Sfoglia il codice sorgente

feat: DM Series ModBus V2.1 完整协议支持 - 多设备全信息实时监控

Coze-Commit-Type: user
Coze-User-ID: 256649704250362
Coze-Conversation-ID: 11640025
steven_roc 2 mesi fa
parent
commit
2c0b8bb696

+ 36 - 0
mppt_monitor/CHANGELOG.md

@@ -4,6 +4,42 @@
 
 ---
 
+## [2.0.0] - 2026-08-05
+
+### 重大更新:DM Series ModBus Protocol V2.1 完整支持
+
+#### 新增功能
+- **完整寄存器读取**:支持全部 Input Registers (0x04, 30001-30011) 和 Output Registers (0x03, 40001-40009)
+- **多设备支持**:可同时轮询最多 16 个设备(地址 1-247),通过 `config.ini` 配置
+- **完整设备信息展示**:
+  - 电池类型(胶体/密封/磷酸铁锂/三元锂电/自定义/加水)
+  - 电池等级(自动识别/12V/24V/36V/48V/60V/72V)
+  - 充电模式(MPPT/DC-DC)
+  - 充电电流限制、过放电压、提升电压、充满电压、恢复电压
+  - 485 地址设置、软件版本号
+  - 完整故障代码描述(20=正常, 30=光伏板超压, 31=过流, 32=电池低压, 33=电池超压, 38=温度高, 50=传感器故障, 88=未解密)
+- **多设备仪表盘**:每个设备独立卡片展示,实时刷新
+- **设备列表 API**:`GET /api/devices` 返回所有设备摘要信息
+- **历史查询增强**:支持按设备地址过滤 (`device_addr` 参数)
+- **数据库扩展**:`history_data` 表包含全部 20+ 字段
+
+#### 架构变更
+- 新增 `config.h/config.c` - INI 配置文件解析模块
+- 新增 `db.h/db.c` - SQLite3 数据库模块
+- 重构 `modbus_client.h/c` - 完整 DM Series V2.1 协议支持
+- 重构 `web_server.h/c` - 多设备 API + 外部 HTML 仪表盘
+- 新增 `www/index.html` - 独立前端仪表盘(Chart.js 图表)
+- 更新 `Makefile` - 链接 SQLite3
+
+#### 协议细节
+| 功能码 | 方向 | 说明 |
+|--------|------|------|
+| 0x04 | Read | Input Registers (30001-30011) - 实时数据 |
+| 0x03 | Read | Output Registers (40001-40009) - 配置参数 |
+| 0x10 | Write | Write Multiple Registers - 写配置(预留) |
+
+---
+
 ## [1.1.0] - 2026-08-04
 
 ### 新增:历史数据存储与查询

+ 76 - 6
mppt_monitor/TECHNICAL.md

@@ -436,16 +436,86 @@ Modbus 读取成功 → 更新共享数据 → 检查存储间隔
 
 ---
 
-## 十、扩展方向
+## 十、DM Series ModBus Protocol V2.1 完整寄存器表
+
+### 10.1 Input Registers (功能码 0x04 - 只读实时数据)
+
+| PLC地址 | 寄存器地址 | 寄存器名 | 值范围 | 换算 | 说明 |
+|---------|-----------|---------|--------|------|------|
+| 30001 | 0x0000 | 电池电压 | 0-65535 | 1=0.1V | 实时电池电压 |
+| 30002 | 0x0001 | 光伏电压 | 0-65535 | 1=0.1V | 光伏板输入电压 |
+| 30003 | 0x0002 | 光伏充电电流 | 0-65535 | 1=0.1A | 当前充电电流 |
+| 30004 | 0x0003 | 累计发电量 | 0-65535 | 1=0.1KWH | 总发电量 |
+| 30005 | 0x0004 | 机器温度 | 0-255 | 1=1°C | 0xFF=故障 |
+| 30006 | 0x0005 | 故障代码 | 0-255 | 见下表 | 设备故障状态 |
+| 30007 | 0x0006 | 电池电量 | 0-100 | % | SOC |
+| 30008 | 0x0007 | 电池串数 | 0-8 | 保留 | |
+| 30009 | 0x0008 | 充电状态 | 0-1 | 0=未充电,1=充电 | |
+| 30010 | 0x0009 | 设备类型 | 0-255 | 设备型号代码 | |
+| 30011 | 0x000A | 软件版本 | 0-255 | 固件版本号 | |
+
+### 10.2 Output Registers (功能码 0x03 - 可读配置参数)
+
+| PLC地址 | 寄存器地址 | 寄存器名 | 值范围 | 换算 | 读写 | 说明 |
+|---------|-----------|---------|--------|------|------|------|
+| 40001 | 0x0000 | 电池类型 | 0-5 | 见下表 | R/W | |
+| 40002 | 0x0001 | 电池等级 | 0-6 | 见下表 | R/W | |
+| 40003 | 0x0002 | 光伏充电电流限制 | 5-100 | % | R/W | |
+| 40004 | 0x0003 | 电池过放电压 | 80-600 | 1=0.1V | 只读 | |
+| 40005 | 0x0004 | 电池提升电压 | 80-600 | 1=0.1V | R/W | |
+| 40006 | 0x0005 | 电池充满电压 | 80-600 | 1=0.1V | R/W | |
+| 40007 | 0x0006 | 过放恢复电压 | 80-600 | 1=0.1V | 只读 | |
+| 40008 | 0x0007 | 设备485地址 | 0-247 | | R/W | |
+| 40009 | 0x0008 | 充电运行模式 | 0-1 | 0=MPPT,1=DCDC | R/W | |
+
+### 10.3 电池类型对照表
+
+| 值 | 类型 | 提升电压 | 过充电压 | 恢复电压 | 过放电压 |
+|----|------|---------|---------|---------|---------|
+| 0 | 胶体电池 | 14.2V | 13.6V | 11.5V | 11.0V |
+| 1 | 密封电池 | 14.4V | 13.6V | 11.5V | 11.0V |
+| 2 | 磷酸铁锂 | 14.4V | 14.4V | 11.5V | 11.0V |
+| 3 | 三元锂电 | 12.6V | 12.6V | 9.5V | 9.0V |
+| 4 | 自定义 | - | - | - | - |
+| 5 | 加水电池 | 14.6V | 13.6V | 11.5V | 11.0V |
+
+### 10.4 故障代码对照表
+
+| 代码 | 故障描述 | 严重程度 |
+|------|---------|---------|
+| 20 | 正常 | - |
+| 30 | 光伏板超压 | 高 |
+| 31 | 光伏充电过流 | 高 |
+| 32 | 电池电压低 | 中 |
+| 33 | 电池超压 | 高 |
+| 38 | 设备温度高 | 中 |
+| 50 | 温度传感器故障 | 中 |
+| 88 | 未解密 | 低 |
+
+### 10.5 电池等级对照表
+
+| 值 | 电压等级 | 说明 |
+|----|---------|------|
+| 0 | 自动识别 | 自动检测电池电压 |
+| 1 | 12V | |
+| 2 | 24V | |
+| 3 | 36V | |
+| 4 | 48V | |
+| 5 | 60V | |
+| 6 | 72V | |
+
+---
+
+## 十一、扩展方向
 
 1. **告警通知**: 温度过高、电压异常时发送邮件/微信通知
 2. **HTTPS 支持**: 已预留 OpenSSL 编译选项
 3. **MQTT 上报**: 将数据发布到 MQTT Broker 供其他系统消费
-4. **多设备支持**: 支持同时监控多个 MPPT 控制器
-5. **Web 认证**: 添加 HTTP Basic Auth 或 Token 认证
-6. **数据导出**: 支持 CSV/Excel 格式导出历史数据
-7. **统计聚合**: 按小时/天/月聚合统计数据(平均、最大、最小)
+4. **Web 认证**: 添加 HTTP Basic Auth 或 Token 认证
+5. **数据导出**: 支持 CSV/Excel 格式导出历史数据
+6. **统计聚合**: 按小时/天/月聚合统计数据(平均、最大、最小)
+7. **写参数功能**: 通过 Web 界面远程修改设备配置(0x10 功能码)
 
 ---
 
-*最后更新: 2026-08-04 (v1.1.0)*
+*最后更新: 2026-08-05 (v2.0.0)*

BIN
mppt_monitor/build/mppt_monitor


+ 20 - 16
mppt_monitor/config.ini

@@ -1,29 +1,33 @@
 # =============================================================================
-# MPPT Solar Charge Controller Monitor - Configuration
-# =============================================================================
-# This file controls runtime behavior. Edit and restart the service to apply.
-# Lines starting with # or ; are comments.
+# MPPT Solar Monitor - Configuration File
+# DM Series ModBus Protocol V2.1
 # =============================================================================
 
 [modbus]
-# Modbus TCP device connection settings
+# Modbus TCP server address
 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
+
+# Device addresses to poll (comma-separated, max 16 devices)
+# Each device will be polled sequentially
+device_addrs = 1
+
+# Poll interval in seconds (time between complete poll cycles)
+poll_interval_sec = 2
+
+# Connection timeout in seconds
+connect_timeout_sec = 10
 
 [storage]
-# How often to save data to the database (seconds)
-# Default: 10 seconds. Minimum: 1 second.
+# Data storage interval in seconds (how often to save to database)
 interval_sec = 10
-# SQLite database file path (relative to working directory or absolute)
+
+# SQLite database file path
 db_path = mppt_data.db
 
 [web]
-# HTTP server port (default: 8085)
-# Note: In sandbox environment, DEPLOY_RUN_PORT env var overrides this.
+# HTTP web server port (overridden by DEPLOY_RUN_PORT env var if set)
 port = 8085
-# Default page size for history API pagination
-page_size = 50
+
+# Default page size for history queries
+page_size = 20

BIN
mppt_monitor/mppt_data.db


+ 85 - 79
mppt_monitor/src/config.c

@@ -1,91 +1,79 @@
 /**
  * @file config.c
- * @brief Simple INI configuration parser for MPPT Monitor
- *
- * Supports [section] and key=value pairs.
- * Lines starting with # or ; are comments.
+ * @brief Configuration Module Implementation
  */
 
 #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)
+/* ---- Default values ---- */
+void config_set_defaults(app_config_t *config)
 {
-    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;
+    memset(config, 0, sizeof(*config));
+
+    strncpy(config->modbus_host, "discover.zhonjin.com", sizeof(config->modbus_host) - 1);
+    config->modbus_port = 40635;
+    config->modbus_device_addrs[0] = 1;  /* Default: device address 1 */
+    config->modbus_device_count = 1;
+    config->modbus_poll_interval_sec = 2;
+    config->modbus_connect_timeout_sec = 10;
 
-    cfg->storage_interval_sec = 10;
-    strncpy(cfg->db_path, "mppt_data.db", sizeof(cfg->db_path) - 1);
+    config->storage_interval_sec = 10;
+    strncpy(config->db_path, "mppt_data.db", sizeof(config->db_path) - 1);
 
-    cfg->web_port = 8085;
-    cfg->web_page_size = 50;
+    config->web_port = 8085;
+    config->web_page_size = 20;
 }
 
-/* ── Helper: trim whitespace ───────────────────────────────────── */
+/* ---- Trim whitespace ---- */
 static char *trim(char *str)
 {
     while (isspace((unsigned char)*str)) str++;
-    if (*str == '\0') return str;
+    if (*str == 0) return str;
     char *end = str + strlen(str) - 1;
     while (end > str && isspace((unsigned char)*end)) end--;
-    *(end + 1) = '\0';
+    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)
+/* ---- Parse device addresses (comma-separated) ---- */
+static int parse_device_addrs(const char *value, uint8_t *addrs, int max_count)
 {
-    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;
+    int count = 0;
+    char buf[256];
+    strncpy(buf, value, sizeof(buf) - 1);
+    buf[sizeof(buf) - 1] = '\0';
+
+    char *token = strtok(buf, ",");
+    while (token && count < max_count) {
+        token = trim(token);
+        int addr = atoi(token);
+        if (addr >= 1 && addr <= 247) {
+            addrs[count++] = (uint8_t)addr;
         }
+        token = strtok(NULL, ",");
     }
+    return count;
 }
 
-/* ── Load INI File ─────────────────────────────────────────────── */
-int config_load(app_config_t *cfg, const char *filepath)
+/* ---- Load configuration ---- */
+int config_load(app_config_t *config, const char *filepath)
 {
-    config_set_defaults(cfg);
+    config_set_defaults(config);
 
     FILE *fp = fopen(filepath, "r");
     if (!fp) {
-        fprintf(stderr, "[CONFIG] Config file '%s' not found, using defaults\n", filepath);
-        return -1;
+        printf("[CONFIG] Config file '%s' not found, using defaults\n", filepath);
+        return 0;
     }
 
+    printf("[CONFIG] Loading config from: %s\n", filepath);
+
     char line[1024];
-    char section[256] = "";
+    char section[64] = "";
 
     while (fgets(line, sizeof(line), fp)) {
         char *trimmed = trim(line);
@@ -101,41 +89,59 @@ int config_load(app_config_t *cfg, const char *filepath)
             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);
+        if (!eq) continue;
+
+        *eq = '\0';
+        char *key = trim(trimmed);
+        char *value = trim(eq + 1);
+
+        /* Parse by section */
+        if (strcmp(section, "modbus") == 0) {
+            if (strcmp(key, "host") == 0) {
+                strncpy(config->modbus_host, value, sizeof(config->modbus_host) - 1);
+            } else if (strcmp(key, "port") == 0) {
+                config->modbus_port = (uint16_t)atoi(value);
+            } else if (strcmp(key, "device_addrs") == 0) {
+                config->modbus_device_count = parse_device_addrs(
+                    value, config->modbus_device_addrs, CONFIG_MAX_DEVICES);
+            } else if (strcmp(key, "poll_interval_sec") == 0) {
+                config->modbus_poll_interval_sec = atoi(value);
+            } else if (strcmp(key, "connect_timeout_sec") == 0) {
+                config->modbus_connect_timeout_sec = atoi(value);
+            }
+        } else if (strcmp(section, "storage") == 0) {
+            if (strcmp(key, "interval_sec") == 0) {
+                config->storage_interval_sec = atoi(value);
+            } else if (strcmp(key, "db_path") == 0) {
+                strncpy(config->db_path, value, sizeof(config->db_path) - 1);
+            }
+        } else if (strcmp(section, "web") == 0) {
+            if (strcmp(key, "port") == 0) {
+                config->web_port = (uint16_t)atoi(value);
+            } else if (strcmp(key, "page_size") == 0) {
+                config->web_page_size = atoi(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");
+    /* Ensure at least one device */
+    if (config->modbus_device_count == 0) {
+        config->modbus_device_addrs[0] = 1;
+        config->modbus_device_count = 1;
+    }
+
+    printf("[CONFIG] Loaded: Modbus=%s:%d Devices=%d Storage=%ds Web=:%d\n",
+           config->modbus_host, config->modbus_port,
+           config->modbus_device_count,
+           config->storage_interval_sec, config->web_port);
+
+    return 0;
 }

+ 24 - 39
mppt_monitor/src/config.h

@@ -1,60 +1,45 @@
 /**
  * @file config.h
- * @brief INI configuration file parser for MPPT Monitor
- *
- * Reads config.ini for runtime settings (storage interval, DB path, etc.)
+ * @brief Configuration Module - INI file parser for MPPT Monitor
  */
 
 #ifndef CONFIG_H
 #define CONFIG_H
 
 #include <stdint.h>
+#include <stdbool.h>
 
-#ifdef __cplusplus
-extern "C" {
-#endif
+#define CONFIG_MAX_PATH     512
+#define CONFIG_MAX_DEVICES  16
 
-/* ── 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 */
+    /* Modbus connection */
+    char     modbus_host[256];
+    uint16_t modbus_port;
+    uint8_t  modbus_device_addrs[CONFIG_MAX_DEVICES];
+    int      modbus_device_count;
+    int      modbus_poll_interval_sec;
+    int      modbus_connect_timeout_sec;
+
+    /* Data storage */
+    int      storage_interval_sec;
+    char     db_path[CONFIG_MAX_PATH];
+
+    /* Web server */
+    uint16_t web_port;
+    int      web_page_size;
 } 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)
+ * If file doesn't exist, uses defaults.
+ * @return 0 on success, -1 on error
  */
-int config_load(app_config_t *cfg, const char *filepath);
+int config_load(app_config_t *config, const char *filepath);
 
 /**
- * Print current configuration to stdout.
+ * Set default configuration values.
  */
-void config_print(const app_config_t *cfg);
-
-/**
- * Get default configuration values.
- */
-void config_set_defaults(app_config_t *cfg);
-
-#ifdef __cplusplus
-}
-#endif
+void config_set_defaults(app_config_t *config);
 
 #endif /* CONFIG_H */

+ 262 - 144
mppt_monitor/src/db.c

@@ -1,252 +1,370 @@
 /**
  * @file db.c
- * @brief SQLite3 database implementation for MPPT historical data
+ * @brief SQLite3 Database Module Implementation
  */
 
 #include "db.h"
-
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <time.h>
 
-/* ── Create Tables ─────────────────────────────────────────────── */
-static int db_create_tables(db_handle_t *handle)
+/* ---- Create tables ---- */
+static int create_tables(db_handle_t *handle)
 {
     const char *sql =
-        "CREATE TABLE IF NOT EXISTS mppt_history ("
+        "CREATE TABLE IF NOT EXISTS history_data ("
         "  id INTEGER PRIMARY KEY AUTOINCREMENT,"
-        "  timestamp INTEGER NOT NULL,"
+        "  timestamp TEXT NOT NULL DEFAULT (datetime('now','localtime')),"
+        "  timestamp_unix INTEGER NOT NULL,"
+        "  device_addr INTEGER NOT NULL DEFAULT 1,"
         "  battery_voltage REAL,"
         "  pv_voltage REAL,"
         "  pv_charge_current REAL,"
+        "  pv_input_power REAL,"
         "  cumulative_energy REAL,"
-        "  machine_temp REAL,"
+        "  machine_temp INTEGER,"
         "  fault_code INTEGER,"
         "  battery_soc INTEGER,"
-        "  battery_string INTEGER,"
+        "  battery_string_count INTEGER,"
         "  charge_state INTEGER,"
         "  device_type INTEGER,"
-        "  pv_input_power REAL"
+        "  software_version INTEGER,"
+        "  battery_type INTEGER,"
+        "  battery_level INTEGER,"
+        "  pv_charge_current_limit INTEGER,"
+        "  batt_over_discharge_v INTEGER,"
+        "  batt_boost_voltage INTEGER,"
+        "  batt_full_voltage INTEGER,"
+        "  batt_recovery_voltage INTEGER,"
+        "  device_485_addr INTEGER,"
+        "  charge_mode INTEGER"
         ");"
-        "CREATE INDEX IF NOT EXISTS idx_timestamp ON mppt_history(timestamp);";
-
-    char *errmsg = NULL;
-    int rc = sqlite3_exec(handle->db, sql, NULL, NULL, &errmsg);
+        "CREATE INDEX IF NOT EXISTS idx_history_timestamp "
+        "  ON history_data(timestamp_unix);"
+        "CREATE INDEX IF NOT EXISTS idx_history_device "
+        "  ON history_data(device_addr);"
+        "CREATE INDEX IF NOT EXISTS idx_history_device_time "
+        "  ON history_data(device_addr, timestamp_unix);";
+
+    char *err_msg = NULL;
+    int rc = sqlite3_exec(handle->db, sql, NULL, NULL, &err_msg);
     if (rc != SQLITE_OK) {
-        fprintf(stderr, "[DB] Failed to create tables: %s\n", errmsg);
-        sqlite3_free(errmsg);
+        fprintf(stderr, "[DB] Failed to create tables: %s\n", err_msg);
+        sqlite3_free(err_msg);
         return -1;
     }
+    printf("[DB] Database tables ready: %s\n", handle->db_path);
     return 0;
 }
 
-/* ── Open Database ─────────────────────────────────────────────── */
-int db_open(db_handle_t *handle, const char *path)
+/* ---- Open database ---- */
+int db_open(db_handle_t *handle, const char *db_path)
 {
     memset(handle, 0, sizeof(*handle));
-    strncpy(handle->db_path, path, sizeof(handle->db_path) - 1);
+    strncpy(handle->db_path, db_path, sizeof(handle->db_path) - 1);
 
-    int rc = sqlite3_open(path, &handle->db);
+    int rc = sqlite3_open(db_path, &handle->db);
     if (rc != SQLITE_OK) {
-        fprintf(stderr, "[DB] Cannot open database '%s': %s\n",
-                path, sqlite3_errmsg(handle->db));
+        fprintf(stderr, "[DB] Cannot open database: %s\n", sqlite3_errmsg(handle->db));
         return -1;
     }
 
-    /* Enable WAL mode for better concurrent read/write performance */
+    /* Enable WAL mode for better concurrent access */
     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;
-    }
+    sqlite3_exec(handle->db, "PRAGMA synchronous=NORMAL;", NULL, NULL, NULL);
 
-    printf("[DB] Database opened: %s\n", path);
-    return 0;
+    printf("[DB] Database opened: %s\n", db_path);
+    return create_tables(handle);
 }
 
-/* ── Close Database ────────────────────────────────────────────── */
+/* ---- 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)
+/* ---- Insert history record ---- */
+int db_insert_history(db_handle_t *handle, uint8_t device_addr,
+                      const mppt_device_data_t *dev)
 {
     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;
+        "INSERT INTO history_data ("
+        "  timestamp_unix, device_addr,"
+        "  battery_voltage, pv_voltage, pv_charge_current, pv_input_power,"
+        "  cumulative_energy, machine_temp, fault_code, battery_soc,"
+        "  battery_string_count, charge_state, device_type, software_version,"
+        "  battery_type, battery_level, pv_charge_current_limit,"
+        "  batt_over_discharge_v, batt_boost_voltage, batt_full_voltage,"
+        "  batt_recovery_voltage, device_485_addr, charge_mode"
+        ") VALUES ("
+        "  ?, ?,"
+        "  ?, ?, ?, ?,"
+        "  ?, ?, ?, ?,"
+        "  ?, ?, ?, ?,"
+        "  ?, ?, ?,"
+        "  ?, ?, ?, ?, ?, ?"
+        ")";
+
+    sqlite3_stmt *stmt;
     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));
+        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);
+    time_t now = time(NULL);
+    int col = 1;
+    sqlite3_bind_int64(stmt, col++, (int64_t)now);
+    sqlite3_bind_int(stmt, col++, device_addr);
+    sqlite3_bind_double(stmt, col++, dev->battery_voltage);
+    sqlite3_bind_double(stmt, col++, dev->pv_voltage);
+    sqlite3_bind_double(stmt, col++, dev->pv_charge_current);
+    sqlite3_bind_double(stmt, col++, dev->pv_input_power);
+    sqlite3_bind_double(stmt, col++, dev->cumulative_energy);
+    sqlite3_bind_int(stmt, col++, dev->machine_temp);
+    sqlite3_bind_int(stmt, col++, dev->fault_code_raw);
+    sqlite3_bind_int(stmt, col++, dev->battery_soc);
+    sqlite3_bind_int(stmt, col++, dev->battery_string_count);
+    sqlite3_bind_int(stmt, col++, dev->charge_state_raw);
+    sqlite3_bind_int(stmt, col++, dev->device_type_raw);
+    sqlite3_bind_int(stmt, col++, dev->software_version_raw);
+    sqlite3_bind_int(stmt, col++, dev->battery_type_raw);
+    sqlite3_bind_int(stmt, col++, dev->battery_level_raw);
+    sqlite3_bind_int(stmt, col++, dev->pv_charge_current_limit);
+    sqlite3_bind_int(stmt, col++, dev->batt_over_discharge_v);
+    sqlite3_bind_int(stmt, col++, dev->batt_boost_voltage);
+    sqlite3_bind_int(stmt, col++, dev->batt_full_voltage);
+    sqlite3_bind_int(stmt, col++, dev->batt_recovery_voltage);
+    sqlite3_bind_int(stmt, col++, dev->device_485_addr);
+    sqlite3_bind_int(stmt, col++, dev->charge_mode_raw);
 
     rc = sqlite3_step(stmt);
     sqlite3_finalize(stmt);
 
     if (rc != SQLITE_DONE) {
-        fprintf(stderr, "[DB] Insert failed: %s\n",
-                sqlite3_errmsg(handle->db));
+        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)
+/* ---- Query history records ---- */
+int db_query_history(db_handle_t *handle,
+                     uint8_t device_addr,
+                     const char *start_time,
+                     const char *end_time,
+                     int page,
+                     int page_size,
+                     bool sort_asc,
+                     db_query_result_t *result)
 {
     memset(result, 0, sizeof(*result));
-    result->page = query->page;
-    result->page_size = query->page_size;
+    result->page = page;
+    result->page_size = 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);
+    char where[512] = "1=1";
+    if (device_addr > 0) {
+        char buf[64];
+        snprintf(buf, sizeof(buf), " AND device_addr = %d", device_addr);
+        strncat(where, buf, sizeof(where) - strlen(where) - 1);
+    }
+    if (start_time && start_time[0] != '\0') {
+        char buf[128];
+        snprintf(buf, sizeof(buf), " AND timestamp >= '%s'", start_time);
+        strncat(where, buf, sizeof(where) - strlen(where) - 1);
+    }
+    if (end_time && end_time[0] != '\0') {
+        char buf[128];
+        snprintf(buf, sizeof(buf), " AND timestamp <= '%s'", end_time);
+        strncat(where, buf, sizeof(where) - strlen(where) - 1);
     }
 
-    /* Count total records */
+    /* Count total */
     char count_sql[1024];
     snprintf(count_sql, sizeof(count_sql),
-             "SELECT COUNT(*) FROM mppt_history %s", where_clause);
+             "SELECT COUNT(*) FROM history_data WHERE %s", where);
 
-    sqlite3_stmt *stmt = NULL;
+    sqlite3_stmt *stmt;
     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 (rc != SQLITE_OK) 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;
+    if (result->total == 0) {
+        result->records = NULL;
+        result->count = 0;
+        result->total_pages = 0;
+        return 0;
+    }
 
-    /* Clamp page number */
-    int page = query->page;
-    if (page < 1) page = 1;
+    result->total_pages = (result->total + page_size - 1) / page_size;
     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));
+    int offset = (page - 1) * page_size;
+
+    /* Query records */
+    char query_sql[2048];
+    snprintf(query_sql, sizeof(query_sql),
+             "SELECT id, timestamp, timestamp_unix, device_addr,"
+             "  battery_voltage, pv_voltage, pv_charge_current, pv_input_power,"
+             "  cumulative_energy, machine_temp, fault_code, battery_soc,"
+             "  battery_string_count, charge_state, device_type, software_version,"
+             "  battery_type, battery_level, pv_charge_current_limit,"
+             "  batt_over_discharge_v, batt_boost_voltage, batt_full_voltage,"
+             "  batt_recovery_voltage, device_485_addr, charge_mode"
+             " FROM history_data WHERE %s"
+             " ORDER BY timestamp_unix %s"
+             " LIMIT %d OFFSET %d",
+             where,
+             sort_asc ? "ASC" : "DESC",
+             page_size, offset);
+
+    rc = sqlite3_prepare_v2(handle->db, query_sql, -1, &stmt, NULL);
+    if (rc != SQLITE_OK) return -1;
+
+    result->records = (db_history_record_t *)calloc(page_size, sizeof(db_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++;
+    int idx = 0;
+    while (sqlite3_step(stmt) == SQLITE_ROW && idx < page_size) {
+        db_history_record_t *r = &result->records[idx];
+        r->id = sqlite3_column_int64(stmt, 0);
+        strncpy(r->timestamp, (const char *)sqlite3_column_text(stmt, 1),
+                sizeof(r->timestamp) - 1);
+        r->timestamp_unix = sqlite3_column_int64(stmt, 2);
+        r->device_addr = (uint8_t)sqlite3_column_int(stmt, 3);
+        r->battery_voltage = sqlite3_column_double(stmt, 4);
+        r->pv_voltage = sqlite3_column_double(stmt, 5);
+        r->pv_charge_current = sqlite3_column_double(stmt, 6);
+        r->pv_input_power = sqlite3_column_double(stmt, 7);
+        r->cumulative_energy = sqlite3_column_double(stmt, 8);
+        r->machine_temp = sqlite3_column_int(stmt, 9);
+        r->fault_code = (uint16_t)sqlite3_column_int(stmt, 10);
+        r->battery_soc = (uint16_t)sqlite3_column_int(stmt, 11);
+        r->battery_string_count = (uint16_t)sqlite3_column_int(stmt, 12);
+        r->charge_state = (uint16_t)sqlite3_column_int(stmt, 13);
+        r->device_type = (uint16_t)sqlite3_column_int(stmt, 14);
+        r->software_version = (uint16_t)sqlite3_column_int(stmt, 15);
+        r->battery_type = (uint16_t)sqlite3_column_int(stmt, 16);
+        r->battery_level = (uint16_t)sqlite3_column_int(stmt, 17);
+        r->pv_charge_current_limit = (uint16_t)sqlite3_column_int(stmt, 18);
+        r->batt_over_discharge_v = (uint16_t)sqlite3_column_int(stmt, 19);
+        r->batt_boost_voltage = (uint16_t)sqlite3_column_int(stmt, 20);
+        r->batt_full_voltage = (uint16_t)sqlite3_column_int(stmt, 21);
+        r->batt_recovery_voltage = (uint16_t)sqlite3_column_int(stmt, 22);
+        r->device_485_addr = (uint16_t)sqlite3_column_int(stmt, 23);
+        r->charge_mode = (uint16_t)sqlite3_column_int(stmt, 24);
+        idx++;
     }
-    result->count = count;
+    result->count = idx;
 
     sqlite3_finalize(stmt);
     return 0;
 }
 
-/* ── Free Result ───────────────────────────────────────────────── */
-void db_free_result(history_result_t *result)
+/* ---- Free query result ---- */
+void db_free_query_result(db_query_result_t *result)
 {
     if (result->records) {
         free(result->records);
         result->records = NULL;
     }
-    result->count = 0;
 }
 
-/* ── Get Total Record Count ────────────────────────────────────── */
+/* ---- Get statistics ---- */
+int db_get_stats(db_handle_t *handle, db_stats_t *stats)
+{
+    memset(stats, 0, sizeof(*stats));
+
+    /* Total records */
+    sqlite3_stmt *stmt;
+    int rc = sqlite3_prepare_v2(handle->db,
+                                "SELECT COUNT(*) FROM history_data",
+                                -1, &stmt, NULL);
+    if (rc != SQLITE_OK) return -1;
+    if (sqlite3_step(stmt) == SQLITE_ROW) {
+        stats->total_records = sqlite3_column_int64(stmt, 0);
+    }
+    sqlite3_finalize(stmt);
+
+    /* First record time */
+    rc = sqlite3_prepare_v2(handle->db,
+                            "SELECT timestamp FROM history_data ORDER BY timestamp_unix ASC LIMIT 1",
+                            -1, &stmt, NULL);
+    if (rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) {
+        const char *t = (const char *)sqlite3_column_text(stmt, 0);
+        if (t) strncpy(stats->first_record_time, t, sizeof(stats->first_record_time) - 1);
+    }
+    sqlite3_finalize(stmt);
+
+    /* Last record time */
+    rc = sqlite3_prepare_v2(handle->db,
+                            "SELECT timestamp FROM history_data ORDER BY timestamp_unix DESC LIMIT 1",
+                            -1, &stmt, NULL);
+    if (rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) {
+        const char *t = (const char *)sqlite3_column_text(stmt, 0);
+        if (t) strncpy(stats->last_record_time, t, sizeof(stats->last_record_time) - 1);
+    }
+    sqlite3_finalize(stmt);
+
+    /* Device count */
+    rc = sqlite3_prepare_v2(handle->db,
+                            "SELECT COUNT(DISTINCT device_addr) FROM history_data",
+                            -1, &stmt, NULL);
+    if (rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) {
+        stats->device_count = sqlite3_column_int(stmt, 0);
+    }
+    sqlite3_finalize(stmt);
+
+    /* Max PV power */
+    rc = sqlite3_prepare_v2(handle->db,
+                            "SELECT MAX(pv_input_power) FROM history_data",
+                            -1, &stmt, NULL);
+    if (rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) {
+        stats->max_pv_power = sqlite3_column_double(stmt, 0);
+    }
+    sqlite3_finalize(stmt);
+
+    /* Max battery voltage */
+    rc = sqlite3_prepare_v2(handle->db,
+                            "SELECT MAX(battery_voltage) FROM history_data",
+                            -1, &stmt, NULL);
+    if (rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) {
+        stats->max_battery_voltage = sqlite3_column_double(stmt, 0);
+    }
+    sqlite3_finalize(stmt);
+
+    /* Max temperature */
+    rc = sqlite3_prepare_v2(handle->db,
+                            "SELECT MAX(machine_temp) FROM history_data",
+                            -1, &stmt, NULL);
+    if (rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) {
+        stats->max_temperature = sqlite3_column_int(stmt, 0);
+    }
+    sqlite3_finalize(stmt);
+
+    return 0;
+}
+
+/* ---- Get record count ---- */
 int db_get_record_count(db_handle_t *handle)
 {
-    sqlite3_stmt *stmt = NULL;
+    sqlite3_stmt *stmt;
     int rc = sqlite3_prepare_v2(handle->db,
-                                "SELECT COUNT(*) FROM mppt_history",
+                                "SELECT COUNT(*) FROM history_data",
                                 -1, &stmt, NULL);
     if (rc != SQLITE_OK) return 0;
 

+ 80 - 53
mppt_monitor/src/db.h

@@ -1,6 +1,8 @@
 /**
  * @file db.h
- * @brief SQLite3 database module for MPPT historical data storage
+ * @brief SQLite3 Database Module for MPPT Monitor
+ *
+ * Stores historical data for ALL device registers (Input + Output).
  */
 
 #ifndef DB_H
@@ -8,64 +10,77 @@
 
 #include <sqlite3.h>
 #include <stdint.h>
+#include <stdbool.h>
 #include <time.h>
 #include "modbus_client.h"
 
-#ifdef __cplusplus
-extern "C" {
-#endif
+/* ---- Database Handle ---- */
+typedef struct {
+    sqlite3 *db;
+    char db_path[512];
+} db_handle_t;
 
-/* ── History Record ────────────────────────────────────────────── */
+/* ---- History Record (complete device data) ---- */
 typedef struct {
     int64_t  id;
-    int64_t  timestamp;           /* Unix timestamp */
+    char     timestamp[32];         /* ISO format: YYYY-MM-DD HH:MM:SS */
+    int64_t  timestamp_unix;
+    uint8_t  device_addr;
+
+    /* Input Registers */
     double   battery_voltage;
     double   pv_voltage;
     double   pv_charge_current;
+    double   pv_input_power;
     double   cumulative_energy;
-    double   machine_temp;
+    int      machine_temp;
     uint16_t fault_code;
     uint16_t battery_soc;
-    uint16_t battery_string;
+    uint16_t battery_string_count;
     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 ──────────────────────────────────────────────── */
+    uint16_t software_version;
+
+    /* Output Registers */
+    uint16_t battery_type;
+    uint16_t battery_level;
+    uint16_t pv_charge_current_limit;
+    uint16_t batt_over_discharge_v;
+    uint16_t batt_boost_voltage;
+    uint16_t batt_full_voltage;
+    uint16_t batt_recovery_voltage;
+    uint16_t device_485_addr;
+    uint16_t charge_mode;
+} db_history_record_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 ───────────────────────────────────────────── */
+    db_history_record_t *records;
+    int      count;
+    int      total;
+    int      page;
+    int      page_size;
+    int      total_pages;
+} db_query_result_t;
+
+/* ---- Statistics ---- */
 typedef struct {
-    sqlite3 *db;
-    char     db_path[512];
-} db_handle_t;
+    int64_t total_records;
+    char    first_record_time[32];
+    char    last_record_time[32];
+    int     device_count;
+    double  max_pv_power;
+    double  max_battery_voltage;
+    int     max_temperature;
+} db_stats_t;
 
-/* ── Public API ────────────────────────────────────────────────── */
+/* ---- API Functions ---- */
 
 /**
  * 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);
+int db_open(db_handle_t *handle, const char *db_path);
 
 /**
  * Close database.
@@ -73,35 +88,47 @@ int db_open(db_handle_t *handle, const char *path);
 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
+ * Insert a history record from device data.
  * @return 0 on success, -1 on error
  */
-int db_insert_record(db_handle_t *handle, const mppt_data_t *data);
+int db_insert_history(db_handle_t *handle, uint8_t device_addr,
+                      const mppt_device_data_t *dev);
 
 /**
- * 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)
+ * Query history records with pagination and filters.
+ * @param handle     Database handle
+ * @param device_addr Filter by device address (0 = all devices)
+ * @param start_time Start time filter (NULL = no filter)
+ * @param end_time   End time filter (NULL = no filter)
+ * @param page       Page number (1-based)
+ * @param page_size  Records per page
+ * @param sort_asc   true = oldest first, false = newest first
+ * @param result     Output query result (caller must free with db_free_query_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);
+int db_query_history(db_handle_t *handle,
+                     uint8_t device_addr,
+                     const char *start_time,
+                     const char *end_time,
+                     int page,
+                     int page_size,
+                     bool sort_asc,
+                     db_query_result_t *result);
 
 /**
- * Free result memory.
+ * Free query result memory.
  */
-void db_free_result(history_result_t *result);
+void db_free_query_result(db_query_result_t *result);
+
+/**
+ * Get database statistics.
+ * @return 0 on success, -1 on error
+ */
+int db_get_stats(db_handle_t *handle, db_stats_t *stats);
 
 /**
  * Get total record count.
  */
 int db_get_record_count(db_handle_t *handle);
 
-#ifdef __cplusplus
-}
-#endif
-
 #endif /* DB_H */

+ 131 - 189
mppt_monitor/src/main.c

@@ -1,20 +1,13 @@
 /**
  * @file main.c
- * @brief MPPT Solar Charge Controller Monitor - Main Entry Point
+ * @brief MPPT Solar Charge Controller Monitor - Main Entry
  *
- * Architecture:
- *   1. Modbus RTU over TCP client -> reads MPPT registers every 2s
- *   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)
- *   - cJSON-1.7.19 (JSON serialization)
- *   - SQLite3 (historical data storage)
+ * DM Series ModBus Protocol V2.1
+ * - Reads ALL Input Registers (0x04) and Output Registers (0x03)
+ * - Supports multiple devices on the same TCP bus
+ * - SQLite3 historical data storage
+ * - Mongoose 7.22 HTTP Web Server
+ * - config.ini configurable
  */
 
 #include <stdio.h>
@@ -24,242 +17,191 @@
 #include <unistd.h>
 #include <time.h>
 #include <pthread.h>
-#include <errno.h>
+#include <sys/stat.h>
 
-#include "config.h"
 #include "modbus_client.h"
 #include "web_server.h"
+#include "config.h"
 #include "db.h"
-#include "mongoose.h"
-#include "cJSON.h"
-
-/* ── Global State ──────────────────────────────────────────────── */
-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;
+/* ---- Globals ---- */
+static volatile int g_keep_running = 1;
+static modbus_client_t g_modbus;
+static db_handle_t     g_db;
+static app_config_t    g_config;
+static pthread_t       g_storage_thread;
 
-/* ── Signal Handler ────────────────────────────────────────────── */
+/* ---- Signal handler ---- */
 static void signal_handler(int sig)
 {
     (void)sig;
-    g_running = 0;
+    g_keep_running = 0;
     printf("\n[MAIN] Shutting down...\n");
 }
 
-/* ── Modbus Polling Thread ─────────────────────────────────────── */
-/*
- * 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. Periodically store to database (config.storage_interval_sec)
- *   5. Print debug output
- */
-static void *modbus_poll_thread(void *arg)
+/* ---- Storage thread: periodically save data to DB ---- */
+static void *storage_thread_func(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",
-           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) {
-                for (int i = 0; i < 10 && g_running; i++) {
-                    struct timespec short_ts = { .tv_sec = 0, .tv_nsec = 100000000L };
-                    nanosleep(&short_ts, NULL);
-                }
-                continue;
-            }
+    while (g_keep_running) {
+        /* Wait for interval */
+        for (int s = 0; s < g_config.storage_interval_sec && g_keep_running; s++) {
+            sleep(1);
         }
-
-        /* Read registers */
-        ret = modbus_read_registers(&g_modbus_conn, &local_data);
-        if (ret == 0) {
-            /* Success - update shared data under lock */
-            pthread_mutex_lock(&g_data_mutex);
-            memcpy((mppt_data_t *)&g_mppt_data, &local_data, sizeof(mppt_data_t));
-            pthread_mutex_unlock(&g_data_mutex);
-
-            /* 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;
+        if (!g_keep_running) break;
+
+        /* Get all device data */
+        mppt_device_data_t devices[MODBUS_MAX_DEVICES];
+        int count = 0;
+        modbus_get_all_data(&g_modbus, devices, &count);
+
+        /* Store each online device */
+        int stored = 0;
+        for (int i = 0; i < count; i++) {
+            if (devices[i].online) {
+                int rc = db_insert_history(&g_db, devices[i].device_addr, &devices[i]);
+                if (rc == 0) {
+                    stored++;
                 } else {
-                    fprintf(stderr, "[STORAGE] Failed to save data to DB\n");
+                    fprintf(stderr, "[STORAGE] Failed to save device %d\n",
+                            devices[i].device_addr);
                 }
             }
-        } else {
-            /* Read failed */
-            local_data.consecutive_failures++;
-            local_data.data_valid = false;
-
-            pthread_mutex_lock(&g_data_mutex);
-            g_mppt_data.consecutive_failures = local_data.consecutive_failures;
-            g_mppt_data.data_valid = false;
-            pthread_mutex_unlock(&g_data_mutex);
-
-            fprintf(stderr, "[MODBUS] Read failed (consecutive: %d)\n",
-                    local_data.consecutive_failures);
-
-            /* If too many failures, disconnect to force reconnect */
-            if (local_data.consecutive_failures >= MODBUS_MAX_RETRIES) {
-                fprintf(stderr, "[MODBUS] Too many failures, reconnecting...\n");
-                modbus_disconnect(&g_modbus_conn);
-                local_data.consecutive_failures = 0;
-            }
         }
 
-        /* Wait for next poll interval */
-        /* Sleep in small increments to allow quick shutdown */
-        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;
-            short_ts.tv_sec = sleep_ms / 1000;
-            short_ts.tv_nsec = (sleep_ms % 1000) * 1000000L;
-            nanosleep(&short_ts, NULL);
-            remaining_ms -= sleep_ms;
+        if (stored > 0) {
+            time_t now = time(NULL);
+            printf("[STORAGE] Saved %d device(s) to DB at %s",
+                   stored, ctime(&now));
         }
     }
-
-    /* Cleanup */
-    modbus_disconnect(&g_modbus_conn);
-    printf("[MODBUS] Poll thread exiting\n");
     return NULL;
 }
 
-/* ── Web Server Watchdog ───────────────────────────────────────── */
-static time_t g_web_last_check = 0;
-static struct mg_connection *g_web_conn = NULL;
-
-static void web_watchdog(struct mg_mgr *mgr)
+/* ---- Print device summary ---- */
+static void print_device_summary(void)
 {
-    time_t now = time(NULL);
-    if ((now - g_web_last_check) < 5) return;
-    g_web_last_check = now;
-
-    if (g_web_conn == NULL) {
-        fprintf(stderr, "[WEB] Server not running, restarting...\n");
-        g_web_conn = web_server_start(mgr, &g_web_ctx);
-        if (g_web_conn) {
-            printf("[WEB] Server restarted successfully\n");
-        } else {
-            fprintf(stderr, "[WEB] Failed to restart server\n");
-        }
+    mppt_device_data_t devices[MODBUS_MAX_DEVICES];
+    int count = 0;
+    modbus_get_all_data(&g_modbus, devices, &count);
+
+    printf("\n=== MPPT Device Summary ===\n");
+    for (int i = 0; i < count; i++) {
+        mppt_device_data_t *d = &devices[i];
+        printf("Device #%d [%s] Batt:%.1fV(%d%%) PV:%.1fV/%.1fA/%.1fW "
+               "Temp:%dC Fault:%s Type:%s Mode:%s SW:v%d\n",
+               d->device_addr,
+               d->online ? "ONLINE" : "OFFLINE",
+               d->battery_voltage, d->battery_soc,
+               d->pv_voltage, d->pv_charge_current, d->pv_input_power,
+               d->machine_temp,
+               modbus_fault_desc(d->fault_code_raw),
+               modbus_battery_type_str(d->battery_type_raw),
+               modbus_charge_mode_str(d->charge_mode_raw),
+               d->software_version_raw);
     }
+    printf("===========================\n\n");
 }
 
-/* ── Main ──────────────────────────────────────────────────────── */
 int main(int argc, char *argv[])
 {
-    struct mg_mgr mgr;
-    pthread_t modbus_tid;
-    int ret;
-
     (void)argc;
     (void)argv;
 
-    /* Banner */
-    printf("============================================\n");
-    printf("  MPPT Solar Charge Controller Monitor\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);
+    printf("========================================\n");
+    printf("  MPPT Solar Monitor - DM Series V2.1\n");
+    printf("  Multi-Device + Full Register Support\n");
+    printf("========================================\n\n");
 
     /* 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;
+    /* Setup signal handlers */
+    signal(SIGINT, signal_handler);
+    signal(SIGTERM, signal_handler);
 
     /* 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);
+    if (db_open(&g_db, g_config.db_path) != 0) {
+        fprintf(stderr, "[MAIN] Failed to open database\n");
         return 1;
     }
-    printf("[MAIN] Database record count: %d\n", db_get_record_count(&g_db));
-
-    /* Initialize Modbus connection */
-    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,
-                    &g_db, &g_config);
-
-    /* Initialize Mongoose event manager */
-    mg_mgr_init(&mgr);
+    int record_count = db_get_record_count(&g_db);
+    printf("[MAIN] Database record count: %d\n", record_count);
+
+    /* Initialize Modbus client */
+    modbus_config_t mb_config;
+    memset(&mb_config, 0, sizeof(mb_config));
+    snprintf(mb_config.host, sizeof(mb_config.host), "%s", g_config.modbus_host);
+    mb_config.port = g_config.modbus_port;
+    mb_config.poll_interval_sec = g_config.modbus_poll_interval_sec;
+    mb_config.connect_timeout_sec = g_config.modbus_connect_timeout_sec;
+    mb_config.device_count = g_config.modbus_device_count;
+    memcpy(mb_config.device_addrs, g_config.modbus_device_addrs,
+           sizeof(mb_config.device_addrs));
+
+    printf("[MAIN] Modbus config: %s:%d, devices: %d, poll: %ds\n",
+           mb_config.host, mb_config.port, mb_config.device_count,
+           mb_config.poll_interval_sec);
+    printf("[MAIN] Device addresses:");
+    for (int i = 0; i < mb_config.device_count; i++) {
+        printf(" %d", mb_config.device_addrs[i]);
+    }
+    printf("\n");
 
-    /* Start HTTP server */
-    g_web_conn = web_server_start(&mgr, &g_web_ctx);
-    if (g_web_conn == NULL) {
-        fprintf(stderr, "[MAIN] FATAL: Cannot start web server\n");
+    modbus_init(&g_modbus, &mb_config);
+    if (modbus_start(&g_modbus) != 0) {
+        fprintf(stderr, "[MAIN] Failed to start Modbus client\n");
         db_close(&g_db);
         return 1;
     }
 
-    /* Start Modbus polling thread */
-    ret = pthread_create(&modbus_tid, NULL, modbus_poll_thread, NULL);
-    if (ret != 0) {
-        fprintf(stderr, "[MAIN] FATAL: Cannot create Modbus thread: %s\n",
-                strerror(ret));
-        mg_mgr_free(&mgr);
+    /* Start web server */
+    uint16_t web_port = g_config.web_port;
+    /* Check DEPLOY_RUN_PORT env var */
+    const char *env_port = getenv("DEPLOY_RUN_PORT");
+    if (env_port) {
+        int p = atoi(env_port);
+        if (p > 0 && p < 65536) {
+            web_port = (uint16_t)p;
+            printf("[MAIN] Using DEPLOY_RUN_PORT: %d\n", web_port);
+        }
+    }
+
+    if (web_server_start(web_port, &g_modbus, &g_db, &g_config) != 0) {
+        fprintf(stderr, "[MAIN] Failed to start web server\n");
+        modbus_stop(&g_modbus);
         db_close(&g_db);
         return 1;
     }
 
-    printf("[MAIN] All systems running. Press Ctrl+C to stop.\n\n");
+    /* Start storage thread */
+    pthread_create(&g_storage_thread, NULL, storage_thread_func, NULL);
 
-    /* Main event loop */
-    while (g_running) {
-        mg_mgr_poll(&mgr, 100);
-        web_watchdog(&mgr);
+    printf("[MAIN] System started. HTTP: http://0.0.0.0:%d\n", web_port);
+    printf("[MAIN] Press Ctrl+C to stop.\n\n");
+
+    /* Main loop: print periodic summary */
+    while (g_keep_running) {
+        sleep(10);
+        if (!g_keep_running) break;
+        print_device_summary();
     }
 
     /* Cleanup */
-    printf("[MAIN] Waiting for Modbus thread...\n");
-    pthread_join(modbus_tid, NULL);
+    printf("[MAIN] Stopping storage thread...\n");
+    pthread_join(g_storage_thread, NULL);
+
+    printf("[MAIN] Stopping web server...\n");
+    web_server_stop();
 
-    printf("[MAIN] Freeing Mongoose resources...\n");
-    mg_mgr_free(&mgr);
+    printf("[MAIN] Stopping Modbus client...\n");
+    modbus_stop(&g_modbus);
 
+    printf("[MAIN] Closing database...\n");
     db_close(&g_db);
-    pthread_mutex_destroy(&g_data_mutex);
 
     printf("[MAIN] Shutdown complete.\n");
     return 0;

+ 439 - 252
mppt_monitor/src/modbus_client.c

@@ -1,45 +1,34 @@
 /**
  * @file modbus_client.c
- * @brief Modbus RTU over TCP client implementation
+ * @brief MPPT Modbus RTU over TCP Client - DM Series Protocol V2.1
  *
- * Implements CRC-16/Modbus, TCP connection management with timeout,
- * register read/parse, and auto-reconnect logic.
+ * Reads ALL Input Registers (0x04) and Output Registers (0x03)
+ * for multiple devices.
  */
 
 #include "modbus_client.h"
-
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <unistd.h>
 #include <errno.h>
-#include <fcntl.h>
-#include <time.h>
-#include <sys/types.h>
 #include <sys/socket.h>
 #include <netinet/in.h>
-#include <netinet/tcp.h>
 #include <arpa/inet.h>
 #include <netdb.h>
+#include <fcntl.h>
 #include <sys/select.h>
 
-/* ── CRC-16/Modbus ─────────────────────────────────────────────── */
-/*
- * Standard Modbus CRC-16 calculation.
- * Polynomial: 0xA001 (reversed 0x8005)
- * Initial value: 0xFFFF
- */
-uint16_t modbus_crc16(const uint8_t *buf, size_t len)
+/* ---- CRC-16/Modbus ---- */
+static uint16_t crc16_modbus(const uint8_t *data, uint16_t length)
 {
     uint16_t crc = 0xFFFF;
-    size_t i;
-    int j;
-
-    for (i = 0; i < len; i++) {
-        crc ^= (uint16_t)buf[i];
-        for (j = 0; j < 8; j++) {
+    for (uint16_t i = 0; i < length; i++) {
+        crc ^= data[i];
+        for (int j = 0; j < 8; j++) {
             if (crc & 0x0001) {
-                crc = (crc >> 1) ^ 0xA001;
+                crc >>= 1;
+                crc ^= 0xA001;
             } else {
                 crc >>= 1;
             }
@@ -48,46 +37,109 @@ uint16_t modbus_crc16(const uint8_t *buf, size_t len)
     return crc;
 }
 
-/* ── Init ──────────────────────────────────────────────────────── */
-void modbus_init(modbus_conn_t *conn, const char *host, uint16_t port)
+/* ---- Human-readable strings ---- */
+const char *modbus_battery_type_str(uint16_t type)
+{
+    switch (type) {
+        case BATT_TYPE_GEL:     return "Gel (胶体)";
+        case BATT_TYPE_SEALED:  return "Sealed (密封)";
+        case BATT_TYPE_LFP:     return "LiFePO4 (磷酸铁锂)";
+        case BATT_TYPE_NCM:     return "NCM (三元锂电)";
+        case BATT_TYPE_CUSTOM:  return "Custom (自定义)";
+        case BATT_TYPE_FLOODED: return "Flooded (加水)";
+        default:                return "Unknown";
+    }
+}
+
+const char *modbus_battery_level_str(uint16_t level)
+{
+    switch (level) {
+        case BATT_LEVEL_AUTO: return "Auto (自动识别)";
+        case BATT_LEVEL_12V:  return "12V";
+        case BATT_LEVEL_24V:  return "24V";
+        case BATT_LEVEL_36V:  return "36V";
+        case BATT_LEVEL_48V:  return "48V";
+        case BATT_LEVEL_60V:  return "60V";
+        case BATT_LEVEL_72V:  return "72V";
+        default:              return "Unknown";
+    }
+}
+
+const char *modbus_fault_desc(uint16_t code)
+{
+    switch (code) {
+        case FAULT_NORMAL:         return "Normal";
+        case FAULT_PV_OVERVOLT:    return "PV Overvoltage (光伏板超压)";
+        case FAULT_PV_OVERCURRENT: return "PV Overcurrent (光伏充电过流)";
+        case FAULT_BATT_LOW:       return "Battery Low (电池电压低)";
+        case FAULT_BATT_OVERVOLT:  return "Battery Overvoltage (电池超压)";
+        case FAULT_TEMP_HIGH:      return "Temperature High (设备温度高)";
+        case FAULT_TEMP_SENSOR:    return "Temp Sensor Fault (温度传感器故障)";
+        case FAULT_NOT_DECRYPTED:  return "Not Decrypted (未解密)";
+        default:                   return "Unknown Fault";
+    }
+}
+
+const char *modbus_charge_state_str(uint16_t state)
+{
+    switch (state) {
+        case CHARGE_STATE_IDLE:     return "Idle (未充电)";
+        case CHARGE_STATE_CHARGING: return "Charging (充电中)";
+        default:                    return "Unknown";
+    }
+}
+
+const char *modbus_charge_mode_str(uint16_t mode)
+{
+    switch (mode) {
+        case CHARGE_MODE_MPPT: return "MPPT";
+        case CHARGE_MODE_DCDC: return "DC-DC";
+        default:               return "Unknown";
+    }
+}
+
+/* ---- Compute derived values ---- */
+void modbus_compute_derived(mppt_device_data_t *dev)
 {
-    memset(conn, 0, sizeof(*conn));
-    conn->sockfd = -1;
-    conn->state = MODBUS_STATE_DISCONNECTED;
-    snprintf(conn->host, sizeof(conn->host), "%s", host);
-    conn->port = port;
-    conn->last_connect_attempt = 0;
-    conn->reconnect_count = 0;
+    dev->battery_voltage   = dev->battery_voltage_raw * 0.1;
+    dev->pv_voltage        = dev->pv_voltage_raw * 0.1;
+    dev->pv_charge_current = dev->pv_charge_current_raw * 0.1;
+    dev->pv_input_power    = dev->pv_voltage * dev->pv_charge_current;
+    dev->cumulative_energy = dev->cumulative_energy_raw * 0.1;
+    dev->battery_soc       = dev->battery_soc_raw;
+
+    if (dev->machine_temp_raw == 0xFF) {
+        dev->machine_temp = -1;  /* Temperature sensor fault */
+    } else {
+        dev->machine_temp = (int)dev->machine_temp_raw;
+    }
 }
 
-/* ── TCP Connect with Timeout ──────────────────────────────────── */
-static int tcp_connect_with_timeout(const char *host, uint16_t port, int timeout_sec)
+/* ---- TCP Connection ---- */
+static int tcp_connect(const char *host, uint16_t port, int timeout_sec)
 {
     struct addrinfo hints, *res, *rp;
     int sockfd = -1;
-    int ret;
-    char port_str[8];
 
     memset(&hints, 0, sizeof(hints));
-    hints.ai_family = AF_UNSPEC;     /* IPv4 or IPv6 */
-    hints.ai_socktype = SOCK_STREAM; /* TCP */
+    hints.ai_family = AF_INET;
+    hints.ai_socktype = SOCK_STREAM;
 
+    char port_str[16];
     snprintf(port_str, sizeof(port_str), "%u", port);
 
-    /* DNS resolution */
-    ret = getaddrinfo(host, port_str, &hints, &res);
+    int ret = getaddrinfo(host, port_str, &hints, &res);
     if (ret != 0) {
-        fprintf(stderr, "[MODBUS] DNS resolution failed for %s:%u - %s\n",
-                host, port, gai_strerror(ret));
+        fprintf(stderr, "[MODBUS] DNS resolution failed for %s: %s\n",
+                host, gai_strerror(ret));
         return -1;
     }
 
-    /* Try each resolved address */
     for (rp = res; rp != NULL; rp = rp->ai_next) {
         sockfd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
         if (sockfd < 0) continue;
 
-        /* Set non-blocking for connect with timeout */
+        /* Set non-blocking for connect timeout */
         int flags = fcntl(sockfd, F_GETFL, 0);
         fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
 
@@ -98,10 +150,8 @@ static int tcp_connect_with_timeout(const char *host, uint16_t port, int timeout
             break;
         }
         if (errno == EINPROGRESS) {
-            /* Wait for connection with timeout using select */
             fd_set wfds;
             struct timeval tv;
-
             FD_ZERO(&wfds);
             FD_SET(sockfd, &wfds);
             tv.tv_sec = timeout_sec;
@@ -109,285 +159,422 @@ static int tcp_connect_with_timeout(const char *host, uint16_t port, int timeout
 
             ret = select(sockfd + 1, NULL, &wfds, NULL, &tv);
             if (ret > 0 && FD_ISSET(sockfd, &wfds)) {
-                /* Check for connection error */
-                int so_error;
-                socklen_t len = sizeof(so_error);
-                getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &so_error, &len);
-                if (so_error == 0) {
-                    /* Connected successfully */
+                int err = 0;
+                socklen_t len = sizeof(err);
+                getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &err, &len);
+                if (err == 0) {
                     fcntl(sockfd, F_SETFL, flags);
                     break;
-                } else {
-                    fprintf(stderr, "[MODBUS] Connect error: %s\n",
-                            strerror(so_error));
                 }
-            } else if (ret == 0) {
-                fprintf(stderr, "[MODBUS] Connect timeout after %ds\n",
-                        timeout_sec);
-            } else {
-                fprintf(stderr, "[MODBUS] Select error: %s\n",
-                        strerror(errno));
             }
-        } else {
-            fprintf(stderr, "[MODBUS] Connect failed: %s\n",
-                    strerror(errno));
         }
-
         close(sockfd);
         sockfd = -1;
     }
-
     freeaddrinfo(res);
 
     if (sockfd >= 0) {
-        /* Set TCP_NODELAY for low latency */
-        int flag = 1;
-        setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));
-
         /* Set receive timeout */
-        struct timeval recv_tv;
-        recv_tv.tv_sec = timeout_sec;
-        recv_tv.tv_usec = 0;
-        setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &recv_tv, sizeof(recv_tv));
+        struct timeval tv;
+        tv.tv_sec = timeout_sec;
+        tv.tv_usec = 0;
+        setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
     }
 
     return sockfd;
 }
 
-/* ── Connect ───────────────────────────────────────────────────── */
-int modbus_connect(modbus_conn_t *conn)
+/* ---- Send Modbus RTU frame and receive response ---- */
+static int modbus_send_recv(int sockfd, const uint8_t *tx_frame, int tx_len,
+                            uint8_t *rx_buf, int rx_buf_size, int timeout_sec)
 {
-    if (conn->state == MODBUS_STATE_CONNECTED && conn->sockfd >= 0) {
-        return 0; /* Already connected */
-    }
-
-    /* Rate limit reconnect attempts */
-    time_t now = time(NULL);
-    if (conn->last_connect_attempt > 0 &&
-        (now - conn->last_connect_attempt) < 2) {
-        return -1; /* Too soon, wait at least 2 seconds between attempts */
+    /* Send */
+    ssize_t sent = send(sockfd, tx_frame, tx_len, 0);
+    if (sent != tx_len) {
+        return -1;
     }
-    conn->last_connect_attempt = now;
-
-    printf("[MODBUS] Connecting to %s:%u ...\n", conn->host, conn->port);
-    conn->state = MODBUS_STATE_CONNECTING;
 
-    /* Close old socket if any */
-    if (conn->sockfd >= 0) {
-        close(conn->sockfd);
-        conn->sockfd = -1;
+    /* Receive with timeout */
+    fd_set rfds;
+    struct timeval tv;
+    FD_ZERO(&rfds);
+    FD_SET(sockfd, &rfds);
+    tv.tv_sec = timeout_sec;
+    tv.tv_usec = 0;
+
+    int ret = select(sockfd + 1, &rfds, NULL, NULL, &tv);
+    if (ret <= 0) {
+        return -1;  /* Timeout or error */
     }
 
-    conn->sockfd = tcp_connect_with_timeout(conn->host, conn->port,
-                                             MODBUS_TIMEOUT_SEC);
-    if (conn->sockfd < 0) {
-        conn->state = MODBUS_STATE_ERROR;
-        conn->reconnect_count++;
-        fprintf(stderr, "[MODBUS] Connection failed (attempt #%d)\n",
-                conn->reconnect_count);
+    ssize_t received = recv(sockfd, rx_buf, rx_buf_size, 0);
+    if (received <= 0) {
         return -1;
     }
 
-    conn->state = MODBUS_STATE_CONNECTED;
-    conn->reconnect_count = 0;
-    printf("[MODBUS] Connected to %s:%u (fd=%d)\n",
-           conn->host, conn->port, conn->sockfd);
-    return 0;
+    return (int)received;
 }
 
-/* ── Disconnect ────────────────────────────────────────────────── */
-void modbus_disconnect(modbus_conn_t *conn)
+/* ---- Build and send read input registers command (0x04) ---- */
+static int read_input_registers(int sockfd, uint8_t addr,
+                                uint16_t start_reg, uint16_t count,
+                                uint16_t *regs, int timeout_sec)
 {
-    if (conn->sockfd >= 0) {
-        close(conn->sockfd);
-        conn->sockfd = -1;
+    uint8_t tx[8];
+    tx[0] = addr;
+    tx[1] = MODBUS_FC_READ_INPUT;
+    tx[2] = (start_reg >> 8) & 0xFF;
+    tx[3] = start_reg & 0xFF;
+    tx[4] = (count >> 8) & 0xFF;
+    tx[5] = count & 0xFF;
+    uint16_t crc = crc16_modbus(tx, 6);
+    tx[6] = crc & 0xFF;        /* CRC low */
+    tx[7] = (crc >> 8) & 0xFF; /* CRC high */
+
+    uint8_t rx[512];
+    int rx_len = modbus_send_recv(sockfd, tx, 8, rx, sizeof(rx), timeout_sec);
+    if (rx_len < 0) {
+        return -1;
     }
-    conn->state = MODBUS_STATE_DISCONNECTED;
-    printf("[MODBUS] Disconnected\n");
-}
 
-/* ── Read Registers ────────────────────────────────────────────── */
-int modbus_read_registers(modbus_conn_t *conn, mppt_data_t *data)
-{
-    uint8_t request[8];
-    uint8_t response[256];
-    ssize_t n;
-    uint16_t crc_recv, crc_calc;
-
-    if (conn->state != MODBUS_STATE_CONNECTED || conn->sockfd < 0) {
-        fprintf(stderr, "[MODBUS] Not connected, cannot read\n");
+    /* Check response */
+    if (rx_len < 5) {
+        fprintf(stderr, "[MODBUS] Response too short: %d bytes\n", rx_len);
         return -1;
     }
-
-    /*
-     * Build Modbus RTU request frame:
-     * [Slave Addr] [Func Code] [Start Reg Hi] [Start Reg Lo]
-     * [Reg Count Hi] [Reg Count Lo] [CRC Lo] [CRC Hi]
-     */
-    request[0] = MODBUS_SLAVE_ADDR;          /* Slave address */
-    request[1] = MODBUS_FUNC_READ;           /* Function code 04 */
-    request[2] = (MODBUS_REG_START >> 8) & 0xFF; /* Start register high */
-    request[3] = MODBUS_REG_START & 0xFF;         /* Start register low */
-    request[4] = (MODBUS_REG_COUNT >> 8) & 0xFF;  /* Register count high */
-    request[5] = MODBUS_REG_COUNT & 0xFF;          /* Register count low */
-
-    /* Calculate CRC over first 6 bytes */
-    crc_calc = modbus_crc16(request, 6);
-    request[6] = crc_calc & 0xFF;        /* CRC low byte */
-    request[7] = (crc_calc >> 8) & 0xFF; /* CRC high byte */
-
-    /* Send request */
-    n = write(conn->sockfd, request, sizeof(request));
-    if (n != sizeof(request)) {
-        fprintf(stderr, "[MODBUS] Write failed: %s (wrote %zd/%zu)\n",
-                strerror(errno), n, sizeof(request));
-        conn->state = MODBUS_STATE_ERROR;
+    if (rx[0] != addr) {
+        fprintf(stderr, "[MODBUS] Address mismatch: expected %d, got %d\n",
+                addr, rx[0]);
         return -1;
     }
-
-    /* Read response with timeout (set via SO_RCVTIMEO) */
-    n = read(conn->sockfd, response, sizeof(response));
-    if (n < 0) {
-        if (errno == EAGAIN || errno == EWOULDBLOCK) {
-            fprintf(stderr, "[MODBUS] Read timeout after %ds\n",
-                    MODBUS_TIMEOUT_SEC);
-        } else {
-            fprintf(stderr, "[MODBUS] Read error: %s\n", strerror(errno));
-        }
-        conn->state = MODBUS_STATE_ERROR;
+    if (rx[1] & 0x80) {
+        fprintf(stderr, "[MODBUS] Exception response: func=0x%02X code=%d\n",
+                rx[1], rx[2]);
         return -1;
     }
-    if (n == 0) {
-        fprintf(stderr, "[MODBUS] Connection closed by remote\n");
-        conn->state = MODBUS_STATE_ERROR;
+    if (rx[1] != MODBUS_FC_READ_INPUT) {
+        fprintf(stderr, "[MODBUS] Function code mismatch: expected 0x04, got 0x%02X\n",
+                rx[1]);
         return -1;
     }
 
-    /* Validate minimum response length: addr(1) + func(1) + len(1) + data(20) + crc(2) = 25 */
-    if (n < 25) {
-        fprintf(stderr, "[MODBUS] Response too short (%zd bytes)\n", n);
+    uint8_t byte_count = rx[2];
+    if (rx_len < 3 + byte_count + 2) {
+        fprintf(stderr, "[MODBUS] Incomplete data: expected %d bytes, got %d\n",
+                3 + byte_count + 2, rx_len);
         return -1;
     }
 
-    /* Check slave address */
-    if (response[0] != MODBUS_SLAVE_ADDR) {
-        fprintf(stderr, "[MODBUS] Wrong slave address: 0x%02X\n", response[0]);
+    /* Verify CRC */
+    uint16_t recv_crc = rx[3 + byte_count] | (rx[3 + byte_count + 1] << 8);
+    uint16_t calc_crc = crc16_modbus(rx, 3 + byte_count);
+    if (recv_crc != calc_crc) {
+        fprintf(stderr, "[MODBUS] CRC mismatch: recv=0x%04X calc=0x%04X\n",
+                recv_crc, calc_crc);
         return -1;
     }
 
-    /* Check for exception response */
-    if (response[1] & 0x80) {
-        fprintf(stderr, "[MODBUS] Exception response: func=0x%02X code=0x%02X\n",
-                response[1], response[2]);
+    /* Parse registers */
+    int reg_count = byte_count / 2;
+    for (int i = 0; i < reg_count && i < count; i++) {
+        regs[i] = (rx[3 + i * 2] << 8) | rx[3 + i * 2 + 1];
+    }
+
+    return reg_count;
+}
+
+/* ---- Build and send read output registers command (0x03) ---- */
+static int read_output_registers(int sockfd, uint8_t addr,
+                                 uint16_t start_reg, uint16_t count,
+                                 uint16_t *regs, int timeout_sec)
+{
+    uint8_t tx[8];
+    tx[0] = addr;
+    tx[1] = MODBUS_FC_READ_OUTPUT;
+    tx[2] = (start_reg >> 8) & 0xFF;
+    tx[3] = start_reg & 0xFF;
+    tx[4] = (count >> 8) & 0xFF;
+    tx[5] = count & 0xFF;
+    uint16_t crc = crc16_modbus(tx, 6);
+    tx[6] = crc & 0xFF;
+    tx[7] = (crc >> 8) & 0xFF;
+
+    uint8_t rx[512];
+    int rx_len = modbus_send_recv(sockfd, tx, 8, rx, sizeof(rx), timeout_sec);
+    if (rx_len < 0) {
         return -1;
     }
 
-    /* Check function code */
-    if (response[1] != MODBUS_FUNC_READ) {
-        fprintf(stderr, "[MODBUS] Unexpected function code: 0x%02X\n", response[1]);
+    if (rx_len < 5) {
+        fprintf(stderr, "[MODBUS] Output reg response too short: %d bytes\n", rx_len);
+        return -1;
+    }
+    if (rx[0] != addr) {
+        return -1;
+    }
+    if (rx[1] & 0x80) {
+        fprintf(stderr, "[MODBUS] Output reg exception: func=0x%02X code=%d\n",
+                rx[1], rx[2]);
+        return -1;
+    }
+    if (rx[1] != MODBUS_FC_READ_OUTPUT) {
         return -1;
     }
 
-    /* Check data length */
-    uint8_t byte_count = response[2];
-    if (byte_count != MODBUS_REG_COUNT * 2) {
-        fprintf(stderr, "[MODBUS] Unexpected data length: %u (expected %u)\n",
-                byte_count, MODBUS_REG_COUNT * 2);
+    uint8_t byte_count = rx[2];
+    if (rx_len < 3 + byte_count + 2) {
         return -1;
     }
 
-    /* Verify CRC */
-    crc_recv = response[n - 2] | ((uint16_t)response[n - 1] << 8);
-    crc_calc = modbus_crc16(response, (size_t)(n - 2));
-    if (crc_recv != crc_calc) {
-        fprintf(stderr, "[MODBUS] CRC mismatch: recv=0x%04X calc=0x%04X\n",
-                crc_recv, crc_calc);
+    uint16_t recv_crc = rx[3 + byte_count] | (rx[3 + byte_count + 1] << 8);
+    uint16_t calc_crc = crc16_modbus(rx, 3 + byte_count);
+    if (recv_crc != calc_crc) {
+        fprintf(stderr, "[MODBUS] Output reg CRC mismatch\n");
         return -1;
     }
 
-    /* Parse register values (big-endian) */
-    for (int i = 0; i < MODBUS_REG_COUNT; i++) {
-        data->reg[i] = ((uint16_t)response[3 + i * 2] << 8) |
-                        (uint16_t)response[3 + i * 2 + 1];
+    int reg_count = byte_count / 2;
+    for (int i = 0; i < reg_count && i < count; i++) {
+        regs[i] = (rx[3 + i * 2] << 8) | rx[3 + i * 2 + 1];
+    }
+
+    return reg_count;
+}
+
+/* ---- Poll a single device ---- */
+static int poll_device(modbus_client_t *client, mppt_device_data_t *dev)
+{
+    uint16_t regs[16];
+    int ret;
+
+    /* Read Input Registers (0x04): 11 registers starting at 0x0000 */
+    ret = read_input_registers(client->sockfd, dev->device_addr,
+                               0x0000, MODBUS_INPUT_REG_COUNT,
+                               regs, client->config.connect_timeout_sec);
+    if (ret < 0) {
+        return -1;
     }
 
-    /* Convert to real-world values */
-    data->battery_voltage     = data->reg[0] / 10.0;     /* V */
-    data->pv_voltage          = data->reg[1] / 10.0;     /* V */
-    data->pv_charge_current   = data->reg[2] / 10.0;     /* A */
-    data->cumulative_energy   = data->reg[3] / 10.0;     /* kWh */
-    data->machine_temp        = (double)data->reg[4];     /* deg C */
-    data->fault_code          = data->reg[5];
-    data->battery_soc         = data->reg[6];             /* % */
-    data->battery_string      = data->reg[7];
-    data->charge_state        = data->reg[8];
-    data->device_type         = data->reg[9];
+    /* Parse input registers */
+    dev->battery_voltage_raw   = regs[0];   /* 30001 */
+    dev->pv_voltage_raw        = regs[1];   /* 30002 */
+    dev->pv_charge_current_raw = regs[2];   /* 30003 */
+    dev->cumulative_energy_raw = regs[3];   /* 30004 */
+    dev->machine_temp_raw      = regs[4];   /* 30005 */
+    dev->fault_code_raw        = regs[5];   /* 30006 */
+    dev->battery_soc_raw       = regs[6];   /* 30007 */
+    dev->battery_string_count  = regs[7];   /* 30008 */
+    dev->charge_state_raw      = regs[8];   /* 30009 */
+    dev->device_type_raw       = regs[9];   /* 30010 */
+    dev->software_version_raw  = regs[10];  /* 30011 */
+
+    /* Read Output Registers (0x03): 9 registers starting at 0x0000 */
+    /* Wait 1 second between commands per protocol spec */
+    usleep(200000);  /* 200ms - reduced for faster polling */
+
+    ret = read_output_registers(client->sockfd, dev->device_addr,
+                                0x0000, MODBUS_OUTPUT_REG_COUNT,
+                                regs, client->config.connect_timeout_sec);
+    if (ret >= 0) {
+        /* Parse output registers */
+        dev->battery_type_raw       = regs[0];  /* 40001 */
+        dev->battery_level_raw      = regs[1];  /* 40002 */
+        dev->pv_charge_current_limit = regs[2]; /* 40003 */
+        dev->batt_over_discharge_v  = regs[3];  /* 40004 */
+        dev->batt_boost_voltage     = regs[4];  /* 40005 */
+        dev->batt_full_voltage      = regs[5];  /* 40006 */
+        dev->batt_recovery_voltage  = regs[6];  /* 40007 */
+        dev->device_485_addr        = regs[7];  /* 40008 */
+        dev->charge_mode_raw        = regs[8];  /* 40009 */
+    } else {
+        /* Output registers read failed, keep previous values */
+        fprintf(stderr, "[MODBUS] Failed to read output regs for device %d\n",
+                dev->device_addr);
+    }
 
-    /* Computed values */
-    data->pv_input_power = data->pv_voltage * data->pv_charge_current;
+    /* Compute derived values */
+    modbus_compute_derived(dev);
 
-    /* Meta */
-    data->last_update = time(NULL);
-    data->data_valid = true;
-    data->consecutive_failures = 0;
+    dev->online = true;
+    dev->last_update = time(NULL);
+    dev->read_error_count = 0;
 
     return 0;
 }
 
-/* ── Fault Code Descriptions ───────────────────────────────────── */
-const char *modbus_fault_string(uint16_t code)
+/* ---- Polling thread ---- */
+static void *poll_thread_func(void *arg)
 {
-    switch (code) {
-        case 0:  return "No fault";
-        case 1:  return "Battery over-voltage";
-        case 2:  return "Battery under-voltage";
-        case 3:  return "PV over-voltage";
-        case 4:  return "PV under-voltage";
-        case 5:  return "Over-temperature";
-        case 6:  return "Short circuit";
-        case 7:  return "Over-current";
-        case 8:  return "Communication error";
-        case 20: return "Normal";
-        default: return "Unknown fault";
+    modbus_client_t *client = (modbus_client_t *)arg;
+
+    while (client->running) {
+        /* Ensure connected */
+        if (!client->connected || client->sockfd < 0) {
+            printf("[MODBUS] Connecting to %s:%d ...\n",
+                   client->config.host, client->config.port);
+            client->sockfd = tcp_connect(client->config.host,
+                                         client->config.port,
+                                         client->config.connect_timeout_sec);
+            if (client->sockfd < 0) {
+                fprintf(stderr, "[MODBUS] Connection failed, retrying in %ds...\n",
+                        client->config.poll_interval_sec);
+                pthread_mutex_lock(&client->data_mutex);
+                for (int i = 0; i < client->device_count; i++) {
+                    client->devices[i].online = false;
+                }
+                pthread_mutex_unlock(&client->data_mutex);
+                sleep(client->config.poll_interval_sec);
+                continue;
+            }
+            client->connected = true;
+            printf("[MODBUS] Connected to %s:%d\n",
+                   client->config.host, client->config.port);
+        }
+
+        /* Poll each device */
+        for (int i = 0; i < client->device_count; i++) {
+            if (!client->running) break;
+
+            pthread_mutex_lock(&client->data_mutex);
+            mppt_device_data_t *dev = &client->devices[i];
+            pthread_mutex_unlock(&client->data_mutex);
+
+            int ret = poll_device(client, dev);
+
+            pthread_mutex_lock(&client->data_mutex);
+            if (ret == 0) {
+                dev->online = true;
+                dev->last_update = time(NULL);
+                dev->read_error_count = 0;
+                printf("[MODBUS] Device %d @ %s - Batt:%.1fV PV:%.1fV/%.1fA "
+                       "SOC:%d%% Temp:%dC Fault:%s Type:%s Mode:%s SW:v%d\n",
+                       dev->device_addr,
+                       dev->online ? "ONLINE" : "OFFLINE",
+                       dev->battery_voltage,
+                       dev->pv_voltage,
+                       dev->pv_charge_current,
+                       dev->battery_soc,
+                       dev->machine_temp,
+                       modbus_fault_desc(dev->fault_code_raw),
+                       modbus_battery_type_str(dev->battery_type_raw),
+                       modbus_charge_mode_str(dev->charge_mode_raw),
+                       dev->software_version_raw);
+            } else {
+                dev->read_error_count++;
+                if (dev->read_error_count >= 3) {
+                    dev->online = false;
+                    fprintf(stderr, "[MODBUS] Device %d marked OFFLINE "
+                            "(%u consecutive errors)\n",
+                            dev->device_addr, dev->read_error_count);
+                }
+            }
+            pthread_mutex_unlock(&client->data_mutex);
+
+            /* Delay between devices */
+            if (i < client->device_count - 1) {
+                usleep(500000);  /* 500ms between devices */
+            }
+        }
+
+        /* Check if connection is still alive */
+        if (client->connected) {
+            /* Simple check: if all devices failed, reconnect */
+            bool all_failed = true;
+            pthread_mutex_lock(&client->data_mutex);
+            for (int i = 0; i < client->device_count; i++) {
+                if (client->devices[i].online) {
+                    all_failed = false;
+                    break;
+                }
+            }
+            pthread_mutex_unlock(&client->data_mutex);
+
+            if (all_failed && client->device_count > 0) {
+                printf("[MODBUS] All devices offline, reconnecting...\n");
+                close(client->sockfd);
+                client->sockfd = -1;
+                client->connected = false;
+            }
+        }
+
+        /* Wait for next poll cycle */
+        for (int s = 0; s < client->config.poll_interval_sec && client->running; s++) {
+            sleep(1);
+        }
     }
+
+    return NULL;
 }
 
-/* ── Charge State Descriptions ─────────────────────────────────── */
-const char *modbus_charge_state_string(uint16_t state)
+/* ---- Public API ---- */
+
+void modbus_init(modbus_client_t *client, const modbus_config_t *config)
 {
-    switch (state) {
-        case 0: return "Idle / No charging";
-        case 1: return "Charging (Bulk)";
-        case 2: return "Absorption";
-        case 3: return "Float / Trickle";
-        case 4: return "Equalize";
-        default: return "Unknown";
+    memset(client, 0, sizeof(*client));
+    memcpy(&client->config, config, sizeof(modbus_config_t));
+    client->sockfd = -1;
+    client->connected = false;
+    client->running = false;
+    client->device_count = config->device_count;
+    pthread_mutex_init(&client->data_mutex, NULL);
+
+    /* Initialize device entries */
+    for (int i = 0; i < config->device_count && i < MODBUS_MAX_DEVICES; i++) {
+        client->devices[i].device_addr = config->device_addrs[i];
+        client->devices[i].online = false;
+        client->devices[i].machine_temp = -1;
+    }
+}
+
+int modbus_start(modbus_client_t *client)
+{
+    client->running = true;
+    int ret = pthread_create(&client->poll_thread, NULL, poll_thread_func, client);
+    if (ret != 0) {
+        fprintf(stderr, "[MODBUS] Failed to create poll thread: %s\n", strerror(ret));
+        client->running = false;
+        return -1;
+    }
+    return 0;
+}
+
+void modbus_stop(modbus_client_t *client)
+{
+    client->running = false;
+    pthread_join(client->poll_thread, NULL);
+
+    if (client->sockfd >= 0) {
+        close(client->sockfd);
+        client->sockfd = -1;
     }
+    client->connected = false;
+    pthread_mutex_destroy(&client->data_mutex);
+}
+
+void modbus_get_all_data(modbus_client_t *client,
+                         mppt_device_data_t *out_devices,
+                         int *out_count)
+{
+    pthread_mutex_lock(&client->data_mutex);
+    *out_count = client->device_count;
+    memcpy(out_devices, client->devices,
+           client->device_count * sizeof(mppt_device_data_t));
+    pthread_mutex_unlock(&client->data_mutex);
 }
 
-/* ── Debug Print ───────────────────────────────────────────────── */
-void modbus_print_data(const mppt_data_t *data)
+bool modbus_get_device_data(modbus_client_t *client,
+                            uint8_t addr,
+                            mppt_device_data_t *out)
 {
-    char timebuf[64];
-    struct tm *tm_info = localtime(&data->last_update);
-    strftime(timebuf, sizeof(timebuf), "%Y-%m-%d %H:%M:%S", tm_info);
-
-    printf("\n");
-    printf("+==========================================+\n");
-    printf("|  MPPT Real-time Data  [%s]  |\n", timebuf);
-    printf("+==========================================+\n");
-    printf("| Battery Voltage  : %8.1f V              |\n", data->battery_voltage);
-    printf("| PV Voltage       : %8.1f V              |\n", data->pv_voltage);
-    printf("| Charge Current   : %8.1f A              |\n", data->pv_charge_current);
-    printf("| PV Input Power   : %8.1f W              |\n", data->pv_input_power);
-    printf("| Cumulative Energy: %8.1f kWh            |\n", data->cumulative_energy);
-    printf("| Machine Temp     : %8.1f C              |\n", data->machine_temp);
-    printf("| Battery SOC      : %8u %%               |\n", data->battery_soc);
-    printf("| Battery String   : %8u                  |\n", data->battery_string);
-    printf("| Charge State     : %-20s  |\n", modbus_charge_state_string(data->charge_state));
-    printf("| Fault Code       : %-20s  |\n", modbus_fault_string(data->fault_code));
-    printf("| Device Type      : %8u                  |\n", data->device_type);
-    printf("+==========================================+\n");
-    printf("\n");
-    fflush(stdout);
+    bool found = false;
+    pthread_mutex_lock(&client->data_mutex);
+    for (int i = 0; i < client->device_count; i++) {
+        if (client->devices[i].device_addr == addr) {
+            memcpy(out, &client->devices[i], sizeof(mppt_device_data_t));
+            found = true;
+            break;
+        }
+    }
+    pthread_mutex_unlock(&client->data_mutex);
+    return found;
 }

+ 183 - 89
mppt_monitor/src/modbus_client.h

@@ -1,10 +1,9 @@
 /**
  * @file modbus_client.h
- * @brief Modbus RTU over TCP client for MPPT solar charge controller
+ * @brief MPPT Modbus RTU over TCP Client - DM Series Protocol V2.1
  *
- * Compatible with: Ubuntu 24.04 / Raspberry Pi 5 8G
- * Protocol: Modbus RTU framing over raw TCP socket
- * Target device: discover.zhonjin.com:40635
+ * Supports reading ALL Input Registers (0x04) and Output Registers (0x03)
+ * for multiple devices on the same TCP connection.
  */
 
 #ifndef MODBUS_CLIENT_H
@@ -12,122 +11,217 @@
 
 #include <stdint.h>
 #include <stdbool.h>
+#include <pthread.h>
 #include <time.h>
 
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-/* ── Configuration ─────────────────────────────────────────────── */
-#define MODBUS_HOST         "discover.zhonjin.com"
-#define MODBUS_PORT         40635
-#define MODBUS_SLAVE_ADDR   0x01
-#define MODBUS_FUNC_READ    0x04    /* Read Input Registers */
-#define MODBUS_REG_START    0x0000  /* Start register 30001 -> offset 0 */
-#define MODBUS_REG_COUNT    10      /* 10 registers (30001~30010) */
-#define MODBUS_TIMEOUT_SEC  10      /* Connection/read timeout */
-#define MODBUS_POLL_MS      2000    /* Poll interval in milliseconds */
-#define MODBUS_MAX_RETRIES  3       /* Max retry before giving up */
-
-/* ── MPPT Data Structure ───────────────────────────────────────── */
-typedef struct {
-    /* Raw register values */
-    uint16_t reg[MODBUS_REG_COUNT];
-
-    /* Parsed real-world values */
-    double   battery_voltage;       /* 30001: V (raw / 10) */
-    double   pv_voltage;            /* 30002: V (raw / 10) */
-    double   pv_charge_current;     /* 30003: A (raw / 10) */
-    double   cumulative_energy;     /* 30004: kWh (raw / 10) */
-    double   machine_temp;          /* 30005: deg C */
-    uint16_t fault_code;            /* 30006 */
-    uint16_t battery_soc;           /* 30007: % */
-    uint16_t battery_string;        /* 30008 */
-    uint16_t charge_state;          /* 30009 */
-    uint16_t device_type;           /* 30010 */
-
-    /* Computed */
-    double   pv_input_power;        /* W = pv_voltage * charge_current */
-
-    /* Meta */
-    time_t   last_update;           /* Timestamp of last successful read */
-    bool     data_valid;            /* true if last read succeeded */
-    int      consecutive_failures;  /* Count of consecutive read failures */
-} mppt_data_t;
-
-/* ── Connection State ──────────────────────────────────────────── */
+/* ---- Protocol Constants ---- */
+#define MODBUS_MAX_DEVICES      16
+#define MODBUS_DEFAULT_ADDR     1
+#define MODBUS_BROADCAST_ADDR   0xFF
+
+/* Function codes */
+#define MODBUS_FC_READ_OUTPUT   0x03  /* Read Output/Holding Registers */
+#define MODBUS_FC_READ_INPUT    0x04  /* Read Input Registers */
+#define MODBUS_FC_WRITE_SINGLE  0x06  /* Write Single Register */
+#define MODBUS_FC_WRITE_MULTI   0x10  /* Write Multiple Registers */
+
+/* Input Register count (30001-30011 = 11 registers) */
+#define MODBUS_INPUT_REG_COUNT  11
+/* Output Register count (40001-40009 = 9 registers) */
+#define MODBUS_OUTPUT_REG_COUNT 9
+
+/* ---- Battery Type Enum ---- */
+typedef enum {
+    BATT_TYPE_GEL     = 0,  /* 胶体电池 */
+    BATT_TYPE_SEALED  = 1,  /* 密封电池 */
+    BATT_TYPE_LFP     = 2,  /* 磷酸铁锂 */
+    BATT_TYPE_NCM     = 3,  /* 三元锂电 */
+    BATT_TYPE_CUSTOM  = 4,  /* 自定义 */
+    BATT_TYPE_FLOODED = 5   /* 加水电池 */
+} battery_type_t;
+
+/* ---- Battery Voltage Level Enum ---- */
+typedef enum {
+    BATT_LEVEL_AUTO  = 0,  /* 自动识别 */
+    BATT_LEVEL_12V   = 1,
+    BATT_LEVEL_24V   = 2,
+    BATT_LEVEL_36V   = 3,
+    BATT_LEVEL_48V   = 4,
+    BATT_LEVEL_60V   = 5,
+    BATT_LEVEL_72V   = 6
+} battery_level_t;
+
+/* ---- Fault Code Enum ---- */
+typedef enum {
+    FAULT_NORMAL         = 20,  /* 正常 */
+    FAULT_PV_OVERVOLT    = 30,  /* 光伏板超压 */
+    FAULT_PV_OVERCURRENT = 31,  /* 光伏充电过流 */
+    FAULT_BATT_LOW       = 32,  /* 电池电压低 */
+    FAULT_BATT_OVERVOLT  = 33,  /* 电池超压 */
+    FAULT_TEMP_HIGH      = 38,  /* 设备温度高 */
+    FAULT_TEMP_SENSOR    = 50,  /* 温度传感器故障 */
+    FAULT_NOT_DECRYPTED  = 88   /* 未解密 */
+} fault_code_t;
+
+/* ---- Charge State Enum ---- */
+typedef enum {
+    CHARGE_STATE_IDLE    = 0,  /* 未充电 */
+    CHARGE_STATE_CHARGING = 1  /* 充电中 */
+} charge_state_t;
+
+/* ---- Charge Mode Enum ---- */
 typedef enum {
-    MODBUS_STATE_DISCONNECTED = 0,
-    MODBUS_STATE_CONNECTING,
-    MODBUS_STATE_CONNECTED,
-    MODBUS_STATE_ERROR
-} modbus_state_t;
+    CHARGE_MODE_MPPT = 0,
+    CHARGE_MODE_DCDC = 1
+} charge_mode_t;
 
+/* ---- Complete Device Data (ALL registers) ---- */
 typedef struct {
-    int             sockfd;
-    modbus_state_t  state;
-    char            host[256];
-    uint16_t        port;
-    time_t          last_connect_attempt;
-    int             reconnect_count;
-} modbus_conn_t;
+    /* Device identification */
+    uint8_t  device_addr;               /* Modbus slave address (1-247) */
+    bool     online;                    /* Device online status */
+    time_t   last_update;               /* Last successful read time */
+    uint32_t read_error_count;          /* Consecutive read error count */
+
+    /* ===== Input Registers (0x04) - Real-time Data ===== */
+    /* 30001 (0x0000) */
+    uint16_t battery_voltage_raw;       /* Battery voltage raw value (0.1V) */
+    /* 30002 (0x0001) */
+    uint16_t pv_voltage_raw;            /* PV voltage raw value (0.1V) */
+    /* 30003 (0x0002) */
+    uint16_t pv_charge_current_raw;     /* PV charge current raw value (0.1A) */
+    /* 30004 (0x0003) */
+    uint16_t cumulative_energy_raw;     /* Cumulative energy raw value (0.1KWH) */
+    /* 30005 (0x0004) */
+    uint16_t machine_temp_raw;          /* Machine temperature (1C, 0xFF=fault) */
+    /* 30006 (0x0005) */
+    uint16_t fault_code_raw;            /* Fault code */
+    /* 30007 (0x0006) */
+    uint16_t battery_soc_raw;           /* Battery SOC (%) */
+    /* 30008 (0x0007) */
+    uint16_t battery_string_count;      /* Battery string count (reserved) */
+    /* 30009 (0x0008) */
+    uint16_t charge_state_raw;          /* Charge state (0=idle, 1=charging) */
+    /* 30010 (0x0009) */
+    uint16_t device_type_raw;           /* Device type code */
+    /* 30011 (0x000A) */
+    uint16_t software_version_raw;      /* Software version */
+
+    /* ===== Output Registers (0x03) - Configuration/Parameters ===== */
+    /* 40001 (0x0000) */
+    uint16_t battery_type_raw;          /* Battery type (0-5) */
+    /* 40002 (0x0001) */
+    uint16_t battery_level_raw;         /* Battery voltage level (0-6) */
+    /* 40003 (0x0002) */
+    uint16_t pv_charge_current_limit;   /* PV charge current limit (%) */
+    /* 40004 (0x0003) */
+    uint16_t batt_over_discharge_v;     /* Battery over-discharge voltage (0.1V) [read-only] */
+    /* 40005 (0x0004) */
+    uint16_t batt_boost_voltage;        /* Battery boost voltage (0.1V) */
+    /* 40006 (0x0005) */
+    uint16_t batt_full_voltage;         /* Battery full charge voltage (0.1V) */
+    /* 40007 (0x0006) */
+    uint16_t batt_recovery_voltage;     /* Battery over-discharge recovery voltage (0.1V) [read-only] */
+    /* 40008 (0x0007) */
+    uint16_t device_485_addr;           /* Device 485 address setting */
+    /* 40009 (0x0008) */
+    uint16_t charge_mode_raw;           /* Charging mode (0=MPPT, 1=DCDC) */
+
+    /* ===== Computed Values ===== */
+    double   battery_voltage;           /* Battery voltage (V) */
+    double   pv_voltage;                /* PV voltage (V) */
+    double   pv_charge_current;         /* PV charge current (A) */
+    double   pv_input_power;            /* PV input power (W) */
+    double   cumulative_energy;         /* Cumulative energy (KWH) */
+    int      machine_temp;              /* Machine temperature (C), -1 if fault */
+    uint16_t battery_soc;               /* Battery SOC (%) */
+} mppt_device_data_t;
+
+/* ---- Modbus Connection Config ---- */
+typedef struct {
+    char     host[256];
+    uint16_t port;
+    uint8_t  device_addrs[MODBUS_MAX_DEVICES]; /* List of device addresses to poll */
+    int      device_count;                      /* Number of devices */
+    int      poll_interval_sec;                 /* Poll interval in seconds */
+    int      connect_timeout_sec;               /* Connection timeout */
+} modbus_config_t;
+
+/* ---- Modbus Client State ---- */
+typedef struct {
+    int                  sockfd;
+    bool                 connected;
+    modbus_config_t      config;
+    mppt_device_data_t   devices[MODBUS_MAX_DEVICES];
+    int                  device_count;
+    pthread_mutex_t      data_mutex;
+    pthread_t            poll_thread;
+    bool                 running;
+} modbus_client_t;
+
+/* ---- API Functions ---- */
 
-/* ── Public API ────────────────────────────────────────────────── */
+/**
+ * Initialize Modbus client with configuration.
+ */
+void modbus_init(modbus_client_t *client, const modbus_config_t *config);
 
 /**
- * Initialize modbus connection structure.
- * @param conn  Pointer to connection struct
- * @param host  Remote hostname
- * @param port  Remote port
+ * Start the polling thread.
+ * @return 0 on success, -1 on error
  */
-void modbus_init(modbus_conn_t *conn, const char *host, uint16_t port);
+int modbus_start(modbus_client_t *client);
 
 /**
- * Connect to the Modbus TCP device.
- * Handles DNS resolution, socket creation, and connect with timeout.
- * @return 0 on success, -1 on failure
+ * Stop the polling thread and close connection.
  */
-int modbus_connect(modbus_conn_t *conn);
+void modbus_stop(modbus_client_t *client);
 
 /**
- * Disconnect and close socket.
+ * Get a snapshot of all device data (thread-safe).
+ * @param out_devices Output array (must hold MODBUS_MAX_DEVICES entries)
+ * @param out_count   Output: number of devices
  */
-void modbus_disconnect(modbus_conn_t *conn);
+void modbus_get_all_data(modbus_client_t *client,
+                         mppt_device_data_t *out_devices,
+                         int *out_count);
 
 /**
- * Send Modbus RTU read command and parse response.
- * Updates mppt_data_t with parsed values.
- * @param conn  Active connection
- * @param data  Pointer to data struct to fill
- * @return 0 on success, -1 on failure
+ * Get data for a specific device address (thread-safe).
+ * @return true if device found, false otherwise
  */
-int modbus_read_registers(modbus_conn_t *conn, mppt_data_t *data);
+bool modbus_get_device_data(modbus_client_t *client,
+                            uint8_t addr,
+                            mppt_device_data_t *out);
 
 /**
- * Calculate CRC-16/Modbus for a byte buffer.
- * @param buf   Input buffer
- * @param len   Buffer length
- * @return CRC-16 value (low byte first in wire format)
+ * Get human-readable battery type string.
  */
-uint16_t modbus_crc16(const uint8_t *buf, size_t len);
+const char *modbus_battery_type_str(uint16_t type);
 
 /**
- * Print parsed MPPT data to stdout (for debug/logging).
+ * Get human-readable battery level string.
  */
-void modbus_print_data(const mppt_data_t *data);
+const char *modbus_battery_level_str(uint16_t level);
 
 /**
  * Get human-readable fault description.
  */
-const char *modbus_fault_string(uint16_t code);
+const char *modbus_fault_desc(uint16_t code);
 
 /**
- * Get human-readable charge state description.
+ * Get human-readable charge state string.
  */
-const char *modbus_charge_state_string(uint16_t state);
+const char *modbus_charge_state_str(uint16_t state);
 
-#ifdef __cplusplus
-}
-#endif
+/**
+ * Get human-readable charge mode string.
+ */
+const char *modbus_charge_mode_str(uint16_t mode);
+
+/**
+ * Compute derived values from raw register data.
+ */
+void modbus_compute_derived(mppt_device_data_t *dev);
 
 #endif /* MODBUS_CLIENT_H */

+ 326 - 650
mppt_monitor/src/web_server.c

@@ -1,715 +1,391 @@
 /**
  * @file web_server.c
- * @brief Mongoose HTTP server implementation for MPPT REST API
+ * @brief Mongoose 7.22 HTTP Web Server - MPPT Monitor
  *
- * Uses mongoose-7.22 API:
- *   - mg_match() instead of deprecated mg_http_match_uri()
- *   - struct mg_str with char *buf (not const char *ptr)
- *   - mg_http_reply() for JSON responses
- *   - cJSON for JSON serialization
- *
- * API Endpoints:
- *   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
+ * REST API endpoints:
+ *   GET /api/data          - All devices real-time data (complete)
+ *   GET /api/devices       - Device list with status
+ *   GET /api/status        - System status
+ *   GET /api/health        - Health check
+ *   GET /api/history       - Historical data (paginated)
+ *   GET /api/history/stats - Database statistics
+ *   GET /                  - HTML Dashboard (multi-device)
  */
 
 #include "web_server.h"
-#include "cJSON.h"
-
+#include "../lib/mongoose-7.22/mongoose.h"
+#include "../lib/cJSON-1.7.19/cJSON.h"
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
 #include <time.h>
 
-/* ── JSON Serialization ────────────────────────────────────────── */
+/* ---- Global context ---- */
+static web_ctx_t  g_ctx;
+static struct mg_mgr s_mgr;
+static bool       g_running = false;
+
+/* ---- Forward declarations ---- */
+static void handle_api_data(struct mg_connection *c, struct mg_http_message *hm);
+static void handle_api_devices(struct mg_connection *c, struct mg_http_message *hm);
+static void handle_api_status(struct mg_connection *c, struct mg_http_message *hm);
+static void handle_api_health(struct mg_connection *c, struct mg_http_message *hm);
+static void handle_api_history(struct mg_connection *c, struct mg_http_message *hm);
+static void handle_api_history_stats(struct mg_connection *c, struct mg_http_message *hm);
+
+/* ---- Helper: get query parameter ---- */
+static void get_query_param(struct mg_http_message *hm,
+                            const char *key, char *buf, size_t buf_size)
+{
+    buf[0] = '\0';
+    mg_http_get_var(&hm->query, key, buf, buf_size);
+}
 
-static char *build_data_json(const mppt_data_t *data)
+/* ---- Helper: build JSON for one device (complete data) ---- */
+static cJSON *device_to_json(const mppt_device_data_t *dev)
 {
     cJSON *root = cJSON_CreateObject();
-    if (!root) return NULL;
+    cJSON_AddNumberToObject(root, "device_addr", dev->device_addr);
+    cJSON_AddBoolToObject(root, "online", dev->online);
+    cJSON_AddNumberToObject(root, "last_update", (double)dev->last_update);
+    cJSON_AddNumberToObject(root, "read_error_count", dev->read_error_count);
+
+    /* Input Registers */
+    cJSON *input = cJSON_AddObjectToObject(root, "input_registers");
+    cJSON_AddNumberToObject(input, "battery_voltage", dev->battery_voltage);
+    cJSON_AddNumberToObject(input, "pv_voltage", dev->pv_voltage);
+    cJSON_AddNumberToObject(input, "pv_charge_current", dev->pv_charge_current);
+    cJSON_AddNumberToObject(input, "pv_input_power", dev->pv_input_power);
+    cJSON_AddNumberToObject(input, "cumulative_energy", dev->cumulative_energy);
+    cJSON_AddNumberToObject(input, "machine_temp", dev->machine_temp);
+    cJSON_AddNumberToObject(input, "fault_code", dev->fault_code_raw);
+    cJSON_AddStringToObject(input, "fault_desc", modbus_fault_desc(dev->fault_code_raw));
+    cJSON_AddNumberToObject(input, "battery_soc", dev->battery_soc);
+    cJSON_AddNumberToObject(input, "battery_string_count", dev->battery_string_count);
+    cJSON_AddNumberToObject(input, "charge_state", dev->charge_state_raw);
+    cJSON_AddStringToObject(input, "charge_state_desc", modbus_charge_state_str(dev->charge_state_raw));
+    cJSON_AddNumberToObject(input, "device_type", dev->device_type_raw);
+    cJSON_AddNumberToObject(input, "software_version", dev->software_version_raw);
+
+    /* Output Registers */
+    cJSON *output = cJSON_AddObjectToObject(root, "output_registers");
+    cJSON_AddNumberToObject(output, "battery_type", dev->battery_type_raw);
+    cJSON_AddStringToObject(output, "battery_type_desc", modbus_battery_type_str(dev->battery_type_raw));
+    cJSON_AddNumberToObject(output, "battery_level", dev->battery_level_raw);
+    cJSON_AddStringToObject(output, "battery_level_desc", modbus_battery_level_str(dev->battery_level_raw));
+    cJSON_AddNumberToObject(output, "pv_charge_current_limit", dev->pv_charge_current_limit);
+    cJSON_AddNumberToObject(output, "batt_over_discharge_v", dev->batt_over_discharge_v * 0.1);
+    cJSON_AddNumberToObject(output, "batt_boost_voltage", dev->batt_boost_voltage * 0.1);
+    cJSON_AddNumberToObject(output, "batt_full_voltage", dev->batt_full_voltage * 0.1);
+    cJSON_AddNumberToObject(output, "batt_recovery_voltage", dev->batt_recovery_voltage * 0.1);
+    cJSON_AddNumberToObject(output, "device_485_addr", dev->device_485_addr);
+    cJSON_AddNumberToObject(output, "charge_mode", dev->charge_mode_raw);
+    cJSON_AddStringToObject(output, "charge_mode_desc", modbus_charge_mode_str(dev->charge_mode_raw));
+
+    return root;
+}
 
-    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(root, "timestamp", timebuf);
-    } else {
-        cJSON_AddStringToObject(root, "timestamp", "N/A");
-    }
+/* ---- GET /api/data ---- */
+static void handle_api_data(struct mg_connection *c, struct mg_http_message *hm)
+{
+    mppt_device_data_t devices[MODBUS_MAX_DEVICES];
+    int count = 0;
+    modbus_get_all_data(g_ctx.modbus, devices, &count);
 
-    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(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_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]));
+    cJSON *root = cJSON_CreateObject();
+    cJSON_AddNumberToObject(root, "device_count", count);
+    cJSON *arr = cJSON_AddArrayToObject(root, "devices");
+    for (int i = 0; i < count; i++) {
+        cJSON_AddItemToArray(arr, device_to_json(&devices[i]));
     }
-    cJSON_AddItemToObject(root, "raw_registers", raw);
 
     char *json_str = cJSON_PrintUnformatted(root);
+    mg_http_reply(c, 200, "Content-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\n", "%s", json_str);
+    free(json_str);
     cJSON_Delete(root);
-    return json_str;
 }
 
-static char *build_status_json(const mppt_data_t *data,
-                               const modbus_conn_t *conn)
+/* ---- Embedded HTML Dashboard ---- */
+static const char *HTML_DASHBOARD = NULL;
+
+static void load_html_dashboard(void)
 {
-    cJSON *root = cJSON_CreateObject();
-    if (!root) return NULL;
-
-    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;
+    FILE *fp = fopen("www/index.html", "r");
+    if (!fp) {
+        fprintf(stderr, "[WEB] Warning: www/index.html not found, using built-in\n");
+        return;
     }
-    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);
-
-    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];
-        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);
+    fseek(fp, 0, SEEK_END);
+    long size = ftell(fp);
+    fseek(fp, 0, SEEK_SET);
+    char *buf = (char *)malloc(size + 1);
+    if (buf) {
+        size_t nread = fread(buf, 1, size, fp);
+        (void)nread;
+        buf[size] = '\0';
+        HTML_DASHBOARD = buf;
+        printf("[WEB] Loaded dashboard HTML: %ld bytes\n", size);
     }
-
-    char *json_str = cJSON_PrintUnformatted(root);
-    cJSON_Delete(root);
-    return json_str;
+    fclose(fp);
 }
 
-/* ── Parse query string helper ─────────────────────────────────── */
-static void get_query_param(struct mg_http_message *hm, const char *key,
-                            char *buf, size_t buf_size)
+/* ---- HTTP Event Handler ---- */
+static void event_handler(struct mg_connection *c, int ev, void *ev_data)
 {
-    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] = ' ';
+    if (ev == MG_EV_HTTP_MSG) {
+        struct mg_http_message *hm = (struct mg_http_message *)ev_data;
+
+        /* API routes */
+        if (mg_match(hm->uri, mg_str("/api/data"), NULL)) {
+            handle_api_data(c, hm);
+        } else if (mg_match(hm->uri, mg_str("/api/devices"), NULL)) {
+            handle_api_devices(c, hm);
+        } else if (mg_match(hm->uri, mg_str("/api/status"), NULL)) {
+            handle_api_status(c, hm);
+        } else if (mg_match(hm->uri, mg_str("/api/health"), NULL)) {
+            handle_api_health(c, hm);
+        } else if (mg_match(hm->uri, mg_str("/api/history/stats"), NULL)) {
+            handle_api_history_stats(c, hm);
+        } else if (mg_match(hm->uri, mg_str("/api/history"), NULL)) {
+            handle_api_history(c, hm);
+        } else if (mg_match(hm->uri, mg_str("/"), NULL)) {
+            /* Serve HTML dashboard */
+            if (HTML_DASHBOARD) {
+                mg_http_reply(c, 200, "Content-Type: text/html; charset=utf-8\r\n",
+                              "%s", HTML_DASHBOARD);
+            } else {
+                mg_http_reply(c, 200, "Content-Type: text/html\r\n",
+                    "<html><body><h1>MPPT Monitor</h1>"
+                    "<p>Dashboard HTML not found. Place index.html in www/ directory.</p>"
+                    "<p>API: <a href='/api/data'>/api/data</a> | "
+                    "<a href='/api/devices'>/api/devices</a> | "
+                    "<a href='/api/status'>/api/status</a> | "
+                    "<a href='/api/health'>/api/health</a> | "
+                    "<a href='/api/history'>/api/history</a></p>"
+                    "</body></html>");
+            }
+        } else {
+            mg_http_reply(c, 404, "", "Not Found");
         }
-    } else {
-        buf[0] = '\0';
     }
 }
 
-/* ── History API Handler ───────────────────────────────────────── */
-static void handle_history(struct mg_connection *c,
-                           struct mg_http_message *hm,
-                           web_ctx_t *ctx)
+/* ---- Web server thread ---- */
+static void *web_thread_func(void *arg)
 {
-    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);
+    uint16_t port = *(uint16_t *)arg;
+    char url[64];
+    snprintf(url, sizeof(url), "http://0.0.0.0:%d", port);
 
-    get_query_param(hm, "end_time", param_buf, sizeof(param_buf));
-    for (char *p = param_buf; *p; p++) {
-        if (*p == 'T') *p = ' ';
+    mg_mgr_init(&s_mgr);
+    load_html_dashboard();
+
+    struct mg_connection *listener = mg_http_listen(&s_mgr, url, event_handler, NULL);
+    if (!listener) {
+        fprintf(stderr, "[WEB] Failed to start HTTP listener on %s\n", url);
+        return NULL;
     }
-    snprintf(query.end_time, sizeof(query.end_time), "%s", param_buf);
+    printf("[WEB] HTTP server started on %s\n", url);
 
-    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;
+    while (g_running) {
+        mg_mgr_poll(&s_mgr, 200);
+    }
 
-    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;
+    mg_mgr_free(&s_mgr);
+    return NULL;
+}
 
-    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");
+/* ---- Public API ---- */
+int web_server_start(uint16_t port,
+                     modbus_client_t *modbus,
+                     db_handle_t *db,
+                     app_config_t *config)
+{
+    memset(&g_ctx, 0, sizeof(g_ctx));
+    g_ctx.modbus = modbus;
+    g_ctx.db = db;
+    g_ctx.config = config;
+    g_ctx.running = true;
+    g_running = true;
+
+    static uint16_t s_port;
+    s_port = port;
+
+    int ret = pthread_create(&g_ctx.thread, NULL, web_thread_func, &s_port);
+    if (ret != 0) {
+        fprintf(stderr, "[WEB] Failed to create web thread: %s\n", strerror(ret));
+        g_running = false;
+        return -1;
     }
+    return 0;
+}
 
-    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;
+void web_server_stop(void)
+{
+    g_running = false;
+    g_ctx.running = false;
+    if (g_ctx.thread) {
+        pthread_join(g_ctx.thread, NULL);
     }
+    if (HTML_DASHBOARD) {
+        free((void *)HTML_DASHBOARD);
+        HTML_DASHBOARD = NULL;
+    }
+}
+
+/* ---- GET /api/devices ---- */
+static void handle_api_devices(struct mg_connection *c, struct mg_http_message *hm)
+{
+    mppt_device_data_t devices[MODBUS_MAX_DEVICES];
+    int count = 0;
+    modbus_get_all_data(g_ctx.modbus, devices, &count);
 
-    /* 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_AddNumberToObject(root, "total", count);
+    cJSON *arr = cJSON_AddArrayToObject(root, "devices");
+    for (int i = 0; i < count; i++) {
+        cJSON *dev = cJSON_CreateObject();
+        cJSON_AddNumberToObject(dev, "addr", devices[i].device_addr);
+        cJSON_AddBoolToObject(dev, "online", devices[i].online);
+        cJSON_AddNumberToObject(dev, "battery_voltage", devices[i].battery_voltage);
+        cJSON_AddNumberToObject(dev, "battery_soc", devices[i].battery_soc);
+        cJSON_AddNumberToObject(dev, "pv_power", devices[i].pv_input_power);
+        cJSON_AddNumberToObject(dev, "machine_temp", devices[i].machine_temp);
+        cJSON_AddStringToObject(dev, "fault_desc", modbus_fault_desc(devices[i].fault_code_raw));
+        cJSON_AddStringToObject(dev, "charge_state", modbus_charge_state_str(devices[i].charge_state_raw));
+        cJSON_AddStringToObject(dev, "battery_type", modbus_battery_type_str(devices[i].battery_type_raw));
+        cJSON_AddNumberToObject(dev, "software_version", devices[i].software_version_raw);
+        cJSON_AddItemToArray(arr, dev);
     }
-    cJSON_AddItemToObject(root, "records", records);
 
     char *json_str = cJSON_PrintUnformatted(root);
+    mg_http_reply(c, 200, "Content-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\n", "%s", json_str);
+    free(json_str);
     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\"}");
+}
+
+/* ---- GET /api/status ---- */
+static void handle_api_status(struct mg_connection *c, struct mg_http_message *hm)
+{
+    mppt_device_data_t devices[MODBUS_MAX_DEVICES];
+    int count = 0;
+    modbus_get_all_data(g_ctx.modbus, devices, &count);
+    int online_count = 0;
+    for (int i = 0; i < count; i++) {
+        if (devices[i].online) online_count++;
     }
+
+    cJSON *root = cJSON_CreateObject();
+    cJSON_AddStringToObject(root, "status", online_count > 0 ? "ok" : "degraded");
+    cJSON_AddBoolToObject(root, "modbus_connected", g_ctx.modbus->connected);
+    cJSON_AddNumberToObject(root, "device_total", count);
+    cJSON_AddNumberToObject(root, "device_online", online_count);
+    cJSON_AddNumberToObject(root, "db_records", db_get_record_count(g_ctx.db));
+
+    char *json_str = cJSON_PrintUnformatted(root);
+    mg_http_reply(c, 200, "Content-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\n", "%s", json_str);
+    free(json_str);
+    cJSON_Delete(root);
 }
 
-/* ── 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)
+/* ---- GET /api/health ---- */
+static void handle_api_health(struct mg_connection *c, struct mg_http_message *hm)
 {
-    ctx->mppt_data = mppt_data;
-    ctx->modbus_conn = conn;
-    ctx->db = db;
-    ctx->config = config;
+    mg_http_reply(c, 200, "Content-Type: application/json\r\n", "{\"status\":\"ok\"}");
 }
 
-/* ── Event Handler (mongoose-7.22 API) ─────────────────────────── */
-void web_event_handler(struct mg_connection *c, int ev, void *ev_data)
+/* ---- GET /api/history ---- */
+static void handle_api_history(struct mg_connection *c, struct mg_http_message *hm)
 {
-    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;
-
-    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);
-        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;
-    }
+    char start_time[64] = {0}, end_time[64] = {0};
+    char addr_str[16] = {0}, page_str[16] = {0}, size_str[16] = {0}, sort_str[16] = {0};
+
+    get_query_param(hm, "start_time", start_time, sizeof(start_time));
+    get_query_param(hm, "end_time", end_time, sizeof(end_time));
+    get_query_param(hm, "device_addr", addr_str, sizeof(addr_str));
+    get_query_param(hm, "page", page_str, sizeof(page_str));
+    get_query_param(hm, "page_size", size_str, sizeof(size_str));
+    get_query_param(hm, "sort", sort_str, sizeof(sort_str));
+
+    uint8_t device_addr = addr_str[0] ? (uint8_t)atoi(addr_str) : 0;
+    int page = page_str[0] ? atoi(page_str) : 1;
+    int page_size = size_str[0] ? atoi(size_str) : g_ctx.config->web_page_size;
+    bool sort_asc = (strcmp(sort_str, "asc") == 0);
+    if (page < 1) page = 1;
+    if (page_size < 1) page_size = 20;
+    if (page_size > 200) page_size = 200;
+
+    db_query_result_t result;
+    int rc = db_query_history(g_ctx.db, device_addr,
+                              start_time[0] ? start_time : NULL,
+                              end_time[0] ? end_time : NULL,
+                              page, page_size, sort_asc, &result);
 
-    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;
-        char *json = build_status_json(data, conn);
-        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\"}");
+    cJSON *root = cJSON_CreateObject();
+    if (rc != 0) {
+        cJSON_AddStringToObject(root, "error", "Query failed");
+    } else {
+        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 *arr = cJSON_AddArrayToObject(root, "records");
+        for (int i = 0; i < result.count; i++) {
+            db_history_record_t *r = &result.records[i];
+            cJSON *rec = cJSON_CreateObject();
+            cJSON_AddNumberToObject(rec, "id", r->id);
+            cJSON_AddStringToObject(rec, "timestamp", r->timestamp);
+            cJSON_AddNumberToObject(rec, "device_addr", r->device_addr);
+            cJSON_AddNumberToObject(rec, "battery_voltage", r->battery_voltage);
+            cJSON_AddNumberToObject(rec, "pv_voltage", r->pv_voltage);
+            cJSON_AddNumberToObject(rec, "pv_charge_current", r->pv_charge_current);
+            cJSON_AddNumberToObject(rec, "pv_input_power", r->pv_input_power);
+            cJSON_AddNumberToObject(rec, "cumulative_energy", r->cumulative_energy);
+            cJSON_AddNumberToObject(rec, "machine_temp", r->machine_temp);
+            cJSON_AddNumberToObject(rec, "fault_code", r->fault_code);
+            cJSON_AddStringToObject(rec, "fault_desc", modbus_fault_desc(r->fault_code));
+            cJSON_AddNumberToObject(rec, "battery_soc", r->battery_soc);
+            cJSON_AddNumberToObject(rec, "charge_state", r->charge_state);
+            cJSON_AddNumberToObject(rec, "device_type", r->device_type);
+            cJSON_AddNumberToObject(rec, "software_version", r->software_version);
+            cJSON_AddNumberToObject(rec, "battery_type", r->battery_type);
+            cJSON_AddStringToObject(rec, "battery_type_desc", modbus_battery_type_str(r->battery_type));
+            cJSON_AddNumberToObject(rec, "battery_level", r->battery_level);
+            cJSON_AddStringToObject(rec, "battery_level_desc", modbus_battery_level_str(r->battery_level));
+            cJSON_AddNumberToObject(rec, "charge_mode", r->charge_mode);
+            cJSON_AddStringToObject(rec, "charge_mode_desc", modbus_charge_mode_str(r->charge_mode));
+            cJSON_AddItemToArray(arr, rec);
         }
-        return;
-    }
-
-    if (mg_match(hm->uri, mg_str("/api/health"), NULL)) {
-        mg_http_reply(c, 200,
-                      "Content-Type: application/json\r\n",
-                      "{\"status\":\"ok\"}");
-        return;
     }
 
-    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",
-                      "%s", DASHBOARD_HTML);
-        return;
-    }
-
-    mg_http_reply(c, 404,
-                  "Content-Type: application/json\r\n",
-                  "{\"error\":\"Not Found\"}");
+    char *json_str = cJSON_PrintUnformatted(root);
+    mg_http_reply(c, 200, "Content-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\n", "%s", json_str);
+    free(json_str);
+    cJSON_Delete(root);
+    db_free_query_result(&result);
 }
 
-/* ── Start Listener ────────────────────────────────────────────── */
-struct mg_connection *web_server_start(struct mg_mgr *mgr, web_ctx_t *ctx)
+/* ---- GET /api/history/stats ---- */
+static void handle_api_history_stats(struct mg_connection *c, struct mg_http_message *hm)
 {
-    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 = WEB_SERVER_PORT;
-    if (env_port != NULL && env_port[0] != '\0') {
-        int parsed = atoi(env_port);
-        if (parsed > 0 && parsed < 65536) {
-            port = parsed;
-        }
-    }
-
-    snprintf(listen_url, sizeof(listen_url), "http://0.0.0.0:%d", port);
+    db_stats_t stats;
+    int rc = db_get_stats(g_ctx.db, &stats);
 
-    c = mg_http_listen(mgr, listen_url, web_event_handler, ctx);
-    if (c == NULL) {
-        fprintf(stderr, "[WEB] Failed to start HTTP listener on %s\n",
-                listen_url);
-        return NULL;
+    cJSON *root = cJSON_CreateObject();
+    if (rc != 0) {
+        cJSON_AddStringToObject(root, "error", "Stats query failed");
+    } else {
+        cJSON_AddNumberToObject(root, "total_records", stats.total_records);
+        cJSON_AddStringToObject(root, "first_record_time", stats.first_record_time);
+        cJSON_AddStringToObject(root, "last_record_time", stats.last_record_time);
+        cJSON_AddNumberToObject(root, "device_count", stats.device_count);
+        cJSON_AddNumberToObject(root, "max_pv_power", stats.max_pv_power);
+        cJSON_AddNumberToObject(root, "max_battery_voltage", stats.max_battery_voltage);
+        cJSON_AddNumberToObject(root, "max_temperature", stats.max_temperature);
     }
 
-    printf("[WEB] HTTP server listening on %s\n", listen_url);
-    return c;
+    char *json_str = cJSON_PrintUnformatted(root);
+    mg_http_reply(c, 200, "Content-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\n", "%s", json_str);
+    free(json_str);
+    cJSON_Delete(root);
 }

+ 26 - 34
mppt_monitor/src/web_server.h

@@ -1,56 +1,48 @@
 /**
  * @file web_server.h
- * @brief Mongoose HTTP server for MPPT data REST API
+ * @brief Mongoose 7.22 HTTP Web Server for MPPT Monitor
  *
- * Uses mongoose-7.22 API (mg_match, struct mg_str with char *buf).
- * Provides JSON REST endpoints for real-time and historical MPPT data.
+ * Provides REST API for real-time data, historical data,
+ * and an HTML dashboard for all MPPT devices.
  */
 
 #ifndef WEB_SERVER_H
 #define WEB_SERVER_H
 
+#include <stdint.h>
+#include <stdbool.h>
+#include <pthread.h>
 #include "modbus_client.h"
 #include "db.h"
 #include "config.h"
-#include "mongoose.h"
 
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-/* ── Configuration ─────────────────────────────────────────────── */
-#define WEB_SERVER_PORT       8085
-#define WEB_SERVER_LISTEN_URL "http://0.0.0.0:8085"
-
-/* ── Shared Data Context ───────────────────────────────────────── */
+/* ---- Web Server Context ---- */
 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 */
+    modbus_client_t *modbus;
+    db_handle_t     *db;
+    app_config_t    *config;
+    pthread_t        thread;
+    bool             running;
+    bool             web_thread_running;
+    pthread_t        web_watchdog_thread;
 } web_ctx_t;
 
-/* ── Public API ────────────────────────────────────────────────── */
-
 /**
- * Initialize the web server context.
+ * Start the web server.
+ * @param port HTTP port to listen on
+ * @param modbus Pointer to Modbus client
+ * @param db Pointer to database handle
+ * @param config Pointer to app configuration
+ * @return 0 on success, -1 on error
  */
-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);
+int web_server_start(uint16_t port,
+                     modbus_client_t *modbus,
+                     db_handle_t *db,
+                     app_config_t *config);
 
 /**
- * Start the Mongoose HTTP listener.
+ * Stop the web server.
  */
-struct mg_connection *web_server_start(struct mg_mgr *mgr, web_ctx_t *ctx);
-
-/**
- * Mongoose event handler callback.
- */
-void web_event_handler(struct mg_connection *c, int ev, void *ev_data);
-
-#ifdef __cplusplus
-}
-#endif
+void web_server_stop(void);
 
 #endif /* WEB_SERVER_H */

+ 344 - 0
mppt_monitor/www/index.html

@@ -0,0 +1,344 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>MPPT Solar Monitor - DM Series</title>
+<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
+.header{background:linear-gradient(135deg,#1e293b,#334155);padding:16px 24px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #475569}
+.header h1{font-size:20px;color:#38bdf8}
+.header .status{display:flex;gap:16px;align-items:center;font-size:13px}
+.header .status .dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:4px}
+.dot-green{background:#22c55e}.dot-red{background:#ef4444}.dot-yellow{background:#eab308}
+.tabs{display:flex;background:#1e293b;border-bottom:1px solid #334155}
+.tab{padding:12px 24px;cursor:pointer;font-size:14px;color:#94a3b8;border-bottom:2px solid transparent;transition:all .2s}
+.tab:hover{color:#e2e8f0}.tab.active{color:#38bdf8;border-bottom-color:#38bdf8}
+.page{display:none;padding:20px 24px}.page.active{display:block}
+/* Device Cards */
+.device-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:16px;margin-bottom:24px}
+.device-card{background:#1e293b;border-radius:12px;border:1px solid #334155;overflow:hidden}
+.device-card.offline{opacity:.6;border-color:#7f1d1d}
+.card-header{padding:12px 16px;background:#334155;display:flex;justify-content:space-between;align-items:center}
+.card-header h3{font-size:15px;color:#f1f5f9}
+.card-header .badge{padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
+.badge-online{background:#065f46;color:#6ee7b7}.badge-offline{background:#7f1d1d;color:#fca5a5}
+.card-body{padding:16px}
+.data-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
+.data-item{display:flex;justify-content:space-between;padding:6px 10px;background:#0f172a;border-radius:6px;font-size:13px}
+.data-item .label{color:#94a3b8}.data-item .value{color:#f1f5f9;font-weight:500}
+.section-title{font-size:13px;color:#38bdf8;margin:12px 0 8px;padding-bottom:4px;border-bottom:1px solid #334155}
+/* History Page */
+.history-controls{background:#1e293b;border-radius:12px;padding:16px;margin-bottom:20px;border:1px solid #334155}
+.controls-row{display:flex;flex-wrap:wrap;gap:12px;align-items:end}
+.control-group{display:flex;flex-direction:column;gap:4px}
+.control-group label{font-size:12px;color:#94a3b8}
+.control-group input,.control-group select{background:#0f172a;border:1px solid #475569;color:#e2e8f0;padding:8px 12px;border-radius:6px;font-size:13px}
+.btn{padding:8px 16px;border:none;border-radius:6px;cursor:pointer;font-size:13px;font-weight:500;transition:all .2s}
+.btn-primary{background:#0ea5e9;color:#fff}.btn-primary:hover{background:#0284c7}
+.btn-secondary{background:#334155;color:#e2e8f0}.btn-secondary:hover{background:#475569}
+.quick-btns{display:flex;gap:6px}
+.quick-btns .btn{padding:6px 10px;font-size:12px}
+/* Chart */
+.chart-container{background:#1e293b;border-radius:12px;padding:16px;margin-bottom:20px;border:1px solid #334155}
+.chart-wrapper{position:relative;height:300px}
+/* Table */
+.table-container{background:#1e293b;border-radius:12px;border:1px solid #334155;overflow:hidden}
+table{width:100%;border-collapse:collapse;font-size:13px}
+th{background:#334155;color:#94a3b8;padding:10px 12px;text-align:left;font-weight:500;white-space:nowrap}
+td{padding:8px 12px;border-top:1px solid #1e293b;white-space:nowrap}
+tr:hover td{background:#1e293b}
+.fault-normal{color:#22c55e}.fault-warning{color:#ef4444}
+/* Pagination */
+.pagination{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:#334155}
+.pagination .info{font-size:13px;color:#94a3b8}
+.pagination .btns{display:flex;gap:8px}
+/* Responsive */
+@media(max-width:768px){.device-grid{grid-template-columns:1fr}.controls-row{flex-direction:column}.data-grid{grid-template-columns:1fr}}
+</style>
+</head>
+<body>
+<div class="header">
+  <h1>MPPT Solar Monitor - DM Series</h1>
+  <div class="status">
+    <span><span class="dot dot-green" id="conn-dot"></span> <span id="conn-status">Connecting...</span></span>
+    <span>Devices: <strong id="dev-online">0</strong>/<strong id="dev-total">0</strong></span>
+    <span>Records: <strong id="db-records">0</strong></span>
+  </div>
+</div>
+<div class="tabs">
+  <div class="tab active" onclick="switchPage('realtime')">Real-time Monitor</div>
+  <div class="tab" onclick="switchPage('history')">History Query</div>
+</div>
+
+<!-- Real-time Page -->
+<div class="page active" id="page-realtime">
+  <div class="device-grid" id="device-grid"></div>
+</div>
+
+<!-- History Page -->
+<div class="page" id="page-history">
+  <div class="history-controls">
+    <div class="controls-row">
+      <div class="control-group">
+        <label>Device</label>
+        <select id="hist-device"><option value="0">All Devices</option></select>
+      </div>
+      <div class="control-group">
+        <label>Start Time</label>
+        <input type="datetime-local" id="hist-start">
+      </div>
+      <div class="control-group">
+        <label>End Time</label>
+        <input type="datetime-local" id="hist-end">
+      </div>
+      <div class="control-group">
+        <label>Page Size</label>
+        <select id="hist-size">
+          <option value="20">20</option><option value="50">50</option>
+          <option value="100">100</option><option value="200">200</option>
+        </select>
+      </div>
+      <div class="control-group">
+        <label>Sort</label>
+        <select id="hist-sort"><option value="desc">Newest First</option><option value="asc">Oldest First</option></select>
+      </div>
+      <div class="control-group">
+        <label>&nbsp;</label>
+        <button class="btn btn-primary" onclick="loadHistory(1)">Query</button>
+      </div>
+      <div class="control-group">
+        <label>Quick Range</label>
+        <div class="quick-btns">
+          <button class="btn btn-secondary" onclick="setQuickRange(1)">1h</button>
+          <button class="btn btn-secondary" onclick="setQuickRange(24)">24h</button>
+          <button class="btn btn-secondary" onclick="setQuickRange(168)">7d</button>
+          <button class="btn btn-secondary" onclick="setQuickRange(720)">30d</button>
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="chart-container">
+    <div class="chart-wrapper"><canvas id="history-chart"></canvas></div>
+  </div>
+  <div class="table-container">
+    <table><thead><tr>
+      <th>Time</th><th>Dev</th><th>Batt V</th><th>SOC</th><th>PV V</th><th>PV A</th>
+      <th>Power</th><th>Temp</th><th>Fault</th><th>Mode</th><th>Batt Type</th><th>Energy</th>
+    </tr></thead><tbody id="history-tbody"></tbody></table>
+    <div class="pagination">
+      <div class="info" id="hist-info">No data</div>
+      <div class="btns">
+        <button class="btn btn-secondary" onclick="loadHistory(histPage-1)" id="btn-prev" disabled>Prev</button>
+        <button class="btn btn-secondary" onclick="loadHistory(histPage+1)" id="btn-next" disabled>Next</button>
+      </div>
+    </div>
+  </div>
+</div>
+
+<script>
+let histPage = 1, histTotalPages = 0, historyChart = null;
+const faultDescs = {20:'Normal',30:'PV Overvoltage',31:'PV Overcurrent',32:'Battery Low',33:'Battery Overvoltage',38:'Temp High',50:'Temp Sensor Fault',88:'Not Decrypted'};
+
+function switchPage(name) {
+  document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
+  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+  document.getElementById('page-' + name).classList.add('active');
+  document.querySelectorAll('.tab')[name === 'realtime' ? 0 : 1].classList.add('active');
+}
+
+function fmtTime(ts) {
+  if (!ts) return '-';
+  return new Date(ts * 1000).toLocaleString('zh-CN', {hour12: false});
+}
+
+function renderDevices(data) {
+  const grid = document.getElementById('device-grid');
+  let onlineCount = 0;
+  let html = '';
+  data.devices.forEach(dev => {
+    if (dev.online) onlineCount++;
+    const inp = dev.input_registers || {};
+    const out = dev.output_registers || {};
+    html += `<div class="device-card ${dev.online ? '' : 'offline'}">
+      <div class="card-header">
+        <h3>Device #${dev.device_addr} ${dev.online ? '' : '(OFFLINE)'}</h3>
+        <span class="badge ${dev.online ? 'badge-online' : 'badge-offline'}">${dev.online ? 'ONLINE' : 'OFFLINE'}</span>
+      </div>
+      <div class="card-body">
+        <div class="section-title">Real-time Data (Input Registers)</div>
+        <div class="data-grid">
+          <div class="data-item"><span class="label">Battery Voltage</span><span class="value">${(inp.battery_voltage||0).toFixed(1)} V</span></div>
+          <div class="data-item"><span class="label">Battery SOC</span><span class="value">${inp.battery_soc||0} %</span></div>
+          <div class="data-item"><span class="label">PV Voltage</span><span class="value">${(inp.pv_voltage||0).toFixed(1)} V</span></div>
+          <div class="data-item"><span class="label">PV Current</span><span class="value">${(inp.pv_charge_current||0).toFixed(1)} A</span></div>
+          <div class="data-item"><span class="label">PV Power</span><span class="value">${(inp.pv_input_power||0).toFixed(1)} W</span></div>
+          <div class="data-item"><span class="label">Temperature</span><span class="value">${inp.machine_temp >= 0 ? inp.machine_temp + ' °C' : 'Fault'}</span></div>
+          <div class="data-item"><span class="label">Fault</span><span class="value ${inp.fault_code===20?'fault-normal':'fault-warning'}">${inp.fault_desc||'Unknown'}</span></div>
+          <div class="data-item"><span class="label">Charge State</span><span class="value">${inp.charge_state_desc||'-'}</span></div>
+          <div class="data-item"><span class="label">Cumulative Energy</span><span class="value">${(inp.cumulative_energy||0).toFixed(1)} kWh</span></div>
+          <div class="data-item"><span class="label">Software Ver</span><span class="value">v${inp.software_version||0}</span></div>
+        </div>
+        <div class="section-title">Configuration (Output Registers)</div>
+        <div class="data-grid">
+          <div class="data-item"><span class="label">Battery Type</span><span class="value">${out.battery_type_desc||'-'}</span></div>
+          <div class="data-item"><span class="label">Battery Level</span><span class="value">${out.battery_level_desc||'-'}</span></div>
+          <div class="data-item"><span class="label">Charge Mode</span><span class="value">${out.charge_mode_desc||'-'}</span></div>
+          <div class="data-item"><span class="label">Current Limit</span><span class="value">${out.pv_charge_current_limit||0} %</span></div>
+          <div class="data-item"><span class="label">Full Voltage</span><span class="value">${(out.batt_full_voltage||0).toFixed(1)} V</span></div>
+          <div class="data-item"><span class="label">Boost Voltage</span><span class="value">${(out.batt_boost_voltage||0).toFixed(1)} V</span></div>
+          <div class="data-item"><span class="label">Over-discharge V</span><span class="value">${(out.batt_over_discharge_v||0).toFixed(1)} V</span></div>
+          <div class="data-item"><span class="label">Recovery V</span><span class="value">${(out.batt_recovery_voltage||0).toFixed(1)} V</span></div>
+          <div class="data-item"><span class="label">485 Address</span><span class="value">${out.device_485_addr||'-'}</span></div>
+          <div class="data-item"><span class="label">Device Type</span><span class="value">${inp.device_type||'-'}</span></div>
+        </div>
+        <div style="margin-top:8px;font-size:11px;color:#64748b">Last update: ${fmtTime(dev.last_update)}</div>
+      </div>
+    </div>`;
+  });
+  grid.innerHTML = html;
+  document.getElementById('dev-online').textContent = onlineCount;
+  document.getElementById('dev-total').textContent = data.device_count;
+}
+
+async function fetchRealtime() {
+  try {
+    const res = await fetch('/api/data');
+    const data = await res.json();
+    renderDevices(data);
+    document.getElementById('conn-dot').className = 'dot dot-green';
+    document.getElementById('conn-status').textContent = 'Connected';
+    // Update device selector in history page
+    const sel = document.getElementById('hist-device');
+    if (sel.options.length <= 1) {
+      data.devices.forEach(d => {
+        const opt = document.createElement('option');
+        opt.value = d.device_addr;
+        opt.textContent = 'Device #' + d.device_addr;
+        sel.appendChild(opt);
+      });
+    }
+  } catch(e) {
+    document.getElementById('conn-dot').className = 'dot dot-red';
+    document.getElementById('conn-status').textContent = 'Disconnected';
+  }
+}
+
+async function fetchStatus() {
+  try {
+    const res = await fetch('/api/status');
+    const data = await res.json();
+    document.getElementById('db-records').textContent = data.db_records || 0;
+  } catch(e) {}
+}
+
+function setQuickRange(hours) {
+  const now = new Date();
+  const start = new Date(now.getTime() - hours * 3600000);
+  document.getElementById('hist-start').value = start.toISOString().slice(0, 16);
+  document.getElementById('hist-end').value = now.toISOString().slice(0, 16);
+  loadHistory(1);
+}
+
+async function loadHistory(page) {
+  if (page < 1) return;
+  if (histTotalPages > 0 && page > histTotalPages) return;
+  histPage = page;
+
+  const params = new URLSearchParams();
+  const addr = document.getElementById('hist-device').value;
+  const start = document.getElementById('hist-start').value;
+  const end = document.getElementById('hist-end').value;
+  const size = document.getElementById('hist-size').value;
+  const sort = document.getElementById('hist-sort').value;
+
+  if (addr !== '0') params.set('device_addr', addr);
+  if (start) params.set('start_time', start.replace('T', ' '));
+  if (end) params.set('end_time', end.replace('T', ' '));
+  params.set('page', page);
+  params.set('page_size', size);
+  params.set('sort', sort);
+
+  try {
+    const res = await fetch('/api/history?' + params.toString());
+    const data = await res.json();
+    histTotalPages = data.total_pages || 0;
+
+    // Update table
+    const tbody = document.getElementById('history-tbody');
+    tbody.innerHTML = '';
+    (data.records || []).forEach(r => {
+      const faultClass = r.fault_code === 20 ? 'fault-normal' : 'fault-warning';
+      tbody.innerHTML += `<tr>
+        <td>${r.timestamp}</td><td>#${r.device_addr}</td>
+        <td>${r.battery_voltage?.toFixed(1)} V</td><td>${r.battery_soc} %</td>
+        <td>${r.pv_voltage?.toFixed(1)} V</td><td>${r.pv_charge_current?.toFixed(1)} A</td>
+        <td>${r.pv_input_power?.toFixed(1)} W</td><td>${r.machine_temp >= 0 ? r.machine_temp + '°C' : 'Fault'}</td>
+        <td class="${faultClass}">${r.fault_desc}</td>
+        <td>${r.charge_mode_desc || '-'}</td>
+        <td>${r.battery_type_desc || '-'}</td>
+        <td>${r.cumulative_energy?.toFixed(1)} kWh</td>
+      </tr>`;
+    });
+
+    // Update pagination
+    document.getElementById('hist-info').textContent =
+      `Page ${data.page}/${data.total_pages} | Total ${data.total} records`;
+    document.getElementById('btn-prev').disabled = page <= 1;
+    document.getElementById('btn-next').disabled = page >= histTotalPages;
+
+    // Update chart
+    updateChart(data.records || []);
+  } catch(e) {
+    console.error('History fetch failed:', e);
+  }
+}
+
+function updateChart(records) {
+  const ctx = document.getElementById('history-chart').getContext('2d');
+  const labels = records.map(r => r.timestamp?.slice(5) || '').reverse();
+  const battV = records.map(r => r.battery_voltage).reverse();
+  const soc = records.map(r => r.battery_soc).reverse();
+  const power = records.map(r => r.pv_input_power).reverse();
+  const temp = records.map(r => r.machine_temp).reverse();
+
+  if (historyChart) historyChart.destroy();
+  historyChart = new Chart(ctx, {
+    type: 'line',
+    data: {
+      labels,
+      datasets: [
+        {label:'Battery (V)',data:battV,borderColor:'#3b82f6',backgroundColor:'rgba(59,130,246,.1)',yAxisID:'y',tension:.3,pointRadius:1},
+        {label:'SOC (%)',data:soc,borderColor:'#22c55e',backgroundColor:'rgba(34,197,94,.1)',yAxisID:'y1',tension:.3,pointRadius:1},
+        {label:'PV Power (W)',data:power,borderColor:'#f59e0b',backgroundColor:'rgba(245,158,11,.1)',yAxisID:'y',tension:.3,pointRadius:1},
+        {label:'Temp (°C)',data:temp,borderColor:'#ef4444',backgroundColor:'rgba(239,68,68,.1)',yAxisID:'y',tension:.3,pointRadius:1}
+      ]
+    },
+    options: {
+      responsive:true,maintainAspectRatio:false,
+      interaction:{mode:'index',intersect:false},
+      plugins:{legend:{labels:{color:'#94a3b8',font:{size:11}}}},
+      scales:{
+        x:{ticks:{color:'#64748b',maxTicksLimit:12,font:{size:10}},grid:{color:'#1e293b'}},
+        y:{type:'linear',position:'left',ticks:{color:'#64748b',font:{size:10}},grid:{color:'#1e293b'},title:{display:true,text:'V / W / °C',color:'#64748b'}},
+        y1:{type:'linear',position:'right',min:0,max:100,ticks:{color:'#64748b',font:{size:10}},grid:{drawOnChartArea:false},title:{display:true,text:'SOC %',color:'#64748b'}}
+      }
+    }
+  });
+}
+
+// Init
+fetchRealtime();
+fetchStatus();
+setInterval(fetchRealtime, 2000);
+setInterval(fetchStatus, 10000);
+// Set default time range
+const now = new Date();
+const start = new Date(now.getTime() - 24*3600000);
+document.getElementById('hist-end').value = now.toISOString().slice(0,16);
+document.getElementById('hist-start').value = start.toISOString().slice(0,16);
+</script>
+</body>
+</html>