#include <WiFi.h>               // wifi stuff
#include <HTTPClient.h>         // for making web requests
#include <ArduinoJson.h>        // "ArduinoJson" by Benoit Blanchon
// ArduinoJson documentation: https://arduinojson.org/
#include <SPI.h>                // screen talks over spi
#include <TFT_eSPI.h>           // the screen library
// TFT_eSPI source: https://github.com/Bodmer/TFT_eSPI
#include <LittleFS.h>           // file system for the images and html
using namespace fs;             // makes fs::File available as just File
#include <time.h>               // for getting the time
#include <math.h>               // math functions like sin and cos
#include <ESPAsyncWebServer.h>  // for the web dashboard
// ESPAsyncWebServer source: https://github.com/me-no-dev/ESPAsyncWebServer


// font shorthands so I don't have to type the full name every time
#define tinyFont nullptr              // builtin tiny font
#define smallFont &FreeSans9pt7b      // small but readable
#define medFont &FreeSansBold12pt7b   // medium bold
#define bigFont &FreeSansBold18pt7b   // big bold for numbers
#define hugeFont &FreeSansBold24pt7b  // biggest one we got

//  your settings — change these to match your setup

const char* wifiName = "";   // wifi network name (change)
const char* wifiPassword = "";  // wifi password (change)

const char* weatherApiKey = "";  // openweathermap api key (change as needded)
const char* myCity = "Fort%20Collins,US";                        // city for weather (url encoded)

#define myTimezone "MST7MDT,M3.2.0,M11.1.0"  // timezone string for mountain time (change as needed)
const char* timeServer = "pool.ntp.org";     // ntp server to sync time from (change as needed)


//which pins things are plugged into
#define soilPin 34    // analog pin for the soil sensor
#define relayPin 26   // relay control pin
#define buttonPin 27  // the button (uses internal pullup)
#define knobPin 32    // potentiometer for switching views (ADC1 ch4)


//soil sensor calibration
// read the raw ADC value with the sensor bone dry, put it here
// then do the same with it fully soaked
#define drySoilReading 4095  // raw adc when totally dry
#define wetSoilReading 1500  // raw adc when totally wet


//  watering rules, these can be changed based on the system
//  (you can also change them live from the web dashboard)

#define startWateringWhen 40       // start watering if soil drops below this percent
#define stopWateringWhen 72        // stop once soil hits this percent
#define maxWindSpeed 15.0f         // dont water if wind is over 15 mph
#define skipIfRainOver 0.50f       // skip if rain chance is over 50 percent
#define skipIfMoreThanMm 2.0f      // skip if more than 2mm of rain is coming
#define defaultStartHour 0         // watering window start time (midnight)
#define defaultEndHour 6           // watering window end time (6am)
#define defaultWaterTime 600000UL  // fallback water time, 10 mins in ms
#define cooldownTime 14400000UL    // wait 4 hours between waterings


//  location stuff for the evaporation math
//  latitude: positive = north, negative = south hemisphere
//  elevation: meters above sea level
//  sprinkler rate: inches per hour your heads actually put out
//    rotor/rotary heads are around 0.4–0.6, spray heads 1.0–1.5
// I just used google to get this data, but this all changes per system

#define myLat 40.585f              // latitude for fort collins co
#define myElevationMeters 1525.0f  // how high up we are in meters
#define sprinklerRate 0.50f        // how much water per hour the sprinkler puts out
#define minMinutes 2UL             // minimum water time, dont want it too short
#define maxMinutes 30UL            // max water time, safety cap so we dont flood


#define checkSoilEvery 5000UL       // check the dirt every 5 seconds
#define checkWeatherEvery 600000UL  // get new weather every 10 mins


//  screen setup ,GC9A01 round display, 240x240

#define screenW 240  // screen is 240 pixels wide
#define screenH 240  // screen is 240 pixels tall
#define midX 120     // horizontal center point
#define midY 120     // vertical center point

// color palette, i just googled pretty colors and found the codes for them
#define colorBg 0x0841      // dark background color
#define colorText 0xFFFF    // white for regular text
#define colorDim 0x7BCF     //gray for less important stuff
#define colorAccent 0x07FF  // cyan for headings and highlights
#define colorGood 0x27E4    //green means everything is fine
#define colorWarn 0xFD40    // orange/yellow for warnings
#define colorBad 0xF800     //red for bad stuff
#define colorWater 0x01F1   //blue for when its watering
#define colorTrack 0x2124   //dark gray for arc backgrounds
#define colorRain 0x867F    //grayish blue for rain stuff

TFT_eSPI tft = TFT_eSPI();

inline void setFont(const GFXfont* f) {  // helper to set font without repeating setTextSize
  tft.setFreeFont(f);                    // set the font
  tft.setTextSize(1);                    // always size 1  sizing is baked into the font
}


//  screensaver (I hope Dr. T doesn't find this)

const char* pics[] = { "/chud.bmp", "/monkey.bmp", "/angry.bmp", "/confused.bmp", "/horse.bmp", "/smile.bmp", "/ropopo.bmp" };  // list of bmp images to cycle
const int picCount = sizeof(pics) / sizeof(pics[0]);                                                                            // count how many images we have
int currentPic = 0;                                                                                                             // which image were showing right now

#define eachImageTime 3000UL       // show each pic for 3 seconds
#define holdForScreensaver 5000UL  // hold button 5 seconds to turn on screensaver

bool screensaverOn = false;       // is the screensaver currently on
unsigned long lastImageSwap = 0;  // when we last swapped the image


//bmp image loading
// NOTE: Claude helped write the BMP loading and pixel-drawing code below.
// The byte math and row-flipping stuff was tricky to get right.
// Anthropic Claude: https://claude.ai
#define bmpBufferRows 24  // load this many rows at once to save ram

inline uint16_t swapBytes(uint16_t color) {
  return (color << 8) | (color >> 8);
}  //bmp bytes are backwards from what the screen wants

bool loadBMP(const char* filename, int16_t x, int16_t y) {  // draws a bmp file to the screen at x,y
  File file = LittleFS.open(filename, "r");                 // open the file
  if (!file) {
    Serial.printf("[bmp] can't open %s\n", filename);
    return false;
  }  // quit if file doesnt exist

  uint8_t header[54];  // bmp header is always 54 bytes
  if (file.read(header, 54) != 54) {
    file.close();
    return false;
  }  //read the header, quit if it fails
  if (header[0] != 'B' || header[1] != 'M') {
    file.close();
    return false;
  }  // first two bytes should always be BM

  // pull the important numbers out of the bmp header
  uint32_t pixelStart = header[10] | (header[11] << 8) | (header[12] << 16) | (header[13] << 24);  // byte offset where pixel data starts
  int32_t imgWidth = header[18] | (header[19] << 8) | (header[20] << 16) | (header[21] << 24);     // image width in pixels
  int32_t imgHeight = header[22] | (header[23] << 8) | (header[24] << 16) | (header[25] << 24);    // image height in pixels
  uint16_t bitsPerPixel = header[28] | (header[29] << 8);                                          // color depth, we only handle 24 or 32 bit

  if (bitsPerPixel != 24 && bitsPerPixel != 32) {                              // check its a suported format
    Serial.printf("[bmp] only 24/32-bit supported (got %d)\n", bitsPerPixel);  // complain if not
    file.close();
    return false;  // cant load it, give up
  }

  // bmp files store rows bottom-up unless height is negative
  bool upsideDown = (imgHeight > 0);          // positive height
  if (imgHeight < 0) imgHeight = -imgHeight;  // make height positive if it was negative

  uint8_t bytesPerPx = bitsPerPixel / 8;                    // how many bytes per pixel (3 for 24bit, 4 for 32bit)
  uint32_t rowStride = ((imgWidth * bytesPerPx) + 3) & ~3;  // rows are padded to 4-byte boundaries

  int32_t rows = min((int32_t)screenH, imgHeight);  //dont draw more rows than fit on screen
  int32_t cols = min((int32_t)screenW, imgWidth);   // dont draw more cols than fit on screen

  uint8_t rowData[rowStride];                         // buffer for one row of pixels
  static uint16_t pixelBuf[screenW * bmpBufferRows];  // buffer for a chunk of rows to push to screen

  // load and draw one chunk of rows at a time to save ram
  for (int32_t chunk = 0; chunk < rows; chunk += bmpBufferRows) {              // loop through chunks of rows
    int32_t chunkH = min((int32_t)bmpBufferRows, rows - chunk);                // how many rows in this chunk
    for (int32_t r = 0; r < chunkH; r++) {                                     // loop through rows in the chunk
      int32_t displayR = chunk + r;                                            // which row on screen we're drawing
      int32_t fileR = upsideDown ? (imgHeight - 1 - displayR) : displayR;      // flip if bmp is upside down
      file.seek(pixelStart + (uint32_t)fileR * rowStride);                     // jump to that row in the file
      file.read(rowData, rowStride);                                           // read the row of pixels
      for (int32_t col = 0; col < cols; col++) {                               // loop through each pixel in the row
        uint8_t blue = rowData[col * bytesPerPx];                              // bmp stores blue first
        uint8_t green = rowData[col * bytesPerPx + 1];                         // then green
        uint8_t red = rowData[col * bytesPerPx + 2];                           // then red (backwards from rgb)
        pixelBuf[r * cols + col] = swapBytes(tft.color565(red, green, blue));  // convert to 16bit color and swap bytes for the screen
      }
    }
    tft.pushImage(x, y + chunk, cols, chunkH, pixelBuf);  // send the chunk to the screen all at once
  }
  file.close();  // done with the file
  return true;   // it worked
}

//Claude AI help me ^^^^^^^^^ I studied how the graphics worked, and to an extend I understand them, however this section was heavily written by claude. I made a few adjustments

void render();

void enterScreensaver() {            // turn on the screensaver
  screensaverOn = true;              // set the flag
  currentPic = 0;                    // start from the first image
  lastImageSwap = millis();          // reset the timer
  Serial.println("screensaver on");  // debug
  loadBMP(pics[currentPic], 0, 0);   // draw the first image right away
}

void exitScreensaver() {                                 // turn off the screensaver
  screensaverOn = false;                                 // clear the flag
  Serial.println("screensaver off, back to dashboard");  //debug
  render();                                              // redraw the normal screen
}

//  all the global state declared early so the web server can see it

int soilMoisture = 0;  // soil moisture as a percent, 0 to 100

// current weather from openweathermap (This is the api I used to get local weather data ESP32)
float temp = 0.0f;            // current temp in fahrenheit
int humidity = 0;             // humidity percent
float windMph = 0.0f;         // wind speed in mph
bool raining = false;         // is it raining right now
float rainChance = 0.0f;      // 0-to-1 chance of rain in next 12 hours
float rainMm = 0.0f;          // mm of rain expected in next 12 hours
int clouds = 50;              // cloud cover percent, 0 is clear 100 is all clouds
char weatherDesc[32] = "--";  // text description like "CLEAR SKY"

// evaporation math results
float dailyET = 0.0f;                               // how much water evaporated today in mm
float seasonalFactor = 0.85f;                       // grass water needs based on time of year
float cropWaterNeed = 0.0f;                         // how much the grass actualy needs today
float inchesNeeded = 0.0f;                          // same thing but in inches
int minutesToWater = 10;                            // how long to water based on the math
unsigned long wateringDuration = defaultWaterTime;  // actual ms for the relay timer

// watering state
bool waterOn = false;              // is the relay on right now
bool manualMode = false;           // did the user trigger it manually
unsigned long waterStartTime = 0;  // when watering started (millis)
unsigned long lastWaterTime = 0;   // when we last finished watering (millis)

// timing for periodic tasks
unsigned long lastSensorTime = 0;   // last time we read the soil sensor
unsigned long lastWeatherTime = 0;  // last time we fetched weather
unsigned long lastTimerUpdate = 0;  // last time we updated the countdown timer
#define countdownRefresh 1000UL     // update countdown every 1 second

// which screen we're showing right now
enum Screen { soilScreen,
              weatherScreen,
              scheduleScreen,
              waterCalcScreen,
              screenCount };        // all the screens we have
Screen currentScreen = soilScreen;  // start on the soil screen

// button loigc stuff
bool lastButtonReading = LOW;     // what the button read last loop
bool buttonIsHeld = false;        // is the button being held down
unsigned long buttonHeldAt = 0;   // when the hold started
bool longPressTriggered = false;  // did we already fire the long press action
#define debounceMs 30             // ignore bounces shorter than this, though this isn't really an issue with the capacitive touch button I used

// potentiometer for switching views
int lastKnobScreen = -1;  // which screen the knob was last pointing at
int lastKnobValue = -1;   // last stable adc reading from the knob
#define knobDeadzone 60   // how much knob has to move before we care
#define knobSamples 6     // average this many reads to reduce noise


//  web server
// ESPAsyncWebServer WebSocket docs: https://github.com/me-no-dev/ESPAsyncWebServer
// Anthropic Claude: https://claude.ai
AsyncWebServer server(80);     // web server on port 80
AsyncWebSocket socket("/ws");  // websocket at /ws path

// live-adjustable settings (changed via the web dashboard)
int startHour = defaultStartHour;  // when watering window opens
int endHour = defaultEndHour;      // when watering window closes
bool skipWeatherChecks = false;    // when true, ignore weather and just water

unsigned long lastBroadcastTime = 0;  // when we last sent state to web clients
#define broadcastEvery 1000UL         // send state once per second


// forward declarations for helpers used inside broadcastState
bool inWaterWindow();               // is it currently the right time to water
long secsToWindow();                // how long until the window opens
String fmtAgo(unsigned long when);  // formats time-since into a string
String timeStr();                   // current time as a string
String dateStr();                   // current date as a string
void evaluateWatering();            // check if we should start or stop
void setRelay(bool on);             // actually flip the relay


// sends the current system state as JSON to everyone connected to the websocket
void broadcastState() {
  if (socket.count() == 0) return;  // nobody connected, dont bother

  // figure out if each watering condition is currently met
  bool noRainCondition = skipWeatherChecks ||                                                     // either we're ignoring weather
                         (!raining && rainChance < skipIfRainOver && rainMm < skipIfMoreThanMm);  // or its genuinly not gonna rain
  bool windOkCondition = skipWeatherChecks || (windMph < maxWindSpeed);                           // wind is acceptable
  bool soilDryCondition = (soilMoisture < startWateringWhen);                                     // soil is dry enough to water
  bool cooldownDone = (lastWaterTime == 0) ||                                                     // never watered before
                      (millis() - lastWaterTime > cooldownTime);                                  // or its been long enough

  // how many seconds are left in the current watering session
  unsigned long secondsLeft = 0;  // default to zero

  if (waterOn) {                                        // only calculate if it's actually watering
    unsigned long elapsed = millis() - waterStartTime;  // how long it's been running

    if (elapsed < wateringDuration) {                       // make sure we dont underflow
      secondsLeft = (wateringDuration - elapsed) / 1000UL;  // convert ms to seconds
    } else {
      secondsLeft = 0UL;
    }
  }

  struct tm t;
  int hour = 0;                            // temp vars for the time
  if (getLocalTime(&t)) hour = t.tm_hour;  //  current hour if we can

  char json[1024];              // buffer for the json string
  snprintf(json, sizeof(json),
           "{"
           "\"soil\":%d,"              // soil moisture percent
           "\"isWatering\":%s,"        // is relay on
           "\"manualWater\":%s,"       // is it manual
           "\"temp\":%.1f,"            // temperature
           "\"humidity\":%d,"          // humidity
           "\"wind\":%.1f,"            // wind speed
           "\"raining\":%s,"           // raining now flag
           "\"desc\":\"%s\","          // weather description
           "\"pop\":%.2f,"             // probability of precipitation
           "\"mm\":%.1f,"              // rain mm forecasted
           "\"clouds\":%d,"            // cloud cover
           "\"windowStart\":%d,"       // watering window start hour
           "\"windowEnd\":%d,"         // watering window end hour
           "\"inWindow\":%s,"          // are we in the window now
           "\"secsToWindow\":%ld,"     // seconds until window opens
           "\"lastWaterAgo\":\"%s\","  // how long ago we watered
           "\"soilDry\":%s,"           // is soil dry enough
           "\"noRain\":%s,"            // no rain condition met
           "\"windOk\":%s,"            // wind condition met
           "\"weatherOverride\":%s,"   // weather override on/off
           "\"cooldownOk\":%s,"        // cooldown period done
           "\"waterRemain\":%lu,"      // seconds left in session
           "\"waterTotal\":%lu,"       // total session length in seconds
           "\"etET0\":%.3f,"           // evaporation ET0 in inches
           "\"etKc\":%.2f,"            // seasonal crop factor
           "\"etETc\":%.3f,"           // crop water need in inches
           "\"etInches\":%.3f,"        // inches needed
           "\"etMinutes\":%d,"         // minutes to water
           "\"currentHour\":%d,"       // current hour
           "\"time\":\"%s\","          // time string
           "\"date\":\"%s\""           // date string
           "}",
           soilMoisture,                   // the question marks (?) used in this code are a compact if else statement. It returns a value, and in this context it basically just is saying if this is true, return true. I had to do this since my code almost maxxes out my ESP32
           waterOn ? "true" : "false",     // relay state
           manualMode ? "true" : "false",  // manual state
           temp, humidity, windMph,
           raining ? "true" : "false",  // raining state
           weatherDesc,
           rainChance, rainMm,
           clouds,
           startHour, endHour,
           inWaterWindow() ? "true" : "false",  // window state
           secsToWindow(),
           fmtAgo(lastWaterTime).c_str(),         // time ago string
           soilDryCondition ? "true" : "false",   // soil condition
           noRainCondition ? "true" : "false",    // rain condition
           windOkCondition ? "true" : "false",    // wind condition
           skipWeatherChecks ? "true" : "false",  // override state
           cooldownDone ? "true" : "false",       // cooldown state
           secondsLeft,
           wateringDuration / 1000UL,                                                             // convert ms to seconds
           dailyET / 25.4f, seasonalFactor, cropWaterNeed / 25.4f, inchesNeeded, minutesToWater,  // all the evap math stuff
           hour,
           timeStr().c_str(),  // time as c string
           dateStr().c_str()   // date as c string
  );

  socket.textAll(json);  // send it to all connected clients
}

// handle commands sent from the web dashboard
void handleWsMessage(const String& msg) {                           // called when we get a message from the browser
  DynamicJsonDocument message(256);                                 // small buffer, commands are short
  if (deserializeJson(message, msg) != DeserializationError::Ok) {  // parse the json
    Serial.println("[ws] got bad json from client");                // log it if its broken
    return;                                                         // bail out, don't just fail
  }

  String command = message["cmd"].as<String>();          // grab the command name
  Serial.printf("[ws] command: %s\n", command.c_str());  // log what we got

  if (command == "startWater") {  // user hit the water button on the web dashboard
    if (!waterOn) {               // only do it if not already watering
      manualMode = true;          // flag as manual
      evaluateWatering();         // this will turn it on
      render();                   // update the screen
    }
  } else if (command == "stopWater") {             // user wants to stop watering
    if (waterOn) {                                 // only if its actually on
      setRelay(false);                             // turn off the relay
      lastWaterTime = millis();                    // record when we stopped
      manualMode = false;                          // clear manual flag
      render();                                    // update screen
      Serial.println("web said stop, relay off");  // log it
    }
  } else if (command == "setWindow") {                                               // user changed the watering window
    int s = message["start"].as<int>();                                              // new start hour s
    int e = message["end"].as<int>();                                                // new end hour e
    if (s >= 0 && s < 24 && e > s && e <= 24) {                                      // sanity check the values, no impossible windows allowed like -6am
      startHour = s;                                                                 // save the new start
      endHour = e;                                                                   // save the new end
      Serial.printf("[ws] watering window → %d:00 to %d:00\n", startHour, endHour);  // log it
      broadcastState();                                                              // confirm immediatel don't wait for the next 1s tick
    } else {
      Serial.printf("[ws] rejected bad window: start=%d end=%d\n", s, e);  // bad values, ignore. for reduncdancy. no one wants to try an water at -12pm
    }
  } else if (command == "weatherOverride") {                                          // user toggled the weather override switch
    skipWeatherChecks = message["value"].as<bool>();                                  // set to whatever the switch says
    Serial.printf("[ws] weather override → %s\n", skipWeatherChecks ? "on" : "off");  // log it (bot statement fr)
  }
}

// websocket event callbacks THIS SECTION I USED CLAUDE FOR. (if you couldn't tell by the funky formatting). This is the main communicaiton between the esp32 and the website. Basically saying "hey, something changed on the website, whatchu tryna do about it?"
void onWsEvent(AsyncWebSocket* srv, AsyncWebSocketClient* client,  // called whenever something happens on the websocket
               AwsEventType type, void* arg, uint8_t* data, size_t len) {
  if (type == WS_EVT_CONNECT) {  // a new browser connected
    Serial.printf("[ws] client #%u connected from %s\n",
                  client->id(), client->remoteIP().toString().c_str());  // log their ip
    broadcastState();                                                    // send current state right away so they're not staring at nothing
  } else if (type == WS_EVT_DISCONNECT) {                                // a browser disconnected
    Serial.printf("[ws] client #%u disconnected\n", client->id());       // log it
  } else if (type == WS_EVT_DATA) {                                      // we got a message
    AwsFrameInfo* frame = (AwsFrameInfo*)arg;                            // get the frame info
    // only handle complete, single-frame text messages
    if (frame->final && frame->index == 0 &&                   // make sure its a complete message
        frame->len == len && frame->opcode == WS_TEXT) {       // and that its text
      String text;                                             // buffer for the message
      text.reserve(len);                                       // pre-allocate so it doesnt keep resizing
      for (size_t i = 0; i < len; i++) text += (char)data[i];  // copy bytes into string
      handleWsMessage(text);                                   // process it
    }
  } else if (type == WS_EVT_ERROR) {                         // something went wrong
    Serial.printf("[ws] client #%u error\n", client->id());  // log the error
  }
}


//  sensors (Techincally for the prototype we only use one sensors, but this is scalable, trust)

int readSoilPercent() {  // reads the soil sensor and returns 0-100%
  // average 10 readings to smooth out noise
  long total = 0;  // accumulate the readings here
  for (int i = 0; i < 10; i++) {
    total += analogRead(soilPin);
    delay(5);
  }                                 // read 10 times with small delay
  int average = (int)(total / 10);  // take the average
  // map raw ADC to 0–100%, clamp so it never goes outside that range. %110 soil moisture would be a crazy value. The cheap sensors tend to have a lot of drift, so this along with avg readings help avoid that
  return constrain(map(average, drySoilReading, wetSoilReading, 0, 100), 0, 100);  // map and clamp -Peter 2026 (w quote for the books)
}

// figure out which screen the knob is pointing at (this is what makes the screen so fun to scroll through)
// averages a bunch of readings to cancel out the esp32's notoriously noisy ADC
int readPotView() {  // returns which screen number the pot is dialed to
  long total = 0;    // accumulate adc readings
  for (int i = 0; i < knobSamples; i++) {
    total += analogRead(knobPin);
    delay(2);
  }                                             // read it several times
  int rawReading = (int)(total / knobSamples);  // take the average

  // these values are specific to the pot I used. Typically most 10k pots will read similar values, but it may need some tunage
  const int potMin = 0;     // lowest value
  const int potMax = 3500;  // highest value

  // map the reading into one of our screen slots
  int screenIndex = map(rawReading, potMin, potMax, 0, screenCount - 1);  // figure out which screen zone were in
  screenIndex = constrain(screenIndex, 0, screenCount - 1);               // keep it in bounds (no scary ghost screens)

  // this stops the view from flickering when you're right on a boundary
  if (abs(rawReading - lastKnobValue) < knobDeadzone && screenIndex == lastKnobScreen) return lastKnobScreen;  // not moved enought, ignore. It's like sig figs but for code (chem 111 actually taught me somethinh)
  lastKnobValue = rawReading;                                                                                  // save the new reading

  return screenIndex;  // return which screen to show
}

//  time stuff
bool getTm(struct tm* t) {
  return getLocalTime(t);
}  // wrapper for getLocalTime, juse easier to type. This is using the big O (openweatherapi)

String timeStr() {  // returns the current time as a string like "3:45 PM"
  struct tm t;
  if (!getTm(&t)) return "--:--";  // return placeholder if time isnt synced
  int h = t.tm_hour % 12;
  if (h == 0) h = 12;                               // convert to 12-hour format (Zero ROTC inclusion in this porject, my bad)
  const char* ap = (t.tm_hour < 12) ? "AM" : "PM";  // figure out AM or PM
  char b[10];
  snprintf(b, 10, "%d:%02d %s", h, t.tm_min, ap);  // format it
  return String(b);                                // return it
}

String dateStr() {  // returns current date like "Apr 02"
  struct tm t;
  if (!getTm(&t)) return "---";  // placeholder if no time
  char b[12];
  strftime(b, 12, "%b %d", &t);  // format like "Apr 02"
  return String(b);              // return it
}

// are we currently inside the allowed watering window?
bool inWaterWindow() {  // returns true if it is currently ok to water
  struct tm t;
  if (!getTm(&t)) return false;                            // cant tell, so say no. better safe than sorry (im failing this project)
  return (t.tm_hour >= startHour && t.tm_hour < endHour);  // check if current hour is in the window
}


//TO MY TA's (Possibly Ethan and Chloe) how many comments are too many? Cuz I lowkey love adding comments, but do yall ever get sick of seeing them?


// how many seconds until the watering window opens (0 if it's already open)
long secsToWindow() {  // returns seconds until window, 0 if its already open
  struct tm t;
  if (!getTm(&t)) return -1;                                                                // cant get time, return error
  int nowInSeconds = t.tm_hour * 3600 + t.tm_min * 60 + t.tm_sec;                           // current time in seconds since midnight
  int windowStartSeconds = startHour * 3600;                                                // window start in seconds since midnight
  if (inWaterWindow()) return 0;                                                            // already open, return 0
  if (nowInSeconds < windowStartSeconds) return (long)(windowStartSeconds - nowInSeconds);  // window is later today
  return (long)(86400 - nowInSeconds + windowStartSeconds);                                 // window is tomorrow, wrap around midnight (mmmmmm, wrap)
}

// turns a number of seconds into something an avg joe could understand
String fmtCountdown(long secs) {  // formats seconds into readable countdown string
  if (secs <= 0) return "OPEN";   // window is open right now
  if (secs < 60) {
    char b[8];
    snprintf(b, 8, "%ds", (int)secs);
    return String(b);
  }  // just show seconds
  if (secs < 3600) {
    char b[8];
    snprintf(b, 8, "%dm %02ds", (int)(secs / 60), (int)(secs % 60));
    return String(b);
  }  // minutes and seconds
  char b[10];
  snprintf(b, 10, "%dh %02dm", (int)(secs / 3600), (int)((secs % 3600) / 60));
  return String(b);  // hours and minutes
}

// turns a millis timestamp into "5m ago", "2h 10m ago", etc.
String fmtAgo(unsigned long when) {                                        // formats a past millis timestamp
  if (when == 0) return "Never";                                           // hasnt happened yet
  unsigned long ago = (millis() - when) / 1000;                            // how many seconds ago
  if (ago < 60) return String(ago) + "s ago";                              // less than a minute
  if (ago < 3600) return String(ago / 60) + "m ago";                       // less than an hour
  return String(ago / 3600) + "h " + String((ago % 3600) / 60) + "m ago";  // hours and minutes
}

//  weather API calls (I gotta give a shoutout from the bottom of my heart to openweathermap for making this porject possible)

// Source: OpenWeatherMap Current Weather API — https://openweathermap.org/current
void fetchCurrentWeather() {                  // grabs current weather from openweathermap| Da big C (Claude) helped out with this section as well, a lot of complicated processing stuff a caveman like me coundl't figoure out on his own
  if (WiFi.status() != WL_CONNECTED) return;  // dont even try if wifi is down
  HTTPClient http;                            // create the http client
  http.begin("http://api.openweathermap.org/data/2.5/weather?q=" + String(myCity)
             + "&appid=" + String(weatherApiKey) + "&units=imperial");      // build the url
  int responseCode = http.GET();                                            // make the request
  if (responseCode == 200) {                                                // 200 means it worked
    DynamicJsonDocument data(2048);                                         // buffer for the response json
    if (!deserializeJson(data, http.getString())) {                         // parse the json (niche start trek reference?)
      temp = data["main"]["temp"].as<float>();                              // grab temp
      humidity = data["main"]["humidity"].as<int>();                        // grab humidity
      windMph = data["wind"]["speed"].as<float>();                          // grab wind speed
      clouds = data["clouds"]["all"].as<int>();                             // 0 to 100%
      String description = data["weather"][0]["description"].as<String>();  // get the text description
      description.toUpperCase();
      description.toCharArray(weatherDesc, sizeof(weatherDesc));                                       // uppercase it and save it
      String mainWeather = data["weather"][0]["main"].as<String>();                                    // get the main weather type
      raining = (mainWeather == "Rain" || mainWeather == "Drizzle" || mainWeather == "Thunderstorm");  // is it actually raining
    }
  }
  http.end();  // close the connection
}

// Source: OpenWeatherMap 5-Day/3-Hour Forecast API — https://openweathermap.org/forecast5
void fetchForecast() {  // grabs the next 12 hours of forecast| I ogt some help form claude, but lowkey I just copied the code above and just changed a few things cuz I'm like that
  // grab the next 12 hours of forecast (4 x 3-hour slots)
  if (WiFi.status() != WL_CONNECTED) return;  // no wifi, skip it
  HTTPClient http;                            // http client for the request
  http.begin("http://api.openweathermap.org/data/2.5/forecast?q=" + String(myCity)
             + "&appid=" + String(weatherApiKey) + "&units=imperial&cnt=4");  // request 4 periods (12 hours)
  if (http.GET() == 200) {                                                    // 200 means sucsess
    DynamicJsonDocument data(4096);                                           // bigger buffer for the forecast
    if (!deserializeJson(data, http.getString())) {                           // parse it
      rainChance = 0.0f;
      rainMm = 0.0f;                                            // reset before we accumulate
      for (int i = 0; i < 4; i++) {                             // loop through all 4 time slots
        float pop = data["list"][i]["pop"].as<float>();         // probability of precip for this slot
        if (pop > rainChance) rainChance = pop;                 // keep the worst-case probability
        if (data["list"][i]["rain"]["3h"].is<float>())          // check if there's rain data
          rainMm += data["list"][i]["rain"]["3h"].as<float>();  // add up total rain mm
      }
    }
  }
  http.end();  // close the connection
}


//  evaporation math. FAO-56 Penman-Monteith (This part is genuienly complicated) When doing research for this project, before I even started the code this formula came up over and over
//  so I knew it was boutta show up in my code. I felt like einstein when I compared my values to historical data and it actually was very good
//  this figures out roughly how much water evaporated from the
//  lawn today, so we know exactly how much to put back.
//
//  inputs come from openweathermap:
//    temp (fahrenheit) · humidity (%) · wind (mph) · cloud cover (%)
//  plus location: myLat · myElevationMeters
//
//  spits out a daily evaporation value in mm/day, then multiplies
//  by a seasonal grass factor to get actual crop water need,
//  then divides by sprinkler rate to get how many minutes to run.
// Source: FAO Irrigation and Drainage Paper No. 56 (Allen et al., 1998) — https://www.fao.org/4/x0490e/x0490e00.htm

// seasonal grass factors, one per month (turf values for cool-season grass like bluegrass/fescue)
// grass needs way more water in summer than winter. these numbers account for that. The numbers are form FAO irrigation (the aboslute goat in this project)
// Monthly Kc values from FAO-56 Table 12 (Allen et al., 1998) — https://www.fao.org/4/x0490e/x0490e00.htm
static const float monthlyFactors[12] = {
  0.60f, 0.60f, 0.70f, 0.85f, 0.95f, 1.00f,  // first half of the year
  1.00f, 0.95f, 0.85f, 0.75f, 0.65f, 0.60f   // second half, grass needs less water as it cools
  //  jul    aug    sep    oct    nov    dec
};

float getSeasonalKc() {  // returns the grass water factor for the current month
  struct tm t;
  if (!getTm(&t)) return 0.85f;     // if we can't get the time, use a middle of year value (sneaky little redundancy)
  return monthlyFactors[t.tm_mon];  // tm_mon is 0-based, so january = 0
}

float calculateET0() {  // calculates how much water evaporated today in mm
  // convert fahrenheit to celsius because all the formulas use celsius
  float tempC = (temp - 32.0f) * 5.0f / 9.0f;  // standard f to c conversion

  // openweathermap gives wind at 10m height but the formula needs it at 2m
  // Wind height adjustment per FAO-56 Eq. 47 — https://www.fao.org/4/x0490e/x0490e07.htm
  float windAt2m = windMph * 0.44704f * (4.87f / logf(672.38f));  // scale wind down to 2m height (lowkey I used chatpgt to for this line of code)

  // how much water is floating in the air as vapor
  float satVapour = 0.6108f * expf(17.27f * tempC / (tempC + 237.3f));  // air's max vapor capacity (kPa)
  float actualVapour = satVapour * humidity / 100.0f;                   // actual vapor in the air right now

  // air pressure and atmospheric stuff, depends on your elevation. This is all info that is availbe online
  float pressure = 101.3f * powf((293.0f - 0.0065f * myElevationMeters) / 293.0f, 5.26f);  // barometric pressure at our elevation
  float psychro = 0.000665f * pressure;                                                    // psychrometric constant (hard to understand but the good thing is I don't need to understand it to do the math)
  float slopeCurve = 4098.0f * satVapour / powf(tempC + 237.3f, 2.0f);                     // slope of the vapor pressure curve

  // figure out how much sunlight is hitting earth today based on the time of year and your location
  // Solar radiation formulas from FAO-56 Ch. 3 — https://www.fao.org/4/x0490e/x0490e07.htm
  struct tm t2;
  getLocalTime(&t2);                                                                     // get current time for the date
  int dayOfYear = t2.tm_yday + 1;                                                        // which day of the year
  float latRad = myLat * DEG_TO_RAD;                                                     // latitude in radians
  float earthSunDist = 1.0f + 0.033f * cosf(2.0f * PI * dayOfYear / 365.0f);             // how far earth is from the sun today
  float solarDecl = 0.409f * sinf(2.0f * PI * dayOfYear / 365.0f - 1.39f);               // sun's angle above the equator
  float sunsetAngle = acosf(constrain(-tanf(latRad) * tanf(solarDecl), -1.0f, 1.0f));    // angle of sunset, clamped so acos doesnt blow up
  const float solarConstant = 0.0820f;                                                   // solar constant MJ/m2/min
  float extraterrestrialRad = (24.0f * 60.0f / PI) * solarConstant * earthSunDist        // total radiation above the atmosphere
                              * (sunsetAngle * sinf(latRad) * sinf(solarDecl)            // sunrise/sunset component
                                 + cosf(latRad) * cosf(solarDecl) * sinf(sunsetAngle));  // daytime arc component

  // adjust for clouds more clouds = less sun reaching the ground = less evaporation
  float sunshineRatio = 1.0f - clouds / 100.0f;                                            // 0 = fully overcast, 1 = clear sky
  float shortwaveRad = (0.25f + 0.50f * sunshineRatio) * extraterrestrialRad;              // how much sun actually hits the ground
  float netShortwave = 0.77f * shortwaveRad;                                               // after grass reflects some back (grass albedo ~0.23)
  float clearSkyRad = (0.75f + 2e-5f * myElevationMeters) * extraterrestrialRad;           // what wed get with no clouds
  float radRatio = (clearSkyRad > 0.01f) ? shortwaveRad / clearSkyRad : 0.5f;              // ratio of actual to clear sky radiation
  float stefanBoltzmann = 4.903e-9f;                                                       // stefan-boltzmann constant for longwave radiation
  float tempKelvin = tempC + 273.16f;                                                      // temp in kelvin for the radiation formula
  float netLongwave = stefanBoltzmann * tempKelvin * tempKelvin * tempKelvin * tempKelvin  // longwave radiation lost to sky
                      * (0.34f - 0.14f * sqrtf(max(actualVapour, 0.001f)))                 // humidity adjustment
                      * (1.35f * radRatio - 0.35f);                                        // cloud adjustment
  float netRad = netShortwave - netLongwave;                                               // total net radiation at the surface (what the ground actually absorbs)

  // the actual Penman-Monteith formula combines everything above into mm/day AI (I understand the formula, but I input all of my variables into claude and told it to complete the syntax for my code)
  // Anthropic Claude: https://claude.ai
  float top = 0.408f * slopeCurve * netRad                                                      // radiation term
              + psychro * (900.0f / (tempC + 273.0f)) * windAt2m * (satVapour - actualVapour);  // wind/humidity term
  float bottom = slopeCurve + psychro * (1.0f + 0.34f * windAt2m);                              // denominator (slopes and wind resistance)
  float ET0 = top / bottom;                                                                     // final ET0 in mm/day

  return max(0.0f, ET0);  // cant evaporate negative water, clamp to zero
}

void updateETWatering() {                    // recalculates how long we should water based on todays ET
  dailyET = calculateET0();                  // run the evap math
  seasonalFactor = getSeasonalKc();          // get the seasonal adjustment for this month
  cropWaterNeed = dailyET * seasonalFactor;  // how much the grass actually needs (mm/day)
  inchesNeeded = cropWaterNeed / 25.4f;      // convert mm to inches (25.4mm per inch) (No one uses metric system in the U.S. which is sad)

  // minutes = (inches needed / sprinkler output rate) × 60
  float rawMinutes = (inchesNeeded / sprinklerRate) * 60.0f;  // raw calculated run time

  // keep it within reasonable bounds so we don't under or over water
  minutesToWater = (int)constrain(rawMinutes, (float)minMinutes, (float)maxMinutes);  // clamp to min/max
  wateringDuration = (unsigned long)minutesToWater * 60000UL;                         // convert to milliseconds for the timer

  Serial.printf("[et] ET0=%.3f in/d  factor=%.2f  need=%.3f in/d  → %d min\n",  // log the results
                dailyET / 25.4f, seasonalFactor, cropWaterNeed / 25.4f, minutesToWater);
}

//  relay control

void setRelay(bool on) {                                            // turns the relay (and therefor the sprinkler) on or off
  digitalWrite(relayPin, on ? LOW : HIGH);                          // active low — LOW = relay on
  waterOn = on;                                                     // update the state flag
  if (on) waterStartTime = millis();                                // record when we started
  Serial.println(on ? "relay on — watering" : "relay off — done");  // log it
}

//  watering decision logic
//  called every 5 seconds to decide whether to start or stop

void evaluateWatering() {  // this is the logic that decides if we should water or not
  if (waterOn) {           // if already watering, check if we should stop
    // check if any stop condition is met
    bool timerUp = (millis() - waterStartTime >= wateringDuration);  // ran long enough
    bool soilWet = (soilMoisture >= stopWateringWhen);               // soil is wet enough now
    bool windowOver = !inWaterWindow() && !manualMode;               // watering window closed
    if (timerUp || soilWet || windowOver) {                          // if any of these are true
      setRelay(false);
      lastWaterTime = millis();
      manualMode = false;  // stop watering
    }
    return;  // done, dont run the start logic
  }
  if (manualMode) {
    setRelay(true);
    return;
  }  // manual override, just turn it on no questions asked

  // check all conditions to see if automatic watering should kick in
  bool tooDry = (soilMoisture < startWateringWhen);                                                         // is the soil dry
  bool rightTime = inWaterWindow();                                                                         // is it the right time of day
  bool windGood = skipWeatherChecks || (windMph < maxWindSpeed);                                            // is wind ok
  bool noRain = skipWeatherChecks || !raining;                                                              // is it not raining
  bool noRainComing = skipWeatherChecks || ((rainChance < skipIfRainOver) && (rainMm < skipIfMoreThanMm));  // is rain not expected
  bool restedEnough = (lastWaterTime == 0) || (millis() - lastWaterTime > cooldownTime);                    // has it been long enough since last water

  if (tooDry && rightTime && windGood && noRain && noRainComing && restedEnough)  // all conditions met
    setRelay(true);                                                               // start watering
}

//  display helpers
// NOTE: Claude (AI) helped extensively with all the arc drawing and
// graphics math below. The trig to draw thick arcs with triangles
// was messy and Claude helped figure it out. I went through and 
// did studied the code, and commented on what is is doing. However
// the math section was %100 AI, there was no 50/50 split or anything like that
// Anthropic Claude: https://claude.ai


// draws a thick arc segment — used for the soil moisture gauge ring
void drawArc(int cx, int cy, int r, int thick,                                                   // center x, center y, radius, thickness
             float startDeg, float sweepDeg, uint16_t color) {                                   // start angle, how far to sweep, color
  if (sweepDeg <= 0.0f || thick <= 0) return;                                                    // nothing to draw, bail
  int outerRadius = r, innerRadius = r - thick;                                                  // outer is r, inner is r minus thickness
  float endAngle = startDeg + sweepDeg, step = 1.0f;                                             // figure out the end angle, step 1 degree at a time
  for (float a = startDeg; a < endAngle; a += step) {                                            // loop through each degree of the arc
    float nextA = fminf(a + step, endAngle);                                                     // next angle, dont overshoot the end
    float angleRad = a * DEG_TO_RAD, nextAngleRad = nextA * DEG_TO_RAD;                          // convert both to radians
    float sinA = sinf(angleRad), cosA = cosf(angleRad);                                          // trig for the current angle
    float sinB = sinf(nextAngleRad), cosB = cosf(nextAngleRad);                                  // trig for the next angle
    int ix0 = cx + (int)(innerRadius * sinA + .5f), iy0 = cy - (int)(innerRadius * cosA + .5f);  // inner point at current angle
    int ox0 = cx + (int)(outerRadius * sinA + .5f), oy0 = cy - (int)(outerRadius * cosA + .5f);  // outer point at current angle
    int ix1 = cx + (int)(innerRadius * sinB + .5f), iy1 = cy - (int)(innerRadius * cosB + .5f);  // inner point at next angle
    int ox1 = cx + (int)(outerRadius * sinB + .5f), oy1 = cy - (int)(outerRadius * cosB + .5f);  // outer point at next angle
    tft.fillTriangle(ix0, iy0, ox0, oy0, ix1, iy1, color);                                       // first triangle fills half the slice
    tft.fillTriangle(ox0, oy0, ox1, oy1, ix1, iy1, color);                                       // second triangle fills the other half
  }
}

// the little dots at the bottom that show which screen you're on (makes it feel much more professional)
void drawPageDots() {                                                             // draws the little navigation dots at the bottom
  const int dotSpacing = 16;                                                      // how far apart the dots are
  for (int i = 0; i < screenCount; i++) {                                         // one dot per screen
    int x = midX + (int)((i - (screenCount - 1) / 2.0f) * dotSpacing);            // center the dots horizontally
    bool isActive = (i == (int)currentScreen);                                    // is this the current screen
    tft.fillCircle(x, 229, isActive ? 4 : 2, isActive ? colorText : colorTrack);  // active dot is bigger and white
  }
}


//  individual screens
// NOTE: Claude (AI) helped with the layout math and drawing code
// for all the screens below. Getting everything to fit nicely on
// a 240x240 circle was tricky. However this part was maybe a 70/30
// split where claude did 30 of the code (backbone stuff) but I went 
// in and adjusted a lot of things, and rewrote code. 
// I understand this part of the code very well. Claude was mainly used as a time saver in this instance
// Anthropic Claude: https://claude.ai


void drawViewSoil() {                                     // draws the main soil moisture screen
  tft.fillScreen(colorBg);                                // clear the screen
  tft.setTextDatum(TC_DATUM);                             // top center alignment
  tft.setTextColor(colorDim, colorBg);                    // gray text on dark background
  setFont(tinyFont);                                      // smallest font for the header
  tft.drawString(timeStr() + " " + dateStr(), midX, 10);  // time and date at the top

  // draw the circular moisture gauge ring
  const float arcStart = 225.0f, arcSweep = 270.0f;                                    // arc starts at bottom-left and sweeps 270 degrees
  const int arcRadius = 88, arcThickness = 11, arcCenterY = 112;                       // size and position of the arc
  drawArc(midX, arcCenterY, arcRadius, arcThickness, arcStart, arcSweep, colorTrack);  // draw the background track first
  if (soilMoisture > 0) {                                                              // only draw the fill if theres any moisture
    uint16_t arcColor = (soilMoisture < 30) ? colorBad :                               // red if really dry
                          (soilMoisture < startWateringWhen) ? colorWarn
                                                             : colorGood;                                      // orange if getting dry, green if fine
    drawArc(midX, arcCenterY, arcRadius, arcThickness, arcStart, arcSweep * soilMoisture / 100.0f, arcColor);  // draw fill proportional to moisture
  }

  tft.setTextDatum(MC_DATUM);                                        // center alignment for the big number
  tft.setTextColor(colorText, colorBg);                              // white text
  setFont(hugeFont);                                                 // big font for the percent number
  tft.drawString(String(soilMoisture) + "%", midX, arcCenterY - 6);  // show the moisture percent
  setFont(tinyFont);                                                 // switch back to small font
  tft.setTextColor(colorDim, colorBg);                               // gray for the label
  tft.drawString("SOIL MOISTURE", midX, arcCenterY + 22);            // label under the number

  // status badge at the bottom shows what's happening right now
  String statusLabel;
  uint16_t boxColor;                                                                    // what the badge says and what color
  if (waterOn) {                                                                        // currently watering
    unsigned long remaining = (wateringDuration - (millis() - waterStartTime)) / 1000;  // seconds left
    char timerText[8];
    snprintf(timerText, 8, "%d:%02d", (int)(remaining / 60), (int)(remaining % 60));  // format as min:sec
    statusLabel = "WATERING  " + String(timerText);
    boxColor = colorWater;  // blue badge with timer
  } else if (manualMode) {
    statusLabel = "MANUAL OVERRIDE";
    boxColor = colorWarn;  // orange for manual
  } else if (raining) {
    statusLabel = "RAINING  SKIP";
    boxColor = colorRain;  // gray-blue for rain
  } else if (soilMoisture >= startWateringWhen) {
    statusLabel = "SOIL OK";
    boxColor = colorGood;  // green if soil is fine
  } else {
    statusLabel = "NEEDS WATER";
    boxColor = colorWarn;
  }  // orange if soil is dry

  tft.fillRoundRect(28, 190, 184, 26, 6, boxColor);  // draw the colored badge background
  tft.setTextColor(colorText, boxColor);             // white text on the badge
  tft.setTextDatum(MC_DATUM);                        // center the text
  setFont(tinyFont);                                 // small font for the badge
  tft.drawString(statusLabel, midX, 203);            // draw the status text in the badge
  drawPageDots();                                    // draw the navigation dots
}

void drawViewWeather() {       // draws the weather info screen
  tft.fillScreen(colorBg);     // clear screen
  tft.setTextDatum(TC_DATUM);  // top center
  tft.setTextColor(colorAccent, colorBg);
  setFont(tinyFont);                             // cyan small font
  tft.drawString("FORT COLLINS, CO", midX, 15);  // city name at top
  tft.setTextColor(colorDim, colorBg);           // gray
  tft.drawString(timeStr(), midX, 30);           // current time
  tft.drawFastHLine(40, 45, 160, colorTrack);    // divider line

  // temperature with the little degree circle symbol
  setFont(bigFont);                                                                                                  // big font for the temp number
  tft.setTextColor(colorText, colorBg);                                                                              // white
  String tempText = String((int)temp);                                                                               // just the integer part of temp
  int tempWidth = tft.textWidth(tempText);                                                                           // how wide the temp text is
  int circleRadius = 4, circleGap = 3;                                                                               // size of the degree circle
  int textStart = midX - (tempWidth + circleGap + circleRadius * 2) / 2;                                             // center the temp + degree symbol together
  tft.setTextDatum(TL_DATUM);                                                                                        // left align so we can position manually
  tft.drawString(tempText, textStart, 55);                                                                           // draw the temperature number
  tft.drawCircle(textStart + tempWidth + circleGap + circleRadius, 55 + circleRadius + 1, circleRadius, colorText);  // draw the degree circle next to it

  setFont(tinyFont);
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(colorDim, colorBg);            // small gray centered
  tft.drawString(String(weatherDesc), midX, 92);  // weather description stuff
  tft.drawFastHLine(18, 106, 204, colorTrack);    // divider line

  tft.setTextDatum(ML_DATUM);
  tft.setTextColor(colorAccent, colorBg);
  setFont(tinyFont);                // left align, cyan
  tft.drawString("WIND", 26, 116);  // wind label on the left
  tft.setTextColor(colorText, colorBg);
  setFont(smallFont);                                      // white, slightly bigger
  tft.drawString(String((int)windMph) + " mph", 26, 136);  // wind speed value

  tft.setTextDatum(MR_DATUM);
  tft.setTextColor(colorAccent, colorBg);
  setFont(tinyFont);                     // right align, cyan
  tft.drawString("HUMIDITY", 214, 116);  // humidity label on the right
  tft.setTextColor(colorText, colorBg);
  setFont(smallFont);                                // white, slightly bigger
  tft.drawString(String(humidity) + "%", 214, 136);  // humidity value
  tft.drawFastHLine(30, 158, 180, colorTrack);       // divider line

  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(colorAccent, colorBg);
  setFont(tinyFont);                             // centered, cyan
  tft.drawString("12H RAIN CHANCE", midX, 168);  // rain chance label
  uint16_t rainTextColor = (rainChance > 0.5f) ? colorBad : (rainChance > 0.25f) ? colorWarn
                                                                                 : colorGood;  // red if likely, orange if possible, green if unlikely
  tft.setTextColor(rainTextColor, colorBg);
  setFont(medFont);                                                  // color depends on how likely rain is
  tft.drawString(String((int)(rainChance * 100)) + "%", midX, 192);  // show the rain probability
  if (raining) {
    setFont(tinyFont);
    tft.setTextColor(colorRain, colorBg);
    tft.drawString("RAINING NOW", midX, 206);
  }                // extra note if its raining right now
  drawPageDots();  // nav dots
}

void drawViewSchedule() {   // draws the watering schedule screen
  tft.fillScreen(colorBg);  // clear screen
  tft.setTextDatum(TC_DATUM);
  tft.setTextColor(colorAccent, colorBg);
  setFont(tinyFont);                             // top center, cyan, small
  tft.drawString("WATERING SCHEDULE", midX, 8);  // screen title

  long countdown = secsToWindow();
  bool windowOpen = inWaterWindow();  // get the window status
  tft.setTextColor(colorDim, colorBg);
  setFont(tinyFont);                                                           // gray small font
  tft.drawString(windowOpen ? "WINDOW IS OPEN" : "NEXT WINDOW IN", midX, 26);  // label depends on if window is open or not
  tft.setTextDatum(MC_DATUM);                                                  // center align
  tft.setTextColor(windowOpen ? colorGood : colorText, colorBg);               // green if open, white if closed
  setFont(windowOpen ? smallFont : medFont);                                   // bigger font for the countdown
  char windowText[16];                                                         // buffer for the window time string
  snprintf(windowText, 16, "%dAM - %d%s",                                      // format the window hours
           startHour,
           endHour > 12 ? endHour - 12 : endHour,                               // convert to 12hr
           endHour >= 12 ? "PM" : "AM");                                        // am or pm
  tft.drawString(windowOpen ? windowText : fmtCountdown(countdown), midX, 56);  // show window time if open, countdown if closed

  tft.drawFastHLine(18, 80, 204, colorTrack);  // divider line
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(colorAccent, colorBg);
  setFont(tinyFont);                         // cyan small centered
  tft.drawString("LAST WATERED", midX, 92);  // label
  tft.setTextColor(colorText, colorBg);
  setFont(smallFont);                                // white, a bit bigger
  tft.drawString(fmtAgo(lastWaterTime), midX, 116);  // show how long ago we last watered

  tft.drawFastHLine(18, 134, 204, colorTrack);  // divider line
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(colorAccent, colorBg);
  setFont(tinyFont);                        // cyan small centered
  tft.drawString("CONDITIONS", midX, 146);  // conditions section label

  // three dots — green if the condition is met, red if not
  struct {
    const char* label;
    bool ok;
  } conditions[3] = {
    // array of conditions with labels
    { "Soil dry", soilMoisture < startWateringWhen },        // is soil dry enough
    { "No rain", !raining && rainChance < skipIfRainOver },  // is rain ok
    { "Wind ok", windMph < maxWindSpeed }                    // is wind ok
  };
  for (int i = 0; i < 3; i++) {                                          // draw each condition dot
    int x = midX + (i - 1) * 55;                                         // space the three dots evenly
    tft.fillCircle(x, 168, 7, conditions[i].ok ? colorGood : colorBad);  // green dot if ok, red if not
    tft.setTextDatum(TC_DATUM);
    tft.setTextColor(colorDim, colorBg);
    setFont(tinyFont);                            // gray small, top center
    tft.drawString(conditions[i].label, x, 180);  // label under each dot
  }
  drawPageDots();  // nav dots
}

// the big blue screen shown while actively watering
void drawWateringScreen() {    // draws the "currently watering" screen
  tft.fillScreen(colorWater);  // fill whole screen blue (water is blue)
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(colorText, colorWater);
  setFont(tinyFont);                                                   // white text, centered
  tft.drawString(manualMode ? "MANUAL WATER" : "WATERING", midX, 52);  // title, manual or auto
  tft.setTextColor(0x4C9F, colorWater);                                // darker blue for the hint text
  tft.drawString("Hold button to stop early", midX, 186);              // tell user how to stop it
  tft.drawRoundRect(28, 154, 184, 14, 5, colorText);                   // outline for the progress bar
  updateWateringCountdown();                                           // draw the timer and bar right away
}

// updates just the countdown timer and progress bar (called every second while watering)
void updateWateringCountdown() {                                                 // refreshes the countdown timer on the watering screen
  unsigned long secondsElapsed = (millis() - waterStartTime) / 1000;             // how long we've been running
  unsigned long totalSeconds = wateringDuration / 1000;                          // total run time in seconds
  unsigned long secondsLeft = totalSeconds - min(secondsElapsed, totalSeconds);  // dont let it go negative
  float progress = min(1.0f, (float)secondsElapsed / (float)totalSeconds);       // 0.0 to 1.0 progress

  int mins = (int)(secondsLeft / 60), secs = (int)(secondsLeft % 60);  // split into minutes and seconds
  char timerText[8];
  snprintf(timerText, 8, "%d:%02d", mins, secs);  // format as min:sec
  tft.fillRect(20, 74, 200, 44, colorWater);      // clear the timer area before redrawing
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(colorText, colorWater);
  setFont(bigFont);                             // white big font centered
  tft.drawString(String(timerText), midX, 96);  // draw the countdown timer

  tft.fillRect(20, 124, 200, 14, colorWater);  // clear the soil info area
  setFont(tinyFont);
  tft.setTextColor(0x9EFF, colorWater);                                                                          // light blue text
  tft.drawString("Soil: " + String(soilMoisture) + "%   stop at " + String(stopWateringWhen) + "%", midX, 128);  // show soil status

  // progress bar fills up as time runs out
  tft.fillRect(30, 156, 180, 10, colorWater);                                                   // clear the progress bar area
  if (progress > 0.0f) tft.fillRoundRect(30, 156, (int)(180.0f * progress), 10, 3, colorText);  // draw the filled portion
}

// simple splash screen that shows while the esp32 is booting up
void drawBoot(const char* status) {  // draws the boot screen with a status message
  tft.fillScreen(colorBg);           // clear screen
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(colorAccent, colorBg);
  setFont(medFont);  // cyan medium centered
  tft.drawString("SMART", midX, 96);
  tft.drawString("SPRINKLER", midX, 122);  // the app name
  setFont(tinyFont);
  tft.setTextColor(colorDim, colorBg);  // gray small for status
  tft.drawString(status, midX, 148);    // draw whatever status message we were given
}

// the evaporation math screen — shows all the nerd numbers
void drawViewET() {         // draws the evapotranspiration data screen
  tft.fillScreen(colorBg);  // clear screen
  tft.setTextDatum(TC_DATUM);
  tft.setTextColor(colorAccent, colorBg);
  setFont(tinyFont);                              // cyan small top center
  tft.drawString("EVAPOTRANSPIRATION", midX, 8);  // screen title

  // how much water evaporated from the yard today
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(colorDim, colorBg);
  setFont(tinyFont);                       // gray centered
  tft.drawString("TODAY'S ET", midX, 28);  // label for the ET number
  tft.setTextColor(colorText, colorBg);
  setFont(medFont);  // white medium font for the value
  char etText[16];
  snprintf(etText, 16, "%.3f in/day", dailyET / 25.4f);  // format as inches per day
  tft.drawString(String(etText), midX, 48);              // show the evaporation amount

  tft.drawFastHLine(20, 62, 200, colorTrack);  // divider line

  // seasonal grass factor — bar chart showing all 12 months, current month highlighted
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(colorAccent, colorBg);
  setFont(tinyFont);                            // cyan small centered
  tft.drawString("SEASONAL FACTOR", midX, 74);  // section label

  struct tm t;
  bool hasTime = getTm(&t);                   // get the current time
  int currentMonth = hasTime ? t.tm_mon : 6;  // default to july if time is unavailable

  const int barWidth = 14, barSpacing = 2, barHeight = 18, barTop = 96;  // bar chart dimensions
  const int totalWidth = 12 * barWidth + 11 * barSpacing;                // total width of all bars
  int barsStart = (screenW - totalWidth) / 2;                            // where to start drawing to center it

  for (int m = 0; m < 12; m++) {                                                    // draw one bar per month
    float factor = monthlyFactors[m];                                               // get the factor for this month
    int barFill = (int)(barHeight * factor);                                        // scale the bar height to the factor
    bool isThisMonth = (m == currentMonth);                                         // is this the current month
    uint16_t barColor = isThisMonth ? colorAccent : colorTrack;                     // current month is cyan, others are gray
    int barX = barsStart + m * (barWidth + barSpacing);                             // x position of this bar
    tft.fillRect(barX, barTop + barHeight - barFill, barWidth, barFill, barColor);  // draw the bar from bottom up
    if (isThisMonth) {                                                              // only for the current month
      tft.setTextDatum(TC_DATUM);
      tft.setTextColor(colorAccent, colorBg);  // cyan text above the bar
      char factorText[6];
      snprintf(factorText, 6, "%.2f", seasonalFactor);              // format the factor value
      tft.drawString(String(factorText), barX + barWidth / 2, 78);  // draw the value above the highlighted bar
    }
  }

  tft.drawFastHLine(20, 122, 200, colorTrack);  // divider line

  // the final calculated watering time
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(colorDim, colorBg);
  setFont(tinyFont);                                 // gray small centered
  tft.drawString("CALCULATED DURATION", midX, 134);  // label for the watering duration
  tft.setTextColor(colorGood, colorBg);
  setFont(bigFont);                                            // green big font for the result
  tft.drawString(String(minutesToWater) + " min", midX, 158);  // show the minutes to water

  tft.setTextColor(colorDim, colorBg);
  setFont(tinyFont);  // gray small
  char needText[20];
  snprintf(needText, 20, "%.3f\" needed", inchesNeeded);  // format inches needed
  tft.drawString(String(needText), midX, 178);            // show how many inches of water the lawn needs

  tft.drawFastHLine(20, 190, 200, colorTrack);  // divider line
  tft.setTextColor(colorDim, colorBg);
  setFont(tinyFont);  // gray small
  char rateText[28];
  snprintf(rateText, 28, "@ %.1f\"/hr application rate", sprinklerRate);  // format the sprinkler rate
  tft.drawString(String(rateText), midX, 200);                            // show what sprinkler rate we're assuming

  drawPageDots();  // nav dots
}

// picks which screen to draw based on what's happening right now
void render() {               // main draw function — picks the right screen to show
  if (screensaverOn) return;  // screensaver handles its own drawing, dont interfere
  if (waterOn) {
    drawWateringScreen();
    return;
  }                                                  // always show watering screen if its running
  switch (currentScreen) {                           // otherwise show whatever screen the knob is pointing at
    case soilScreen: drawViewSoil(); break;          // soil moisture screen
    case weatherScreen: drawViewWeather(); break;    // weather screen
    case scheduleScreen: drawViewSchedule(); break;  // schedule screen
    case waterCalcScreen: drawViewET(); break;       // evap math screen
    default: break;                                  // shouldnt happen but just in case
  }
}

//  setup runs once at boot

void setup() {
  Serial.begin(115200);                                   // start serial for debug output
  Serial.println("\n\n=== smart sprinkler booting ===");  // annouce we're booting cuz were chill like that

  pinMode(soilPin, INPUT);           // soil sensor is an input
  pinMode(relayPin, OUTPUT);         // relay is an output
  pinMode(buttonPin, INPUT_PULLUP);  // button with internal pullup (reads HIGH when not pressed)
  pinMode(knobPin, INPUT);           // pot is an input
  setRelay(false);                   // make sure the relay is off at startup

  tft.init();                  // initialize the screen
  tft.setRotation(0);          // portrait orientation
  tft.fillScreen(colorBg);     // black out the screen
  drawBoot("Starting up...");  // show boot screen
  delay(400);                  // brief pause so it doesnt flash by too fast

  // mount the file system, needed for screensaver images and the web dashboard html
  if (!LittleFS.begin()) {                                                          // try to mount the filesystem
    Serial.println("[littlefs] mount failed — screensaver and web ui won't work");  // warn if it fails
  } else {
    Serial.println("[littlefs] mounted ok");  // let us know its ok
  }

  drawBoot("Connecting to Wi-Fi...");  // update the boot screen
  WiFi.begin(wifiName, wifiPassword);  // start connecting
  int wifiAttempts = 0;                // count our attempts
  while (WiFi.status() != WL_CONNECTED && wifiAttempts++ < 40) {
    delay(500);
    Serial.print(".");
  }                                              // wait up to 20 seconds
  if (WiFi.status() != WL_CONNECTED) {           // if we never connected
    drawBoot("Wi-Fi failed! Check SSID/pass.");  // show error
    while (true) delay(1000);                    // stop here forever — nothing works without wifi
  }
  Serial.printf("\nwifi connected: %s\n", WiFi.localIP().toString().c_str());  // log our ip address

  drawBoot("Syncing time (NTP)...");     // update boot screen
  configTzTime(myTimezone, timeServer);  // set timezone and ntp server
  struct tm t;
  int ntpAttempts = 0;  // temp struct and counter
  while (!getLocalTime(&t) && ntpAttempts++ < 30) {
    delay(500);
    Serial.print("T");
  }                                                           // wait for ntp to sync
  if (ntpAttempts >= 30) {                                    // if it never synced
    Serial.println("\nntp sync failed — continuing anyway");  // warn but dont stop
    drawBoot("NTP failed — continuing...");                   // show warning
    delay(2000);                                              // pause so user can read it
  } else {
    Serial.printf("\ntime synced: %s  %s\n", timeStr().c_str(), dateStr().c_str());  // log the synced time
  }

  drawBoot("Fetching weather...");  // update boot screen
  fetchCurrentWeather();            // get the current conditions
  fetchForecast();                  // get the 12 hour forecast
  updateETWatering();               // run the evaporation math with the fresh weather data

  // set up the web server and websocket
  socket.onEvent(onWsEvent);   // wire up the websocket event handler
  server.addHandler(&socket);  // add the socket to the server

  // serve the dashboard html from littlefs
  server.on("/", HTTP_GET, [](AsyncWebServerRequest* req) {  // handle requests to the root url
    if (LittleFS.exists("/index.html")) {                    // check if the file is there
      req->send(LittleFS, "/index.html", "text/html");       // serve it
    } else {                                                 // file not found
      req->send(200, "text/plain",                           // send a helpful error message instead
                "index.html not found in LittleFS.\n"
                "Upload it via Tools → ESP32 Sketch Data Upload.\n"
                "IP: "
                  + WiFi.localIP().toString());
    }
  });

  // serve any other static files too (css, js, images, etc.)
  server.serveStatic("/", LittleFS, "/");  // anything not matched above gets served from littlefs

  server.onNotFound([](AsyncWebServerRequest* req) {  // 404 handler
    req->send(404, "text/plain", "Not found");        // simple 404 message
  });

  server.begin();                                                                      // start the web server
  Serial.printf("[web] dashboard at http://%s\n", WiFi.localIP().toString().c_str());  // log the url

  soilMoisture = readSoilPercent();             // get an initial soil reading
  Serial.printf("soil: %d%%\n", soilMoisture);  // log it
  Serial.println("=== boot complete ===\n");    // boot is done
  render();                                     // draw the initial screen
}

//  main loop runs forever cuz its scared

void loop() {
  unsigned long now = millis();  // grab current time once per loop, use this everywhere

  bool buttonNow = digitalRead(buttonPin);  // read the button right now

  // button just went from not pressed to pressed
  if (buttonNow == HIGH && lastButtonReading == LOW) {  // rising edge
    buttonIsHeld = true;                                // start tracking the hold
    buttonHeldAt = now;                                 // record when it was pressed
    longPressTriggered = false;                         // havent fired the long press yet
  }

  // button is being held — check if we've crossed the long press time
  if (buttonIsHeld && buttonNow == HIGH && !longPressTriggered) {  // button is held and we havent fired yet
    if (now - buttonHeldAt >= holdForScreensaver) {                // has it been held long enough
      longPressTriggered = true;                                   // mark it so we dont fire twice
      Serial.println("long press — entering screensaver");         // log it
      enterScreensaver();                                          // show the screensaver
    }
  }

  // button just got released
  if (buttonNow == LOW && lastButtonReading == HIGH) {  // falling edge (button released)
    if (buttonIsHeld && !longPressTriggered) {          // short press released before long press fired
      // short press — do the right thing based on what's currently happening
      if (screensaverOn) {                                 // if screensaver is showing
        exitScreensaver();                                 // turn it off
      } else if (waterOn && manualMode) {                  // if manually watering
        setRelay(false);                                   // stop watering
        lastWaterTime = now;                               // record the stop time
        manualMode = false;                                // clear manual flag
        Serial.println("button stopped manual watering");  // log it
        render();                                          // update the screen
      } else {                                             // otherwise
        manualMode = true;                                 // set manual mode
        Serial.println("button started manual watering");  // log it
        render();                                          // update screen (evaluateWatering will turn it on next soil check)
      }
    }
    buttonIsHeld = false;  // button is no longer held
  }

  lastButtonReading = buttonNow;  // save the reading for next loop

  // screensaver is running cycle images and skip all the watering logic
  if (screensaverOn) {                                                 // if screensaver is on
    if (now - lastImageSwap >= eachImageTime) {                        // time to switch to the next image
      lastImageSwap = now;                                             // reset the timer
      currentPic = (currentPic + 1) % picCount;                        // advance to next image, wrap around at the end
      Serial.printf("[screensaver] showing: %s\n", pics[currentPic]);  // log which image
      loadBMP(pics[currentPic], 0, 0);                                 // draw the image
    }
    return;  // skip the rest of the loop while the screensaver is on
  }

  // read the soil sensor and decide whether to water (every 5 seconds)
  if (now - lastSensorTime >= checkSoilEvery) {            // time to read the sensor
    lastSensorTime = now;                                  // reset the timer
    soilMoisture = readSoilPercent();                      // read the sensor
    Serial.printf("[sensor] soil: %d%%\n", soilMoisture);  // log it
    bool wasWatering = waterOn;                            // note if we were watering (unused but could be useful)
    evaluateWatering();                                    // decide if we should start or stop
    render();                                              // always rerender so time, soil %, and status badge stay fresh
    (void)wasWatering;                                     // suppress the unused variable warning
  }

  // update the countdown timer every second while watering
  if (waterOn && (now - lastTimerUpdate >= countdownRefresh)) {  // only when watering, once per second
    lastTimerUpdate = now;                                       // reset the timer
    updateWateringCountdown();                                   // refresh just the counter, not the whole screen
  }

  // grab fresh weather data every 10 minutes
  if (now - lastWeatherTime >= checkWeatherEvery) {  // time for a weather update
    lastWeatherTime = now;                           // reset the timer
    fetchCurrentWeather();                           // get the current conditions
    fetchForecast();                                 // get the forecast
    updateETWatering();                              // recalculate watering time with the new weather data
  }

  // check the knob position and switch screens if it moved (80ms between checks)
  // 80ms = ~12 checks/second — feels instant but gives the screen time to finish
  // drawing before we ask it to draw something else
  static unsigned long lastKnobTime = 0;                           // static so it persists between calls
  if (!waterOn && (now - lastKnobTime >= 80UL)) {                  // only check knob if not watering
    lastKnobTime = now;                                            // reset the timer
    int newScreen = readPotView();                                 // see which screen the knob is at
    if (newScreen != lastKnobScreen) {                             // did it change
      lastKnobScreen = newScreen;                                  // save the new screen
      currentScreen = (Screen)newScreen;                           // switch to it
      Serial.printf("[knob] switched to screen %d\n", newScreen);  // log it
      render();                                                    // draw the new screen
    }
  }

  // push current state to any connected web dashboard clients every second
  if (now - lastBroadcastTime >= broadcastEvery) {  // once per second
    lastBroadcastTime = now;                        // reset the timer
    socket.cleanupClients();                        // drop any dead connections (bro left his day ones)
    broadcastState();                               // send the state to all connected clients
  }
}
