3 Commits 7f04bb2b45 ... 3026fddea4

Auteur SHA1 Message Date
  moki 3026fddea4 修改固件 il y a 1 jour
  moki 4db2fd664d 修改固件 il y a 1 jour
  moki 0c815f1b89 修改固件 il y a 1 jour
2 fichiers modifiés avec 178 ajouts et 347 suppressions
  1. 178 30
      firmware/ai_light/ai_light.ino
  2. 0 317
      web/ble-config.html

+ 178 - 30
firmware/ai_light/ai_light.ino

@@ -6,6 +6,8 @@
 #include <BLEUtils.h>
 #include <BLE2902.h>
 #include <Preferences.h>
+#include <HTTPClient.h>
+#include <Update.h>
 
 // =====================================================
 // ESP32-C3 SuperMini + 原玩具公共正极灯板:BLE + MQTT 双模式
@@ -30,32 +32,39 @@
 //     "mqtt_pass": "pass",
 //     "mqtt_client": "AI-Light",
 //     "mqtt_topic": "opencode/status",
-//     "mqtt_status": "openCodeLight/status"
+//     "mqtt_status": "openCodeLight/status",
+//     "pin_red": 4,
+//     "pin_green": 3,
+//     "pin_yellow": 2,
+//     "ota_url": "https://example.com/firmware.bin"
 //   }
 //
-// 接线方式(V3 版本):
+// 接线方式(默认引脚,可通过配置修改):
 //   ESP32 3.3V  -> 原灯板 + / 原电池正极
-//   ESP32 IO2   -> 220Ω -> L1 控制点 = 黄灯
-//   ESP32 IO3   -> 220Ω -> L2 控制点 = 绿灯
-//   ESP32 IO4   -> 220Ω -> L3 控制点 = 红灯
+//   ESP32 IO4   -> 220Ω -> L3 控制点 = 红灯(默认)
+//   ESP32 IO3   -> 220Ω -> L2 控制点 = 绿灯(默认)
+//   ESP32 IO2   -> 220Ω -> L1 控制点 = 黄灯(默认)
+//   ESP32 IO8   -> 状态 LED(固定)
+//   ESP32 IO9   -> BOOT 按钮(固定)
 // =====================================================
 
 // =====================================================
 // BLE 配置
 // =====================================================
 const char* BLE_DEVICE_NAME = "AI-Light";
+const char* FW_VERSION = "1.0.0";
 #define SERVICE_UUID        "b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001"
 #define MODE_CHAR_UUID      "b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001"
 #define CONFIG_CHAR_UUID    "b8b7e003-7a6b-4f4f-9a8b-11c0ffee0001"
 
 // =====================================================
-// 引脚定义(V3 版本
+// 引脚定义(红绿黄可通过配置动态修改,状态灯和按钮固定
 // =====================================================
-const int RED_PIN = 4;
-const int GREEN_PIN = 3;
-const int YELLOW_PIN = 2;
-const int WIFI_LED_PIN = 8;
-const int BTN_BOOT_PIN = 9;
+int redPin = 4;
+int greenPin = 3;
+int yellowPin = 2;
+const int STATUS_PIN = 8;
+const int BUTTON_PIN = 9;
 
 const int PWM_FREQ = 5000;
 const int PWM_RESOLUTION = 8;
@@ -95,6 +104,8 @@ String cfgMqttPass = "";
 String cfgMqttClient = "AI-Light";
 String cfgMqttTopic = "opencode/status";
 String cfgMqttStatus = "openCodeLight/status";
+String cfgOtaUrl = "";
+bool otaInProgress = false;
 
 
 // =====================================================
@@ -111,6 +122,10 @@ void loadConfig() {
   cfgMqttClient = preferences.getString("mqtt_client", "AI-Light");
   cfgMqttTopic = preferences.getString("mqtt_topic", "opencode/status");
   cfgMqttStatus = preferences.getString("mqtt_status", "openCodeLight/status");
+  redPin = preferences.getUInt("pin_red", 4);
+  greenPin = preferences.getUInt("pin_green", 3);
+  yellowPin = preferences.getUInt("pin_yellow", 2);
+  cfgOtaUrl = preferences.getString("ota_url", "");
 }
 
 bool isConfigComplete() {
@@ -119,6 +134,7 @@ bool isConfigComplete() {
 
 String getConfigJson() {
   JsonDocument doc;
+  doc["fw_version"] = FW_VERSION;
   doc["wifi_ssid"] = cfgWifiSsid;
   doc["mqtt_broker"] = cfgMqttBroker;
   doc["mqtt_port"] = cfgMqttPort;
@@ -127,6 +143,10 @@ String getConfigJson() {
   doc["mqtt_topic"] = cfgMqttTopic;
   doc["mqtt_status"] = cfgMqttStatus;
   doc["comm_mode"] = useMQTT ? 1 : 0;
+  doc["pin_red"] = redPin;
+  doc["pin_green"] = greenPin;
+  doc["pin_yellow"] = yellowPin;
+  doc["ota_url"] = cfgOtaUrl;
   String out;
   serializeJson(doc, out);
   return out;
@@ -159,6 +179,14 @@ void saveConfigFromJson(const String& json) {
     preferences.putString("mqtt_topic", doc["mqtt_topic"].as<String>());
   if (doc.containsKey("mqtt_status"))
     preferences.putString("mqtt_status", doc["mqtt_status"].as<String>());
+  if (doc.containsKey("pin_red"))
+    preferences.putUInt("pin_red", doc["pin_red"].as<uint8_t>());
+  if (doc.containsKey("pin_green"))
+    preferences.putUInt("pin_green", doc["pin_green"].as<uint8_t>());
+  if (doc.containsKey("pin_yellow"))
+    preferences.putUInt("pin_yellow", doc["pin_yellow"].as<uint8_t>());
+  if (doc.containsKey("ota_url"))
+    preferences.putString("ota_url", doc["ota_url"].as<String>());
 
   Serial.println("Config saved. Restarting...");
   delay(500);
@@ -177,15 +205,15 @@ void writeLed(int pin, int value) {
 }
 
 void allOff() {
-  writeLed(RED_PIN, 0);
-  writeLed(YELLOW_PIN, 0);
-  writeLed(GREEN_PIN, 0);
+  writeLed(redPin, 0);
+  writeLed(yellowPin, 0);
+  writeLed(greenPin, 0);
 }
 
 void setOnly(int red, int yellow, int green) {
-  writeLed(RED_PIN, constrain(red, 0, RED_MAX));
-  writeLed(YELLOW_PIN, constrain(yellow, 0, YELLOW_MAX));
-  writeLed(GREEN_PIN, constrain(green, 0, GREEN_MAX));
+  writeLed(redPin, constrain(red, 0, RED_MAX));
+  writeLed(yellowPin, constrain(yellow, 0, YELLOW_MAX));
+  writeLed(greenPin, constrain(green, 0, GREEN_MAX));
 }
 
 int triWave(unsigned long t, unsigned long period, int maxValue) {
@@ -413,7 +441,7 @@ bool bootWasPressed = false;
 bool switchTriggered = false;
 
 void checkBootButton() {
-  bool pressed = (digitalRead(BTN_BOOT_PIN) == LOW);
+  bool pressed = (digitalRead(BUTTON_PIN) == LOW);
   if (pressed && !bootWasPressed) {
     bootPressStart = millis();
     bootWasPressed = true;
@@ -450,12 +478,12 @@ bool statusLedState = false;
 void updateStatusLed() {
   bool connected = (useMQTT && wifiConnected) ? (WiFi.status() == WL_CONNECTED) : bleDeviceConnected;
   if (connected) {
-    digitalWrite(WIFI_LED_PIN, LOW);
+    digitalWrite(STATUS_PIN, LOW);
     return;
   }
   if (millis() - lastStatusLedToggle >= 500) {
     statusLedState = !statusLedState;
-    digitalWrite(WIFI_LED_PIN, statusLedState ? LOW : HIGH);
+    digitalWrite(STATUS_PIN, statusLedState ? LOW : HIGH);
     lastStatusLedToggle = millis();
   }
 }
@@ -471,7 +499,14 @@ class ServerCallbacks : public BLEServerCallbacks {
 };
 
 class ModeCharCallbacks : public BLECharacteristicCallbacks {
-  void onWrite(BLECharacteristic* c) { setMode(c->getValue()); }
+  void onWrite(BLECharacteristic* c) {
+    String val = c->getValue();
+    if (val == "ota") {
+      performOTA();
+      return;
+    }
+    setMode(val);
+  }
   void onRead(BLECharacteristic* c) { c->setValue(currentMode.c_str()); }
 };
 
@@ -529,6 +564,10 @@ void mqttCallback(char* topic, byte* payload, unsigned int length) {
     else if (c == "reasoning") setMode("thinking");
     else if (c == "using_tool") setMode("ai");
     else if (c == "error") setMode("error");
+    else if (c == "ota") {
+      performOTA();
+      return;
+    }
     else setMode(c);
   }
 }
@@ -546,7 +585,7 @@ bool connectWiFi() {
     }
     if (WiFi.status() == WL_CONNECTED) {
       Serial.printf("\nWiFi OK. IP: %s\n", WiFi.localIP().toString().c_str());
-      digitalWrite(WIFI_LED_PIN, LOW);
+      digitalWrite(STATUS_PIN, LOW);
       return true;
     }
     Serial.println("\nFailed, retrying..."); delay(2000);
@@ -581,6 +620,114 @@ void checkMQTTConnection() {
 }
 
 
+// =====================================================
+// OTA 固件更新
+// =====================================================
+
+void notifyOtaStatus(const String& status) {
+  if (pModeCharacteristic && bleDeviceConnected) {
+    pModeCharacteristic->setValue(status.c_str());
+    pModeCharacteristic->notify();
+  }
+  if (useMQTT && mqttClient.connected()) {
+    mqttClient.publish(cfgMqttStatus.c_str(), status.c_str(), true);
+  }
+}
+
+void performOTA() {
+  Serial.println("=== OTA Start ===");
+
+  if (otaInProgress) {
+    Serial.println("OTA already in progress");
+    return;
+  }
+
+  if (cfgOtaUrl.length() == 0) {
+    Serial.println("OTA URL not configured");
+    notifyOtaStatus("ota:no_url");
+    return;
+  }
+
+  if (WiFi.status() != WL_CONNECTED) {
+    Serial.println("WiFi not connected");
+    notifyOtaStatus("ota:no_wifi");
+    return;
+  }
+
+  otaInProgress = true;
+  notifyOtaStatus("ota:downloading");
+
+  HTTPClient http;
+  http.begin(cfgOtaUrl);
+  http.setTimeout(30000);
+
+  int httpCode = http.GET();
+  Serial.printf("HTTP GET: %d\n", httpCode);
+
+  if (httpCode != 200) {
+    Serial.printf("HTTP error: %d\n", httpCode);
+    notifyOtaStatus("ota:error");
+    http.end();
+    otaInProgress = false;
+    return;
+  }
+
+  int contentLength = http.getSize();
+  Serial.printf("Firmware size: %d bytes\n", contentLength);
+
+  if (contentLength <= 0) {
+    Serial.println("Invalid content length");
+    notifyOtaStatus("ota:error");
+    http.end();
+    otaInProgress = false;
+    return;
+  }
+
+  if (!Update.begin(contentLength)) {
+    Serial.println("Update.begin failed");
+    notifyOtaStatus("ota:error");
+    http.end();
+    otaInProgress = false;
+    return;
+  }
+
+  WiFiClient* stream = http.getStreamPtr();
+  uint8_t buf[1024];
+  int written = 0;
+  unsigned long lastProgress = millis();
+
+  while (http.connected() && written < contentLength) {
+    size_t size = stream->available();
+    if (size) {
+      int bytesRead = stream->readBytes(buf, min(size, sizeof(buf)));
+      size_t bytesWritten = Update.write(buf, bytesRead);
+      written += bytesWritten;
+
+      if (millis() - lastProgress >= 1000) {
+        Serial.printf("Progress: %d/%d (%.1f%%)\n", written, contentLength, (float)written / contentLength * 100);
+        lastProgress = millis();
+      }
+    }
+    delay(1);
+  }
+
+  Serial.printf("Written: %d/%d\n", written, contentLength);
+
+  if (Update.end(true)) {
+    Serial.println("OTA success!");
+    notifyOtaStatus("ota:success");
+    delay(1000);
+    ESP.restart();
+  } else {
+    Serial.printf("OTA error: %s\n", Update.errorString());
+    notifyOtaStatus("ota:error");
+  }
+
+  http.end();
+  otaInProgress = false;
+}
+
+
 // =====================================================
 // 初始化
 // =====================================================
@@ -589,23 +736,24 @@ void setup() {
   Serial.begin(115200);
   delay(500);
 
-  ledcAttach(RED_PIN, PWM_FREQ, PWM_RESOLUTION);
-  ledcAttach(YELLOW_PIN, PWM_FREQ, PWM_RESOLUTION);
-  ledcAttach(GREEN_PIN, PWM_FREQ, PWM_RESOLUTION);
-  pinMode(WIFI_LED_PIN, OUTPUT);
-  pinMode(BTN_BOOT_PIN, INPUT_PULLUP);
-  digitalWrite(WIFI_LED_PIN, HIGH);
-  allOff();
-
   preferences.begin("ai-light", false);
   useMQTT = (preferences.getUInt("comm_mode", 1) != 0);
   loadConfig();
 
+  ledcAttach(redPin, PWM_FREQ, PWM_RESOLUTION);
+  ledcAttach(yellowPin, PWM_FREQ, PWM_RESOLUTION);
+  ledcAttach(greenPin, PWM_FREQ, PWM_RESOLUTION);
+  pinMode(STATUS_PIN, OUTPUT);
+  pinMode(BUTTON_PIN, INPUT_PULLUP);
+  digitalWrite(STATUS_PIN, HIGH);
+  allOff();
+
   Serial.println();
   Serial.println("=== AI-Light ===");
   Serial.printf("Mode: %s\n", useMQTT ? "MQTT" : "BLE-only");
   Serial.printf("WiFi: %s\n", cfgWifiSsid.length() > 0 ? cfgWifiSsid.c_str() : "(not set)");
   Serial.printf("MQTT: %s\n", cfgMqttBroker.length() > 0 ? cfgMqttBroker.c_str() : "(not set)");
+  Serial.printf("Pins: R=%d G=%d Y=%d\n", redPin, greenPin, yellowPin);
 
   currentMode = "init";
   modeStart = millis();

+ 0 - 317
web/ble-config.html

@@ -1,317 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-<meta charset="UTF-8">
-<meta name="viewport" content="width=device-width,initial-scale=1">
-<title>AI-Light BLE Config</title>
-<style>
-*{box-sizing:border-box;margin:0;padding:0}
-body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#f0f2f5;min-height:100vh;display:flex;flex-direction:column;align-items:center;padding:20px}
-.container{width:100%;max-width:420px}
-h1{font-size:22px;text-align:center;color:#333;margin-bottom:20px}
-h1 small{display:block;font-size:12px;color:#888;font-weight:normal;margin-top:4px}
-.card{background:#fff;border-radius:14px;padding:18px;margin-bottom:14px;box-shadow:0 2px 8px rgba(0,0,0,.06)}
-.card h2{font-size:13px;color:#999;margin-bottom:14px;text-transform:uppercase;letter-spacing:1.5px;font-weight:600}
-label{display:block;font-size:12px;color:#666;margin:10px 0 5px;font-weight:600}
-input,select{width:100%;padding:11px 12px;border:1.5px solid #e0e0e0;border-radius:10px;font-size:14px;outline:none;transition:border .2s;background:#fafafa}
-input:focus,select:focus{border-color:#4CAF50;background:#fff}
-.row{display:flex;gap:10px}
-.row>div{flex:1}
-.btn{display:block;width:100%;padding:14px;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;transition:all .2s;letter-spacing:.5px}
-.btn:active{transform:scale(.98)}
-.btn-connect{background:#2196F3;color:#fff}
-.btn-connect.connected{background:#f44336}
-.btn-save{background:#4CAF50;color:#fff;margin-top:10px}
-.btn-restart{background:#ff9800;color:#fff;margin-top:10px}
-.btn:disabled{opacity:.5;cursor:not-allowed}
-.status-bar{padding:12px 16px;border-radius:10px;font-size:13px;font-weight:500;margin-bottom:14px;text-align:center}
-.status-bar.disconnected{background:#ffebee;color:#c62828}
-.status-bar.connected{background:#e8f5e9;color:#2e7d32}
-.status-bar.connecting{background:#fff3e0;color:#e65100}
-.badge{display:inline-block;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:700}
-.badge-ok{background:#e8f5e9;color:#2e7d32}
-.badge-err{background:#ffebee;color:#c62828}
-.status-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid #f5f5f5}
-.status-row:last-child{border:none}
-.status-label{font-size:13px;color:#999}
-.status-value{font-size:13px;color:#333;font-weight:600}
-.log{background:#263238;color:#a5d6a7;border-radius:10px;padding:12px;font-family:'SF Mono',monospace;font-size:11px;max-height:120px;overflow-y:auto;line-height:1.6;word-break:break-all}
-.hidden{display:none}
-.mode-btns{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
-.mode-btn{padding:10px 4px;border:1.5px solid #e0e0e0;border-radius:10px;background:#fff;font-size:12px;font-weight:600;cursor:pointer;text-align:center;transition:all .15s}
-.mode-btn:hover{border-color:#4CAF50;background:#f1f8e9}
-.mode-btn:active{transform:scale(.95)}
-.mode-btn.active{border-color:#4CAF50;background:#4CAF50;color:#fff}
-</style>
-</head>
-<body>
-<div class="container">
-  <h1>AI-Light<small>Web Bluetooth Configuration</small></h1>
-
-  <!-- Connection -->
-  <div id="conn-bar" class="status-bar disconnected">Not connected</div>
-  <button id="btn-connect" class="btn btn-connect" onclick="toggleConnect()">Connect</button>
-
-  <!-- Status (shown after connect) -->
-  <div id="sec-status" class="card hidden">
-    <h2>Device Status</h2>
-    <div class="status-row"><span class="status-label">WiFi</span><span class="status-value" id="s-wifi">-</span></div>
-    <div class="status-row"><span class="status-label">MQTT</span><span class="status-value" id="s-mqtt">-</span></div>
-    <div class="status-row"><span class="status-label">Mode</span><span class="status-value" id="s-mode">-</span></div>
-    <div class="status-row"><span class="status-label">Comm</span><span class="status-value" id="s-comm">-</span></div>
-  </div>
-
-  <!-- Light Mode (shown after connect) -->
-  <div id="sec-mode" class="card hidden">
-    <h2>Light Mode</h2>
-    <div class="mode-btns">
-      <div class="mode-btn" onclick="setMode('traffic')">Traffic</div>
-      <div class="mode-btn" onclick="setMode('thinking')">Thinking</div>
-      <div class="mode-btn" onclick="setMode('ai')">AI</div>
-      <div class="mode-btn" onclick="setMode('busy')">Busy</div>
-      <div class="mode-btn" onclick="setMode('success')">Success</div>
-      <div class="mode-btn" onclick="setMode('error')">Error</div>
-      <div class="mode-btn" onclick="setMode('alarm')">Alarm</div>
-      <div class="mode-btn" onclick="setMode('init')">Init</div>
-      <div class="mode-btn" onclick="setMode('off')">Off</div>
-    </div>
-  </div>
-
-  <!-- WiFi Config -->
-  <div id="sec-wifi" class="card hidden">
-    <h2>WiFi</h2>
-    <label>SSID</label>
-    <input id="f-ssid" placeholder="WiFi name">
-    <label>Password</label>
-    <input id="f-pass" type="password" placeholder="WiFi password">
-  </div>
-
-  <!-- MQTT Config -->
-  <div id="sec-mqtt" class="card hidden">
-    <h2>MQTT</h2>
-    <label>Broker</label>
-    <input id="f-broker" placeholder="192.168.1.100">
-    <div class="row">
-      <div><label>Port</label><input id="f-port" type="number" value="1883"></div>
-      <div><label>Client ID</label><input id="f-client" value="AI-Light"></div>
-    </div>
-    <label>Username</label>
-    <input id="f-muser" placeholder="(optional)">
-    <label>Password</label>
-    <input id="f-mpass" type="password" placeholder="(optional)">
-    <label>Subscribe Topic</label>
-    <input id="f-topic" placeholder="opencode/status">
-    <label>Status Topic</label>
-    <input id="f-stopic" placeholder="openCodeLight/status">
-  </div>
-
-  <!-- Actions (shown after connect) -->
-  <div id="sec-actions" class="hidden">
-    <button class="btn btn-save" onclick="saveConfig()">Save & Restart</button>
-    <button class="btn btn-restart" onclick="restartDevice()">Restart</button>
-  </div>
-
-  <!-- Log -->
-  <div id="sec-log" class="card hidden">
-    <h2>Log</h2>
-    <div id="log" class="log"></div>
-  </div>
-</div>
-
-<script>
-const SERVICE_UUID = 'b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001';
-const MODE_UUID = 'b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001';
-const CONFIG_UUID = 'b8b7e003-7a6b-4f4f-9a8b-11c0ffee0001';
-
-let device = null;
-let modeChar = null;
-let configChar = null;
-let connected = false;
-
-const $ = id => document.getElementById(id);
-const logEl = $('log');
-
-function log(msg) {
-  const t = new Date().toLocaleTimeString();
-  logEl.innerHTML += `<div>[${t}] ${msg}</div>`;
-  logEl.scrollTop = logEl.scrollHeight;
-}
-
-function showSections(show) {
-  ['sec-status','sec-mode','sec-wifi','sec-mqtt','sec-actions','sec-log'].forEach(id => {
-    $(id).classList.toggle('hidden', !show);
-  });
-}
-
-function setStatus(text, type) {
-  const bar = $('conn-bar');
-  bar.textContent = text;
-  bar.className = 'status-bar ' + type;
-}
-
-async function toggleConnect() {
-  if (connected) {
-    if (device && device.gatt.connected) device.gatt.disconnect();
-    return;
-  }
-
-  try {
-    setStatus('Scanning...', 'connecting');
-    log('Requesting BLE device...');
-
-    device = await navigator.bluetooth.requestDevice({
-      filters: [{ name: 'AI-Light' }],
-      optionalServices: [SERVICE_UUID]
-    });
-
-    log('Device found: ' + device.name);
-    setStatus('Connecting...', 'connecting');
-
-    device.addEventListener('gattserverdisconnected', onDisconnected);
-
-    const server = await device.gatt.connect();
-    log('GATT connected');
-
-    const service = await server.getPrimaryService(SERVICE_UUID);
-    log('Service found');
-
-    modeChar = await service.getCharacteristic(MODE_UUID);
-    configChar = await service.getCharacteristic(CONFIG_UUID);
-    log('Characteristics found');
-
-    modeChar.addEventListener('characteristicvaluechanged', onModeChanged);
-    await modeChar.startNotifications();
-    log('Mode notifications enabled');
-
-    connected = true;
-    setStatus('Connected: ' + device.name, 'connected');
-    $('btn-connect').textContent = 'Disconnect';
-    $('btn-connect').classList.add('connected');
-    showSections(true);
-
-    await readConfig();
-    await readMode();
-
-  } catch (err) {
-    log('Error: ' + err.message);
-    setStatus('Connection failed', 'disconnected');
-    connected = false;
-    showSections(false);
-  }
-}
-
-function onDisconnected() {
-  connected = false;
-  modeChar = null;
-  configChar = null;
-  setStatus('Disconnected', 'disconnected');
-  $('btn-connect').textContent = 'Connect';
-  $('btn-connect').classList.remove('connected');
-  showSections(false);
-  log('Device disconnected');
-}
-
-function onModeChanged(event) {
-  const decoder = new TextDecoder();
-  const mode = decoder.decode(event.target.value).trim();
-  $('s-mode').textContent = mode;
-  log('Mode: ' + mode);
-  updateModeButtons(mode);
-}
-
-function updateModeButtons(mode) {
-  document.querySelectorAll('.mode-btn').forEach(btn => {
-    btn.classList.toggle('active', btn.textContent.toLowerCase() === mode);
-  });
-}
-
-async function readMode() {
-  try {
-    const val = await modeChar.readValue();
-    const decoder = new TextDecoder();
-    const mode = decoder.decode(val).trim();
-    $('s-mode').textContent = mode;
-    updateModeButtons(mode);
-    log('Current mode: ' + mode);
-  } catch (err) {
-    log('Read mode error: ' + err.message);
-  }
-}
-
-async function readConfig() {
-  try {
-    const val = await configChar.readValue();
-    const decoder = new TextDecoder();
-    const json = decoder.decode(val);
-    log('Config loaded');
-    const cfg = JSON.parse(json);
-
-    $('f-ssid').value = cfg.wifi_ssid || '';
-    $('f-broker').value = cfg.mqtt_broker || '';
-    $('f-port').value = cfg.mqtt_port || 1883;
-    $('f-client').value = cfg.mqtt_client || 'AI-Light';
-    $('f-muser').value = cfg.mqtt_user || '';
-    $('f-topic').value = cfg.mqtt_topic || '';
-    $('f-stopic').value = cfg.mqtt_status || '';
-
-    $('s-wifi').innerHTML = cfg.wifi_ssid ? '<span class="badge badge-ok">' + cfg.wifi_ssid + '</span>' : '<span class="badge badge-err">Not configured</span>';
-    $('s-mqtt').innerHTML = cfg.mqtt_broker ? '<span class="badge badge-ok">' + cfg.mqtt_broker + '</span>' : '<span class="badge badge-err">Not configured</span>';
-    $('s-comm').textContent = cfg.comm_mode === 1 ? 'MQTT' : 'BLE-only';
-
-  } catch (err) {
-    log('Read config error: ' + err.message);
-  }
-}
-
-async function setMode(mode) {
-  if (!modeChar) return;
-  try {
-    const encoder = new TextEncoder();
-    await modeChar.writeValue(encoder.encode(mode));
-    log('Set mode: ' + mode);
-  } catch (err) {
-    log('Set mode error: ' + err.message);
-  }
-}
-
-async function saveConfig() {
-  if (!configChar) return;
-  const cfg = {
-    wifi_ssid: $('f-ssid').value,
-    wifi_pass: $('f-pass').value,
-    mqtt_broker: $('f-broker').value,
-    mqtt_port: parseInt($('f-port').value) || 1883,
-    mqtt_user: $('f-muser').value,
-    mqtt_pass: $('f-mpass').value,
-    mqtt_client: $('f-client').value,
-    mqtt_topic: $('f-topic').value,
-    mqtt_status: $('f-stopic').value
-  };
-
-  try {
-    const encoder = new TextEncoder();
-    await configChar.writeValue(encoder.encode(JSON.stringify(cfg)));
-    log('Config saved! Device will restart...');
-    alert('Config saved! Device will restart.');
-  } catch (err) {
-    log('Save config error: ' + err.message);
-    alert('Error: ' + err.message);
-  }
-}
-
-async function restartDevice() {
-  if (!confirm('Restart device?')) return;
-  if (!configChar) return;
-  try {
-    const encoder = new TextEncoder();
-    await configChar.writeValue(encoder.encode('{"restart":true}'));
-    log('Restart command sent');
-  } catch (err) {
-    log('Restart error: ' + err.message);
-  }
-}
-
-showSections(false);
-</script>
-</body>
-</html>