feat: implement Web UI dashboard, Wi-Fi AP/STA dual mode, Captive Portal, mDNS, and RESTful APIs

This commit is contained in:
cuicuiV5 committed 2026-08-27 21:03:03 +08:00
1 parent 61fc819a18
commit caf5da201b
8 files changed
+739 -16

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
#include "app_log.h"
#include <ArduinoJson.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
static char s_log_lines[MAX_LOG_LINES][MAX_LOG_LINE_LEN];
static size_t s_log_head = 0;
static size_t s_log_count = 0;
static portMUX_TYPE s_log_mux = portMUX_INITIALIZER_UNLOCKED;
void app_log_init(void) {
s_log_head = 0;
s_log_count = 0;
memset(s_log_lines, 0, sizeof(s_log_lines));
}
void app_log(const char* tag, const char* format, ...) {
char msg_buf[128];
va_list args;
va_start(args, format);
vsnprintf(msg_buf, sizeof(msg_buf), format, args);
va_end(args);
uint32_t now_ms = millis();
uint32_t sec = now_ms / 1000;
uint32_t ms = now_ms % 1000;
char full_line[MAX_LOG_LINE_LEN];
snprintf(full_line, sizeof(full_line), "[%04u.%03u] [%s] %s", (unsigned int)sec, (unsigned int)ms, tag, msg_buf);
// 1. Output to Serial
Serial.println(full_line);
// 2. Store to circular buffer
taskENTER_CRITICAL(&s_log_mux);
strncpy(s_log_lines[s_log_head], full_line, MAX_LOG_LINE_LEN - 1);
s_log_lines[s_log_head][MAX_LOG_LINE_LEN - 1] = '\0';
s_log_head = (s_log_head + 1) % MAX_LOG_LINES;
if (s_log_count < MAX_LOG_LINES) {
s_log_count++;
}
taskEXIT_CRITICAL(&s_log_mux);
}
String app_log_get_json(void) {
JsonDocument doc;
JsonArray arr = doc["logs"].to<JsonArray>();
taskENTER_CRITICAL(&s_log_mux);
size_t start_idx = (s_log_count < MAX_LOG_LINES) ? 0 : s_log_head;
for (size_t i = 0; i < s_log_count; i++) {
size_t idx = (start_idx + i) % MAX_LOG_LINES;
arr.add(s_log_lines[idx]);
}
taskEXIT_CRITICAL(&s_log_mux);
String out;
serializeJson(doc, out);
return out;
}
void app_log_clear(void) {
taskENTER_CRITICAL(&s_log_mux);
s_log_head = 0;
s_log_count = 0;
taskEXIT_CRITICAL(&s_log_mux);
}
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <Arduino.h>
#define MAX_LOG_LINES 60
#define MAX_LOG_LINE_LEN 160
#ifdef __cplusplus
extern "C" {
#endif
void app_log_init(void);
void app_log(const char* tag, const char* format, ...);
String app_log_get_json(void);
void app_log_clear(void);
#ifdef __cplusplus
}
#endif
+33 -16
View File
@@ -1,6 +1,9 @@
#include <Arduino.h>
#include "app_config.h"
#include "version.h"
#include "log/app_log.h"
#include "wifi/wifi_manager.h"
#include "web/web_server.h"
#include "audio/audio_pipeline.h"
#include "keymap/key_state_machine.h"
#include "usb/usb_composite.h"
@@ -11,7 +14,7 @@ key_mapper_engine_t g_key_engine;
// Task running on Core 0: BLE Central & Audio Decoding
static void ble_task_core0(void* param) {
Serial.printf("[SYSTEM] BLE & Audio Task started on Core %d\n", xPortGetCoreID());
app_log("SYSTEM", "BLE & Audio Task started on Core %d", xPortGetCoreID());
ble_remote_init();
while (true) {
@@ -22,29 +25,37 @@ static void ble_task_core0(void* param) {
void setup() {
Serial.begin(115200);
delay(500);
delay(400);
Serial.println("==================================================");
Serial.printf(" %s v%s (%s)\n", FIRMWARE_NAME, FIRMWARE_VERSION, HARDWARE_TARGET);
Serial.println(" Xiaomi Remote Hardware Bridge (BLE -> USB)");
Serial.println("==================================================");
// 1. Initialize Global Log System
app_log_init();
app_log("SYSTEM", "==================================================");
app_log("SYSTEM", " %s v%s (%s)", FIRMWARE_NAME, FIRMWARE_VERSION, HARDWARE_TARGET);
app_log("SYSTEM", " Xiaomi Remote Hardware Bridge (BLE -> USB + Web)");
app_log("SYSTEM", "==================================================");
// 1. Initialize Audio Pipeline
// 2. Initialize Audio Pipeline
audio_pipeline_init(&g_audio_pipeline);
Serial.println("[INIT] Audio Pipeline initialized (16kHz 16-bit Mono)");
app_log("INIT", "Audio Pipeline initialized (16kHz 16-bit Mono UAC 1.0)");
// 2. Initialize Key Engine with USB HID dispatcher callback
// 3. Initialize Key Engine with USB HID dispatcher callback
key_engine_init(&g_key_engine, usb_hid_dispatch_action);
Serial.printf("[INIT] Key Engine initialized with %u mappings\n", (unsigned int)g_key_engine.binding_count);
app_log("INIT", "Key Engine initialized with %u mappings", (unsigned int)g_key_engine.binding_count);
// 3. Initialize USB Composite Stack (UAC Mic + HID Keyboard + Consumer + CDC)
// 4. Initialize USB Composite Stack (UAC Mic + HID Keyboard + Consumer + CDC)
usb_composite_init();
Serial.println("[INIT] USB Composite Device ready (UAC Mic + HID Keyboard/Consumer)");
app_log("INIT", "USB Composite Device ready");
// 4. Initialize CLI Manager
// 5. Initialize Serial / CDC CLI Manager
cli_manager_init();
// 5. Launch BLE Central Task pinned to Core 0
// 6. Initialize Wi-Fi AP + STA & Captive Portal
wifi_manager_init();
// 7. Initialize Embedded Web Server & REST APIs
web_server_init();
// 8. Launch BLE Central Task pinned to Core 0
xTaskCreatePinnedToCore(
ble_task_core0,
"ble_audio_task",
@@ -55,7 +66,7 @@ void setup() {
TASK_CORE_BLE
);
Serial.println("[SYSTEM] System initialization complete. Ready!");
app_log("SYSTEM", "System initialization complete. Web available at http://192.168.4.1 or http://remotemapper.local");
}
void loop() {
@@ -67,7 +78,13 @@ void loop() {
// 2. Service Key State Machine timers (long press, double click, repeat)
key_engine_tick(&g_key_engine, now);
// 3. Service Serial / WebSerial CLI commands
// 3. Service Wi-Fi & DNS tasks
wifi_manager_task();
// 4. Service HTTP Web Server
web_server_task();
// 5. Service Serial / WebSerial CLI commands
cli_manager_task();
delay(2);
+132
View File
@@ -0,0 +1,132 @@
#include "web_server.h"
#include "web_ui.h"
#include "log/app_log.h"
#include "wifi/wifi_manager.h"
#include "version.h"
#include "ble/ble_remote_client.h"
#include "audio/audio_pipeline.h"
#include "keymap/key_state_machine.h"
#include <WebServer.h>
#include <ArduinoJson.h>
static WebServer s_server(80);
extern key_mapper_engine_t g_key_engine;
static void handle_root() {
s_server.sendHeader("Connection", "close");
s_server.send(200, "text/html; charset=utf-8", INDEX_HTML);
}
static void handle_status() {
JsonDocument doc;
doc["firmware"] = FIRMWARE_NAME;
doc["version"] = FIRMWARE_VERSION;
doc["uptime_sec"] = millis() / 1000;
doc["ble_state"] = (int)ble_remote_get_state();
doc["frames_decoded"] = g_audio_pipeline.total_frames_decoded;
doc["samples_pushed"] = g_audio_pipeline.total_samples_pushed;
doc["free_heap"] = ESP.getFreeHeap();
doc["free_psram"] = ESP.getFreePsram();
doc["ap_ip"] = wifi_manager_get_ap_ip();
doc["sta_ip"] = wifi_manager_get_sta_ip();
doc["sta_connected"] = wifi_manager_is_sta_connected();
String out;
serializeJson(doc, out);
s_server.send(200, "application/json", out);
}
static void handle_logs() {
String json = app_log_get_json();
s_server.send(200, "application/json", json);
}
static void handle_logs_clear() {
app_log_clear();
s_server.send(200, "application/json", "{\"status\":\"cleared\"}");
}
static void handle_wifi_scan() {
String json = wifi_manager_scan_json();
s_server.send(200, "application/json", json);
}
static void handle_wifi_config() {
if (!s_server.hasArg("plain")) {
s_server.send(400, "application/json", "{\"error\":\"missing_body\"}");
return;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, s_server.arg("plain"));
if (err) {
s_server.send(400, "application/json", "{\"error\":\"invalid_json\"}");
return;
}
String ssid = doc["ssid"] | "";
String pass = doc["pass"] | "";
if (ssid.length() == 0) {
s_server.send(400, "application/json", "{\"error\":\"empty_ssid\"}");
return;
}
wifi_manager_save_sta_config(ssid, pass);
s_server.send(200, "application/json", "{\"status\":\"ok\"}");
}
static void handle_keymap_reset() {
key_engine_load_defaults(&g_key_engine);
app_log("KEYMAP", "Reset keymap to factory defaults via Web API");
s_server.send(200, "application/json", "{\"status\":\"reset_ok\"}");
}
static void handle_ble_reconnect() {
app_log("BLE", "Triggered manual reconnect scan via Web API");
ble_remote_trigger_reconnect();
s_server.send(200, "application/json", "{\"status\":\"reconnecting\"}");
}
static void handle_system_restart() {
app_log("SYSTEM", "Rebooting ESP32 via Web API...");
s_server.send(200, "application/json", "{\"status\":\"rebooting\"}");
delay(500);
ESP.restart();
}
static void handle_captive_portal() {
String host = s_server.hostHeader();
if (host != "192.168.4.1" && host != "remotemapper.local") {
s_server.sendHeader("Location", "http://192.168.4.1/", true);
s_server.send(302, "text/plain", "");
return;
}
handle_root();
}
void web_server_init(void) {
// API Routes
s_server.on("/", HTTP_GET, handle_root);
s_server.on("/api/status", HTTP_GET, handle_status);
s_server.on("/api/logs", HTTP_GET, handle_logs);
s_server.on("/api/logs/clear", HTTP_POST, handle_logs_clear);
s_server.on("/api/wifi/scan", HTTP_GET, handle_wifi_scan);
s_server.on("/api/wifi/config", HTTP_POST, handle_wifi_config);
s_server.on("/api/keymap/reset", HTTP_POST, handle_keymap_reset);
s_server.on("/api/ble/reconnect", HTTP_POST, handle_ble_reconnect);
s_server.on("/api/system/restart", HTTP_POST, handle_system_restart);
// Captive Portal probe redirects
s_server.on("/generate_204", HTTP_GET, handle_captive_portal);
s_server.on("/hotspot-detect.html", HTTP_GET, handle_captive_portal);
s_server.on("/canonical.html", HTTP_GET, handle_captive_portal);
s_server.on("/connecttest.txt", HTTP_GET, handle_captive_portal);
s_server.onNotFound(handle_captive_portal);
s_server.begin();
app_log("WEB", "HTTP Web Server started on port 80");
}
void web_server_task(void) {
s_server.handleClient();
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
void web_server_init(void);
void web_server_task(void);
#ifdef __cplusplus
}
#endif
+325
View File
@@ -0,0 +1,325 @@
#pragma once
#include <Arduino.h>
static const char INDEX_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RemoteMapper 控制台</title>
<style>
:root {
--bg: #0d1117;
--card-bg: #161b22;
--card-border: #30363d;
--accent: #58a6ff;
--accent-glow: rgba(88, 166, 255, 0.2);
--success: #238636;
--success-glow: rgba(35, 134, 54, 0.3);
--warning: #d29922;
--danger: #da3633;
--text: #c9d1d9;
--text-bright: #f0f6fc;
--text-muted: #8b949e;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; }
body { background-color: var(--bg); color: var(--text); padding: 16px; font-size: 14px; line-height: 1.5; }
.container { max-width: 960px; margin: 0 auto; }
header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 16px; border-bottom: 1px solid var(--card-border); margin-bottom: 20px; }
.logo { display: flex; align-items: center; gap: 10px; }
.logo-icon { font-size: 24px; background: var(--accent-glow); padding: 8px; border-radius: 8px; }
.logo h1 { font-size: 18px; color: var(--text-bright); }
.logo span { font-size: 12px; color: var(--text-muted); }
.status-pills { display: flex; gap: 8px; flex-wrap: wrap; }
.pill { font-size: 12px; padding: 4px 10px; border-radius: 12px; border: 1px solid var(--card-border); background: var(--card-bg); }
.pill.green { color: #3fb950; border-color: #238636; }
.pill.yellow { color: #d29922; border-color: #9e6a03; }
.pill.blue { color: #58a6ff; border-color: #1f6feb; }
/* Tabs */
.tabs { display: flex; gap: 8px; margin-bottom: 20px; border-bottom: 1px solid var(--card-border); padding-bottom: 8px; overflow-x: auto; }
.tab-btn { background: none; border: none; color: var(--text-muted); padding: 8px 16px; font-size: 14px; font-weight: 500; cursor: pointer; border-radius: var(--radius); transition: all 0.2s; white-space: nowrap; }
.tab-btn:hover { color: var(--text-bright); background: rgba(255,255,255,0.05); }
.tab-btn.active { color: var(--accent); background: var(--accent-glow); }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* Grid & Cards */
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-bottom: 20px; }
.card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); padding: 16px; }
.card-title { font-size: 15px; color: var(--text-bright); font-weight: 600; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
.stat-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
.stat-row:last-child { border-bottom: none; }
.stat-label { color: var(--text-muted); }
.stat-val { color: var(--text-bright); font-weight: 500; font-family: Consolas, monospace; }
/* Buttons & Inputs */
.btn { background: #21262d; color: var(--text-bright); border: 1px solid var(--card-border); padding: 8px 14px; border-radius: var(--radius); cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { background: #30363d; border-color: #8b949e; }
.btn-primary { background: #1f6feb; border-color: #388bfd; color: #fff; }
.btn-primary:hover { background: #388bfd; }
.btn-success { background: #238636; border-color: #2ea043; color: #fff; }
.btn-success:hover { background: #2ea043; }
.btn-danger { background: #da3633; border-color: #f85149; color: #fff; }
.btn-danger:hover { background: #f85149; }
.form-group { margin-bottom: 12px; }
.form-label { display: block; margin-bottom: 4px; color: var(--text-muted); font-size: 12px; }
.form-control { width: 100%; padding: 8px 12px; background: #0d1117; border: 1px solid var(--card-border); border-radius: var(--radius); color: var(--text-bright); font-size: 13px; outline: none; }
.form-control:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow); }
/* Log Box */
.log-box { background: #010409; border: 1px solid var(--card-border); border-radius: var(--radius); height: 360px; overflow-y: auto; padding: 12px; font-family: Consolas, monospace; font-size: 12px; line-height: 1.6; color: #7ee787; }
.log-line { margin-bottom: 2px; word-break: break-all; }
/* Keymap list */
.key-item { display: flex; justify-content: space-between; align-items: center; padding: 10px; background: #0d1117; border: 1px solid var(--card-border); border-radius: var(--radius); margin-bottom: 8px; }
.key-badge { background: #21262d; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; color: var(--accent); }
.key-desc { font-size: 12px; color: var(--text-muted); }
</style>
</head>
<body>
<div class="container">
<header>
<div class="logo">
<div class="logo-icon">🎙️</div>
<div>
<h1>RemoteMapper ESP32-S3</h1>
<span>小米蓝牙语音遥控器 硬件复合中继系统</span>
</div>
</div>
<div class="status-pills">
<div class="pill green" id="pill-ble">BLE: 已连接</div>
<div class="pill blue" id="pill-wifi">Wi-Fi: AP+STA</div>
<div class="pill yellow" id="pill-sta-ip">IP: 192.168.4.1</div>
</div>
</header>
<div class="tabs">
<button class="tab-btn active" onclick="showTab('dashboard')">📊 状态仪表盘</button>
<button class="tab-btn" onclick="showTab('keymap')">🎮 按键配置</button>
<button class="tab-btn" onclick="showTab('ble')">📡 蓝牙管理</button>
<button class="tab-btn" onclick="showTab('wifi')">📶 Wi-Fi 配网</button>
<button class="tab-btn" onclick="showTab('logs')">📜 实时日志</button>
</div>
<!-- 1. Dashboard Tab -->
<div id="tab-dashboard" class="tab-content active">
<div class="grid-2">
<div class="card">
<div class="card-title">🎙️ BLE 遥控器与音频流</div>
<div class="stat-row"><span class="stat-label">连接状态</span><span class="stat-val" id="stat-ble-state">Connected</span></div>
<div class="stat-row"><span class="stat-label">音频规格</span><span class="stat-val">16kHz 16-bit Mono (UAC 1.0)</span></div>
<div class="stat-row"><span class="stat-label">已解码音频帧</span><span class="stat-val" id="stat-frames">0 帧</span></div>
<div class="stat-row"><span class="stat-label">已推流采样点</span><span class="stat-val" id="stat-samples">0 点</span></div>
<div style="margin-top: 14px; display: flex; gap: 8px;">
<button class="btn btn-primary" onclick="apiAction('/api/ble/reconnect')">🔄 重新扫描连接</button>
</div>
</div>
<div class="card">
<div class="card-title">⚡ 硬件与系统资源</div>
<div class="stat-row"><span class="stat-label">主控芯片</span><span class="stat-val">ESP32-S3 N16R8</span></div>
<div class="stat-row"><span class="stat-label">运行时间</span><span class="stat-val" id="stat-uptime">0s</span></div>
<div class="stat-row"><span class="stat-label">空闲堆内存 (Heap)</span><span class="stat-val" id="stat-heap">0 KB</span></div>
<div class="stat-row"><span class="stat-label">空闲 PSRAM</span><span class="stat-val" id="stat-psram">0 KB</span></div>
<div style="margin-top: 14px; display: flex; gap: 8px;">
<button class="btn btn-danger" onclick="apiAction('/api/system/restart')">⚠️ 重启 ESP32</button>
</div>
</div>
</div>
<div class="card">
<div class="card-title">🌐 网络访问信息</div>
<div class="stat-row"><span class="stat-label">AP 热点 IP (直连)</span><span class="stat-val">192.168.4.1 (SSID: RemoteMapper-AP)</span></div>
<div class="stat-row"><span class="stat-label">家庭局域网 IP (STA)</span><span class="stat-val" id="stat-sta-ip">获取中...</span></div>
<div class="stat-row"><span class="stat-label">局域网 mDNS 快速访问</span><span class="stat-val"><a href="http://remotemapper.local" target="_blank" style="color:var(--accent);">http://remotemapper.local</a></span></div>
</div>
</div>
<!-- 2. Keymap Tab -->
<div id="tab-keymap" class="tab-content">
<div class="card">
<div class="card-title">
<span>🎮 默认物理按键映射规则</span>
<button class="btn btn-primary" onclick="resetKeymap()">恢复默认映射</button>
</div>
<div class="key-item">
<div><strong>音量加 (+) / 音量减 (-)</strong><div class="key-desc">0x80 / 0x81 (原始 Android 键码)</div></div>
<div class="key-badge">USB Consumer Vol Up/Down (连续连发)</div>
</div>
<div class="key-item">
<div><strong>返回键 (Back)</strong><div class="key-desc">0xF1 (原始 Android 键码)</div></div>
<div class="key-badge">USB Consumer AC Back (浏览器/播放器后退)</div>
</div>
<div class="key-item">
<div><strong>语音键 (Voice HTT)</strong><div class="key-desc">按住说话 / 松开结束</div></div>
<div class="key-badge">UAC 麦克风音频流 + 注入 RAlt+Comma</div>
</div>
<div class="key-item">
<div><strong>电源键 (Power)</strong><div class="key-desc">单击 / 长按</div></div>
<div class="key-badge">单击: Alt+Tab | 长按: Sleep</div>
</div>
<div class="key-item">
<div><strong>主页键 (Home)</strong><div class="key-desc">单击</div></div>
<div class="key-badge">USB Keyboard Win+D (显示桌面)</div>
</div>
<div class="key-item">
<div><strong>菜单键 (Menu)</strong><div class="key-desc">单击</div></div>
<div class="key-badge">USB Keyboard Space (播放/暂停)</div>
</div>
<div class="key-item">
<div><strong>直播/TV 键</strong><div class="key-desc">单击</div></div>
<div class="key-badge">USB Keyboard F8</div>
</div>
</div>
</div>
<!-- 3. BLE Tab -->
<div id="tab-ble" class="tab-content">
<div class="card">
<div class="card-title">
<span>📡 扫描周围蓝牙遥控器</span>
<button class="btn btn-primary" onclick="scanBle()">🔍 开始扫描</button>
</div>
<div id="ble-list" style="margin-top: 10px;">
<p style="color:var(--text-muted);">点击上方按钮扫描周围的小米蓝牙语音遥控器 (MI RC)...</p>
</div>
</div>
</div>
<!-- 4. Wi-Fi Tab -->
<div id="tab-wifi" class="tab-content">
<div class="card">
<div class="card-title">📶 连接家庭局域网 Wi-Fi</div>
<p style="color:var(--text-muted); margin-bottom: 12px;">配置连接路由器后,您可以在家庭局域网内任意手机或电脑通过 <strong>http://remotemapper.local</strong> 直接打开本配置面板。</p>
<div class="form-group">
<label class="form-label">周围 2.4GHz Wi-Fi</label>
<div style="display:flex; gap:8px;">
<select class="form-control" id="wifi-ssid-select" onchange="document.getElementById('wifi-ssid').value = this.value">
<option value="">-- 点击右侧扫描获取列表 --</option>
</select>
<button class="btn" onclick="scanWifi()">🔍 扫描</button>
</div>
</div>
<div class="form-group">
<label class="form-label">Wi-Fi 名称 (SSID)</label>
<input type="text" class="form-control" id="wifi-ssid" placeholder="输入或上方选择 Wi-Fi 名称">
</div>
<div class="form-group">
<label class="form-label">Wi-Fi 密码</label>
<input type="password" class="form-control" id="wifi-pass" placeholder="输入 Wi-Fi 密码">
</div>
<button class="btn btn-success" onclick="saveWifi()">💾 保存并连接</button>
</div>
</div>
<!-- 5. Logs Tab -->
<div id="tab-logs" class="tab-content">
<div class="card">
<div class="card-title">
<span>📜 ESP32-S3 实时运行日志</span>
<div style="display:flex; gap:8px;">
<button class="btn" onclick="refreshLogs()">🔄 刷新</button>
<button class="btn btn-danger" onclick="clearLogs()">🧹 清空</button>
</div>
</div>
<div class="log-box" id="log-container">加载日志中...</div>
</div>
</div>
</div>
<script>
function showTab(id) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('tab-' + id).classList.add('active');
if (id === 'logs') refreshLogs();
}
async function fetchStatus() {
try {
const res = await fetch('/api/status');
const data = await res.json();
document.getElementById('stat-ble-state').innerText = data.ble_state === 3 ? 'Connected (已连接)' : (data.ble_state === 4 ? 'Talking (语音推流中)' : 'Scanning / Disconnected');
document.getElementById('pill-ble').innerText = 'BLE: ' + (data.ble_state >= 3 ? '已连接' : '未连接');
document.getElementById('pill-ble').className = 'pill ' + (data.ble_state >= 3 ? 'green' : 'yellow');
document.getElementById('stat-frames').innerText = (data.frames_decoded || 0) + ' 帧';
document.getElementById('stat-samples').innerText = (data.samples_pushed || 0) + ' 点';
document.getElementById('stat-uptime').innerText = (data.uptime_sec || 0) + ' 秒';
document.getElementById('stat-heap').innerText = Math.round((data.free_heap || 0) / 1024) + ' KB';
document.getElementById('stat-psram').innerText = Math.round((data.free_psram || 0) / 1024) + ' KB';
document.getElementById('stat-sta-ip').innerText = data.sta_ip || 'Disconnected';
document.getElementById('pill-sta-ip').innerText = 'IP: ' + (data.sta_ip || '192.168.4.1');
} catch (e) {}
}
async function refreshLogs() {
try {
const res = await fetch('/api/logs');
const data = await res.json();
const box = document.getElementById('log-container');
box.innerHTML = '';
(data.logs || []).forEach(line => {
const div = document.createElement('div');
div.className = 'log-line';
div.innerText = line;
box.appendChild(div);
});
box.scrollTop = box.scrollHeight;
} catch (e) {}
}
async function apiAction(url) {
if (confirm('确定要执行此操作吗?')) {
await fetch(url, { method: 'POST' });
alert('操作已发送!');
}
}
async function scanWifi() {
const sel = document.getElementById('wifi-ssid-select');
sel.innerHTML = '<option>正在扫描周围 Wi-Fi...</option>';
try {
const res = await fetch('/api/wifi/scan');
const data = await res.json();
sel.innerHTML = '<option value="">-- 选择 Wi-Fi --</option>';
(data.networks || []).forEach(n => {
const opt = document.createElement('option');
opt.value = n.ssid;
opt.innerText = n.ssid + ' (' + n.rssi + ' dBm)' + (n.secure ? ' 🔒' : '');
sel.appendChild(opt);
});
} catch (e) {
sel.innerHTML = '<option>扫描失败,请重试</option>';
}
}
async function saveWifi() {
const ssid = document.getElementById('wifi-ssid').value.trim();
const pass = document.getElementById('wifi-pass').value.trim();
if (!ssid) { alert('请输入 Wi-Fi 名称'); return; }
const res = await fetch('/api/wifi/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid, pass })
});
alert('Wi-Fi 配置已保存,ESP32 正在尝试连接路由器!');
}
async function resetKeymap() {
if (confirm('确定恢复出厂默认按键映射?')) {
await fetch('/api/keymap/reset', { method: 'POST' });
alert('按键映射已恢复为默认配置!');
}
}
setInterval(fetchStatus, 2000);
fetchStatus();
</script>
</body>
</html>
)rawliteral";
+123
View File
@@ -0,0 +1,123 @@
#include "wifi_manager.h"
#include "log/app_log.h"
#include <WiFi.h>
#include <DNSServer.h>
#include <ESPmDNS.h>
#include <Preferences.h>
#include <ArduinoJson.h>
#define AP_SSID "RemoteMapper-AP"
#define AP_PASS "" // Open network for quick onboarding
#define DNS_PORT 53
#define MDNS_HOSTNAME "remotemapper"
static DNSServer s_dns_server;
static Preferences s_prefs;
static IPAddress s_ap_ip(192, 168, 4, 1);
static IPAddress s_ap_netmask(255, 255, 255, 0);
static bool s_sta_configured = false;
static uint32_t s_last_sta_check = 0;
void wifi_manager_init(void) {
s_prefs.begin("wifi_conf", false);
String sta_ssid = s_prefs.getString("ssid", "");
String sta_pass = s_prefs.getString("pass", "");
// Set Wi-Fi Mode
WiFi.mode(WIFI_AP_STA);
// 1. Configure and start AP Mode
WiFi.softAPConfig(s_ap_ip, s_ap_ip, s_ap_netmask);
WiFi.softAP(AP_SSID, AP_PASS);
app_log("WIFI", "AP Started: %s (IP: 192.168.4.1)", AP_SSID);
// 2. Start Captive Portal DNS
s_dns_server.setErrorReplyCode(DNSReplyCode::NoError);
s_dns_server.start(DNS_PORT, "*", s_ap_ip);
// 3. Connect to Home Wi-Fi if saved
if (sta_ssid.length() > 0) {
s_sta_configured = true;
app_log("WIFI", "Connecting to Home Wi-Fi: %s...", sta_ssid.c_str());
WiFi.begin(sta_ssid.c_str(), sta_pass.c_str());
} else {
app_log("WIFI", "No Home Wi-Fi configured, running in AP Setup mode");
}
// 4. Start mDNS
if (MDNS.begin(MDNS_HOSTNAME)) {
MDNS.addService("http", "tcp", 80);
app_log("WIFI", "mDNS responder started: http://%s.local", MDNS_HOSTNAME);
}
}
void wifi_manager_task(void) {
s_dns_server.processNextRequest();
uint32_t now = millis();
if (s_sta_configured && (now - s_last_sta_check > 5000)) {
s_last_sta_check = now;
if (WiFi.status() == WL_CONNECTED) {
static bool s_logged_connected = false;
if (!s_logged_connected) {
app_log("WIFI", "Connected to Home Wi-Fi! Local IP: %s", WiFi.localIP().toString().c_str());
s_logged_connected = true;
}
}
}
}
String wifi_manager_get_ap_ip(void) {
return WiFi.softAPIP().toString();
}
String wifi_manager_get_sta_ip(void) {
if (WiFi.status() == WL_CONNECTED) {
return WiFi.localIP().toString();
}
return "Disconnected";
}
bool wifi_manager_is_sta_connected(void) {
return WiFi.status() == WL_CONNECTED;
}
int8_t wifi_manager_get_sta_rssi(void) {
if (WiFi.status() == WL_CONNECTED) {
return WiFi.RSSI();
}
return 0;
}
String wifi_manager_scan_json(void) {
app_log("WIFI", "Scanning for 2.4GHz Wi-Fi networks...");
int n = WiFi.scanNetworks();
JsonDocument doc;
JsonArray arr = doc["networks"].to<JsonArray>();
for (int i = 0; i < n; i++) {
JsonObject obj = arr.add<JsonObject>();
obj["ssid"] = WiFi.SSID(i);
obj["rssi"] = WiFi.RSSI(i);
obj["secure"] = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
}
String out;
serializeJson(doc, out);
WiFi.scanDelete();
return out;
}
bool wifi_manager_save_sta_config(const String& ssid, const String& password) {
if (ssid.length() == 0) return false;
s_prefs.putString("ssid", ssid);
s_prefs.putString("pass", password);
s_sta_configured = true;
app_log("WIFI", "Saved new Wi-Fi credentials for: %s, connecting...", ssid.c_str());
WiFi.disconnect();
WiFi.begin(ssid.c_str(), password.c_str());
return true;
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include <Arduino.h>
#ifdef __cplusplus
extern "C" {
#endif
void wifi_manager_init(void);
void wifi_manager_task(void);
String wifi_manager_get_ap_ip(void);
String wifi_manager_get_sta_ip(void);
bool wifi_manager_is_sta_connected(void);
int8_t wifi_manager_get_sta_rssi(void);
String wifi_manager_scan_json(void);
bool wifi_manager_save_sta_config(const String& ssid, const String& password);
#ifdef __cplusplus
}
#endif