#include "web_server.h" #include #include "wifi_manager.h" #include "config.h" #include "sd_logger.h" #include "time_manager.h" #include "html_util.h" #include #include #include #include #include static WebServer server(80); static const SenderStatus *g_statuses = nullptr; static uint8_t g_status_count = 0; static WifiMqttConfig g_config; static bool g_is_ap = false; static String g_web_user; static String g_web_pass; static const FaultCounters *g_sender_faults = nullptr; static const FaultType *g_sender_last_errors = nullptr; static MeterData g_last_batch[NUM_SENDERS][METER_BATCH_MAX_SAMPLES]; static uint8_t g_last_batch_count[NUM_SENDERS] = {}; struct HistoryBin { uint32_t ts; float value; uint32_t count; }; enum class HistoryMode : uint8_t { Avg = 0, Max = 1 }; struct HistoryJob { bool active; bool done; bool error; String error_msg; String device_id; HistoryMode mode; uint32_t start_ts; uint32_t end_ts; uint32_t res_sec; uint32_t bins_count; uint32_t bins_filled; uint16_t day_index; File file; HistoryBin *bins; }; static HistoryJob g_history = {}; static constexpr size_t SD_LIST_MAX_FILES = 200; static constexpr size_t SD_DOWNLOAD_MAX_PATH = 160; static bool auth_required() { return g_is_ap ? WEB_AUTH_REQUIRE_AP : WEB_AUTH_REQUIRE_STA; } static bool ensure_auth() { if (!auth_required()) { return true; } const char *user = g_web_user.c_str(); const char *pass = g_web_pass.c_str(); if (server.authenticate(user, pass)) { return true; } server.requestAuthentication(BASIC_AUTH, "DD3", "Authentication required"); return false; } static String html_header(const String &title) { String safe_title = html_escape(title); String h = ""; h += "" + safe_title + ""; h += "

" + safe_title + "

"; return h; } static String html_footer() { return ""; } static String format_faults(uint8_t idx) { if (!g_sender_faults || !g_sender_last_errors || idx >= g_status_count) { return ""; } String s; s += " faults m:"; s += String(g_sender_faults[idx].meter_read_fail); s += " d:"; s += String(g_sender_faults[idx].decode_fail); s += " tx:"; s += String(g_sender_faults[idx].lora_tx_fail); s += " last:"; s += String(static_cast(g_sender_last_errors[idx])); return s; } static bool sanitize_sd_download_path(String &path, String &error) { path.trim(); if (path.length() == 0) { error = "empty"; return false; } if (path.startsWith("dd3/")) { path = "/" + path; } if (path.length() > SD_DOWNLOAD_MAX_PATH) { error = "too_long"; return false; } if (!path.startsWith("/dd3/")) { error = "prefix"; return false; } if (path.indexOf("..") >= 0) { error = "dotdot"; return false; } if (path.indexOf('\\') >= 0) { error = "backslash"; return false; } if (path.indexOf("//") >= 0) { error = "repeated_slash"; return false; } return true; } static bool checkbox_checked(const char *name) { if (!server.hasArg(name)) { return false; } String val = server.arg(name); return val == "on" || val == "true" || val == "1"; } static bool sanitize_history_device_id(const String &input, String &out_device_id) { if (sanitize_device_id(input, out_device_id)) { return true; } if (g_statuses) { for (uint8_t i = 0; i < g_status_count; ++i) { String known = g_statuses[i].last_data.device_id; if (input.equalsIgnoreCase(known) && sanitize_device_id(known, out_device_id)) { return true; } } } return false; } static String sanitize_download_filename(const String &input, bool &clean) { String out; out.reserve(input.length()); clean = true; for (size_t i = 0; i < input.length(); ++i) { unsigned char c = static_cast(input[i]); if (c < 32 || c == 127 || c == '"' || c == '\\' || c == '/') { out += '_'; clean = false; continue; } out += static_cast(c); } out.trim(); if (out.length() == 0) { out = "download.bin"; clean = false; } return out; } static void history_reset() { if (g_history.file) { g_history.file.close(); } if (g_history.bins) { delete[] g_history.bins; } g_history = {}; } static String history_date_from_epoch(uint32_t ts_utc) { time_t t = static_cast(ts_utc); struct tm tm_utc; gmtime_r(&t, &tm_utc); char buf[16]; snprintf(buf, sizeof(buf), "%04d-%02d-%02d", tm_utc.tm_year + 1900, tm_utc.tm_mon + 1, tm_utc.tm_mday); return String(buf); } static bool history_open_next_file() { if (!g_history.active || g_history.done || g_history.error) { return false; } if (g_history.file) { g_history.file.close(); } uint32_t day_ts = g_history.start_ts + static_cast(g_history.day_index) * 86400UL; if (day_ts > g_history.end_ts) { g_history.done = true; return false; } String path = String("/dd3/") + g_history.device_id + "/" + history_date_from_epoch(day_ts) + ".csv"; g_history.file = SD.open(path.c_str(), FILE_READ); g_history.day_index++; return true; } static bool history_parse_line(const char *line, uint32_t &ts_out, float &p_out) { if (!line || line[0] < '0' || line[0] > '9') { return false; } const char *comma = strchr(line, ','); if (!comma) { return false; } char ts_buf[16]; size_t ts_len = static_cast(comma - line); if (ts_len >= sizeof(ts_buf)) { return false; } memcpy(ts_buf, line, ts_len); ts_buf[ts_len] = '\0'; char *end = nullptr; uint32_t ts = static_cast(strtoul(ts_buf, &end, 10)); if (end == ts_buf) { return false; } const char *p_start = comma + 1; const char *p_end = strchr(p_start, ','); char p_buf[16]; size_t p_len = p_end ? static_cast(p_end - p_start) : strlen(p_start); if (p_len >= sizeof(p_buf)) { return false; } memcpy(p_buf, p_start, p_len); p_buf[p_len] = '\0'; char *endp = nullptr; float p = strtof(p_buf, &endp); if (endp == p_buf) { return false; } ts_out = ts; p_out = p; return true; } static void history_tick() { if (!g_history.active || g_history.done || g_history.error) { return; } if (!sd_logger_is_ready()) { g_history.error = true; g_history.error_msg = "sd_not_ready"; return; } uint32_t start_ms = millis(); while (millis() - start_ms < SD_HISTORY_TIME_BUDGET_MS) { if (!g_history.file) { if (!history_open_next_file()) { if (g_history.done) { g_history.active = false; } return; } } if (!g_history.file.available()) { g_history.file.close(); continue; } char line[160]; size_t n = g_history.file.readBytesUntil('\n', line, sizeof(line) - 1); line[n] = '\0'; if (n == 0) { continue; } uint32_t ts = 0; float p = 0.0f; if (!history_parse_line(line, ts, p)) { continue; } if (ts < g_history.start_ts || ts > g_history.end_ts) { continue; } uint32_t idx = (ts - g_history.start_ts) / g_history.res_sec; if (idx >= g_history.bins_count) { continue; } HistoryBin &bin = g_history.bins[idx]; if (bin.count == 0) { bin.ts = g_history.start_ts + idx * g_history.res_sec; bin.value = p; bin.count = 1; g_history.bins_filled++; } else if (g_history.mode == HistoryMode::Avg) { bin.value += p; bin.count++; } else { if (p > bin.value) { bin.value = p; } bin.count++; } } } static String render_sender_block(const SenderStatus &status) { String s; s += "
"; uint8_t idx = 0; if (g_statuses) { for (uint8_t i = 0; i < g_status_count; ++i) { if (&g_statuses[i] == &status) { idx = i; break; } } } String device_id = status.last_data.device_id; String device_id_safe = html_escape(device_id); String device_id_url = url_encode_component(device_id); s += "" + device_id_safe + ""; if (status.has_data && status.last_data.link_valid) { s += " RSSI:" + String(status.last_data.link_rssi_dbm) + " SNR:" + String(status.last_data.link_snr_db, 1); } if (status.has_data) { s += " err_tx:" + String(status.last_data.err_lora_tx); s += " err_last:" + String(static_cast(status.last_data.last_error)); } s += format_faults(idx); s += "
"; if (!status.has_data) { s += "No data"; } else { s += "Energy: " + String(status.last_data.energy_total_kwh, 3) + " kWh
"; s += "Power: " + String(status.last_data.total_power_w, 1) + " W
"; s += "P1/P2/P3: " + String(status.last_data.phase_power_w[0], 1) + " / " + String(status.last_data.phase_power_w[1], 1) + " / " + String(status.last_data.phase_power_w[2], 1) + " W
"; s += "Battery: " + String(status.last_data.battery_percent) + "% (" + String(status.last_data.battery_voltage_v, 2) + " V)"; } s += "
"; return s; } static void append_sd_listing(String &html, const String &dir_path, uint8_t depth, size_t &count) { if (count >= SD_LIST_MAX_FILES || depth > 4) { return; } File dir = SD.open(dir_path.c_str()); if (!dir || !dir.isDirectory()) { return; } File entry = dir.openNextFile(); while (entry && count < SD_LIST_MAX_FILES) { String name = entry.name(); String full_path = name; if (!full_path.startsWith(dir_path)) { if (!dir_path.endsWith("/")) { full_path = dir_path + "/" + name; } else { full_path = dir_path + name; } } if (entry.isDirectory()) { html += "
  • " + html_escape(full_path) + "/
  • "; append_sd_listing(html, full_path, depth + 1, count); } else { String href = full_path; if (!href.startsWith("/")) { href = "/" + href; } String href_enc = url_encode_component(href); html += "
  • " + html_escape(full_path) + ""; html += " (" + String(entry.size()) + " bytes)
  • "; count++; } entry = dir.openNextFile(); } dir.close(); } static void handle_root() { if (!ensure_auth()) { return; } String html = html_header("DD3 Bridge Status"); html += g_is_ap ? "

    Mode: AP

    " : "

    Mode: STA

    "; if (g_statuses) { for (uint8_t i = 0; i < g_status_count; ++i) { html += render_sender_block(g_statuses[i]); } } if (sd_logger_is_ready()) { html += "

    SD Files

      "; size_t count = 0; append_sd_listing(html, "/dd3", 0, count); if (count >= SD_LIST_MAX_FILES) { html += "
    • Listing truncated...
    • "; } html += "
    "; } else { html += "

    SD: not ready

    "; } html += "

    Configure WiFi/MQTT/NTP

    "; html += "

    Manual

    "; html += html_footer(); server.send(200, "text/html", html); } static void handle_wifi_get() { if (!ensure_auth()) { return; } String html = html_header("WiFi/MQTT Config"); html += "
    "; html += "SSID:
    "; html += "Password: "; html += "
    "; html += "MQTT Host:
    "; html += "MQTT Port:
    "; html += "MQTT User:
    "; html += "MQTT Pass: "; html += "
    "; html += "NTP Server 1:
    "; html += "NTP Server 2:
    "; html += "
    "; html += "Web UI User:
    "; html += "Web UI Pass: "; html += "
    "; html += "
    Leaving password blank keeps the existing one.
    "; html += ""; html += "
    "; html += html_footer(); server.send(200, "text/html", html); } static void handle_wifi_post() { if (!ensure_auth()) { return; } WifiMqttConfig cfg = g_config; cfg.ntp_server_1 = g_config.ntp_server_1.length() > 0 ? g_config.ntp_server_1 : "pool.ntp.org"; cfg.ntp_server_2 = g_config.ntp_server_2.length() > 0 ? g_config.ntp_server_2 : "time.nist.gov"; cfg.ssid = server.arg("ssid"); String wifi_pass = server.arg("pass"); if (checkbox_checked("clear_wifi_pass")) { cfg.password = ""; } else if (wifi_pass.length() > 0) { cfg.password = wifi_pass; } cfg.mqtt_host = server.arg("mqhost"); cfg.mqtt_port = static_cast(server.arg("mqport").toInt()); cfg.mqtt_user = server.arg("mquser"); String mqtt_pass = server.arg("mqpass"); if (checkbox_checked("clear_mqtt_pass")) { cfg.mqtt_pass = ""; } else if (mqtt_pass.length() > 0) { cfg.mqtt_pass = mqtt_pass; } String web_user = server.arg("webuser"); if (web_user.length() > 0) { cfg.web_user = web_user; } String web_pass = server.arg("webpass"); if (checkbox_checked("clear_web_pass")) { cfg.web_pass = ""; } else if (web_pass.length() > 0) { cfg.web_pass = web_pass; } if (server.arg("ntp1").length() > 0) { cfg.ntp_server_1 = server.arg("ntp1"); } if (server.arg("ntp2").length() > 0) { cfg.ntp_server_2 = server.arg("ntp2"); } cfg.valid = true; g_config = cfg; g_web_user = cfg.web_user; g_web_pass = cfg.web_pass; wifi_save_config(cfg); server.send(200, "text/html", "Saved. Rebooting..."); delay(1000); ESP.restart(); } static void handle_sender() { if (!ensure_auth()) { return; } if (!g_statuses) { server.send(404, "text/plain", "No senders"); return; } String uri = server.uri(); String device_id = uri.substring(String("/sender/").length()); String device_id_url = url_encode_component(device_id); for (uint8_t i = 0; i < g_status_count; ++i) { if (device_id.equalsIgnoreCase(g_statuses[i].last_data.device_id)) { String html = html_header("Sender " + device_id); html += render_sender_block(g_statuses[i]); html += "

    History (Power)

    "; html += "
    "; html += "Days: "; html += "Res(min): "; html += " "; html += ""; html += "
    "; html += ""; html += "
    "; html += ""; if (g_last_batch_count[i] > 0) { html += "

    Last batch (" + String(g_last_batch_count[i]) + " samples)

    "; html += ""; html += ""; html += ""; for (uint8_t r = 0; r < g_last_batch_count[i]; ++r) { const MeterData &d = g_last_batch[i][r]; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; html += ""; } html += "
    #tse_kwhp_wp1_wp2_wp3_wbat_vbat_pctrssisnrerr_txerr_last
    " + String(r) + "" + String(d.ts_utc) + "" + String(d.energy_total_kwh, 3) + "" + String(d.total_power_w, 1) + "" + String(d.phase_power_w[0], 1) + "" + String(d.phase_power_w[1], 1) + "" + String(d.phase_power_w[2], 1) + "" + String(d.battery_voltage_v, 2) + "" + String(d.battery_percent) + "" + String(d.link_rssi_dbm) + "" + String(d.link_snr_db, 1) + "" + String(d.err_lora_tx) + "" + String(static_cast(d.last_error)) + "
    "; } html += html_footer(); server.send(200, "text/html", html); return; } } server.send(404, "text/plain", "Not found"); } static void handle_manual() { if (!ensure_auth()) { return; } String html = html_header("DD3 Manual"); html += "
      "; html += "
    • Energy: total kWh since meter start.
    • "; html += "
    • Power: total active power in W.
    • "; html += "
    • P1/P2/P3: phase power in W.
    • "; html += "
    • Battery: percent with voltage in V.
    • "; html += "
    • RSSI/SNR: LoRa link quality from last packet.
    • "; html += "
    • err_tx: sender-side LoRa TX error counter.
    • "; html += "
    • err_last: last error code (0=None, 1=MeterRead, 2=Decode, 3=LoraTx, 4=TimeSync).
    • "; html += "
    • faults m/d/tx: receiver-side counters (meter read fails, decode fails, LoRa TX fails).
    • "; html += "
    • faults last: last receiver-side error code (same mapping as err_last).
    • "; html += "
    "; html += html_footer(); server.send(200, "text/html", html); } static void handle_history_start() { if (!ensure_auth()) { return; } if (!sd_logger_is_ready()) { server.send(200, "application/json", "{\"ok\":false,\"error\":\"sd_not_ready\"}"); return; } if (!time_is_synced()) { server.send(200, "application/json", "{\"ok\":false,\"error\":\"time_not_synced\"}"); return; } String device_id_arg = server.arg("device_id"); String device_id; if (!sanitize_history_device_id(device_id_arg, device_id)) { server.send(200, "application/json", "{\"ok\":false,\"error\":\"bad_device_id\"}"); return; } uint16_t days = static_cast(server.arg("days").toInt()); uint16_t res_min = static_cast(server.arg("res").toInt()); String mode_str = server.arg("mode"); if (device_id.length() == 0 || days == 0 || res_min == 0) { server.send(200, "application/json", "{\"ok\":false,\"error\":\"bad_params\"}"); return; } if (days > SD_HISTORY_MAX_DAYS) { days = SD_HISTORY_MAX_DAYS; } if (res_min < SD_HISTORY_MIN_RES_MIN) { res_min = SD_HISTORY_MIN_RES_MIN; } uint32_t bins = (static_cast(days) * 24UL * 60UL) / res_min; if (bins == 0 || bins > SD_HISTORY_MAX_BINS) { String resp = String("{\"ok\":false,\"error\":\"too_many_bins\",\"max_bins\":") + SD_HISTORY_MAX_BINS + "}"; server.send(200, "application/json", resp); return; } history_reset(); g_history.active = true; g_history.done = false; g_history.error = false; g_history.device_id = device_id; g_history.mode = (mode_str == "max") ? HistoryMode::Max : HistoryMode::Avg; g_history.res_sec = static_cast(res_min) * 60UL; g_history.bins_count = bins; g_history.day_index = 0; g_history.bins = new (std::nothrow) HistoryBin[bins]; if (!g_history.bins) { g_history.error = true; g_history.error_msg = "oom"; server.send(200, "application/json", "{\"ok\":false,\"error\":\"oom\"}"); return; } for (uint32_t i = 0; i < bins; ++i) { g_history.bins[i] = {}; } g_history.end_ts = time_get_utc(); uint32_t span = static_cast(days) * 86400UL; g_history.start_ts = g_history.end_ts > span ? (g_history.end_ts - span) : 0; if (g_history.res_sec > 0) { g_history.start_ts = (g_history.start_ts / g_history.res_sec) * g_history.res_sec; } String resp = String("{\"ok\":true,\"bins\":") + bins + "}"; server.send(200, "application/json", resp); } static void handle_history_data() { if (!ensure_auth()) { return; } String device_id_arg = server.arg("device_id"); String device_id; if (!sanitize_history_device_id(device_id_arg, device_id)) { server.send(200, "application/json", "{\"ready\":false,\"error\":\"bad_device_id\"}"); return; } if (!g_history.bins || device_id.length() == 0 || device_id != g_history.device_id) { server.send(200, "application/json", "{\"ready\":false,\"error\":\"no_job\"}"); return; } if (g_history.error) { String resp = String("{\"ready\":false,\"error\":\"") + g_history.error_msg + "\"}"; server.send(200, "application/json", resp); return; } if (g_history.active && !g_history.done) { uint32_t progress = g_history.bins_count == 0 ? 0 : (g_history.bins_filled * 100UL / g_history.bins_count); String resp = String("{\"ready\":false,\"progress\":") + progress + "}"; server.send(200, "application/json", resp); return; } server.setContentLength(CONTENT_LENGTH_UNKNOWN); server.send(200, "application/json", ""); server.sendContent("{\"ready\":true,\"series\":["); bool first = true; for (uint32_t i = 0; i < g_history.bins_count; ++i) { const HistoryBin &bin = g_history.bins[i]; if (!first) { server.sendContent(","); } first = false; float value = NAN; if (bin.count > 0) { value = (g_history.mode == HistoryMode::Avg) ? (bin.value / static_cast(bin.count)) : bin.value; } if (bin.count == 0) { server.sendContent(String("[") + bin.ts + ",null]"); } else { server.sendContent(String("[") + bin.ts + "," + String(value, 2) + "]"); } } server.sendContent("]}"); } static void handle_sd_download() { if (!ensure_auth()) { return; } if (!sd_logger_is_ready()) { server.send(404, "text/plain", "SD not ready"); return; } String path = server.arg("path"); String error; if (!sanitize_sd_download_path(path, error)) { if (SERIAL_DEBUG_MODE) { Serial.printf("sd: reject path '%s' reason=%s\n", path.c_str(), error.c_str()); } server.send(400, "text/plain", "Invalid path"); return; } File f = SD.open(path.c_str(), FILE_READ); if (!f) { server.send(404, "text/plain", "Not found"); return; } size_t size = f.size(); String filename = path.substring(path.lastIndexOf('/') + 1); bool name_clean = true; (void)name_clean; String safe_name = sanitize_download_filename(filename, name_clean); String cd = "attachment; filename=\"" + safe_name + "\"; filename*=UTF-8''" + url_encode_component(safe_name); server.sendHeader("Content-Disposition", cd); server.setContentLength(size); const char *content_type = "application/octet-stream"; if (filename.endsWith(".csv")) { content_type = "text/csv"; } else if (filename.endsWith(".txt")) { content_type = "text/plain"; } server.send(200, content_type, ""); WiFiClient client = server.client(); uint8_t buf[512]; while (f.available()) { size_t n = f.read(buf, sizeof(buf)); if (n == 0) { break; } client.write(buf, n); delay(0); } f.close(); } void web_server_set_config(const WifiMqttConfig &config) { g_config = config; g_web_user = config.web_user; g_web_pass = config.web_pass; } void web_server_set_sender_faults(const FaultCounters *faults, const FaultType *last_errors) { g_sender_faults = faults; g_sender_last_errors = last_errors; } void web_server_set_last_batch(uint8_t sender_index, const MeterData *samples, size_t count) { if (!samples || sender_index >= NUM_SENDERS) { return; } if (count > METER_BATCH_MAX_SAMPLES) { count = METER_BATCH_MAX_SAMPLES; } g_last_batch_count[sender_index] = static_cast(count); for (size_t i = 0; i < count; ++i) { g_last_batch[sender_index][i] = samples[i]; } } void web_server_begin_ap(const SenderStatus *statuses, uint8_t count) { g_statuses = statuses; g_status_count = count; g_is_ap = true; server.on("/", handle_root); server.on("/manual", handle_manual); server.on("/history/start", handle_history_start); server.on("/history/data", handle_history_data); server.on("/sd/download", handle_sd_download); server.on("/wifi", HTTP_GET, handle_wifi_get); server.on("/wifi", HTTP_POST, handle_wifi_post); server.on("/sender/", handle_sender); server.onNotFound([]() { if (server.uri().startsWith("/sender/")) { handle_sender(); return; } server.send(404, "text/plain", "Not found"); }); server.begin(); } void web_server_begin_sta(const SenderStatus *statuses, uint8_t count) { g_statuses = statuses; g_status_count = count; g_is_ap = false; server.on("/", handle_root); server.on("/manual", handle_manual); server.on("/sender/", handle_sender); server.on("/history/start", handle_history_start); server.on("/history/data", handle_history_data); server.on("/sd/download", handle_sd_download); server.on("/wifi", HTTP_GET, handle_wifi_get); server.on("/wifi", HTTP_POST, handle_wifi_post); server.onNotFound([]() { if (server.uri().startsWith("/sender/")) { handle_sender(); return; } server.send(404, "text/plain", "Not found"); }); server.begin(); } void web_server_loop() { history_tick(); server.handleClient(); }