diff --git a/README.md b/README.md index 9f6f3bb..66f099d 100644 --- a/README.md +++ b/README.md @@ -21,17 +21,23 @@ Variants: - SCL: GPIO22 - RST: **not used** (SSD1306 init uses `-1` reset pin) - I2C address: 0x3C +- microSD (on-board) + - CS: GPIO13 + - MOSI: GPIO15 + - SCK: GPIO14 + - MISO: GPIO2 - I2C RTC (DS3231) - SDA: GPIO21 - SCL: GPIO22 - I2C address: 0x68 - Battery ADC: GPIO35 (via on-board divider) -- **Role select**: GPIO13 (INPUT_PULLDOWN) - - LOW = Sender - - HIGH = Receiver -- **OLED control**: GPIO14 (INPUT_PULLDOWN) +- **Role select**: GPIO14 (INPUT_PULLDOWN, sampled at boot) + - HIGH = Sender + - LOW/floating = Receiver +- **OLED control**: GPIO13 (INPUT_PULLDOWN, sender only) - HIGH = force OLED on - LOW = allow auto-off after timeout + - Not used on receiver (OLED always on) - Smart meter UART RX: GPIO34 (input-only, always connected) ### Notes on GPIOs @@ -257,6 +263,8 @@ inline constexpr uint16_t EXPECTED_SENDER_IDS[NUM_SENDERS] = { 0xF19C }; - `/sender/`: per-sender details - Sender IDs on `/` are clickable (open sender page in a new tab). - In STA mode, the UI is also available via the board’s IP/hostname on your WiFi network. +- Main page shows SD card file listing (downloadable). +- Sender page includes a history chart (power) with configurable range/resolution/mode. ## MQTT - Topic: `smartmeter//state` @@ -293,6 +301,8 @@ Key timing settings in `include/config.h`: - `SERIAL_DEBUG_MODE` / `SERIAL_DEBUG_DUMP_JSON` - `LORA_SEND_BYPASS` (debug only) - `ENABLE_SD_LOGGING` / `PIN_SD_CS` + - `SD_HISTORY_MAX_DAYS` / `SD_HISTORY_MIN_RES_MIN` + - `SD_HISTORY_MAX_BINS` / `SD_HISTORY_TIME_BUDGET_MS` ## Limits & Known Constraints - **Compression**: MeterData uses lightweight RLE (good for JSON but not optimal). @@ -309,6 +319,9 @@ Optional CSV logging to microSD (FAT32) when `ENABLE_SD_LOGGING = true`. - Columns: `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` - `err_last` is written as text (`meter`, `decode`, `loratx`) only on the last sample of a batch that reports an error. +- Files are downloadable from the main UI page. +- History chart on sender page stream-parses CSVs and bins data in the background. +- SD uses the on-board microSD SPI pins (CS=13, MOSI=15, SCK=14, MISO=2). ## Files & Modules - `include/config.h`, `src/config.cpp`: pins, radio settings, sender IDs @@ -331,7 +344,7 @@ Optional CSV logging to microSD (FAT32) when `ENABLE_SD_LOGGING = true`. 1. Set role jumper on GPIO13: - LOW: sender - HIGH: receiver -2. OLED control on GPIO14: +2. OLED control on GPIO13: - HIGH: always on - LOW: auto-off after 10 minutes 3. Build and upload: diff --git a/include/config.h b/include/config.h index 7da81f1..5bcc3c7 100644 --- a/include/config.h +++ b/include/config.h @@ -39,8 +39,8 @@ constexpr uint8_t OLED_HEIGHT = 64; constexpr uint8_t PIN_BAT_ADC = 35; -constexpr uint8_t PIN_ROLE = 13; -constexpr uint8_t PIN_OLED_CTRL = 14; +constexpr uint8_t PIN_ROLE = 14; +constexpr uint8_t PIN_OLED_CTRL = 13; constexpr uint8_t PIN_METER_RX = 34; @@ -73,11 +73,18 @@ constexpr uint8_t BATCH_QUEUE_DEPTH = 10; constexpr BatchRetryPolicy BATCH_RETRY_POLICY = BatchRetryPolicy::Keep; constexpr uint32_t WATCHDOG_TIMEOUT_SEC = 120; constexpr bool ENABLE_HA_DISCOVERY = true; -constexpr bool SERIAL_DEBUG_MODE = false; +constexpr bool SERIAL_DEBUG_MODE = true; constexpr bool SERIAL_DEBUG_DUMP_JSON = false; constexpr bool LORA_SEND_BYPASS = false; -constexpr bool ENABLE_SD_LOGGING = false; -constexpr uint8_t PIN_SD_CS = 25; +constexpr bool ENABLE_SD_LOGGING = true; +constexpr uint8_t PIN_SD_CS = 13; +constexpr uint8_t PIN_SD_MOSI = 15; +constexpr uint8_t PIN_SD_MISO = 2; +constexpr uint8_t PIN_SD_SCK = 14; +constexpr uint16_t SD_HISTORY_MAX_DAYS = 30; +constexpr uint16_t SD_HISTORY_MIN_RES_MIN = 1; +constexpr uint16_t SD_HISTORY_MAX_BINS = 4000; +constexpr uint16_t SD_HISTORY_TIME_BUDGET_MS = 10; constexpr const char *AP_SSID_PREFIX = "DD3-Bridge-"; constexpr const char *AP_PASSWORD = "changeme123"; diff --git a/src/config.cpp b/src/config.cpp index cf2fe9d..0632bce 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -2,5 +2,5 @@ DeviceRole detect_role() { pinMode(PIN_ROLE, INPUT_PULLDOWN); - return digitalRead(PIN_ROLE) == HIGH ? DeviceRole::Receiver : DeviceRole::Sender; + return digitalRead(PIN_ROLE) == HIGH ? DeviceRole::Sender : DeviceRole::Receiver; } diff --git a/src/display_ui.cpp b/src/display_ui.cpp index 1327004..e22f9ba 100644 --- a/src/display_ui.cpp +++ b/src/display_ui.cpp @@ -69,7 +69,9 @@ void display_power_down() { } void display_init() { - pinMode(PIN_OLED_CTRL, INPUT_PULLDOWN); + if (g_role == DeviceRole::Sender) { + pinMode(PIN_OLED_CTRL, INPUT_PULLDOWN); + } Wire.begin(PIN_OLED_SDA, PIN_OLED_SCL); Wire.setClock(100000); g_display_ready = display.begin(SSD1306_SWITCHCAPVCC, OLED_I2C_ADDR); @@ -373,7 +375,10 @@ void display_tick() { } return; } - bool ctrl_high = digitalRead(PIN_OLED_CTRL) == HIGH; + bool ctrl_high = false; + if (g_role == DeviceRole::Sender) { + ctrl_high = digitalRead(PIN_OLED_CTRL) == HIGH; + } bool in_boot_window = (millis() - g_boot_ms) < OLED_AUTO_OFF_MS; if (g_role == DeviceRole::Receiver) { diff --git a/src/main.cpp b/src/main.cpp index 488fa37..32e3fc8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -603,6 +603,7 @@ void setup() { g_boot_ms = millis(); g_role = detect_role(); init_device_ids(g_short_id, g_device_id, sizeof(g_device_id)); + display_set_role(g_role); if (SERIAL_DEBUG_MODE) { #ifdef ARDUINO_ARCH_ESP32 serial_debug_printf("boot: reset_reason=%d", static_cast(esp_reset_reason())); @@ -615,7 +616,6 @@ void setup() { display_init(); time_rtc_init(); time_try_load_from_rtc(); - display_set_role(g_role); display_set_self_ids(g_short_id, g_device_id); if (g_role == DeviceRole::Sender) { diff --git a/src/sd_logger.cpp b/src/sd_logger.cpp index ceabfcd..085e2b4 100644 --- a/src/sd_logger.cpp +++ b/src/sd_logger.cpp @@ -5,6 +5,7 @@ #include static bool g_sd_ready = false; +static SPIClass *g_sd_spi = nullptr; static const char *fault_text(FaultType fault) { switch (fault) { @@ -43,7 +44,20 @@ void sd_logger_init() { g_sd_ready = false; return; } - g_sd_ready = SD.begin(PIN_SD_CS); + if (!g_sd_spi) { + g_sd_spi = new SPIClass(HSPI); + } + g_sd_spi->begin(PIN_SD_SCK, PIN_SD_MISO, PIN_SD_MOSI, PIN_SD_CS); + g_sd_ready = SD.begin(PIN_SD_CS, *g_sd_spi); + if (SERIAL_DEBUG_MODE) { + if (g_sd_ready) { + uint8_t type = SD.cardType(); + uint64_t size = SD.cardSize(); + Serial.printf("sd: ok type=%u size=%llu\n", static_cast(type), static_cast(size)); + } else { + Serial.println("sd: init failed"); + } + } } bool sd_logger_is_ready() { diff --git a/src/web_server.cpp b/src/web_server.cpp index 4682c5d..7fdc81b 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -2,6 +2,13 @@ #include #include "wifi_manager.h" #include "config.h" +#include "sd_logger.h" +#include "time_manager.h" +#include +#include +#include +#include +#include static WebServer server(80); static const SenderStatus *g_statuses = nullptr; @@ -13,6 +20,37 @@ 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 String html_header(const String &title) { String h = ""; h += "" + title + ""; @@ -40,6 +78,143 @@ static String format_faults(uint8_t idx) { return s; } +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 += "
"; @@ -76,6 +251,42 @@ static String render_sender_block(const SenderStatus &status) { 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 += "
  • " + full_path + "/
  • "; + append_sd_listing(html, full_path, depth + 1, count); + } else { + String href = full_path; + if (!href.startsWith("/")) { + href = "/" + href; + } + html += "
  • " + full_path + ""; + html += " (" + String(entry.size()) + " bytes)
  • "; + count++; + } + entry = dir.openNextFile(); + } + dir.close(); +} + static void handle_root() { String html = html_header("DD3 Bridge Status"); html += g_is_ap ? "

    Mode: AP

    " : "

    Mode: STA

    "; @@ -86,6 +297,18 @@ static void handle_root() { } } + 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(); @@ -143,6 +366,63 @@ static void handle_sender() { 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 += ""; @@ -193,6 +473,149 @@ static void handle_manual() { server.send(200, "text/html", html); } +static void handle_history_start() { + 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 = server.arg("device_id"); + 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() { + String device_id = server.arg("device_id"); + 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 (!sd_logger_is_ready()) { + server.send(404, "text/plain", "SD not ready"); + return; + } + String path = server.arg("path"); + if (path.startsWith("dd3/")) { + path = "/" + path; + } + if (!path.startsWith("/dd3/")) { + 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); + server.sendHeader("Content-Disposition", "attachment; filename=\"" + filename + "\""); + 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; } @@ -222,6 +645,9 @@ void web_server_begin_ap(const SenderStatus *statuses, uint8_t count) { 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); @@ -243,6 +669,9 @@ void web_server_begin_sta(const SenderStatus *statuses, uint8_t count) { 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([]() { @@ -256,5 +685,6 @@ void web_server_begin_sta(const SenderStatus *statuses, uint8_t count) { } void web_server_loop() { + history_tick(); server.handleClient(); }