|
|
@@ -12,6 +12,7 @@
|
|
|
* GET /api/data - Full MPPT data as JSON
|
|
|
* GET /api/status - Connection & device status
|
|
|
* GET /api/health - Health check (simple)
|
|
|
+ * GET /api/history - Historical data with pagination & time range
|
|
|
* GET / - HTML dashboard page
|
|
|
*/
|
|
|
|
|
|
@@ -25,16 +26,11 @@
|
|
|
|
|
|
/* ── JSON Serialization ────────────────────────────────────────── */
|
|
|
|
|
|
-/**
|
|
|
- * Build JSON response from MPPT data using cJSON.
|
|
|
- * Caller must free the returned string with cJSON_Delete / free.
|
|
|
- */
|
|
|
static char *build_data_json(const mppt_data_t *data)
|
|
|
{
|
|
|
cJSON *root = cJSON_CreateObject();
|
|
|
if (!root) return NULL;
|
|
|
|
|
|
- /* Timestamp */
|
|
|
char timebuf[64];
|
|
|
if (data->data_valid && data->last_update > 0) {
|
|
|
struct tm *tm_info = localtime(&data->last_update);
|
|
|
@@ -46,24 +42,20 @@ static char *build_data_json(const mppt_data_t *data)
|
|
|
|
|
|
cJSON_AddBoolToObject(root, "data_valid", data->data_valid ? 1 : 0);
|
|
|
|
|
|
- /* Battery */
|
|
|
cJSON *battery = cJSON_CreateObject();
|
|
|
cJSON_AddNumberToObject(battery, "voltage", data->battery_voltage);
|
|
|
cJSON_AddNumberToObject(battery, "soc", data->battery_soc);
|
|
|
cJSON_AddNumberToObject(battery, "string_count", data->battery_string);
|
|
|
cJSON_AddItemToObject(root, "battery", battery);
|
|
|
|
|
|
- /* PV (Solar) */
|
|
|
cJSON *pv = cJSON_CreateObject();
|
|
|
cJSON_AddNumberToObject(pv, "voltage", data->pv_voltage);
|
|
|
cJSON_AddNumberToObject(pv, "current", data->pv_charge_current);
|
|
|
cJSON_AddNumberToObject(pv, "power", data->pv_input_power);
|
|
|
cJSON_AddItemToObject(root, "pv", pv);
|
|
|
|
|
|
- /* Energy */
|
|
|
cJSON_AddNumberToObject(root, "cumulative_energy_kwh", data->cumulative_energy);
|
|
|
|
|
|
- /* Device */
|
|
|
cJSON *device = cJSON_CreateObject();
|
|
|
cJSON_AddNumberToObject(device, "temperature", data->machine_temp);
|
|
|
cJSON_AddNumberToObject(device, "fault_code", data->fault_code);
|
|
|
@@ -75,7 +67,6 @@ static char *build_data_json(const mppt_data_t *data)
|
|
|
cJSON_AddNumberToObject(device, "device_type", data->device_type);
|
|
|
cJSON_AddItemToObject(root, "device", device);
|
|
|
|
|
|
- /* Raw registers */
|
|
|
cJSON *raw = cJSON_CreateArray();
|
|
|
for (int i = 0; i < MODBUS_REG_COUNT; i++) {
|
|
|
cJSON_AddItemToArray(raw, cJSON_CreateNumber(data->reg[i]));
|
|
|
@@ -87,16 +78,12 @@ static char *build_data_json(const mppt_data_t *data)
|
|
|
return json_str;
|
|
|
}
|
|
|
|
|
|
-/**
|
|
|
- * Build status JSON (connection info + device status).
|
|
|
- */
|
|
|
static char *build_status_json(const mppt_data_t *data,
|
|
|
const modbus_conn_t *conn)
|
|
|
{
|
|
|
cJSON *root = cJSON_CreateObject();
|
|
|
if (!root) return NULL;
|
|
|
|
|
|
- /* Connection status */
|
|
|
cJSON *connection = cJSON_CreateObject();
|
|
|
const char *state_str;
|
|
|
switch (conn->state) {
|
|
|
@@ -111,12 +98,10 @@ static char *build_status_json(const mppt_data_t *data,
|
|
|
cJSON_AddNumberToObject(connection, "reconnect_count", conn->reconnect_count);
|
|
|
cJSON_AddItemToObject(root, "connection", connection);
|
|
|
|
|
|
- /* Device status summary */
|
|
|
cJSON_AddBoolToObject(root, "device_online", data->data_valid ? 1 : 0);
|
|
|
cJSON_AddNumberToObject(root, "consecutive_failures",
|
|
|
data->consecutive_failures);
|
|
|
|
|
|
- /* Last update */
|
|
|
if (data->data_valid && data->last_update > 0) {
|
|
|
char timebuf[64];
|
|
|
struct tm *tm_info = localtime(&data->last_update);
|
|
|
@@ -129,6 +114,125 @@ static char *build_status_json(const mppt_data_t *data,
|
|
|
return json_str;
|
|
|
}
|
|
|
|
|
|
+/* ── Parse query string helper ─────────────────────────────────── */
|
|
|
+static void get_query_param(struct mg_http_message *hm, const char *key,
|
|
|
+ char *buf, size_t buf_size)
|
|
|
+{
|
|
|
+ int n = mg_http_get_var(&hm->query, key, buf, (size_t)buf_size);
|
|
|
+ if (n > 0) {
|
|
|
+ buf[n] = '\0';
|
|
|
+ /* URL decode + to space */
|
|
|
+ for (int i = 0; i < n; i++) {
|
|
|
+ if (buf[i] == '+') buf[i] = ' ';
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ buf[0] = '\0';
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* ── History API Handler ───────────────────────────────────────── */
|
|
|
+static void handle_history(struct mg_connection *c,
|
|
|
+ struct mg_http_message *hm,
|
|
|
+ web_ctx_t *ctx)
|
|
|
+{
|
|
|
+ char param_buf[64];
|
|
|
+ history_query_t query;
|
|
|
+ memset(&query, 0, sizeof(query));
|
|
|
+
|
|
|
+ /* Parse start_time: "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DDTHH:MM:SS" */
|
|
|
+ get_query_param(hm, "start_time", param_buf, sizeof(param_buf));
|
|
|
+ /* Convert T separator to space for SQLite strftime */
|
|
|
+ for (char *p = param_buf; *p; p++) {
|
|
|
+ if (*p == 'T') *p = ' ';
|
|
|
+ }
|
|
|
+ snprintf(query.start_time, sizeof(query.start_time), "%s", param_buf);
|
|
|
+
|
|
|
+ get_query_param(hm, "end_time", param_buf, sizeof(param_buf));
|
|
|
+ for (char *p = param_buf; *p; p++) {
|
|
|
+ if (*p == 'T') *p = ' ';
|
|
|
+ }
|
|
|
+ snprintf(query.end_time, sizeof(query.end_time), "%s", param_buf);
|
|
|
+
|
|
|
+ get_query_param(hm, "page", param_buf, sizeof(param_buf));
|
|
|
+ query.page = param_buf[0] ? atoi(param_buf) : 1;
|
|
|
+ if (query.page < 1) query.page = 1;
|
|
|
+
|
|
|
+ get_query_param(hm, "page_size", param_buf, sizeof(param_buf));
|
|
|
+ query.page_size = param_buf[0] ? atoi(param_buf) : ctx->config->web_page_size;
|
|
|
+ if (query.page_size < 1) query.page_size = ctx->config->web_page_size;
|
|
|
+ if (query.page_size > 500) query.page_size = 500;
|
|
|
+
|
|
|
+ get_query_param(hm, "sort", param_buf, sizeof(param_buf));
|
|
|
+ snprintf(query.sort_order, sizeof(query.sort_order), "%s", param_buf);
|
|
|
+ if (strcmp(query.sort_order, "ASC") != 0 && strcmp(query.sort_order, "DESC") != 0) {
|
|
|
+ strcpy(query.sort_order, "DESC");
|
|
|
+ }
|
|
|
+
|
|
|
+ history_result_t result;
|
|
|
+ int rc = db_query_history(ctx->db, &query, &result);
|
|
|
+ if (rc != 0) {
|
|
|
+ mg_http_reply(c, 500,
|
|
|
+ "Content-Type: application/json\r\n",
|
|
|
+ "{\"error\":\"Database query failed\"}");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* Build JSON response */
|
|
|
+ cJSON *root = cJSON_CreateObject();
|
|
|
+ cJSON_AddNumberToObject(root, "total", result.total);
|
|
|
+ cJSON_AddNumberToObject(root, "page", result.page);
|
|
|
+ cJSON_AddNumberToObject(root, "page_size", result.page_size);
|
|
|
+ cJSON_AddNumberToObject(root, "total_pages", result.total_pages);
|
|
|
+
|
|
|
+ cJSON *records = cJSON_CreateArray();
|
|
|
+ for (int i = 0; i < result.count; i++) {
|
|
|
+ history_record_t *rec = &result.records[i];
|
|
|
+ cJSON *item = cJSON_CreateObject();
|
|
|
+ cJSON_AddNumberToObject(item, "id", rec->id);
|
|
|
+
|
|
|
+ char timebuf[64];
|
|
|
+ time_t ts = (time_t)rec->timestamp;
|
|
|
+ struct tm *tm_info = localtime(&ts);
|
|
|
+ strftime(timebuf, sizeof(timebuf), "%Y-%m-%d %H:%M:%S", tm_info);
|
|
|
+ cJSON_AddStringToObject(item, "timestamp", timebuf);
|
|
|
+ cJSON_AddNumberToObject(item, "timestamp_unix", rec->timestamp);
|
|
|
+
|
|
|
+ cJSON_AddNumberToObject(item, "battery_voltage", rec->battery_voltage);
|
|
|
+ cJSON_AddNumberToObject(item, "pv_voltage", rec->pv_voltage);
|
|
|
+ cJSON_AddNumberToObject(item, "pv_charge_current", rec->pv_charge_current);
|
|
|
+ cJSON_AddNumberToObject(item, "pv_input_power", rec->pv_input_power);
|
|
|
+ cJSON_AddNumberToObject(item, "cumulative_energy", rec->cumulative_energy);
|
|
|
+ cJSON_AddNumberToObject(item, "machine_temp", rec->machine_temp);
|
|
|
+ cJSON_AddNumberToObject(item, "fault_code", rec->fault_code);
|
|
|
+ cJSON_AddStringToObject(item, "fault_desc",
|
|
|
+ modbus_fault_string(rec->fault_code));
|
|
|
+ cJSON_AddNumberToObject(item, "battery_soc", rec->battery_soc);
|
|
|
+ cJSON_AddNumberToObject(item, "charge_state", rec->charge_state);
|
|
|
+ cJSON_AddStringToObject(item, "charge_state_desc",
|
|
|
+ modbus_charge_state_string(rec->charge_state));
|
|
|
+ cJSON_AddNumberToObject(item, "device_type", rec->device_type);
|
|
|
+
|
|
|
+ cJSON_AddItemToArray(records, item);
|
|
|
+ }
|
|
|
+ cJSON_AddItemToObject(root, "records", records);
|
|
|
+
|
|
|
+ char *json_str = cJSON_PrintUnformatted(root);
|
|
|
+ cJSON_Delete(root);
|
|
|
+ db_free_result(&result);
|
|
|
+
|
|
|
+ if (json_str) {
|
|
|
+ mg_http_reply(c, 200,
|
|
|
+ "Content-Type: application/json\r\n"
|
|
|
+ "Access-Control-Allow-Origin: *\r\n",
|
|
|
+ "%s", json_str);
|
|
|
+ free(json_str);
|
|
|
+ } else {
|
|
|
+ mg_http_reply(c, 500,
|
|
|
+ "Content-Type: application/json\r\n",
|
|
|
+ "{\"error\":\"JSON serialization failed\"}");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
/* ── HTML Dashboard ────────────────────────────────────────────── */
|
|
|
|
|
|
static const char *DASHBOARD_HTML =
|
|
|
@@ -138,81 +242,194 @@ static const char *DASHBOARD_HTML =
|
|
|
"<meta charset=\"UTF-8\">\n"
|
|
|
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
|
|
|
"<title>MPPT Solar Monitor</title>\n"
|
|
|
+"<script src=\"https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js\"></script>\n"
|
|
|
+"<script src=\"https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js\"></script>\n"
|
|
|
"<style>\n"
|
|
|
"* { margin: 0; padding: 0; box-sizing: border-box; }\n"
|
|
|
-"body { font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;\n"
|
|
|
-" background: #0f172a; color: #e2e8f0; padding: 20px; }\n"
|
|
|
-".header { text-align: center; margin-bottom: 30px; }\n"
|
|
|
-".header h1 { font-size: 1.8em; color: #38bdf8; }\n"
|
|
|
-".header .subtitle { color: #94a3b8; font-size: 0.9em; margin-top: 5px; }\n"
|
|
|
-".grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));\n"
|
|
|
-" gap: 16px; max-width: 1200px; margin: 0 auto; }\n"
|
|
|
+"body { font-family: -apple-system, 'Segoe UI', Roboto, 'Noto Sans SC', sans-serif;\n"
|
|
|
+" background: #0f172a; color: #e2e8f0; min-height: 100vh; }\n"
|
|
|
+".nav { background: #1e293b; border-bottom: 1px solid #334155; padding: 0 24px;\n"
|
|
|
+" display: flex; align-items: center; height: 56px; }\n"
|
|
|
+".nav h1 { font-size: 1.1em; color: #38bdf8; margin-right: 32px; }\n"
|
|
|
+".nav-tab { padding: 16px 20px; cursor: pointer; color: #94a3b8;\n"
|
|
|
+" border-bottom: 2px solid transparent; font-size: 0.9em;\n"
|
|
|
+" transition: all 0.2s; }\n"
|
|
|
+".nav-tab:hover { color: #e2e8f0; }\n"
|
|
|
+".nav-tab.active { color: #38bdf8; border-bottom-color: #38bdf8; }\n"
|
|
|
+".container { max-width: 1200px; margin: 0 auto; padding: 24px; }\n"
|
|
|
+".page { display: none; }\n"
|
|
|
+".page.active { display: block; }\n"
|
|
|
+".grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));\n"
|
|
|
+" gap: 16px; margin-bottom: 24px; }\n"
|
|
|
".card { background: #1e293b; border-radius: 12px; padding: 20px;\n"
|
|
|
" border: 1px solid #334155; }\n"
|
|
|
-".card h2 { color: #38bdf8; font-size: 1em; margin-bottom: 12px;\n"
|
|
|
+".card h2 { color: #38bdf8; font-size: 0.95em; margin-bottom: 12px;\n"
|
|
|
" border-bottom: 1px solid #334155; padding-bottom: 8px; }\n"
|
|
|
".metric { display: flex; justify-content: space-between; align-items: center;\n"
|
|
|
-" padding: 8px 0; border-bottom: 1px solid #1e293b; }\n"
|
|
|
-".metric:last-child { border-bottom: none; }\n"
|
|
|
+" padding: 6px 0; }\n"
|
|
|
".metric .label { color: #94a3b8; font-size: 0.85em; }\n"
|
|
|
-".metric .value { font-size: 1.2em; font-weight: 600; color: #f1f5f9; }\n"
|
|
|
-".metric .unit { color: #64748b; font-size: 0.8em; margin-left: 4px; }\n"
|
|
|
-".status-badge { display: inline-block; padding: 4px 12px; border-radius: 20px;\n"
|
|
|
-" font-size: 0.8em; font-weight: 600; }\n"
|
|
|
+".metric .value { font-size: 1.15em; font-weight: 600; color: #f1f5f9; }\n"
|
|
|
+".metric .unit { color: #64748b; font-size: 0.8em; margin-left: 3px; }\n"
|
|
|
+".status-badge { display: inline-block; padding: 3px 10px; border-radius: 20px;\n"
|
|
|
+" font-size: 0.78em; font-weight: 600; }\n"
|
|
|
".status-ok { background: #064e3b; color: #34d399; }\n"
|
|
|
".status-warn { background: #78350f; color: #fbbf24; }\n"
|
|
|
".status-err { background: #7f1d1d; color: #f87171; }\n"
|
|
|
-".update-time { text-align: center; color: #64748b; font-size: 0.8em;\n"
|
|
|
-" margin-top: 20px; }\n"
|
|
|
-".power-highlight { font-size: 1.8em; color: #fbbf24; text-align: center;\n"
|
|
|
-" padding: 10px 0; }\n"
|
|
|
+".power-hl { font-size: 1.6em; color: #fbbf24; text-align: center; padding: 8px 0; }\n"
|
|
|
+".update-time { text-align: center; color: #64748b; font-size: 0.8em; margin-top: 16px; }\n"
|
|
|
+/* History page */
|
|
|
+".filter-bar { background: #1e293b; border-radius: 12px; padding: 20px;\n"
|
|
|
+" border: 1px solid #334155; margin-bottom: 20px;\n"
|
|
|
+" display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; }\n"
|
|
|
+".filter-group { display: flex; flex-direction: column; gap: 4px; }\n"
|
|
|
+".filter-group label { color: #94a3b8; font-size: 0.8em; }\n"
|
|
|
+".filter-group input, .filter-group select {\n"
|
|
|
+" background: #0f172a; border: 1px solid #334155; color: #e2e8f0;\n"
|
|
|
+" padding: 8px 12px; border-radius: 8px; font-size: 0.9em; }\n"
|
|
|
+".filter-group input:focus, .filter-group select:focus {\n"
|
|
|
+" outline: none; border-color: #38bdf8; }\n"
|
|
|
+".btn { padding: 8px 20px; border-radius: 8px; border: none;\n"
|
|
|
+" cursor: pointer; font-size: 0.9em; font-weight: 500; transition: all 0.2s; }\n"
|
|
|
+".btn-primary { background: #38bdf8; color: #0f172a; }\n"
|
|
|
+".btn-primary:hover { background: #7dd3fc; }\n"
|
|
|
+".btn-secondary { background: #334155; color: #e2e8f0; }\n"
|
|
|
+".btn-secondary:hover { background: #475569; }\n"
|
|
|
+".chart-container { background: #1e293b; border-radius: 12px; padding: 20px;\n"
|
|
|
+" border: 1px solid #334155; margin-bottom: 20px; }\n"
|
|
|
+".chart-container h3 { color: #94a3b8; font-size: 0.9em; margin-bottom: 12px; }\n"
|
|
|
+".chart-metrics { display: flex; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }\n"
|
|
|
+".chart-metric-btn { padding: 4px 12px; border-radius: 16px; border: 1px solid #334155;\n"
|
|
|
+" background: transparent; color: #94a3b8; cursor: pointer;\n"
|
|
|
+" font-size: 0.8em; transition: all 0.2s; }\n"
|
|
|
+".chart-metric-btn.active { background: #38bdf8; color: #0f172a; border-color: #38bdf8; }\n"
|
|
|
+".chart-metric-btn:hover { border-color: #38bdf8; }\n"
|
|
|
+".table-container { background: #1e293b; border-radius: 12px; padding: 20px;\n"
|
|
|
+" border: 1px solid #334155; overflow-x: auto; }\n"
|
|
|
+".data-table { width: 100%; border-collapse: collapse; font-size: 0.85em; }\n"
|
|
|
+".data-table th { text-align: left; padding: 10px 12px; color: #94a3b8;\n"
|
|
|
+" border-bottom: 1px solid #334155; font-weight: 500;\n"
|
|
|
+" white-space: nowrap; }\n"
|
|
|
+".data-table td { padding: 8px 12px; border-bottom: 1px solid #1e293b;\n"
|
|
|
+" color: #e2e8f0; white-space: nowrap; }\n"
|
|
|
+".data-table tr:hover td { background: rgba(56,189,248,0.05); }\n"
|
|
|
+".pagination { display: flex; justify-content: center; align-items: center;\n"
|
|
|
+" gap: 8px; margin-top: 16px; }\n"
|
|
|
+".pagination button { padding: 6px 14px; border-radius: 6px; border: 1px solid #334155;\n"
|
|
|
+" background: transparent; color: #94a3b8; cursor: pointer;\n"
|
|
|
+" font-size: 0.85em; }\n"
|
|
|
+".pagination button:hover:not(:disabled) { border-color: #38bdf8; color: #38bdf8; }\n"
|
|
|
+".pagination button:disabled { opacity: 0.3; cursor: default; }\n"
|
|
|
+".pagination .page-info { color: #94a3b8; font-size: 0.85em; }\n"
|
|
|
+".no-data { text-align: center; color: #64748b; padding: 40px; }\n"
|
|
|
+".loading { text-align: center; color: #94a3b8; padding: 20px; }\n"
|
|
|
"</style>\n"
|
|
|
"</head>\n"
|
|
|
"<body>\n"
|
|
|
-"<div class=\"header\">\n"
|
|
|
-" <h1>MPPT Solar Charge Controller</h1>\n"
|
|
|
-" <div class=\"subtitle\">Real-time Monitoring Dashboard</div>\n"
|
|
|
+"<div class=\"nav\">\n"
|
|
|
+" <h1>MPPT Monitor</h1>\n"
|
|
|
+" <div class=\"nav-tab active\" onclick=\"switchPage('realtime')\">Real-time</div>\n"
|
|
|
+" <div class=\"nav-tab\" onclick=\"switchPage('history')\">History</div>\n"
|
|
|
"</div>\n"
|
|
|
-"<div class=\"grid\">\n"
|
|
|
-" <div class=\"card\">\n"
|
|
|
-" <h2>Battery</h2>\n"
|
|
|
-" <div class=\"metric\"><span class=\"label\">Voltage</span>\n"
|
|
|
-" <span class=\"value\" id=\"bat-v\">--<span class=\"unit\">V</span></span></div>\n"
|
|
|
-" <div class=\"metric\"><span class=\"label\">SOC</span>\n"
|
|
|
-" <span class=\"value\" id=\"bat-soc\">--<span class=\"unit\">%</span></span></div>\n"
|
|
|
-" <div class=\"metric\"><span class=\"label\">String Count</span>\n"
|
|
|
-" <span class=\"value\" id=\"bat-str\">--</span></div>\n"
|
|
|
+"<div class=\"container\">\n"
|
|
|
+" <!-- Real-time Page -->\n"
|
|
|
+" <div id=\"page-realtime\" class=\"page active\">\n"
|
|
|
+" <div class=\"grid\">\n"
|
|
|
+" <div class=\"card\"><h2>Battery</h2>\n"
|
|
|
+" <div class=\"metric\"><span class=\"label\">Voltage</span>\n"
|
|
|
+" <span class=\"value\" id=\"bat-v\">--<span class=\"unit\">V</span></span></div>\n"
|
|
|
+" <div class=\"metric\"><span class=\"label\">SOC</span>\n"
|
|
|
+" <span class=\"value\" id=\"bat-soc\">--<span class=\"unit\">%</span></span></div>\n"
|
|
|
+" <div class=\"metric\"><span class=\"label\">String</span>\n"
|
|
|
+" <span class=\"value\" id=\"bat-str\">--</span></div>\n"
|
|
|
+" </div>\n"
|
|
|
+" <div class=\"card\"><h2>Solar PV</h2>\n"
|
|
|
+" <div class=\"metric\"><span class=\"label\">Voltage</span>\n"
|
|
|
+" <span class=\"value\" id=\"pv-v\">--<span class=\"unit\">V</span></span></div>\n"
|
|
|
+" <div class=\"metric\"><span class=\"label\">Current</span>\n"
|
|
|
+" <span class=\"value\" id=\"pv-a\">--<span class=\"unit\">A</span></span></div>\n"
|
|
|
+" <div class=\"power-hl\" id=\"pv-w\">-- W</div>\n"
|
|
|
+" </div>\n"
|
|
|
+" <div class=\"card\"><h2>Device</h2>\n"
|
|
|
+" <div class=\"metric\"><span class=\"label\">Temperature</span>\n"
|
|
|
+" <span class=\"value\" id=\"dev-temp\">--<span class=\"unit\">C</span></span></div>\n"
|
|
|
+" <div class=\"metric\"><span class=\"label\">Charge State</span>\n"
|
|
|
+" <span class=\"value\" id=\"dev-state\">--</span></div>\n"
|
|
|
+" <div class=\"metric\"><span class=\"label\">Fault</span>\n"
|
|
|
+" <span class=\"value\" id=\"dev-fault\">--</span></div>\n"
|
|
|
+" <div class=\"metric\"><span class=\"label\">Type</span>\n"
|
|
|
+" <span class=\"value\" id=\"dev-type\">--</span></div>\n"
|
|
|
+" </div>\n"
|
|
|
+" <div class=\"card\"><h2>Energy</h2>\n"
|
|
|
+" <div class=\"metric\"><span class=\"label\">Cumulative</span>\n"
|
|
|
+" <span class=\"value\" id=\"energy\">--<span class=\"unit\">kWh</span></span></div>\n"
|
|
|
+" <div class=\"metric\"><span class=\"label\">Status</span>\n"
|
|
|
+" <span class=\"value\" id=\"conn-status\">--</span></div>\n"
|
|
|
+" </div>\n"
|
|
|
+" </div>\n"
|
|
|
+" <div class=\"update-time\" id=\"update-time\">Waiting for data...</div>\n"
|
|
|
" </div>\n"
|
|
|
-" <div class=\"card\">\n"
|
|
|
-" <h2>Solar PV</h2>\n"
|
|
|
-" <div class=\"metric\"><span class=\"label\">Voltage</span>\n"
|
|
|
-" <span class=\"value\" id=\"pv-v\">--<span class=\"unit\">V</span></span></div>\n"
|
|
|
-" <div class=\"metric\"><span class=\"label\">Current</span>\n"
|
|
|
-" <span class=\"value\" id=\"pv-a\">--<span class=\"unit\">A</span></span></div>\n"
|
|
|
-" <div class=\"power-highlight\" id=\"pv-w\">-- W</div>\n"
|
|
|
-" </div>\n"
|
|
|
-" <div class=\"card\">\n"
|
|
|
-" <h2>Device</h2>\n"
|
|
|
-" <div class=\"metric\"><span class=\"label\">Temperature</span>\n"
|
|
|
-" <span class=\"value\" id=\"dev-temp\">--<span class=\"unit\">C</span></span></div>\n"
|
|
|
-" <div class=\"metric\"><span class=\"label\">Charge State</span>\n"
|
|
|
-" <span class=\"value\" id=\"dev-state\">--</span></div>\n"
|
|
|
-" <div class=\"metric\"><span class=\"label\">Fault</span>\n"
|
|
|
-" <span class=\"value\" id=\"dev-fault\">--</span></div>\n"
|
|
|
-" <div class=\"metric\"><span class=\"label\">Device Type</span>\n"
|
|
|
-" <span class=\"value\" id=\"dev-type\">--</span></div>\n"
|
|
|
-" </div>\n"
|
|
|
-" <div class=\"card\">\n"
|
|
|
-" <h2>Energy</h2>\n"
|
|
|
-" <div class=\"metric\"><span class=\"label\">Cumulative</span>\n"
|
|
|
-" <span class=\"value\" id=\"energy\">--<span class=\"unit\">kWh</span></span></div>\n"
|
|
|
-" <div class=\"metric\"><span class=\"label\">Status</span>\n"
|
|
|
-" <span class=\"value\" id=\"conn-status\">--</span></div>\n"
|
|
|
+" <!-- History Page -->\n"
|
|
|
+" <div id=\"page-history\" class=\"page\">\n"
|
|
|
+" <div class=\"filter-bar\">\n"
|
|
|
+" <div class=\"filter-group\">\n"
|
|
|
+" <label>Start Time</label>\n"
|
|
|
+" <input type=\"datetime-local\" id=\"hist-start\">\n"
|
|
|
+" </div>\n"
|
|
|
+" <div class=\"filter-group\">\n"
|
|
|
+" <label>End Time</label>\n"
|
|
|
+" <input type=\"datetime-local\" id=\"hist-end\">\n"
|
|
|
+" </div>\n"
|
|
|
+" <div class=\"filter-group\">\n"
|
|
|
+" <label>Page Size</label>\n"
|
|
|
+" <select id=\"hist-page-size\">\n"
|
|
|
+" <option value=\"20\">20</option>\n"
|
|
|
+" <option value=\"50\" selected>50</option>\n"
|
|
|
+" <option value=\"100\">100</option>\n"
|
|
|
+" <option value=\"200\">200</option>\n"
|
|
|
+" </select>\n"
|
|
|
+" </div>\n"
|
|
|
+" <button class=\"btn btn-primary\" onclick=\"queryHistory(1)\">Query</button>\n"
|
|
|
+" <button class=\"btn btn-secondary\" onclick=\"resetFilter()\">Reset</button>\n"
|
|
|
+" </div>\n"
|
|
|
+" <div class=\"chart-container\">\n"
|
|
|
+" <h3>Trend Chart</h3>\n"
|
|
|
+" <div class=\"chart-metrics\">\n"
|
|
|
+" <button class=\"chart-metric-btn active\" data-metric=\"battery_voltage\" onclick=\"toggleMetric(this)\">Battery V</button>\n"
|
|
|
+" <button class=\"chart-metric-btn\" data-metric=\"pv_voltage\" onclick=\"toggleMetric(this)\">PV Voltage</button>\n"
|
|
|
+" <button class=\"chart-metric-btn\" data-metric=\"pv_charge_current\" onclick=\"toggleMetric(this)\">Current</button>\n"
|
|
|
+" <button class=\"chart-metric-btn\" data-metric=\"pv_input_power\" onclick=\"toggleMetric(this)\">Power</button>\n"
|
|
|
+" <button class=\"chart-metric-btn\" data-metric=\"machine_temp\" onclick=\"toggleMetric(this)\">Temp</button>\n"
|
|
|
+" <button class=\"chart-metric-btn\" data-metric=\"battery_soc\" onclick=\"toggleMetric(this)\">SOC %</button>\n"
|
|
|
+" </div>\n"
|
|
|
+" <canvas id=\"historyChart\" height=\"260\"></canvas>\n"
|
|
|
+" </div>\n"
|
|
|
+" <div class=\"table-container\">\n"
|
|
|
+" <div id=\"hist-loading\" class=\"loading\" style=\"display:none\">Loading...</div>\n"
|
|
|
+" <div id=\"hist-nodata\" class=\"no-data\" style=\"display:none\">No data. Click Query to load history.</div>\n"
|
|
|
+" <table class=\"data-table\" id=\"hist-table\" style=\"display:none\">\n"
|
|
|
+" <thead><tr>\n"
|
|
|
+" <th>Time</th><th>Bat V</th><th>PV V</th><th>Current A</th>\n"
|
|
|
+" <th>Power W</th><th>Energy kWh</th><th>Temp C</th>\n"
|
|
|
+" <th>SOC %</th><th>State</th><th>Fault</th>\n"
|
|
|
+" </tr></thead>\n"
|
|
|
+" <tbody id=\"hist-tbody\"></tbody>\n"
|
|
|
+" </table>\n"
|
|
|
+" <div class=\"pagination\" id=\"hist-pagination\"></div>\n"
|
|
|
+" </div>\n"
|
|
|
" </div>\n"
|
|
|
"</div>\n"
|
|
|
-"<div class=\"update-time\" id=\"update-time\">Waiting for data...</div>\n"
|
|
|
"<script>\n"
|
|
|
-"async function fetchData() {\n"
|
|
|
+"/* ── Page Switching ─────────────────────────────────── */\n"
|
|
|
+"function switchPage(name) {\n"
|
|
|
+" document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));\n"
|
|
|
+" document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));\n"
|
|
|
+" document.getElementById('page-'+name).classList.add('active');\n"
|
|
|
+" document.querySelectorAll('.nav-tab')[name==='realtime'?0:1].classList.add('active');\n"
|
|
|
+" if (name === 'history' && !histChart) initChart();\n"
|
|
|
+"}\n"
|
|
|
+"\n"
|
|
|
+"/* ── Real-time Data ─────────────────────────────────── */\n"
|
|
|
+"async function fetchRealtime() {\n"
|
|
|
" try {\n"
|
|
|
" const res = await fetch('/api/data');\n"
|
|
|
" const d = await res.json();\n"
|
|
|
@@ -225,38 +442,180 @@ static const char *DASHBOARD_HTML =
|
|
|
" document.getElementById('pv-w').textContent = d.pv.power.toFixed(1)+' W';\n"
|
|
|
" document.getElementById('dev-temp').innerHTML = d.device.temperature.toFixed(1)+'<span class=\"unit\">C</span>';\n"
|
|
|
" document.getElementById('dev-state').textContent = d.device.charge_state_desc;\n"
|
|
|
-" var faultEl = document.getElementById('dev-fault');\n"
|
|
|
-" faultEl.textContent = d.device.fault_desc;\n"
|
|
|
-" faultEl.className = 'value ' + (d.device.fault_code===0||d.device.fault_code===20?'status-ok':'status-err');\n"
|
|
|
+" var fe = document.getElementById('dev-fault');\n"
|
|
|
+" fe.textContent = d.device.fault_desc;\n"
|
|
|
+" fe.className = 'value ' + (d.device.fault_code===0||d.device.fault_code===20?'status-ok':'status-err');\n"
|
|
|
" document.getElementById('dev-type').textContent = d.device.device_type;\n"
|
|
|
" document.getElementById('energy').innerHTML = d.cumulative_energy_kwh.toFixed(1)+'<span class=\"unit\">kWh</span>';\n"
|
|
|
" document.getElementById('update-time').textContent = 'Updated: '+d.timestamp;\n"
|
|
|
-" var statusEl = document.getElementById('conn-status');\n"
|
|
|
-" statusEl.textContent = d.data_valid ? 'Online' : 'Offline';\n"
|
|
|
-" statusEl.className = 'value ' + (d.data_valid ? 'status-ok' : 'status-err');\n"
|
|
|
+" var cs = document.getElementById('conn-status');\n"
|
|
|
+" cs.textContent = d.data_valid ? 'Online' : 'Offline';\n"
|
|
|
+" cs.className = 'value ' + (d.data_valid ? 'status-ok' : 'status-err');\n"
|
|
|
" } catch(e) { console.error(e); }\n"
|
|
|
"}\n"
|
|
|
-"fetchData();\n"
|
|
|
-"setInterval(fetchData, 2000);\n"
|
|
|
+"fetchRealtime();\n"
|
|
|
+"setInterval(fetchRealtime, 2000);\n"
|
|
|
+"\n"
|
|
|
+"/* ── History Chart ──────────────────────────────────── */\n"
|
|
|
+"let histChart = null;\n"
|
|
|
+"let histData = [];\n"
|
|
|
+"const metricColors = {\n"
|
|
|
+" battery_voltage: '#38bdf8',\n"
|
|
|
+" pv_voltage: '#fbbf24',\n"
|
|
|
+" pv_charge_current: '#34d399',\n"
|
|
|
+" pv_input_power: '#f87171',\n"
|
|
|
+" machine_temp: '#a78bfa',\n"
|
|
|
+" battery_soc: '#fb923c'\n"
|
|
|
+"};\n"
|
|
|
+"const metricLabels = {\n"
|
|
|
+" battery_voltage: 'Battery V',\n"
|
|
|
+" pv_voltage: 'PV Voltage',\n"
|
|
|
+" pv_charge_current: 'Current A',\n"
|
|
|
+" pv_input_power: 'Power W',\n"
|
|
|
+" machine_temp: 'Temp C',\n"
|
|
|
+" battery_soc: 'SOC %'\n"
|
|
|
+"};\n"
|
|
|
+"let activeMetrics = new Set(['battery_voltage']);\n"
|
|
|
+"\n"
|
|
|
+"function initChart() {\n"
|
|
|
+" const ctx = document.getElementById('historyChart').getContext('2d');\n"
|
|
|
+" histChart = new Chart(ctx, {\n"
|
|
|
+" type: 'line',\n"
|
|
|
+" data: { datasets: [] },\n"
|
|
|
+" options: {\n"
|
|
|
+" responsive: true,\n"
|
|
|
+" maintainAspectRatio: false,\n"
|
|
|
+" interaction: { mode: 'index', intersect: false },\n"
|
|
|
+" plugins: { legend: { display: true, labels: { color: '#94a3b8' } } },\n"
|
|
|
+" scales: {\n"
|
|
|
+" x: { type: 'time', ticks: { color: '#64748b' }, grid: { color: '#1e293b' } },\n"
|
|
|
+" y: { ticks: { color: '#64748b' }, grid: { color: '#334155' } }\n"
|
|
|
+" }\n"
|
|
|
+" }\n"
|
|
|
+" });\n"
|
|
|
+"}\n"
|
|
|
+"\n"
|
|
|
+"function toggleMetric(btn) {\n"
|
|
|
+" const m = btn.dataset.metric;\n"
|
|
|
+" if (activeMetrics.has(m)) { activeMetrics.delete(m); btn.classList.remove('active'); }\n"
|
|
|
+" else { activeMetrics.add(m); btn.classList.add('active'); }\n"
|
|
|
+" updateChart();\n"
|
|
|
+"}\n"
|
|
|
+"\n"
|
|
|
+"function updateChart() {\n"
|
|
|
+" if (!histChart || histData.length === 0) return;\n"
|
|
|
+" const datasets = [];\n"
|
|
|
+" activeMetrics.forEach(m => {\n"
|
|
|
+" datasets.push({\n"
|
|
|
+" label: metricLabels[m] || m,\n"
|
|
|
+" data: histData.map(r => ({ x: r.timestamp_unix * 1000, y: r[m] })),\n"
|
|
|
+" borderColor: metricColors[m] || '#fff',\n"
|
|
|
+" backgroundColor: (metricColors[m] || '#fff') + '20',\n"
|
|
|
+" borderWidth: 2, pointRadius: 1, tension: 0.3, fill: false\n"
|
|
|
+" });\n"
|
|
|
+" });\n"
|
|
|
+" histChart.data.datasets = datasets;\n"
|
|
|
+" histChart.update();\n"
|
|
|
+"}\n"
|
|
|
+"\n"
|
|
|
+"/* ── History Query ──────────────────────────────────── */\n"
|
|
|
+"let currentPage = 1;\n"
|
|
|
+"let currentTotalPages = 1;\n"
|
|
|
+"\n"
|
|
|
+"async function queryHistory(page) {\n"
|
|
|
+" page = page || 1;\n"
|
|
|
+" currentPage = page;\n"
|
|
|
+" const startEl = document.getElementById('hist-start');\n"
|
|
|
+" const endEl = document.getElementById('hist-end');\n"
|
|
|
+" const psEl = document.getElementById('hist-page-size');\n"
|
|
|
+" let url = '/api/history?page=' + page + '&page_size=' + psEl.value + '&sort=DESC';\n"
|
|
|
+" if (startEl.value) url += '&start_time=' + encodeURIComponent(startEl.value.replace('T',' '));\n"
|
|
|
+" if (endEl.value) url += '&end_time=' + encodeURIComponent(endEl.value.replace('T',' '));\n"
|
|
|
+"\n"
|
|
|
+" document.getElementById('hist-loading').style.display = 'block';\n"
|
|
|
+" document.getElementById('hist-nodata').style.display = 'none';\n"
|
|
|
+" document.getElementById('hist-table').style.display = 'none';\n"
|
|
|
+"\n"
|
|
|
+" try {\n"
|
|
|
+" const res = await fetch(url);\n"
|
|
|
+" const d = await res.json();\n"
|
|
|
+" document.getElementById('hist-loading').style.display = 'none';\n"
|
|
|
+"\n"
|
|
|
+" if (!d.records || d.records.length === 0) {\n"
|
|
|
+" document.getElementById('hist-nodata').style.display = 'block';\n"
|
|
|
+" histData = [];\n"
|
|
|
+" updateChart();\n"
|
|
|
+" return;\n"
|
|
|
+" }\n"
|
|
|
+"\n"
|
|
|
+" histData = d.records;\n"
|
|
|
+" currentTotalPages = d.total_pages;\n"
|
|
|
+"\n"
|
|
|
+" /* Populate table */\n"
|
|
|
+" const tbody = document.getElementById('hist-tbody');\n"
|
|
|
+" tbody.innerHTML = '';\n"
|
|
|
+" d.records.forEach(r => {\n"
|
|
|
+" const tr = document.createElement('tr');\n"
|
|
|
+" tr.innerHTML = '<td>'+r.timestamp+'</td>'\n"
|
|
|
+" + '<td>'+r.battery_voltage.toFixed(1)+'</td>'\n"
|
|
|
+" + '<td>'+r.pv_voltage.toFixed(1)+'</td>'\n"
|
|
|
+" + '<td>'+r.pv_charge_current.toFixed(1)+'</td>'\n"
|
|
|
+" + '<td>'+r.pv_input_power.toFixed(1)+'</td>'\n"
|
|
|
+" + '<td>'+r.cumulative_energy.toFixed(1)+'</td>'\n"
|
|
|
+" + '<td>'+r.machine_temp.toFixed(0)+'</td>'\n"
|
|
|
+" + '<td>'+r.battery_soc+'</td>'\n"
|
|
|
+" + '<td>'+r.charge_state_desc+'</td>'\n"
|
|
|
+" + '<td><span class=\"status-badge '+(r.fault_code===0||r.fault_code===20?'status-ok':'status-err')+'\">'+r.fault_desc+'</span></td>';\n"
|
|
|
+" tbody.appendChild(tr);\n"
|
|
|
+" });\n"
|
|
|
+" document.getElementById('hist-table').style.display = 'table';\n"
|
|
|
+"\n"
|
|
|
+" /* Pagination */\n"
|
|
|
+" const pg = document.getElementById('hist-pagination');\n"
|
|
|
+" pg.innerHTML = '<button '+(d.page<=1?'disabled':'')+' onclick=\"queryHistory('+(d.page-1)+')\">Prev</button>'\n"
|
|
|
+" + '<span class=\"page-info\">Page '+d.page+' / '+d.total_pages+' (Total: '+d.total+')</span>'\n"
|
|
|
+" + '<button '+(d.page>=d.total_pages?'disabled':'')+' onclick=\"queryHistory('+(d.page+1)+')\">Next</button>';\n"
|
|
|
+"\n"
|
|
|
+" updateChart();\n"
|
|
|
+" } catch(e) {\n"
|
|
|
+" console.error(e);\n"
|
|
|
+" document.getElementById('hist-loading').style.display = 'none';\n"
|
|
|
+" document.getElementById('hist-nodata').style.display = 'block';\n"
|
|
|
+" document.getElementById('hist-nodata').textContent = 'Query failed: ' + e.message;\n"
|
|
|
+" }\n"
|
|
|
+"}\n"
|
|
|
+"\n"
|
|
|
+"function resetFilter() {\n"
|
|
|
+" document.getElementById('hist-start').value = '';\n"
|
|
|
+" document.getElementById('hist-end').value = '';\n"
|
|
|
+" document.getElementById('hist-page-size').value = '50';\n"
|
|
|
+" queryHistory(1);\n"
|
|
|
+"}\n"
|
|
|
+"\n"
|
|
|
+"/* Set default date range: last 24 hours */\n"
|
|
|
+"(function() {\n"
|
|
|
+" const now = new Date();\n"
|
|
|
+" const ago = new Date(now.getTime() - 24*60*60*1000);\n"
|
|
|
+" const fmt = d => d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0')+'T'+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');\n"
|
|
|
+" document.getElementById('hist-start').value = fmt(ago);\n"
|
|
|
+" document.getElementById('hist-end').value = fmt(now);\n"
|
|
|
+"})();\n"
|
|
|
"</script>\n"
|
|
|
"</body>\n"
|
|
|
"</html>\n";
|
|
|
|
|
|
/* ── Init ──────────────────────────────────────────────────────── */
|
|
|
void web_server_init(web_ctx_t *ctx, mppt_data_t *mppt_data,
|
|
|
- modbus_conn_t *conn)
|
|
|
+ modbus_conn_t *conn, db_handle_t *db,
|
|
|
+ const app_config_t *config)
|
|
|
{
|
|
|
ctx->mppt_data = mppt_data;
|
|
|
ctx->modbus_conn = conn;
|
|
|
+ ctx->db = db;
|
|
|
+ ctx->config = config;
|
|
|
}
|
|
|
|
|
|
/* ── Event Handler (mongoose-7.22 API) ─────────────────────────── */
|
|
|
-/*
|
|
|
- * Key API changes in mongoose-7.22:
|
|
|
- * - Use mg_match(hm->uri, mg_str("/path"), NULL) instead of mg_http_match_uri()
|
|
|
- * - struct mg_str uses char *buf (not const char *ptr)
|
|
|
- * - mg_http_reply() for sending responses
|
|
|
- */
|
|
|
void web_event_handler(struct mg_connection *c, int ev, void *ev_data)
|
|
|
{
|
|
|
if (ev != MG_EV_HTTP_MSG) return;
|
|
|
@@ -264,12 +623,6 @@ void web_event_handler(struct mg_connection *c, int ev, void *ev_data)
|
|
|
struct mg_http_message *hm = (struct mg_http_message *)ev_data;
|
|
|
web_ctx_t *ctx = (web_ctx_t *)c->fn_data;
|
|
|
|
|
|
- /*
|
|
|
- * Route: GET /api/data
|
|
|
- * Returns full MPPT data as JSON
|
|
|
- *
|
|
|
- * mongoose-7.22: Use mg_match() instead of mg_http_match_uri()
|
|
|
- */
|
|
|
if (mg_match(hm->uri, mg_str("/api/data"), NULL)) {
|
|
|
const mppt_data_t *data = (const mppt_data_t *)ctx->mppt_data;
|
|
|
char *json = build_data_json(data);
|
|
|
@@ -287,10 +640,6 @@ void web_event_handler(struct mg_connection *c, int ev, void *ev_data)
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- /*
|
|
|
- * Route: GET /api/status
|
|
|
- * Returns connection & device status
|
|
|
- */
|
|
|
if (mg_match(hm->uri, mg_str("/api/status"), NULL)) {
|
|
|
const mppt_data_t *data = (const mppt_data_t *)ctx->mppt_data;
|
|
|
const modbus_conn_t *conn = (const modbus_conn_t *)ctx->modbus_conn;
|
|
|
@@ -309,10 +658,6 @@ void web_event_handler(struct mg_connection *c, int ev, void *ev_data)
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- /*
|
|
|
- * Route: GET /api/health
|
|
|
- * Simple health check endpoint
|
|
|
- */
|
|
|
if (mg_match(hm->uri, mg_str("/api/health"), NULL)) {
|
|
|
mg_http_reply(c, 200,
|
|
|
"Content-Type: application/json\r\n",
|
|
|
@@ -320,10 +665,11 @@ void web_event_handler(struct mg_connection *c, int ev, void *ev_data)
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- /*
|
|
|
- * Route: GET /
|
|
|
- * HTML dashboard
|
|
|
- */
|
|
|
+ if (mg_match(hm->uri, mg_str("/api/history"), NULL)) {
|
|
|
+ handle_history(c, hm, ctx);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
if (mg_match(hm->uri, mg_str("/"), NULL)) {
|
|
|
mg_http_reply(c, 200,
|
|
|
"Content-Type: text/html; charset=utf-8\r\n",
|
|
|
@@ -331,7 +677,6 @@ void web_event_handler(struct mg_connection *c, int ev, void *ev_data)
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- /* 404 for unmatched routes */
|
|
|
mg_http_reply(c, 404,
|
|
|
"Content-Type: application/json\r\n",
|
|
|
"{\"error\":\"Not Found\"}");
|