// Glasses for Dyslexic Readers - ESP32-CAM capture firmware
// Button press -> photo with flash -> HTTPS POST to the server, which has the reading screen
// read the page aloud. A heartbeat every 30 s lets the reading screen show "Glasses online"
// and count restarts (useful for the battery test).
//
// Arduino IDE: board "AI Thinker ESP32-CAM", Partition Scheme "Huge APP" (its default; PSRAM is always on).
// Tested to compile with the esp32 package 3.3.12.
// Copy secrets.example.h to secrets.h and fill it in before uploading.

#include "esp_camera.h"
#include "esp_system.h"
#include <WiFi.h>
#include <WiFiMulti.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>

#if __has_include("secrets.h")
#include "secrets.h"
#else
#error "Copy secrets.example.h to secrets.h and fill in the Wi-Fi and server details"
#endif

#define FW_VERSION      "1.1"
#define BUTTON_PIN      13     // button to GND, internal pull-up
#define FLASH_PIN        4     // bright white LED on the front
#define STATUS_LED      33     // small red LED on the back, lit when LOW
#define USE_FLASH        1     // 0 if the flash glares off glossy pages
#define FLASH_SETTLE_MS 200    // time for the exposure to adjust to the flash
#define PING_EVERY_MS 30000
#define CAM_VFLIP        0     // 1 if the camera is mounted upside down
#define CAM_HMIRROR      0

WiFiMulti wifi;
String deviceId;
volatile bool pressed = false;
volatile uint32_t pressedAt = 0;
uint32_t lastPing = 0;

void IRAM_ATTR onButton() {
  uint32_t now = millis();
  if (now - pressedAt > 400) { pressed = true; pressedAt = now; }   // ignore bounce and double taps
}

void led(bool on) { digitalWrite(STATUS_LED, on ? LOW : HIGH); }
void blink(int times, int ms) {
  for (int i = 0; i < times; i++) { led(true); delay(ms); led(false); delay(ms); }
}

const char* resetReason() {
  switch (esp_reset_reason()) {
    case ESP_RST_POWERON:  return "power-on";
    case ESP_RST_BROWNOUT: return "brownout";     // battery or boost converter sagging
    case ESP_RST_PANIC:    return "crash";
    case ESP_RST_INT_WDT:
    case ESP_RST_TASK_WDT:
    case ESP_RST_WDT:      return "watchdog";
    case ESP_RST_SW:       return "software";
    case ESP_RST_EXT:      return "reset-button";
    default:               return "other";
  }
}

// POSTs a body to base + path. Returns the HTTP status, or a negative error.
// HTTPS skips certificate checks (setInsecure): fine for a demo, the access key still applies.
int post(const char* base, const char* path, const char* type, uint8_t* body, size_t len) {
  if (!base[0]) return -1000;
  String url = String(base) + path;
  WiFiClientSecure tls;
  WiFiClient plain;
  HTTPClient http;   // declared after the clients, so it is destroyed before them
  bool ok;
  if (url.startsWith("https://")) { tls.setInsecure(); ok = http.begin(tls, url); }
  else ok = http.begin(plain, url);
  if (!ok) return -1001;
  http.setConnectTimeout(8000);
  http.setTimeout(15000);
  http.setUserAgent("glasses4dyslexic/" FW_VERSION);
  http.addHeader("Content-Type", type);
  http.addHeader("X-Access-Key", ACCESS_KEY);
  http.addHeader("X-Device-Id", deviceId);
  int code = http.POST(body, len);
  http.end();
  return code;
}

bool upload(camera_fb_t* fb) {
  const char* targets[] = { SERVER_URL, SERVER_URL, FALLBACK_URL };   // cloud twice, then the laptop
  for (const char* base : targets) {
    if (!base[0]) continue;
    uint32_t t0 = millis();
    int code = post(base, "/api/upload", "image/jpeg", fb->buf, fb->len);
    Serial.printf("Upload to %s: %d (%u bytes, %lu ms)\n", base, code,
                  (unsigned)fb->len, (unsigned long)(millis() - t0));
    if (code >= 200 && code < 300) return true;
    if (code == 401) Serial.println("Server refused the ACCESS_KEY in secrets.h");
  }
  return false;
}

void ping() {
  char body[220];
  snprintf(body, sizeof body,
           "{\"device\":\"%s\",\"fw\":\"%s\",\"rssi\":%d,\"uptime_ms\":%lu,\"heap\":%u,\"psram\":%u,\"reset_reason\":\"%s\"}",
           deviceId.c_str(), FW_VERSION, (int)WiFi.RSSI(), (unsigned long)millis(),
           (unsigned)ESP.getFreeHeap(), (unsigned)ESP.getFreePsram(), resetReason());
  int code = post(SERVER_URL, "/api/ping", "application/json", (uint8_t*)body, strlen(body));
  if ((code < 200 || code >= 300) && FALLBACK_URL[0])
    code = post(FALLBACK_URL, "/api/ping", "application/json", (uint8_t*)body, strlen(body));
  Serial.printf("Ping: %d, RSSI %d dBm\n", code, (int)WiFi.RSSI());
}

camera_fb_t* capture() {
  if (USE_FLASH) digitalWrite(FLASH_PIN, HIGH);
  delay(FLASH_SETTLE_MS);
  // Frames started before the flash came on are dark or stale: throw two away.
  for (int i = 0; i < 2; i++) {
    camera_fb_t* old = esp_camera_fb_get();
    if (old) esp_camera_fb_return(old);
  }
  camera_fb_t* fb = esp_camera_fb_get();
  digitalWrite(FLASH_PIN, LOW);
  return fb;
}

void setup() {
  Serial.begin(115200);
  Serial.printf("\nglasses4dyslexic firmware %s, reset reason: %s\n", FW_VERSION, resetReason());
  pinMode(FLASH_PIN, OUTPUT);
  digitalWrite(FLASH_PIN, LOW);
  pinMode(STATUS_LED, OUTPUT);
  led(false);
  pinMode(BUTTON_PIN, INPUT_PULLUP);

  // AI Thinker ESP32-CAM pin map
  camera_config_t c = {};
  c.ledc_channel = LEDC_CHANNEL_0;  c.ledc_timer = LEDC_TIMER_0;
  c.pin_d0 = 5;  c.pin_d1 = 18; c.pin_d2 = 19; c.pin_d3 = 21;
  c.pin_d4 = 36; c.pin_d5 = 39; c.pin_d6 = 34; c.pin_d7 = 35;
  c.pin_xclk = 0;  c.pin_pclk = 22; c.pin_vsync = 25; c.pin_href = 23;
  c.pin_sccb_sda = 26; c.pin_sccb_scl = 27;
  c.pin_pwdn = 32; c.pin_reset = -1;
  c.xclk_freq_hz = 20000000;
  c.pixel_format = PIXFORMAT_JPEG;
  c.frame_size   = FRAMESIZE_UXGA;   // 1600x1200
  c.jpeg_quality = 10;               // lower = better quality
  c.fb_count     = 1;
  c.fb_location  = CAMERA_FB_IN_PSRAM;
  c.grab_mode    = CAMERA_GRAB_WHEN_EMPTY;

  if (esp_camera_init(&c) != ESP_OK) {
    Serial.println("Camera init failed, restarting");
    blink(5, 100);
    ESP.restart();
  }
  sensor_t* s = esp_camera_sensor_get();
  s->set_vflip(s, CAM_VFLIP);
  s->set_hmirror(s, CAM_HMIRROR);

  WiFi.mode(WIFI_STA);
  for (auto& net : WIFI_NETWORKS) wifi.addAP(net[0], net[1]);
  String mac = WiFi.macAddress();
  mac.replace(":", "");
  deviceId = "g4d-" + mac.substring(6);

  Serial.print("Connecting to Wi-Fi");
  while (wifi.run(10000) != WL_CONNECTED) { Serial.print("."); blink(1, 150); }
  Serial.printf("\nConnected to %s, IP %s, RSSI %d dBm\n", WiFi.SSID().c_str(),
                WiFi.localIP().toString().c_str(), (int)WiFi.RSSI());

  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), onButton, FALLING);
  blink(2, 80);
  ping();
  lastPing = millis();
}

void loop() {
  if (WiFi.status() != WL_CONNECTED) {
    led(true);
    Serial.println("Wi-Fi lost, reconnecting");
    if (wifi.run(5000) == WL_CONNECTED) {
      Serial.printf("Reconnected to %s\n", WiFi.SSID().c_str());
      led(false);
    }
    return;
  }

  if (pressed) {
    pressed = false;
    uint32_t age = millis() - pressedAt;
    if (age < 30) {                                   // confirm it is a real press, not a glitch
      delay(30 - age);
      if (digitalRead(BUTTON_PIN) != LOW) return;
    }
    if (age > 5000) return;                           // pressed long ago while offline: ignore

    Serial.println("Button pressed, taking photo");
    led(true);
    camera_fb_t* fb = capture();
    if (!fb) {
      Serial.println("Capture failed");
      led(false);
      blink(3, 100);
      return;
    }
    Serial.printf("Photo %ux%u, %u bytes\n", (unsigned)fb->width, (unsigned)fb->height, (unsigned)fb->len);
    bool ok = upload(fb);
    esp_camera_fb_return(fb);
    led(false);
    if (ok) blink(1, 400); else blink(3, 100);       // one long blink = sent, three short = failed
    return;
  }

  if (millis() - lastPing > PING_EVERY_MS) {
    lastPing = millis();
    ping();
  }
  delay(10);
}
