Переглянути джерело

feat: 新增设备设置功能 - Modbus 0x10写入 + Web配置界面 + 参数校验

Coze-Commit-Type: user
Coze-User-ID: 256649704250362
Coze-Conversation-ID: 11640025
steven_roc 2 місяців тому
батько
коміт
7206807dc3

+ 36 - 0
mppt_monitor/CHANGELOG.md

@@ -4,6 +4,42 @@
 
 ---
 
+## [2.1.0] - 2026-08-06
+
+### 新增:设备设置功能 (Device Settings)
+
+#### 功能描述
+通过 Web 界面远程配置 MPPT 设备参数,使用 Modbus 功能码 0x10 (Write Multiple Registers)。
+
+#### 新增 API
+- `GET /api/settings` - 返回可写寄存器列表和说明
+- `POST /api/settings` - 写入设备参数
+  - 请求体: `{"device_addr": 1, "param": "battery_type", "value": 2}`
+  - 响应: `{"success": true, "param_desc": "电池类型", "value": 2, "message": "写入成功"}`
+
+#### 可写参数
+| 参数名 | 寄存器 | 范围 | 说明 |
+|--------|--------|------|------|
+| battery_type | 40001 (0x0000) | 0-5 | 电池类型 |
+| battery_level | 40002 (0x0001) | 0-6 | 电池电压等级 |
+| charge_current_limit | 40003 (0x0002) | 5-100 | 充电电流限制(%) |
+| full_charge_voltage | 40006 (0x0005) | 80-600 | 充满电压(0.1V),仅自定义电池可写 |
+| device_address | 40008 (0x0007) | 0-247 | 485设备地址 |
+| charge_mode | 40009 (0x0008) | 0-1 | 0=MPPT, 1=DCDC |
+
+#### 新增文件/修改
+- `src/modbus_client.h/c`: 新增 `modbus_write_register()` 和 `modbus_validate_setting()`
+- `src/web_server.c`: 新增 `handle_api_settings()` 处理函数
+- `www/index.html`: 新增 "Device Settings" 标签页,包含参数选择表单和寄存器参考表
+
+#### 安全特性
+- 写入前参数范围校验
+- 自定义电池类型才能修改电压参数
+- 写入前确认对话框
+- 芯片擦写次数警告(最大100000次)
+
+---
+
 ## [2.0.1] - 2026-08-06
 
 ### Bug 修复:Modbus 数据读取失败

BIN
mppt_monitor/build/mppt_monitor


BIN
mppt_monitor/mppt_data.db


+ 117 - 0
mppt_monitor/src/modbus_client.c

@@ -636,3 +636,120 @@ bool modbus_get_device_data(modbus_client_t *client,
     pthread_mutex_unlock(&client->data_mutex);
     return found;
 }
+
+/* ===== Write Functions (Function Code 0x10) ===== */
+
+int modbus_validate_setting(uint16_t reg_addr, uint16_t value, uint16_t battery_type)
+{
+    switch (reg_addr) {
+    case 0x0000: /* Battery type: 0-5 */
+        if (value > 5) return -1;
+        break;
+    case 0x0001: /* Battery level: 0-6 */
+        if (value > 6) return -1;
+        break;
+    case 0x0002: /* PV charge current limit: 5-100% */
+        if (value < 5 || value > 100) return -1;
+        break;
+    case 0x0005: /* Battery full charge voltage: 80-600 (0.1V), custom type only */
+        if (battery_type != 4) return -1; /* Only custom battery type allows voltage write */
+        if (value < 80 || value > 600) return -1;
+        break;
+    case 0x0007: /* Device 485 address: 0-247 */
+        if (value > 247) return -1;
+        break;
+    case 0x0008: /* Charge mode: 0=MPPT, 1=DCDC */
+        if (value > 1) return -1;
+        break;
+    default:
+        return -1; /* Read-only or invalid register */
+    }
+    return 0;
+}
+
+int modbus_write_register(modbus_client_t *ctx, uint8_t dev_addr,
+                          uint16_t reg_addr, uint16_t value)
+{
+    if (!ctx || !ctx->connected) return -1;
+
+    /* Build Write Multiple Registers frame (0x10) */
+    uint8_t frame[11];
+    frame[0] = dev_addr;
+    frame[1] = 0x10;                 /* Function code: Write Multiple Registers */
+    frame[2] = (reg_addr >> 8) & 0xFF; /* Start address high */
+    frame[3] = reg_addr & 0xFF;       /* Start address low */
+    frame[4] = 0x00;                  /* Number of registers high */
+    frame[5] = 0x01;                  /* Number of registers low (1 register) */
+    frame[6] = 0x02;                  /* Byte count (2 bytes for 1 register) */
+    frame[7] = (value >> 8) & 0xFF;  /* Value high byte */
+    frame[8] = value & 0xFF;          /* Value low byte */
+
+    uint16_t crc = crc16_modbus(frame, 9);
+    frame[9]  = crc & 0xFF;          /* CRC low */
+    frame[10] = (crc >> 8) & 0xFF;   /* CRC high */
+
+    /* Drain socket before sending */
+    drain_socket(ctx->sockfd);
+
+    /* Send frame */
+    ssize_t sent = send(ctx->sockfd, frame, 11, 0);
+    if (sent != 11) {
+        fprintf(stderr, "[MODBUS-WRITE] Send failed: %zd/11\n", sent);
+        return -1;
+    }
+
+    printf("[MODBUS-WRITE] Sent to dev %d reg 0x%04X value %u\n",
+           dev_addr, reg_addr, value);
+
+    /* Read response (should be 6 bytes: addr + 0x10 + start_addr(2) + count(2) + crc(2)) */
+    uint8_t resp[6];
+    ssize_t total = 0;
+    int retries = 0;
+    while (total < 6 && retries < 20) {
+        struct timeval tv = { .tv_sec = 0, .tv_usec = 100000 }; /* 100ms */
+        fd_set readfds;
+        FD_ZERO(&readfds);
+        FD_SET(ctx->sockfd, &readfds);
+        int ret = select(ctx->sockfd + 1, &readfds, NULL, NULL, &tv);
+        if (ret > 0) {
+            ssize_t n = recv(ctx->sockfd, resp + total, 6 - total, 0);
+            if (n <= 0) break;
+            total += n;
+        } else {
+            retries++;
+        }
+    }
+
+    if (total < 6) {
+        fprintf(stderr, "[MODBUS-WRITE] Response too short: %zd/6 bytes\n", total);
+        return -1;
+    }
+
+    /* Verify response */
+    if (resp[0] != dev_addr) {
+        fprintf(stderr, "[MODBUS-WRITE] Address mismatch: expected %d, got %d\n",
+                dev_addr, resp[0]);
+        return -1;
+    }
+    if (resp[1] == 0x90) {
+        /* Error response: 0x10 + 0x80 = 0x90 */
+        fprintf(stderr, "[MODBUS-WRITE] Error response: code %d\n", resp[2]);
+        return -1;
+    }
+    if (resp[1] != 0x10) {
+        fprintf(stderr, "[MODBUS-WRITE] Unexpected function code: 0x%02X\n", resp[1]);
+        return -1;
+    }
+
+    /* Verify CRC */
+    uint16_t recv_crc = resp[4] | (resp[5] << 8);
+    uint16_t calc_crc = crc16_modbus(resp, 4);
+    if (recv_crc != calc_crc) {
+        fprintf(stderr, "[MODBUS-WRITE] CRC mismatch\n");
+        return -1;
+    }
+
+    printf("[MODBUS-WRITE] Success: dev %d reg 0x%04X = %u\n",
+           dev_addr, reg_addr, value);
+    return 0;
+}

+ 33 - 0
mppt_monitor/src/modbus_client.h

@@ -224,4 +224,37 @@ const char *modbus_charge_mode_str(uint16_t mode);
  */
 void modbus_compute_derived(mppt_device_data_t *dev);
 
+/* ===== Write Functions (Function Code 0x10) ===== */
+
+/**
+ * Write a single holding register (Output Register).
+ * Uses Modbus function code 0x10 (Write Multiple Registers).
+ *
+ * @param ctx       Modbus context
+ * @param dev_addr  Device address (1-247)
+ * @param reg_addr  Register address (0x0000-0x0008 for 40001-40009)
+ * @param value     Value to write
+ * @return          0 on success, -1 on error
+ *
+ * Writable registers:
+ *   0x0000 (40001): Battery type (0-5)
+ *   0x0001 (40002): Battery level (0-6)
+ *   0x0002 (40003): PV charge current limit (5-100%)
+ *   0x0005 (40006): Battery full charge voltage (80-600, unit 0.1V) [custom type only]
+ *   0x0007 (40008): Device 485 address (0-247)
+ *   0x0008 (40009): Charge mode (0=MPPT, 1=DCDC)
+ */
+int modbus_write_register(modbus_client_t *ctx, uint8_t dev_addr,
+                          uint16_t reg_addr, uint16_t value);
+
+/**
+ * Validate a setting value before writing.
+ *
+ * @param reg_addr  Register address
+ * @param value     Value to validate
+ * @param battery_type  Current battery type (for voltage validation)
+ * @return          0 if valid, -1 if invalid
+ */
+int modbus_validate_setting(uint16_t reg_addr, uint16_t value, uint16_t battery_type);
+
 #endif /* MODBUS_CLIENT_H */

+ 140 - 0
mppt_monitor/src/web_server.c

@@ -32,6 +32,7 @@ static void handle_api_status(struct mg_connection *c, struct mg_http_message *h
 static void handle_api_health(struct mg_connection *c, struct mg_http_message *hm);
 static void handle_api_history(struct mg_connection *c, struct mg_http_message *hm);
 static void handle_api_history_stats(struct mg_connection *c, struct mg_http_message *hm);
+static void handle_api_settings(struct mg_connection *c, struct mg_http_message *hm);
 
 /* ---- Helper: get query parameter ---- */
 static void get_query_param(struct mg_http_message *hm,
@@ -105,6 +106,143 @@ static void handle_api_data(struct mg_connection *c, struct mg_http_message *hm)
     cJSON_Delete(root);
 }
 
+/* ---- Handle POST /api/settings - Write device configuration ---- */
+static void handle_api_settings(struct mg_connection *c, struct mg_http_message *hm)
+{
+    /* Only accept POST */
+    if (mg_match(hm->method, mg_str("GET"), NULL)) {
+        /* GET returns current settings info */
+        cJSON *root = cJSON_CreateObject();
+        cJSON_AddStringToObject(root, "method", "GET");
+        cJSON_AddStringToObject(root, "description", "Use POST to write device settings");
+        cJSON *writable = cJSON_CreateArray();
+        cJSON_AddItemToArray(writable, cJSON_CreateString("battery_type (0-5): 0=胶体 1=密封 2=磷酸铁锂 3=三元锂电 4=自定义 5=加水"));
+        cJSON_AddItemToArray(writable, cJSON_CreateString("battery_level (0-6): 0=自动识别 1=12V 2=24V 3=36V 4=48V 5=60V 6=72V"));
+        cJSON_AddItemToArray(writable, cJSON_CreateString("charge_current_limit (5-100): PV充电电流限制%"));
+        cJSON_AddItemToArray(writable, cJSON_CreateString("full_charge_voltage (80-600): 充满电压0.1V,仅自定义电池可写"));
+        cJSON_AddItemToArray(writable, cJSON_CreateString("device_address (0-247): 485设备地址"));
+        cJSON_AddItemToArray(writable, cJSON_CreateString("charge_mode (0-1): 0=MPPT 1=DCDC"));
+        cJSON_AddItemToObject(root, "writable_registers", writable);
+        char *json_str = cJSON_PrintUnformatted(root);
+        mg_http_reply(c, 200, "Content-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\n", "%s", json_str);
+        free(json_str);
+        cJSON_Delete(root);
+        return;
+    }
+
+    if (!mg_match(hm->method, mg_str("POST"), NULL)) {
+        mg_http_reply(c, 405, "Content-Type: application/json\r\n", "{\"error\":\"Method not allowed. Use POST.\"}");
+        return;
+    }
+
+    /* Parse JSON body */
+    char body_buf[2048];
+    size_t body_len = (hm->body.len < sizeof(body_buf) - 1) ? hm->body.len : sizeof(body_buf) - 1;
+    memcpy(body_buf, hm->body.buf, body_len);
+    body_buf[body_len] = '\0';
+
+    cJSON *req = cJSON_Parse(body_buf);
+    if (!req) {
+        mg_http_reply(c, 400, "Content-Type: application/json\r\n", "{\"error\":\"Invalid JSON body\"}");
+        return;
+    }
+
+    /* Extract parameters */
+    cJSON *j_addr = cJSON_GetObjectItem(req, "device_addr");
+    cJSON *j_param = cJSON_GetObjectItem(req, "param");
+    cJSON *j_value = cJSON_GetObjectItem(req, "value");
+
+    if (!cJSON_IsNumber(j_addr) || !cJSON_IsString(j_param) || !cJSON_IsNumber(j_value)) {
+        cJSON_Delete(req);
+        mg_http_reply(c, 400, "Content-Type: application/json\r\n",
+                      "{\"error\":\"Missing fields: device_addr(int), param(string), value(int)\"}");
+        return;
+    }
+
+    uint8_t dev_addr = (uint8_t)j_addr->valueint;
+    const char *param = j_param->valuestring;
+    uint16_t value = (uint16_t)j_value->valueint;
+
+    /* Map parameter name to register address */
+    uint16_t reg_addr = 0xFFFF;
+    const char *param_desc = "";
+    if (strcmp(param, "battery_type") == 0) {
+        reg_addr = 0x0000;
+        param_desc = "电池类型";
+    } else if (strcmp(param, "battery_level") == 0) {
+        reg_addr = 0x0001;
+        param_desc = "电池等级";
+    } else if (strcmp(param, "charge_current_limit") == 0) {
+        reg_addr = 0x0002;
+        param_desc = "充电电流限制";
+    } else if (strcmp(param, "full_charge_voltage") == 0) {
+        reg_addr = 0x0005;
+        param_desc = "充满电压";
+    } else if (strcmp(param, "device_address") == 0) {
+        reg_addr = 0x0007;
+        param_desc = "设备485地址";
+    } else if (strcmp(param, "charge_mode") == 0) {
+        reg_addr = 0x0008;
+        param_desc = "充电模式";
+    } else {
+        cJSON_Delete(req);
+        mg_http_reply(c, 400, "Content-Type: application/json\r\n",
+                      "{\"error\":\"Unknown param. Valid: battery_type, battery_level, "
+                      "charge_current_limit, full_charge_voltage, device_address, charge_mode\"}");
+        return;
+    }
+
+    /* Get current battery type for validation */
+    mppt_device_data_t dev_data;
+    uint16_t current_battery_type = 4; /* default to custom */
+    if (modbus_get_device_data(g_ctx.modbus, dev_addr, &dev_data)) {
+        current_battery_type = dev_data.battery_type_raw;
+    }
+
+    /* Validate */
+    if (modbus_validate_setting(reg_addr, value, current_battery_type) != 0) {
+        cJSON_Delete(req);
+        char err_msg[256];
+        snprintf(err_msg, sizeof(err_msg),
+                 "{\"error\":\"Invalid value for %s (reg 0x%04X). Value %u out of range.\"}",
+                 param, reg_addr, value);
+        mg_http_reply(c, 400, "Content-Type: application/json\r\n", "%s", err_msg);
+        return;
+    }
+
+    /* Check if Modbus is connected */
+    if (!g_ctx.modbus || !g_ctx.modbus->connected) {
+        cJSON_Delete(req);
+        mg_http_reply(c, 503, "Content-Type: application/json\r\n",
+                      "{\"error\":\"Modbus not connected\"}");
+        return;
+    }
+
+    /* Write register */
+    int ret = modbus_write_register(g_ctx.modbus, dev_addr, reg_addr, value);
+    cJSON_Delete(req);
+
+    if (ret == 0) {
+        char resp[512];
+        snprintf(resp, sizeof(resp),
+                 "{\"success\":true,\"device_addr\":%d,\"param\":\"%s\","
+                 "\"param_desc\":\"%s\",\"reg_addr\":\"0x%04X\",\"value\":%d,"
+                 "\"message\":\"写入成功\"}",
+                 dev_addr, param, param_desc, reg_addr, value);
+        mg_http_reply(c, 200, "Content-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\n",
+                      "%s", resp);
+        printf("[WEB] Settings write success: dev %d, %s (0x%04X) = %u\n",
+               dev_addr, param, reg_addr, value);
+    } else {
+        char resp[256];
+        snprintf(resp, sizeof(resp),
+                 "{\"success\":false,\"error\":\"Modbus写入失败,请检查设备连接\","
+                 "\"device_addr\":%d,\"param\":\"%s\"}",
+                 dev_addr, param);
+        mg_http_reply(c, 500, "Content-Type: application/json\r\n", "%s", resp);
+    }
+}
+
 /* ---- Embedded HTML Dashboard ---- */
 static const char *HTML_DASHBOARD = NULL;
 
@@ -148,6 +286,8 @@ static void event_handler(struct mg_connection *c, int ev, void *ev_data)
             handle_api_history_stats(c, hm);
         } else if (mg_match(hm->uri, mg_str("/api/history"), NULL)) {
             handle_api_history(c, hm);
+        } else if (mg_match(hm->uri, mg_str("/api/settings"), NULL)) {
+            handle_api_settings(c, hm);
         } else if (mg_match(hm->uri, mg_str("/"), NULL)) {
             /* Serve HTML dashboard */
             if (HTML_DASHBOARD) {

+ 138 - 1
mppt_monitor/www/index.html

@@ -71,6 +71,7 @@ tr:hover td{background:#1e293b}
 <div class="tabs">
   <div class="tab active" onclick="switchPage('realtime')">Real-time Monitor</div>
   <div class="tab" onclick="switchPage('history')">History Query</div>
+  <div class="tab" onclick="switchPage('settings')">Device Settings</div>
 </div>
 
 <!-- Real-time Page -->
@@ -138,6 +139,57 @@ tr:hover td{background:#1e293b}
   </div>
 </div>
 
+<!-- Settings Page -->
+<div class="page" id="page-settings">
+  <div class="history-controls">
+    <h3 style="color:#38bdf8;margin-bottom:12px;font-size:16px">Device Configuration (Output Registers)</h3>
+    <p style="color:#94a3b8;font-size:13px;margin-bottom:16px">Write parameters to MPPT device via Modbus function code 0x10. Changes take effect immediately.</p>
+    <div class="controls-row">
+      <div class="control-group">
+        <label>Target Device</label>
+        <select id="set-device"></select>
+      </div>
+      <div class="control-group">
+        <label>Parameter</label>
+        <select id="set-param" onchange="updateParamInfo()">
+          <option value="battery_type">Battery Type (电池类型)</option>
+          <option value="battery_level">Battery Level (电池等级)</option>
+          <option value="charge_current_limit">Charge Current Limit (充电电流限制 %)</option>
+          <option value="full_charge_voltage">Full Charge Voltage (充满电压, 0.1V)</option>
+          <option value="device_address">485 Address (设备地址)</option>
+          <option value="charge_mode">Charge Mode (充电模式)</option>
+        </select>
+      </div>
+      <div class="control-group">
+        <label>Value</label>
+        <input type="number" id="set-value" min="0" max="255" step="1">
+      </div>
+      <div class="control-group">
+        <label>&nbsp;</label>
+        <button class="btn btn-primary" onclick="writeSetting()" id="btn-write">Write</button>
+      </div>
+    </div>
+    <div id="param-info" style="margin-top:12px;padding:10px;background:#0f172a;border-radius:6px;font-size:12px;color:#94a3b8"></div>
+    <div id="write-result" style="margin-top:12px;padding:10px;border-radius:6px;display:none"></div>
+  </div>
+  <div class="table-container" style="margin-top:20px">
+    <table>
+      <thead><tr><th>Register</th><th>Address</th><th>Range</th><th>Description</th><th>Access</th></tr></thead>
+      <tbody>
+        <tr><td>Battery Type</td><td>40001 (0x0000)</td><td>0-5</td><td>0=胶体 1=密封 2=磷酸铁锂 3=三元锂电 4=自定义 5=加水</td><td style="color:#22c55e">R/W</td></tr>
+        <tr><td>Battery Level</td><td>40002 (0x0001)</td><td>0-6</td><td>0=自动识别 1=12V 2=24V 3=36V 4=48V 5=60V 6=72V</td><td style="color:#22c55e">R/W</td></tr>
+        <tr><td>Charge Current Limit</td><td>40003 (0x0002)</td><td>5-100</td><td>PV充电电流限制 (%)</td><td style="color:#22c55e">R/W</td></tr>
+        <tr><td>Over-discharge Voltage</td><td>40004 (0x0003)</td><td>80-600</td><td>电池过放电压 (0.1V)</td><td style="color:#ef4444">Read Only</td></tr>
+        <tr><td>Boost Voltage</td><td>40005 (0x0004)</td><td>80-600</td><td>电池提升电压 (0.1V)</td><td style="color:#ef4444">Read Only</td></tr>
+        <tr><td>Full Charge Voltage</td><td>40006 (0x0005)</td><td>80-600</td><td>电池充满电压 (0.1V) - 仅自定义电池可写</td><td style="color:#eab308">R/W (Custom only)</td></tr>
+        <tr><td>Recovery Voltage</td><td>40007 (0x0006)</td><td>80-600</td><td>电池过放恢复电压 (0.1V)</td><td style="color:#ef4444">Read Only</td></tr>
+        <tr><td>485 Address</td><td>40008 (0x0007)</td><td>0-247</td><td>设备485通信地址</td><td style="color:#22c55e">R/W</td></tr>
+        <tr><td>Charge Mode</td><td>40009 (0x0008)</td><td>0-1</td><td>0=MPPT 1=DCDC</td><td style="color:#22c55e">R/W</td></tr>
+      </tbody>
+    </table>
+  </div>
+</div>
+
 <script>
 let histPage = 1, histTotalPages = 0, historyChart = null;
 const faultDescs = {20:'Normal',30:'PV Overvoltage',31:'PV Overcurrent',32:'Battery Low',33:'Battery Overvoltage',38:'Temp High',50:'Temp Sensor Fault',88:'Not Decrypted'};
@@ -146,7 +198,9 @@ function switchPage(name) {
   document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
   document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
   document.getElementById('page-' + name).classList.add('active');
-  document.querySelectorAll('.tab')[name === 'realtime' ? 0 : 1].classList.add('active');
+  const tabIdx = name === 'realtime' ? 0 : name === 'history' ? 1 : 2;
+  document.querySelectorAll('.tab')[tabIdx].classList.add('active');
+  if (name === 'settings') updateSettingsDeviceList();
 }
 
 function fmtTime(ts) {
@@ -339,6 +393,89 @@ const now = new Date();
 const start = new Date(now.getTime() - 24*3600000);
 document.getElementById('hist-end').value = now.toISOString().slice(0,16);
 document.getElementById('hist-start').value = start.toISOString().slice(0,16);
+
+// ===== Settings Page Functions =====
+const paramInfo = {
+  battery_type: {desc:'电池类型', range:'0-5', options:'0=胶体 1=密封 2=磷酸铁锂 3=三元锂电 4=自定义 5=加水', note:'注意: 芯片擦写次数最大100000次,不要频繁设置'},
+  battery_level: {desc:'电池电压等级', range:'0-6', options:'0=自动识别 1=12V 2=24V 3=36V 4=48V 5=60V 6=72V', note:'自定义电池类型时忽略此参数'},
+  charge_current_limit: {desc:'光伏充电电流限制', range:'5-100', options:'单位: %', note:'限制光伏最大充电电流百分比'},
+  full_charge_voltage: {desc:'电池充满电压', range:'80-600', options:'单位: 0.1V (如280=28.0V)', note:'仅自定义电池类型(type=4)允许修改'},
+  device_address: {desc:'设备485地址', range:'0-247', options:'Modbus设备地址', note:'修改后需使用新地址重新连接设备'},
+  charge_mode: {desc:'充电运行模式', range:'0-1', options:'0=MPPT 1=DCDC', note:'MPPT=最大功率点跟踪, DCDC=直流转换'}
+};
+
+function updateParamInfo() {
+  const param = document.getElementById('set-param').value;
+  const info = paramInfo[param];
+  document.getElementById('param-info').innerHTML =
+    `<strong>${info.desc}</strong> | 范围: ${info.range} | ${info.options}<br><span style="color:#eab308">⚠ ${info.note}</span>`;
+}
+
+function updateSettingsDeviceList() {
+  fetch('/api/data').then(r=>r.json()).then(data => {
+    const sel = document.getElementById('set-device');
+    sel.innerHTML = '';
+    data.devices.forEach(dev => {
+      const opt = document.createElement('option');
+      opt.value = dev.device_addr;
+      opt.textContent = `Device #${dev.device_addr} ${dev.online ? '(ONLINE)' : '(OFFLINE)'}`;
+      sel.appendChild(opt);
+    });
+  });
+  updateParamInfo();
+}
+
+function writeSetting() {
+  const addr = parseInt(document.getElementById('set-device').value);
+  const param = document.getElementById('set-param').value;
+  const value = parseInt(document.getElementById('set-value').value);
+  const resultDiv = document.getElementById('write-result');
+
+  if (isNaN(addr) || isNaN(value)) {
+    resultDiv.style.display = 'block';
+    resultDiv.style.background = '#7f1d1d';
+    resultDiv.style.color = '#fca5a5';
+    resultDiv.textContent = '请输入有效的设备地址和数值';
+    return;
+  }
+
+  if (!confirm(`确认写入?\n设备: #${addr}\n参数: ${param}\n值: ${value}\n\n注意: 频繁写入可能损坏芯片(最大100000次)`)) {
+    return;
+  }
+
+  const btn = document.getElementById('btn-write');
+  btn.disabled = true;
+  btn.textContent = 'Writing...';
+
+  fetch('/api/settings', {
+    method: 'POST',
+    headers: {'Content-Type': 'application/json'},
+    body: JSON.stringify({device_addr: addr, param: param, value: value})
+  })
+  .then(r => r.json())
+  .then(data => {
+    resultDiv.style.display = 'block';
+    if (data.success) {
+      resultDiv.style.background = '#065f46';
+      resultDiv.style.color = '#6ee7b7';
+      resultDiv.innerHTML = `✓ 写入成功! ${data.param_desc} = ${data.value}`;
+    } else {
+      resultDiv.style.background = '#7f1d1d';
+      resultDiv.style.color = '#fca5a5';
+      resultDiv.textContent = `✗ 写入失败: ${data.error}`;
+    }
+  })
+  .catch(err => {
+    resultDiv.style.display = 'block';
+    resultDiv.style.background = '#7f1d1d';
+    resultDiv.style.color = '#fca5a5';
+    resultDiv.textContent = `✗ 请求失败: ${err.message}`;
+  })
+  .finally(() => {
+    btn.disabled = false;
+    btn.textContent = 'Write';
+  });
+}
 </script>
 </body>
 </html>