Browse Source

feat: 多设备支持 - 同时监控多台MPPT设备,全量信息实时显示,设备卡片仪表盘

Coze-Commit-Type: user
Coze-User-ID: 256649704250362
Coze-Conversation-ID: 11640025
steven_roc 2 tháng trước cách đây
mục cha
commit
6c4d639181

+ 44 - 0
mppt_monitor/CHANGELOG.md

@@ -4,6 +4,50 @@
 
 ---
 
+## [1.2.0] - 2026-08-04
+
+### 新增:多设备支持与全量信息实时显示
+
+#### 多设备架构
+- **config.ini 多设备配置**:支持 `[device.N]` 格式定义多台设备(最多 MAX_DEVICES=16)
+  - 每个设备独立配置:name、host、port、slave_addr、enabled
+  - 向后兼容:无 `[device]` 段时自动从 `[modbus]` 段创建默认设备
+- **多线程轮询**:每台设备独立 pthread 线程,独立 Modbus 连接,互不干扰
+- **设备运行时状态**:`device_runtime_t` 结构体管理每设备的数据/锁/连接状态
+
+#### API 新增
+- `GET /api/devices` - 返回所有设备列表及实时数据
+- `GET /api/devices/<name>` - 返回指定设备的完整实时数据
+- `GET /api/history?device_name=xxx` - 按设备名称过滤历史数据
+
+#### 数据库更新
+- `history_data` 表新增 `device_name TEXT` 列
+- 历史查询支持按设备名称过滤
+- 自动迁移:旧数据库自动 ALTER TABLE 添加 device_name 列
+
+#### 前端仪表盘
+- **设备卡片网格**:所有设备以卡片形式展示,显示关键指标(电池电压/SOC/光伏功率/温度/状态)
+- **状态指示灯**:绿色=数据有效,红色=连接异常,灰色=未启用
+- **实时刷新**:2 秒自动轮询 `/api/devices` 更新所有设备数据
+- **设备详情展开**:点击卡片可展开查看完整寄存器数据和计算值
+
+#### 文件变更
+| 文件 | 变更类型 | 说明 |
+|------|----------|------|
+| `src/config.h` | 重写 | 新增 device_config_t、MAX_DEVICES、多设备解析 |
+| `src/config.c` | 重写 | 支持 [device.N] 段解析,向后兼容 |
+| `src/modbus_client.h` | 更新 | mppt_data_t 新增 device_id/device_name/device_type |
+| `src/modbus_client.c` | 更新 | 使用 conn->slave_addr 替代硬编码 |
+| `src/db.h` | 更新 | db_record_t 新增 device_name |
+| `src/db.c` | 更新 | 表结构新增 device_name 列,查询支持设备过滤 |
+| `src/web_server.h` | 重写 | web_ctx 改为设备数组,新增 device_runtime_t |
+| `src/web_server.c` | 重写 | 多设备 API、设备卡片仪表盘 |
+| `src/dashboard_html.h` | 新增 | 独立 HTML 仪表盘(设备网格+详情+历史) |
+| `src/main.c` | 重写 | 多设备线程管理 |
+| `config.ini` | 更新 | 多设备配置示例 |
+
+---
+
 ## [1.1.0] - 2026-08-04
 
 ### 新增:历史数据存储与查询

+ 88 - 5
mppt_monitor/TECHNICAL.md

@@ -441,11 +441,94 @@ Modbus 读取成功 → 更新共享数据 → 检查存储间隔
 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. **统计聚合**: 按小时/天/月聚合统计数据(平均、最大、最小)
 
 ---
 
-*最后更新: 2026-08-04 (v1.1.0)*
+## 八、多设备架构 (v1.2.0)
+
+### 8.1 架构设计
+
+```
+┌─────────────────────────────────────────────────────┐
+│                    main.c                           │
+│  ┌──────────┐  ┌──────────┐  ┌──────────┐          │
+│  │ Thread 1 │  │ Thread 2 │  │ Thread N │  ...      │
+│  │ MPPT-1   │  │ MPPT-2   │  │ MPPT-N   │          │
+│  │ modbus   │  │ modbus   │  │ modbus   │          │
+│  └────┬─────┘  └────┬─────┘  └────┬─────┘          │
+│       │              │              │                │
+│  ┌────▼──────────────▼──────────────▼────┐          │
+│  │     device_runtime_t[MAX_DEVICES]     │          │
+│  │     (共享数据 + pthread_mutex)         │          │
+│  └────┬──────────────┬──────────────┬────┘          │
+│       │              │              │                │
+│  ┌────▼──────────────▼──────────────▼────┐          │
+│  │         web_server (Mongoose)         │          │
+│  │  /api/devices  /api/devices/<name>    │          │
+│  │  /api/data     /api/history           │          │
+│  └───────────────────────────────────────┘          │
+│                                                     │
+│  ┌───────────────────────────────────────┐          │
+│  │     SQLite3 (device_name 过滤)        │          │
+│  └───────────────────────────────────────┘          │
+└─────────────────────────────────────────────────────┘
+```
+
+### 8.2 设备配置格式
+
+```ini
+[device]
+count = 3
+
+[device.1]
+name = MPPT-1
+host = discover.zhonjin.com
+port = 40635
+slave_addr = 1
+enabled = true
+
+[device.2]
+name = MPPT-2
+host = discover.zhonjin.com
+port = 40636
+slave_addr = 1
+enabled = true
+
+[device.3]
+name = MPPT-3
+host = 192.168.1.100
+port = 502
+slave_addr = 2
+enabled = false
+```
+
+### 8.3 多设备 API
+
+#### GET /api/devices
+返回所有设备列表及实时数据:
+```json
+{
+  "device_count": 2,
+  "timestamp": "2026-08-04 15:30:00",
+  "devices": [
+    { "name": "MPPT-1", "data_valid": true, ... },
+    { "name": "MPPT-2", "data_valid": true, ... }
+  ]
+}
+```
+
+#### GET /api/devices/<name>
+返回指定设备的完整实时数据。
+
+### 8.4 数据库设备隔离
+
+- `history_data` 表包含 `device_name` 列
+- 历史查询支持 `?device_name=MPPT-1` 过滤
+- 自动迁移:旧数据库自动添加 `device_name` 列
+
+---
+
+*最后更新: 2026-08-04 (v1.2.0)*

BIN
mppt_monitor/build/mppt_monitor


+ 33 - 16
mppt_monitor/config.ini

@@ -1,29 +1,46 @@
-# =============================================================================
-# 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 Monitor Configuration
+# Multi-device support: configure multiple MPPT controllers
 
-[modbus]
-# Modbus TCP device connection settings
+# ── Device Configuration ──────────────────────────────────────────
+# Each device gets its own [device.N] section (N = 1, 2, 3, ...)
+# If no [device.N] sections exist, falls back to [modbus] section below.
+
+[device]
+count = 1
+
+[device.1]
+name = MPPT-1
 host = discover.zhonjin.com
 port = 40635
-slave_addr = 0x01
+slave_addr = 1
+enabled = 1
+
+# ── Example: Add more devices ─────────────────────────────────────
+# [device.2]
+# name = MPPT-2
+# host = discover.zhonjin.com
+# port = 40636
+# slave_addr = 1
+# enabled = 1
+#
+# [device.3]
+# name = MPPT-3
+# host = 192.168.1.100
+# port = 502
+# slave_addr = 2
+# enabled = 1
+
+# ── Modbus Global Settings ────────────────────────────────────────
+[modbus]
 timeout_sec = 10
-# How often to poll the device for new data (milliseconds)
 poll_ms = 2000
 
+# ── Storage Settings ──────────────────────────────────────────────
 [storage]
-# How often to save data to the database (seconds)
-# Default: 10 seconds. Minimum: 1 second.
 interval_sec = 10
-# SQLite database file path (relative to working directory or absolute)
 db_path = mppt_data.db
 
+# ── Web Server Settings ───────────────────────────────────────────
 [web]
-# HTTP server port (default: 8085)
-# Note: In sandbox environment, DEPLOY_RUN_PORT env var overrides this.
 port = 8085
-# Default page size for history API pagination
 page_size = 50

BIN
mppt_monitor/mppt_data.db


+ 99 - 22
mppt_monitor/src/config.c

@@ -1,9 +1,9 @@
 /**
  * @file config.c
- * @brief Simple INI configuration parser for MPPT Monitor
+ * @brief INI configuration parser for MPPT Monitor
  *
- * Supports [section] and key=value pairs.
- * Lines starting with # or ; are comments.
+ * Supports multiple MPPT devices via [device.N] sections.
+ * Backward compatible: if no [device] section, uses [modbus] for single device.
  */
 
 #include "config.h"
@@ -16,9 +16,16 @@
 /* ── Default Values ────────────────────────────────────────────── */
 void config_set_defaults(app_config_t *cfg)
 {
-    strncpy(cfg->modbus_host, "discover.zhonjin.com", sizeof(cfg->modbus_host) - 1);
-    cfg->modbus_port = 40635;
-    cfg->modbus_slave_addr = 0x01;
+    memset(cfg, 0, sizeof(app_config_t));
+
+    /* Default single device */
+    cfg->device_count = 1;
+    strncpy(cfg->devices[0].name, "MPPT-1", MAX_DEVICE_NAME - 1);
+    strncpy(cfg->devices[0].host, "discover.zhonjin.com", sizeof(cfg->devices[0].host) - 1);
+    cfg->devices[0].port = 40635;
+    cfg->devices[0].slave_addr = 0x01;
+    cfg->devices[0].enabled = 1;
+
     cfg->modbus_timeout_sec = 10;
     cfg->modbus_poll_ms = 2000;
 
@@ -40,17 +47,59 @@ static char *trim(char *str)
     return str;
 }
 
+/* ── Helper: parse device section index from "device.N" ────────── */
+static int parse_device_index(const char *section)
+{
+    /* Match "device.N" where N is 1..MAX_DEVICES */
+    if (strncmp(section, "device.", 7) != 0) return -1;
+    int idx = atoi(section + 7);
+    if (idx < 1 || idx > MAX_DEVICES) return -1;
+    return idx - 1;  /* 0-based index */
+}
+
 /* ── Helper: set a config value by section + key ───────────────── */
 static void config_set_value(app_config_t *cfg, const char *section,
                               const char *key, const char *value)
 {
-    if (strcmp(section, "modbus") == 0) {
+    /* Check if this is a device.N section */
+    int dev_idx = parse_device_index(section);
+    if (dev_idx >= 0) {
+        device_config_t *dev = &cfg->devices[dev_idx];
+        if (strcmp(key, "name") == 0) {
+            strncpy(dev->name, value, MAX_DEVICE_NAME - 1);
+        } else if (strcmp(key, "host") == 0) {
+            strncpy(dev->host, value, sizeof(dev->host) - 1);
+        } else if (strcmp(key, "port") == 0) {
+            dev->port = atoi(value);
+        } else if (strcmp(key, "slave_addr") == 0) {
+            dev->slave_addr = (int)strtol(value, NULL, 0);
+        } else if (strcmp(key, "enabled") == 0) {
+            dev->enabled = atoi(value);
+        }
+        /* Track max device index for count */
+        if (dev_idx + 1 > cfg->device_count) {
+            cfg->device_count = dev_idx + 1;
+        }
+        return;
+    }
+
+    /* Global sections */
+    if (strcmp(section, "device") == 0) {
+        if (strcmp(key, "count") == 0) {
+            /* Override count if explicitly set */
+            int c = atoi(value);
+            if (c >= 1 && c <= MAX_DEVICES) {
+                cfg->device_count = c;
+            }
+        }
+    } else if (strcmp(section, "modbus") == 0) {
+        /* Backward compatibility: [modbus] section sets device[0] */
         if (strcmp(key, "host") == 0) {
-            strncpy(cfg->modbus_host, value, sizeof(cfg->modbus_host) - 1);
+            strncpy(cfg->devices[0].host, value, sizeof(cfg->devices[0].host) - 1);
         } else if (strcmp(key, "port") == 0) {
-            cfg->modbus_port = atoi(value);
+            cfg->devices[0].port = atoi(value);
         } else if (strcmp(key, "slave_addr") == 0) {
-            cfg->modbus_slave_addr = (int)strtol(value, NULL, 0);
+            cfg->devices[0].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) {
@@ -86,6 +135,7 @@ int config_load(app_config_t *cfg, const char *filepath)
 
     char line[1024];
     char section[256] = "";
+    int has_device_sections = 0;
 
     while (fgets(line, sizeof(line), fp)) {
         char *trimmed = trim(line);
@@ -102,6 +152,9 @@ int config_load(app_config_t *cfg, const char *filepath)
                 *end = '\0';
                 strncpy(section, trimmed + 1, sizeof(section) - 1);
                 section[sizeof(section) - 1] = '\0';
+                if (parse_device_index(section) >= 0) {
+                    has_device_sections = 1;
+                }
             }
             continue;
         }
@@ -117,6 +170,29 @@ int config_load(app_config_t *cfg, const char *filepath)
     }
 
     fclose(fp);
+
+    /* If no device.N sections found, reset to single device from [modbus] */
+    if (!has_device_sections) {
+        cfg->device_count = 1;
+        cfg->devices[0].enabled = 1;
+        if (cfg->devices[0].name[0] == '\0') {
+            strncpy(cfg->devices[0].name, "MPPT-1", MAX_DEVICE_NAME - 1);
+        }
+    }
+
+    /* Ensure all devices have names */
+    for (int i = 0; i < cfg->device_count; i++) {
+        if (cfg->devices[i].name[0] == '\0') {
+            snprintf(cfg->devices[i].name, MAX_DEVICE_NAME, "MPPT-%d", i + 1);
+        }
+        if (cfg->devices[i].port == 0) {
+            cfg->devices[i].port = 40635;
+        }
+        if (cfg->devices[i].slave_addr == 0) {
+            cfg->devices[i].slave_addr = 1;
+        }
+    }
+
     printf("[CONFIG] Loaded configuration from '%s'\n", filepath);
     return 0;
 }
@@ -125,17 +201,18 @@ int config_load(app_config_t *cfg, const char *filepath)
 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] Devices: %d\n", cfg->device_count);
+    for (int i = 0; i < cfg->device_count; i++) {
+        const device_config_t *dev = &cfg->devices[i];
+        printf("[CONFIG]   [%d] %-12s  %s:%d  addr=%d  %s\n",
+               i + 1, dev->name, dev->host, dev->port,
+               dev->slave_addr, dev->enabled ? "ON" : "OFF");
+    }
+    printf("[CONFIG] Modbus: timeout=%ds  poll=%dms\n",
+           cfg->modbus_timeout_sec, cfg->modbus_poll_ms);
+    printf("[CONFIG] Storage: interval=%ds  db=%s\n",
+           cfg->storage_interval_sec, cfg->db_path);
+    printf("[CONFIG] Web: port=%d  page_size=%d\n",
+           cfg->web_port, cfg->web_page_size);
     printf("[CONFIG] =====================\n");
 }

+ 27 - 12
mppt_monitor/src/config.h

@@ -2,7 +2,8 @@
  * @file config.h
  * @brief INI configuration file parser for MPPT Monitor
  *
- * Reads config.ini for runtime settings (storage interval, DB path, etc.)
+ * Supports multiple MPPT devices configuration.
+ * Backward compatible with single-device config format.
  */
 
 #ifndef CONFIG_H
@@ -14,29 +15,43 @@
 extern "C" {
 #endif
 
-/* ── Configuration Structure ───────────────────────────────────── */
+/* ── Limits ────────────────────────────────────────────────────── */
+#define MAX_DEVICES         16      /* Max number of MPPT devices */
+#define MAX_DEVICE_NAME     64      /* Max device name length */
+
+/* ── Single Device Configuration ───────────────────────────────── */
+typedef struct {
+    char    name[MAX_DEVICE_NAME];  /* Device display name */
+    char    host[256];              /* Modbus TCP host */
+    int     port;                   /* Modbus TCP port */
+    int     slave_addr;             /* Modbus slave address (1-247) */
+    int     enabled;                /* 1=enabled, 0=disabled */
+} device_config_t;
+
+/* ── Application Configuration ─────────────────────────────────── */
 typedef struct {
-    /* Modbus settings */
-    char  modbus_host[256];
-    int   modbus_port;
-    int   modbus_slave_addr;
+    /* Devices */
+    device_config_t devices[MAX_DEVICES];
+    int             device_count;       /* Number of configured devices */
+
+    /* Modbus global settings */
     int   modbus_timeout_sec;
-    int   modbus_poll_ms;         /* Modbus read interval (ms) */
+    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 */
+    int   storage_interval_sec;       /* Data storage interval (seconds) */
+    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 */
+    int   web_port;                   /* HTTP server port */
+    int   web_page_size;              /* Default page size for history API */
 } app_config_t;
 
 /* ── Public API ────────────────────────────────────────────────── */
 
 /**
  * Load configuration from INI file.
- * If file doesn't exist, loads defaults.
+ * If file doesn't exist, loads defaults (single device from [modbus] section).
  * @param cfg       Pointer to config struct to fill
  * @param filepath  Path to config.ini
  * @return 0 on success, -1 on error (defaults still loaded)

+ 356 - 0
mppt_monitor/src/dashboard_html.h

@@ -0,0 +1,356 @@
+/**
+ * @file dashboard_html.h
+ * @brief Embedded HTML dashboard for MPPT Monitor
+ *
+ * Multi-device real-time monitoring with history charts.
+ * Auto-refreshes every 2 seconds.
+ */
+
+#ifndef DASHBOARD_HTML_H
+#define DASHBOARD_HTML_H
+
+static inline const char *web_get_dashboard_html(void)
+{
+    return
+"<!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 - Multi-Device</title>\n"
+"<script src=\"https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.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"
+".header { background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);\n"
+"  padding: 16px 24px; border-bottom: 1px solid #334155;\n"
+"  display: flex; justify-content: space-between; align-items: center; }\n"
+".header h1 { font-size: 20px; color: #f59e0b; }\n"
+".header .status { font-size: 13px; color: #94a3b8; }\n"
+".tabs { display: flex; gap: 0; background: #1e293b; border-bottom: 1px solid #334155; }\n"
+".tab { padding: 10px 24px; cursor: pointer; color: #94a3b8; font-size: 14px;\n"
+"  border-bottom: 2px solid transparent; transition: all 0.2s; }\n"
+".tab:hover { color: #e2e8f0; }\n"
+".tab.active { color: #f59e0b; border-bottom-color: #f59e0b; }\n"
+".page { display: none; padding: 20px; }\n"
+".page.active { display: block; }\n"
+".devices-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));\n"
+"  gap: 16px; margin-bottom: 20px; }\n"
+".device-card { background: #1e293b; border: 1px solid #334155; border-radius: 12px;\n"
+"  padding: 20px; transition: border-color 0.3s; }\n"
+".device-card.online { border-color: #22c55e; }\n"
+".device-card.offline { border-color: #ef4444; }\n"
+".device-header { display: flex; justify-content: space-between; align-items: center;\n"
+"  margin-bottom: 16px; }\n"
+".device-name { font-size: 18px; font-weight: 600; color: #f8fafc; }\n"
+".device-status { font-size: 12px; padding: 3px 10px; border-radius: 12px;\n"
+"  font-weight: 500; }\n"
+".device-status.online { background: #22c55e20; color: #22c55e; }\n"
+".device-status.offline { background: #ef444420; color: #ef4444; }\n"
+".data-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }\n"
+".data-item { background: #0f172a; border-radius: 8px; padding: 10px 12px; }\n"
+".data-label { font-size: 11px; color: #64748b; text-transform: uppercase;\n"
+"  letter-spacing: 0.5px; margin-bottom: 4px; }\n"
+".data-value { font-size: 20px; font-weight: 700; color: #f8fafc; }\n"
+".data-value .unit { font-size: 12px; color: #94a3b8; font-weight: 400; }\n"
+".data-item.full { grid-column: 1 / -1; }\n"
+".soc-bar { height: 8px; background: #334155; border-radius: 4px; margin-top: 6px;\n"
+"  overflow: hidden; }\n"
+".soc-fill { height: 100%; border-radius: 4px; transition: width 0.5s;\n"
+"  background: linear-gradient(90deg, #22c55e, #84cc16); }\n"
+".charge-badge { display: inline-block; padding: 2px 8px; border-radius: 4px;\n"
+"  font-size: 12px; font-weight: 500; }\n"
+".charge-badge.charging { background: #f59e0b20; color: #f59e0b; }\n"
+".charge-badge.idle { background: #64748b20; color: #94a3b8; }\n"
+".history-controls { background: #1e293b; border-radius: 12px; padding: 20px;\n"
+"  margin-bottom: 20px; border: 1px solid #334155; }\n"
+".controls-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }\n"
+".control-group { display: flex; flex-direction: column; gap: 4px; }\n"
+".control-group label { font-size: 12px; color: #94a3b8; }\n"
+".control-group input, .control-group select { background: #0f172a;\n"
+"  border: 1px solid #334155; border-radius: 6px; padding: 8px 12px;\n"
+"  color: #e2e8f0; font-size: 13px; }\n"
+".btn { padding: 8px 20px; border-radius: 6px; border: none; cursor: pointer;\n"
+"  font-size: 13px; font-weight: 500; transition: all 0.2s; }\n"
+".btn-primary { background: #f59e0b; color: #0f172a; }\n"
+".btn-primary:hover { background: #d97706; }\n"
+".btn-secondary { background: #334155; color: #e2e8f0; }\n"
+".btn-secondary:hover { background: #475569; }\n"
+".chart-container { background: #1e293b; border-radius: 12px; padding: 20px;\n"
+"  margin-bottom: 20px; border: 1px solid #334155; }\n"
+".chart-container h3 { color: #f59e0b; margin-bottom: 12px; font-size: 15px; }\n"
+".chart-wrapper { position: relative; height: 300px; }\n"
+".table-container { background: #1e293b; border-radius: 12px; padding: 20px;\n"
+"  border: 1px solid #334155; overflow-x: auto; }\n"
+"table { width: 100%; border-collapse: collapse; font-size: 13px; }\n"
+"th { background: #0f172a; color: #94a3b8; padding: 10px 12px; text-align: left;\n"
+"  font-weight: 500; text-transform: uppercase; font-size: 11px;\n"
+"  letter-spacing: 0.5px; }\n"
+"td { padding: 10px 12px; border-bottom: 1px solid #1e293b; color: #e2e8f0; }\n"
+"tr:hover td { background: #0f172a80; }\n"
+".pagination { display: flex; justify-content: center; gap: 8px;\n"
+"  margin-top: 16px; align-items: center; }\n"
+".pagination button { padding: 6px 14px; border-radius: 6px; border: 1px solid #334155;\n"
+"  background: #0f172a; color: #e2e8f0; cursor: pointer; font-size: 13px; }\n"
+".pagination button:hover:not(:disabled) { background: #334155; }\n"
+".pagination button:disabled { opacity: 0.4; cursor: not-allowed; }\n"
+".pagination .page-info { color: #94a3b8; font-size: 13px; }\n"
+".update-time { text-align: center; color: #64748b; font-size: 12px;\n"
+"  margin-top: 12px; }\n"
+"@media (max-width: 768px) {\n"
+"  .devices-grid { grid-template-columns: 1fr; }\n"
+"  .controls-row { flex-direction: column; }\n"
+"  .data-grid { grid-template-columns: 1fr; }\n"
+"}\n"
+"</style>\n"
+"</head>\n"
+"<body>\n"
+"<div class=\"header\">\n"
+"  <h1>MPPT Solar Monitor</h1>\n"
+"  <div class=\"status\" id=\"update-status\">Connecting...</div>\n"
+"</div>\n"
+"<div class=\"tabs\">\n"
+"  <div class=\"tab active\" onclick=\"switchPage('realtime')\">Real-time</div>\n"
+"  <div class=\"tab\" onclick=\"switchPage('history')\">History</div>\n"
+"</div>\n"
+
+"<!-- Real-time Page -->\n"
+"<div class=\"page active\" id=\"page-realtime\">\n"
+"  <div class=\"devices-grid\" id=\"devices-grid\"></div>\n"
+"  <div class=\"update-time\" id=\"rt-update-time\"></div>\n"
+"</div>\n"
+
+"<!-- History Page -->\n"
+"<div class=\"page\" id=\"page-history\">\n"
+"  <div class=\"history-controls\">\n"
+"    <div class=\"controls-row\">\n"
+"      <div class=\"control-group\">\n"
+"        <label>Device</label>\n"
+"        <select id=\"hist-device\"><option value=\"\">All Devices</option></select>\n"
+"      </div>\n"
+"      <div class=\"control-group\">\n"
+"        <label>Start</label>\n"
+"        <input type=\"datetime-local\" id=\"hist-start\">\n"
+"      </div>\n"
+"      <div class=\"control-group\">\n"
+"        <label>End</label>\n"
+"        <input type=\"datetime-local\" id=\"hist-end\">\n"
+"      </div>\n"
+"      <div class=\"control-group\">\n"
+"        <label>Page Size</label>\n"
+"        <select id=\"hist-pagesize\">\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=\"loadHistory()\">Query</button>\n"
+"      <button class=\"btn btn-secondary\" onclick=\"setQuickRange(1)\">1h</button>\n"
+"      <button class=\"btn btn-secondary\" onclick=\"setQuickRange(24)\">24h</button>\n"
+"      <button class=\"btn btn-secondary\" onclick=\"setQuickRange(168)\">7d</button>\n"
+"    </div>\n"
+"  </div>\n"
+"  <div class=\"chart-container\">\n"
+"    <h3>Trend Chart</h3>\n"
+"    <div class=\"chart-wrapper\"><canvas id=\"historyChart\"></canvas></div>\n"
+"  </div>\n"
+"  <div class=\"table-container\">\n"
+"    <table><thead><tr>\n"
+"      <th>Time</th><th>Device</th><th>Battery(V)</th><th>SOC(%)</th>\n"
+"      <th>PV(V)</th><th>Current(A)</th><th>Power(W)</th>\n"
+"      <th>Temp(C)</th><th>Energy(kWh)</th><th>Fault</th><th>State</th>\n"
+"    </tr></thead><tbody id=\"history-tbody\"></tbody></table>\n"
+"    <div class=\"pagination\">\n"
+"      <button id=\"btn-prev\" onclick=\"historyPrev()\" disabled>Prev</button>\n"
+"      <span class=\"page-info\" id=\"page-info\">-</span>\n"
+"      <button id=\"btn-next\" onclick=\"historyNext()\" disabled>Next</button>\n"
+"    </div>\n"
+"  </div>\n"
+"</div>\n"
+
+"<script>\n"
+"let historyChart = null;\n"
+"let currentPage = 1;\n"
+"let totalPages = 1;\n"
+"let deviceNames = [];\n"
+"const CHARGE_STATES = ['Idle','Charging(Bulk)','Charging(Absorb)','Charging(Float)'];\n"
+"\n"
+"function switchPage(name) {\n"
+"  document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));\n"
+"  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n"
+"  document.getElementById('page-' + name).classList.add('active');\n"
+"  document.querySelectorAll('.tab')[name === 'realtime' ? 0 : 1].classList.add('active');\n"
+"  if (name === 'history' && !historyChart) initChart();\n"
+"}\n"
+"\n"
+"function renderDevices(data) {\n"
+"  const grid = document.getElementById('devices-grid');\n"
+"  if (!data.devices) return;\n"
+"  deviceNames = data.devices.map(d => d.name);\n"
+"  updateDeviceSelect();\n"
+"  let html = '';\n"
+"  data.devices.forEach(d => {\n"
+"    const online = d.data_valid && d.conn_state === 'connected';\n"
+"    const socColor = d.battery.soc > 80 ? '#22c55e' : d.battery.soc > 40 ? '#f59e0b' : '#ef4444';\n"
+"    const charging = d.device.charge_state > 0;\n"
+"    html += '<div class=\"device-card ' + (online ? 'online' : 'offline') + '\">';\n"
+"    html += '<div class=\"device-header\">';\n"
+"    html += '<span class=\"device-name\">' + d.name + '</span>';\n"
+"    html += '<span class=\"device-status ' + (online ? 'online' : 'offline') + '\">';\n"
+"    html += (online ? 'ONLINE' : 'OFFLINE') + '</span></div>';\n"
+"    html += '<div class=\"data-grid\">';\n"
+"    html += '<div class=\"data-item\"><div class=\"data-label\">Battery</div>';\n"
+"    html += '<div class=\"data-value\">' + d.battery.voltage.toFixed(1) + ' <span class=\"unit\">V</span></div>';\n"
+"    html += '<div class=\"soc-bar\"><div class=\"soc-fill\" style=\"width:' + d.battery.soc + '%;background:' + socColor + '\"></div></div></div>';\n"
+"    html += '<div class=\"data-item\"><div class=\"data-label\">SOC</div>';\n"
+"    html += '<div class=\"data-value\">' + d.battery.soc + ' <span class=\"unit\">%</span></div></div>';\n"
+"    html += '<div class=\"data-item\"><div class=\"data-label\">PV Voltage</div>';\n"
+"    html += '<div class=\"data-value\">' + d.pv.voltage.toFixed(1) + ' <span class=\"unit\">V</span></div></div>';\n"
+"    html += '<div class=\"data-item\"><div class=\"data-label\">PV Current</div>';\n"
+"    html += '<div class=\"data-value\">' + d.pv.current.toFixed(1) + ' <span class=\"unit\">A</span></div></div>';\n"
+"    html += '<div class=\"data-item\"><div class=\"data-label\">PV Power</div>';\n"
+"    html += '<div class=\"data-value\">' + d.pv.power.toFixed(1) + ' <span class=\"unit\">W</span></div></div>';\n"
+"    html += '<div class=\"data-item\"><div class=\"data-label\">Temperature</div>';\n"
+"    html += '<div class=\"data-value\">' + d.device.temperature.toFixed(0) + ' <span class=\"unit\">&deg;C</span></div></div>';\n"
+"    html += '<div class=\"data-item\"><div class=\"data-label\">Energy</div>';\n"
+"    html += '<div class=\"data-value\">' + d.cumulative_energy_kwh.toFixed(1) + ' <span class=\"unit\">kWh</span></div></div>';\n"
+"    html += '<div class=\"data-item\"><div class=\"data-label\">Status</div>';\n"
+"    html += '<span class=\"charge-badge ' + (charging ? 'charging' : 'idle') + '\">';\n"
+"    html += (charging ? d.device.charge_state_desc : 'Idle') + '</span>';\n"
+"    html += ' <span style=\"font-size:11px;color:#64748b\">' + d.device.fault_desc + '</span></div>';\n"
+"    html += '</div></div>';\n"
+"  });\n"
+"  grid.innerHTML = html;\n"
+"  document.getElementById('rt-update-time').textContent = 'Updated: ' + new Date().toLocaleTimeString();\n"
+"  document.getElementById('update-status').textContent = data.device_count + ' device(s) | ' + new Date().toLocaleTimeString();\n"
+"}\n"
+"\n"
+"function updateDeviceSelect() {\n"
+"  const sel = document.getElementById('hist-device');\n"
+"  const current = sel.value;\n"
+"  sel.innerHTML = '<option value=\"\">All Devices</option>';\n"
+"  deviceNames.forEach(n => {\n"
+"    sel.innerHTML += '<option value=\"' + n + '\">' + n + '</option>';\n"
+"  });\n"
+"  sel.value = current;\n"
+"}\n"
+"\n"
+"function fetchDevices() {\n"
+"  fetch('/api/devices').then(r => r.json()).then(data => {\n"
+"    renderDevices(data);\n"
+"  }).catch(e => {\n"
+"    document.getElementById('update-status').textContent = 'Error: ' + e.message;\n"
+"  });\n"
+"}\n"
+"\n"
+"function initChart() {\n"
+"  const ctx = document.getElementById('historyChart').getContext('2d');\n"
+"  historyChart = new Chart(ctx, {\n"
+"    type: 'line',\n"
+"    data: { datasets: [] },\n"
+"    options: {\n"
+"      responsive: true, maintainAspectRatio: false,\n"
+"      interaction: { mode: 'index', intersect: false },\n"
+"      scales: {\n"
+"        x: { type: 'time', ticks: { color: '#94a3b8' }, grid: { color: '#334155' } },\n"
+"        y: { ticks: { color: '#94a3b8' }, grid: { color: '#33415580' } }\n"
+"      },\n"
+"      plugins: { legend: { labels: { color: '#e2e8f0' } } }\n"
+"    }\n"
+"  });\n"
+"}\n"
+"\n"
+"function setQuickRange(hours) {\n"
+"  const end = new Date();\n"
+"  const start = new Date(end - hours * 3600000);\n"
+"  document.getElementById('hist-start').value = start.toISOString().slice(0, 16);\n"
+"  document.getElementById('hist-end').value = end.toISOString().slice(0, 16);\n"
+"  loadHistory();\n"
+"}\n"
+"\n"
+"function loadHistory(page) {\n"
+"  if (page) currentPage = page;\n"
+"  const device = document.getElementById('hist-device').value;\n"
+"  const start = document.getElementById('hist-start').value;\n"
+"  const end = document.getElementById('hist-end').value;\n"
+"  const pageSize = document.getElementById('hist-pagesize').value;\n"
+"  let url = '/api/history?page=' + currentPage + '&page_size=' + pageSize;\n"
+"  if (device) url += '&device=' + encodeURIComponent(device);\n"
+"  if (start) url += '&start_time=' + start.replace('T', ' ');\n"
+"  if (end) url += '&end_time=' + end.replace('T', ' ');\n"
+"  fetch(url).then(r => r.json()).then(data => {\n"
+"    totalPages = data.total_pages || 1;\n"
+"    document.getElementById('page-info').textContent =\n"
+"      'Page ' + data.page + '/' + totalPages + ' (' + data.total + ' records)';\n"
+"    document.getElementById('btn-prev').disabled = data.page <= 1;\n"
+"    document.getElementById('btn-next').disabled = data.page >= totalPages;\n"
+"    renderHistoryTable(data.records);\n"
+"    renderHistoryChart(data.records);\n"
+"  });\n"
+"}\n"
+"\n"
+"function historyPrev() { if (currentPage > 1) loadHistory(currentPage - 1); }\n"
+"function historyNext() { if (currentPage < totalPages) loadHistory(currentPage + 1); }\n"
+"\n"
+"function renderHistoryTable(records) {\n"
+"  const tbody = document.getElementById('history-tbody');\n"
+"  let html = '';\n"
+"  records.forEach(r => {\n"
+"    html += '<tr>';\n"
+"    html += '<td>' + r.timestamp + '</td>';\n"
+"    html += '<td>' + (r.device_name || '-') + '</td>';\n"
+"    html += '<td>' + r.battery_voltage.toFixed(1) + '</td>';\n"
+"    html += '<td>' + r.battery_soc + '</td>';\n"
+"    html += '<td>' + r.pv_voltage.toFixed(1) + '</td>';\n"
+"    html += '<td>' + r.pv_charge_current.toFixed(1) + '</td>';\n"
+"    html += '<td>' + r.pv_input_power.toFixed(1) + '</td>';\n"
+"    html += '<td>' + r.machine_temp.toFixed(0) + '</td>';\n"
+"    html += '<td>' + r.cumulative_energy.toFixed(1) + '</td>';\n"
+"    html += '<td>' + r.fault_desc + '</td>';\n"
+"    html += '<td>' + (r.charge_state_desc || '-') + '</td>';\n"
+"    html += '</tr>';\n"
+"  });\n"
+"  tbody.innerHTML = html || '<tr><td colspan=\"11\" style=\"text-align:center;color:#64748b\">No data</td></tr>';\n"
+"}\n"
+"\n"
+"function renderHistoryChart(records) {\n"
+"  if (!historyChart) return;\n"
+"  const datasets = [];\n"
+"  const deviceGroups = {};\n"
+"  records.forEach(r => {\n"
+"    const name = r.device_name || 'Default';\n"
+"    if (!deviceGroups[name]) deviceGroups[name] = [];\n"
+"    deviceGroups[name].push(r);\n"
+"  });\n"
+"  const colors = ['#f59e0b','#22c55e','#3b82f6','#ef4444','#8b5cf6','#ec4899'];\n"
+"  let ci = 0;\n"
+"  for (const [name, recs] of Object.entries(deviceGroups)) {\n"
+"    const color = colors[ci % colors.length]; ci++;\n"
+"    datasets.push({\n"
+"      label: name + ' Battery(V)',\n"
+"      data: recs.map(r => ({ x: new Date(r.timestamp_unix * 1000), y: r.battery_voltage })),\n"
+"      borderColor: color, backgroundColor: color + '20',\n"
+"      tension: 0.3, pointRadius: 2, borderWidth: 2\n"
+"    });\n"
+"    datasets.push({\n"
+"      label: name + ' Power(W)',\n"
+"      data: recs.map(r => ({ x: new Date(r.timestamp_unix * 1000), y: r.pv_input_power })),\n"
+"      borderColor: color + '80', backgroundColor: 'transparent',\n"
+"      tension: 0.3, pointRadius: 1, borderWidth: 1, borderDash: [5, 3]\n"
+"    });\n"
+"  }\n"
+"  historyChart.data.datasets = datasets;\n"
+"  historyChart.update();\n"
+"}\n"
+"\n"
+"/* Init */\n"
+"fetchDevices();\n"
+"setInterval(fetchDevices, 2000);\n"
+"</script>\n"
+"</body>\n"
+"</html>\n";
+}
+
+#endif /* DASHBOARD_HTML_H */

+ 70 - 43
mppt_monitor/src/db.c

@@ -16,6 +16,7 @@ static int db_create_tables(db_handle_t *handle)
         "CREATE TABLE IF NOT EXISTS mppt_history ("
         "  id INTEGER PRIMARY KEY AUTOINCREMENT,"
         "  timestamp INTEGER NOT NULL,"
+        "  device_name TEXT DEFAULT '',"
         "  battery_voltage REAL,"
         "  pv_voltage REAL,"
         "  pv_charge_current REAL,"
@@ -28,7 +29,8 @@ static int db_create_tables(db_handle_t *handle)
         "  device_type INTEGER,"
         "  pv_input_power REAL"
         ");"
-        "CREATE INDEX IF NOT EXISTS idx_timestamp ON mppt_history(timestamp);";
+        "CREATE INDEX IF NOT EXISTS idx_timestamp ON mppt_history(timestamp);"
+        "CREATE INDEX IF NOT EXISTS idx_device ON mppt_history(device_name);";
 
     char *errmsg = NULL;
     int rc = sqlite3_exec(handle->db, sql, NULL, NULL, &errmsg);
@@ -37,6 +39,12 @@ static int db_create_tables(db_handle_t *handle)
         sqlite3_free(errmsg);
         return -1;
     }
+
+    /* Migration: add device_name column if it doesn't exist (for existing DBs) */
+    sqlite3_exec(handle->db,
+        "ALTER TABLE mppt_history ADD COLUMN device_name TEXT DEFAULT '';",
+        NULL, NULL, NULL);  /* Ignore error if column already exists */
+
     return 0;
 }
 
@@ -83,10 +91,10 @@ int db_insert_record(db_handle_t *handle, const mppt_data_t *data)
 {
     const char *sql =
         "INSERT INTO mppt_history "
-        "(timestamp, battery_voltage, pv_voltage, pv_charge_current, "
+        "(timestamp, device_name, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+        "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
 
     sqlite3_stmt *stmt = NULL;
     int rc = sqlite3_prepare_v2(handle->db, sql, -1, &stmt, NULL);
@@ -97,17 +105,18 @@ int db_insert_record(db_handle_t *handle, const mppt_data_t *data)
     }
 
     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);
+    sqlite3_bind_text(stmt, 2, data->device_name, -1, SQLITE_STATIC);
+    sqlite3_bind_double(stmt, 3, data->battery_voltage);
+    sqlite3_bind_double(stmt, 4, data->pv_voltage);
+    sqlite3_bind_double(stmt, 5, data->pv_charge_current);
+    sqlite3_bind_double(stmt, 6, data->cumulative_energy);
+    sqlite3_bind_double(stmt, 7, data->machine_temp);
+    sqlite3_bind_int(stmt, 8, (int)data->fault_code);
+    sqlite3_bind_int(stmt, 9, (int)data->battery_soc);
+    sqlite3_bind_int(stmt, 10, (int)data->battery_string);
+    sqlite3_bind_int(stmt, 11, (int)data->charge_state);
+    sqlite3_bind_int(stmt, 12, (int)data->device_type);
+    sqlite3_bind_double(stmt, 13, data->pv_input_power);
 
     rc = sqlite3_step(stmt);
     sqlite3_finalize(stmt);
@@ -130,23 +139,36 @@ int db_query_history(db_handle_t *handle, const history_query_t *query,
     result->page_size = query->page_size;
 
     /* Build WHERE clause */
-    char where_clause[512] = "";
-    int has_start = (query->start_time[0] != '\0');
-    int has_end = (query->end_time[0] != '\0');
-
-    if (has_start && has_end) {
-        snprintf(where_clause, sizeof(where_clause),
-                 "WHERE timestamp >= strftime('%%s', '%s') "
-                 "AND timestamp <= strftime('%%s', '%s')",
-                 query->start_time, query->end_time);
-    } else if (has_start) {
-        snprintf(where_clause, sizeof(where_clause),
-                 "WHERE timestamp >= strftime('%%s', '%s')",
-                 query->start_time);
-    } else if (has_end) {
-        snprintf(where_clause, sizeof(where_clause),
-                 "WHERE timestamp <= strftime('%%s', '%s')",
-                 query->end_time);
+    char where_clause[768] = "";
+    char conditions[768] = "";
+    int cond_count = 0;
+
+    /* Device name filter */
+    if (query->device_name[0] != '\0') {
+        snprintf(conditions + strlen(conditions),
+                 sizeof(conditions) - strlen(conditions),
+                 "device_name = '%s'", query->device_name);
+        cond_count++;
+    }
+
+    /* Time range filters */
+    if (query->start_time[0] != '\0') {
+        if (cond_count > 0) strncat(conditions, " AND ", sizeof(conditions) - strlen(conditions) - 1);
+        snprintf(conditions + strlen(conditions),
+                 sizeof(conditions) - strlen(conditions),
+                 "timestamp >= strftime('%%s', '%s')", query->start_time);
+        cond_count++;
+    }
+    if (query->end_time[0] != '\0') {
+        if (cond_count > 0) strncat(conditions, " AND ", sizeof(conditions) - strlen(conditions) - 1);
+        snprintf(conditions + strlen(conditions),
+                 sizeof(conditions) - strlen(conditions),
+                 "timestamp <= strftime('%%s', '%s')", query->end_time);
+        cond_count++;
+    }
+
+    if (cond_count > 0) {
+        snprintf(where_clause, sizeof(where_clause), "WHERE %s", conditions);
     }
 
     /* Count total records */
@@ -183,7 +205,7 @@ int db_query_history(db_handle_t *handle, const history_query_t *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, "
+             "SELECT id, timestamp, device_name, battery_voltage, pv_voltage, "
              "pv_charge_current, cumulative_energy, machine_temp, "
              "fault_code, battery_soc, battery_string, charge_state, "
              "device_type, pv_input_power "
@@ -212,17 +234,22 @@ int db_query_history(db_handle_t *handle, const history_query_t *query,
         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);
+        /* device_name */
+        const unsigned char *dn = sqlite3_column_text(stmt, 2);
+        if (dn) {
+            strncpy(rec->device_name, (const char *)dn, sizeof(rec->device_name) - 1);
+        }
+        rec->battery_voltage = sqlite3_column_double(stmt, 3);
+        rec->pv_voltage = sqlite3_column_double(stmt, 4);
+        rec->pv_charge_current = sqlite3_column_double(stmt, 5);
+        rec->cumulative_energy = sqlite3_column_double(stmt, 6);
+        rec->machine_temp = sqlite3_column_double(stmt, 7);
+        rec->fault_code = (uint16_t)sqlite3_column_int(stmt, 8);
+        rec->battery_soc = (uint16_t)sqlite3_column_int(stmt, 9);
+        rec->battery_string = (uint16_t)sqlite3_column_int(stmt, 10);
+        rec->charge_state = (uint16_t)sqlite3_column_int(stmt, 11);
+        rec->device_type = (uint16_t)sqlite3_column_int(stmt, 12);
+        rec->pv_input_power = sqlite3_column_double(stmt, 13);
         count++;
     }
     result->count = count;

+ 2 - 0
mppt_monitor/src/db.h

@@ -19,6 +19,7 @@ extern "C" {
 typedef struct {
     int64_t  id;
     int64_t  timestamp;           /* Unix timestamp */
+    char     device_name[64];     /* Device name */
     double   battery_voltage;
     double   pv_voltage;
     double   pv_charge_current;
@@ -34,6 +35,7 @@ typedef struct {
 
 /* ── Query Parameters ──────────────────────────────────────────── */
 typedef struct {
+    char     device_name[64];     /* Filter by device name (empty = all) */
     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 */

+ 90 - 59
mppt_monitor/src/main.c

@@ -2,8 +2,8 @@
  * @file main.c
  * @brief MPPT Solar Charge Controller Monitor - Main Entry Point
  *
- * Architecture:
- *   1. Modbus RTU over TCP client -> reads MPPT registers every 2s
+ * Multi-device architecture:
+ *   1. Multiple Modbus RTU over TCP clients (one thread per device)
  *   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
@@ -35,8 +35,8 @@
 
 /* ── 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 device_runtime_t g_devices[MAX_DEVICES];
+static int g_device_count = 0;
 static web_ctx_t    g_web_ctx;
 static db_handle_t  g_db;
 static app_config_t g_config;
@@ -52,33 +52,34 @@ static void signal_handler(int sig)
     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
- */
+/* ── Per-device Modbus Polling Thread ──────────────────────────── */
+typedef struct {
+    int device_idx;
+} thread_arg_t;
+
 static void *modbus_poll_thread(void *arg)
 {
-    (void)arg;
+    thread_arg_t *targ = (thread_arg_t *)arg;
+    int idx = targ->device_idx;
+    free(targ);
+
+    device_runtime_t *dev = &g_devices[idx];
     mppt_data_t local_data;
     int ret;
     time_t last_store_time = 0;
 
     memset(&local_data, 0, sizeof(local_data));
+    local_data.device_id = idx;
+    snprintf(local_data.device_name, sizeof(local_data.device_name),
+             "%s", dev->conn.device_name);
 
-    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);
+    printf("[MODBUS-%s] Poll thread started (interval: %dms)\n",
+           dev->conn.device_name, g_config.modbus_poll_ms);
 
     while (g_running) {
         /* Ensure connection is alive */
-        if (g_modbus_conn.state != MODBUS_STATE_CONNECTED) {
-            ret = modbus_connect(&g_modbus_conn);
+        if (dev->conn.state != MODBUS_STATE_CONNECTED) {
+            ret = modbus_connect(&dev->conn);
             if (ret != 0) {
                 for (int i = 0; i < 10 && g_running; i++) {
                     struct timespec short_ts = { .tv_sec = 0, .tv_nsec = 100000000L };
@@ -89,11 +90,11 @@ static void *modbus_poll_thread(void *arg)
         }
 
         /* Read registers */
-        ret = modbus_read_registers(&g_modbus_conn, &local_data);
+        ret = modbus_read_registers(&dev->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));
+            memcpy(&dev->data, &local_data, sizeof(mppt_data_t));
             pthread_mutex_unlock(&g_data_mutex);
 
             /* Print to console */
@@ -103,15 +104,16 @@ static void *modbus_poll_thread(void *arg)
             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);
+                ret = db_insert_record(&g_db, &dev->data);
                 pthread_mutex_unlock(&g_data_mutex);
 
                 if (ret == 0) {
-                    printf("[STORAGE] Data saved to DB at %s",
-                           ctime(&now));
+                    printf("[STORAGE-%s] Data saved to DB at %s",
+                           dev->conn.device_name, ctime(&now));
                     last_store_time = now;
                 } else {
-                    fprintf(stderr, "[STORAGE] Failed to save data to DB\n");
+                    fprintf(stderr, "[STORAGE-%s] Failed to save data to DB\n",
+                            dev->conn.device_name);
                 }
             }
         } else {
@@ -120,23 +122,23 @@ static void *modbus_poll_thread(void *arg)
             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;
+            dev->data.consecutive_failures = local_data.consecutive_failures;
+            dev->data.data_valid = false;
             pthread_mutex_unlock(&g_data_mutex);
 
-            fprintf(stderr, "[MODBUS] Read failed (consecutive: %d)\n",
-                    local_data.consecutive_failures);
+            fprintf(stderr, "[MODBUS-%s] Read failed (consecutive: %d)\n",
+                    dev->conn.device_name, 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);
+                fprintf(stderr, "[MODBUS-%s] Too many failures, reconnecting...\n",
+                        dev->conn.device_name);
+                modbus_disconnect(&dev->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;
@@ -149,8 +151,8 @@ static void *modbus_poll_thread(void *arg)
     }
 
     /* Cleanup */
-    modbus_disconnect(&g_modbus_conn);
-    printf("[MODBUS] Poll thread exiting\n");
+    modbus_disconnect(&dev->conn);
+    printf("[MODBUS-%s] Poll thread exiting\n", dev->conn.device_name);
     return NULL;
 }
 
@@ -179,7 +181,7 @@ static void web_watchdog(struct mg_mgr *mgr)
 int main(int argc, char *argv[])
 {
     struct mg_mgr mgr;
-    pthread_t modbus_tid;
+    pthread_t modbus_tids[MAX_DEVICES];
     int ret;
 
     (void)argc;
@@ -188,7 +190,7 @@ int main(int argc, char *argv[])
     /* Banner */
     printf("============================================\n");
     printf("  MPPT Solar Charge Controller Monitor\n");
-    printf("  Version: 1.1.0\n");
+    printf("  Version: 1.2.0 (Multi-Device)\n");
     printf("  Mongoose: 7.22 | cJSON: 1.7.19 | SQLite3\n");
     printf("============================================\n\n");
 
@@ -201,9 +203,31 @@ int main(int argc, char *argv[])
     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;
+    g_device_count = g_config.device_count;
+    printf("[MAIN] Configured devices: %d\n", g_device_count);
+
+    /* Initialize MPPT data for each device */
+    for (int i = 0; i < g_device_count; i++) {
+        device_config_t *dcfg = &g_config.devices[i];
+        if (!dcfg->enabled) {
+            printf("[MAIN] Device [%s] disabled, skipping\n", dcfg->name);
+            continue;
+        }
+
+        memset(&g_devices[i].data, 0, sizeof(mppt_data_t));
+        g_devices[i].data.data_valid = false;
+        g_devices[i].data.device_id = i;
+        snprintf(g_devices[i].data.device_name,
+                 sizeof(g_devices[i].data.device_name), "%s", dcfg->name);
+
+        modbus_init(&g_devices[i].conn, dcfg->host,
+                    (uint16_t)dcfg->port,
+                    (uint8_t)dcfg->slave_addr,
+                    i, dcfg->name);
+
+        printf("[MAIN] Device [%s] -> %s:%d (addr=%d)\n",
+               dcfg->name, dcfg->host, dcfg->port, dcfg->slave_addr);
+    }
 
     /* Initialize database */
     ret = db_open(&g_db, g_config.db_path);
@@ -214,13 +238,9 @@ int main(int argc, char *argv[])
     }
     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);
+    web_server_init(&g_web_ctx, g_devices, g_device_count,
+                    &g_db, &g_config, &g_data_mutex);
 
     /* Initialize Mongoose event manager */
     mg_mgr_init(&mgr);
@@ -233,17 +253,26 @@ int main(int argc, char *argv[])
         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);
-        db_close(&g_db);
-        return 1;
+    /* Start Modbus polling threads (one per enabled device) */
+    int active_devices = 0;
+    for (int i = 0; i < g_device_count; i++) {
+        if (!g_config.devices[i].enabled) continue;
+
+        thread_arg_t *targ = (thread_arg_t *)malloc(sizeof(thread_arg_t));
+        targ->device_idx = i;
+
+        ret = pthread_create(&modbus_tids[i], NULL, modbus_poll_thread, targ);
+        if (ret != 0) {
+            fprintf(stderr, "[MAIN] FATAL: Cannot create Modbus thread for [%s]: %s\n",
+                    g_config.devices[i].name, strerror(ret));
+            free(targ);
+            continue;
+        }
+        active_devices++;
     }
 
-    printf("[MAIN] All systems running. Press Ctrl+C to stop.\n\n");
+    printf("[MAIN] %d device thread(s) running. Press Ctrl+C to stop.\n\n",
+           active_devices);
 
     /* Main event loop */
     while (g_running) {
@@ -252,12 +281,14 @@ int main(int argc, char *argv[])
     }
 
     /* Cleanup */
-    printf("[MAIN] Waiting for Modbus thread...\n");
-    pthread_join(modbus_tid, NULL);
+    printf("[MAIN] Waiting for threads to exit...\n");
+    for (int i = 0; i < g_device_count; i++) {
+        if (g_config.devices[i].enabled) {
+            pthread_join(modbus_tids[i], NULL);
+        }
+    }
 
-    printf("[MAIN] Freeing Mongoose resources...\n");
     mg_mgr_free(&mgr);
-
     db_close(&g_db);
     pthread_mutex_destroy(&g_data_mutex);
 

+ 10 - 3
mppt_monitor/src/modbus_client.c

@@ -49,13 +49,18 @@ uint16_t modbus_crc16(const uint8_t *buf, size_t len)
 }
 
 /* ── Init ──────────────────────────────────────────────────────── */
-void modbus_init(modbus_conn_t *conn, const char *host, uint16_t port)
+void modbus_init(modbus_conn_t *conn, const char *host, uint16_t port,
+                 uint8_t slave_addr, int device_id, const char *device_name)
 {
     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->slave_addr = slave_addr;
+    conn->device_id = device_id;
+    snprintf(conn->device_name, sizeof(conn->device_name), "%s",
+             device_name ? device_name : "Unknown");
     conn->last_connect_attempt = 0;
     conn->reconnect_count = 0;
 }
@@ -224,7 +229,7 @@ int modbus_read_registers(modbus_conn_t *conn, mppt_data_t *data)
      * [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[0] = conn->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 */
@@ -270,7 +275,7 @@ int modbus_read_registers(modbus_conn_t *conn, mppt_data_t *data)
     }
 
     /* Check slave address */
-    if (response[0] != MODBUS_SLAVE_ADDR) {
+    if (response[0] != conn->slave_addr) {
         fprintf(stderr, "[MODBUS] Wrong slave address: 0x%02X\n", response[0]);
         return -1;
     }
@@ -375,6 +380,8 @@ void modbus_print_data(const mppt_data_t *data)
     printf("\n");
     printf("+==========================================+\n");
     printf("|  MPPT Real-time Data  [%s]  |\n", timebuf);
+    printf("|  Device: %-12s (ID:%d)             |\n",
+           data->device_name, data->device_id);
     printf("+==========================================+\n");
     printf("| Battery Voltage  : %8.1f V              |\n", data->battery_voltage);
     printf("| PV Voltage       : %8.1f V              |\n", data->pv_voltage);

+ 15 - 4
mppt_monitor/src/modbus_client.h

@@ -31,6 +31,10 @@ extern "C" {
 
 /* ── MPPT Data Structure ───────────────────────────────────────── */
 typedef struct {
+    /* Device identification */
+    int      device_id;               /* Device index (0-based) */
+    char     device_name[64];         /* Device display name */
+
     /* Raw register values */
     uint16_t reg[MODBUS_REG_COUNT];
 
@@ -68,6 +72,9 @@ typedef struct {
     modbus_state_t  state;
     char            host[256];
     uint16_t        port;
+    uint8_t         slave_addr;         /* Modbus slave address */
+    int             device_id;          /* Device index (0-based) */
+    char            device_name[64];    /* Device display name */
     time_t          last_connect_attempt;
     int             reconnect_count;
 } modbus_conn_t;
@@ -76,11 +83,15 @@ typedef struct {
 
 /**
  * Initialize modbus connection structure.
- * @param conn  Pointer to connection struct
- * @param host  Remote hostname
- * @param port  Remote port
+ * @param conn        Pointer to connection struct
+ * @param host        Remote hostname
+ * @param port        Remote port
+ * @param slave_addr  Modbus slave address (1-247)
+ * @param device_id   Device index (0-based)
+ * @param device_name Device display name
  */
-void modbus_init(modbus_conn_t *conn, const char *host, uint16_t port);
+void modbus_init(modbus_conn_t *conn, const char *host, uint16_t port,
+                 uint8_t slave_addr, int device_id, const char *device_name);
 
 /**
  * Connect to the Modbus TCP device.

+ 269 - 489
mppt_monitor/src/web_server.c

@@ -2,6 +2,8 @@
  * @file web_server.c
  * @brief Mongoose HTTP server implementation for MPPT REST API
  *
+ * Multi-device support: serves data for all configured MPPT devices.
+ *
  * Uses mongoose-7.22 API:
  *   - mg_match() instead of deprecated mg_http_match_uri()
  *   - struct mg_str with char *buf (not const char *ptr)
@@ -9,11 +11,14 @@
  *   - 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
+ *   GET /api/devices           - All devices real-time data
+ *   GET /api/devices/<name>    - Single device real-time data
+ *   GET /api/data              - Alias for /api/devices (backward compat)
+ *   GET /api/status            - All devices connection status
+ *   GET /api/health            - Health check
+ *   GET /api/history           - Historical data with pagination
+ *   GET /api/history/stats     - History statistics
+ *   GET /                      - HTML dashboard
  */
 
 #include "web_server.h"
@@ -23,38 +28,40 @@
 #include <stdlib.h>
 #include <string.h>
 #include <time.h>
+#include <pthread.h>
 
-/* ── JSON Serialization ────────────────────────────────────────── */
-
-static char *build_data_json(const mppt_data_t *data)
+/* ── JSON: Build single device data object ─────────────────────── */
+static cJSON *build_device_data_obj(const mppt_data_t *data)
 {
-    cJSON *root = cJSON_CreateObject();
-    if (!root) return NULL;
+    cJSON *obj = cJSON_CreateObject();
+    if (!obj) return NULL;
+
+    cJSON_AddStringToObject(obj, "name", data->device_name);
+    cJSON_AddNumberToObject(obj, "id", data->device_id);
 
     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);
+        cJSON_AddStringToObject(obj, "timestamp", timebuf);
     } else {
-        cJSON_AddStringToObject(root, "timestamp", "N/A");
+        cJSON_AddStringToObject(obj, "timestamp", "N/A");
     }
-
-    cJSON_AddBoolToObject(root, "data_valid", data->data_valid ? 1 : 0);
+    cJSON_AddBoolToObject(obj, "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_AddItemToObject(obj, "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_AddItemToObject(obj, "pv", pv);
 
-    cJSON_AddNumberToObject(root, "cumulative_energy_kwh", data->cumulative_energy);
+    cJSON_AddNumberToObject(obj, "cumulative_energy_kwh", data->cumulative_energy);
 
     cJSON *device = cJSON_CreateObject();
     cJSON_AddNumberToObject(device, "temperature", data->machine_temp);
@@ -65,49 +72,153 @@ static char *build_data_json(const mppt_data_t *data)
     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_AddItemToObject(obj, "device", device);
 
     cJSON *raw = cJSON_CreateArray();
     for (int i = 0; i < MODBUS_REG_COUNT; i++) {
         cJSON_AddItemToArray(raw, cJSON_CreateNumber(data->reg[i]));
     }
-    cJSON_AddItemToObject(root, "raw_registers", raw);
+    cJSON_AddItemToObject(obj, "raw_registers", raw);
+
+    return obj;
+}
+
+/* ── JSON: All devices data ────────────────────────────────────── */
+char *web_build_devices_json(web_ctx_t *ctx)
+{
+    cJSON *root = cJSON_CreateObject();
+    if (!root) return NULL;
+
+    cJSON_AddNumberToObject(root, "device_count", ctx->device_count);
+
+    cJSON *devices = cJSON_CreateArray();
+    pthread_mutex_lock(ctx->mutex);
+    for (int i = 0; i < ctx->device_count; i++) {
+        cJSON *dev_obj = build_device_data_obj(&ctx->devices[i].data);
+        if (dev_obj) {
+            /* Add connection state */
+            const modbus_conn_t *conn = &ctx->devices[i].conn;
+            const char *state_str;
+            switch (conn->state) {
+                case MODBUS_STATE_CONNECTED:  state_str = "connected"; break;
+                case MODBUS_STATE_CONNECTING: state_str = "connecting"; break;
+                case MODBUS_STATE_ERROR:      state_str = "error"; break;
+                default:                      state_str = "disconnected"; break;
+            }
+            cJSON_AddStringToObject(dev_obj, "conn_state", state_str);
+            cJSON_AddStringToObject(dev_obj, "host", conn->host);
+            cJSON_AddNumberToObject(dev_obj, "port", conn->port);
+            cJSON_AddItemToArray(devices, dev_obj);
+        }
+    }
+    pthread_mutex_unlock(ctx->mutex);
+
+    cJSON_AddItemToObject(root, "devices", devices);
 
     char *json_str = cJSON_PrintUnformatted(root);
     cJSON_Delete(root);
     return json_str;
 }
 
-static char *build_status_json(const mppt_data_t *data,
-                               const modbus_conn_t *conn)
+/* ── JSON: Single device data ──────────────────────────────────── */
+char *web_build_device_json(web_ctx_t *ctx, int device_idx)
 {
+    if (device_idx < 0 || device_idx >= ctx->device_count) return NULL;
+
     cJSON *root = cJSON_CreateObject();
     if (!root) return NULL;
 
-    cJSON *connection = cJSON_CreateObject();
+    pthread_mutex_lock(ctx->mutex);
+    cJSON *dev_obj = build_device_data_obj(&ctx->devices[device_idx].data);
+    const modbus_conn_t *conn = &ctx->devices[device_idx].conn;
+    pthread_mutex_unlock(ctx->mutex);
+
+    if (!dev_obj) {
+        cJSON_Delete(root);
+        return NULL;
+    }
+
+    /* Add connection info */
     const char *state_str;
     switch (conn->state) {
-        case MODBUS_STATE_CONNECTED:    state_str = "connected"; break;
-        case MODBUS_STATE_CONNECTING:   state_str = "connecting"; break;
-        case MODBUS_STATE_ERROR:        state_str = "error"; break;
-        default:                        state_str = "disconnected"; break;
+        case MODBUS_STATE_CONNECTED:  state_str = "connected"; break;
+        case MODBUS_STATE_CONNECTING: state_str = "connecting"; break;
+        case MODBUS_STATE_ERROR:      state_str = "error"; break;
+        default:                      state_str = "disconnected"; break;
     }
-    cJSON_AddStringToObject(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_AddStringToObject(dev_obj, "conn_state", state_str);
+    cJSON_AddStringToObject(dev_obj, "host", conn->host);
+    cJSON_AddNumberToObject(dev_obj, "port", conn->port);
+    cJSON_AddNumberToObject(dev_obj, "reconnect_count", conn->reconnect_count);
+    cJSON_AddNumberToObject(dev_obj, "consecutive_failures",
+                            ctx->devices[device_idx].data.consecutive_failures);
 
-    cJSON_AddBoolToObject(root, "device_online", data->data_valid ? 1 : 0);
-    cJSON_AddNumberToObject(root, "consecutive_failures",
-                            data->consecutive_failures);
+    cJSON_AddItemToObject(root, "device", dev_obj);
+
+    char *json_str = cJSON_PrintUnformatted(root);
+    cJSON_Delete(root);
+    return json_str;
+}
+
+/* ── JSON: History query result ────────────────────────────────── */
+char *web_build_history_json(history_result_t *result)
+{
+    cJSON *root = cJSON_CreateObject();
+    if (!root) return NULL;
+
+    cJSON_AddNumberToObject(root, "total", result->total);
+    cJSON_AddNumberToObject(root, "page", result->page);
+    cJSON_AddNumberToObject(root, "page_size", result->page_size);
+    cJSON_AddNumberToObject(root, "total_pages", result->total_pages);
+
+    cJSON *records = cJSON_CreateArray();
+    for (int i = 0; i < result->count; i++) {
+        history_record_t *rec = &result->records[i];
+        cJSON *item = cJSON_CreateObject();
+        cJSON_AddNumberToObject(item, "id", rec->id);
 
-    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);
+        time_t ts = (time_t)rec->timestamp;
+        struct tm *tm_info = localtime(&ts);
+        strftime(timebuf, sizeof(timebuf), "%Y-%m-%d %H:%M:%S", tm_info);
+        cJSON_AddStringToObject(item, "timestamp", timebuf);
+        cJSON_AddNumberToObject(item, "timestamp_unix", rec->timestamp);
+        cJSON_AddStringToObject(item, "device_name", rec->device_name);
+
+        cJSON_AddNumberToObject(item, "battery_voltage", rec->battery_voltage);
+        cJSON_AddNumberToObject(item, "pv_voltage", rec->pv_voltage);
+        cJSON_AddNumberToObject(item, "pv_charge_current", rec->pv_charge_current);
+        cJSON_AddNumberToObject(item, "pv_input_power", rec->pv_input_power);
+        cJSON_AddNumberToObject(item, "cumulative_energy", rec->cumulative_energy);
+        cJSON_AddNumberToObject(item, "machine_temp", rec->machine_temp);
+        cJSON_AddNumberToObject(item, "fault_code", rec->fault_code);
+        cJSON_AddStringToObject(item, "fault_desc",
+                                modbus_fault_string(rec->fault_code));
+        cJSON_AddNumberToObject(item, "battery_soc", rec->battery_soc);
+        cJSON_AddNumberToObject(item, "charge_state", rec->charge_state);
+        cJSON_AddStringToObject(item, "charge_state_desc",
+                                modbus_charge_state_string(rec->charge_state));
+        cJSON_AddNumberToObject(item, "device_type", rec->device_type);
+
+        cJSON_AddItemToArray(records, item);
     }
+    cJSON_AddItemToObject(root, "records", records);
+
+    char *json_str = cJSON_PrintUnformatted(root);
+    cJSON_Delete(root);
+    return json_str;
+}
+
+/* ── JSON: History statistics ──────────────────────────────────── */
+char *web_build_stats_json(db_handle_t *db, const char *device_name)
+{
+    (void)db;
+    (void)device_name;
+    /* Placeholder - stats can be extended later */
+    cJSON *root = cJSON_CreateObject();
+    if (!root) return NULL;
+
+    cJSON_AddNumberToObject(root, "total_records", db_get_record_count(db));
 
     char *json_str = cJSON_PrintUnformatted(root);
     cJSON_Delete(root);
@@ -121,7 +232,6 @@ static void get_query_param(struct mg_http_message *hm, const char *key,
     int n = mg_http_get_var(&hm->query, key, buf, (size_t)buf_size);
     if (n > 0) {
         buf[n] = '\0';
-        /* URL decode + to space */
         for (int i = 0; i < n; i++) {
             if (buf[i] == '+') buf[i] = ' ';
         }
@@ -130,29 +240,40 @@ static void get_query_param(struct mg_http_message *hm, const char *key,
     }
 }
 
+/* ── Find device index by name ─────────────────────────────────── */
+static int find_device_by_name(web_ctx_t *ctx, const char *name)
+{
+    for (int i = 0; i < ctx->device_count; i++) {
+        if (strcmp(ctx->devices[i].data.device_name, name) == 0) {
+            return i;
+        }
+    }
+    return -1;
+}
+
 /* ── History API Handler ───────────────────────────────────────── */
 static void handle_history(struct mg_connection *c,
                            struct mg_http_message *hm,
                            web_ctx_t *ctx)
 {
-    char param_buf[64];
+    char param_buf[128];
     history_query_t query;
     memset(&query, 0, sizeof(query));
 
-    /* Parse start_time: "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DDTHH:MM:SS" */
+    /* Device name filter */
+    get_query_param(hm, "device", param_buf, sizeof(param_buf));
+    snprintf(query.device_name, sizeof(query.device_name), "%s", param_buf);
+
+    /* Time range */
     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 = ' ';
-    }
+    for (char *p = param_buf; *p; p++) { if (*p == 'T') *p = ' '; }
     snprintf(query.start_time, sizeof(query.start_time), "%s", param_buf);
 
     get_query_param(hm, "end_time", param_buf, sizeof(param_buf));
-    for (char *p = param_buf; *p; p++) {
-        if (*p == 'T') *p = ' ';
-    }
+    for (char *p = param_buf; *p; p++) { if (*p == 'T') *p = ' '; }
     snprintf(query.end_time, sizeof(query.end_time), "%s", param_buf);
 
+    /* Pagination */
     get_query_param(hm, "page", param_buf, sizeof(param_buf));
     query.page = param_buf[0] ? atoi(param_buf) : 1;
     if (query.page < 1) query.page = 1;
@@ -177,47 +298,7 @@ static void handle_history(struct mg_connection *c,
         return;
     }
 
-    /* Build JSON response */
-    cJSON *root = cJSON_CreateObject();
-    cJSON_AddNumberToObject(root, "total", result.total);
-    cJSON_AddNumberToObject(root, "page", result.page);
-    cJSON_AddNumberToObject(root, "page_size", result.page_size);
-    cJSON_AddNumberToObject(root, "total_pages", result.total_pages);
-
-    cJSON *records = cJSON_CreateArray();
-    for (int i = 0; i < result.count; i++) {
-        history_record_t *rec = &result.records[i];
-        cJSON *item = cJSON_CreateObject();
-        cJSON_AddNumberToObject(item, "id", rec->id);
-
-        char timebuf[64];
-        time_t ts = (time_t)rec->timestamp;
-        struct tm *tm_info = localtime(&ts);
-        strftime(timebuf, sizeof(timebuf), "%Y-%m-%d %H:%M:%S", tm_info);
-        cJSON_AddStringToObject(item, "timestamp", timebuf);
-        cJSON_AddNumberToObject(item, "timestamp_unix", rec->timestamp);
-
-        cJSON_AddNumberToObject(item, "battery_voltage", rec->battery_voltage);
-        cJSON_AddNumberToObject(item, "pv_voltage", rec->pv_voltage);
-        cJSON_AddNumberToObject(item, "pv_charge_current", rec->pv_charge_current);
-        cJSON_AddNumberToObject(item, "pv_input_power", rec->pv_input_power);
-        cJSON_AddNumberToObject(item, "cumulative_energy", rec->cumulative_energy);
-        cJSON_AddNumberToObject(item, "machine_temp", rec->machine_temp);
-        cJSON_AddNumberToObject(item, "fault_code", rec->fault_code);
-        cJSON_AddStringToObject(item, "fault_desc",
-                                modbus_fault_string(rec->fault_code));
-        cJSON_AddNumberToObject(item, "battery_soc", rec->battery_soc);
-        cJSON_AddNumberToObject(item, "charge_state", rec->charge_state);
-        cJSON_AddStringToObject(item, "charge_state_desc",
-                                modbus_charge_state_string(rec->charge_state));
-        cJSON_AddNumberToObject(item, "device_type", rec->device_type);
-
-        cJSON_AddItemToArray(records, item);
-    }
-    cJSON_AddItemToObject(root, "records", records);
-
-    char *json_str = cJSON_PrintUnformatted(root);
-    cJSON_Delete(root);
+    char *json_str = web_build_history_json(&result);
     db_free_result(&result);
 
     if (json_str) {
@@ -233,399 +314,69 @@ static void handle_history(struct mg_connection *c,
     }
 }
 
-/* ── HTML Dashboard ────────────────────────────────────────────── */
-
-static const char *DASHBOARD_HTML =
-"<!DOCTYPE html>\n"
-"<html lang=\"zh-CN\">\n"
-"<head>\n"
-"<meta charset=\"UTF-8\">\n"
-"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
-"<title>MPPT Solar Monitor</title>\n"
-"<script src=\"https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js\"></script>\n"
-"<script src=\"https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js\"></script>\n"
-"<style>\n"
-"* { margin: 0; padding: 0; box-sizing: border-box; }\n"
-"body { font-family: -apple-system, 'Segoe UI', Roboto, 'Noto Sans SC', sans-serif;\n"
-"       background: #0f172a; color: #e2e8f0; min-height: 100vh; }\n"
-".nav { background: #1e293b; border-bottom: 1px solid #334155; padding: 0 24px;\n"
-"       display: flex; align-items: center; height: 56px; }\n"
-".nav h1 { font-size: 1.1em; color: #38bdf8; margin-right: 32px; }\n"
-".nav-tab { padding: 16px 20px; cursor: pointer; color: #94a3b8;\n"
-"           border-bottom: 2px solid transparent; font-size: 0.9em;\n"
-"           transition: all 0.2s; }\n"
-".nav-tab:hover { color: #e2e8f0; }\n"
-".nav-tab.active { color: #38bdf8; border-bottom-color: #38bdf8; }\n"
-".container { max-width: 1200px; margin: 0 auto; padding: 24px; }\n"
-".page { display: none; }\n"
-".page.active { display: block; }\n"
-".grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));\n"
-"        gap: 16px; margin-bottom: 24px; }\n"
-".card { background: #1e293b; border-radius: 12px; padding: 20px;\n"
-"        border: 1px solid #334155; }\n"
-".card h2 { color: #38bdf8; font-size: 0.95em; margin-bottom: 12px;\n"
-"           border-bottom: 1px solid #334155; padding-bottom: 8px; }\n"
-".metric { display: flex; justify-content: space-between; align-items: center;\n"
-"          padding: 6px 0; }\n"
-".metric .label { color: #94a3b8; font-size: 0.85em; }\n"
-".metric .value { font-size: 1.15em; font-weight: 600; color: #f1f5f9; }\n"
-".metric .unit { color: #64748b; font-size: 0.8em; margin-left: 3px; }\n"
-".status-badge { display: inline-block; padding: 3px 10px; border-radius: 20px;\n"
-"                font-size: 0.78em; font-weight: 600; }\n"
-".status-ok { background: #064e3b; color: #34d399; }\n"
-".status-warn { background: #78350f; color: #fbbf24; }\n"
-".status-err { background: #7f1d1d; color: #f87171; }\n"
-".power-hl { font-size: 1.6em; color: #fbbf24; text-align: center; padding: 8px 0; }\n"
-".update-time { text-align: center; color: #64748b; font-size: 0.8em; margin-top: 16px; }\n"
-/* History page */
-".filter-bar { background: #1e293b; border-radius: 12px; padding: 20px;\n"
-"              border: 1px solid #334155; margin-bottom: 20px;\n"
-"              display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; }\n"
-".filter-group { display: flex; flex-direction: column; gap: 4px; }\n"
-".filter-group label { color: #94a3b8; font-size: 0.8em; }\n"
-".filter-group input, .filter-group select {\n"
-"  background: #0f172a; border: 1px solid #334155; color: #e2e8f0;\n"
-"  padding: 8px 12px; border-radius: 8px; font-size: 0.9em; }\n"
-".filter-group input:focus, .filter-group select:focus {\n"
-"  outline: none; border-color: #38bdf8; }\n"
-".btn { padding: 8px 20px; border-radius: 8px; border: none;\n"
-"       cursor: pointer; font-size: 0.9em; font-weight: 500; transition: all 0.2s; }\n"
-".btn-primary { background: #38bdf8; color: #0f172a; }\n"
-".btn-primary:hover { background: #7dd3fc; }\n"
-".btn-secondary { background: #334155; color: #e2e8f0; }\n"
-".btn-secondary:hover { background: #475569; }\n"
-".chart-container { background: #1e293b; border-radius: 12px; padding: 20px;\n"
-"                   border: 1px solid #334155; margin-bottom: 20px; }\n"
-".chart-container h3 { color: #94a3b8; font-size: 0.9em; margin-bottom: 12px; }\n"
-".chart-metrics { display: flex; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }\n"
-".chart-metric-btn { padding: 4px 12px; border-radius: 16px; border: 1px solid #334155;\n"
-"                    background: transparent; color: #94a3b8; cursor: pointer;\n"
-"                    font-size: 0.8em; transition: all 0.2s; }\n"
-".chart-metric-btn.active { background: #38bdf8; color: #0f172a; border-color: #38bdf8; }\n"
-".chart-metric-btn:hover { border-color: #38bdf8; }\n"
-".table-container { background: #1e293b; border-radius: 12px; padding: 20px;\n"
-"                   border: 1px solid #334155; overflow-x: auto; }\n"
-".data-table { width: 100%; border-collapse: collapse; font-size: 0.85em; }\n"
-".data-table th { text-align: left; padding: 10px 12px; color: #94a3b8;\n"
-"                 border-bottom: 1px solid #334155; font-weight: 500;\n"
-"                 white-space: nowrap; }\n"
-".data-table td { padding: 8px 12px; border-bottom: 1px solid #1e293b;\n"
-"                 color: #e2e8f0; white-space: nowrap; }\n"
-".data-table tr:hover td { background: rgba(56,189,248,0.05); }\n"
-".pagination { display: flex; justify-content: center; align-items: center;\n"
-"              gap: 8px; margin-top: 16px; }\n"
-".pagination button { padding: 6px 14px; border-radius: 6px; border: 1px solid #334155;\n"
-"                     background: transparent; color: #94a3b8; cursor: pointer;\n"
-"                     font-size: 0.85em; }\n"
-".pagination button:hover:not(:disabled) { border-color: #38bdf8; color: #38bdf8; }\n"
-".pagination button:disabled { opacity: 0.3; cursor: default; }\n"
-".pagination .page-info { color: #94a3b8; font-size: 0.85em; }\n"
-".no-data { text-align: center; color: #64748b; padding: 40px; }\n"
-".loading { text-align: center; color: #94a3b8; padding: 20px; }\n"
-"</style>\n"
-"</head>\n"
-"<body>\n"
-"<div class=\"nav\">\n"
-"  <h1>MPPT Monitor</h1>\n"
-"  <div class=\"nav-tab active\" onclick=\"switchPage('realtime')\">Real-time</div>\n"
-"  <div class=\"nav-tab\" onclick=\"switchPage('history')\">History</div>\n"
-"</div>\n"
-"<div class=\"container\">\n"
-"  <!-- Real-time Page -->\n"
-"  <div id=\"page-realtime\" class=\"page active\">\n"
-"    <div class=\"grid\">\n"
-"      <div class=\"card\"><h2>Battery</h2>\n"
-"        <div class=\"metric\"><span class=\"label\">Voltage</span>\n"
-"          <span class=\"value\" id=\"bat-v\">--<span class=\"unit\">V</span></span></div>\n"
-"        <div class=\"metric\"><span class=\"label\">SOC</span>\n"
-"          <span class=\"value\" id=\"bat-soc\">--<span class=\"unit\">%</span></span></div>\n"
-"        <div class=\"metric\"><span class=\"label\">String</span>\n"
-"          <span class=\"value\" id=\"bat-str\">--</span></div>\n"
-"      </div>\n"
-"      <div class=\"card\"><h2>Solar PV</h2>\n"
-"        <div class=\"metric\"><span class=\"label\">Voltage</span>\n"
-"          <span class=\"value\" id=\"pv-v\">--<span class=\"unit\">V</span></span></div>\n"
-"        <div class=\"metric\"><span class=\"label\">Current</span>\n"
-"          <span class=\"value\" id=\"pv-a\">--<span class=\"unit\">A</span></span></div>\n"
-"        <div class=\"power-hl\" id=\"pv-w\">-- W</div>\n"
-"      </div>\n"
-"      <div class=\"card\"><h2>Device</h2>\n"
-"        <div class=\"metric\"><span class=\"label\">Temperature</span>\n"
-"          <span class=\"value\" id=\"dev-temp\">--<span class=\"unit\">C</span></span></div>\n"
-"        <div class=\"metric\"><span class=\"label\">Charge State</span>\n"
-"          <span class=\"value\" id=\"dev-state\">--</span></div>\n"
-"        <div class=\"metric\"><span class=\"label\">Fault</span>\n"
-"          <span class=\"value\" id=\"dev-fault\">--</span></div>\n"
-"        <div class=\"metric\"><span class=\"label\">Type</span>\n"
-"          <span class=\"value\" id=\"dev-type\">--</span></div>\n"
-"      </div>\n"
-"      <div class=\"card\"><h2>Energy</h2>\n"
-"        <div class=\"metric\"><span class=\"label\">Cumulative</span>\n"
-"          <span class=\"value\" id=\"energy\">--<span class=\"unit\">kWh</span></span></div>\n"
-"        <div class=\"metric\"><span class=\"label\">Status</span>\n"
-"          <span class=\"value\" id=\"conn-status\">--</span></div>\n"
-"      </div>\n"
-"    </div>\n"
-"    <div class=\"update-time\" id=\"update-time\">Waiting for data...</div>\n"
-"  </div>\n"
-"  <!-- History Page -->\n"
-"  <div id=\"page-history\" class=\"page\">\n"
-"    <div class=\"filter-bar\">\n"
-"      <div class=\"filter-group\">\n"
-"        <label>Start Time</label>\n"
-"        <input type=\"datetime-local\" id=\"hist-start\">\n"
-"      </div>\n"
-"      <div class=\"filter-group\">\n"
-"        <label>End Time</label>\n"
-"        <input type=\"datetime-local\" id=\"hist-end\">\n"
-"      </div>\n"
-"      <div class=\"filter-group\">\n"
-"        <label>Page Size</label>\n"
-"        <select id=\"hist-page-size\">\n"
-"          <option value=\"20\">20</option>\n"
-"          <option value=\"50\" selected>50</option>\n"
-"          <option value=\"100\">100</option>\n"
-"          <option value=\"200\">200</option>\n"
-"        </select>\n"
-"      </div>\n"
-"      <button class=\"btn btn-primary\" onclick=\"queryHistory(1)\">Query</button>\n"
-"      <button class=\"btn btn-secondary\" onclick=\"resetFilter()\">Reset</button>\n"
-"    </div>\n"
-"    <div class=\"chart-container\">\n"
-"      <h3>Trend Chart</h3>\n"
-"      <div class=\"chart-metrics\">\n"
-"        <button class=\"chart-metric-btn active\" data-metric=\"battery_voltage\" onclick=\"toggleMetric(this)\">Battery V</button>\n"
-"        <button class=\"chart-metric-btn\" data-metric=\"pv_voltage\" onclick=\"toggleMetric(this)\">PV Voltage</button>\n"
-"        <button class=\"chart-metric-btn\" data-metric=\"pv_charge_current\" onclick=\"toggleMetric(this)\">Current</button>\n"
-"        <button class=\"chart-metric-btn\" data-metric=\"pv_input_power\" onclick=\"toggleMetric(this)\">Power</button>\n"
-"        <button class=\"chart-metric-btn\" data-metric=\"machine_temp\" onclick=\"toggleMetric(this)\">Temp</button>\n"
-"        <button class=\"chart-metric-btn\" data-metric=\"battery_soc\" onclick=\"toggleMetric(this)\">SOC %</button>\n"
-"      </div>\n"
-"      <canvas id=\"historyChart\" height=\"260\"></canvas>\n"
-"    </div>\n"
-"    <div class=\"table-container\">\n"
-"      <div id=\"hist-loading\" class=\"loading\" style=\"display:none\">Loading...</div>\n"
-"      <div id=\"hist-nodata\" class=\"no-data\" style=\"display:none\">No data. Click Query to load history.</div>\n"
-"      <table class=\"data-table\" id=\"hist-table\" style=\"display:none\">\n"
-"        <thead><tr>\n"
-"          <th>Time</th><th>Bat V</th><th>PV V</th><th>Current A</th>\n"
-"          <th>Power W</th><th>Energy kWh</th><th>Temp C</th>\n"
-"          <th>SOC %</th><th>State</th><th>Fault</th>\n"
-"        </tr></thead>\n"
-"        <tbody id=\"hist-tbody\"></tbody>\n"
-"      </table>\n"
-"      <div class=\"pagination\" id=\"hist-pagination\"></div>\n"
-"    </div>\n"
-"  </div>\n"
-"</div>\n"
-"<script>\n"
-"/* ── Page Switching ─────────────────────────────────── */\n"
-"function switchPage(name) {\n"
-"  document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));\n"
-"  document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));\n"
-"  document.getElementById('page-'+name).classList.add('active');\n"
-"  document.querySelectorAll('.nav-tab')[name==='realtime'?0:1].classList.add('active');\n"
-"  if (name === 'history' && !histChart) initChart();\n"
-"}\n"
-"\n"
-"/* ── Real-time Data ─────────────────────────────────── */\n"
-"async function fetchRealtime() {\n"
-"  try {\n"
-"    const res = await fetch('/api/data');\n"
-"    const d = await res.json();\n"
-"    if (!d.data_valid) return;\n"
-"    document.getElementById('bat-v').innerHTML = d.battery.voltage.toFixed(1)+'<span class=\"unit\">V</span>';\n"
-"    document.getElementById('bat-soc').innerHTML = d.battery.soc+'<span class=\"unit\">%</span>';\n"
-"    document.getElementById('bat-str').textContent = d.battery.string_count;\n"
-"    document.getElementById('pv-v').innerHTML = d.pv.voltage.toFixed(1)+'<span class=\"unit\">V</span>';\n"
-"    document.getElementById('pv-a').innerHTML = d.pv.current.toFixed(1)+'<span class=\"unit\">A</span>';\n"
-"    document.getElementById('pv-w').textContent = d.pv.power.toFixed(1)+' W';\n"
-"    document.getElementById('dev-temp').innerHTML = d.device.temperature.toFixed(1)+'<span class=\"unit\">C</span>';\n"
-"    document.getElementById('dev-state').textContent = d.device.charge_state_desc;\n"
-"    var fe = document.getElementById('dev-fault');\n"
-"    fe.textContent = d.device.fault_desc;\n"
-"    fe.className = 'value ' + (d.device.fault_code===0||d.device.fault_code===20?'status-ok':'status-err');\n"
-"    document.getElementById('dev-type').textContent = d.device.device_type;\n"
-"    document.getElementById('energy').innerHTML = d.cumulative_energy_kwh.toFixed(1)+'<span class=\"unit\">kWh</span>';\n"
-"    document.getElementById('update-time').textContent = 'Updated: '+d.timestamp;\n"
-"    var cs = document.getElementById('conn-status');\n"
-"    cs.textContent = d.data_valid ? 'Online' : 'Offline';\n"
-"    cs.className = 'value ' + (d.data_valid ? 'status-ok' : 'status-err');\n"
-"  } catch(e) { console.error(e); }\n"
-"}\n"
-"fetchRealtime();\n"
-"setInterval(fetchRealtime, 2000);\n"
-"\n"
-"/* ── History Chart ──────────────────────────────────── */\n"
-"let histChart = null;\n"
-"let histData = [];\n"
-"const metricColors = {\n"
-"  battery_voltage: '#38bdf8',\n"
-"  pv_voltage: '#fbbf24',\n"
-"  pv_charge_current: '#34d399',\n"
-"  pv_input_power: '#f87171',\n"
-"  machine_temp: '#a78bfa',\n"
-"  battery_soc: '#fb923c'\n"
-"};\n"
-"const metricLabels = {\n"
-"  battery_voltage: 'Battery V',\n"
-"  pv_voltage: 'PV Voltage',\n"
-"  pv_charge_current: 'Current A',\n"
-"  pv_input_power: 'Power W',\n"
-"  machine_temp: 'Temp C',\n"
-"  battery_soc: 'SOC %'\n"
-"};\n"
-"let activeMetrics = new Set(['battery_voltage']);\n"
-"\n"
-"function initChart() {\n"
-"  const ctx = document.getElementById('historyChart').getContext('2d');\n"
-"  histChart = new Chart(ctx, {\n"
-"    type: 'line',\n"
-"    data: { datasets: [] },\n"
-"    options: {\n"
-"      responsive: true,\n"
-"      maintainAspectRatio: false,\n"
-"      interaction: { mode: 'index', intersect: false },\n"
-"      plugins: { legend: { display: true, labels: { color: '#94a3b8' } } },\n"
-"      scales: {\n"
-"        x: { type: 'time', ticks: { color: '#64748b' }, grid: { color: '#1e293b' } },\n"
-"        y: { ticks: { color: '#64748b' }, grid: { color: '#334155' } }\n"
-"      }\n"
-"    }\n"
-"  });\n"
-"}\n"
-"\n"
-"function toggleMetric(btn) {\n"
-"  const m = btn.dataset.metric;\n"
-"  if (activeMetrics.has(m)) { activeMetrics.delete(m); btn.classList.remove('active'); }\n"
-"  else { activeMetrics.add(m); btn.classList.add('active'); }\n"
-"  updateChart();\n"
-"}\n"
-"\n"
-"function updateChart() {\n"
-"  if (!histChart || histData.length === 0) return;\n"
-"  const datasets = [];\n"
-"  activeMetrics.forEach(m => {\n"
-"    datasets.push({\n"
-"      label: metricLabels[m] || m,\n"
-"      data: histData.map(r => ({ x: r.timestamp_unix * 1000, y: r[m] })),\n"
-"      borderColor: metricColors[m] || '#fff',\n"
-"      backgroundColor: (metricColors[m] || '#fff') + '20',\n"
-"      borderWidth: 2, pointRadius: 1, tension: 0.3, fill: false\n"
-"    });\n"
-"  });\n"
-"  histChart.data.datasets = datasets;\n"
-"  histChart.update();\n"
-"}\n"
-"\n"
-"/* ── History Query ──────────────────────────────────── */\n"
-"let currentPage = 1;\n"
-"let currentTotalPages = 1;\n"
-"\n"
-"async function queryHistory(page) {\n"
-"  page = page || 1;\n"
-"  currentPage = page;\n"
-"  const startEl = document.getElementById('hist-start');\n"
-"  const endEl = document.getElementById('hist-end');\n"
-"  const psEl = document.getElementById('hist-page-size');\n"
-"  let url = '/api/history?page=' + page + '&page_size=' + psEl.value + '&sort=DESC';\n"
-"  if (startEl.value) url += '&start_time=' + encodeURIComponent(startEl.value.replace('T',' '));\n"
-"  if (endEl.value) url += '&end_time=' + encodeURIComponent(endEl.value.replace('T',' '));\n"
-"\n"
-"  document.getElementById('hist-loading').style.display = 'block';\n"
-"  document.getElementById('hist-nodata').style.display = 'none';\n"
-"  document.getElementById('hist-table').style.display = 'none';\n"
-"\n"
-"  try {\n"
-"    const res = await fetch(url);\n"
-"    const d = await res.json();\n"
-"    document.getElementById('hist-loading').style.display = 'none';\n"
-"\n"
-"    if (!d.records || d.records.length === 0) {\n"
-"      document.getElementById('hist-nodata').style.display = 'block';\n"
-"      histData = [];\n"
-"      updateChart();\n"
-"      return;\n"
-"    }\n"
-"\n"
-"    histData = d.records;\n"
-"    currentTotalPages = d.total_pages;\n"
-"\n"
-"    /* Populate table */\n"
-"    const tbody = document.getElementById('hist-tbody');\n"
-"    tbody.innerHTML = '';\n"
-"    d.records.forEach(r => {\n"
-"      const tr = document.createElement('tr');\n"
-"      tr.innerHTML = '<td>'+r.timestamp+'</td>'\n"
-"        + '<td>'+r.battery_voltage.toFixed(1)+'</td>'\n"
-"        + '<td>'+r.pv_voltage.toFixed(1)+'</td>'\n"
-"        + '<td>'+r.pv_charge_current.toFixed(1)+'</td>'\n"
-"        + '<td>'+r.pv_input_power.toFixed(1)+'</td>'\n"
-"        + '<td>'+r.cumulative_energy.toFixed(1)+'</td>'\n"
-"        + '<td>'+r.machine_temp.toFixed(0)+'</td>'\n"
-"        + '<td>'+r.battery_soc+'</td>'\n"
-"        + '<td>'+r.charge_state_desc+'</td>'\n"
-"        + '<td><span class=\"status-badge '+(r.fault_code===0||r.fault_code===20?'status-ok':'status-err')+'\">'+r.fault_desc+'</span></td>';\n"
-"      tbody.appendChild(tr);\n"
-"    });\n"
-"    document.getElementById('hist-table').style.display = 'table';\n"
-"\n"
-"    /* Pagination */\n"
-"    const pg = document.getElementById('hist-pagination');\n"
-"    pg.innerHTML = '<button '+(d.page<=1?'disabled':'')+' onclick=\"queryHistory('+(d.page-1)+')\">Prev</button>'\n"
-"      + '<span class=\"page-info\">Page '+d.page+' / '+d.total_pages+' (Total: '+d.total+')</span>'\n"
-"      + '<button '+(d.page>=d.total_pages?'disabled':'')+' onclick=\"queryHistory('+(d.page+1)+')\">Next</button>';\n"
-"\n"
-"    updateChart();\n"
-"  } catch(e) {\n"
-"    console.error(e);\n"
-"    document.getElementById('hist-loading').style.display = 'none';\n"
-"    document.getElementById('hist-nodata').style.display = 'block';\n"
-"    document.getElementById('hist-nodata').textContent = 'Query failed: ' + e.message;\n"
-"  }\n"
-"}\n"
-"\n"
-"function resetFilter() {\n"
-"  document.getElementById('hist-start').value = '';\n"
-"  document.getElementById('hist-end').value = '';\n"
-"  document.getElementById('hist-page-size').value = '50';\n"
-"  queryHistory(1);\n"
-"}\n"
-"\n"
-"/* Set default date range: last 24 hours */\n"
-"(function() {\n"
-"  const now = new Date();\n"
-"  const ago = new Date(now.getTime() - 24*60*60*1000);\n"
-"  const fmt = d => d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0')+'T'+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');\n"
-"  document.getElementById('hist-start').value = fmt(ago);\n"
-"  document.getElementById('hist-end').value = fmt(now);\n"
-"})();\n"
-"</script>\n"
-"</body>\n"
-"</html>\n";
-
-/* ── Init ──────────────────────────────────────────────────────── */
-void web_server_init(web_ctx_t *ctx, mppt_data_t *mppt_data,
-                     modbus_conn_t *conn, db_handle_t *db,
-                     const app_config_t *config)
-{
-    ctx->mppt_data = mppt_data;
-    ctx->modbus_conn = conn;
-    ctx->db = db;
-    ctx->config = config;
-}
-
-/* ── Event Handler (mongoose-7.22 API) ─────────────────────────── */
-void web_event_handler(struct mg_connection *c, int ev, void *ev_data)
+/* ── Event Handler ─────────────────────────────────────────────── */
+static void web_event_handler(struct mg_connection *c, int ev,
+                               void *ev_data)
 {
     if (ev != MG_EV_HTTP_MSG) return;
 
     struct mg_http_message *hm = (struct mg_http_message *)ev_data;
     web_ctx_t *ctx = (web_ctx_t *)c->fn_data;
 
+    /* GET /api/devices - all devices data */
+    if (mg_match(hm->uri, mg_str("/api/devices"), NULL)) {
+        char *json = web_build_devices_json(ctx);
+        if (json) {
+            mg_http_reply(c, 200,
+                          "Content-Type: application/json\r\n"
+                          "Access-Control-Allow-Origin: *\r\n",
+                          "%s", json);
+            free(json);
+        } else {
+            mg_http_reply(c, 500, "Content-Type: application/json\r\n",
+                          "{\"error\":\"Failed to build JSON\"}");
+        }
+        return;
+    }
+
+    /* GET /api/devices/<name> - single device */
+    if (hm->uri.len > 13 && strncmp(hm->uri.buf, "/api/devices/", 13) == 0) {
+        char dev_name[128] = {0};
+        size_t name_len = hm->uri.len - 13;
+        if (name_len >= sizeof(dev_name)) name_len = sizeof(dev_name) - 1;
+        memcpy(dev_name, hm->uri.buf + 13, name_len);
+        dev_name[name_len] = '\0';
+
+        /* URL decode */
+        for (char *p = dev_name; *p; p++) {
+            if (*p == '+' || *p == '%') {
+                if (*p == '+') *p = ' ';
+            }
+        }
+
+        int idx = find_device_by_name(ctx, dev_name);
+        if (idx >= 0) {
+            char *json = web_build_device_json(ctx, idx);
+            if (json) {
+                mg_http_reply(c, 200,
+                              "Content-Type: application/json\r\n"
+                              "Access-Control-Allow-Origin: *\r\n",
+                              "%s", json);
+                free(json);
+            } else {
+                mg_http_reply(c, 500, "Content-Type: application/json\r\n",
+                              "{\"error\":\"JSON build failed\"}");
+            }
+        } else {
+            mg_http_reply(c, 404, "Content-Type: application/json\r\n",
+                          "{\"error\":\"Device not found: %s\"}", dev_name);
+        }
+        return;
+    }
+
+    /* GET /api/data - backward compat (returns all devices) */
     if (mg_match(hm->uri, mg_str("/api/data"), NULL)) {
-        const mppt_data_t *data = (const mppt_data_t *)ctx->mppt_data;
-        char *json = build_data_json(data);
+        char *json = web_build_devices_json(ctx);
         if (json) {
             mg_http_reply(c, 200,
                           "Content-Type: application/json\r\n"
@@ -633,17 +384,15 @@ void web_event_handler(struct mg_connection *c, int ev, void *ev_data)
                           "%s", json);
             free(json);
         } else {
-            mg_http_reply(c, 500,
-                          "Content-Type: application/json\r\n",
+            mg_http_reply(c, 500, "Content-Type: application/json\r\n",
                           "{\"error\":\"Failed to build JSON\"}");
         }
         return;
     }
 
+    /* GET /api/status - all devices status */
     if (mg_match(hm->uri, mg_str("/api/status"), NULL)) {
-        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);
+        char *json = web_build_devices_json(ctx);
         if (json) {
             mg_http_reply(c, 200,
                           "Content-Type: application/json\r\n"
@@ -651,13 +400,13 @@ void web_event_handler(struct mg_connection *c, int ev, void *ev_data)
                           "%s", json);
             free(json);
         } else {
-            mg_http_reply(c, 500,
-                          "Content-Type: application/json\r\n",
+            mg_http_reply(c, 500, "Content-Type: application/json\r\n",
                           "{\"error\":\"Failed to build JSON\"}");
         }
         return;
     }
 
+    /* GET /api/health */
     if (mg_match(hm->uri, mg_str("/api/health"), NULL)) {
         mg_http_reply(c, 200,
                       "Content-Type: application/json\r\n",
@@ -665,15 +414,35 @@ void web_event_handler(struct mg_connection *c, int ev, void *ev_data)
         return;
     }
 
+    /* GET /api/history/stats - must be before /api/history */
+    if (mg_match(hm->uri, mg_str("/api/history/stats"), NULL)) {
+        char param_buf[128];
+        get_query_param(hm, "device", param_buf, sizeof(param_buf));
+        char *json = web_build_stats_json(ctx->db, param_buf);
+        if (json) {
+            mg_http_reply(c, 200,
+                          "Content-Type: application/json\r\n"
+                          "Access-Control-Allow-Origin: *\r\n",
+                          "%s", json);
+            free(json);
+        } else {
+            mg_http_reply(c, 500, "Content-Type: application/json\r\n",
+                          "{\"error\":\"JSON build failed\"}");
+        }
+        return;
+    }
+
+    /* GET /api/history */
     if (mg_match(hm->uri, mg_str("/api/history"), NULL)) {
         handle_history(c, hm, ctx);
         return;
     }
 
+    /* GET / - HTML dashboard */
     if (mg_match(hm->uri, mg_str("/"), NULL)) {
         mg_http_reply(c, 200,
                       "Content-Type: text/html; charset=utf-8\r\n",
-                      "%s", DASHBOARD_HTML);
+                      "%s", web_get_dashboard_html());
         return;
     }
 
@@ -682,18 +451,29 @@ void web_event_handler(struct mg_connection *c, int ev, void *ev_data)
                   "{\"error\":\"Not Found\"}");
 }
 
-/* ── Start Listener ────────────────────────────────────────────── */
+/* ── Init & Start ──────────────────────────────────────────────── */
+void web_server_init(web_ctx_t *ctx, device_runtime_t *devices,
+                     int device_count, db_handle_t *db,
+                     app_config_t *config, pthread_mutex_t *mutex)
+{
+    memset(ctx, 0, sizeof(*ctx));
+    ctx->devices = devices;
+    ctx->device_count = device_count;
+    ctx->db = db;
+    ctx->config = config;
+    ctx->mutex = mutex;
+}
+
 struct mg_connection *web_server_start(struct mg_mgr *mgr, web_ctx_t *ctx)
 {
     struct mg_connection *c;
     char listen_url[128];
 
-    /*
-     * Read port from DEPLOY_RUN_PORT environment variable (sandbox compat).
-     * Falls back to WEB_SERVER_PORT (8085) if not set.
-     */
     const char *env_port = getenv("DEPLOY_RUN_PORT");
-    int port = WEB_SERVER_PORT;
+    int port = 8085;
+    if (ctx->config && ctx->config->web_port > 0) {
+        port = ctx->config->web_port;
+    }
     if (env_port != NULL && env_port[0] != '\0') {
         int parsed = atoi(env_port);
         if (parsed > 0 && parsed < 65536) {

+ 53 - 19
mppt_monitor/src/web_server.h

@@ -1,53 +1,87 @@
 /**
  * @file web_server.h
- * @brief Mongoose HTTP server for MPPT data REST API
+ * @brief Mongoose HTTP 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, device list, and history queries.
+ * Serves embedded HTML dashboard with real-time updates.
+ *
+ * Uses mongoose-7.22 API (mg_match, mg_http_reply, etc.)
  */
 
 #ifndef WEB_SERVER_H
 #define WEB_SERVER_H
 
+#include "mongoose.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"
+/* ── Limits ────────────────────────────────────────────────────── */
+#define MAX_WEB_DEVICES  MAX_DEVICES   /* Max devices for web context */
+
+/* ── Per-device runtime state ──────────────────────────────────── */
+typedef struct {
+    mppt_data_t   data;               /* Real-time data (protected by mutex) */
+    modbus_conn_t conn;               /* Modbus connection */
+} device_runtime_t;
 
-/* ── 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 */
+    device_runtime_t *devices;        /* Pointer to devices array */
+    int              device_count;
+    db_handle_t     *db;
+    app_config_t    *config;
+    pthread_mutex_t *mutex;           /* Shared data mutex */
 } web_ctx_t;
 
 /* ── Public API ────────────────────────────────────────────────── */
 
 /**
- * Initialize the web server context.
+ * Initialize web server context.
  */
-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);
+void web_server_init(web_ctx_t *ctx, device_runtime_t *devices,
+                     int device_count, db_handle_t *db,
+                     app_config_t *config, pthread_mutex_t *mutex);
 
 /**
- * Start the Mongoose HTTP listener.
+ * Start HTTP server on configured port.
+ * @return Connection pointer, or NULL on failure
  */
 struct mg_connection *web_server_start(struct mg_mgr *mgr, web_ctx_t *ctx);
 
 /**
- * Mongoose event handler callback.
+ * Generate JSON string for all devices real-time data.
+ * Caller must free the returned string with free().
+ */
+char *web_build_devices_json(web_ctx_t *ctx);
+
+/**
+ * Generate JSON string for a single device's real-time data.
+ * Caller must free the returned string with free().
+ */
+char *web_build_device_json(web_ctx_t *ctx, int device_idx);
+
+/**
+ * Generate JSON string for history query result.
+ * Caller must free the returned string with free().
+ */
+char *web_build_history_json(history_result_t *result);
+
+/**
+ * Generate JSON string for history statistics.
+ * Caller must free the returned string with free().
+ */
+char *web_build_stats_json(db_handle_t *db, const char *device_name);
+
+/**
+ * Get the embedded HTML dashboard page.
+ * Defined in dashboard_html.h as static inline.
  */
-void web_event_handler(struct mg_connection *c, int ev, void *ev_data);
+#include "dashboard_html.h"
 
 #ifdef __cplusplus
 }