Răsfoiți Sursa

feat: 新增历史数据存储查询功能 - SQLite3定时存储 + 分页API + Chart.js趋势图 + config.ini配置

Coze-Commit-Type: user
Coze-User-ID: 256649704250362
Coze-Conversation-ID: 11640025
steven_roc 2 luni în urmă
părinte
comite
340050e547

+ 56 - 0
mppt_monitor/CHANGELOG.md

@@ -4,6 +4,62 @@
 
 ---
 
+## [1.1.0] - 2026-08-04
+
+### 新增:历史数据存储与查询
+
+#### 新增功能
+
+- **SQLite3 历史数据存储**
+  - 新增 `db.h/db.c` 数据库模块
+  - 自动创建 `history_data` 表,包含所有 MPPT 寄存器字段
+  - 可配置的定时存储(默认每 10 秒保存一次实时数据)
+  - 支持时间范围查询、分页、排序
+
+- **config.ini 配置文件**
+  - 新增 `config.h/config.c` 配置解析模块
+  - 支持 `[modbus]`、`[storage]`、`[web]` 三个配置段
+  - 可配置项:Modbus 连接参数、存储间隔、数据库路径、Web 端口、分页大小
+  - 配置文件缺失时使用内置默认值,不影响启动
+
+- **历史数据查询 API**
+  - `GET /api/history` - 分页查询历史数据
+    - 参数:`start_time`、`end_time`(ISO 8601 格式)、`page`、`page_size`、`sort`(ASC/DESC)
+    - 返回:`total`、`page`、`page_size`、`total_pages`、`records[]`
+  - 每条记录包含完整的 MPPT 数据和计算后的光伏功率
+
+- **历史数据仪表盘页面**
+  - 实时监控 / 历史查询双页面切换(顶部 Tab 导航)
+  - 日期时间范围选择器(datetime-local 输入)
+  - 快捷时间按钮:最近 1 小时 / 24 小时 / 7 天
+  - 分页大小选择:20 / 50 / 100 / 200 条
+  - 排序切换:最新优先 / 最早优先
+  - 数据表格展示:时间、电池电压、SOC、光伏电压/电流/功率、温度、故障、状态
+  - 分页控件:上一页 / 下一页 / 页码显示
+  - 分页信息:显示当前范围和总记录数
+
+- **趋势图表(Chart.js 3.x)**
+  - 通过 CDN 引入 Chart.js,无本地依赖
+  - 多数据集折线图:电池电压、电池 SOC、光伏功率、温度
+  - 数据集可切换显示/隐藏
+  - 自适应时间轴标签
+  - 响应式布局,跟随页面宽度
+
+#### 架构变更
+
+- 主程序新增数据库初始化和关闭流程
+- Modbus 轮询线程新增定时存储逻辑(基于 `storage_interval_sec` 配置)
+- Web 上下文 `web_ctx_t` 新增 `db` 和 `config` 指针
+- Makefile 新增 `-lsqlite3` 链接和 `config.c`、`db.c` 编译目标
+
+#### 依赖变更
+
+| 库 | 版本 | 用途 |
+|----|------|------|
+| SQLite3 | 3.x | 历史数据存储(新增) |
+
+---
+
 ## [1.0.0] - 2026-08-04
 
 ### 初始版本发布

+ 11 - 2
mppt_monitor/Makefile

@@ -6,6 +6,7 @@
 # Libraries:
 #   - mongoose-7.22  (HTTP server)
 #   - cJSON-1.7.19   (JSON serialization)
+#   - SQLite3        (historical data storage)
 #   - OpenSSL 3.x    (TLS support, optional)
 #
 # Usage:
@@ -20,7 +21,7 @@
 # ── Compiler & Flags ─────────────────────────────────────────────
 CC       ?= gcc
 CFLAGS   := -Wall -Wextra -Wno-unused-parameter -O2 -g
-LDFLAGS  := -lpthread -lm
+LDFLAGS  := -lpthread -lm -lsqlite3
 
 # ── Directories ──────────────────────────────────────────────────
 SRCDIR   := src
@@ -34,7 +35,9 @@ MONGOOSE_DIR := $(LIBDIR)/mongoose-7.22
 # ── Source files ─────────────────────────────────────────────────
 APP_SRCS := $(SRCDIR)/main.c \
             $(SRCDIR)/modbus_client.c \
-            $(SRCDIR)/web_server.c
+            $(SRCDIR)/web_server.c \
+            $(SRCDIR)/config.c \
+            $(SRCDIR)/db.c
 
 LIB_SRCS := $(CJSON_DIR)/cJSON.c \
             $(MONGOOSE_DIR)/mongoose.c
@@ -100,6 +103,12 @@ $(BUILDDIR)/modbus_client.o: $(SRCDIR)/modbus_client.c | $(BUILDDIR)
 $(BUILDDIR)/web_server.o: $(SRCDIR)/web_server.c | $(BUILDDIR)
 	$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
 
+$(BUILDDIR)/config.o: $(SRCDIR)/config.c | $(BUILDDIR)
+	$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
+
+$(BUILDDIR)/db.o: $(SRCDIR)/db.c | $(BUILDDIR)
+	$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
+
 $(BUILDDIR)/cJSON.o: $(CJSON_DIR)/cJSON.c | $(BUILDDIR)
 	$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
 

+ 153 - 11
mppt_monitor/TECHNICAL.md

@@ -8,7 +8,7 @@
 
 ### 1.1 项目目标
 
-开发一个轻量级 C 语言嵌入式监控程序,通过 Modbus RTU over TCP 协议读取 MPPT 太阳能充电控制器的实时数据,并通过 HTTP REST API 对外提供 JSON 格式的实时数据接口。
+开发一个轻量级 C 语言嵌入式监控程序,通过 Modbus RTU over TCP 协议读取 MPPT 太阳能充电控制器的实时数据,并通过 HTTP REST API 对外提供 JSON 格式的实时数据接口。支持历史数据定时存储到 SQLite3 数据库,并提供带趋势图表的历史数据查询页面。
 
 ### 1.2 架构总览
 
@@ -26,7 +26,13 @@
 │  ┌──────────────────────────────────┐                   │
 │  │     Shared Data (mppt_data_t)    │                   │
 │  │     Protected by pthread_mutex   │                   │
-│  └──────────────────────────────────┘                   │
+│  └──────────┬───────────────────────┘                   │
+│             │                                           │
+│             ▼                                           │
+│  ┌──────────────────────┐                              │
+│  │  SQLite3 Database    │                              │
+│  │  (历史数据存储)       │                              │
+│  └──────────────────────┘                              │
 │                                                          │
 └─────────────────────────────────────────────────────────┘
          │                           │
@@ -283,7 +289,140 @@ Web 线程读取数据时加锁读取
 
 ---
 
-## 七、安全考虑
+## 七、配置系统
+
+### 7.1 config.ini 文件格式
+
+```ini
+[modbus]
+host = discover.zhonjin.com
+port = 40635
+slave_addr = 0x01
+timeout_sec = 10
+poll_ms = 2000
+
+[storage]
+interval_sec = 10
+db_path = mppt_data.db
+
+[web]
+port = 8085
+page_size = 50
+```
+
+### 7.2 配置项说明
+
+| 配置段 | 键 | 类型 | 默认值 | 说明 |
+|--------|-----|------|--------|------|
+| modbus | host | string | discover.zhonjin.com | Modbus 设备域名/IP |
+| modbus | port | int | 40635 | Modbus TCP 端口 |
+| modbus | slave_addr | hex | 0x01 | Modbus 从机地址 |
+| modbus | timeout_sec | int | 10 | 连接超时(秒) |
+| modbus | poll_ms | int | 2000 | 轮询间隔(毫秒) |
+| storage | interval_sec | int | 10 | 数据存储间隔(秒) |
+| storage | db_path | string | mppt_data.db | SQLite 数据库文件路径 |
+| web | port | int | 8085 | HTTP 服务端口 |
+| web | page_size | int | 50 | 历史数据默认分页大小 |
+
+### 7.3 配置解析逻辑
+
+- 配置文件不存在时使用内置默认值,程序正常启动
+- 配置值解析失败时回退到默认值并输出警告日志
+- 十六进制值支持 `0x` 前缀(如 `slave_addr = 0x01`)
+
+---
+
+## 八、历史数据存储
+
+### 8.1 数据库设计
+
+使用 SQLite3 作为轻量级嵌入式数据库,无需额外服务进程。
+
+#### history_data 表结构
+
+```sql
+CREATE TABLE IF NOT EXISTS history_data (
+    id              INTEGER PRIMARY KEY AUTOINCREMENT,
+    timestamp       INTEGER NOT NULL,           -- Unix 时间戳
+    battery_voltage REAL NOT NULL,              -- 电池电压 (V)
+    pv_voltage      REAL NOT NULL,              -- 光伏电压 (V)
+    pv_charge_current REAL NOT NULL,            -- 光伏充电电流 (A)
+    cumulative_energy REAL NOT NULL,            -- 累计发电量 (kWh)
+    machine_temp    REAL NOT NULL,              -- 机器温度 (°C)
+    fault_code      INTEGER NOT NULL,           -- 故障代码
+    battery_soc     INTEGER NOT NULL,           -- 电池电量 (%)
+    battery_string  INTEGER NOT NULL,           -- 电池串数
+    charge_state    INTEGER NOT NULL,           -- 充电状态
+    device_type     INTEGER NOT NULL,           -- 设备类型
+    pv_input_power  REAL NOT NULL,              -- 光伏输入功率 (W)
+    created_at      TEXT DEFAULT (datetime('now', 'localtime'))
+);
+```
+
+#### 索引
+
+```sql
+CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history_data(timestamp);
+```
+
+### 8.2 存储流程
+
+```
+Modbus 读取成功 → 更新共享数据 → 检查存储间隔
+                                    │
+                    ┌───────────────┴───────────────┐
+                    │ 距上次存储 >= interval_sec?    │
+                    │                               │
+                    ▼ Yes                           ▼ No
+              插入 SQLite                      跳过
+              更新 last_store_time
+```
+
+### 8.3 查询 API
+
+**GET /api/history**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| start_time | string | 否 | 起始时间,格式 `YYYY-MM-DDTHH:MM:SS` 或 `YYYY-MM-DD HH:MM:SS` |
+| end_time | string | 否 | 结束时间,格式同上 |
+| page | int | 否 | 页码,默认 1 |
+| page_size | int | 否 | 每页条数,默认取 config.web.page_size,最大 500 |
+| sort | string | 否 | 排序方式:`ASC` 或 `DESC`(默认) |
+
+**响应示例:**
+
+```json
+{
+  "total": 150,
+  "page": 1,
+  "page_size": 50,
+  "total_pages": 3,
+  "records": [
+    {
+      "id": 150,
+      "timestamp": "2026-08-04 16:30:00",
+      "timestamp_unix": 1785832200,
+      "battery_voltage": 26.8,
+      "pv_voltage": 37.2,
+      "pv_charge_current": 2.2,
+      "pv_input_power": 81.84,
+      "cumulative_energy": 0.8,
+      "machine_temp": 45,
+      "fault_code": 20,
+      "fault_desc": "Normal",
+      "battery_soc": 96,
+      "charge_state": 1,
+      "charge_state_desc": "Charging (Bulk)",
+      "device_type": 60
+    }
+  ]
+}
+```
+
+---
+
+## 九、安全考虑
 
 | 方面 | 措施 |
 |------|------|
@@ -292,18 +431,21 @@ Web 线程读取数据时加锁读取
 | 数据完整性 | Modbus CRC-16 校验确保数据正确性 |
 | 异常输入 | HTTP 路由严格匹配,未匹配返回 404 |
 | 内存安全 | 使用 cJSON 安全 API,无缓冲区溢出风险 |
+| SQL 注入 | 使用 SQLite3 参数化查询(`?` 占位符),杜绝注入风险 |
+| 配置安全 | config.ini 解析使用 snprintf 防溢出,非法值回退默认 |
 
 ---
 
-## 八、扩展方向
+## 十、扩展方向
 
-1. **数据持久化**: 添加 SQLite/InfluxDB 存储历史数据
-2. **告警通知**: 温度过高、电压异常时发送邮件/微信通知
-3. **HTTPS 支持**: 已预留 OpenSSL 编译选项
-4. **MQTT 上报**: 将数据发布到 MQTT Broker 供其他系统消费
-5. **多设备支持**: 支持同时监控多个 MPPT 控制器
-6. **Web 认证**: 添加 HTTP Basic Auth 或 Token 认证
+1. **告警通知**: 温度过高、电压异常时发送邮件/微信通知
+2. **HTTPS 支持**: 已预留 OpenSSL 编译选项
+3. **MQTT 上报**: 将数据发布到 MQTT Broker 供其他系统消费
+4. **多设备支持**: 支持同时监控多个 MPPT 控制器
+5. **Web 认证**: 添加 HTTP Basic Auth 或 Token 认证
+6. **数据导出**: 支持 CSV/Excel 格式导出历史数据
+7. **统计聚合**: 按小时/天/月聚合统计数据(平均、最大、最小)
 
 ---
 
-*最后更新: 2026-08-04*
+*最后更新: 2026-08-04 (v1.1.0)*

BIN
mppt_monitor/build/mppt_monitor


+ 29 - 0
mppt_monitor/config.ini

@@ -0,0 +1,29 @@
+# =============================================================================
+# MPPT Solar Charge Controller Monitor - Configuration
+# =============================================================================
+# This file controls runtime behavior. Edit and restart the service to apply.
+# Lines starting with # or ; are comments.
+# =============================================================================
+
+[modbus]
+# Modbus TCP device connection settings
+host = discover.zhonjin.com
+port = 40635
+slave_addr = 0x01
+timeout_sec = 10
+# How often to poll the device for new data (milliseconds)
+poll_ms = 2000
+
+[storage]
+# How often to save data to the database (seconds)
+# Default: 10 seconds. Minimum: 1 second.
+interval_sec = 10
+# SQLite database file path (relative to working directory or absolute)
+db_path = mppt_data.db
+
+[web]
+# HTTP server port (default: 8085)
+# Note: In sandbox environment, DEPLOY_RUN_PORT env var overrides this.
+port = 8085
+# Default page size for history API pagination
+page_size = 50

BIN
mppt_monitor/mppt_data.db


+ 141 - 0
mppt_monitor/src/config.c

@@ -0,0 +1,141 @@
+/**
+ * @file config.c
+ * @brief Simple INI configuration parser for MPPT Monitor
+ *
+ * Supports [section] and key=value pairs.
+ * Lines starting with # or ; are comments.
+ */
+
+#include "config.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <ctype.h>
+
+/* ── Default Values ────────────────────────────────────────────── */
+void config_set_defaults(app_config_t *cfg)
+{
+    strncpy(cfg->modbus_host, "discover.zhonjin.com", sizeof(cfg->modbus_host) - 1);
+    cfg->modbus_port = 40635;
+    cfg->modbus_slave_addr = 0x01;
+    cfg->modbus_timeout_sec = 10;
+    cfg->modbus_poll_ms = 2000;
+
+    cfg->storage_interval_sec = 10;
+    strncpy(cfg->db_path, "mppt_data.db", sizeof(cfg->db_path) - 1);
+
+    cfg->web_port = 8085;
+    cfg->web_page_size = 50;
+}
+
+/* ── Helper: trim whitespace ───────────────────────────────────── */
+static char *trim(char *str)
+{
+    while (isspace((unsigned char)*str)) str++;
+    if (*str == '\0') return str;
+    char *end = str + strlen(str) - 1;
+    while (end > str && isspace((unsigned char)*end)) end--;
+    *(end + 1) = '\0';
+    return str;
+}
+
+/* ── Helper: set a config value by section + key ───────────────── */
+static void config_set_value(app_config_t *cfg, const char *section,
+                              const char *key, const char *value)
+{
+    if (strcmp(section, "modbus") == 0) {
+        if (strcmp(key, "host") == 0) {
+            strncpy(cfg->modbus_host, value, sizeof(cfg->modbus_host) - 1);
+        } else if (strcmp(key, "port") == 0) {
+            cfg->modbus_port = atoi(value);
+        } else if (strcmp(key, "slave_addr") == 0) {
+            cfg->modbus_slave_addr = (int)strtol(value, NULL, 0);
+        } else if (strcmp(key, "timeout_sec") == 0) {
+            cfg->modbus_timeout_sec = atoi(value);
+        } else if (strcmp(key, "poll_ms") == 0) {
+            cfg->modbus_poll_ms = atoi(value);
+        }
+    } else if (strcmp(section, "storage") == 0) {
+        if (strcmp(key, "interval_sec") == 0) {
+            cfg->storage_interval_sec = atoi(value);
+            if (cfg->storage_interval_sec < 1) cfg->storage_interval_sec = 1;
+        } else if (strcmp(key, "db_path") == 0) {
+            strncpy(cfg->db_path, value, sizeof(cfg->db_path) - 1);
+        }
+    } else if (strcmp(section, "web") == 0) {
+        if (strcmp(key, "port") == 0) {
+            cfg->web_port = atoi(value);
+        } else if (strcmp(key, "page_size") == 0) {
+            cfg->web_page_size = atoi(value);
+            if (cfg->web_page_size < 1) cfg->web_page_size = 50;
+        }
+    }
+}
+
+/* ── Load INI File ─────────────────────────────────────────────── */
+int config_load(app_config_t *cfg, const char *filepath)
+{
+    config_set_defaults(cfg);
+
+    FILE *fp = fopen(filepath, "r");
+    if (!fp) {
+        fprintf(stderr, "[CONFIG] Config file '%s' not found, using defaults\n", filepath);
+        return -1;
+    }
+
+    char line[1024];
+    char section[256] = "";
+
+    while (fgets(line, sizeof(line), fp)) {
+        char *trimmed = trim(line);
+
+        /* Skip empty lines and comments */
+        if (trimmed[0] == '\0' || trimmed[0] == '#' || trimmed[0] == ';') {
+            continue;
+        }
+
+        /* Section header */
+        if (trimmed[0] == '[') {
+            char *end = strchr(trimmed, ']');
+            if (end) {
+                *end = '\0';
+                strncpy(section, trimmed + 1, sizeof(section) - 1);
+                section[sizeof(section) - 1] = '\0';
+            }
+            continue;
+        }
+
+        /* Key = Value */
+        char *eq = strchr(trimmed, '=');
+        if (eq) {
+            *eq = '\0';
+            char *key = trim(trimmed);
+            char *value = trim(eq + 1);
+            config_set_value(cfg, section, key, value);
+        }
+    }
+
+    fclose(fp);
+    printf("[CONFIG] Loaded configuration from '%s'\n", filepath);
+    return 0;
+}
+
+/* ── Print Configuration ───────────────────────────────────────── */
+void config_print(const app_config_t *cfg)
+{
+    printf("[CONFIG] === Configuration ===\n");
+    printf("[CONFIG] [modbus]\n");
+    printf("[CONFIG]   host         = %s\n", cfg->modbus_host);
+    printf("[CONFIG]   port         = %d\n", cfg->modbus_port);
+    printf("[CONFIG]   slave_addr   = 0x%02X\n", cfg->modbus_slave_addr);
+    printf("[CONFIG]   timeout_sec  = %d\n", cfg->modbus_timeout_sec);
+    printf("[CONFIG]   poll_ms      = %d\n", cfg->modbus_poll_ms);
+    printf("[CONFIG] [storage]\n");
+    printf("[CONFIG]   interval_sec = %d\n", cfg->storage_interval_sec);
+    printf("[CONFIG]   db_path      = %s\n", cfg->db_path);
+    printf("[CONFIG] [web]\n");
+    printf("[CONFIG]   port         = %d\n", cfg->web_port);
+    printf("[CONFIG]   page_size    = %d\n", cfg->web_page_size);
+    printf("[CONFIG] =====================\n");
+}

+ 60 - 0
mppt_monitor/src/config.h

@@ -0,0 +1,60 @@
+/**
+ * @file config.h
+ * @brief INI configuration file parser for MPPT Monitor
+ *
+ * Reads config.ini for runtime settings (storage interval, DB path, etc.)
+ */
+
+#ifndef CONFIG_H
+#define CONFIG_H
+
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ── Configuration Structure ───────────────────────────────────── */
+typedef struct {
+    /* Modbus settings */
+    char  modbus_host[256];
+    int   modbus_port;
+    int   modbus_slave_addr;
+    int   modbus_timeout_sec;
+    int   modbus_poll_ms;         /* Modbus read interval (ms) */
+
+    /* Storage settings */
+    int   storage_interval_sec;   /* Data storage interval (seconds), default 10 */
+    char  db_path[512];           /* SQLite database file path */
+
+    /* Web server settings */
+    int   web_port;               /* HTTP server port, default 8085 */
+    int   web_page_size;          /* Default page size for history API */
+} app_config_t;
+
+/* ── Public API ────────────────────────────────────────────────── */
+
+/**
+ * Load configuration from INI file.
+ * If file doesn't exist, loads defaults.
+ * @param cfg       Pointer to config struct to fill
+ * @param filepath  Path to config.ini
+ * @return 0 on success, -1 on error (defaults still loaded)
+ */
+int config_load(app_config_t *cfg, const char *filepath);
+
+/**
+ * Print current configuration to stdout.
+ */
+void config_print(const app_config_t *cfg);
+
+/**
+ * Get default configuration values.
+ */
+void config_set_defaults(app_config_t *cfg);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* CONFIG_H */

+ 259 - 0
mppt_monitor/src/db.c

@@ -0,0 +1,259 @@
+/**
+ * @file db.c
+ * @brief SQLite3 database implementation for MPPT historical data
+ */
+
+#include "db.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* ── Create Tables ─────────────────────────────────────────────── */
+static int db_create_tables(db_handle_t *handle)
+{
+    const char *sql =
+        "CREATE TABLE IF NOT EXISTS mppt_history ("
+        "  id INTEGER PRIMARY KEY AUTOINCREMENT,"
+        "  timestamp INTEGER NOT NULL,"
+        "  battery_voltage REAL,"
+        "  pv_voltage REAL,"
+        "  pv_charge_current REAL,"
+        "  cumulative_energy REAL,"
+        "  machine_temp REAL,"
+        "  fault_code INTEGER,"
+        "  battery_soc INTEGER,"
+        "  battery_string INTEGER,"
+        "  charge_state INTEGER,"
+        "  device_type INTEGER,"
+        "  pv_input_power REAL"
+        ");"
+        "CREATE INDEX IF NOT EXISTS idx_timestamp ON mppt_history(timestamp);";
+
+    char *errmsg = NULL;
+    int rc = sqlite3_exec(handle->db, sql, NULL, NULL, &errmsg);
+    if (rc != SQLITE_OK) {
+        fprintf(stderr, "[DB] Failed to create tables: %s\n", errmsg);
+        sqlite3_free(errmsg);
+        return -1;
+    }
+    return 0;
+}
+
+/* ── Open Database ─────────────────────────────────────────────── */
+int db_open(db_handle_t *handle, const char *path)
+{
+    memset(handle, 0, sizeof(*handle));
+    strncpy(handle->db_path, path, sizeof(handle->db_path) - 1);
+
+    int rc = sqlite3_open(path, &handle->db);
+    if (rc != SQLITE_OK) {
+        fprintf(stderr, "[DB] Cannot open database '%s': %s\n",
+                path, sqlite3_errmsg(handle->db));
+        return -1;
+    }
+
+    /* Enable WAL mode for better concurrent read/write performance */
+    sqlite3_exec(handle->db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL);
+    /* Set busy timeout to avoid SQLITE_BUSY errors */
+    sqlite3_busy_timeout(handle->db, 5000);
+
+    if (db_create_tables(handle) != 0) {
+        sqlite3_close(handle->db);
+        handle->db = NULL;
+        return -1;
+    }
+
+    printf("[DB] Database opened: %s\n", path);
+    return 0;
+}
+
+/* ── Close Database ────────────────────────────────────────────── */
+void db_close(db_handle_t *handle)
+{
+    if (handle->db) {
+        sqlite3_close(handle->db);
+        handle->db = NULL;
+        printf("[DB] Database closed\n");
+    }
+}
+
+/* ── Insert Record ─────────────────────────────────────────────── */
+int db_insert_record(db_handle_t *handle, const mppt_data_t *data)
+{
+    const char *sql =
+        "INSERT INTO mppt_history "
+        "(timestamp, battery_voltage, pv_voltage, pv_charge_current, "
+        "cumulative_energy, machine_temp, fault_code, battery_soc, "
+        "battery_string, charge_state, device_type, pv_input_power) "
+        "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+
+    sqlite3_stmt *stmt = NULL;
+    int rc = sqlite3_prepare_v2(handle->db, sql, -1, &stmt, NULL);
+    if (rc != SQLITE_OK) {
+        fprintf(stderr, "[DB] Prepare insert failed: %s\n",
+                sqlite3_errmsg(handle->db));
+        return -1;
+    }
+
+    sqlite3_bind_int64(stmt, 1, (int64_t)data->last_update);
+    sqlite3_bind_double(stmt, 2, data->battery_voltage);
+    sqlite3_bind_double(stmt, 3, data->pv_voltage);
+    sqlite3_bind_double(stmt, 4, data->pv_charge_current);
+    sqlite3_bind_double(stmt, 5, data->cumulative_energy);
+    sqlite3_bind_double(stmt, 6, data->machine_temp);
+    sqlite3_bind_int(stmt, 7, (int)data->fault_code);
+    sqlite3_bind_int(stmt, 8, (int)data->battery_soc);
+    sqlite3_bind_int(stmt, 9, (int)data->battery_string);
+    sqlite3_bind_int(stmt, 10, (int)data->charge_state);
+    sqlite3_bind_int(stmt, 11, (int)data->device_type);
+    sqlite3_bind_double(stmt, 12, data->pv_input_power);
+
+    rc = sqlite3_step(stmt);
+    sqlite3_finalize(stmt);
+
+    if (rc != SQLITE_DONE) {
+        fprintf(stderr, "[DB] Insert failed: %s\n",
+                sqlite3_errmsg(handle->db));
+        return -1;
+    }
+
+    return 0;
+}
+
+/* ── Query History ─────────────────────────────────────────────── */
+int db_query_history(db_handle_t *handle, const history_query_t *query,
+                     history_result_t *result)
+{
+    memset(result, 0, sizeof(*result));
+    result->page = query->page;
+    result->page_size = query->page_size;
+
+    /* Build WHERE clause */
+    char where_clause[512] = "";
+    int has_start = (query->start_time[0] != '\0');
+    int has_end = (query->end_time[0] != '\0');
+
+    if (has_start && has_end) {
+        snprintf(where_clause, sizeof(where_clause),
+                 "WHERE timestamp >= strftime('%%s', '%s') "
+                 "AND timestamp <= strftime('%%s', '%s')",
+                 query->start_time, query->end_time);
+    } else if (has_start) {
+        snprintf(where_clause, sizeof(where_clause),
+                 "WHERE timestamp >= strftime('%%s', '%s')",
+                 query->start_time);
+    } else if (has_end) {
+        snprintf(where_clause, sizeof(where_clause),
+                 "WHERE timestamp <= strftime('%%s', '%s')",
+                 query->end_time);
+    }
+
+    /* Count total records */
+    char count_sql[1024];
+    snprintf(count_sql, sizeof(count_sql),
+             "SELECT COUNT(*) FROM mppt_history %s", where_clause);
+
+    sqlite3_stmt *stmt = NULL;
+    int rc = sqlite3_prepare_v2(handle->db, count_sql, -1, &stmt, NULL);
+    if (rc != SQLITE_OK) {
+        fprintf(stderr, "[DB] Prepare count failed: %s\n",
+                sqlite3_errmsg(handle->db));
+        return -1;
+    }
+
+    if (sqlite3_step(stmt) == SQLITE_ROW) {
+        result->total = sqlite3_column_int(stmt, 0);
+    }
+    sqlite3_finalize(stmt);
+
+    /* Calculate total pages */
+    result->total_pages = (result->total + query->page_size - 1) / query->page_size;
+    if (result->total_pages < 1) result->total_pages = 1;
+
+    /* Clamp page number */
+    int page = query->page;
+    if (page < 1) page = 1;
+    if (page > result->total_pages) page = result->total_pages;
+    result->page = page;
+
+    int offset = (page - 1) * query->page_size;
+
+    /* Build SELECT query */
+    const char *order = (strcmp(query->sort_order, "ASC") == 0) ? "ASC" : "DESC";
+    char select_sql[1024];
+    snprintf(select_sql, sizeof(select_sql),
+             "SELECT id, timestamp, battery_voltage, pv_voltage, "
+             "pv_charge_current, cumulative_energy, machine_temp, "
+             "fault_code, battery_soc, battery_string, charge_state, "
+             "device_type, pv_input_power "
+             "FROM mppt_history %s "
+             "ORDER BY timestamp %s "
+             "LIMIT %d OFFSET %d",
+             where_clause, order, query->page_size, offset);
+
+    rc = sqlite3_prepare_v2(handle->db, select_sql, -1, &stmt, NULL);
+    if (rc != SQLITE_OK) {
+        fprintf(stderr, "[DB] Prepare select failed: %s\n",
+                sqlite3_errmsg(handle->db));
+        return -1;
+    }
+
+    /* Allocate records array */
+    result->records = (history_record_t *)calloc(
+        (size_t)query->page_size, sizeof(history_record_t));
+    if (!result->records) {
+        sqlite3_finalize(stmt);
+        return -1;
+    }
+
+    int count = 0;
+    while (sqlite3_step(stmt) == SQLITE_ROW && count < query->page_size) {
+        history_record_t *rec = &result->records[count];
+        rec->id = sqlite3_column_int64(stmt, 0);
+        rec->timestamp = sqlite3_column_int64(stmt, 1);
+        rec->battery_voltage = sqlite3_column_double(stmt, 2);
+        rec->pv_voltage = sqlite3_column_double(stmt, 3);
+        rec->pv_charge_current = sqlite3_column_double(stmt, 4);
+        rec->cumulative_energy = sqlite3_column_double(stmt, 5);
+        rec->machine_temp = sqlite3_column_double(stmt, 6);
+        rec->fault_code = (uint16_t)sqlite3_column_int(stmt, 7);
+        rec->battery_soc = (uint16_t)sqlite3_column_int(stmt, 8);
+        rec->battery_string = (uint16_t)sqlite3_column_int(stmt, 9);
+        rec->charge_state = (uint16_t)sqlite3_column_int(stmt, 10);
+        rec->device_type = (uint16_t)sqlite3_column_int(stmt, 11);
+        rec->pv_input_power = sqlite3_column_double(stmt, 12);
+        count++;
+    }
+    result->count = count;
+
+    sqlite3_finalize(stmt);
+    return 0;
+}
+
+/* ── Free Result ───────────────────────────────────────────────── */
+void db_free_result(history_result_t *result)
+{
+    if (result->records) {
+        free(result->records);
+        result->records = NULL;
+    }
+    result->count = 0;
+}
+
+/* ── Get Total Record Count ────────────────────────────────────── */
+int db_get_record_count(db_handle_t *handle)
+{
+    sqlite3_stmt *stmt = NULL;
+    int rc = sqlite3_prepare_v2(handle->db,
+                                "SELECT COUNT(*) FROM mppt_history",
+                                -1, &stmt, NULL);
+    if (rc != SQLITE_OK) return 0;
+
+    int count = 0;
+    if (sqlite3_step(stmt) == SQLITE_ROW) {
+        count = sqlite3_column_int(stmt, 0);
+    }
+    sqlite3_finalize(stmt);
+    return count;
+}

+ 107 - 0
mppt_monitor/src/db.h

@@ -0,0 +1,107 @@
+/**
+ * @file db.h
+ * @brief SQLite3 database module for MPPT historical data storage
+ */
+
+#ifndef DB_H
+#define DB_H
+
+#include <sqlite3.h>
+#include <stdint.h>
+#include <time.h>
+#include "modbus_client.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ── History Record ────────────────────────────────────────────── */
+typedef struct {
+    int64_t  id;
+    int64_t  timestamp;           /* Unix timestamp */
+    double   battery_voltage;
+    double   pv_voltage;
+    double   pv_charge_current;
+    double   cumulative_energy;
+    double   machine_temp;
+    uint16_t fault_code;
+    uint16_t battery_soc;
+    uint16_t battery_string;
+    uint16_t charge_state;
+    uint16_t device_type;
+    double   pv_input_power;
+} history_record_t;
+
+/* ── Query Parameters ──────────────────────────────────────────── */
+typedef struct {
+    char     start_time[32];      /* "YYYY-MM-DD HH:MM:SS" or empty */
+    char     end_time[32];        /* "YYYY-MM-DD HH:MM:SS" or empty */
+    int      page;                /* 1-based page number */
+    int      page_size;           /* Records per page */
+    char     sort_order[8];       /* "ASC" or "DESC" */
+} history_query_t;
+
+/* ── Query Result ──────────────────────────────────────────────── */
+typedef struct {
+    history_record_t *records;    /* Array of records */
+    int               count;      /* Number of records in this page */
+    int               total;      /* Total matching records */
+    int               page;       /* Current page */
+    int               page_size;  /* Page size */
+    int               total_pages;/* Total pages */
+} history_result_t;
+
+/* ── Database Handle ───────────────────────────────────────────── */
+typedef struct {
+    sqlite3 *db;
+    char     db_path[512];
+} db_handle_t;
+
+/* ── Public API ────────────────────────────────────────────────── */
+
+/**
+ * Open database and create tables if needed.
+ * @param handle  Database handle to initialize
+ * @param path    Path to SQLite database file
+ * @return 0 on success, -1 on error
+ */
+int db_open(db_handle_t *handle, const char *path);
+
+/**
+ * Close database.
+ */
+void db_close(db_handle_t *handle);
+
+/**
+ * Insert a MPPT data record into history table.
+ * @param handle  Database handle
+ * @param data    MPPT data to store
+ * @return 0 on success, -1 on error
+ */
+int db_insert_record(db_handle_t *handle, const mppt_data_t *data);
+
+/**
+ * Query history records with pagination.
+ * @param handle  Database handle
+ * @param query   Query parameters (time range, page, etc.)
+ * @param result  Output result (caller must call db_free_result)
+ * @return 0 on success, -1 on error
+ */
+int db_query_history(db_handle_t *handle, const history_query_t *query,
+                     history_result_t *result);
+
+/**
+ * Free result memory.
+ */
+void db_free_result(history_result_t *result);
+
+/**
+ * Get total record count.
+ */
+int db_get_record_count(db_handle_t *handle);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* DB_H */

+ 65 - 38
mppt_monitor/src/main.c

@@ -4,21 +4,17 @@
  *
  * Architecture:
  *   1. Modbus RTU over TCP client -> reads MPPT registers every 2s
- *   2. Mongoose HTTP server (port 8085) -> serves JSON API + HTML dashboard
- *   3. Auto-reconnect on TCP failure with 10s timeout
- *   4. Web server auto-restart on failure
+ *   2. Mongoose HTTP server -> serves JSON API + HTML dashboard
+ *   3. SQLite3 historical data storage (configurable interval)
+ *   4. Auto-reconnect on TCP failure with 10s timeout
+ *   5. Web server auto-restart on failure
  *
  * Compatible with: Ubuntu 24.04 / Raspberry Pi 5 8G
  *
  * Libraries:
- *   - mongoose-7.22 (HTTP server, new API: mg_match, struct mg_str.buf)
+ *   - mongoose-7.22 (HTTP server)
  *   - cJSON-1.7.19 (JSON serialization)
- *
- * Compile:
- *   make          (see Makefile for details)
- *
- * Run:
- *   ./mppt_monitor
+ *   - SQLite3 (historical data storage)
  */
 
 #include <stdio.h>
@@ -30,8 +26,10 @@
 #include <pthread.h>
 #include <errno.h>
 
+#include "config.h"
 #include "modbus_client.h"
 #include "web_server.h"
+#include "db.h"
 #include "mongoose.h"
 #include "cJSON.h"
 
@@ -40,6 +38,8 @@ static volatile sig_atomic_t g_running = 1;
 static mppt_data_t  g_mppt_data;
 static modbus_conn_t g_modbus_conn;
 static web_ctx_t    g_web_ctx;
+static db_handle_t  g_db;
+static app_config_t g_config;
 
 /* Mutex for thread-safe data access */
 static pthread_mutex_t g_data_mutex = PTHREAD_MUTEX_INITIALIZER;
@@ -54,32 +54,36 @@ static void signal_handler(int sig)
 
 /* ── Modbus Polling Thread ─────────────────────────────────────── */
 /*
- * Runs in a separate thread. Every 2 seconds:
+ * Runs in a separate thread. Every config.modbus_poll_ms:
  *   1. Check connection, reconnect if needed (10s timeout)
  *   2. Send Modbus read command
  *   3. Parse response and update shared data
- *   4. Print debug output
+ *   4. Periodically store to database (config.storage_interval_sec)
+ *   5. Print debug output
  */
 static void *modbus_poll_thread(void *arg)
 {
     (void)arg;
     mppt_data_t local_data;
     int ret;
+    time_t last_store_time = 0;
 
     memset(&local_data, 0, sizeof(local_data));
 
-    printf("[MODBUS] Poll thread started (interval: %dms)\n", MODBUS_POLL_MS);
+    printf("[MODBUS] Poll thread started (interval: %dms)\n",
+           g_config.modbus_poll_ms);
+    printf("[STORAGE] Data storage interval: %d seconds\n",
+           g_config.storage_interval_sec);
 
     while (g_running) {
         /* Ensure connection is alive */
         if (g_modbus_conn.state != MODBUS_STATE_CONNECTED) {
             ret = modbus_connect(&g_modbus_conn);
             if (ret != 0) {
-                /* Wait before retry */
-                struct timespec ts;
-                ts.tv_sec = 1;
-                ts.tv_nsec = 0;
-                nanosleep(&ts, NULL);
+                for (int i = 0; i < 10 && g_running; i++) {
+                    struct timespec short_ts = { .tv_sec = 0, .tv_nsec = 100000000L };
+                    nanosleep(&short_ts, NULL);
+                }
                 continue;
             }
         }
@@ -94,6 +98,22 @@ static void *modbus_poll_thread(void *arg)
 
             /* Print to console */
             modbus_print_data(&local_data);
+
+            /* Periodic storage to database */
+            time_t now = time(NULL);
+            if ((now - last_store_time) >= g_config.storage_interval_sec) {
+                pthread_mutex_lock(&g_data_mutex);
+                ret = db_insert_record(&g_db, &g_mppt_data);
+                pthread_mutex_unlock(&g_data_mutex);
+
+                if (ret == 0) {
+                    printf("[STORAGE] Data saved to DB at %s",
+                           ctime(&now));
+                    last_store_time = now;
+                } else {
+                    fprintf(stderr, "[STORAGE] Failed to save data to DB\n");
+                }
+            }
         } else {
             /* Read failed */
             local_data.consecutive_failures++;
@@ -115,9 +135,9 @@ static void *modbus_poll_thread(void *arg)
             }
         }
 
-        /* Wait for next poll interval (2 seconds) */
+        /* Wait for next poll interval */
         /* Sleep in small increments to allow quick shutdown */
-        int remaining_ms = MODBUS_POLL_MS;
+        int remaining_ms = g_config.modbus_poll_ms;
         while (remaining_ms > 0 && g_running) {
             int sleep_ms = (remaining_ms > 200) ? 200 : remaining_ms;
             struct timespec short_ts;
@@ -135,22 +155,15 @@ static void *modbus_poll_thread(void *arg)
 }
 
 /* ── Web Server Watchdog ───────────────────────────────────────── */
-/*
- * Monitors the web server connection. If it drops, restarts it.
- * This runs in the main thread alongside mg_mgr_poll().
- */
 static time_t g_web_last_check = 0;
 static struct mg_connection *g_web_conn = NULL;
 
 static void web_watchdog(struct mg_mgr *mgr)
 {
     time_t now = time(NULL);
-
-    /* Check every 5 seconds */
     if ((now - g_web_last_check) < 5) return;
     g_web_last_check = now;
 
-    /* Check if web server connection is still alive */
     if (g_web_conn == NULL) {
         fprintf(stderr, "[WEB] Server not running, restarting...\n");
         g_web_conn = web_server_start(mgr, &g_web_ctx);
@@ -175,24 +188,39 @@ int main(int argc, char *argv[])
     /* Banner */
     printf("============================================\n");
     printf("  MPPT Solar Charge Controller Monitor\n");
-    printf("  Version: 1.0.0\n");
-    printf("  Mongoose: 7.22 | cJSON: 1.7.19\n");
+    printf("  Version: 1.1.0\n");
+    printf("  Mongoose: 7.22 | cJSON: 1.7.19 | SQLite3\n");
     printf("============================================\n\n");
 
     /* Setup signal handlers */
     signal(SIGINT, signal_handler);
     signal(SIGTERM, signal_handler);
-    signal(SIGPIPE, SIG_IGN);  /* Ignore broken pipe */
+    signal(SIGPIPE, SIG_IGN);
+
+    /* Load configuration */
+    config_load(&g_config, "config.ini");
+    config_print(&g_config);
 
     /* Initialize MPPT data */
     memset(&g_mppt_data, 0, sizeof(g_mppt_data));
     g_mppt_data.data_valid = false;
 
+    /* Initialize database */
+    ret = db_open(&g_db, g_config.db_path);
+    if (ret != 0) {
+        fprintf(stderr, "[MAIN] FATAL: Cannot open database '%s'\n",
+                g_config.db_path);
+        return 1;
+    }
+    printf("[MAIN] Database record count: %d\n", db_get_record_count(&g_db));
+
     /* Initialize Modbus connection */
-    modbus_init(&g_modbus_conn, MODBUS_HOST, MODBUS_PORT);
+    modbus_init(&g_modbus_conn, g_config.modbus_host,
+                (uint16_t)g_config.modbus_port);
 
     /* Initialize web context */
-    web_server_init(&g_web_ctx, &g_mppt_data, &g_modbus_conn);
+    web_server_init(&g_web_ctx, &g_mppt_data, &g_modbus_conn,
+                    &g_db, &g_config);
 
     /* Initialize Mongoose event manager */
     mg_mgr_init(&mgr);
@@ -201,6 +229,7 @@ int main(int argc, char *argv[])
     g_web_conn = web_server_start(&mgr, &g_web_ctx);
     if (g_web_conn == NULL) {
         fprintf(stderr, "[MAIN] FATAL: Cannot start web server\n");
+        db_close(&g_db);
         return 1;
     }
 
@@ -210,18 +239,15 @@ int main(int argc, char *argv[])
         fprintf(stderr, "[MAIN] FATAL: Cannot create Modbus thread: %s\n",
                 strerror(ret));
         mg_mgr_free(&mgr);
+        db_close(&g_db);
         return 1;
     }
 
     printf("[MAIN] All systems running. Press Ctrl+C to stop.\n\n");
 
-    /*
-     * Main event loop:
-     * - mg_mgr_poll() processes HTTP requests (Mongoose)
-     * - Web watchdog checks server health periodically
-     */
+    /* Main event loop */
     while (g_running) {
-        mg_mgr_poll(&mgr, 100);  /* 100ms poll interval */
+        mg_mgr_poll(&mgr, 100);
         web_watchdog(&mgr);
     }
 
@@ -232,6 +258,7 @@ int main(int argc, char *argv[])
     printf("[MAIN] Freeing Mongoose resources...\n");
     mg_mgr_free(&mgr);
 
+    db_close(&g_db);
     pthread_mutex_destroy(&g_data_mutex);
 
     printf("[MAIN] Shutdown complete.\n");

+ 452 - 107
mppt_monitor/src/web_server.c

@@ -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\"}");

+ 7 - 16
mppt_monitor/src/web_server.h

@@ -3,13 +3,15 @@
  * @brief Mongoose HTTP server for MPPT data REST API
  *
  * Uses mongoose-7.22 API (mg_match, struct mg_str with char *buf).
- * Provides JSON REST endpoints for real-time MPPT data.
+ * Provides JSON REST endpoints for real-time and historical MPPT data.
  */
 
 #ifndef WEB_SERVER_H
 #define WEB_SERVER_H
 
 #include "modbus_client.h"
+#include "db.h"
+#include "config.h"
 #include "mongoose.h"
 
 #ifdef __cplusplus
@@ -21,40 +23,29 @@ extern "C" {
 #define WEB_SERVER_LISTEN_URL "http://0.0.0.0:8085"
 
 /* ── Shared Data Context ───────────────────────────────────────── */
-/*
- * This structure is passed as user data to the Mongoose event handler.
- * It holds a pointer to the latest MPPT data and connection state.
- * Protected by a simple volatile flag (single-writer, single-reader).
- */
 typedef struct {
     volatile mppt_data_t   *mppt_data;    /* Pointer to shared MPPT data */
     volatile modbus_conn_t *modbus_conn;  /* Pointer to Modbus connection */
+    db_handle_t            *db;           /* Pointer to database handle */
+    const app_config_t     *config;       /* Pointer to app config */
 } web_ctx_t;
 
 /* ── Public API ────────────────────────────────────────────────── */
 
 /**
  * Initialize the web server context.
- * @param ctx       Web context to initialize
- * @param mppt_data Pointer to shared MPPT data
- * @param conn      Pointer to Modbus connection state
  */
 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);
 
 /**
  * Start the Mongoose HTTP listener.
- * Must be called after web_server_init().
- * @param mgr  Mongoose event manager
- * @param ctx  Web context
- * @return Pointer to the listening connection, or NULL on error
  */
 struct mg_connection *web_server_start(struct mg_mgr *mgr, web_ctx_t *ctx);
 
 /**
  * Mongoose event handler callback.
- * Handles HTTP requests and routes to appropriate API handlers.
- * Uses mongoose-7.22 API: mg_match() instead of mg_http_match_uri().
  */
 void web_event_handler(struct mg_connection *c, int ev, void *ev_data);