// ----------------------------------------------------------------------------- // Copyright (c) 2024 Mu Shibo // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. // ----------------------------------------------------------------------------- #include "wifi_manager.h" #include #include #include // --------------------------------------------------------------------------- // 热点(AP)参数:作为外部 WiFi 连不上时的备用入口 // --------------------------------------------------------------------------- const char *AP_SSID = "WLROBOT"; const char *AP_PSW = "12345678"; // 注意:192.168.4.x 是 ESP32 的常用热点网段,避免和多数家用路由器的 // 192.168.1.x 冲突。如果你的路由器本身就是 192.168.4.x,请改这里。 IPAddress AP_IP(192, 168, 4, 1); IPAddress AP_GATEWAY(192, 168, 4, 1); IPAddress AP_SUBNET(255, 255, 255, 0); // 首次烧录、Flash 里还没有保存过配置时,沿用原代码里的外部 WiFi。 // 如果不希望自动连它,在 /wifi 页面清空 SSID 保存即可。 static const char *DEFAULT_STA_SSID = "MUJITECH"; static const char *DEFAULT_STA_PASS = "mujitech"; // --------------------------------------------------------------------------- // 配置存储 // --------------------------------------------------------------------------- struct WiFiConfig { String ssid; String password; bool use_static = false; String ip; String gateway; String subnet; String dns; }; static const char *NVS_NAMESPACE = "wifi_cfg"; // 编译时间:打开 http://192.168.4.1/wifi/version 可确认板子里跑的是不是这份代码 static const char *WIFI_CFG_BUILD = __DATE__ " " __TIME__; static WiFiConfig wifi_cfg; static bool wifi_cfg_active = false; // SSID 非空,表示需要连接外部 WiFi static WebServer *wifi_server = nullptr; static uint32_t restart_at_ms = 0; static uint32_t last_retry_ms = 0; static bool sta_was_connected = false; static int last_disconnect_reason = -1; // 最近一次 STA 断开/连接失败的 reason static const char *wifiReasonText(uint8_t reason) { switch (reason) { case 2: return "AUTH_EXPIRE"; case 3: return "AUTH_LEAVE"; case 4: return "ASSOC_EXPIRE"; case 5: return "ASSOC_TOOMANY"; case 6: return "NOT_AUTHED"; case 7: return "NOT_ASSOCED"; case 8: return "ASSOC_LEAVE"; case 9: return "ASSOC_NOT_AUTHED"; case 15: return "4WAY_HANDSHAKE_TIMEOUT(密码错误/加密不兼容)"; case 16: return "GROUP_KEY_UPDATE_TIMEOUT"; case 17: return "IE_IN_4WAY_DIFFERS"; case 18: return "GROUP_CIPHER_INVALID"; case 19: return "PAIRWISE_CIPHER_INVALID"; case 20: return "AKMP_INVALID"; case 23: return "802_1X_AUTH_FAILED(企业级加密)"; case 24: return "CIPHER_SUITE_REJECTED(加密方式不支持)"; case 200: return "BEACON_TIMEOUT"; case 201: return "NO_AP_FOUND(SSID/2.4GHz/信号)"; case 202: return "AUTH_FAIL(密码)"; case 203: return "ASSOC_FAIL"; case 204: return "HANDSHAKE_TIMEOUT(密码)"; case 205: return "CONNECTION_FAIL(路由器拒绝/信号)"; default: return "UNKNOWN"; } } static void onWiFiEvent(arduino_event_id_t event, arduino_event_info_t info) { if (event == ARDUINO_EVENT_WIFI_STA_CONNECTED) { Serial.println("[WiFi] STA associated, waiting for IP..."); } else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) { last_disconnect_reason = -1; Serial.printf("[WiFi] STA got IP: %s\n", WiFi.localIP().toString().c_str()); } else if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) { last_disconnect_reason = info.wifi_sta_disconnected.reason; Serial.printf("[WiFi] STA disconnected/failed, reason=%d (%s)\n", last_disconnect_reason, wifiReasonText((uint8_t)last_disconnect_reason)); } } static bool loadConfig(WiFiConfig &cfg, bool &found) { found = false; Preferences prefs; if (!prefs.begin(NVS_NAMESPACE, true)) { return false; } if (!prefs.isKey("ssid")) { prefs.end(); return false; } found = true; cfg.ssid = prefs.getString("ssid", ""); cfg.password = prefs.getString("pass", ""); cfg.use_static = prefs.getBool("static", false); cfg.ip = prefs.getString("ip", ""); cfg.gateway = prefs.getString("gw", ""); cfg.subnet = prefs.getString("sn", ""); cfg.dns = prefs.getString("dns", ""); prefs.end(); return true; } static bool saveConfig(const WiFiConfig &cfg) { Preferences prefs; if (!prefs.begin(NVS_NAMESPACE, false)) { return false; } prefs.putString("ssid", cfg.ssid); prefs.putString("pass", cfg.password); prefs.putBool("static", cfg.use_static); prefs.putString("ip", cfg.ip); prefs.putString("gw", cfg.gateway); prefs.putString("sn", cfg.subnet); prefs.putString("dns", cfg.dns); prefs.end(); return true; } static bool parseIP(const String &s, IPAddress &out) { if (s.length() == 0 || s.length() > 15) { return false; } return out.fromString(s.c_str()); } static bool sameSubnet(const IPAddress &ip, const IPAddress &gateway, const IPAddress &mask) { const uint32_t m = (uint32_t)mask; return (((uint32_t)ip & m) == ((uint32_t)gateway & m)); } // 连接外部 WiFi。WiFi.begin() 本身是非阻塞的,不会卡住 setup。 static void startSTA(void) { if (!wifi_cfg_active) { return; } if (wifi_cfg.use_static) { IPAddress ip, gateway, subnet, dns; if (parseIP(wifi_cfg.ip, ip) && parseIP(wifi_cfg.gateway, gateway) && parseIP(wifi_cfg.subnet, subnet)) { if (!parseIP(wifi_cfg.dns, dns)) { dns = gateway; } if (!WiFi.config(ip, gateway, subnet, dns)) { Serial.println("[WiFi] Static IP config rejected, using DHCP"); } } else { Serial.println("[WiFi] Invalid static IP in Flash, using DHCP"); } } Serial.printf("[WiFi] Connecting to \"%s\" (%s)...\n", wifi_cfg.ssid.c_str(), wifi_cfg.use_static ? "static IP" : "DHCP"); WiFi.begin(wifi_cfg.ssid.c_str(), wifi_cfg.password.c_str()); last_retry_ms = millis(); } // --------------------------------------------------------------------------- // 网页配置页面 // --------------------------------------------------------------------------- static String htmlEscape(const String &in) { String out; out.reserve(in.length() + 16); for (size_t i = 0; i < in.length(); i++) { const char c = in[i]; switch (c) { case '&': out += F("&"); break; case '<': out += F("<"); break; case '>': out += F(">"); break; case '"': out += F("""); break; case '\'': out += F("'"); break; default: out += c; break; } } return out; } static String buildConfigPage(void) { String page; page.reserve(5200); const bool connected = (WiFi.status() == WL_CONNECTED); const String ap_ip = WiFi.softAPIP().toString(); page += F( "" "" "WL-PRO WiFi 设置

WiFi 设置

"); // 当前状态 page += F("
"); page += F("
热点:"); page += AP_SSID; page += F(" / "); page += AP_PSW; page += F("
"); page += F("
热点地址:http://"); page += ap_ip; page += F("
"); page += F("
STA MAC:"); page += WiFi.macAddress(); page += F("
"); page += F("
外部 WiFi:"); page += (wifi_cfg.ssid.length() > 0) ? htmlEscape(wifi_cfg.ssid) : String("未配置(仅热点)"); page += F("
"); page += F("
连接状态:"); if (connected) { page += F("已连接"); } else if (wifi_cfg_active) { page += F("未连接(空闲时自动重试)"); } else { page += F("仅热点"); } page += F("
"); if (connected) { page += F("
当前 IP:"); page += WiFi.localIP().toString(); page += F("
"); page += F("
信号强度:"); page += String((int)WiFi.RSSI()); page += F(" dBm
"); } if (wifi_cfg.ssid.length() > 0) { page += F("
IP 方式:"); page += wifi_cfg.use_static ? F("静态 IP") : F("DHCP"); page += F("
"); } if (!connected && wifi_cfg_active && last_disconnect_reason >= 0) { page += F("
最近一次失败:reason "); page += String(last_disconnect_reason); page += F("("); page += wifiReasonText((uint8_t)last_disconnect_reason); page += F(")
"); } page += F("
"); // 表单 page += F( "
" "
" "" "" "" "" "

仅支持 2.4GHz、WPA2。密码留空会保留旧密码;" "换 SSID 时请重新输入密码。

"); page += F( "
"); page += F( "
" "
"); page += F( "
" "
" "
"); page += F( "
" "
" "

静态 IP 必须和路由器同网段,并避开 DHCP 地址池。" "热点固定使用 192.168.4.1,请避免路由器也使用 192.168.4.x 网段。

"); page += F( "" "返回遥控页面" "

保存后机器人会重启," "请先扶稳或放在支架上。

"); page += F("

固件构建:"); page += WIFI_CFG_BUILD; page += F("

"); return page; } static void sendResult(int code, const String &title, const String &message, bool refresh) { if (wifi_server == nullptr) { return; } String page; page.reserve(900); page += F( "" ""); if (refresh) { page += F(""); } page += F( "

"); page += title; page += F("

"); page += message; page += F("

返回 WiFi 设置 " "遥控页面

"); wifi_server->send(code, "text/html; charset=utf-8", page); } static void handleConfigPage(void) { if (wifi_server == nullptr) { return; } wifi_server->send(200, "text/html; charset=utf-8", buildConfigPage()); } // 直接用浏览器打开 /wifi/save(GET)时给出提示,而不是只回一个 404 static void handleSaveGet(void) { if (wifi_server == nullptr) { return; } sendResult(405, "请求方式不对", "请返回 WiFi 设置页面,填写后点击“保存并重启”按钮。", false); } static void handleVersion(void) { if (wifi_server == nullptr) { return; } String msg = F("wifi_manager build: "); msg += WIFI_CFG_BUILD; wifi_server->send(200, "text/plain; charset=utf-8", msg); } static void handleNotFound(void) { if (wifi_server == nullptr) { return; } const HTTPMethod m = wifi_server->method(); const char *name = (m == HTTP_GET) ? "GET" : (m == HTTP_POST) ? "POST" : "OTHER"; Serial.printf("[HTTP] 404 %s %s\n", name, wifi_server->uri().c_str()); String msg = F("Not found: "); msg += wifi_server->uri(); msg += F(" (method "); msg += name; msg += F(")"); wifi_server->send(404, "text/plain; charset=utf-8", msg); } static void handleSave(void) { if (wifi_server == nullptr) { return; } Serial.printf("[WiFi] /wifi/save request, method=%d, args=%d\n", (int)wifi_server->method(), wifi_server->args()); String ssid = wifi_server->arg("ssid"); ssid.trim(); if (ssid.length() > 32) { sendResult(400, "保存失败", "SSID 不能超过 32 个字节。", false); return; } const bool clear_pass = wifi_server->hasArg("clear_pass"); String pass = wifi_server->arg("pass"); if (clear_pass) { pass = ""; } else if (pass.length() == 0) { // 留空表示沿用 Flash 里的旧密码 pass = wifi_cfg.password; } if (pass.length() > 63) { sendResult(400, "保存失败", "WiFi 密码不能超过 63 个字符。", false); return; } if (ssid.length() > 0 && !clear_pass && pass.length() == 0 && ssid != wifi_cfg.ssid) { sendResult(400, "保存失败", "更换 SSID 时请重新输入密码;如果是开放网络,请勾选“清除密码”。", false); return; } if (ssid.length() > 0 && pass.length() > 0 && pass.length() < 8) { sendResult(400, "保存失败", "WPA2 密码至少 8 位;开放网络请勾选“清除密码”。", false); return; } const bool use_static = wifi_server->hasArg("use_static"); String ip = wifi_server->arg("ip"); String gateway = wifi_server->arg("gw"); String subnet = wifi_server->arg("sn"); String dns = wifi_server->arg("dns"); ip.trim(); gateway.trim(); subnet.trim(); dns.trim(); IPAddress ip_addr, gateway_addr, subnet_addr, dns_addr; if (use_static && ssid.length() > 0) { if (!parseIP(ip, ip_addr) || (uint32_t)ip_addr == 0) { sendResult(400, "保存失败", "静态 IP 地址无效。", false); return; } if (!parseIP(gateway, gateway_addr) || (uint32_t)gateway_addr == 0) { sendResult(400, "保存失败", "网关地址无效。", false); return; } if (!parseIP(subnet, subnet_addr) || (uint32_t)subnet_addr == 0) { sendResult(400, "保存失败", "子网掩码无效。", false); return; } if (dns.length() == 0) { dns = gateway; dns_addr = gateway_addr; } else if (!parseIP(dns, dns_addr) || (uint32_t)dns_addr == 0) { sendResult(400, "保存失败", "DNS 地址无效。", false); return; } if (!sameSubnet(ip_addr, gateway_addr, subnet_addr)) { sendResult(400, "保存失败", "静态 IP 和网关不在同一网段,请检查 IP/网关/子网掩码。", false); return; } if (sameSubnet(ip_addr, AP_IP, AP_SUBNET)) { sendResult(400, "保存失败", "静态 IP 不能使用热点的 192.168.4.x 网段。", false); return; } } WiFiConfig cfg; cfg.ssid = ssid; cfg.password = pass; cfg.use_static = use_static; cfg.ip = ip; cfg.gateway = gateway; cfg.subnet = subnet; cfg.dns = dns; if (!saveConfig(cfg)) { sendResult(500, "保存失败", "写入 Flash 失败,请重试。", false); return; } // 让状态页立即反映新配置(随后会重启) wifi_cfg = cfg; wifi_cfg_active = (cfg.ssid.length() > 0); String message; if (cfg.ssid.length() == 0) { message = "已保存。机器人将重启,之后只使用热点 WLROBOT。"; } else if (cfg.use_static) { message = "已保存。机器人将在约 1 秒后重启,并尝试连接 \""; message += htmlEscape(cfg.ssid); message += "\",静态 IP 为 "; message += cfg.ip; message += "。"; } else { message = "已保存。机器人将在约 1 秒后重启,并尝试连接 \""; message += htmlEscape(cfg.ssid); message += "\"(DHCP)。"; } message += " 如果外部 WiFi 连不上,热点 WLROBOT 仍然可用" "(http://192.168.4.1)。"; sendResult(200, "保存成功", message, true); restart_at_ms = millis() + 1000; } void WiFi_RegisterWebHandlers(WebServer &server) { wifi_server = &server; server.on("/wifi", HTTP_GET, handleConfigPage); server.on("/wifi/", HTTP_GET, handleConfigPage); server.on("/wifi/version", HTTP_GET, handleVersion); server.on("/wifi/save", HTTP_POST, handleSave); server.on("/wifi/save", HTTP_GET, handleSaveGet); server.onNotFound(handleNotFound); } // --------------------------------------------------------------------------- // 初始化与主循环 // --------------------------------------------------------------------------- void WiFi_Init(void) { Serial.printf("[WiFi] wifi_manager build: %s\n", WIFI_CFG_BUILD); WiFi.mode(WIFI_AP_STA); WiFi.onEvent(onWiFiEvent); WiFi.setHostname("wlrobot"); WiFi.setAutoReconnect(true); WiFi.softAPConfig(AP_IP, AP_GATEWAY, AP_SUBNET); if (!WiFi.softAP(AP_SSID, AP_PSW)) { Serial.println("[WiFi] softAP start failed"); } Serial.printf("\n[WiFi] AP \"%s\" / \"%s\" -> http://%s/\n", AP_SSID, AP_PSW, WiFi.softAPIP().toString().c_str()); Serial.printf("[WiFi] Settings page: http://%s/wifi\n", WiFi.softAPIP().toString().c_str()); Serial.printf("[WiFi] STA MAC: %s, AP MAC: %s\n", WiFi.macAddress().c_str(), WiFi.softAPmacAddress().c_str()); bool found = false; loadConfig(wifi_cfg, found); if (!found) { // 首次烧录:沿用原代码的默认外部 WiFi,之后可在 /wifi 页面修改 wifi_cfg.ssid = DEFAULT_STA_SSID; wifi_cfg.password = DEFAULT_STA_PASS; wifi_cfg.use_static = false; wifi_cfg.ip = ""; wifi_cfg.gateway = ""; wifi_cfg.subnet = ""; wifi_cfg.dns = ""; Serial.println("[WiFi] No saved config, using default STA credentials"); } wifi_cfg_active = (wifi_cfg.ssid.length() > 0); if (wifi_cfg_active) { startSTA(); } else { // 清掉 WiFi 驱动里缓存的旧 AP,避免“仅热点”模式下又自动连上旧网络 WiFi.disconnect(false, true); Serial.println("[WiFi] No external WiFi configured, AP only"); } } void WiFi_Loop(void) { // 配置保存后延时重启,避免在 Web 回调里阻塞平衡控制 if (restart_at_ms != 0 && (int32_t)(millis() - restart_at_ms) >= 0) { Serial.println("[WiFi] Rebooting to apply settings..."); delay(50); ESP.restart(); } const wl_status_t status = WiFi.status(); if (status == WL_CONNECTED) { if (!sta_was_connected) { sta_was_connected = true; Serial.printf("[WiFi] STA connected: \"%s\", IP %s\n", WiFi.SSID().c_str(), WiFi.localIP().toString().c_str()); Serial.printf("[WiFi] Remote UI: http://%s/ Settings: http://%s/wifi\n", WiFi.localIP().toString().c_str(), WiFi.localIP().toString().c_str()); } return; } if (sta_was_connected) { sta_was_connected = false; Serial.println("[WiFi] STA disconnected; reconnecting in background " "(AP still available)"); } // 外部 WiFi 未连接时后台重试。即使手机正连着热点也照常重试, // 否则用户连上 AP 查看状态时反而永远不会重连。 if (wifi_cfg_active && (millis() - last_retry_ms > 15000)) { last_retry_ms = millis(); Serial.printf("[WiFi] retrying \"%s\"...\n", wifi_cfg.ssid.c_str()); WiFi.reconnect(); } }