feat: implement Web UI dashboard, Wi-Fi AP/STA dual mode, Captive Portal, mDNS, and RESTful APIs
This commit is contained in:
1 parent
61fc819a18
commit
caf5da201b
8 files changed
+739
-16
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user