Add SD history UI and pin remap
- Add SD history chart + download listing to web UI - Use HSPI for SD and fix SD pin mapping - Swap role/OLED control pins and update role detection - Update README pin mapping and SD/history docs
This commit is contained in:
23
README.md
23
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/<device_id>`: 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/<deviceId>/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:
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,9 @@ void display_power_down() {
|
||||
}
|
||||
|
||||
void display_init() {
|
||||
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) {
|
||||
|
||||
@@ -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<int>(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) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <time.h>
|
||||
|
||||
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<unsigned>(type), static_cast<unsigned long long>(size));
|
||||
} else {
|
||||
Serial.println("sd: init failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool sd_logger_is_ready() {
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
#include <WebServer.h>
|
||||
#include "wifi_manager.h"
|
||||
#include "config.h"
|
||||
#include "sd_logger.h"
|
||||
#include "time_manager.h"
|
||||
#include <SD.h>
|
||||
#include <WiFi.h>
|
||||
#include <time.h>
|
||||
#include <new>
|
||||
#include <stdlib.h>
|
||||
|
||||
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 = "<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>";
|
||||
h += "<title>" + title + "</title></head><body>";
|
||||
@@ -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<time_t>(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<uint32_t>(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<size_t>(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<uint32_t>(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<size_t>(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 += "<div style='margin-bottom:10px;padding:6px;border:1px solid #ccc'>";
|
||||
@@ -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 += "<li><strong>" + full_path + "/</strong></li>";
|
||||
append_sd_listing(html, full_path, depth + 1, count);
|
||||
} else {
|
||||
String href = full_path;
|
||||
if (!href.startsWith("/")) {
|
||||
href = "/" + href;
|
||||
}
|
||||
html += "<li><a href='/sd/download?path=" + href + "' target='_blank' rel='noopener noreferrer'>" + full_path + "</a>";
|
||||
html += " (" + String(entry.size()) + " bytes)</li>";
|
||||
count++;
|
||||
}
|
||||
entry = dir.openNextFile();
|
||||
}
|
||||
dir.close();
|
||||
}
|
||||
|
||||
static void handle_root() {
|
||||
String html = html_header("DD3 Bridge Status");
|
||||
html += g_is_ap ? "<p>Mode: AP</p>" : "<p>Mode: STA</p>";
|
||||
@@ -86,6 +297,18 @@ static void handle_root() {
|
||||
}
|
||||
}
|
||||
|
||||
if (sd_logger_is_ready()) {
|
||||
html += "<h3>SD Files</h3><ul>";
|
||||
size_t count = 0;
|
||||
append_sd_listing(html, "/dd3", 0, count);
|
||||
if (count >= SD_LIST_MAX_FILES) {
|
||||
html += "<li>Listing truncated...</li>";
|
||||
}
|
||||
html += "</ul>";
|
||||
} else {
|
||||
html += "<p>SD: not ready</p>";
|
||||
}
|
||||
|
||||
html += "<p><a href='/wifi'>Configure WiFi/MQTT/NTP</a></p>";
|
||||
html += "<p><a href='/manual'>Manual</a></p>";
|
||||
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 += "<h3>History (Power)</h3>";
|
||||
html += "<div>";
|
||||
html += "Days: <input id='hist_days' type='number' min='1' max='" + String(SD_HISTORY_MAX_DAYS) + "' value='7' style='width:60px'> ";
|
||||
html += "Res(min): <input id='hist_res' type='number' min='" + String(SD_HISTORY_MIN_RES_MIN) + "' value='5' style='width:60px'> ";
|
||||
html += "<select id='hist_mode'><option value='avg'>avg</option><option value='max'>max</option></select> ";
|
||||
html += "<button onclick='drawHistory()'>Draw</button>";
|
||||
html += "<div id='hist_status' style='font-size:12px;margin-top:4px;color:#666;'></div>";
|
||||
html += "<canvas id='hist_canvas' width='320' height='140' style='width:100%;max-width:520px;border:1px solid #ccc;margin-top:6px;'></canvas>";
|
||||
html += "</div>";
|
||||
html += "<script>";
|
||||
html += "let histTimer=null;";
|
||||
html += "function histStatus(msg){document.getElementById('hist_status').textContent=msg;}";
|
||||
html += "function drawHistory(){";
|
||||
html += "const days=document.getElementById('hist_days').value;";
|
||||
html += "const res=document.getElementById('hist_res').value;";
|
||||
html += "const mode=document.getElementById('hist_mode').value;";
|
||||
html += "histStatus('Starting...');";
|
||||
html += "fetch(`/history/start?device_id=" + device_id + "&days=${days}&res=${res}&mode=${mode}`)";
|
||||
html += ".then(r=>r.json()).then(j=>{";
|
||||
html += "if(!j.ok){histStatus('Error: '+(j.error||'failed'));return;}";
|
||||
html += "if(histTimer){clearInterval(histTimer);}";
|
||||
html += "histTimer=setInterval(()=>fetchHistory(),1000);";
|
||||
html += "fetchHistory();";
|
||||
html += "});";
|
||||
html += "}";
|
||||
html += "function fetchHistory(){";
|
||||
html += "fetch(`/history/data?device_id=" + device_id + "`).then(r=>r.json()).then(j=>{";
|
||||
html += "if(!j.ready){histStatus(j.error?('Error: '+j.error):('Processing... '+(j.progress||0)+'%'));return;}";
|
||||
html += "if(histTimer){clearInterval(histTimer);histTimer=null;}";
|
||||
html += "renderChart(j.series);";
|
||||
html += "histStatus('Done');";
|
||||
html += "});";
|
||||
html += "}";
|
||||
html += "function renderChart(series){";
|
||||
html += "const canvas=document.getElementById('hist_canvas');";
|
||||
html += "const w=canvas.clientWidth;const h=canvas.clientHeight;";
|
||||
html += "canvas.width=w;canvas.height=h;";
|
||||
html += "const ctx=canvas.getContext('2d');";
|
||||
html += "ctx.clearRect(0,0,w,h);";
|
||||
html += "if(!series||series.length===0){ctx.fillText('No data',10,20);return;}";
|
||||
html += "let min=Infinity,max=-Infinity;";
|
||||
html += "for(const p of series){if(p[1]===null)continue; if(p[1]<min)min=p[1]; if(p[1]>max)max=p[1];}";
|
||||
html += "if(!isFinite(min)||!isFinite(max)){ctx.fillText('No data',10,20);return;}";
|
||||
html += "if(min===max){min=0;}";
|
||||
html += "ctx.strokeStyle='#333';ctx.lineWidth=1;ctx.beginPath();";
|
||||
html += "let first=true;";
|
||||
html += "for(let i=0;i<series.length;i++){";
|
||||
html += "const v=series[i][1];";
|
||||
html += "if(v===null)continue;";
|
||||
html += "const x=(i/(series.length-1))* (w-2) + 1;";
|
||||
html += "const y=h-2-((v-min)/(max-min))*(h-4);";
|
||||
html += "if(first){ctx.moveTo(x,y);first=false;} else {ctx.lineTo(x,y);} }";
|
||||
html += "ctx.stroke();";
|
||||
html += "ctx.fillStyle='#666';ctx.fillText(min.toFixed(0)+'W',4,h-4);";
|
||||
html += "ctx.fillText(max.toFixed(0)+'W',4,12);";
|
||||
html += "}";
|
||||
html += "</script>";
|
||||
if (g_last_batch_count[i] > 0) {
|
||||
html += "<h3>Last batch (" + String(g_last_batch_count[i]) + " samples)</h3>";
|
||||
html += "<table border='1' cellspacing='0' cellpadding='3'>";
|
||||
@@ -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<uint16_t>(server.arg("days").toInt());
|
||||
uint16_t res_min = static_cast<uint16_t>(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<uint32_t>(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<uint32_t>(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<uint32_t>(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<float>(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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user