// SPDX-License-Identifier: CC0-1.0
// Copyright © Fabrice Bellamy 2026

/*
 *  The Bib O'Tron project
 *  A proof of concept for replacing the raspberry pi that is managing the bib button by an ESP32 microcontroller.
 *
 *  license : Creative Commons Zero v1.0 Universal
 *  original source code and documentation : https://git.interhacker.space/12b/bibotron
 */

// Include some libraries
#include <FastLED.h>
#include <Arduino.h>
//#include <WiFi.h>
#include <SPI.h>
#include <EthernetESP32.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <ESPping.h>
#include <ArduinoJson.h>

// You may need to change the below line in libraries/ElegantOTA/src/ElegantOTA.h for the ElegantOTA library to be built with the correct web server type. If you know a better way of doing this feel free to send a pull request ;-)
// #define ELEGANTOTA_USE_ASYNC_WEBSERVER O
#include <ElegantOTA.h>

// Include a non-versioned file that containes the web OTA update credentials (you have to create this file by copying the example one if you do not have it)
#include "secrets.h" 

#if !( defined(ESP32) )
  #error This code is designed for ESP32_S3 + W5500! Please check your Tools->Board setting. (see the file README.md for the settings to use)
#endif

// LED GPIOs 
#define LEDS_PIN_0 21 // The integrated board LED
#define LEDS_PIN_1 2  // External RGB LED(s strip)
// Traffic lights GPIOs 
#define LIGHT_RED    39
#define LIGHT_GREEN  40
#define LIGHT_ORANGE 38
#define SPARE_1      37
#define SPARE_2      42
#define SPARE_3      41

// Button GPIO
#define BIB_BUTTON 3  // Digital input for the bib button
// W5500 SPI/Ethernet chip GPIOs
#define W5500_CS    14  // Chip Select pin
#define W5500_RST    9  // Reset pin
#define W5500_INT   10  // Interrupt pin
#define W5500_MISO  12  // MISO pin
#define W5500_MOSI  11  // MOSI pin
#define W5500_SCK   13  // Clock pin

// Number of LEDs for FastLED arrays
#define NUM_LEDS_0 1 // The integrated board LED is only one LED
#define NUM_LEDS_1 64 // LED(s) connected to GPIO LEDS_PIN_1.

// Other settings
#define LEDS_BRIGHTNESS 50   // Min 0, Max 255
#define HEARTBEAT_PERIOD 20  // Cycle time for aniating the heartbeat LED (in ms)
// #define PING_PERIOD 60000    // Cycle time for checking network connectivity by ping of our reverse proxy
#define PING_PERIOD 1000    // Cycle time for checking network connectivity by ping of our reverse proxy

// SPI ethernet chip driver
W5500Driver driver(W5500_CS);

// HTTP Web server
WebServer server(8000);

// LEDs values arrays for FastLED
CRGB leds_0[NUM_LEDS_0];
CRGB leds_1[NUM_LEDS_1];

// Heartbeat related variables
int heartbeat_index = 0;
unsigned long last_heartbeat_time = 0;
unsigned long last_ping_time = 0;
bool is_network_ok = false;

int forced_state = -1;
int button_state = -1;
unsigned long long request_count = 0;
unsigned long long request_chen = 0;
bool clear_msb = false;



// Home web page handler
void handleRoot() {
  Serial.println("serving page /");
  String message =  "Hello,<br>I am <b>Bib O'Tron</b>!<br>Master of buttons and lights.</p>";
  message += "<hr><ul>";
  message += "<li><a href=\"/bibutton\">Bib button status</a></li>";
  message += "<li><a href=\"https://git.distrilab.fr/Charavane/arduino/src/branch/main/bibotron/bibotron\">Source code and documentation</a></li>";
  message += "<li><a href=\"/update\">Update firmware via Web OTA</a></li>";
  message += "</ul>";
  server.send(200, "text/html", message);
}

// `/bibutton` page handler
void handleGetBibbutton() {
  Serial.println("serving page /bibutton");
  Serial.print(" FreeHeapSize : ");
  Serial.println(xPortGetFreeHeapSize());

  request_count++;
  request_chen|=1;

  bool isOpen = false;

  if ((forced_state < 0)) {
    isOpen = (button_state == HIGH); 
  } else {
    isOpen = (forced_state == HIGH); 
  }

  String message = "<html><head>";
  message += "<meta charset=\"UTF-8\">";
  message += "<meta http-equiv=\"refresh\" content=\"60\">";
  message += "<link rel=\"stylesheet\" href=\"/css/bibstate.css\">";
  message += "<link rel=\"stylesheet\" href=\"/css/style.css\">";
  // message += "<link rel=\"icon\" type=\"image/png\" href=\"https://lebib.org/sites/default/files/favicon_02.png\"/>";
  message += "</head><body>";
  message += "<div id=\"bibstate\">";

  if (isOpen) {
    message += "<div id=\"bibopen\"></div>";
  } else {
    message += "<div id=\"bibclose\"></div>";
  }
  message +=  "<div class=\"bibstatus-status\">";
  if (isOpen) {
    message += "Le Bib est ouvert";
  } else {
    message += "Le Bib est Fermé";
  }
  message += "</div></div></body></html>";
  server.send(200, "text/html", message);

  Serial.print(" FreeHeapSize : ");
  Serial.println(xPortGetFreeHeapSize());
}

// `/bibleds` POST request handler
void handleSetButton() {
  if (!server.authenticate(button_user, button_password)) {
    return server.requestAuthentication();
  }

  if (server.method() == HTTP_POST) {
    Serial.println("received post request on /bibutton");
    String postBody = server.arg("plain");
    /* Disable printing the request body for prod
    Serial.println(postBody);
    */
    DynamicJsonDocument doc(512);
    DeserializationError error = deserializeJson(doc, postBody);
    if (error) {
      Serial.print(F("Error parsing JSON "));
      Serial.println(error.c_str());
      String msg = error.c_str();
      server.send(400, F("text/html"),
        "Error while parsing json data! <br>" + msg);
    } else {
      JsonObject postObj = doc.as<JsonObject>();
      forced_state = postObj["forceState"] | forced_state;
      if ((forced_state != -1) && (forced_state != LOW) && (forced_state != HIGH)) {
        forced_state = -1;
      }
      DynamicJsonDocument doc(512);
      doc["forced_state"] = forced_state;
      doc["button_state"] = button_state;
      String buf;
      serializeJson(doc, buf);
      server.send(200, F("application/json"), buf);
      Serial.print(F("done."));
    }
  }
}

// `/bibutton/leds` POST request handler
void handleSetLeds() {
  if (!server.authenticate(leds_user, leds_password)) {
    return server.requestAuthentication();
  }

  if (server.method() == HTTP_POST) {
    Serial.println("received post request on /bibutton/leds");
    String postBody = server.arg("plain");
    /* Disable printing the request body for prod
    Serial.println(postBody);
    */
    DynamicJsonDocument doc(512);
    DeserializationError error = deserializeJson(doc, postBody);
    if (error) {
      Serial.print(F("Error parsing JSON "));
      Serial.println(error.c_str());
      String msg = error.c_str();
      server.send(400, F("text/html"),
        "Error while parsing json data! <br>" + msg);
    } else {
    JsonObject postObj = doc.as<JsonObject>();
      if (postObj.containsKey("index") && postObj.containsKey("count") && postObj.containsKey("values")) { 
        int index = postObj["index"] | -1;
        int count = postObj["count"] | -1;
        JsonArray values = postObj["values"];
        if (index >= 0 && index < NUM_LEDS_1 && count > 0 && (index+count) <= NUM_LEDS_1) {
          for (int i=0; i<count; i++) {
            JsonArray val = values[i];
            byte red = val[0];
            byte green = val[1];
            byte blue = val[2];
            /* Disable printing these details for prod
            Serial.print(F("Setting led #"));
            Serial.print(index+i);
            Serial.print(F(" to (r,g,b)=("));
            Serial.print(red);
            Serial.print(F(","));
            Serial.print(green);
            Serial.print(F(","));
            Serial.print(blue);
            Serial.println(F(")"));
            */
            leds_1[i] = CRGB(red,green,blue);
          }
        }else {
          DynamicJsonDocument doc(512);
          doc["status"] = "Failed";
          doc["message"] = F("Invalid index or count value!");
            String buf;
          serializeJson(doc, buf);
          server.send(400, F("application/json"), buf);
          Serial.print(F("done."));
        }

        DynamicJsonDocument doc(512);
        doc["status"] = "OK"; 
        String buf;
        serializeJson(doc, buf);
        server.send(201, F("application/json"), buf);
      }else {
        DynamicJsonDocument doc(512);
        doc["status"] = "Failed";
        doc["message"] = F("No data found, or incorrect!");
          String buf;
        serializeJson(doc, buf);
        server.send(400, F("application/json"), buf);
        Serial.print(F("done."));
      }
    }
  }
}


// Error 404 handler
void handleNotFound() {
  Serial.println("serving page 404");
  String message = "Error 404\n\n";
  message += "URI: ";
  message += server.uri();
  message += "\nMethod: ";
  message += (server.method() == HTTP_GET) ? "GET" : "POST";
  message += "\nArguments: ";
  message += server.args();
  message += "\n";
  for (uint8_t i = 0; i < server.args(); i++) {
    message += " " + server.argName(i) + ": " + server.arg(i) + "\n";
  }
  server.send(404, "text/plain", message);
}


void animateLedStrip(void) {
  unsigned long long val = request_count ^ request_chen;

  int i = 0;
  while (i<NUM_LEDS_1 && val>0) {
    unsigned bit = val & 1;
    if (bit == 1) {
      leds_1[NUM_LEDS_1-1-i] = CHSV((i+heartbeat_index)%255,255,255);
    } else {
      leds_1[NUM_LEDS_1-1-i] = CRGB(0,0,0);
    }
    val >>= 1;
    i++;
  }
  if (i<NUM_LEDS_1) {
    leds_1[NUM_LEDS_1-1-i] = CRGB(0,0,0);
  }
  if (clear_msb) {
    clear_msb = false;
    leds_1[0] = CRGB(0,0,0);
  }
  if (request_chen > 0) {
    request_chen <<= 1;
    clear_msb = (request_chen == 0);
  }
}


// Setup the web OTA update library
void otaSetup(void) {
  ElegantOTA.onStart([]() {
    Serial.println("OTA update process started.");
  });
  // This is spitting out too many line to the serial line. Enable it only for debugging purpose.
  // ElegantOTA.onProgress([](size_t current, size_t final) {
  //   Serial.printf("Progress: %u%%\n", (current * 100) / final);
  // });
  ElegantOTA.onEnd([](bool success) {
    if (success) {
      Serial.println("OTA update completed successfully.");
    } else {
      Serial.println("OTA update failed.");
    }
  });
  ElegantOTA.begin(&server);  
  ElegantOTA.setAuth(ota_user, ota_password);
}


void  webServerSetup() {
  server.on("/", handleRoot);
  server.on("/bibutton", HTTP_GET, handleGetBibbutton);
  server.on("/bibutton", HTTP_POST, handleSetButton);
  server.on("/bibleds", HTTP_POST, handleSetLeds);
  server.onNotFound(handleNotFound);
  server.begin();
  Serial.println("HTTP server started");  
}


// Main setup function
void setup() {
  // Init serial port
  Serial.begin(500000);
  Serial.setDebugOutput(true);
  Serial.println("Serial port initialized.");

  // Safety delay
  delay(1000);

  // Setup Addressable LEDs
  Serial.println("Setting-up the LEDs...");
  FastLED.addLeds<WS2812, LEDS_PIN_0, GRB>(leds_0, NUM_LEDS_0); 
  FastLED.addLeds<WS2812, LEDS_PIN_1, GRB>(leds_1, NUM_LEDS_1); 
  FastLED.setBrightness(LEDS_BRIGHTNESS);
  FastLED.show();
  
  // Setup the GPIO of the bib button as an input with an integrated weak pull-up resistor
  Serial.println("Setting-up the button GPIO...");
  pinMode(BIB_BUTTON, INPUT_PULLDOWN);
  // Setup the GPIOs of the traffic lights as output
  pinMode(LIGHT_RED, OUTPUT);
  pinMode(LIGHT_GREEN, OUTPUT);
  pinMode(LIGHT_ORANGE, OUTPUT);

  Serial.println("Setting-up the ethernet interface...");
  // Initialize SPI with specified pin configuration
  SPI.begin(W5500_SCK, W5500_MISO, W5500_MOSI);
  // Initialize Ethernet using DHCP to obtain an IP address
  Ethernet.init(driver);
  if (Ethernet.begin(mac[SELECTED_MAC]) == 0) {
    Serial.println("DHCP failed, falling back to static IP...");
    // Initialize with static IP
    Ethernet.begin(mac[SELECTED_MAC], myIP, myDNS, myGW, mySN);
  } else {
    is_network_ok = true;
  }
  // Print the assigned IP address
  Serial.print("IP Address: ");
  Serial.println(Ethernet.localIP());

  //Setup mdns so that other hosts can resolve our <hostname>.local DNS name
  Serial.println("Setting-up the MDNS responder...");
  if (!MDNS.begin(hostname)) {
    Serial.println("Error setting up MDNS responder!");
  }

  Serial.println("Setting-up the web server...");
  webServerSetup();

  Serial.println("Setting-up the web OTA library...");
  otaSetup();

  Serial.println("Setup completed :-)");
}

// Main loop function
void loop() {
  // Get current time 
  unsigned long current_time = micros();

  // Check if it is time to update the heartbeat LED
  if ((current_time - last_heartbeat_time) >= 1000*HEARTBEAT_PERIOD) {
    // Update last heartbeat time
    last_heartbeat_time = current_time;

    // Increment the hue we are displaying on the board LED, and cycle back to 0 when reaching 256
    heartbeat_index = (heartbeat_index + 1) % 256;
    // Update the LED color in the FastLED array 
    leds_0[0] = CHSV(heartbeat_index,255,255);

    // Read the bibutton status and turn on/off the red and green lights accordingly
    button_state = digitalRead(BIB_BUTTON);
    if ((forced_state < 0) || forced_state == button_state) {
      forced_state = -1;
      if (button_state == HIGH) {
        digitalWrite(LIGHT_GREEN, HIGH);
        digitalWrite(LIGHT_RED, LOW);
      } else {
        digitalWrite(LIGHT_GREEN, LOW);
        digitalWrite(LIGHT_RED, HIGH);
      }
    } else {
      digitalWrite(LIGHT_GREEN, HIGH);
      digitalWrite(LIGHT_RED, HIGH);
    }

    // Turn on/off the orange light according to network connectivity status
    if (!is_network_ok) {
      digitalWrite(LIGHT_ORANGE, HIGH);
    } else {
      digitalWrite(LIGHT_ORANGE, LOW);
    }

    // Do something on the LED strip values
    animateLedStrip();

    // Update the LEDs with the values we set in the arrays
    FastLED.show();
  }

  // Check if it is time to check the network connectivity
  if ((current_time - last_ping_time) >= 1000*PING_PERIOD) {
    // ToDo : move that to an asynchronous task as the ping can take a while if the internet is not reachable. But that is not that important, because if the net is down, our main purpose is borked anyway.
    is_network_ok = Ping.ping(myProxy, 1);
    if (is_network_ok) {
      Serial.print("ping time: ");
      Serial.print(Ping.averageTime());
      Serial.println(" ms");
    } else {
      Serial.println("Failed to ping remote host!");
    }
    last_ping_time = micros();
  }

  // Do nothing for 1 ms
  delay(1);

  // Let the web server and the web OTA update library do their things
  server.handleClient();
  ElegantOTA.loop();
}
