|
|
@@ -2,8 +2,6 @@
|
|
|
* @file web_server.c
|
|
|
* @brief Mongoose HTTP server implementation for MPPT REST API
|
|
|
*
|
|
|
- * Multi-device support: serves data for all configured MPPT devices.
|
|
|
- *
|
|
|
* Uses mongoose-7.22 API:
|
|
|
* - mg_match() instead of deprecated mg_http_match_uri()
|
|
|
* - struct mg_str with char *buf (not const char *ptr)
|
|
|
@@ -11,14 +9,11 @@
|
|
|
* - cJSON for JSON serialization
|
|
|
*
|
|
|
* API Endpoints:
|
|
|
- * GET /api/devices - All devices real-time data
|
|
|
- * GET /api/devices/<name> - Single device real-time data
|
|
|
- * GET /api/data - Alias for /api/devices (backward compat)
|
|
|
- * GET /api/status - All devices connection status
|
|
|
- * GET /api/health - Health check
|
|
|
- * GET /api/history - Historical data with pagination
|
|
|
- * GET /api/history/stats - History statistics
|
|
|
- * GET / - HTML dashboard
|
|
|
+ * 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
|
|
|
*/
|
|
|
|
|
|
#include "web_server.h"
|
|
|
@@ -28,224 +23,91 @@
|
|
|
#include <stdlib.h>
|
|
|
#include <string.h>
|
|
|
#include <time.h>
|
|
|
-#include <pthread.h>
|
|
|
|
|
|
-/* ── JSON: Build single device data object (ALL fields) ────────── */
|
|
|
-static cJSON *build_device_data_obj(const mppt_data_t *data)
|
|
|
-{
|
|
|
- cJSON *obj = cJSON_CreateObject();
|
|
|
- if (!obj) return NULL;
|
|
|
+/* ── JSON Serialization ────────────────────────────────────────── */
|
|
|
|
|
|
- cJSON_AddStringToObject(obj, "name", data->device_name);
|
|
|
- cJSON_AddNumberToObject(obj, "id", data->device_id);
|
|
|
+static char *build_data_json(const mppt_data_t *data)
|
|
|
+{
|
|
|
+ cJSON *root = cJSON_CreateObject();
|
|
|
+ if (!root) return NULL;
|
|
|
|
|
|
char timebuf[64];
|
|
|
if (data->data_valid && data->last_update > 0) {
|
|
|
struct tm *tm_info = localtime(&data->last_update);
|
|
|
strftime(timebuf, sizeof(timebuf), "%Y-%m-%dT%H:%M:%S", tm_info);
|
|
|
- cJSON_AddStringToObject(obj, "timestamp", timebuf);
|
|
|
+ cJSON_AddStringToObject(root, "timestamp", timebuf);
|
|
|
} else {
|
|
|
- cJSON_AddStringToObject(obj, "timestamp", "N/A");
|
|
|
+ cJSON_AddStringToObject(root, "timestamp", "N/A");
|
|
|
}
|
|
|
- cJSON_AddBoolToObject(obj, "data_valid", data->data_valid ? 1 : 0);
|
|
|
- cJSON_AddBoolToObject(obj, "params_valid", data->params_valid ? 1 : 0);
|
|
|
|
|
|
- /* ── Input Registers (Real-time data) ── */
|
|
|
- cJSON *input = cJSON_CreateObject();
|
|
|
+ cJSON_AddBoolToObject(root, "data_valid", data->data_valid ? 1 : 0);
|
|
|
|
|
|
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(input, "battery", battery);
|
|
|
+ cJSON_AddItemToObject(root, "battery", battery);
|
|
|
|
|
|
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_AddNumberToObject(pv, "cumulative_energy_kwh", data->cumulative_energy);
|
|
|
- cJSON_AddItemToObject(input, "pv", pv);
|
|
|
-
|
|
|
- cJSON_AddNumberToObject(input, "temperature", data->machine_temp);
|
|
|
- cJSON_AddNumberToObject(input, "fault_code", data->fault_code);
|
|
|
- cJSON_AddStringToObject(input, "fault_desc", data->fault_desc);
|
|
|
- cJSON_AddNumberToObject(input, "charge_state", data->charge_state);
|
|
|
- cJSON_AddStringToObject(input, "charge_state_desc", data->charge_state_str);
|
|
|
- cJSON_AddNumberToObject(input, "device_type", data->device_type);
|
|
|
- cJSON_AddNumberToObject(input, "software_version", data->software_version);
|
|
|
-
|
|
|
- cJSON_AddItemToObject(obj, "input_registers", input);
|
|
|
-
|
|
|
- /* ── Output Registers (Parameters) ── */
|
|
|
- cJSON *output = cJSON_CreateObject();
|
|
|
-
|
|
|
- cJSON_AddNumberToObject(output, "battery_type", data->battery_type);
|
|
|
- cJSON_AddStringToObject(output, "battery_type_desc", data->battery_type_str);
|
|
|
- cJSON_AddNumberToObject(output, "battery_level", data->battery_level);
|
|
|
- cJSON_AddStringToObject(output, "battery_level_desc", data->battery_level_str);
|
|
|
- cJSON_AddNumberToObject(output, "charge_current_pct", data->charge_current_pct);
|
|
|
- cJSON_AddNumberToObject(output, "over_discharge_voltage", data->over_discharge_voltage);
|
|
|
- cJSON_AddNumberToObject(output, "boost_voltage", data->boost_voltage);
|
|
|
- cJSON_AddNumberToObject(output, "full_charge_voltage", data->full_charge_voltage);
|
|
|
- cJSON_AddNumberToObject(output, "over_discharge_recovery_voltage", data->over_discharge_recovery);
|
|
|
- cJSON_AddNumberToObject(output, "rs485_address", data->rs485_address);
|
|
|
- cJSON_AddNumberToObject(output, "charge_mode", data->charge_mode);
|
|
|
- cJSON_AddStringToObject(output, "charge_mode_desc", data->charge_mode_str);
|
|
|
-
|
|
|
- cJSON_AddItemToObject(obj, "output_registers", output);
|
|
|
-
|
|
|
- /* ── Raw registers ── */
|
|
|
- cJSON *raw_in = cJSON_CreateArray();
|
|
|
- for (int i = 0; i < MODBUS_INPUT_REG_COUNT; i++) {
|
|
|
- cJSON_AddItemToArray(raw_in, cJSON_CreateNumber(data->in_reg[i]));
|
|
|
- }
|
|
|
- cJSON_AddItemToObject(obj, "raw_input_registers", raw_in);
|
|
|
-
|
|
|
- cJSON *raw_out = cJSON_CreateArray();
|
|
|
- for (int i = 0; i < MODBUS_OUTPUT_REG_COUNT; i++) {
|
|
|
- cJSON_AddItemToArray(raw_out, cJSON_CreateNumber(data->out_reg[i]));
|
|
|
- }
|
|
|
- cJSON_AddItemToObject(obj, "raw_output_registers", raw_out);
|
|
|
-
|
|
|
- return obj;
|
|
|
-}
|
|
|
-
|
|
|
-/* ── JSON: All devices data ────────────────────────────────────── */
|
|
|
-char *web_build_devices_json(web_ctx_t *ctx)
|
|
|
-{
|
|
|
- cJSON *root = cJSON_CreateObject();
|
|
|
- if (!root) return NULL;
|
|
|
-
|
|
|
- cJSON_AddNumberToObject(root, "device_count", ctx->device_count);
|
|
|
-
|
|
|
- cJSON *devices = cJSON_CreateArray();
|
|
|
- pthread_mutex_lock(ctx->mutex);
|
|
|
- for (int i = 0; i < ctx->device_count; i++) {
|
|
|
- cJSON *dev_obj = build_device_data_obj(&ctx->devices[i].data);
|
|
|
- if (dev_obj) {
|
|
|
- /* Add connection state */
|
|
|
- const modbus_conn_t *conn = &ctx->devices[i].conn;
|
|
|
- const char *state_str;
|
|
|
- switch (conn->state) {
|
|
|
- case MODBUS_STATE_CONNECTED: state_str = "connected"; break;
|
|
|
- case MODBUS_STATE_CONNECTING: state_str = "connecting"; break;
|
|
|
- case MODBUS_STATE_ERROR: state_str = "error"; break;
|
|
|
- default: state_str = "disconnected"; break;
|
|
|
- }
|
|
|
- cJSON_AddStringToObject(dev_obj, "conn_state", state_str);
|
|
|
- cJSON_AddStringToObject(dev_obj, "host", conn->host);
|
|
|
- cJSON_AddNumberToObject(dev_obj, "port", conn->port);
|
|
|
- cJSON_AddItemToArray(devices, dev_obj);
|
|
|
- }
|
|
|
+ cJSON_AddItemToObject(root, "pv", pv);
|
|
|
+
|
|
|
+ cJSON_AddNumberToObject(root, "cumulative_energy_kwh", data->cumulative_energy);
|
|
|
+
|
|
|
+ cJSON *device = cJSON_CreateObject();
|
|
|
+ cJSON_AddNumberToObject(device, "temperature", data->machine_temp);
|
|
|
+ cJSON_AddNumberToObject(device, "fault_code", data->fault_code);
|
|
|
+ cJSON_AddStringToObject(device, "fault_desc",
|
|
|
+ modbus_fault_string(data->fault_code));
|
|
|
+ cJSON_AddNumberToObject(device, "charge_state", data->charge_state);
|
|
|
+ cJSON_AddStringToObject(device, "charge_state_desc",
|
|
|
+ modbus_charge_state_string(data->charge_state));
|
|
|
+ cJSON_AddNumberToObject(device, "device_type", data->device_type);
|
|
|
+ cJSON_AddItemToObject(root, "device", device);
|
|
|
+
|
|
|
+ cJSON *raw = cJSON_CreateArray();
|
|
|
+ for (int i = 0; i < MODBUS_REG_COUNT; i++) {
|
|
|
+ cJSON_AddItemToArray(raw, cJSON_CreateNumber(data->reg[i]));
|
|
|
}
|
|
|
- pthread_mutex_unlock(ctx->mutex);
|
|
|
-
|
|
|
- cJSON_AddItemToObject(root, "devices", devices);
|
|
|
+ cJSON_AddItemToObject(root, "raw_registers", raw);
|
|
|
|
|
|
char *json_str = cJSON_PrintUnformatted(root);
|
|
|
cJSON_Delete(root);
|
|
|
return json_str;
|
|
|
}
|
|
|
|
|
|
-/* ── JSON: Single device data ──────────────────────────────────── */
|
|
|
-char *web_build_device_json(web_ctx_t *ctx, int device_idx)
|
|
|
+static char *build_status_json(const mppt_data_t *data,
|
|
|
+ const modbus_conn_t *conn)
|
|
|
{
|
|
|
- if (device_idx < 0 || device_idx >= ctx->device_count) return NULL;
|
|
|
-
|
|
|
cJSON *root = cJSON_CreateObject();
|
|
|
if (!root) return NULL;
|
|
|
|
|
|
- pthread_mutex_lock(ctx->mutex);
|
|
|
- cJSON *dev_obj = build_device_data_obj(&ctx->devices[device_idx].data);
|
|
|
- const modbus_conn_t *conn = &ctx->devices[device_idx].conn;
|
|
|
- pthread_mutex_unlock(ctx->mutex);
|
|
|
-
|
|
|
- if (!dev_obj) {
|
|
|
- cJSON_Delete(root);
|
|
|
- return NULL;
|
|
|
- }
|
|
|
-
|
|
|
- /* Add connection info */
|
|
|
+ cJSON *connection = cJSON_CreateObject();
|
|
|
const char *state_str;
|
|
|
switch (conn->state) {
|
|
|
- case MODBUS_STATE_CONNECTED: state_str = "connected"; break;
|
|
|
- case MODBUS_STATE_CONNECTING: state_str = "connecting"; break;
|
|
|
- case MODBUS_STATE_ERROR: state_str = "error"; break;
|
|
|
- default: state_str = "disconnected"; break;
|
|
|
+ case MODBUS_STATE_CONNECTED: state_str = "connected"; break;
|
|
|
+ case MODBUS_STATE_CONNECTING: state_str = "connecting"; break;
|
|
|
+ case MODBUS_STATE_ERROR: state_str = "error"; break;
|
|
|
+ default: state_str = "disconnected"; break;
|
|
|
}
|
|
|
- cJSON_AddStringToObject(dev_obj, "conn_state", state_str);
|
|
|
- cJSON_AddStringToObject(dev_obj, "host", conn->host);
|
|
|
- cJSON_AddNumberToObject(dev_obj, "port", conn->port);
|
|
|
- cJSON_AddNumberToObject(dev_obj, "reconnect_count", conn->reconnect_count);
|
|
|
- cJSON_AddNumberToObject(dev_obj, "consecutive_failures",
|
|
|
- ctx->devices[device_idx].data.consecutive_failures);
|
|
|
-
|
|
|
- cJSON_AddItemToObject(root, "device", dev_obj);
|
|
|
+ cJSON_AddStringToObject(connection, "state", state_str);
|
|
|
+ cJSON_AddStringToObject(connection, "host", conn->host);
|
|
|
+ cJSON_AddNumberToObject(connection, "port", conn->port);
|
|
|
+ cJSON_AddNumberToObject(connection, "reconnect_count", conn->reconnect_count);
|
|
|
+ cJSON_AddItemToObject(root, "connection", connection);
|
|
|
|
|
|
- char *json_str = cJSON_PrintUnformatted(root);
|
|
|
- cJSON_Delete(root);
|
|
|
- return json_str;
|
|
|
-}
|
|
|
-
|
|
|
-/* ── JSON: History query result ────────────────────────────────── */
|
|
|
-char *web_build_history_json(history_result_t *result)
|
|
|
-{
|
|
|
- cJSON *root = cJSON_CreateObject();
|
|
|
- if (!root) return NULL;
|
|
|
-
|
|
|
- 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);
|
|
|
+ cJSON_AddBoolToObject(root, "device_online", data->data_valid ? 1 : 0);
|
|
|
+ cJSON_AddNumberToObject(root, "consecutive_failures",
|
|
|
+ data->consecutive_failures);
|
|
|
|
|
|
+ if (data->data_valid && data->last_update > 0) {
|
|
|
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_AddStringToObject(item, "device_name", rec->device_name);
|
|
|
-
|
|
|
- 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);
|
|
|
+ struct tm *tm_info = localtime(&data->last_update);
|
|
|
+ strftime(timebuf, sizeof(timebuf), "%Y-%m-%dT%H:%M:%S", tm_info);
|
|
|
+ cJSON_AddStringToObject(root, "last_update", timebuf);
|
|
|
}
|
|
|
- cJSON_AddItemToObject(root, "records", records);
|
|
|
-
|
|
|
- char *json_str = cJSON_PrintUnformatted(root);
|
|
|
- cJSON_Delete(root);
|
|
|
- return json_str;
|
|
|
-}
|
|
|
-
|
|
|
-/* ── JSON: History statistics ──────────────────────────────────── */
|
|
|
-char *web_build_stats_json(db_handle_t *db, const char *device_name)
|
|
|
-{
|
|
|
- (void)db;
|
|
|
- (void)device_name;
|
|
|
- /* Placeholder - stats can be extended later */
|
|
|
- cJSON *root = cJSON_CreateObject();
|
|
|
- if (!root) return NULL;
|
|
|
-
|
|
|
- cJSON_AddNumberToObject(root, "total_records", db_get_record_count(db));
|
|
|
|
|
|
char *json_str = cJSON_PrintUnformatted(root);
|
|
|
cJSON_Delete(root);
|
|
|
@@ -259,6 +121,7 @@ static void get_query_param(struct mg_http_message *hm, const char *key,
|
|
|
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] = ' ';
|
|
|
}
|
|
|
@@ -267,40 +130,29 @@ static void get_query_param(struct mg_http_message *hm, const char *key,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-/* ── Find device index by name ─────────────────────────────────── */
|
|
|
-static int find_device_by_name(web_ctx_t *ctx, const char *name)
|
|
|
-{
|
|
|
- for (int i = 0; i < ctx->device_count; i++) {
|
|
|
- if (strcmp(ctx->devices[i].data.device_name, name) == 0) {
|
|
|
- return i;
|
|
|
- }
|
|
|
- }
|
|
|
- return -1;
|
|
|
-}
|
|
|
-
|
|
|
/* ── History API Handler ───────────────────────────────────────── */
|
|
|
static void handle_history(struct mg_connection *c,
|
|
|
struct mg_http_message *hm,
|
|
|
web_ctx_t *ctx)
|
|
|
{
|
|
|
- char param_buf[128];
|
|
|
+ char param_buf[64];
|
|
|
history_query_t query;
|
|
|
memset(&query, 0, sizeof(query));
|
|
|
|
|
|
- /* Device name filter */
|
|
|
- get_query_param(hm, "device", param_buf, sizeof(param_buf));
|
|
|
- snprintf(query.device_name, sizeof(query.device_name), "%s", param_buf);
|
|
|
-
|
|
|
- /* Time range */
|
|
|
+ /* 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));
|
|
|
- for (char *p = param_buf; *p; p++) { if (*p == 'T') *p = ' '; }
|
|
|
+ /* 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 = ' '; }
|
|
|
+ for (char *p = param_buf; *p; p++) {
|
|
|
+ if (*p == 'T') *p = ' ';
|
|
|
+ }
|
|
|
snprintf(query.end_time, sizeof(query.end_time), "%s", param_buf);
|
|
|
|
|
|
- /* Pagination */
|
|
|
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;
|
|
|
@@ -325,7 +177,47 @@ static void handle_history(struct mg_connection *c,
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- char *json_str = web_build_history_json(&result);
|
|
|
+ /* 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) {
|
|
|
@@ -341,69 +233,399 @@ static void handle_history(struct mg_connection *c,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-/* ── Event Handler ─────────────────────────────────────────────── */
|
|
|
-static void web_event_handler(struct mg_connection *c, int ev,
|
|
|
- void *ev_data)
|
|
|
+/* ── HTML Dashboard ────────────────────────────────────────────── */
|
|
|
+
|
|
|
+static const char *DASHBOARD_HTML =
|
|
|
+"<!DOCTYPE html>\n"
|
|
|
+"<html lang=\"zh-CN\">\n"
|
|
|
+"<head>\n"
|
|
|
+"<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, '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: 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: 6px 0; }\n"
|
|
|
+".metric .label { color: #94a3b8; font-size: 0.85em; }\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"
|
|
|
+".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=\"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=\"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"
|
|
|
+" <!-- 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"
|
|
|
+"<script>\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"
|
|
|
+" if (!d.data_valid) return;\n"
|
|
|
+" document.getElementById('bat-v').innerHTML = d.battery.voltage.toFixed(1)+'<span class=\"unit\">V</span>';\n"
|
|
|
+" document.getElementById('bat-soc').innerHTML = d.battery.soc+'<span class=\"unit\">%</span>';\n"
|
|
|
+" document.getElementById('bat-str').textContent = d.battery.string_count;\n"
|
|
|
+" document.getElementById('pv-v').innerHTML = d.pv.voltage.toFixed(1)+'<span class=\"unit\">V</span>';\n"
|
|
|
+" document.getElementById('pv-a').innerHTML = d.pv.current.toFixed(1)+'<span class=\"unit\">A</span>';\n"
|
|
|
+" 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 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 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"
|
|
|
+"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, 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) ─────────────────────────── */
|
|
|
+void web_event_handler(struct mg_connection *c, int ev, void *ev_data)
|
|
|
{
|
|
|
if (ev != MG_EV_HTTP_MSG) return;
|
|
|
|
|
|
struct mg_http_message *hm = (struct mg_http_message *)ev_data;
|
|
|
web_ctx_t *ctx = (web_ctx_t *)c->fn_data;
|
|
|
|
|
|
- /* GET /api/devices - all devices data */
|
|
|
- if (mg_match(hm->uri, mg_str("/api/devices"), NULL)) {
|
|
|
- char *json = web_build_devices_json(ctx);
|
|
|
- if (json) {
|
|
|
- mg_http_reply(c, 200,
|
|
|
- "Content-Type: application/json\r\n"
|
|
|
- "Access-Control-Allow-Origin: *\r\n",
|
|
|
- "%s", json);
|
|
|
- free(json);
|
|
|
- } else {
|
|
|
- mg_http_reply(c, 500, "Content-Type: application/json\r\n",
|
|
|
- "{\"error\":\"Failed to build JSON\"}");
|
|
|
- }
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- /* GET /api/devices/<name> - single device */
|
|
|
- if (hm->uri.len > 13 && strncmp(hm->uri.buf, "/api/devices/", 13) == 0) {
|
|
|
- char dev_name[128] = {0};
|
|
|
- size_t name_len = hm->uri.len - 13;
|
|
|
- if (name_len >= sizeof(dev_name)) name_len = sizeof(dev_name) - 1;
|
|
|
- memcpy(dev_name, hm->uri.buf + 13, name_len);
|
|
|
- dev_name[name_len] = '\0';
|
|
|
-
|
|
|
- /* URL decode */
|
|
|
- for (char *p = dev_name; *p; p++) {
|
|
|
- if (*p == '+' || *p == '%') {
|
|
|
- if (*p == '+') *p = ' ';
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- int idx = find_device_by_name(ctx, dev_name);
|
|
|
- if (idx >= 0) {
|
|
|
- char *json = web_build_device_json(ctx, idx);
|
|
|
- if (json) {
|
|
|
- mg_http_reply(c, 200,
|
|
|
- "Content-Type: application/json\r\n"
|
|
|
- "Access-Control-Allow-Origin: *\r\n",
|
|
|
- "%s", json);
|
|
|
- free(json);
|
|
|
- } else {
|
|
|
- mg_http_reply(c, 500, "Content-Type: application/json\r\n",
|
|
|
- "{\"error\":\"JSON build failed\"}");
|
|
|
- }
|
|
|
- } else {
|
|
|
- mg_http_reply(c, 404, "Content-Type: application/json\r\n",
|
|
|
- "{\"error\":\"Device not found: %s\"}", dev_name);
|
|
|
- }
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- /* GET /api/data - backward compat (returns all devices) */
|
|
|
if (mg_match(hm->uri, mg_str("/api/data"), NULL)) {
|
|
|
- char *json = web_build_devices_json(ctx);
|
|
|
+ const mppt_data_t *data = (const mppt_data_t *)ctx->mppt_data;
|
|
|
+ char *json = build_data_json(data);
|
|
|
if (json) {
|
|
|
mg_http_reply(c, 200,
|
|
|
"Content-Type: application/json\r\n"
|
|
|
@@ -411,15 +633,17 @@ static void web_event_handler(struct mg_connection *c, int ev,
|
|
|
"%s", json);
|
|
|
free(json);
|
|
|
} else {
|
|
|
- mg_http_reply(c, 500, "Content-Type: application/json\r\n",
|
|
|
+ mg_http_reply(c, 500,
|
|
|
+ "Content-Type: application/json\r\n",
|
|
|
"{\"error\":\"Failed to build JSON\"}");
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- /* GET /api/status - all devices status */
|
|
|
if (mg_match(hm->uri, mg_str("/api/status"), NULL)) {
|
|
|
- char *json = web_build_devices_json(ctx);
|
|
|
+ const mppt_data_t *data = (const mppt_data_t *)ctx->mppt_data;
|
|
|
+ const modbus_conn_t *conn = (const modbus_conn_t *)ctx->modbus_conn;
|
|
|
+ char *json = build_status_json(data, conn);
|
|
|
if (json) {
|
|
|
mg_http_reply(c, 200,
|
|
|
"Content-Type: application/json\r\n"
|
|
|
@@ -427,13 +651,13 @@ static void web_event_handler(struct mg_connection *c, int ev,
|
|
|
"%s", json);
|
|
|
free(json);
|
|
|
} else {
|
|
|
- mg_http_reply(c, 500, "Content-Type: application/json\r\n",
|
|
|
+ mg_http_reply(c, 500,
|
|
|
+ "Content-Type: application/json\r\n",
|
|
|
"{\"error\":\"Failed to build JSON\"}");
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- /* GET /api/health */
|
|
|
if (mg_match(hm->uri, mg_str("/api/health"), NULL)) {
|
|
|
mg_http_reply(c, 200,
|
|
|
"Content-Type: application/json\r\n",
|
|
|
@@ -441,35 +665,15 @@ static void web_event_handler(struct mg_connection *c, int ev,
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- /* GET /api/history/stats - must be before /api/history */
|
|
|
- if (mg_match(hm->uri, mg_str("/api/history/stats"), NULL)) {
|
|
|
- char param_buf[128];
|
|
|
- get_query_param(hm, "device", param_buf, sizeof(param_buf));
|
|
|
- char *json = web_build_stats_json(ctx->db, param_buf);
|
|
|
- if (json) {
|
|
|
- mg_http_reply(c, 200,
|
|
|
- "Content-Type: application/json\r\n"
|
|
|
- "Access-Control-Allow-Origin: *\r\n",
|
|
|
- "%s", json);
|
|
|
- free(json);
|
|
|
- } else {
|
|
|
- mg_http_reply(c, 500, "Content-Type: application/json\r\n",
|
|
|
- "{\"error\":\"JSON build failed\"}");
|
|
|
- }
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- /* GET /api/history */
|
|
|
if (mg_match(hm->uri, mg_str("/api/history"), NULL)) {
|
|
|
handle_history(c, hm, ctx);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- /* GET / - HTML dashboard */
|
|
|
if (mg_match(hm->uri, mg_str("/"), NULL)) {
|
|
|
mg_http_reply(c, 200,
|
|
|
"Content-Type: text/html; charset=utf-8\r\n",
|
|
|
- "%s", web_get_dashboard_html());
|
|
|
+ "%s", DASHBOARD_HTML);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
@@ -478,29 +682,18 @@ static void web_event_handler(struct mg_connection *c, int ev,
|
|
|
"{\"error\":\"Not Found\"}");
|
|
|
}
|
|
|
|
|
|
-/* ── Init & Start ──────────────────────────────────────────────── */
|
|
|
-void web_server_init(web_ctx_t *ctx, device_runtime_t *devices,
|
|
|
- int device_count, db_handle_t *db,
|
|
|
- app_config_t *config, pthread_mutex_t *mutex)
|
|
|
-{
|
|
|
- memset(ctx, 0, sizeof(*ctx));
|
|
|
- ctx->devices = devices;
|
|
|
- ctx->device_count = device_count;
|
|
|
- ctx->db = db;
|
|
|
- ctx->config = config;
|
|
|
- ctx->mutex = mutex;
|
|
|
-}
|
|
|
-
|
|
|
+/* ── Start Listener ────────────────────────────────────────────── */
|
|
|
struct mg_connection *web_server_start(struct mg_mgr *mgr, web_ctx_t *ctx)
|
|
|
{
|
|
|
struct mg_connection *c;
|
|
|
char listen_url[128];
|
|
|
|
|
|
+ /*
|
|
|
+ * Read port from DEPLOY_RUN_PORT environment variable (sandbox compat).
|
|
|
+ * Falls back to WEB_SERVER_PORT (8085) if not set.
|
|
|
+ */
|
|
|
const char *env_port = getenv("DEPLOY_RUN_PORT");
|
|
|
- int port = 8085;
|
|
|
- if (ctx->config && ctx->config->web_port > 0) {
|
|
|
- port = ctx->config->web_port;
|
|
|
- }
|
|
|
+ int port = WEB_SERVER_PORT;
|
|
|
if (env_port != NULL && env_port[0] != '\0') {
|
|
|
int parsed = atoi(env_port);
|
|
|
if (parsed > 0 && parsed < 65536) {
|