From 4de1dda82b84d0f308c18df0f7e99484de416c11 Mon Sep 17 00:00:00 2001 From: acidburns Date: Mon, 16 Feb 2026 08:57:16 +0100 Subject: [PATCH] Add human-readable UTC time alongside epoch in web UI and CSV --- src/sd_logger.cpp | 20 +++++++++++++++- src/web_server.cpp | 59 ++++++++++++++++++++++++++++++---------------- 2 files changed, 58 insertions(+), 21 deletions(-) diff --git a/src/sd_logger.cpp b/src/sd_logger.cpp index 085e2b4..5041c6f 100644 --- a/src/sd_logger.cpp +++ b/src/sd_logger.cpp @@ -39,6 +39,21 @@ static String format_date_utc(uint32_t ts_utc) { return String(buf); } +static String format_hms_utc(uint32_t ts_utc) { + if (ts_utc == 0) { + return ""; + } + time_t t = static_cast(ts_utc); + struct tm tm_utc; + gmtime_r(&t, &tm_utc); + char buf[16]; + snprintf(buf, sizeof(buf), "%02d:%02d:%02d", + tm_utc.tm_hour, + tm_utc.tm_min, + tm_utc.tm_sec); + return String(buf); +} + void sd_logger_init() { if (!ENABLE_SD_LOGGING) { g_sd_ready = false; @@ -87,11 +102,14 @@ void sd_logger_log_sample(const MeterData &data, bool include_error_text) { } if (new_file) { - f.println("ts_utc,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last"); + f.println("ts_utc,ts_hms_utc,p_w,p1_w,p2_w,p3_w,e_kwh,bat_v,bat_pct,rssi,snr,err_m,err_d,err_tx,err_last"); } + String ts_hms_utc = format_hms_utc(data.ts_utc); f.print(data.ts_utc); f.print(','); + f.print(ts_hms_utc); + f.print(','); f.print(data.total_power_w, 1); f.print(','); f.print(data.phase_power_w[0], 1); diff --git a/src/web_server.cpp b/src/web_server.cpp index 65edb7c..920fb52 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -57,24 +57,28 @@ static HistoryJob g_history = {}; static constexpr size_t SD_LIST_MAX_FILES = 200; static constexpr size_t SD_DOWNLOAD_MAX_PATH = 160; -static String format_utc_timestamp(uint32_t ts_utc) { +static String format_utc_hms(uint32_t ts_utc) { if (ts_utc == 0) { return "n/a"; } time_t t = static_cast(ts_utc); struct tm tm_utc; gmtime_r(&t, &tm_utc); - char buf[32]; - snprintf(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d UTC", - tm_utc.tm_year + 1900, - tm_utc.tm_mon + 1, - tm_utc.tm_mday, + char buf[16]; + snprintf(buf, sizeof(buf), "%02d:%02d:%02d UTC", tm_utc.tm_hour, tm_utc.tm_min, tm_utc.tm_sec); return String(buf); } +static String format_epoch_hms(uint32_t ts_utc) { + if (ts_utc == 0) { + return "n/a"; + } + return String(ts_utc) + " (" + format_utc_hms(ts_utc) + ")"; +} + static uint32_t timestamp_age_seconds(uint32_t ts_utc) { uint32_t now_utc = time_get_utc(); if (ts_utc == 0 || now_utc < ts_utc) { @@ -289,18 +293,32 @@ static bool history_parse_line(const char *line, uint32_t &ts_out, float &p_out) 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; + auto parse_float_field = [](const char *start, const char *end, float &out) -> bool { + if (!start) { + return false; + } + char p_buf[16]; + size_t p_len = end ? static_cast(end - start) : strlen(start); + if (p_len == 0 || p_len >= sizeof(p_buf)) { + return false; + } + memcpy(p_buf, start, p_len); + p_buf[p_len] = '\0'; + char *endp = nullptr; + out = strtof(p_buf, &endp); + return endp != p_buf; + }; + + const char *field2_start = comma + 1; + const char *field2_end = strchr(field2_start, ','); + float p = 0.0f; + bool parsed_power = parse_float_field(field2_start, field2_end, p); + if (!parsed_power && field2_end) { + const char *field3_start = field2_end + 1; + const char *field3_end = strchr(field3_start, ','); + parsed_power = parse_float_field(field3_start, field3_end, p); } - 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) { + if (!parsed_power) { return false; } ts_out = ts; @@ -400,7 +418,7 @@ static String render_sender_block(const SenderStatus &status) { if (!status.has_data) { s += "No data"; } else { - s += "Last update: " + format_utc_timestamp(status.last_update_ts_utc); + s += "Last update: " + format_epoch_hms(status.last_update_ts_utc); if (time_is_synced()) { s += " (" + String(timestamp_age_seconds(status.last_update_ts_utc)) + "s ago)"; } @@ -419,7 +437,7 @@ static String render_sender_block(const SenderStatus &status) { duplicate_pct = (static_cast(duplicate_batches) * 100.0f) / static_cast(total_batches); } s += "
Dup batches: " + String(duplicate_batches) + "/" + String(total_batches) + " (" + String(duplicate_pct, 1) + "%)"; - s += " last: " + format_utc_timestamp(status.rx_last_duplicate_ts_utc); + s += " last: " + format_epoch_hms(status.rx_last_duplicate_ts_utc); if (time_is_synced() && status.rx_last_duplicate_ts_utc > 0) { s += " (" + String(timestamp_age_seconds(status.rx_last_duplicate_ts_utc)) + "s ago)"; } @@ -647,13 +665,14 @@ static void handle_sender() { if (g_last_batch_count[i] > 0) { html += "

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

"; html += ""; - 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 += "";
#tse_kwhp_wp1_wp2_wp3_w
#ts_utcts_hms_utce_kwhp_wp1_wp2_wp3_wbat_vbat_pctrssisnrerr_txerr_lastrx_reject
" + String(r) + "" + String(d.ts_utc) + "" + format_utc_hms(d.ts_utc) + "" + String(d.energy_total_kwh, 2) + "" + String(round_power_w(d.total_power_w)) + "" + String(round_power_w(d.phase_power_w[0])) + "