/* * web_server.cpp - Web server handlers * TJ-56-654 Weather Clock v1.9.3 */ #include "globals.h" #include #include // Dummy function for compatibility void ICACHE_FLASH_ATTR displaySegments(const uint8_t segments[]) { // Not used with OLED } // PROGMEM templates for handleRoot() const char ROOT_HTML_HEADER[] PROGMEM = "" "" "" "TJ-56-654 Clock v" FIRMWARE_VERSION "" "" "" "" "
" "

TJ-56-654 NTP Clock v" FIRMWARE_VERSION "

" "
--:--:--
"; const char ROOT_HTML_FOOTER[] PROGMEM = "Configuration" "Debug Info" "Firmware Update" "Status (JSON)" "" "
"; void ICACHE_FLASH_ATTR handleRoot() { char buf[150]; server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, "text/html", ""); server.sendContent_P(ROOT_HTML_HEADER); if (lastError != "") { snprintf_P(buf, sizeof(buf), PSTR("
Error: %s
"), lastError.c_str()); server.sendContent(buf); } snprintf_P(buf, sizeof(buf), PSTR("
WiFi: %s
"), WiFi.SSID().c_str()); server.sendContent(buf); snprintf_P(buf, sizeof(buf), PSTR("
IP: %s
"), WiFi.localIP().toString().c_str()); server.sendContent(buf); snprintf_P(buf, sizeof(buf), PSTR("
Hostname: %s.local
"), config.hostname); server.sendContent(buf); snprintf_P(buf, sizeof(buf), PSTR("
Uptime: %lu seconds
"), millis()/1000); server.sendContent(buf); if (!timeClient.isTimeSet()) { snprintf_P(buf, sizeof(buf), PSTR("
NTP not synced yet
Attempts: %d | Success: %d
"), ntpAttempts, ntpSuccesses); server.sendContent(buf); } server.sendContent_P(ROOT_HTML_FOOTER); server.sendContent(""); } // PROGMEM templates for handleDebug() const char DEBUG_HTML_HEADER[] PROGMEM = "" "" "" "Debug Info" "" "" "
" "

Debug Information

" "

Network

";

const char DEBUG_HTML_FOOTER[] PROGMEM =
  "

Actions

" "" "" "" "" "" "" "
"; void ICACHE_FLASH_ATTR handleDebug() { char buf[200]; server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, "text/html", ""); server.sendContent_P(DEBUG_HTML_HEADER); snprintf_P(buf, sizeof(buf), PSTR("SSID: %s\nIP: %s\nGateway: %s\nDNS: %s\nRSSI: %d dBm\nHostname: %s\n"), WiFi.SSID().c_str(), WiFi.localIP().toString().c_str(), WiFi.gatewayIP().toString().c_str(), WiFi.dnsIP().toString().c_str(), WiFi.RSSI(), config.hostname); server.sendContent(buf); server.sendContent_P(PSTR("

Internet Connectivity

Status: "));
  server.sendContent_P(internetConnected ? PSTR("Connected\n
") : PSTR("Not connected\n")); server.sendContent_P(PSTR("

NTP

"));
  snprintf_P(buf, sizeof(buf), PSTR("Server: %s\nUpdate interval: %u seconds\nSynced: "), config.ntp_server, config.ntp_interval);
  server.sendContent(buf);
  server.sendContent_P(timeClient.isTimeSet() ? PSTR("Yes\n") : PSTR("No\n"));

  snprintf_P(buf, sizeof(buf), PSTR("UTC time: %s\n"), timeClient.getFormattedTime().c_str());
  server.sendContent(buf);

  if (timeClient.isTimeSet()) {
    unsigned long epochTime = timeClient.getEpochTime();
    unsigned long localTime = epochTime + getTotalOffset(epochTime);
    snprintf_P(buf, sizeof(buf), PSTR("Local time: %02d:%02d:%02d\n"), (int)((localTime/3600)%24), (int)((localTime/60)%60), (int)(localTime%60));
    server.sendContent(buf);
  }

  snprintf_P(buf, sizeof(buf), PSTR("Attempts: %d\nSuccesses: %d\nLast error: %s\n
"), ntpAttempts, ntpSuccesses, lastError.c_str()); server.sendContent(buf); server.sendContent_P(PSTR("

Timezone & DST

"));
  snprintf_P(buf, sizeof(buf), PSTR("Base offset: %.1f hours (%ld seconds)\nDST enabled: %s\n"),
    config.timezone_offset/3600.0, config.timezone_offset, config.dst_enabled ? "Yes" : "No");
  server.sendContent(buf);

  if (config.dst_enabled && timeClient.isTimeSet()) {
    unsigned long epochTime = timeClient.getEpochTime();
    bool inDST = isDST(epochTime);
    snprintf_P(buf, sizeof(buf), PSTR("DST active now: %s\nTotal offset: %.1f hours\n"),
      inDST ? "Yes (+1 hour)" : "No", getTotalOffset(epochTime)/3600.0);
    server.sendContent(buf);
  }

  snprintf_P(buf, sizeof(buf), PSTR("Time format: %s\n
"), config.hour_format_24 ? "24-hour" : "12-hour (AM/PM)"); server.sendContent(buf); server.sendContent_P(PSTR("

Weather

"));
  snprintf_P(buf, sizeof(buf), PSTR("Enabled: %s\nValid data: %s\n"),
    config.weather_enabled ? "Yes" : "No",
    weather.valid ? "Yes" : "No");
  server.sendContent(buf);

  if (weather.valid) {
    snprintf_P(buf, sizeof(buf), PSTR("Temperature: %.1f C\nWeather code: %d\nWind speed: %.1f km/h\nLast update: %lu sec ago\n"),
      weather.temperature, weather.weathercode, weather.windspeed, (millis() - weather.lastUpdate)/1000);
    server.sendContent(buf);
  }

  snprintf_P(buf, sizeof(buf), PSTR("City: %s\nLocation: %.6f, %.6f\nUpdate interval: %u seconds\n
"), config.city_name, config.latitude, config.longitude, config.weather_interval); server.sendContent(buf); server.sendContent_P(PSTR("

Sunrise/Sunset

"));
  snprintf_P(buf, sizeof(buf), PSTR("Enabled: %s\n"), config.show_sunrise_sunset ? "Yes" : "No");
  server.sendContent(buf);

  if (sunTimes.lastDay != -1) {
    snprintf_P(buf, sizeof(buf), PSTR("Data available\nSunrise: %s (%d min)\nSunset: %s (%d min)\nLast update day: %d\n
"), sunTimes.sunrise, sunTimes.sunriseMinutes, sunTimes.sunset, sunTimes.sunsetMinutes, sunTimes.lastDay); } else { snprintf_P(buf, sizeof(buf), PSTR("No data\n")); } server.sendContent(buf); server.sendContent_P(PSTR("

Display

"));
  const char* modeStr = (displayMode == 0) ? " (Time)" : (displayMode == 1) ? " (Weather)" : " (Sun times)";
  snprintf_P(buf, sizeof(buf), PSTR("Current mode: %d%s\nRotation interval: %u seconds\nBrightness: %d (0-7)\nShow weather: %s\nShow sun times: %s\nNTP synced: %s\n
"), displayMode, modeStr, config.display_rotation_sec, config.brightness, config.show_weather ? "Yes" : "No", config.show_sunrise_sunset ? "Yes" : "No", timeClient.isTimeSet() ? "Yes" : "No"); server.sendContent(buf); server.sendContent_P(PSTR("

System

"));
  snprintf_P(buf, sizeof(buf), PSTR("Uptime: %lu seconds\nFree heap: %u bytes\nChip ID: %X\nFlash size: %u bytes\nSDK version: %s\n
"), millis()/1000, ESP.getFreeHeap(), ESP.getChipId(), ESP.getFlashChipSize(), ESP.getSdkVersion()); server.sendContent(buf); server.sendContent_P(DEBUG_HTML_FOOTER); server.sendContent(""); } void ICACHE_FLASH_ATTR handleTestNTP() { testInternetConnectivity(); updateNTPTime(); server.sendHeader("Location", "/debug"); server.send(303); } void ICACHE_FLASH_ATTR handleTestDisplay() { uint8_t data[] = {0xFF, 0xFF, 0xFF, 0xFF}; displaySegments(data); delay(3000); server.sendHeader("Location", "/debug"); server.send(303); } // PROGMEM templates for handleConfig() const char CONFIG_HTML_HEADER[] PROGMEM = "" "" "" "Configuration" "" "" "
" "

Configuration

" "
"; const char CONFIG_HTML_FOOTER[] PROGMEM = "" "
" "

Back to Home

" "
"; void ICACHE_FLASH_ATTR handleConfig() { char buf[150]; server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, "text/html", ""); server.sendContent_P(CONFIG_HTML_HEADER); snprintf_P(buf, sizeof(buf), PSTR(""), config.ssid); server.sendContent(buf); snprintf_P(buf, sizeof(buf), PSTR(""), config.password); server.sendContent(buf); snprintf_P(buf, sizeof(buf), PSTR(""), config.timezone_offset); server.sendContent(buf); snprintf_P(buf, sizeof(buf), PSTR(""), config.brightness); server.sendContent(buf); snprintf_P(buf, sizeof(buf), PSTR(""), config.hostname); server.sendContent(buf); server.sendContent_P(PSTR("

Weather Settings

")); snprintf_P(buf, sizeof(buf), PSTR(""), config.city_name); server.sendContent(buf); snprintf_P(buf, sizeof(buf), PSTR(""), config.latitude); server.sendContent(buf); snprintf_P(buf, sizeof(buf), PSTR(""), config.longitude); server.sendContent(buf); snprintf_P(buf, sizeof(buf), PSTR(""), config.weather_interval); server.sendContent(buf); server.sendContent_P(PSTR("

Display Settings

")); snprintf_P(buf, sizeof(buf), PSTR(""), config.display_rotation_sec); server.sendContent(buf); server.sendContent_P(CONFIG_HTML_FOOTER); server.sendContent(""); } // Validate SSID: 1-31 printable ASCII chars, not all-same-char (likely fuzz garbage) static bool isValidSSID(const String& s) { size_t n = s.length(); if (n == 0 || n > 31) return false; char first = s[0]; bool allSame = true; for (size_t i = 0; i < n; i++) { char c = s[i]; if (c < 0x20 || c > 0x7E) return false; // non-printable if (c != first) allSame = false; } return !allSame; // reject "AAAAA...", "BBBBB...", etc. } void ICACHE_FLASH_ATTR handleConfigSave() { // Track if reboot is required (only WiFi/network changes need it) bool needsRestart = false; // Validate before saving — reject obviously bad input rather than brick the device if (server.hasArg("ssid")) { String s = server.arg("ssid"); if (!isValidSSID(s)) { server.send(400, "text/plain", "Invalid SSID (1-31 printable chars, not all-same)"); return; } if (s != String(config.ssid)) needsRestart = true; safeStringCopy(s, config.ssid, sizeof(config.ssid)); } if (server.hasArg("password")) { String p = server.arg("password"); if (p.length() > 63) { server.send(400, "text/plain", "Password too long (max 63 chars)"); return; } if (p != String(config.password)) needsRestart = true; safeStringCopy(p, config.password, sizeof(config.password)); } if (server.hasArg("timezone")) { long tz = server.arg("timezone").toInt(); config.timezone_offset = constrain(tz, -43200L, 43200L); // ±12h } if (server.hasArg("brightness")) { config.brightness = constrain(server.arg("brightness").toInt(), 0, 7); } if (server.hasArg("hostname")) { String h = server.arg("hostname"); if (h.length() == 0 || h.length() > 31) { server.send(400, "text/plain", "Invalid hostname length (1-31)"); return; } if (h != String(config.hostname)) needsRestart = true; // mDNS bind on boot safeStringCopy(h, config.hostname, sizeof(config.hostname)); } if (server.hasArg("city_name")) { String c = server.arg("city_name"); if (c.length() > 31) { server.send(400, "text/plain", "City name too long (max 31)"); return; } safeStringCopy(c, config.city_name, sizeof(config.city_name)); } if (server.hasArg("latitude")) { float lat = server.arg("latitude").toFloat(); config.latitude = constrain(lat, -90.0f, 90.0f); } if (server.hasArg("longitude")) { float lon = server.arg("longitude").toFloat(); config.longitude = constrain(lon, -180.0f, 180.0f); } if (server.hasArg("weather_interval")) { long wi = server.arg("weather_interval").toInt(); config.weather_interval = constrain(wi, 60L, 86400L); // 1min to 1day } if (server.hasArg("ntp_interval")) { long ni = server.arg("ntp_interval").toInt(); config.ntp_interval = constrain(ni, 60L, 86400L); } if (server.hasArg("ntp_server")) { String n = server.arg("ntp_server"); if (n.length() == 0 || n.length() > 63) { server.send(400, "text/plain", "Invalid NTP server (1-63 chars)"); return; } if (n != String(config.ntp_server)) needsRestart = true; // NTPClient re-init safeStringCopy(n, config.ntp_server, sizeof(config.ntp_server)); } if (server.hasArg("display_rotation_sec")) { config.display_rotation_sec = constrain(server.arg("display_rotation_sec").toInt(), 1, 60); } if (server.hasArg("display_orientation")) { config.display_orientation = constrain(server.arg("display_orientation").toInt(), 0, 3); display.setRotation(config.display_orientation); } saveConfig(); // Only restart for WiFi/network changes; other settings apply live if (needsRestart) { server.send(200, "text/html", F("" "" "

Configuration Saved!

" "

WiFi/network changed — device will reboot in 5 seconds...

")); delay(1000); ESP.restart(); } else { server.send(200, "text/html", F("" "" "

Configuration Saved

" "

Applied without reboot. Returning to main page...

")); } } void ICACHE_FLASH_ATTR handleAPITime() { char buf[256]; server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, "application/json", ""); snprintf_P(buf, sizeof(buf), PSTR("{\"time\":\"%s\",\"hours\":%d,\"minutes\":%d,\"seconds\":%d,\"epoch\":%lu}"), timeClient.getFormattedTime().c_str(), timeClient.getHours(), timeClient.getMinutes(), timeClient.getSeconds(), timeClient.getEpochTime()); server.sendContent(buf); server.sendContent(""); } void ICACHE_FLASH_ATTR handleAPIStatus() { char buf[256]; server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, "application/json", ""); snprintf_P(buf, sizeof(buf), PSTR("{\"wifi\":{\"ssid\":\"%s\",\"ip\":\"%s\",\"rssi\":%d,\"hostname\":\"%s\"},"), WiFi.SSID().c_str(), WiFi.localIP().toString().c_str(), WiFi.RSSI(), config.hostname); server.sendContent(buf); snprintf_P(buf, sizeof(buf), PSTR("\"time\":{\"current\":\"%s\",\"timezone_offset\":%ld,\"ntp_synced\":%s},"), timeClient.getFormattedTime().c_str(), config.timezone_offset, timeClient.isTimeSet() ? "true" : "false"); server.sendContent(buf); snprintf_P(buf, sizeof(buf), PSTR("\"system\":{\"uptime\":%lu,\"free_heap\":%u,\"chip_id\":\"%x\"}}"), millis() / 1000, ESP.getFreeHeap(), ESP.getChipId()); server.sendContent(buf); server.sendContent(""); } void ICACHE_FLASH_ATTR handleAPIDebug() { char buf[256]; char errBuf[80]; server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, "application/json", ""); // Clamp lastError to prevent snprintf truncation causing malformed JSON strncpy(errBuf, lastError.c_str(), sizeof(errBuf) - 1); errBuf[sizeof(errBuf) - 1] = '\0'; snprintf_P(buf, sizeof(buf), PSTR("{\"internet_connected\":%s,\"ntp_attempts\":%d,\"ntp_successes\":%d,\"last_error\":\"%s\",\"gateway\":\"%s\",\"dns\":\"%s\"}"), internetConnected ? "true" : "false", ntpAttempts, ntpSuccesses, errBuf, WiFi.gatewayIP().toString().c_str(), WiFi.dnsIP().toString().c_str()); server.sendContent(buf); server.sendContent(""); } void ICACHE_FLASH_ATTR handleAPIWeather() { char buf[256]; server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, "application/json", ""); snprintf_P(buf, sizeof(buf), PSTR("{\"enabled\":%s,\"valid\":%s,\"temperature\":%.1f,\"weathercode\":%d,\"windspeed\":%.1f,\"last_update\":%lu,"), config.weather_enabled ? "true" : "false", weather.valid ? "true" : "false", weather.temperature, weather.weathercode, weather.windspeed, weather.lastUpdate); server.sendContent(buf); snprintf_P(buf, sizeof(buf), PSTR("\"sunrise\":\"%s\",\"sunset\":\"%s\",\"sunrise_minutes\":%d,\"sunset_minutes\":%d}"), sunTimes.sunrise, sunTimes.sunset, sunTimes.sunriseMinutes, sunTimes.sunsetMinutes); server.sendContent(buf); server.sendContent(""); } void ICACHE_FLASH_ATTR handleAPIConfigExport() { String json = "{"; json += "\"firmware_version\":\"" FIRMWARE_VERSION "\","; json += "\"magic\":\"0x" + String(config.magic, HEX) + "\","; json += "\"ssid\":\"" + String(config.ssid) + "\","; json += "\"password\":\"" + String(config.password) + "\","; json += "\"timezone_offset\":" + String(config.timezone_offset) + ","; json += "\"dst_enabled\":" + String(config.dst_enabled ? "true" : "false") + ","; json += "\"brightness\":" + String(config.brightness) + ","; json += "\"ntp_server\":\"" + String(config.ntp_server) + "\","; json += "\"ntp_interval\":" + String(config.ntp_interval) + ","; json += "\"hour_format_24\":" + String(config.hour_format_24 ? "true" : "false") + ","; json += "\"hostname\":\"" + String(config.hostname) + "\","; json += "\"latitude\":" + String(config.latitude, 6) + ","; json += "\"longitude\":" + String(config.longitude, 6) + ","; json += "\"city_name\":\"" + String(config.city_name) + "\","; json += "\"weather_enabled\":" + String(config.weather_enabled ? "true" : "false") + ","; json += "\"weather_interval\":" + String(config.weather_interval) + ","; json += "\"display_rotation_sec\":" + String(config.display_rotation_sec) + ","; json += "\"show_weather\":" + String(config.show_weather ? "true" : "false") + ","; json += "\"show_sunrise_sunset\":" + String(config.show_sunrise_sunset ? "true" : "false"); json += "}"; server.sendHeader("Content-Disposition", "attachment; filename=clock-config.json"); server.send(200, "application/json", json); } void ICACHE_FLASH_ATTR handleAPIConfigImport() { if (!server.hasArg("plain")) { server.send(400, "text/plain", "No config data received"); return; } String body = server.arg("plain"); Serial.println("Received config: " + body); // Parse various fields (simplified parsing) int pos; pos = body.indexOf("\"ssid\":\""); if (pos >= 0) { int start = pos + 8; int end = body.indexOf("\"", start); if (end > start) { safeStringCopy(body.substring(start, end), config.ssid, sizeof(config.ssid)); } } pos = body.indexOf("\"password\":\""); if (pos >= 0) { int start = pos + 12; int end = body.indexOf("\"", start); if (end > start) { safeStringCopy(body.substring(start, end), config.password, sizeof(config.password)); } } pos = body.indexOf("\"timezone_offset\":"); if (pos >= 0) { int start = pos + 18; int end = body.indexOf(",", start); if (end < 0) end = body.indexOf("}", start); if (end > start) { config.timezone_offset = body.substring(start, end).toInt(); } } pos = body.indexOf("\"brightness\":"); if (pos >= 0) { int start = pos + 13; int end = body.indexOf(",", start); if (end < 0) end = body.indexOf("}", start); if (end > start) { config.brightness = body.substring(start, end).toInt(); } } pos = body.indexOf("\"hostname\":\""); if (pos >= 0) { int start = pos + 12; int end = body.indexOf("\"", start); if (end > start) { safeStringCopy(body.substring(start, end), config.hostname, sizeof(config.hostname)); } } pos = body.indexOf("\"city_name\":\""); if (pos >= 0) { int start = pos + 13; int end = body.indexOf("\"", start); if (end > start) { safeStringCopy(body.substring(start, end), config.city_name, sizeof(config.city_name)); } } pos = body.indexOf("\"latitude\":"); if (pos >= 0) { int start = pos + 11; int end = body.indexOf(",", start); if (end < 0) end = body.indexOf("}", start); if (end > start) { config.latitude = body.substring(start, end).toFloat(); } } pos = body.indexOf("\"longitude\":"); if (pos >= 0) { int start = pos + 12; int end = body.indexOf(",", start); if (end < 0) end = body.indexOf("}", start); if (end > start) { config.longitude = body.substring(start, end).toFloat(); } } config.magic = CONFIG_MAGIC; saveConfig(); server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"Config imported and saved. Reboot recommended.\"}"); } void ICACHE_FLASH_ATTR handleEEPROMClear() { EEPROM.begin(512); for (int i = 0; i < 512; i++) { EEPROM.write(i, 0xFF); } EEPROM.commit(); EEPROM.end(); Serial.println("EEPROM cleared!"); server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"EEPROM cleared, device will reboot\"}"); delay(1000); ESP.restart(); } void ICACHE_FLASH_ATTR handleReboot() { Serial.println("Reboot requested via web interface"); server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"Device rebooting...\"}"); delay(1000); ESP.restart(); } void ICACHE_FLASH_ATTR handleI2CScan() { String json = "{\"i2c_scan\":{\"devices\":["; int deviceCount = 0; for (uint8_t address = 0x08; address <= 0x77; address++) { Wire.beginTransmission(address); uint8_t error = Wire.endTransmission(); if (error == 0) { if (deviceCount > 0) json += ","; json += "{\"address\":\"0x"; if (address < 16) json += "0"; json += String(address, HEX); json += "\",\"decimal\":" + String(address) + "}"; deviceCount++; } delay(1); } json += "],\"count\":" + String(deviceCount); json += ",\"oled_test\":{"; Wire.beginTransmission(0x3C); json += "\"0x3C\":\"" + String(Wire.endTransmission() == 0 ? "FOUND" : "not found") + "\","; Wire.beginTransmission(0x3D); json += "\"0x3D\":\"" + String(Wire.endTransmission() == 0 ? "FOUND" : "not found") + "\""; json += "}}}"; Serial.println("I2C Scan results: " + json); server.send(200, "application/json", json); } // Setup web server void ICACHE_FLASH_ATTR setupWebServer() { httpUpdater.setup(&server, "/update", "admin", "admin"); server.on("/", HTTP_GET, handleRoot); server.on("/config", HTTP_GET, handleConfig); server.on("/config", HTTP_POST, handleConfigSave); server.on("/debug", HTTP_GET, handleDebug); server.on("/test-ntp", HTTP_GET, handleTestNTP); server.on("/test-display", HTTP_GET, handleTestDisplay); server.on("/api/time", HTTP_GET, handleAPITime); server.on("/api/status", HTTP_GET, handleAPIStatus); server.on("/api/debug", HTTP_GET, handleAPIDebug); server.on("/api/weather", HTTP_GET, handleAPIWeather); server.on("/api/config", HTTP_GET, handleAPIConfigExport); server.on("/api/config", HTTP_POST, handleAPIConfigImport); server.on("/api/eeprom-clear", HTTP_POST, handleEEPROMClear); server.on("/api/reboot", HTTP_POST, handleReboot); server.on("/api/i2c-scan", HTTP_GET, handleI2CScan); server.begin(); Serial.println("Web server started"); if (MDNS.begin(config.hostname)) { Serial.printf("mDNS responder started: %s.local\n", config.hostname); MDNS.addService("http", "tcp", 80); } }