From 8d98e17cbd9b14d2e4fb1ea505687ff1da9c8175 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 7 Sep 2026 19:08:31 -0400 Subject: [PATCH] refactor(#274): E4 band 3e -- the web server leaves src.ino; the band-3 parent is satisfied src/web.{h,cpp}: webServer and wifiConnected published; the WiFi/NTP state, the credential placeholders (#245, #99) and the upload/serial-stream buffers private; WEB_SERVER_PORT, WIFI_RECONNECT_INTERVAL; initWiFi, checkWiFi, initWebServer and the 22 handlers -- 1,181 lines, the largest unit. It reads every domain through headers and reaches back into src.ino for nothing. src/fc_version.h: FW_VERSION, in one obvious file (the boot banner, the About screen and every web page show it); CLAUDE.md's pointer follows. display.h takes LVGL_BUF_LINES/LVGL_BUF_SIZE (the diagnostics page shows the buffer size; initLVGL joins that side in band 4). src.ino 5,581 -> 4,387 lines. The acceptance of the band-3 parent task (E4-5), measured: nm on src.ino.cpp.o defines none of the 35 cut-set variables; the reference graph of what remains shows 3 external-linkage variables, 78 authored functions, 77 of them touching no shared state of the file, cut set 0, largest cluster 1. Harness: a constructor-call declaration (WebServer webServer(80);) is a variable. The in-worktree baseline build raced the extraction and compiled post-move code; the before-side artefacts came from a scratch worktree at main, and the record says so. Verified by scripts/verify_extraction.py, PASS: 592 -> 592 symbols, 55 relocated, no linkage change, 12 size deltas net +16 all attributed, all 27 moved functions checked by reference set, unexplained none. .flash.text +28 (inputs +28), .flash.rodata +28 (inputs +60, merged), RAM unchanged. Flash 1,856,634 -> 1,856,690. Both toolchains build. 58 tests pass. Record: section 14 of docs/god-object-inventory.md. --- CLAUDE.md | 2 +- docs/god-object-inventory.md | 32 + scripts/extract_unit.py | 13 +- src/display.h | 4 + src/fc_version.h | 5 + src/src.ino | 1202 +--------------------------------- src/web.cpp | 1181 +++++++++++++++++++++++++++++++++ src/web.h | 38 ++ 8 files changed, 1276 insertions(+), 1201 deletions(-) create mode 100644 src/fc_version.h create mode 100644 src/web.cpp create mode 100644 src/web.h diff --git a/CLAUDE.md b/CLAUDE.md index 75ed064..c44b271 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -268,7 +268,7 @@ FC follows CLAUDE-BASE's SemVer rules (`vMAJOR.MINOR.PATCH`) with one firmware-s - Every successful compile on a branch = commit - After PR merge to `main`, tag the commit that represents a shippable milestone -- `FW_VERSION` constant in `src/src.ino` must match the tag +- `FW_VERSION` constant in `src/fc_version.h` (moved out of `src.ino` by E4 band 3e, #274) must match the tag - **Do NOT use auto-commit version-bump workflows** — per SAFELANE §7 incident 2026-03-29 (auto-commits orphaned by rebase merges). Compute at build time or update `FW_VERSION` manually before tagging. ## Board & Label Conventions (FC overrides) diff --git a/docs/god-object-inventory.md b/docs/god-object-inventory.md index 2ea2dbd..a93b3c5 100644 --- a/docs/god-object-inventory.md +++ b/docs/god-object-inventory.md @@ -439,6 +439,38 @@ Flash 1,856,590 -> 1,856,634 (+44) RAM 103,248 unchanged src.ino 5,805 -> 5,581 lines arduino-cli SUCCESS 58 tests pass ``` +## 14. Band 3e — the web server; #264 is met + +**What moved.** The web server, whole, into `src/web.{h,cpp}`: `webServer` and `wifiConnected` published; the WiFi and NTP state (`webServerStarted`, `ntpSynced`, `lastWiFiAttempt`, `rtcSyncedFromNTP`), the credential placeholders and `NTP_SERVER` (#245 — the location #99 will replace with runtime storage), and the upload and serial-stream buffers private; `WEB_SERVER_PORT` and `WIFI_RECONNECT_INTERVAL`; `initWiFi`, `checkWiFi`, `initWebServer` and the 22 handlers. 1,181 lines, the largest unit, and it reaches back into `src.ino` for nothing: every domain it reads — GPS, IMU, environment, battery, FRAM, weather, geocaches, settings, RTC, logging, display state, touch — comes through a header that the four earlier children published. `src.ino` 5,581 → 4,387 lines. + +**#264's acceptance, measured on the object:** + +- `nm` on `src.ino.cpp.o` defines **none of the 35 cut-set variables**. +- `python scripts/reference-graph.py` on what remains: 3 external-linkage variables (the button-debounce state, touched only by `handleButtons`), 78 authored functions, 77 of them touching no shared state of the file, **cut set 0, largest cluster 1**. The file is one connected component no longer. What is left is 163 TU-private statics — the LVGL screen objects and their labels — plus `setup`, `loop`, the buttons, the screen builders and their callbacks, and the LVGL glue: band 4's material, all of it. + +**Calls made under the grant, for review:** `src/fc_version.h` now holds `FW_VERSION`, because `src.ino` (boot banner, About screen) and `web.cpp` (every page) both show it, and one obvious file is where a bump belongs; the `CLAUDE.md` Versioning pointer follows. `LVGL_BUF_LINES`/`LVGL_BUF_SIZE` went to `display.h`: the diagnostics page reports the buffer size, and `initLVGL` joins the display side in band 4. The WiFi/NTP flags and the credentials are private, not published — nothing outside the unit reads them, and a header is no place for credentials even as placeholders. + +**Harness, this band:** a constructor-call declaration (`WebServer webServer(WEB_SERVER_PORT);`) is recognised as a variable. **A process fault, recorded:** the in-worktree baseline build was started before the extraction ran and PlatformIO compiles libraries first, so it picked up post-move source — and failed on it, which is how it was noticed. The before-side artefacts for this proof came from a scratch worktree at `main` (7bfefa7), whose Flash figure matches the 3d merge exactly. Rule for band 4: capture the baseline before touching the tree, or build it elsewhere. + +**Measured** — `scripts/verify_extraction.py`, PASS: + +``` +symbols 592 before -> 592 after, union of 19 objects + 55 relocated to web (src.ino's static initialiser among them: its last dynamically + initialised object, the WebServer, left with it -- src.ino now has none) + linkage changes 0, removed none, added none +deltas 12 symbols, net +16: 6 moved functions with identical references, 6 identical references + unexplained: none +moved 27 functions checked by reference set: every reference kept +sections .flash.text +28: our input sections +28 (.text +12, literal pools +16) + .flash.rodata +28: input +60 (string literals of 25 functions split out), merged + .dram0.bss +0 +Flash 1,856,634 -> 1,856,690 (+56) RAM 103,248 unchanged +src.ino 5,581 -> 4,387 lines arduino-cli SUCCESS 58 tests pass +``` + +**Band 3 in total** (#270–#274, five PRs): 99 functions and 122 variables left `src.ino` in 14 units (fram, settings, rtc, gps, imu, env, battery, weather, geocache, oled, display, ui_state, touch, web), plus `fc_version.h`; `src.ino` went from 7,986 lines before 3a to 4,387; Flash from 1,856,026 to 1,856,690 (+664, every byte traced: seven static initialisers, function relaxation and literal pools, string-literal splitting, alignment); RAM from 103,232 to 103,248 (+16, 3a's `.bss` packing). Every extraction was proven behaviour-neutral by symbol set and, from 3c on, by every moved function's reference set. Nothing was flashed: hardware verification of the whole sequence is Epic #212's integration test. + --- ## Appendix — the full tables diff --git a/scripts/extract_unit.py b/scripts/extract_unit.py index 22dd886..4ffd13f 100644 --- a/scripts/extract_unit.py +++ b/scripts/extract_unit.py @@ -129,7 +129,9 @@ def is_static(lines: list[str], start: int) -> bool: # in `*`. The name follows, optionally an array suffix, then `=`, `;` or `,`. # Column 0 only: file-scope declarations are, and an indented # `else gprmcFixThisCycle = true;` reads as a declaration otherwise (band 3b). -VAR_RE_TMPL = r"^(?:static\s+)?(?:volatile\s+)?(?:const\s+)?(?:[A-Za-z_][\w:<>]*\*?\s+)+\**{name}\s*(?:\[[^\]]*\])*\s*(?:=|;|,)" +VAR_RE_TMPL = r"^(?:static\s+)?(?:volatile\s+)?(?:const\s+)?(?:[A-Za-z_][\w:<>]*\*?\s+)+\**{name}\s*(?:\[[^\]]*\])*\s*(?:=|;|,|\()" +# the trailing `(` is a constructor-call declaration, `WebServer webServer(WEB_SERVER_PORT);` +# (band 3e); a prototype never collides because variables are looked up by name # A name that is not the first declarator on its line: `float a = 0, b = 0, c;` @@ -181,10 +183,17 @@ def split_top(body: str) -> list[str]: def strip_initialiser(seg: str) -> str: - out = "" + """`a = 0` -> `a`; `webServer(WEB_SERVER_PORT)` -> `webServer` (constructor call).""" + out, depth = "", 0 for ch, top in _walk(seg): if ch == "=" and top: break + if ch == "(" and depth == 0 and not out.endswith("["): + break + if ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 out += ch return out.rstrip() diff --git a/src/display.h b/src/display.h index 5421f23..27b803f 100644 --- a/src/display.h +++ b/src/display.h @@ -9,6 +9,10 @@ #define OLED_SLEEP_TIMEOUT 180000 // 3 minutes for OLED (high burn-in risk) #define TFT_BL_PWM 255 // Default brightness (0=off, 255=full) +// Two 480x50 partial-render buffers in PSRAM (~48KB each, 96KB total) +#define LVGL_BUF_LINES 50 +#define LVGL_BUF_SIZE (480 * LVGL_BUF_LINES * sizeof(uint16_t)) + // Colors (RGB565) -- the TFT boot screen in setup() and initTFT use these #define COLOR_BG 0x0000 // Black #define COLOR_TEXT 0xFFFF // White diff --git a/src/fc_version.h b/src/fc_version.h new file mode 100644 index 0000000..9d1214f --- /dev/null +++ b/src/fc_version.h @@ -0,0 +1,5 @@ +#pragma once +// fc_version.h -- the firmware version, in one place (E4, #274). +// Bump it here before tagging; it must match the tag (CLAUDE.md, Versioning). +// Shown on the TFT boot banner, the About screen and every web page. +#define FW_VERSION "0.51.1" diff --git a/src/src.ino b/src/src.ino index 25d3230..a6e9489 100644 --- a/src/src.ino +++ b/src/src.ino @@ -24,8 +24,7 @@ * Issues: #44, #46, #47 */ -// Firmware version -#define FW_VERSION "0.51.1" +#include "fc_version.h" // FW_VERSION #include #include @@ -70,6 +69,7 @@ #include "display.h" #include "ui_state.h" #include "touch.h" +#include "web.h" // Set to 1 to enable LVGL test rendering (label in corner during boot). // Set to 0 for normal operation where sprite pipeline handles all rendering. @@ -79,9 +79,8 @@ static lv_display_t* lvglDisplay = NULL; // Two 480x50 partial-render buffers in PSRAM (~48KB each, 96KB total) -// Allocated dynamically in initLVGL() via heap_caps_malloc(MALLOC_CAP_SPIRAM) -#define LVGL_BUF_LINES 50 -#define LVGL_BUF_SIZE (480 * LVGL_BUF_LINES * sizeof(uint16_t)) +// Allocated dynamically in initLVGL() via heap_caps_malloc(MALLOC_CAP_SPIRAM); +// LVGL_BUF_LINES / LVGL_BUF_SIZE are in display.h (the diagnostics page shows them) static uint8_t* lvglBuf1 = NULL; static uint8_t* lvglBuf2 = NULL; @@ -104,17 +103,7 @@ static lv_style_t fcStyleError; // Red, MD (18) — errors // ============== Configuration ============== -// WiFi credentials -const char* WIFI_SSID_1 = "REDACTED_SSID_1"; -const char* WIFI_PASS_1 = "REDACTED_WIFI_PASSWORD"; -const char* WIFI_SSID_2 = "REDACTED_SSID_2"; -const char* WIFI_PASS_2 = "REDACTED_WIFI_PASSWORD"; -const char* WIFI_SSID_3 = "REDACTED_SSID_3"; -const char* WIFI_PASS_3 = "REDACTED_HOTSPOT_PASSWORD"; -// NTP configuration -const char* NTP_SERVER = "pool.ntp.org"; -// Timezone handled by POSIX TZ string — see posixTZ global (#98) // TFT Display pins (ST7796U 3.5" IPS, SPI) — TFT_CS=18, TFT_DC=17, TFT_RST=16 are // defined in platformio.ini build_flags under USER_SETUP_LOADED=1, which tells @@ -138,12 +127,6 @@ const char* NTP_SERVER = "pool.ntp.org"; // Debounce time in ms #define DEBOUNCE_MS 200 -// WiFi reconnect interval (ms) -#define WIFI_RECONNECT_INTERVAL 30000 - - -// Web server configuration -#define WEB_SERVER_PORT 80 // Serial log to SD (#59) #define LOG_FLUSH_INTERVAL 5000 // Flush SD buffer every 5 seconds (ms) @@ -179,25 +162,12 @@ static const char* settingsMenuItems[] = { }; -// Web Server -WebServer webServer(WEB_SERVER_PORT); - // ============== Global State ============== // Button debounce unsigned long lastButtonPress = 0; -// WiFi reconnect tracking -unsigned long lastWiFiAttempt = 0; - - -bool wifiConnected = false; -bool ntpSynced = false; -bool webServerStarted = false; - -// RTC sync tracking - avoid repeated syncs -bool rtcSyncedFromNTP = false; // RTC was synced from NTP this session // Periodic status logging static unsigned long lastStatusLog = 0; @@ -214,11 +184,6 @@ bool buttonCLongPressHandled = false; #define LONG_PRESS_MS 800 // 800ms for long press -// ============== Serial Ring Buffer (moved before setup for use in init) ============== - -// Web serial streaming position tracker -static uint16_t webSerialReadPos = 0; - // ============== LVGL Tick Callback ============== // LVGL needs a tick source to track elapsed time for animations/timers. @@ -3795,1165 +3760,6 @@ void initSD() { } -// ============== GPX File Parser (#70) ============== - -// GPX upload state (for multipart handler) -static String gpxUploadBuffer; -static bool gpxUploadSuccess = false; -static String gpxUploadError; - -// ============== Weather Logging Functions ============== - -// ─── Settings Persistence (#98) ───────────────────────────────────────────── - -void initWiFi() { - logPrint("Connecting to WiFi"); - - // Try networks in order - const char* ssids[] = {WIFI_SSID_1, WIFI_SSID_2, WIFI_SSID_3}; - const char* passwords[] = {WIFI_PASS_1, WIFI_PASS_2, WIFI_PASS_3}; - - for (int net = 0; net < 3; net++) { - logPrintf(" [%s]", ssids[net]); - - WiFi.begin(ssids[net], passwords[net]); - - int attempts = 0; - while (WiFi.status() != WL_CONNECTED && attempts < 15) { - delay(500); - logPrint("."); - attempts++; - } - - if (WiFi.status() == WL_CONNECTED) break; - } - - if (WiFi.status() == WL_CONNECTED) { - wifiConnected = true; - logPrintln(" OK"); - logPrintf(" IP: %s\n", WiFi.localIP().toString().c_str()); - - // Sync NTP time - logPrint("Syncing NTP time... "); - configTime(0, 0, NTP_SERVER); // NTP provides UTC; POSIX TZ handles offset (#98) - applyTimezone(); // Ensure TZ is set after configTime - - struct tm timeinfo; - if (getLocalTime(&timeinfo, 5000)) { - ntpSynced = true; - logPrintln("OK"); - - // Sync RTC from NTP (if GPS hasn't already synced it) - if (!rtcSyncedFromGPS && !rtcSyncedFromNTP) { - syncRTCFromSystemTime("NTP"); - rtcSyncedFromNTP = true; - } - } else { - logPrintln("FAILED"); - } - } else { - logPrintln(" FAILED"); - } - - lastWiFiAttempt = millis(); -} - -void checkWiFi() { - // Update connection status - wifiConnected = (WiFi.status() == WL_CONNECTED); - - // Attempt reconnect if disconnected - if (!wifiConnected && (millis() - lastWiFiAttempt > WIFI_RECONNECT_INTERVAL)) { - logPrintln("WiFi disconnected, attempting reconnect..."); - WiFi.reconnect(); - lastWiFiAttempt = millis(); - - // Wait briefly for connection - int attempts = 0; - while (WiFi.status() != WL_CONNECTED && attempts < 10) { - delay(500); - attempts++; - } - - wifiConnected = (WiFi.status() == WL_CONNECTED); - if (wifiConnected) { - logPrintln("WiFi reconnected!"); - // Start web server if not already running - if (!webServerStarted) { - initWebServer(); - } - } - } - - // Ensure web server is started if WiFi is connected - if (wifiConnected && !webServerStarted) { - initWebServer(); - } -} - -// ============== Web Server Handlers ============== - -void handleWebRoot() { - String html = ""; - html += "Field Compass v"; - html += FW_VERSION; - html += ""; - html += ""; - html += ""; - html += ""; - html += "

Field Compass v"; - html += FW_VERSION; - html += "

"; - html += "Operational Info"; - html += "GPS"; - html += "Environment"; - html += "IMU / Compass"; - html += "Diagnostics"; - html += "Geocache Manager"; - html += "Serial Monitor"; - html += "Serial Logs (SD)"; - html += "JSON API"; - html += "

http://fieldcompass.local/

"; - html += ""; - webServer.send(200, "text/html", html); -} - -void handleWebOps() { - String html = "OPS"; - html += ""; - html += ""; - html += "

OPERATIONAL

";
-
-  // Time
-  char buf[64];
-  if (gpsData.timeValid) {
-    struct tm timeinfo;
-    if (getLocalTime(&timeinfo)) {
-      char tbuf[16];
-      formatTimeStr(tbuf, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, true);
-      sprintf(buf, "Time:    %s GPS\n", tbuf);
-    } else {
-      strcpy(buf, "Time:    --:--:-- GPS\n");
-    }
-  } else if (ntpSynced) {
-    struct tm timeinfo;
-    if (getLocalTime(&timeinfo)) {
-      char tbuf[16];
-      formatTimeStr(tbuf, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, true);
-      sprintf(buf, "Time:    %s NTP\n", tbuf);
-    }
-  } else {
-    sprintf(buf, "Time:    --:--:-- N/A\n");
-  }
-  html += buf;
-
-  // Uptime
-  unsigned long totalSec = millis() / 1000;
-  int days = totalSec / 86400;
-  int hours = (totalSec % 86400) / 3600;
-  int mins = (totalSec % 3600) / 60;
-  int secs = totalSec % 60;
-  if (days > 0) {
-    sprintf(buf, "Uptime:  %dd %02d:%02d:%02d\n", days, hours, mins, secs);
-  } else {
-    sprintf(buf, "Uptime:  %02d:%02d:%02d\n", hours, mins, secs);
-  }
-  html += buf;
-
-  // WiFi
-  if (wifiConnected) {
-    html += "WiFi:    " + WiFi.SSID() + " (" + WiFi.localIP().toString() + ")\n";
-  } else {
-    html += "WiFi:    Disconnected\n";
-  }
-
-  // Battery
-  if (batteryAvailable && isBatteryConnected()) {
-    sprintf(buf, "Battery: %.0f%% (%.2fV)\n", battery.cellPercent(), battery.cellVoltage());
-    html += buf;
-  } else if (batteryAvailable) {
-    html += "Battery: USB Only\n";
-  } else {
-    html += "Battery: N/A\n";
-  }
-
-  html += "
Back"; - webServer.send(200, "text/html", html); -} - -void handleWebGPS() { - String html = "GPS"; - html += ""; - html += ""; - html += "

GPS

";
-
-  char buf[64];
-  if (gpsData.valid) {
-    sprintf(buf, "Latitude:  %.6f %c\n", fabs(gpsData.latitude), gpsData.latitude >= 0 ? 'N' : 'S');
-    html += buf;
-    sprintf(buf, "Longitude: %.6f %c\n", fabs(gpsData.longitude), gpsData.longitude >= 0 ? 'E' : 'W');
-    html += buf;
-    sprintf(buf, "Altitude:  %.1f m\n", gpsData.altitude);
-    html += buf;
-    html += "Status:    Fix OK\n";
-  } else if (gpsData.receiving) {
-    html += "Status: Acquiring fix...\n";
-    if (gpsData.timeValid) {
-      struct tm timeinfo;
-      if (getLocalTime(&timeinfo)) {
-        char tbuf[16];
-        formatTimeStr(tbuf, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, true);
-        sprintf(buf, "Time:   %s\n", tbuf);
-      } else {
-        strcpy(buf, "Time:   --:--:--\n");
-      }
-      html += buf;
-    }
-  } else {
-    html += "Status: No GPS data\n";
-  }
-
-  html += "
Back"; - webServer.send(200, "text/html", html); -} - -// GPS debug toggle (#115) — future: move to Settings LVGL screen (#112) -void handleWebGpsDebug() { - gpsDebugEnabled = !gpsDebugEnabled; - logPrintf("[GPS] Runtime debug logging %s\n", gpsDebugEnabled ? "ENABLED" : "DISABLED"); - String json = "{\"gpsDebug\":"; - json += gpsDebugEnabled ? "true" : "false"; - json += "}"; - webServer.send(200, "application/json", json); -} - -// GPS soft reset (#115) -void handleWebGpsReset() { - logPrintf("[GPS] Soft reset triggered via web\n"); - - // Clear all GPS state - gpsData.valid = false; - gpsData.receiving = false; - gpsData.latitude = 0; - gpsData.longitude = 0; - gpsData.altitude = 0; - gpsData.hdop = 99.0; - gpsData.satellites = 0; - gpsData.speedKnots = 0; - gpsData.timeValid = false; - gpsData.dateValid = false; - - // Reset tracking flags - gpsHadFirstReceive = false; - gpsHadFirstFix = false; - gpsFirstReceiveTime = 0; - gpsFirstFixTime = 0; - gpsSignalLostTime = 0; - gpsLastByteTime = 0; - gprmcFixThisCycle = false; - gnrmcFixThisCycle = false; - lastRmcCycleTime = 0; - - // Flush Serial1 RX buffer - while (Serial1.available()) Serial1.read(); - - // Send hot-restart command (MediaTek MTK chipsets — ignored by others) - Serial1.println("$PMTK101*32"); - - webServer.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"GPS reset\"}"); -} - -void handleWebEnv() { - String html = "ENV"; - html += ""; - html += ""; - html += "

ENVIRONMENT

";
-
-  char buf[80];
-  if (bmeAvailable || shtAvailable) {
-    // SHT41 preferred for temp/humidity (#48)
-    float tempC = shtAvailable ? shtData.temperature : envData.temperature;
-    float tempF = tempC * 9.0 / 5.0 + 32.0;
-    float humid = shtAvailable ? shtData.humidity : envData.humidity;
-    const char* src = shtAvailable ? "SHT41" : "BME688";
-    if (useFahrenheit)
-      sprintf(buf, "Temp:     %.1fF (%.1fC) [%s]\n", tempF, tempC, src);
-    else
-      sprintf(buf, "Temp:     %.1fC (%.1fF) [%s]\n", tempC, tempF, src);
-    html += buf;
-    sprintf(buf, "Humidity: %.1f%% [%s]\n", humid, src);
-    html += buf;
-    if (bmeAvailable) {
-      sprintf(buf, "IAQ:      %.0f [%s]\n", envData.iaq, getIaqAccuracyText(envData.iaqAccuracy));
-      html += buf;
-      sprintf(buf, "CO2:      %.0f ppm\n", envData.co2Equivalent);
-      html += buf;
-      sprintf(buf, "Pressure: %.1f hPa (%.2f\")\n", envData.pressure, hPaToInHg(envData.pressure));
-      html += buf;
-      sprintf(buf, "Forecast: %s %s\n", getTrendArrow(), weatherTrend.forecast);
-      html += buf;
-    }
-  } else {
-    html += "BME688 not available\n";
-  }
-
-  html += "
Back"; - webServer.send(200, "text/html", html); -} - -void handleWebIMU() { - String html = "IMU"; - html += ""; - html += ""; - html += "

IMU / COMPASS

";
-
-  char buf[64];
-  if (imuAvailable && magAvailable) {
-    sprintf(buf, "Heading: %.0f %s\n", imuData.heading, getCardinal(imuData.heading));
-    html += buf;
-    sprintf(buf, "Roll:    %.0f deg\n", imuData.roll);
-    html += buf;
-    sprintf(buf, "Pitch:   %.0f deg\n", imuData.pitch);
-    html += buf;
-    sprintf(buf, "Accel:   %.2f m/s2\n", imuData.accelMag);
-    html += buf;
-  } else {
-    html += "IMU not available\n";
-  }
-
-  html += "
Back"; - webServer.send(200, "text/html", html); -} - -void handleWebDiags() { - String html = "DIAGS"; - html += ""; - html += ""; - html += "

DIAGNOSTICS

";
-
-  char buf[80];
-
-  // Firmware info
-  html += "=== Firmware ===\n";
-  sprintf(buf, "Version:  %s\n", FW_VERSION);
-  html += buf;
-  html += "Build:    Field Compass Dual\n";
-  {
-    unsigned long totalSec = millis() / 1000;
-    int days = totalSec / 86400;
-    int hours = (totalSec % 86400) / 3600;
-    int mins = (totalSec % 3600) / 60;
-    int secs = totalSec % 60;
-    if (days > 0) {
-      sprintf(buf, "Uptime:   %dd %02d:%02d:%02d\n", days, hours, mins, secs);
-    } else {
-      sprintf(buf, "Uptime:   %02d:%02d:%02d\n", hours, mins, secs);
-    }
-    html += buf;
-  }
-  if (wifiConnected) {
-    html += "WiFi IP:  ";
-    html += WiFi.localIP().toString();
-    html += "\n";
-  } else {
-    html += "WiFi IP:  Disconnected\n";
-  }
-  html += "mDNS:     fieldcompass.local\n";
-
-  // BSEC State
-  html += "\n=== BSEC State ===\n";
-  sprintf(buf, "Loaded:   %s\n", bsecStateLoaded ? "Yes" : "No");
-  html += buf;
-  sprintf(buf, "Saved:    %s\n", bsecStateSaved ? "Yes" : "No");
-  html += buf;
-  sprintf(buf, "Accuracy: %d (%s)\n", envData.iaqAccuracy, getIaqAccuracyText(envData.iaqAccuracy));
-  html += buf;
-
-  // Weather Log
-  html += "\n=== Weather Log ===\n";
-  sprintf(buf, "In Memory: %d readings\n", weatherHistoryCount);
-  html += buf;
-  sprintf(buf, "Files:     %d\n", weatherLogFileCount);
-  html += buf;
-  sprintf(buf, "Total:     %d entries\n", weatherLogEntryCount);
-  html += buf;
-
-  // System
-  html += "\n=== System ===\n";
-  sprintf(buf, "Free Heap: %lu bytes\n", (unsigned long)ESP.getFreeHeap());
-  html += buf;
-  sprintf(buf, "Heap Size: %lu bytes\n", (unsigned long)ESP.getHeapSize());
-  html += buf;
-  sprintf(buf, "CPU Freq:  %lu MHz\n", (unsigned long)ESP.getCpuFreqMHz());
-  html += buf;
-  if (psramFound()) {
-    sprintf(buf, "PSRAM:     %luK / %luK\n",
-            (unsigned long)ESP.getFreePsram() / 1024,
-            (unsigned long)ESP.getPsramSize() / 1024);
-  } else {
-    sprintf(buf, "PSRAM:     Not detected\n");
-  }
-  html += buf;
-  sprintf(buf, "Render:    LVGL 9.5 (2x%dKB PSRAM buffers)\n", LVGL_BUF_SIZE / 1024);
-  html += buf;
-
-  // Sensors
-  html += "\n=== Sensors ===\n";
-  sprintf(buf, "BME688:  %s\n", bmeAvailable ? "OK" : "N/A");
-  html += buf;
-  sprintf(buf, "SHT41:   %s\n", shtAvailable ? "OK" : "N/A");
-  html += buf;
-  sprintf(buf, "IMU:     %s\n", imuAvailable ? "OK" : "N/A");
-  html += buf;
-  sprintf(buf, "Mag:     %s\n", magAvailable ? "OK" : "N/A");
-  html += buf;
-  sprintf(buf, "Battery: %s\n", batteryAvailable ? "OK" : "N/A");
-  html += buf;
-  sprintf(buf, "SD Card: %s\n", sdAvailable ? "OK" : "N/A");
-  html += buf;
-  sprintf(buf, "OLED:    %s\n", oledAvailable ? "OK" : "N/A");
-  html += buf;
-  sprintf(buf, "Touch:   %s\n", touchAvailable ? "OK (FT6336U)" : "N/A");
-  html += buf;
-  if (framAvailable) {
-    sprintf(buf, "FRAM:    OK (256KB) Batt:%d/%d Wx:%d/%d %s\n",
-            framHeader.battCount, FRAM_BATT_COUNT,
-            framHeader.wxCount, FRAM_WX_COUNT,
-            (framHeader.flags & 0x01) ? "DIRTY" : "Clean");
-  } else {
-    sprintf(buf, "FRAM:    N/A\n");
-  }
-  html += buf;
-
-  // Mag Calibration
-  html += "\n=== Mag Calibration ===\n";
-  if (magCalibrated) {
-    html += "Status:   Calibrated\n";
-    sprintf(buf, "Offsets:  X=%.2f Y=%.2f Z=%.2f\n", magOffsetX, magOffsetY, magOffsetZ);
-    html += buf;
-  } else if (magCalibrating) {
-    unsigned long elapsed = (millis() - magCalStartTime) / 1000;
-    sprintf(buf, "Status:   Calibrating (%lus / 15s)\n", elapsed);
-    html += buf;
-  } else {
-    html += "Status:   Not Calibrated\n";
-    html += "          (Settings > Compass Cal)\n";
-  }
-
-  // Temp comparison SHT41 vs BME688 (#48)
-  html += "\n=== Temperature Comparison ===\n";
-  if (shtAvailable) {
-    float shtF = shtData.temperature * 9.0 / 5.0 + 32.0;
-    if (useFahrenheit)
-      sprintf(buf, "SHT41:   %.1fF (%.1fC)\n", shtF, shtData.temperature);
-    else
-      sprintf(buf, "SHT41:   %.1fC (%.1fF)\n", shtData.temperature, shtF);
-    html += buf;
-    sprintf(buf, "SHT41 H: %.1f%%\n", shtData.humidity);
-    html += buf;
-  } else {
-    html += "SHT41:   N/A\n";
-  }
-  if (bmeAvailable) {
-    float bmeF = envData.temperature * 9.0 / 5.0 + 32.0;
-    if (useFahrenheit)
-      sprintf(buf, "BME688:  %.1fF (%.1fC)\n", bmeF, envData.temperature);
-    else
-      sprintf(buf, "BME688:  %.1fC (%.1fF)\n", envData.temperature, bmeF);
-    html += buf;
-    sprintf(buf, "BME688 H: %.1f%%\n", envData.humidity);
-    html += buf;
-  } else {
-    html += "BME688:  N/A\n";
-  }
-  if (shtAvailable && bmeAvailable) {
-    float deltaF = (shtData.temperature - envData.temperature) * 9.0 / 5.0;
-    sprintf(buf, "Delta:   %+.1fF (SHT - BME)\n", deltaF);
-    html += buf;
-  }
-
-  // GPS (#68)
-  html += "\n=== GPS ===\n";
-
-  // Status: Fix OK / Acquiring / No Data
-  const char* gpsStatus;
-  if (gpsData.valid) {
-    gpsStatus = "Fix OK";
-  } else if (gpsData.receiving) {
-    gpsStatus = "Acquiring";
-  } else {
-    gpsStatus = "No Data";
-  }
-  sprintf(buf, "Status:   %s\n", gpsStatus);
-  html += buf;
-
-  // TTFF - retained once acquired
-  if (gpsHadFirstFix) {
-    sprintf(buf, "TTFF:     %lu seconds\n", gpsFirstFixTime / 1000);
-    html += buf;
-  } else {
-    html += "TTFF:     (not yet acquired)\n";
-  }
-
-  // Acquiring/Reacquire time - show if not currently valid
-  if (!gpsData.valid && gpsHadFirstReceive) {
-    if (gpsHadFirstFix && gpsSignalLostTime > 0) {
-      // Lost signal after having fix - show time since signal lost
-      unsigned long reacquiring = (millis() - gpsSignalLostTime) / 1000;
-      sprintf(buf, "Reacquire: %lum %lus\n", reacquiring / 60, reacquiring % 60);
-    } else {
-      // Never had fix, show time since first NMEA data
-      unsigned long elapsed = (millis() - gpsFirstReceiveTime) / 1000;
-      sprintf(buf, "Elapsed:  %lum %lus\n", elapsed / 60, elapsed % 60);
-    }
-    html += buf;
-  }
-
-  html += "
Back"; - webServer.send(200, "text/html", html); -} - -void handleWebSerial() { - String html = "Serial Log"; - html += ""; - html += ""; - html += "

Serial Log

"; - html += "
"; - html += "
"; - html += ""; - html += ""; - html += "Live ▼"; - html += "Back"; - html += "
"; - html += ""; - html += ""; - webServer.send(200, "text/html", html); -} - -void handleWebSerialData() { - // Return buffered serial data since last request (incremental) - String out; - out.reserve(1024); - - uint16_t head = serialRingHead; - uint16_t tail = serialRingTail; - - // If our read pos is behind tail, we lost data - jump to tail - uint16_t dist = (head >= webSerialReadPos) ? - (head - webSerialReadPos) : - (SERIAL_RING_SIZE - webSerialReadPos + head); - if (dist > SERIAL_RING_SIZE - 100) { - webSerialReadPos = tail; - } - - // Read available data - while (webSerialReadPos != head && out.length() < 1024) { - char c = serialRing[webSerialReadPos]; - webSerialReadPos = (webSerialReadPos + 1) % SERIAL_RING_SIZE; - if (c != '\r') { - out += c; - } - } - - webServer.send(200, "text/plain", out); -} - -// GET /logs — List serial log files with download links (#59) -void handleWebLogs() { - String html = "Serial Logs"; - html += ""; - html += ""; - html += "

Serial Logs

"; - - if (!sdAvailable || !SD.exists(LOG_DIR)) { - html += "

No log files available.

"; - } else { - // Flush current buffer before listing - serialLogFlush(); - - html += ""; - - File dir = SD.open(LOG_DIR); - if (dir && dir.isDirectory()) { - File entry = dir.openNextFile(); - while (entry) { - if (!entry.isDirectory()) { - const char* name = entry.name(); - size_t sz = entry.size(); - html += ""; - } - entry.close(); - entry = dir.openNextFile(); - } - dir.close(); - } - html += "
FileSizeAction
"; - html += name; - html += ""; - if (sz >= 1024) { - html += String(sz / 1024.0, 1) + " KB"; - } else { - html += String((unsigned long)sz) + " B"; - } - html += "Download
"; - } - - if (serialLogActive) { - html += "

Currently logging to: "; - html += serialLogFilename; - html += "

"; - } - html += "

Back

"; - webServer.send(200, "text/html", html); -} - -// GET /logs/download?file= — Download a serial log file (#59) -void handleWebLogDownload() { - if (!webServer.hasArg("file")) { - webServer.send(400, "text/plain", "Missing file parameter"); - return; - } - - String filename = webServer.arg("file"); - // Security: prevent path traversal — only allow alphanumeric, underscore, dot, hyphen - for (unsigned int i = 0; i < filename.length(); i++) { - char c = filename[i]; - if (!isalnum(c) && c != '_' && c != '.' && c != '-') { - webServer.send(400, "text/plain", "Invalid filename"); - return; - } - } - - // Flush current buffer if this is the active log file - if (serialLogActive) { - String activeName = String(serialLogFilename).substring(strlen(LOG_DIR) + 1); - if (filename.equals(activeName)) { - serialLogFlush(); - } - } - - String fullPath = String(LOG_DIR) + "/" + filename; - if (!SD.exists(fullPath)) { - webServer.send(404, "text/plain", "File not found"); - return; - } - - File f = SD.open(fullPath, "r"); - if (!f) { - webServer.send(500, "text/plain", "Failed to open file"); - return; - } - - String disposition = "attachment; filename=" + filename; - webServer.sendHeader("Content-Disposition", disposition); - webServer.streamFile(f, "text/plain"); - f.close(); -} - -void handleWebJSON() { - char buf[1024]; - bool battConnected = batteryAvailable && isBatteryConnected(); - // SHT41 preferred for temp/humidity in JSON API (#48) - float jsonTempC = shtAvailable ? shtData.temperature : envData.temperature; - float jsonHumid = shtAvailable ? shtData.humidity : envData.humidity; - snprintf(buf, sizeof(buf), - "{" - "\"gps\":{\"valid\":%s,\"lat\":%.6f,\"lon\":%.6f,\"alt\":%.1f}," - "\"env\":{\"temp\":%.1f,\"humidity\":%.1f,\"pressure\":%.1f,\"iaq\":%.0f,\"iaqQuality\":\"%s\",\"co2\":%.0f,\"accuracy\":%d,\"tempSource\":\"%s\"}," - "\"imu\":{\"heading\":%.1f,\"roll\":%.1f,\"pitch\":%.1f,\"accel\":%.2f}," - "\"system\":{\"uptime\":%lu,\"wifi\":%s,\"battery\":%.1f,\"batteryConnected\":%s,\"heap\":%lu}" - "}", - gpsData.valid ? "true" : "false", gpsData.latitude, gpsData.longitude, gpsData.altitude, - jsonTempC, jsonHumid, envData.pressure, envData.iaq, getIaqQualityText(envData.iaq), envData.co2Equivalent, envData.iaqAccuracy, - shtAvailable ? "SHT41" : "BME688", - imuData.heading, imuData.roll, imuData.pitch, imuData.accelMag, - millis() / 1000, wifiConnected ? "true" : "false", - battConnected ? battery.cellPercent() : -1.0, - battConnected ? "true" : "false", - (unsigned long)ESP.getFreeHeap() - ); - webServer.send(200, "application/json", buf); -} - -// Battery log download endpoint -void handleWebBattLog() { - if (!sdHealth.available) { - webServer.send(404, "text/plain", "SD card not available"); - return; - } - - File f = sdOpenSafe(BATT_LOG_FILE, "r", true); - if (!f) { - webServer.send(404, "text/plain", "Battery log file not found. Wait for data collection."); - return; - } - - // Stream the file with CSV content type for easy download - webServer.sendHeader("Content-Disposition", "attachment; filename=battlog.csv"); - webServer.streamFile(f, "text/csv"); - f.close(); -} - -// Battery log clear endpoint -void handleWebBattLogClear() { - if (!sdHealth.available) { - webServer.send(404, "text/plain", "SD card not available"); - return; - } - - if (SD.exists(BATT_LOG_FILE)) { - SD.remove(BATT_LOG_FILE); - webServer.send(200, "text/plain", "Battery log cleared. New log will start on next cycle."); - } else { - webServer.send(200, "text/plain", "Battery log already empty."); - } -} - -// ============== Geocache Web Handlers (#70) ============== - -// GET /geocaches - Main geocache manager page -void handleWebGeocaches() { - String html = "Geocaches"; - html += ""; - html += ""; - - html += "

Geocache Manager

"; - - // Show status message if present - if (webServer.hasArg("msg")) { - String msg = webServer.arg("msg"); - bool isError = webServer.hasArg("err"); - html += "
" + msg + "
"; - } - - // Upload form - html += "

Upload GPX File

"; - html += "
"; - html += "
"; - html += ""; - html += "
"; - html += "

Max file size: 64KB | Max caches: " + String(MAX_CACHES) + "

"; - - // Action buttons - html += "
"; - html += "Download Found Status "; - html += "Clear All"; - html += "
"; - - // Current cache list - html += "

Loaded Caches (" + String(cacheListCount) + ")

"; - - if (cacheListCount == 0) { - html += "

No caches loaded. Upload a GPX file to get started.

"; - } else { - for (int i = 0; i < cacheListCount; i++) { - GeocacheEntry& c = cacheList[i]; - if (!c.valid) continue; - - html += "
"; - html += "
"; - html += "" + String(c.gcCode) + ": " + String(c.name); - html += "
"; - html += "D" + String(c.difficulty, 1) + "/T" + String(c.terrain, 1); - html += " | " + String(c.latitude, 5) + ", " + String(c.longitude, 5); - if (c.found) html += " | FOUND"; - html += "
"; - html += "
"; - html += "" + String(c.found ? "Unfound" : "Found") + " "; - html += "Del"; - html += "
"; - } - } - - html += "
Back to Home"; - html += ""; - - webServer.send(200, "text/html", html); -} - -// POST upload data handler - receives file chunks -void handleGeocacheUploadData() { - HTTPUpload& upload = webServer.upload(); - - if (upload.status == UPLOAD_FILE_START) { - gpxUploadBuffer = ""; - gpxUploadSuccess = false; - gpxUploadError = ""; - logPrintf("[GEOCACHE] Upload started: %s\n", upload.filename.c_str()); - - // Validate file extension - String filename = upload.filename; - filename.toLowerCase(); - if (!filename.endsWith(".gpx") && !filename.endsWith(".xml")) { - gpxUploadError = "Invalid file type. Please upload a .gpx file."; - return; - } - } - - else if (upload.status == UPLOAD_FILE_WRITE) { - // Check size limit - if (gpxUploadBuffer.length() + upload.currentSize > GPX_MAX_FILE_SIZE) { - gpxUploadError = "File too large. Maximum size is 64KB."; - return; - } - - // Append chunk to buffer - for (size_t i = 0; i < upload.currentSize; i++) { - gpxUploadBuffer += (char)upload.buf[i]; - } - } - - else if (upload.status == UPLOAD_FILE_END) { - logPrintf("[GEOCACHE] Upload complete: %d bytes\n", gpxUploadBuffer.length()); - - if (gpxUploadError.length() > 0) { - return; // Already have an error - } - - // Parse GPX data (append to existing caches) - int parsed = parseGPXFromString(gpxUploadBuffer, true); - - if (parsed <= 0) { - gpxUploadError = "No new waypoints found (duplicates skipped or invalid)."; - return; - } - - // Save GPX to SD with original filename (allows multi-file accumulation) - if (sdAvailable) { - if (!SD.exists(GEOCACHE_DIR)) { - SD.mkdir(GEOCACHE_DIR); - } - String savePath = String(GEOCACHE_DIR) + "/" + upload.filename; - File f = sdOpenSafe(savePath.c_str(), "w", true); - if (f) { - f.print(gpxUploadBuffer); - f.close(); - recordSDSuccess(); - logPrintf("[GEOCACHE] Saved GPX to SD: %s (%d bytes)\n", savePath.c_str(), gpxUploadBuffer.length()); - } - } - - // Load any saved found status - loadCacheFoundStatus(); - gcApplyFilters(); // Filter + sort (#122) - - gpxUploadSuccess = true; - logPrintf("[GEOCACHE] Added %d caches (total %d)\n", parsed, cacheListCount); - } - - else if (upload.status == UPLOAD_FILE_ABORTED) { - gpxUploadError = "Upload aborted."; - gpxUploadBuffer = ""; - } -} - -// POST upload complete - send response -void handleGeocacheUploadComplete() { - // Clear upload buffer to free RAM - gpxUploadBuffer = ""; - - String msg; - if (gpxUploadSuccess) { - msg = "Loaded " + String(cacheListCount) + " geocaches successfully!"; - webServer.sendHeader("Location", "/geocaches?msg=" + msg); - } else { - msg = gpxUploadError.length() > 0 ? gpxUploadError : "Upload failed"; - webServer.sendHeader("Location", "/geocaches?msg=" + msg + "&err=1"); - } - webServer.send(302, "text/plain", "Redirecting..."); -} - -// GET /geocaches/download - Download found status CSV -void handleGeocacheDownload() { - if (!sdAvailable || !SD.exists(GEOCACHE_FOUND_FILE)) { - webServer.send(404, "text/plain", "No found status file available"); - return; - } - - File f = sdOpenSafe(GEOCACHE_FOUND_FILE, "r"); - if (!f) { - webServer.send(500, "text/plain", "Failed to open file"); - return; - } - - webServer.sendHeader("Content-Disposition", "attachment; filename=geocache_found.csv"); - webServer.streamFile(f, "text/csv"); - f.close(); - recordSDSuccess(); -} - -// GET /geocaches/delete?idx=N - Delete single cache -void handleGeocacheDelete() { - if (!webServer.hasArg("idx")) { - webServer.sendHeader("Location", "/geocaches?msg=Missing+index&err=1"); - webServer.send(302); - return; - } - - int idx = webServer.arg("idx").toInt(); - if (idx < 0 || idx >= cacheListCount) { - webServer.sendHeader("Location", "/geocaches?msg=Invalid+index&err=1"); - webServer.send(302); - return; - } - - String deletedName = String(cacheList[idx].gcCode); - - // Shift remaining caches - for (int i = idx; i < cacheListCount - 1; i++) { - cacheList[i] = cacheList[i + 1]; - } - cacheListCount--; - - // Update selected index if needed - if (selectedCacheIndex >= cacheListCount) { - selectedCacheIndex = max(0, cacheListCount - 1); - } - - // Re-save GPX file (regenerate from memory) - // For simplicity, we'll just mark the change happened - a full GPX rewrite would be complex - logPrintf("[GEOCACHE] Deleted cache %s, %d remaining\n", deletedName.c_str(), cacheListCount); - - webServer.sendHeader("Location", "/geocaches?msg=Deleted+" + deletedName); - webServer.send(302); -} - -// GET /geocaches/clear?confirm=yes - Clear all caches -void handleGeocacheClear() { - if (!webServer.hasArg("confirm") || webServer.arg("confirm") != "yes") { - webServer.sendHeader("Location", "/geocaches?msg=Clear+requires+confirm=yes&err=1"); - webServer.send(302); - return; - } - - int oldCount = cacheListCount; - - // Clear memory - cacheListCount = 0; - selectedCacheIndex = 0; - memset(cacheList, 0, sizeof(cacheList)); - - // Delete all GPX files from SD (but keep found status) - if (sdAvailable) { - if (SD.exists(GEOCACHE_GPX_FILE)) SD.remove(GEOCACHE_GPX_FILE); - if (SD.exists(GEOCACHE_DIR)) { - File dir = SD.open(GEOCACHE_DIR); - if (dir && dir.isDirectory()) { - File entry = dir.openNextFile(); - while (entry) { - String name = String(GEOCACHE_DIR) + "/" + entry.name(); - entry.close(); - if (name.endsWith(".gpx")) SD.remove(name.c_str()); - entry = dir.openNextFile(); - } - dir.close(); - } - } - recordSDSuccess(); - } - - logPrintf("[GEOCACHE] Cleared all %d caches\n", oldCount); - webServer.sendHeader("Location", "/geocaches?msg=Cleared+" + String(oldCount) + "+caches"); - webServer.send(302); -} - -// GET /geocaches/togglefound?idx=N - Toggle found status -void handleGeocacheToggleFound() { - if (!webServer.hasArg("idx")) { - webServer.sendHeader("Location", "/geocaches?msg=Missing+index&err=1"); - webServer.send(302); - return; - } - - int idx = webServer.arg("idx").toInt(); - if (idx < 0 || idx >= cacheListCount) { - webServer.sendHeader("Location", "/geocaches?msg=Invalid+index&err=1"); - webServer.send(302); - return; - } - - cacheList[idx].found = !cacheList[idx].found; - if (cacheList[idx].found) { - cacheList[idx].foundTime = millis() / 1000; // Simple timestamp - } else { - cacheList[idx].foundTime = 0; - } - - // Save updated found status - saveCacheFoundStatus(); - - String status = cacheList[idx].found ? "found" : "not+found"; - webServer.sendHeader("Location", "/geocaches?msg=" + String(cacheList[idx].gcCode) + "+marked+" + status); - webServer.send(302); -} - -void initWebServer() { - if (!wifiConnected) return; - if (webServerStarted) return; // Already started - - LOG_INFO("Starting web server..."); - - // Setup mDNS - if (MDNS.begin("fieldcompass")) { - LOG_INFO("mDNS OK (fieldcompass.local)"); - } - - // Register handlers - webServer.on("/", handleWebRoot); - webServer.on("/ops", handleWebOps); - webServer.on("/gps", handleWebGPS); - webServer.on("/env", handleWebEnv); - webServer.on("/imu", handleWebIMU); - webServer.on("/diags", handleWebDiags); - webServer.on("/serial", handleWebSerial); - webServer.on("/serial-data", handleWebSerialData); - // GPS debug/reset endpoints (#115) - webServer.on("/gps/debug", HTTP_GET, handleWebGpsDebug); - webServer.on("/gps/reset", HTTP_GET, handleWebGpsReset); - webServer.on("/json", handleWebJSON); - webServer.on("/battlog", handleWebBattLog); - webServer.on("/battlog/clear", handleWebBattLogClear); - - // Geocache manager endpoints (#70) - webServer.on("/geocaches", HTTP_GET, handleWebGeocaches); - webServer.on("/geocaches/upload", HTTP_POST, handleGeocacheUploadComplete, handleGeocacheUploadData); - webServer.on("/geocaches/download", HTTP_GET, handleGeocacheDownload); - webServer.on("/geocaches/delete", HTTP_GET, handleGeocacheDelete); - webServer.on("/geocaches/clear", HTTP_GET, handleGeocacheClear); - webServer.on("/geocaches/togglefound", HTTP_GET, handleGeocacheToggleFound); - - // Serial log file endpoints (#59) - webServer.on("/logs", HTTP_GET, handleWebLogs); - webServer.on("/logs/download", HTTP_GET, handleWebLogDownload); - - webServer.begin(); - webServerStarted = true; - LOG_INFO("Web server OK — http://%s/", WiFi.localIP().toString().c_str()); -} - // ============== LVGL Screen Navigation (#113) ============== // Screen table for main screens (Settings is modal, not in cycle) diff --git a/src/web.cpp b/src/web.cpp new file mode 100644 index 0000000..001c9c3 --- /dev/null +++ b/src/web.cpp @@ -0,0 +1,1181 @@ +// web.cpp -- extracted from src.ino by scripts/extract_unit.py (E4). +#include "web.h" +#include "fc_version.h" +#include "settings.h" +#include "rtc.h" +#include "logging.h" +#include "fc_config.h" +#include "fram.h" +#include "gps.h" +#include "imu.h" +#include "env.h" +#include "battery.h" +#include "weather.h" +#include "geocache.h" +#include "ui_state.h" +#include "oled.h" +#include "display.h" +#include "touch.h" +#include "geo.h" + +const char* WIFI_SSID_1 = "REDACTED_SSID_1"; +const char* WIFI_PASS_1 = "REDACTED_WIFI_PASSWORD"; +const char* WIFI_SSID_2 = "REDACTED_SSID_2"; +const char* WIFI_PASS_2 = "REDACTED_WIFI_PASSWORD"; +const char* WIFI_SSID_3 = "REDACTED_SSID_3"; +const char* WIFI_PASS_3 = "REDACTED_HOTSPOT_PASSWORD"; +const char* NTP_SERVER = "pool.ntp.org"; +WebServer webServer(WEB_SERVER_PORT); +unsigned long lastWiFiAttempt = 0; +bool wifiConnected = false; +bool ntpSynced = false; +bool webServerStarted = false; +bool rtcSyncedFromNTP = false; // RTC was synced from NTP this session +static uint16_t webSerialReadPos = 0; +static String gpxUploadBuffer; +static bool gpxUploadSuccess = false; +static String gpxUploadError; + +void initWiFi() { + logPrint("Connecting to WiFi"); + + // Try networks in order + const char* ssids[] = {WIFI_SSID_1, WIFI_SSID_2, WIFI_SSID_3}; + const char* passwords[] = {WIFI_PASS_1, WIFI_PASS_2, WIFI_PASS_3}; + + for (int net = 0; net < 3; net++) { + logPrintf(" [%s]", ssids[net]); + + WiFi.begin(ssids[net], passwords[net]); + + int attempts = 0; + while (WiFi.status() != WL_CONNECTED && attempts < 15) { + delay(500); + logPrint("."); + attempts++; + } + + if (WiFi.status() == WL_CONNECTED) break; + } + + if (WiFi.status() == WL_CONNECTED) { + wifiConnected = true; + logPrintln(" OK"); + logPrintf(" IP: %s\n", WiFi.localIP().toString().c_str()); + + // Sync NTP time + logPrint("Syncing NTP time... "); + configTime(0, 0, NTP_SERVER); // NTP provides UTC; POSIX TZ handles offset (#98) + applyTimezone(); // Ensure TZ is set after configTime + + struct tm timeinfo; + if (getLocalTime(&timeinfo, 5000)) { + ntpSynced = true; + logPrintln("OK"); + + // Sync RTC from NTP (if GPS hasn't already synced it) + if (!rtcSyncedFromGPS && !rtcSyncedFromNTP) { + syncRTCFromSystemTime("NTP"); + rtcSyncedFromNTP = true; + } + } else { + logPrintln("FAILED"); + } + } else { + logPrintln(" FAILED"); + } + + lastWiFiAttempt = millis(); +} + +void checkWiFi() { + // Update connection status + wifiConnected = (WiFi.status() == WL_CONNECTED); + + // Attempt reconnect if disconnected + if (!wifiConnected && (millis() - lastWiFiAttempt > WIFI_RECONNECT_INTERVAL)) { + logPrintln("WiFi disconnected, attempting reconnect..."); + WiFi.reconnect(); + lastWiFiAttempt = millis(); + + // Wait briefly for connection + int attempts = 0; + while (WiFi.status() != WL_CONNECTED && attempts < 10) { + delay(500); + attempts++; + } + + wifiConnected = (WiFi.status() == WL_CONNECTED); + if (wifiConnected) { + logPrintln("WiFi reconnected!"); + // Start web server if not already running + if (!webServerStarted) { + initWebServer(); + } + } + } + + // Ensure web server is started if WiFi is connected + if (wifiConnected && !webServerStarted) { + initWebServer(); + } +} + +void handleWebRoot() { + String html = ""; + html += "Field Compass v"; + html += FW_VERSION; + html += ""; + html += ""; + html += ""; + html += ""; + html += "

Field Compass v"; + html += FW_VERSION; + html += "

"; + html += "Operational Info"; + html += "GPS"; + html += "Environment"; + html += "IMU / Compass"; + html += "Diagnostics"; + html += "Geocache Manager"; + html += "Serial Monitor"; + html += "Serial Logs (SD)"; + html += "JSON API"; + html += "

http://fieldcompass.local/

"; + html += ""; + webServer.send(200, "text/html", html); +} + +void handleWebOps() { + String html = "OPS"; + html += ""; + html += ""; + html += "

OPERATIONAL

";
+
+  // Time
+  char buf[64];
+  if (gpsData.timeValid) {
+    struct tm timeinfo;
+    if (getLocalTime(&timeinfo)) {
+      char tbuf[16];
+      formatTimeStr(tbuf, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, true);
+      sprintf(buf, "Time:    %s GPS\n", tbuf);
+    } else {
+      strcpy(buf, "Time:    --:--:-- GPS\n");
+    }
+  } else if (ntpSynced) {
+    struct tm timeinfo;
+    if (getLocalTime(&timeinfo)) {
+      char tbuf[16];
+      formatTimeStr(tbuf, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, true);
+      sprintf(buf, "Time:    %s NTP\n", tbuf);
+    }
+  } else {
+    sprintf(buf, "Time:    --:--:-- N/A\n");
+  }
+  html += buf;
+
+  // Uptime
+  unsigned long totalSec = millis() / 1000;
+  int days = totalSec / 86400;
+  int hours = (totalSec % 86400) / 3600;
+  int mins = (totalSec % 3600) / 60;
+  int secs = totalSec % 60;
+  if (days > 0) {
+    sprintf(buf, "Uptime:  %dd %02d:%02d:%02d\n", days, hours, mins, secs);
+  } else {
+    sprintf(buf, "Uptime:  %02d:%02d:%02d\n", hours, mins, secs);
+  }
+  html += buf;
+
+  // WiFi
+  if (wifiConnected) {
+    html += "WiFi:    " + WiFi.SSID() + " (" + WiFi.localIP().toString() + ")\n";
+  } else {
+    html += "WiFi:    Disconnected\n";
+  }
+
+  // Battery
+  if (batteryAvailable && isBatteryConnected()) {
+    sprintf(buf, "Battery: %.0f%% (%.2fV)\n", battery.cellPercent(), battery.cellVoltage());
+    html += buf;
+  } else if (batteryAvailable) {
+    html += "Battery: USB Only\n";
+  } else {
+    html += "Battery: N/A\n";
+  }
+
+  html += "
Back"; + webServer.send(200, "text/html", html); +} + +void handleWebGPS() { + String html = "GPS"; + html += ""; + html += ""; + html += "

GPS

";
+
+  char buf[64];
+  if (gpsData.valid) {
+    sprintf(buf, "Latitude:  %.6f %c\n", fabs(gpsData.latitude), gpsData.latitude >= 0 ? 'N' : 'S');
+    html += buf;
+    sprintf(buf, "Longitude: %.6f %c\n", fabs(gpsData.longitude), gpsData.longitude >= 0 ? 'E' : 'W');
+    html += buf;
+    sprintf(buf, "Altitude:  %.1f m\n", gpsData.altitude);
+    html += buf;
+    html += "Status:    Fix OK\n";
+  } else if (gpsData.receiving) {
+    html += "Status: Acquiring fix...\n";
+    if (gpsData.timeValid) {
+      struct tm timeinfo;
+      if (getLocalTime(&timeinfo)) {
+        char tbuf[16];
+        formatTimeStr(tbuf, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, true);
+        sprintf(buf, "Time:   %s\n", tbuf);
+      } else {
+        strcpy(buf, "Time:   --:--:--\n");
+      }
+      html += buf;
+    }
+  } else {
+    html += "Status: No GPS data\n";
+  }
+
+  html += "
Back"; + webServer.send(200, "text/html", html); +} + +// GPS debug toggle (#115) — future: move to Settings LVGL screen (#112) +void handleWebGpsDebug() { + gpsDebugEnabled = !gpsDebugEnabled; + logPrintf("[GPS] Runtime debug logging %s\n", gpsDebugEnabled ? "ENABLED" : "DISABLED"); + String json = "{\"gpsDebug\":"; + json += gpsDebugEnabled ? "true" : "false"; + json += "}"; + webServer.send(200, "application/json", json); +} + +// GPS soft reset (#115) +void handleWebGpsReset() { + logPrintf("[GPS] Soft reset triggered via web\n"); + + // Clear all GPS state + gpsData.valid = false; + gpsData.receiving = false; + gpsData.latitude = 0; + gpsData.longitude = 0; + gpsData.altitude = 0; + gpsData.hdop = 99.0; + gpsData.satellites = 0; + gpsData.speedKnots = 0; + gpsData.timeValid = false; + gpsData.dateValid = false; + + // Reset tracking flags + gpsHadFirstReceive = false; + gpsHadFirstFix = false; + gpsFirstReceiveTime = 0; + gpsFirstFixTime = 0; + gpsSignalLostTime = 0; + gpsLastByteTime = 0; + gprmcFixThisCycle = false; + gnrmcFixThisCycle = false; + lastRmcCycleTime = 0; + + // Flush Serial1 RX buffer + while (Serial1.available()) Serial1.read(); + + // Send hot-restart command (MediaTek MTK chipsets — ignored by others) + Serial1.println("$PMTK101*32"); + + webServer.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"GPS reset\"}"); +} + +void handleWebEnv() { + String html = "ENV"; + html += ""; + html += ""; + html += "

ENVIRONMENT

";
+
+  char buf[80];
+  if (bmeAvailable || shtAvailable) {
+    // SHT41 preferred for temp/humidity (#48)
+    float tempC = shtAvailable ? shtData.temperature : envData.temperature;
+    float tempF = tempC * 9.0 / 5.0 + 32.0;
+    float humid = shtAvailable ? shtData.humidity : envData.humidity;
+    const char* src = shtAvailable ? "SHT41" : "BME688";
+    if (useFahrenheit)
+      sprintf(buf, "Temp:     %.1fF (%.1fC) [%s]\n", tempF, tempC, src);
+    else
+      sprintf(buf, "Temp:     %.1fC (%.1fF) [%s]\n", tempC, tempF, src);
+    html += buf;
+    sprintf(buf, "Humidity: %.1f%% [%s]\n", humid, src);
+    html += buf;
+    if (bmeAvailable) {
+      sprintf(buf, "IAQ:      %.0f [%s]\n", envData.iaq, getIaqAccuracyText(envData.iaqAccuracy));
+      html += buf;
+      sprintf(buf, "CO2:      %.0f ppm\n", envData.co2Equivalent);
+      html += buf;
+      sprintf(buf, "Pressure: %.1f hPa (%.2f\")\n", envData.pressure, hPaToInHg(envData.pressure));
+      html += buf;
+      sprintf(buf, "Forecast: %s %s\n", getTrendArrow(), weatherTrend.forecast);
+      html += buf;
+    }
+  } else {
+    html += "BME688 not available\n";
+  }
+
+  html += "
Back"; + webServer.send(200, "text/html", html); +} + +void handleWebIMU() { + String html = "IMU"; + html += ""; + html += ""; + html += "

IMU / COMPASS

";
+
+  char buf[64];
+  if (imuAvailable && magAvailable) {
+    sprintf(buf, "Heading: %.0f %s\n", imuData.heading, getCardinal(imuData.heading));
+    html += buf;
+    sprintf(buf, "Roll:    %.0f deg\n", imuData.roll);
+    html += buf;
+    sprintf(buf, "Pitch:   %.0f deg\n", imuData.pitch);
+    html += buf;
+    sprintf(buf, "Accel:   %.2f m/s2\n", imuData.accelMag);
+    html += buf;
+  } else {
+    html += "IMU not available\n";
+  }
+
+  html += "
Back"; + webServer.send(200, "text/html", html); +} + +void handleWebDiags() { + String html = "DIAGS"; + html += ""; + html += ""; + html += "

DIAGNOSTICS

";
+
+  char buf[80];
+
+  // Firmware info
+  html += "=== Firmware ===\n";
+  sprintf(buf, "Version:  %s\n", FW_VERSION);
+  html += buf;
+  html += "Build:    Field Compass Dual\n";
+  {
+    unsigned long totalSec = millis() / 1000;
+    int days = totalSec / 86400;
+    int hours = (totalSec % 86400) / 3600;
+    int mins = (totalSec % 3600) / 60;
+    int secs = totalSec % 60;
+    if (days > 0) {
+      sprintf(buf, "Uptime:   %dd %02d:%02d:%02d\n", days, hours, mins, secs);
+    } else {
+      sprintf(buf, "Uptime:   %02d:%02d:%02d\n", hours, mins, secs);
+    }
+    html += buf;
+  }
+  if (wifiConnected) {
+    html += "WiFi IP:  ";
+    html += WiFi.localIP().toString();
+    html += "\n";
+  } else {
+    html += "WiFi IP:  Disconnected\n";
+  }
+  html += "mDNS:     fieldcompass.local\n";
+
+  // BSEC State
+  html += "\n=== BSEC State ===\n";
+  sprintf(buf, "Loaded:   %s\n", bsecStateLoaded ? "Yes" : "No");
+  html += buf;
+  sprintf(buf, "Saved:    %s\n", bsecStateSaved ? "Yes" : "No");
+  html += buf;
+  sprintf(buf, "Accuracy: %d (%s)\n", envData.iaqAccuracy, getIaqAccuracyText(envData.iaqAccuracy));
+  html += buf;
+
+  // Weather Log
+  html += "\n=== Weather Log ===\n";
+  sprintf(buf, "In Memory: %d readings\n", weatherHistoryCount);
+  html += buf;
+  sprintf(buf, "Files:     %d\n", weatherLogFileCount);
+  html += buf;
+  sprintf(buf, "Total:     %d entries\n", weatherLogEntryCount);
+  html += buf;
+
+  // System
+  html += "\n=== System ===\n";
+  sprintf(buf, "Free Heap: %lu bytes\n", (unsigned long)ESP.getFreeHeap());
+  html += buf;
+  sprintf(buf, "Heap Size: %lu bytes\n", (unsigned long)ESP.getHeapSize());
+  html += buf;
+  sprintf(buf, "CPU Freq:  %lu MHz\n", (unsigned long)ESP.getCpuFreqMHz());
+  html += buf;
+  if (psramFound()) {
+    sprintf(buf, "PSRAM:     %luK / %luK\n",
+            (unsigned long)ESP.getFreePsram() / 1024,
+            (unsigned long)ESP.getPsramSize() / 1024);
+  } else {
+    sprintf(buf, "PSRAM:     Not detected\n");
+  }
+  html += buf;
+  sprintf(buf, "Render:    LVGL 9.5 (2x%dKB PSRAM buffers)\n", LVGL_BUF_SIZE / 1024);
+  html += buf;
+
+  // Sensors
+  html += "\n=== Sensors ===\n";
+  sprintf(buf, "BME688:  %s\n", bmeAvailable ? "OK" : "N/A");
+  html += buf;
+  sprintf(buf, "SHT41:   %s\n", shtAvailable ? "OK" : "N/A");
+  html += buf;
+  sprintf(buf, "IMU:     %s\n", imuAvailable ? "OK" : "N/A");
+  html += buf;
+  sprintf(buf, "Mag:     %s\n", magAvailable ? "OK" : "N/A");
+  html += buf;
+  sprintf(buf, "Battery: %s\n", batteryAvailable ? "OK" : "N/A");
+  html += buf;
+  sprintf(buf, "SD Card: %s\n", sdAvailable ? "OK" : "N/A");
+  html += buf;
+  sprintf(buf, "OLED:    %s\n", oledAvailable ? "OK" : "N/A");
+  html += buf;
+  sprintf(buf, "Touch:   %s\n", touchAvailable ? "OK (FT6336U)" : "N/A");
+  html += buf;
+  if (framAvailable) {
+    sprintf(buf, "FRAM:    OK (256KB) Batt:%d/%d Wx:%d/%d %s\n",
+            framHeader.battCount, FRAM_BATT_COUNT,
+            framHeader.wxCount, FRAM_WX_COUNT,
+            (framHeader.flags & 0x01) ? "DIRTY" : "Clean");
+  } else {
+    sprintf(buf, "FRAM:    N/A\n");
+  }
+  html += buf;
+
+  // Mag Calibration
+  html += "\n=== Mag Calibration ===\n";
+  if (magCalibrated) {
+    html += "Status:   Calibrated\n";
+    sprintf(buf, "Offsets:  X=%.2f Y=%.2f Z=%.2f\n", magOffsetX, magOffsetY, magOffsetZ);
+    html += buf;
+  } else if (magCalibrating) {
+    unsigned long elapsed = (millis() - magCalStartTime) / 1000;
+    sprintf(buf, "Status:   Calibrating (%lus / 15s)\n", elapsed);
+    html += buf;
+  } else {
+    html += "Status:   Not Calibrated\n";
+    html += "          (Settings > Compass Cal)\n";
+  }
+
+  // Temp comparison SHT41 vs BME688 (#48)
+  html += "\n=== Temperature Comparison ===\n";
+  if (shtAvailable) {
+    float shtF = shtData.temperature * 9.0 / 5.0 + 32.0;
+    if (useFahrenheit)
+      sprintf(buf, "SHT41:   %.1fF (%.1fC)\n", shtF, shtData.temperature);
+    else
+      sprintf(buf, "SHT41:   %.1fC (%.1fF)\n", shtData.temperature, shtF);
+    html += buf;
+    sprintf(buf, "SHT41 H: %.1f%%\n", shtData.humidity);
+    html += buf;
+  } else {
+    html += "SHT41:   N/A\n";
+  }
+  if (bmeAvailable) {
+    float bmeF = envData.temperature * 9.0 / 5.0 + 32.0;
+    if (useFahrenheit)
+      sprintf(buf, "BME688:  %.1fF (%.1fC)\n", bmeF, envData.temperature);
+    else
+      sprintf(buf, "BME688:  %.1fC (%.1fF)\n", envData.temperature, bmeF);
+    html += buf;
+    sprintf(buf, "BME688 H: %.1f%%\n", envData.humidity);
+    html += buf;
+  } else {
+    html += "BME688:  N/A\n";
+  }
+  if (shtAvailable && bmeAvailable) {
+    float deltaF = (shtData.temperature - envData.temperature) * 9.0 / 5.0;
+    sprintf(buf, "Delta:   %+.1fF (SHT - BME)\n", deltaF);
+    html += buf;
+  }
+
+  // GPS (#68)
+  html += "\n=== GPS ===\n";
+
+  // Status: Fix OK / Acquiring / No Data
+  const char* gpsStatus;
+  if (gpsData.valid) {
+    gpsStatus = "Fix OK";
+  } else if (gpsData.receiving) {
+    gpsStatus = "Acquiring";
+  } else {
+    gpsStatus = "No Data";
+  }
+  sprintf(buf, "Status:   %s\n", gpsStatus);
+  html += buf;
+
+  // TTFF - retained once acquired
+  if (gpsHadFirstFix) {
+    sprintf(buf, "TTFF:     %lu seconds\n", gpsFirstFixTime / 1000);
+    html += buf;
+  } else {
+    html += "TTFF:     (not yet acquired)\n";
+  }
+
+  // Acquiring/Reacquire time - show if not currently valid
+  if (!gpsData.valid && gpsHadFirstReceive) {
+    if (gpsHadFirstFix && gpsSignalLostTime > 0) {
+      // Lost signal after having fix - show time since signal lost
+      unsigned long reacquiring = (millis() - gpsSignalLostTime) / 1000;
+      sprintf(buf, "Reacquire: %lum %lus\n", reacquiring / 60, reacquiring % 60);
+    } else {
+      // Never had fix, show time since first NMEA data
+      unsigned long elapsed = (millis() - gpsFirstReceiveTime) / 1000;
+      sprintf(buf, "Elapsed:  %lum %lus\n", elapsed / 60, elapsed % 60);
+    }
+    html += buf;
+  }
+
+  html += "
Back"; + webServer.send(200, "text/html", html); +} + +void handleWebSerial() { + String html = "Serial Log"; + html += ""; + html += ""; + html += "

Serial Log

"; + html += "
"; + html += "
"; + html += ""; + html += ""; + html += "Live ▼"; + html += "Back"; + html += "
"; + html += ""; + html += ""; + webServer.send(200, "text/html", html); +} + +void handleWebSerialData() { + // Return buffered serial data since last request (incremental) + String out; + out.reserve(1024); + + uint16_t head = serialRingHead; + uint16_t tail = serialRingTail; + + // If our read pos is behind tail, we lost data - jump to tail + uint16_t dist = (head >= webSerialReadPos) ? + (head - webSerialReadPos) : + (SERIAL_RING_SIZE - webSerialReadPos + head); + if (dist > SERIAL_RING_SIZE - 100) { + webSerialReadPos = tail; + } + + // Read available data + while (webSerialReadPos != head && out.length() < 1024) { + char c = serialRing[webSerialReadPos]; + webSerialReadPos = (webSerialReadPos + 1) % SERIAL_RING_SIZE; + if (c != '\r') { + out += c; + } + } + + webServer.send(200, "text/plain", out); +} + +// GET /logs — List serial log files with download links (#59) +void handleWebLogs() { + String html = "Serial Logs"; + html += ""; + html += ""; + html += "

Serial Logs

"; + + if (!sdAvailable || !SD.exists(LOG_DIR)) { + html += "

No log files available.

"; + } else { + // Flush current buffer before listing + serialLogFlush(); + + html += ""; + + File dir = SD.open(LOG_DIR); + if (dir && dir.isDirectory()) { + File entry = dir.openNextFile(); + while (entry) { + if (!entry.isDirectory()) { + const char* name = entry.name(); + size_t sz = entry.size(); + html += ""; + } + entry.close(); + entry = dir.openNextFile(); + } + dir.close(); + } + html += "
FileSizeAction
"; + html += name; + html += ""; + if (sz >= 1024) { + html += String(sz / 1024.0, 1) + " KB"; + } else { + html += String((unsigned long)sz) + " B"; + } + html += "Download
"; + } + + if (serialLogActive) { + html += "

Currently logging to: "; + html += serialLogFilename; + html += "

"; + } + html += "

Back

"; + webServer.send(200, "text/html", html); +} + +// GET /logs/download?file= — Download a serial log file (#59) +void handleWebLogDownload() { + if (!webServer.hasArg("file")) { + webServer.send(400, "text/plain", "Missing file parameter"); + return; + } + + String filename = webServer.arg("file"); + // Security: prevent path traversal — only allow alphanumeric, underscore, dot, hyphen + for (unsigned int i = 0; i < filename.length(); i++) { + char c = filename[i]; + if (!isalnum(c) && c != '_' && c != '.' && c != '-') { + webServer.send(400, "text/plain", "Invalid filename"); + return; + } + } + + // Flush current buffer if this is the active log file + if (serialLogActive) { + String activeName = String(serialLogFilename).substring(strlen(LOG_DIR) + 1); + if (filename.equals(activeName)) { + serialLogFlush(); + } + } + + String fullPath = String(LOG_DIR) + "/" + filename; + if (!SD.exists(fullPath)) { + webServer.send(404, "text/plain", "File not found"); + return; + } + + File f = SD.open(fullPath, "r"); + if (!f) { + webServer.send(500, "text/plain", "Failed to open file"); + return; + } + + String disposition = "attachment; filename=" + filename; + webServer.sendHeader("Content-Disposition", disposition); + webServer.streamFile(f, "text/plain"); + f.close(); +} + +void handleWebJSON() { + char buf[1024]; + bool battConnected = batteryAvailable && isBatteryConnected(); + // SHT41 preferred for temp/humidity in JSON API (#48) + float jsonTempC = shtAvailable ? shtData.temperature : envData.temperature; + float jsonHumid = shtAvailable ? shtData.humidity : envData.humidity; + snprintf(buf, sizeof(buf), + "{" + "\"gps\":{\"valid\":%s,\"lat\":%.6f,\"lon\":%.6f,\"alt\":%.1f}," + "\"env\":{\"temp\":%.1f,\"humidity\":%.1f,\"pressure\":%.1f,\"iaq\":%.0f,\"iaqQuality\":\"%s\",\"co2\":%.0f,\"accuracy\":%d,\"tempSource\":\"%s\"}," + "\"imu\":{\"heading\":%.1f,\"roll\":%.1f,\"pitch\":%.1f,\"accel\":%.2f}," + "\"system\":{\"uptime\":%lu,\"wifi\":%s,\"battery\":%.1f,\"batteryConnected\":%s,\"heap\":%lu}" + "}", + gpsData.valid ? "true" : "false", gpsData.latitude, gpsData.longitude, gpsData.altitude, + jsonTempC, jsonHumid, envData.pressure, envData.iaq, getIaqQualityText(envData.iaq), envData.co2Equivalent, envData.iaqAccuracy, + shtAvailable ? "SHT41" : "BME688", + imuData.heading, imuData.roll, imuData.pitch, imuData.accelMag, + millis() / 1000, wifiConnected ? "true" : "false", + battConnected ? battery.cellPercent() : -1.0, + battConnected ? "true" : "false", + (unsigned long)ESP.getFreeHeap() + ); + webServer.send(200, "application/json", buf); +} + +// Battery log download endpoint +void handleWebBattLog() { + if (!sdHealth.available) { + webServer.send(404, "text/plain", "SD card not available"); + return; + } + + File f = sdOpenSafe(BATT_LOG_FILE, "r", true); + if (!f) { + webServer.send(404, "text/plain", "Battery log file not found. Wait for data collection."); + return; + } + + // Stream the file with CSV content type for easy download + webServer.sendHeader("Content-Disposition", "attachment; filename=battlog.csv"); + webServer.streamFile(f, "text/csv"); + f.close(); +} + +// Battery log clear endpoint +void handleWebBattLogClear() { + if (!sdHealth.available) { + webServer.send(404, "text/plain", "SD card not available"); + return; + } + + if (SD.exists(BATT_LOG_FILE)) { + SD.remove(BATT_LOG_FILE); + webServer.send(200, "text/plain", "Battery log cleared. New log will start on next cycle."); + } else { + webServer.send(200, "text/plain", "Battery log already empty."); + } +} + +// GET /geocaches - Main geocache manager page +void handleWebGeocaches() { + String html = "Geocaches"; + html += ""; + html += ""; + + html += "

Geocache Manager

"; + + // Show status message if present + if (webServer.hasArg("msg")) { + String msg = webServer.arg("msg"); + bool isError = webServer.hasArg("err"); + html += "
" + msg + "
"; + } + + // Upload form + html += "

Upload GPX File

"; + html += "
"; + html += "
"; + html += ""; + html += "
"; + html += "

Max file size: 64KB | Max caches: " + String(MAX_CACHES) + "

"; + + // Action buttons + html += "
"; + html += "Download Found Status "; + html += "Clear All"; + html += "
"; + + // Current cache list + html += "

Loaded Caches (" + String(cacheListCount) + ")

"; + + if (cacheListCount == 0) { + html += "

No caches loaded. Upload a GPX file to get started.

"; + } else { + for (int i = 0; i < cacheListCount; i++) { + GeocacheEntry& c = cacheList[i]; + if (!c.valid) continue; + + html += "
"; + html += "
"; + html += "" + String(c.gcCode) + ": " + String(c.name); + html += "
"; + html += "D" + String(c.difficulty, 1) + "/T" + String(c.terrain, 1); + html += " | " + String(c.latitude, 5) + ", " + String(c.longitude, 5); + if (c.found) html += " | FOUND"; + html += "
"; + html += "
"; + html += "" + String(c.found ? "Unfound" : "Found") + " "; + html += "Del"; + html += "
"; + } + } + + html += "
Back to Home"; + html += ""; + + webServer.send(200, "text/html", html); +} + +// POST upload data handler - receives file chunks +void handleGeocacheUploadData() { + HTTPUpload& upload = webServer.upload(); + + if (upload.status == UPLOAD_FILE_START) { + gpxUploadBuffer = ""; + gpxUploadSuccess = false; + gpxUploadError = ""; + logPrintf("[GEOCACHE] Upload started: %s\n", upload.filename.c_str()); + + // Validate file extension + String filename = upload.filename; + filename.toLowerCase(); + if (!filename.endsWith(".gpx") && !filename.endsWith(".xml")) { + gpxUploadError = "Invalid file type. Please upload a .gpx file."; + return; + } + } + + else if (upload.status == UPLOAD_FILE_WRITE) { + // Check size limit + if (gpxUploadBuffer.length() + upload.currentSize > GPX_MAX_FILE_SIZE) { + gpxUploadError = "File too large. Maximum size is 64KB."; + return; + } + + // Append chunk to buffer + for (size_t i = 0; i < upload.currentSize; i++) { + gpxUploadBuffer += (char)upload.buf[i]; + } + } + + else if (upload.status == UPLOAD_FILE_END) { + logPrintf("[GEOCACHE] Upload complete: %d bytes\n", gpxUploadBuffer.length()); + + if (gpxUploadError.length() > 0) { + return; // Already have an error + } + + // Parse GPX data (append to existing caches) + int parsed = parseGPXFromString(gpxUploadBuffer, true); + + if (parsed <= 0) { + gpxUploadError = "No new waypoints found (duplicates skipped or invalid)."; + return; + } + + // Save GPX to SD with original filename (allows multi-file accumulation) + if (sdAvailable) { + if (!SD.exists(GEOCACHE_DIR)) { + SD.mkdir(GEOCACHE_DIR); + } + String savePath = String(GEOCACHE_DIR) + "/" + upload.filename; + File f = sdOpenSafe(savePath.c_str(), "w", true); + if (f) { + f.print(gpxUploadBuffer); + f.close(); + recordSDSuccess(); + logPrintf("[GEOCACHE] Saved GPX to SD: %s (%d bytes)\n", savePath.c_str(), gpxUploadBuffer.length()); + } + } + + // Load any saved found status + loadCacheFoundStatus(); + gcApplyFilters(); // Filter + sort (#122) + + gpxUploadSuccess = true; + logPrintf("[GEOCACHE] Added %d caches (total %d)\n", parsed, cacheListCount); + } + + else if (upload.status == UPLOAD_FILE_ABORTED) { + gpxUploadError = "Upload aborted."; + gpxUploadBuffer = ""; + } +} + +// POST upload complete - send response +void handleGeocacheUploadComplete() { + // Clear upload buffer to free RAM + gpxUploadBuffer = ""; + + String msg; + if (gpxUploadSuccess) { + msg = "Loaded " + String(cacheListCount) + " geocaches successfully!"; + webServer.sendHeader("Location", "/geocaches?msg=" + msg); + } else { + msg = gpxUploadError.length() > 0 ? gpxUploadError : "Upload failed"; + webServer.sendHeader("Location", "/geocaches?msg=" + msg + "&err=1"); + } + webServer.send(302, "text/plain", "Redirecting..."); +} + +// GET /geocaches/download - Download found status CSV +void handleGeocacheDownload() { + if (!sdAvailable || !SD.exists(GEOCACHE_FOUND_FILE)) { + webServer.send(404, "text/plain", "No found status file available"); + return; + } + + File f = sdOpenSafe(GEOCACHE_FOUND_FILE, "r"); + if (!f) { + webServer.send(500, "text/plain", "Failed to open file"); + return; + } + + webServer.sendHeader("Content-Disposition", "attachment; filename=geocache_found.csv"); + webServer.streamFile(f, "text/csv"); + f.close(); + recordSDSuccess(); +} + +// GET /geocaches/delete?idx=N - Delete single cache +void handleGeocacheDelete() { + if (!webServer.hasArg("idx")) { + webServer.sendHeader("Location", "/geocaches?msg=Missing+index&err=1"); + webServer.send(302); + return; + } + + int idx = webServer.arg("idx").toInt(); + if (idx < 0 || idx >= cacheListCount) { + webServer.sendHeader("Location", "/geocaches?msg=Invalid+index&err=1"); + webServer.send(302); + return; + } + + String deletedName = String(cacheList[idx].gcCode); + + // Shift remaining caches + for (int i = idx; i < cacheListCount - 1; i++) { + cacheList[i] = cacheList[i + 1]; + } + cacheListCount--; + + // Update selected index if needed + if (selectedCacheIndex >= cacheListCount) { + selectedCacheIndex = max(0, cacheListCount - 1); + } + + // Re-save GPX file (regenerate from memory) + // For simplicity, we'll just mark the change happened - a full GPX rewrite would be complex + logPrintf("[GEOCACHE] Deleted cache %s, %d remaining\n", deletedName.c_str(), cacheListCount); + + webServer.sendHeader("Location", "/geocaches?msg=Deleted+" + deletedName); + webServer.send(302); +} + +// GET /geocaches/clear?confirm=yes - Clear all caches +void handleGeocacheClear() { + if (!webServer.hasArg("confirm") || webServer.arg("confirm") != "yes") { + webServer.sendHeader("Location", "/geocaches?msg=Clear+requires+confirm=yes&err=1"); + webServer.send(302); + return; + } + + int oldCount = cacheListCount; + + // Clear memory + cacheListCount = 0; + selectedCacheIndex = 0; + memset(cacheList, 0, sizeof(cacheList)); + + // Delete all GPX files from SD (but keep found status) + if (sdAvailable) { + if (SD.exists(GEOCACHE_GPX_FILE)) SD.remove(GEOCACHE_GPX_FILE); + if (SD.exists(GEOCACHE_DIR)) { + File dir = SD.open(GEOCACHE_DIR); + if (dir && dir.isDirectory()) { + File entry = dir.openNextFile(); + while (entry) { + String name = String(GEOCACHE_DIR) + "/" + entry.name(); + entry.close(); + if (name.endsWith(".gpx")) SD.remove(name.c_str()); + entry = dir.openNextFile(); + } + dir.close(); + } + } + recordSDSuccess(); + } + + logPrintf("[GEOCACHE] Cleared all %d caches\n", oldCount); + webServer.sendHeader("Location", "/geocaches?msg=Cleared+" + String(oldCount) + "+caches"); + webServer.send(302); +} + +// GET /geocaches/togglefound?idx=N - Toggle found status +void handleGeocacheToggleFound() { + if (!webServer.hasArg("idx")) { + webServer.sendHeader("Location", "/geocaches?msg=Missing+index&err=1"); + webServer.send(302); + return; + } + + int idx = webServer.arg("idx").toInt(); + if (idx < 0 || idx >= cacheListCount) { + webServer.sendHeader("Location", "/geocaches?msg=Invalid+index&err=1"); + webServer.send(302); + return; + } + + cacheList[idx].found = !cacheList[idx].found; + if (cacheList[idx].found) { + cacheList[idx].foundTime = millis() / 1000; // Simple timestamp + } else { + cacheList[idx].foundTime = 0; + } + + // Save updated found status + saveCacheFoundStatus(); + + String status = cacheList[idx].found ? "found" : "not+found"; + webServer.sendHeader("Location", "/geocaches?msg=" + String(cacheList[idx].gcCode) + "+marked+" + status); + webServer.send(302); +} + +void initWebServer() { + if (!wifiConnected) return; + if (webServerStarted) return; // Already started + + LOG_INFO("Starting web server..."); + + // Setup mDNS + if (MDNS.begin("fieldcompass")) { + LOG_INFO("mDNS OK (fieldcompass.local)"); + } + + // Register handlers + webServer.on("/", handleWebRoot); + webServer.on("/ops", handleWebOps); + webServer.on("/gps", handleWebGPS); + webServer.on("/env", handleWebEnv); + webServer.on("/imu", handleWebIMU); + webServer.on("/diags", handleWebDiags); + webServer.on("/serial", handleWebSerial); + webServer.on("/serial-data", handleWebSerialData); + // GPS debug/reset endpoints (#115) + webServer.on("/gps/debug", HTTP_GET, handleWebGpsDebug); + webServer.on("/gps/reset", HTTP_GET, handleWebGpsReset); + webServer.on("/json", handleWebJSON); + webServer.on("/battlog", handleWebBattLog); + webServer.on("/battlog/clear", handleWebBattLogClear); + + // Geocache manager endpoints (#70) + webServer.on("/geocaches", HTTP_GET, handleWebGeocaches); + webServer.on("/geocaches/upload", HTTP_POST, handleGeocacheUploadComplete, handleGeocacheUploadData); + webServer.on("/geocaches/download", HTTP_GET, handleGeocacheDownload); + webServer.on("/geocaches/delete", HTTP_GET, handleGeocacheDelete); + webServer.on("/geocaches/clear", HTTP_GET, handleGeocacheClear); + webServer.on("/geocaches/togglefound", HTTP_GET, handleGeocacheToggleFound); + + // Serial log file endpoints (#59) + webServer.on("/logs", HTTP_GET, handleWebLogs); + webServer.on("/logs/download", HTTP_GET, handleWebLogDownload); + + webServer.begin(); + webServerStarted = true; + LOG_INFO("Web server OK — http://%s/", WiFi.localIP().toString().c_str()); +} diff --git a/src/web.h b/src/web.h new file mode 100644 index 0000000..60a0af8 --- /dev/null +++ b/src/web.h @@ -0,0 +1,38 @@ +#pragma once +// web.h -- extracted from src.ino by scripts/extract_unit.py (E4). +#include +#include +#include +#include + +#define WEB_SERVER_PORT 80 +#define WIFI_RECONNECT_INTERVAL 30000 + +extern WebServer webServer; +extern bool wifiConnected; + +void initWiFi(); +void checkWiFi(); +void handleWebRoot(); +void handleWebOps(); +void handleWebGPS(); +void handleWebGpsDebug(); +void handleWebGpsReset(); +void handleWebEnv(); +void handleWebIMU(); +void handleWebDiags(); +void handleWebSerial(); +void handleWebSerialData(); +void handleWebLogs(); +void handleWebLogDownload(); +void handleWebJSON(); +void handleWebBattLog(); +void handleWebBattLogClear(); +void handleWebGeocaches(); +void handleGeocacheUploadData(); +void handleGeocacheUploadComplete(); +void handleGeocacheDownload(); +void handleGeocacheDelete(); +void handleGeocacheClear(); +void handleGeocacheToggleFound(); +void initWebServer();