From 00e0b683aa9f2337a332c2da60d898fdeb03ac82 Mon Sep 17 00:00:00 2001 From: cuicuiV5 Date: Mon, 31 Aug 2026 17:01:22 +0800 Subject: [PATCH] feat(web/nvs): Add full Flash NVS manager, multi-trigger remap UI (click/long/double), and self-healing storage engine --- src/keymap/key_config_storage.cpp | 102 ++-- src/keymap/key_state_machine.c | 79 ++- src/main.cpp | 10 + src/nvs/nvs_manager.cpp | 241 +++++++++ src/nvs/nvs_manager.h | 23 + src/web/web_server.cpp | 34 ++ src/web/web_ui.h | 828 ++++++++++++++++++++++++++---- 7 files changed, 1175 insertions(+), 142 deletions(-) create mode 100644 src/nvs/nvs_manager.cpp create mode 100644 src/nvs/nvs_manager.h diff --git a/src/keymap/key_config_storage.cpp b/src/keymap/key_config_storage.cpp index 598b73b..1c95d56 100644 --- a/src/keymap/key_config_storage.cpp +++ b/src/keymap/key_config_storage.cpp @@ -3,12 +3,10 @@ #include #include -static Preferences s_key_prefs; - void key_config_storage_init(key_mapper_engine_t *engine) { - s_key_prefs.begin("keymap_conf", false); if (!key_config_storage_load(engine)) { - app_log("KEYMAP", "No custom keymap found, loaded factory defaults (%u bindings)", (unsigned int)engine->binding_count); + key_engine_load_defaults(engine); + app_log("KEYMAP", "Loaded safe factory defaults (%u bindings)", (unsigned int)engine->binding_count); } else { app_log("KEYMAP", "Loaded custom keymap from NVS (%u bindings)", (unsigned int)engine->binding_count); } @@ -27,27 +25,33 @@ String key_config_to_json(const key_mapper_engine_t *engine) { obj["source_vk"] = b->source_vk; // Click action - obj["has_click"] = b->has_click; - obj["click_type"] = (int)b->click_action.type; - obj["click_mod"] = b->click_action.modifier; - obj["click_key"] = b->click_action.key_code; - obj["click_cons"] = b->click_action.consumer_code; + if (b->has_click) { + obj["has_click"] = true; + obj["click_type"] = (int)b->click_action.type; + if (b->click_action.modifier != 0) obj["click_mod"] = b->click_action.modifier; + if (b->click_action.key_code != 0) obj["click_key"] = b->click_action.key_code; + if (b->click_action.consumer_code != 0) obj["click_cons"] = b->click_action.consumer_code; + } - // Long action - obj["has_long"] = b->has_long; - obj["long_ms"] = b->long_ms; - obj["long_type"] = (int)b->long_action.type; - obj["long_mod"] = b->long_action.modifier; - obj["long_key"] = b->long_action.key_code; - obj["long_cons"] = b->long_action.consumer_code; + // Long action - omit unused fields if false to keep NVS size tiny + if (b->has_long) { + obj["has_long"] = true; + obj["long_ms"] = b->long_ms; + obj["long_type"] = (int)b->long_action.type; + if (b->long_action.modifier != 0) obj["long_mod"] = b->long_action.modifier; + if (b->long_action.key_code != 0) obj["long_key"] = b->long_action.key_code; + if (b->long_action.consumer_code != 0) obj["long_cons"] = b->long_action.consumer_code; + } - // Double action - obj["has_double"] = b->has_double; - obj["double_ms"] = b->double_ms; - obj["double_type"] = (int)b->double_action.type; - obj["double_mod"] = b->double_action.modifier; - obj["double_key"] = b->double_action.key_code; - obj["double_cons"] = b->double_action.consumer_code; + // Double action - omit unused fields if false to keep NVS size tiny + if (b->has_double) { + obj["has_double"] = true; + obj["double_ms"] = b->double_ms; + obj["double_type"] = (int)b->double_action.type; + if (b->double_action.modifier != 0) obj["double_mod"] = b->double_action.modifier; + if (b->double_action.key_code != 0) obj["double_key"] = b->double_action.key_code; + if (b->double_action.consumer_code != 0) obj["double_cons"] = b->double_action.consumer_code; + } } String out; @@ -94,15 +98,15 @@ bool key_config_from_json(key_mapper_engine_t *engine, const String &json_str) { } b.has_long = obj["has_long"] | false; - b.long_ms = obj["long_ms"] | 500; - b.long_action.type = (key_action_type_t)(obj["long_type"] | 0); + b.long_ms = obj["long_ms"] | 600; + b.long_action.type = (key_action_type_t)(obj["long_type"] | 1); b.long_action.modifier = obj["long_mod"] | 0; b.long_action.key_code = obj["long_key"] | 0; b.long_action.consumer_code = obj["long_cons"] | 0; b.has_double = obj["has_double"] | false; b.double_ms = obj["double_ms"] | 250; - b.double_action.type = (key_action_type_t)(obj["double_type"] | 0); + b.double_action.type = (key_action_type_t)(obj["double_type"] | 1); b.double_action.modifier = obj["double_mod"] | 0; b.double_action.key_code = obj["double_key"] | 0; b.double_action.consumer_code = obj["double_cons"] | 0; @@ -117,21 +121,55 @@ bool key_config_from_json(key_mapper_engine_t *engine, const String &json_str) { bool key_config_storage_save(key_mapper_engine_t *engine) { if (!engine) return false; String json = key_config_to_json(engine); - s_key_prefs.putString("cfg_json", json); - app_log("KEYMAP", "Saved keymap to NVS (%u bytes)", (unsigned int)json.length()); + + Preferences prefs; + if (!prefs.begin("keymap_conf", false)) { + app_log("KEYMAP", "Failed to open keymap_conf NVS namespace for writing!"); + return false; + } + size_t written = prefs.putString("cfg_json", json); + prefs.end(); + + if (written == 0) { + app_log("KEYMAP", "ERROR: putString to NVS failed (written 0 bytes)!"); + return false; + } + app_log("KEYMAP", "Saved keymap to NVS successfully (%u bytes written)", (unsigned int)written); return true; } bool key_config_storage_load(key_mapper_engine_t *engine) { if (!engine) return false; - String json = s_key_prefs.getString("cfg_json", ""); - if (json.length() == 0) return false; - return key_config_from_json(engine, json); + + Preferences prefs; + if (!prefs.begin("keymap_conf", true)) { + key_engine_load_defaults(engine); + return false; + } + String json = prefs.getString("cfg_json", ""); + prefs.end(); + + if (json.length() == 0) { + key_engine_load_defaults(engine); + return false; + } + bool ok = key_config_from_json(engine, json); + if (!ok || engine->binding_count == 0) { + app_log("KEYMAP", "NVS keymap is invalid or corrupted -> auto-fallback to safe defaults!"); + key_engine_load_defaults(engine); + return false; + } + return true; } void key_config_storage_reset_defaults(key_mapper_engine_t *engine) { if (!engine) return; - s_key_prefs.remove("cfg_json"); + + Preferences prefs; + if (prefs.begin("keymap_conf", false)) { + prefs.remove("cfg_json"); + prefs.end(); + } key_engine_load_defaults(engine); app_log("KEYMAP", "Reset keymap to factory defaults (%u bindings)", (unsigned int)engine->binding_count); } diff --git a/src/keymap/key_state_machine.c b/src/keymap/key_state_machine.c index 6a6529a..13684a5 100644 --- a/src/keymap/key_state_machine.c +++ b/src/keymap/key_state_machine.c @@ -55,6 +55,19 @@ static void emit_action(key_mapper_engine_t *engine, const key_action_t *action, } } +static void emit_action_as_tap_if_hold(key_mapper_engine_t *engine, const key_action_t *action, uint8_t source_vk) { + if (!action || action->type == ACTION_NONE) return; + if (action->type == ACTION_KEYBOARD_HOLD) { + key_action_t tap = { ACTION_KEYBOARD_TAP, action->modifier, action->key_code, 0 }; + emit_action(engine, &tap, source_vk, false); + } else if (action->type == ACTION_CONSUMER_HOLD) { + key_action_t tap = { ACTION_CONSUMER_TAP, 0, 0, action->consumer_code }; + emit_action(engine, &tap, source_vk, false); + } else { + emit_action(engine, action, source_vk, false); + } +} + void key_engine_load_defaults(key_mapper_engine_t *engine) { if (!engine) return; engine->binding_count = 0; @@ -275,8 +288,11 @@ void key_engine_feed_key(key_mapper_engine_t *engine, uint8_t raw_key_code, bool s->next_repeat_timestamp = now_ms + b->repeat_delay_ms; } - if (b->click_action.type == ACTION_KEYBOARD_HOLD || b->click_action.type == ACTION_CONSUMER_HOLD || b->click_action.type == ACTION_VOICE_HOLD) { - emit_action(engine, &b->click_action, raw_key_code, true); + // If key has NO long press and NO double click configured, trigger pure instant 0ms pass-through + if (!b->has_long && !b->has_double) { + if (b->click_action.type == ACTION_KEYBOARD_HOLD || b->click_action.type == ACTION_CONSUMER_HOLD || b->click_action.type == ACTION_VOICE_HOLD) { + emit_action(engine, &b->click_action, raw_key_code, true); + } } } } else { @@ -286,26 +302,45 @@ void key_engine_feed_key(key_mapper_engine_t *engine, uint8_t raw_key_code, bool uint32_t duration = now_ms - s->press_timestamp; engine->last_telemetry.duration_ms = duration; - if (b->click_action.type == ACTION_KEYBOARD_HOLD) { - key_action_t rel = { ACTION_KEYBOARD_RELEASE, 0, 0, 0 }; - emit_action(engine, &rel, raw_key_code, false); - } else if (b->click_action.type == ACTION_CONSUMER_HOLD) { - key_action_t rel = { ACTION_CONSUMER_RELEASE, 0, 0, 0 }; - emit_action(engine, &rel, raw_key_code, false); - } else if (b->click_action.type == ACTION_VOICE_HOLD) { - key_action_t rel = { ACTION_VOICE_RELEASE, 0, 0, 0 }; - emit_action(engine, &rel, raw_key_code, false); - } else if (b->has_click && !s->long_fired) { - if (!b->has_double) { + if (!b->has_long && !b->has_double) { + // Pure pass-through release + if (b->click_action.type == ACTION_KEYBOARD_HOLD) { + key_action_t rel = { ACTION_KEYBOARD_RELEASE, 0, 0, 0 }; + emit_action(engine, &rel, raw_key_code, false); + } else if (b->click_action.type == ACTION_CONSUMER_HOLD) { + key_action_t rel = { ACTION_CONSUMER_RELEASE, 0, 0, 0 }; + emit_action(engine, &rel, raw_key_code, false); + } else if (b->click_action.type == ACTION_VOICE_HOLD) { + key_action_t rel = { ACTION_VOICE_RELEASE, 0, 0, 0 }; + emit_action(engine, &rel, raw_key_code, false); + } else if (b->has_click) { emit_action(engine, &b->click_action, raw_key_code, false); - } else { - s->press_count++; - if (s->press_count == 1) { - s->waiting_double = true; - } else if (s->press_count >= 2) { - s->waiting_double = false; - s->press_count = 0; - emit_action(engine, &b->double_action, raw_key_code, false); + } + } else { + // Key has Long Press or Double Click configured + if (s->long_fired) { + // Long press was fired during hold -> release long action if it was a hold + if (b->long_action.type == ACTION_KEYBOARD_HOLD) { + key_action_t rel = { ACTION_KEYBOARD_RELEASE, 0, 0, 0 }; + emit_action(engine, &rel, raw_key_code, false); + } else if (b->long_action.type == ACTION_CONSUMER_HOLD) { + key_action_t rel = { ACTION_CONSUMER_RELEASE, 0, 0, 0 }; + emit_action(engine, &rel, raw_key_code, false); + } + // Click action is suppressed + } else if (b->has_click) { + // Short click (< long_ms) + if (!b->has_double) { + emit_action_as_tap_if_hold(engine, &b->click_action, raw_key_code); + } else { + s->press_count++; + if (s->press_count == 1) { + s->waiting_double = true; + } else if (s->press_count >= 2) { + s->waiting_double = false; + s->press_count = 0; + emit_action_as_tap_if_hold(engine, &b->double_action, raw_key_code); + } } } } @@ -337,7 +372,7 @@ void key_engine_tick(key_mapper_engine_t *engine, uint32_t now_ms) { if (s->waiting_double && (now_ms - s->release_timestamp >= b->double_ms)) { s->waiting_double = false; s->press_count = 0; - emit_action(engine, &b->click_action, b->source_vk, false); + emit_action_as_tap_if_hold(engine, &b->click_action, b->source_vk); } } } diff --git a/src/main.cpp b/src/main.cpp index 1d17854..dcfa94a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -12,6 +12,9 @@ #include "cli/cli_manager.h" #include "led_indicator.h" +#include +#include + key_mapper_engine_t g_key_engine; // Task running on Core 0: BLE Central & Audio Decoding @@ -27,6 +30,13 @@ static void ble_task_core0(void* param) { } void setup() { + // 0. Initialize Flash NVS with auto-recovery for corrupted partitions + esp_err_t nvs_err = nvs_flash_init(); + if (nvs_err == ESP_ERR_NVS_NO_FREE_PAGES || nvs_err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + nvs_flash_erase(); + nvs_flash_init(); + } + // 1. Initialize Log System first app_log_init(); diff --git a/src/nvs/nvs_manager.cpp b/src/nvs/nvs_manager.cpp new file mode 100644 index 0000000..053dc94 --- /dev/null +++ b/src/nvs/nvs_manager.cpp @@ -0,0 +1,241 @@ +#include "nvs_manager.h" +#include +#include +#include +#include +#include "keymap/key_config_storage.h" +#include "keymap/key_state_machine.h" +#include "log/app_log.h" + +extern key_mapper_engine_t g_key_engine; + +static bool is_valid_printable_str(const char* buf, size_t len) { + if (!buf || len == 0) return false; + for (size_t i = 0; i < len && buf[i] != '\0'; i++) { + uint8_t c = (uint8_t)buf[i]; + if (c < 32 && c != '\t' && c != '\n' && c != '\r') return false; + } + return true; +} + +static String sanitize_str(const char* buf, size_t len) { + if (!buf || len == 0) return ""; + String s = ""; + for (size_t i = 0; i < len && buf[i] != '\0'; i++) { + uint8_t c = (uint8_t)buf[i]; + if (c >= 32 || c == '\t' || c == '\n' || c == '\r') { + s += (char)c; + } + } + return s; +} + +static String blob_to_hex_str(const uint8_t* buf, size_t len) { + if (!buf || len == 0) return "0x00"; + String hex = "0x"; + const char* digits = "0123456789ABCDEF"; + for (size_t i = 0; i < len; i++) { + hex += digits[(buf[i] >> 4) & 0x0F]; + hex += digits[buf[i] & 0x0F]; + } + return hex; +} + +String nvs_manager_dump_json(void) { + JsonDocument doc; + nvs_iterator_t it = nvs_entry_find("nvs", NULL, NVS_TYPE_ANY); + while (it != NULL) { + nvs_entry_info_t info; + nvs_entry_info(it, &info); + nvs_handle_t handle; + esp_err_t err = nvs_open(info.namespace_name, NVS_READONLY, &handle); + if (err == ESP_OK) { + JsonObject nsObj = doc[info.namespace_name].is() ? + doc[info.namespace_name].as() : + doc[info.namespace_name].to(); + switch (info.type) { + case NVS_TYPE_U8: { uint8_t val = 0; if (nvs_get_u8(handle, info.key, &val) == ESP_OK) nsObj[info.key] = val; break; } + case NVS_TYPE_I8: { int8_t val = 0; if (nvs_get_i8(handle, info.key, &val) == ESP_OK) nsObj[info.key] = val; break; } + case NVS_TYPE_U16: { uint16_t val = 0; if (nvs_get_u16(handle, info.key, &val) == ESP_OK) nsObj[info.key] = val; break; } + case NVS_TYPE_I16: { int16_t val = 0; if (nvs_get_i16(handle, info.key, &val) == ESP_OK) nsObj[info.key] = val; break; } + case NVS_TYPE_U32: { uint32_t val = 0; if (nvs_get_u32(handle, info.key, &val) == ESP_OK) nsObj[info.key] = val; break; } + case NVS_TYPE_I32: { int32_t val = 0; if (nvs_get_i32(handle, info.key, &val) == ESP_OK) nsObj[info.key] = val; break; } + case NVS_TYPE_STR: { + size_t req_len = 0; + if (nvs_get_str(handle, info.key, NULL, &req_len) == ESP_OK && req_len > 0) { + char* buf = (char*)malloc(req_len + 1); + if (buf) { + if (nvs_get_str(handle, info.key, buf, &req_len) == ESP_OK) { + buf[req_len] = '\0'; + JsonDocument subDoc; + if (deserializeJson(subDoc, buf) == DeserializationError::Ok) { + nsObj[info.key] = subDoc; + } else { + nsObj[info.key] = sanitize_str(buf, req_len); + } + } + free(buf); + } + } + break; + } + case NVS_TYPE_BLOB: { + size_t req_len = 0; + if (nvs_get_blob(handle, info.key, NULL, &req_len) == ESP_OK && req_len > 0) { + uint8_t* buf = (uint8_t*)malloc(req_len + 1); + if (buf) { + if (nvs_get_blob(handle, info.key, buf, &req_len) == ESP_OK) { + buf[req_len] = '\0'; + JsonDocument subDoc; + if (deserializeJson(subDoc, (char*)buf) == DeserializationError::Ok) { + nsObj[info.key] = subDoc; + } else if (is_valid_printable_str((char*)buf, req_len)) { + nsObj[info.key] = sanitize_str((char*)buf, req_len); + } else { + nsObj[info.key] = blob_to_hex_str(buf, req_len); + } + } + free(buf); + } + } + break; + } + default: break; + } + nvs_close(handle); + } + it = nvs_entry_next(it); + } + Preferences prefs; + if (prefs.begin("wifi_conf", true)) { + if (doc["wifi_conf"].isNull()) doc["wifi_conf"].to(); + JsonObject wifiObj = doc["wifi_conf"].as(); + if (prefs.isKey("sta_ssid")) wifiObj["sta_ssid"] = prefs.getString("sta_ssid", ""); + if (prefs.isKey("sta_pass")) wifiObj["sta_pass"] = prefs.getString("sta_pass", ""); + if (prefs.isKey("ap_ssid")) wifiObj["ap_ssid"] = prefs.getString("ap_ssid", ""); + if (prefs.isKey("ap_pass")) wifiObj["ap_pass"] = prefs.getString("ap_pass", ""); + prefs.end(); + } + if (prefs.begin("ble_conf", true)) { + if (doc["ble_conf"].isNull()) doc["ble_conf"].to(); + JsonObject bleObj = doc["ble_conf"].as(); + if (prefs.isKey("bound_mac")) bleObj["bound_mac"] = prefs.getString("bound_mac", ""); + if (prefs.isKey("bound_name")) bleObj["bound_name"] = prefs.getString("bound_name", ""); + if (prefs.isKey("bound_type")) bleObj["bound_type"] = prefs.getUChar("bound_type", 0); + prefs.end(); + } + if (prefs.begin("keymap_conf", true)) { + if (doc["keymap_conf"].isNull()) doc["keymap_conf"].to(); + JsonObject keyObj = doc["keymap_conf"].as(); + if (prefs.isKey("cfg_json")) { + String cj = prefs.getString("cfg_json", ""); + JsonDocument subDoc; + if (deserializeJson(subDoc, cj) == DeserializationError::Ok) { keyObj["cfg_json"] = subDoc; } + else { keyObj["cfg_json"] = cj; } + } + prefs.end(); + } + String out; + serializeJsonPretty(doc, out); + return out; +} + +bool nvs_manager_apply_json(const String& json_str, String& err_msg) { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, json_str); + if (err) { + err_msg = String("JSON 格式语法错误: ") + err.c_str(); + return false; + } + if (!doc.is()) { + err_msg = "根节点必须是 JSON 对象 (Root element must be a JSON Object)"; + return false; + } + JsonObject root = doc.as(); + + // 1. Dry-run safety validation for keymap_conf + if (!root["keymap_conf"].isNull()) { + JsonVariant km = root["keymap_conf"]; + if (km.is() && !km.as()["cfg_json"].isNull()) { + JsonVariant cfg = km["cfg_json"]; + String testJson = ""; + if (cfg.is() || cfg.is()) { + serializeJson(cfg, testJson); + } else if (cfg.is()) { + testJson = cfg.as(); + } + if (testJson.length() > 0) { + key_mapper_engine_t test_engine; + memset(&test_engine, 0, sizeof(test_engine)); + if (!key_config_from_json(&test_engine, testJson) || test_engine.binding_count == 0) { + err_msg = "【安全保护拦截】keymap_conf.cfg_json 按键映射格式错误或为空,已拒绝写入 Flash 以避免设备故障!"; + return false; + } + } + } + } + + // 2. Protected system-internal partitions (prevent user edits from corrupting RF calibration or BLE keys) + const char* const PROTECTED_NAMESPACES[] = { + "nvs.net80211", "phy", "dhcp_state", "nimble_bond" + }; + + for (JsonPair nsPair : root) { + String nsName = nsPair.key().c_str(); + if (!nsPair.value().is()) continue; + + bool isProtected = false; + for (const char* prot : PROTECTED_NAMESPACES) { + if (nsName.equalsIgnoreCase(prot)) { + isProtected = true; + break; + } + } + if (isProtected) { + app_log("NVS", "Preserved low-level system partition: %s", nsName.c_str()); + continue; + } + + Preferences prefs; + prefs.begin(nsName.c_str(), false); + JsonObject nsObj = nsPair.value().as(); + for (JsonPair kv : nsObj) { + String key = kv.key().c_str(); + JsonVariant val = kv.value(); + if (val.is()) { + prefs.putBool(key.c_str(), val.as()); + } else if (val.is() || val.is() || val.is()) { + prefs.putLong(key.c_str(), val.as()); + } else if (val.is() || val.is()) { + prefs.putDouble(key.c_str(), val.as()); + } else if (val.is() || val.is()) { + String subStr; + serializeJson(val, subStr); + prefs.putString(key.c_str(), subStr); + } else if (val.is() || val.is()) { + String sVal = val.as(); + prefs.putString(key.c_str(), sVal); + } + } + prefs.end(); + } + + // 3. Hot-reload Key Engine with safe auto-fallback + if (!key_config_storage_load(&g_key_engine)) { + app_log("NVS", "Keymap reload fallback to safe defaults"); + key_engine_load_defaults(&g_key_engine); + } + + app_log("NVS", "NVS configuration validated & updated safely via Web Manager"); + return true; +} + +bool nvs_manager_erase_all(void) { + app_log("NVS", "Erasing all NVS Flash partitions..."); + esp_err_t err = nvs_flash_erase(); + if (err == ESP_OK) { + nvs_flash_init(); + return true; + } + return false; +} diff --git a/src/nvs/nvs_manager.h b/src/nvs/nvs_manager.h new file mode 100644 index 0000000..de24607 --- /dev/null +++ b/src/nvs/nvs_manager.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +/** + * @brief Dump all NVS namespaces and key-values as pretty-printed JSON string + */ +String nvs_manager_dump_json(void); + +/** + * @brief Apply edited JSON string into NVS storage across all namespaces + * @param json_str JSON string representing namespace dictionary + * @param err_msg Output error message if parsing/writing fails + * @return true on success, false on error + */ +bool nvs_manager_apply_json(const String& json_str, String& err_msg); + +/** + * @brief Factory reset: Erase entire NVS partition and reinitialize + */ +bool nvs_manager_erase_all(void); diff --git a/src/web/web_server.cpp b/src/web/web_server.cpp index 14c791c..0dfb07e 100644 --- a/src/web/web_server.cpp +++ b/src/web/web_server.cpp @@ -7,6 +7,7 @@ #include "audio/audio_pipeline.h" #include "keymap/key_state_machine.h" #include "keymap/key_config_storage.h" +#include "nvs/nvs_manager.h" #include #include @@ -156,6 +157,36 @@ static void handle_system_restart() { ESP.restart(); } +static void handle_nvs_get() { + String json = nvs_manager_dump_json(); + s_server.send(200, "application/json", json); +} + +static void handle_nvs_save() { + if (!s_server.hasArg("plain")) { + s_server.send(400, "application/json", "{\"error\":\"missing_body\"}"); + return; + } + String err; + bool ok = nvs_manager_apply_json(s_server.arg("plain"), err); + if (ok) { + s_server.send(200, "application/json", "{\"status\":\"saved\",\"message\":\"NVS配置已成功更新并写入Flash!\"}"); + } else { + s_server.send(400, "application/json", String("{\"error\":\"") + err + "\"}"); + } +} + +static void handle_nvs_reset() { + bool ok = nvs_manager_erase_all(); + if (ok) { + s_server.send(200, "application/json", "{\"status\":\"erased\",\"message\":\"NVS已清空,系统即将重启...\"}"); + delay(500); + ESP.restart(); + } else { + s_server.send(500, "application/json", "{\"error\":\"erase_failed\"}"); + } +} + static void handle_captive_portal() { String host = s_server.hostHeader(); if (host != "192.168.4.1" && host != "remotemapper.local") { @@ -183,6 +214,9 @@ void web_server_init(void) { s_server.on("/api/ble/unpair", HTTP_POST, handle_ble_unpair); s_server.on("/api/ble/info", HTTP_GET, handle_ble_info); s_server.on("/api/ble/reconnect", HTTP_POST, handle_ble_reconnect); + s_server.on("/api/nvs", HTTP_GET, handle_nvs_get); + s_server.on("/api/nvs/save", HTTP_POST, handle_nvs_save); + s_server.on("/api/nvs/reset", HTTP_POST, handle_nvs_reset); s_server.on("/api/system/restart", HTTP_POST, handle_system_restart); // Captive Portal probe redirects diff --git a/src/web/web_ui.h b/src/web/web_ui.h index 51ffa29..73d3cff 100644 --- a/src/web/web_ui.h +++ b/src/web/web_ui.h @@ -166,6 +166,73 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( .key-badge-display { font-size: 24px; font-weight: 800; color: #fff; margin-top: 8px; min-height: 36px; display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; } .kbd-chip { background: #0f172a; border: 1px solid var(--accent-cyan); color: var(--accent-cyan); padding: 4px 12px; border-radius: 6px; font-size: 18px; box-shadow: 0 2px 6px rgba(0,0,0,0.5); } + .mode-card { + cursor: pointer; + background: #0f172a; + border: 2px solid var(--border-color); + border-radius: 10px; + padding: 10px 6px; + text-align: center; + transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1); + user-select: none; + } + .mode-card:hover { + border-color: #3b82f6; + background: #1e293b; + transform: translateY(-1px); + } + .mode-card.selected { + border-color: var(--accent-cyan) !important; + background: rgba(6, 182, 212, 0.18) !important; + box-shadow: 0 0 14px rgba(6, 182, 212, 0.3) !important; + } + .mode-card.selected .mode-title { + color: var(--accent-cyan) !important; + } + + .trigger-tab-btn { + flex: 1; + background: transparent; + border: none; + color: var(--text-muted); + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: all 0.15s; + } + .trigger-tab-btn:hover { + color: #fff; + } + .trigger-tab-btn.active { + background: var(--bg-card); + color: var(--accent-cyan); + box-shadow: 0 2px 8px rgba(0,0,0,0.5); + } + + .switch-toggle { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + } + .switch-toggle input { opacity: 0; width: 0; height: 0; } + .switch-toggle .slider { + position: absolute; cursor: pointer; inset: 0; + background-color: #27272a; transition: .2s; border-radius: 24px; border: 1px solid #3f3f46; + } + .switch-toggle .slider:before { + position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; + background-color: #d4d4d8; transition: .2s; border-radius: 50%; + } + .switch-toggle input:checked + .slider { + background-color: var(--accent-cyan); border-color: var(--accent-cyan); + } + .switch-toggle input:checked + .slider:before { + transform: translateX(20px); background-color: #000; + } + .btn { background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan)); color: #fff; border: none; padding: 10px 18px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity 0.2s; } .btn:hover { opacity: 0.9; } .btn-outline { background: transparent; border: 1px solid var(--border-color); color: var(--text-main); } @@ -213,6 +280,7 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( + @@ -327,7 +395,56 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral( - + +
+
+
+
+ 💾 ESP32 Flash NVS 纯文本配置管理器 +
+ 以 JSON 纯文本形式统一管理 ESP32 Flash NVS 中存储的所有持久化参数(Wi-Fi、蓝牙绑定、改键规则等)。支持实时编辑、保存、导出备份与导入恢复。 +
+
+
+ + + + + + + +
+
+ +
+
+ 🟢 JSON 格式有效 +
+ +
+ +
+
+ 💡 NVS 命名空间常用说明 + • wifi_conf: 包含 sta_ssid, sta_pass (Wi-Fi配置) 和 ap_ssid, ap_pass (热点配置)
+ • ble_conf: 包含 bound_mac, bound_name, bound_type (绑定的蓝牙遥控器)
+ • keymap_conf: 包含 cfg_json (13个按键的完整映射规则字典)
+ • 支持任意自由增删或自定义新的命名空间与键值对,保存后将持久化保存在 Flash 中。 +
+
+
+ ⚠️ 危险区域:清空 NVS / 恢复出厂设置 + 擦除整个 ESP32 Flash NVS 分区中的所有存储数据(清除所有 Wi-Fi、蓝牙配对和改键设置),并自动重启系统。 +
+ +
+
+
+
+ +
@@ -363,14 +480,89 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(