先把目标定为同网控制一盏普通灯
这一篇只在手机、电脑与S3所在的可信局域网里工作:打开设备页面、发送明确的开或关、刷新后读取设备报告。没有云账号,不用把设备端口暴露到公网;这也不是小智热点配网,地址由路由器实际分配。
先按P01确认外接普通LED可亮灭,再加网络。若同时换RGB灯、供电和Wi-Fi,很难分清网页成功而灯不亮发生在哪一层。控制命令采用ON/OFF式设定状态,不设计成每收到一次就翻转。
- 做到这里,你应该看到
- 能分别验收本地灯、局域网连接和HTTP请求,不把网页颜色当成实体反馈。
参考:[1] Arduino-ESP32 3.3.12 WebServer公开接口[2] Arduino-ESP32 3.3.12 WebServer请求与响应实现[5] 乐鑫 DevKitC-1 v1.1 固定文档:修订、供电与排针[6] Kingbright WP7113ID数据手册:极性与额定条件
延续同一GPIO4回路,凭据只放本机
固定v1.1 N8R8板,GPIO4/J1-4经330Ω接WP7113ID阳极,LED阴极接J1-22地,只接一条桥接USB线。板载GPIO38 RGB不参与;断电后核对器件极性和排针端点。
将wifi_secrets.example.h复制为被忽略的wifi_secrets.h,再填写自己的2.4GHz网络。占位值时worker报告CONFIG_MISSING,不尝试联网;包内没有私人SSID或密码,日志也不打印它们。
| 端点 | 连接 |
|---|---|
| GPIO4 / J1-4 | 330Ω → 普通红LED阳极 |
| GND / J1-22 | LED阴极 |
| USB-to-UART | 数据、供电与115200串口 |
- 做到这里,你应该看到
- P01能独立运行;私有凭据文件没有被加入仓库或下载包。
参考:[4] Arduino-ESP32 3.3.12 Wi-Fi STA实现[5] 乐鑫 DevKitC-1 v1.1 固定文档:修订、供电与排针[6] Kingbright WP7113ID数据手册:极性与额定条件
联网交给worker,网页只在loop里服务
本程序延续N01的连接窗口与退避:每个应用窗口10秒,失败后2/4/8/16/30秒封顶,拿到真实IP才ONLINE。Wi-Fi回调只将断开原因或GOT_IP送队列,API调用在worker,HTTP与GPIO在loop。core内部可能额外首连重试,所以应用attempt不是射频尝试次数。
服务器在首次ONLINE后才启动,使用所有本地接口监听;掉线重连可能获得不同IP,读取新日志中的地址。WebServer是简单同步服务器,不承诺多客户端吞吐或主循环永不被HTTP占用。
- 做到这里,你应该看到
- 串口明确给出ONLINE与实际地址,手机确实连接同网;不要输入固定的192.168.4.1。
参考:[1] Arduino-ESP32 3.3.12 WebServer公开接口[2] Arduino-ESP32 3.3.12 WebServer请求与响应实现[3] Arduino-ESP32 3.3.12 HelloServer上游例程[4] Arduino-ESP32 3.3.12 Wi-Fi STA实现
读状态用GET,改状态只接受精确POST
GET /api/led不带参数,返回新读取的GPIO数字电平、pin=4与含义。POST只接受恰好一个on=0或on=1表单字段;缺失、重复、额外参数、true、2都会返回400并保持原来输出。未知路由404,GET带on也不会改变硬件。
网页在无JavaScript时仍使用普通表单,成功后303回首页;增强脚本使用fetch,等待设备JSON才更新文字,失败显示尚未确认并建议刷新。这避免只改按钮外观就把请求写成已执行。GPIO数字电平仍不证明LED发光,需要另看实体。
| 请求 | 检查 |
|---|---|
| GET /api/led | 无参数;返回设备当前状态 |
| POST on=1 / on=0 | 仅一个精确字段;读取后返回 |
| on=2 / on=true /重复或多字段 | 400;旧状态保留 |
| GET /api/led?on=1 | 400;GET不改输出 |
- 做到这里,你应该看到
- 能用curl分别得到成功、400与404,并对照灯和状态是否一致。
参考:[1] Arduino-ESP32 3.3.12 WebServer公开接口[2] Arduino-ESP32 3.3.12 WebServer请求与响应实现
先编译,再用实际IP打开完整页面
下载包包括完整.ino、凭据模板、profile和观察CSV,不需要额外Web库。Arduino3.3.12、8MB Flash、OPI PSRAM、桥接UART设置与P01/N01相同;先编译通过,再上传并打开115200串口。
把手机放在同一个AP的同一可互访网络,打开实际打印的http地址。访客网、隔离开关、蜂窝数据与跨VLAN都会改变可达性;已ONLINE但页面打不开,先从同网电脑访问相同IP,而不是再刷一遍固件。
arduino-cli compile --fqbn 'esp32:esp32:esp32s3:FlashSize=8M,PSRAM=opi,FlashMode=qio,PartitionScheme=default_8MB,CDCOnBoot=default,USBMode=hwcdc' .
arduino-cli upload --fqbn 'esp32:esp32:esp32s3:FlashSize=8M,PSRAM=opi,FlashMode=qio,PartitionScheme=default_8MB,CDCOnBoot=default,USBMode=hwcdc' -p PORT .
arduino-cli monitor -p PORT --config baudrate=115200- 做到这里,你应该看到
- 网页地址对应这台设备当前IP,手机能读取状态;没有实板则保留此项待验证。
参考:[1] Arduino-ESP32 3.3.12 WebServer公开接口[3] Arduino-ESP32 3.3.12 HelloServer上游例程[4] Arduino-ESP32 3.3.12 Wi-Fi STA实现[5] 乐鑫 DevKitC-1 v1.1 固定文档:修订、供电与排针
完整程序让请求、状态与重连对应同一份实现
下方程序和下载包是同一份源码。GPIO上电先关灯;HTTP请求处理不在Wi-Fi回调里,断网不会把LED变成另一个随机状态。重启后默认关闭,本文不实现掉电保存。
网页请求失败时先刷新状态,ON/OFF重复设定不会多翻转一次。后续增加电机、继电器或更广网络访问时需要重新设计执行边界,本例只控制普通LED。
// SPDX-License-Identifier: MIT
// N02: same fixed Wi-Fi retry policy as N01; HTTP and GPIO run in loop().
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <atomic>
#include <cstring>
#if __has_include("wifi_secrets.h")
#include "wifi_secrets.h"
#else
#include "wifi_secrets.example.h"
#endif
constexpr uint8_t LED_PIN = 4;
constexpr uint32_t CONNECT_WINDOW_MS = 10000, FIRST_BACKOFF_MS = 2000, MAX_BACKOFF_MS = 30000;
enum class Phase : uint8_t { CONFIG_MISSING, START_FAILED, CONNECTING, ONLINE, BACKOFF };
struct Report { Phase phase; uint32_t attempt, windowAt, backoffMs, ip; int status; };
struct Notice { uint32_t at; uint16_t event; uint8_t reason; };
QueueHandle_t reportQueue = nullptr, noticeQueue = nullptr;
std::atomic<uint32_t> droppedNotices{0};
bool wifiTaskReady = false;
const char *phaseName(Phase phase) {
switch (phase) {
case Phase::CONFIG_MISSING: return "CONFIG_MISSING";
case Phase::START_FAILED: return "START_FAILED";
case Phase::CONNECTING: return "CONNECTING";
case Phase::ONLINE: return "ONLINE";
case Phase::BACKOFF: return "BACKOFF";
}
return "UNKNOWN";
}
void onWifiEvent(arduino_event_id_t event, arduino_event_info_t info) {
if (event != ARDUINO_EVENT_WIFI_STA_DISCONNECTED && event != ARDUINO_EVENT_WIFI_STA_GOT_IP) return;
Notice notice{millis(), uint16_t(event), uint8_t(event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED ? info.wifi_sta_disconnected.reason : 0)};
if (xQueueSend(noticeQueue, ¬ice, 0) != pdTRUE) ++droppedNotices;
}
void wifiTask(void *) {
Report r{Phase::CONFIG_MISSING, 0, millis(), 0, 0, WL_DISCONNECTED};
if (!std::strcmp(WIFI_SSID, "REPLACE_WITH_2G4_SSID") || !std::strcmp(WIFI_PASSWORD, "REPLACE_WITH_PASSWORD") || !*WIFI_SSID) {
xQueueOverwrite(reportQueue, &r);
vTaskDelete(nullptr);
return;
}
WiFi.persistent(false);
WiFi.onEvent(onWifiEvent);
if (!WiFi.mode(WIFI_STA)) {
r.phase = Phase::START_FAILED;
xQueueOverwrite(reportQueue, &r);
vTaskDelete(nullptr);
return;
}
WiFi.setAutoReconnect(false); // core 3.3.12 still has one internal first-connect retry
uint32_t nextBackoff = FIRST_BACKOFF_MS;
bool startAttempt = true;
for (;;) {
uint32_t now = millis();
if (startAttempt) {
startAttempt = false;
++r.attempt;
r.phase = Phase::CONNECTING;
r.backoffMs = 0;
r.ip = 0;
r.windowAt = now;
WiFi.begin(WIFI_SSID, WIFI_PASSWORD); // no waitForConnectResult / while waiting
xQueueOverwrite(reportQueue, &r);
}
r.status = WiFi.status();
now = millis();
if (r.phase == Phase::CONNECTING) {
if (r.status == WL_CONNECTED && uint32_t(WiFi.localIP()) != 0) {
r.phase = Phase::ONLINE;
r.ip = uint32_t(WiFi.localIP());
nextBackoff = FIRST_BACKOFF_MS;
xQueueOverwrite(reportQueue, &r);
} else if (uint32_t(now - r.windowAt) >= CONNECT_WINDOW_MS) {
WiFi.disconnect(false, false, 0); // no deliberate connection-wait timeout
r.phase = Phase::BACKOFF;
r.windowAt = millis();
r.backoffMs = nextBackoff;
nextBackoff = min(nextBackoff * 2, MAX_BACKOFF_MS);
xQueueOverwrite(reportQueue, &r);
}
} else if (r.phase == Phase::ONLINE && r.status != WL_CONNECTED) {
WiFi.disconnect(false, false, 0);
r.phase = Phase::BACKOFF;
r.ip = 0;
r.windowAt = millis();
r.backoffMs = FIRST_BACKOFF_MS;
nextBackoff = min(FIRST_BACKOFF_MS * 2, MAX_BACKOFF_MS);
xQueueOverwrite(reportQueue, &r);
} else if (r.phase == Phase::BACKOFF && uint32_t(now - r.windowAt) >= r.backoffMs) {
startAttempt = true;
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
WebServer server(80);
bool serverStarted = false;
const char PAGE[] PROGMEM = R"PAGE(<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ESP32 局域网小灯</title><style>body{font-family:system-ui,sans-serif;max-width:38rem;margin:3rem auto;padding:0 1.2rem;background:#f7f8f3;color:#203b29;line-height:1.7}main{padding:1.5rem;border:1px solid #a8baa0;border-radius:1rem;background:white}h1{font-size:1.8rem;line-height:1.4}button{min-height:48px;min-width:100px;margin:8px 12px 8px 0;padding:10px 18px;border:1px solid #436b45;border-radius:6px;background:#e5efdf;color:#203b29;font:inherit}button:focus-visible,a:focus-visible{outline:3px solid #245d91;outline-offset:4px}output{font-weight:700}small{display:block;color:#4d5f49}</style><main><h1>ESP32 局域网小灯</h1><p>GPIO4 当前输出:<output id="state" aria-live="polite">%STATE%</output></p><form method="post" action="/api/led"><button name="on" value="1">打开小灯</button><button name="on" value="0">关闭小灯</button></form><p id="feedback" role="status"></p><a href="/">刷新设备状态</a><small>这里只读回 GPIO 数字电平;没有光学或电流检测。请在同一可信局域网使用,不做公网端口映射。</small></main><script>const form=document.querySelector('form'),state=document.querySelector('#state'),feedback=document.querySelector('#feedback');form.addEventListener('submit',async event=>{if(!event.submitter)return;event.preventDefault();const buttons=[...form.querySelectorAll('button')];buttons.forEach(b=>b.disabled=true);feedback.textContent='正在请求设备…';try{const response=await fetch('/api/led',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},body:'on='+event.submitter.value});if(!response.ok)throw new Error('HTTP '+response.status);const data=await response.json();state.textContent=data.on?'开(HIGH)':'关(LOW)';feedback.textContent='设备已返回当前 GPIO 状态。';}catch(error){feedback.textContent='请求未确认,请保持同网并刷新状态。';}finally{buttons.forEach(b=>b.disabled=false);}});</script></html>)PAGE";
void sendState() {
const bool on = digitalRead(LED_PIN) == HIGH;
server.sendHeader("Cache-Control", "no-store");
server.send(200, "application/json; charset=utf-8", String("{\"on\":") + (on ? "true" : "false") + ",\"pin\":4,\"meaning\":\"gpio-digital-read\"}");
}
void handleSetLed() {
if (server.args() != 1 || !server.hasArg("on") || (server.arg("on") != "0" && server.arg("on") != "1")) {
server.send(400, "application/json; charset=utf-8", "{\"error\":\"exactly one on=0 or on=1 is required\"}");
return;
}
digitalWrite(LED_PIN, server.arg("on") == "1" ? HIGH : LOW);
if (server.header("Accept").indexOf("application/json") >= 0) sendState();
else { server.sendHeader("Location", "/"); server.send(303, "text/plain", "See /"); }
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, LOW);
const char *headers[] = {"Accept"}; server.collectHeaders(headers, 1);
server.on("/", HTTP_GET, []() {
String html = FPSTR(PAGE); html.replace("%STATE%", digitalRead(LED_PIN) ? "开(HIGH)" : "关(LOW)");
server.sendHeader("Cache-Control", "no-store"); server.send(200, "text/html; charset=utf-8", html);
});
server.on("/api/led", HTTP_GET, []() { if (server.args()) server.send(400, "application/json", "{\"error\":\"GET takes no parameters\"}"); else sendState(); });
server.on("/api/led", HTTP_POST, handleSetLed);
server.onNotFound([]() { server.send(404, "application/json", "{\"error\":\"not found\"}"); });
reportQueue = xQueueCreate(1, sizeof(Report)); noticeQueue = xQueueCreate(16, sizeof(Notice));
if (reportQueue && noticeQueue) wifiTaskReady = xTaskCreate(wifiTask, "n02-wifi", 6144, nullptr, 1, nullptr) == pdPASS;
Serial.printf("N02 core=3.3.12 task=%u HTTP=80 GPIO4 starts LOW; no credentials logged\n", wifiTaskReady);
}
void loop() {
if (serverStarted) server.handleClient();
Report report;
if (wifiTaskReady && xQueueReceive(reportQueue, &report, 0) == pdTRUE) {
Serial.printf("STATE=%s attempt=%lu backoff_ms=%lu status=%d IP=%s\n",phaseName(report.phase),(unsigned long)report.attempt,(unsigned long)report.backoffMs,report.status,IPAddress(report.ip).toString().c_str());
if (report.phase == Phase::ONLINE && !serverStarted) { server.begin(); serverStarted = true; }
if (report.phase == Phase::ONLINE) Serial.printf("OPEN http://%s/ from same LAN\n",IPAddress(report.ip).toString().c_str());
}
Notice notice;
while (wifiTaskReady && xQueueReceive(noticeQueue, ¬ice, 0) == pdTRUE)
if (notice.event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) Serial.printf("DISCONNECTED reason=%u at=%lu\n",notice.reason,(unsigned long)notice.at);
const uint32_t dropped = droppedNotices.exchange(0);
if (dropped) Serial.printf("NOTICE_OVERFLOW dropped=%lu\n",(unsigned long)dropped);
delay(1);
}
参考:[1] Arduino-ESP32 3.3.12 WebServer公开接口[2] Arduino-ESP32 3.3.12 WebServer请求与响应实现[3] Arduino-ESP32 3.3.12 HelloServer上游例程[4] Arduino-ESP32 3.3.12 Wi-Fi STA实现
把实体、HTTP和重新连接各留一条证据
依次按开、关,记录实际灯、JSON与HTTP状态;刷新和重开页面后仍应读取设备状态。发送错误字段,确认响应400且实体状态不改变。再关闭并恢复AP,保存断开原因、重连状态与新IP后再访问。
网页成功而灯不亮,先回P01检查极性、电阻与接点;仅页面打不开则查同网/IP/隔离,不修改灯的接线。本站已实际编译,尚无手机开关、AP重启或实体灯的本站记录;观察CSV保持空白,不能用预期文字补齐。
- 做到这里,你应该看到
- 四种观察都能对应实际条件;未执行的硬件步骤没有标为通过。
参考:[2] Arduino-ESP32 3.3.12 WebServer请求与响应实现[4] Arduino-ESP32 3.3.12 Wi-Fi STA实现[5] 乐鑫 DevKitC-1 v1.1 固定文档:修订、供电与排针[6] Kingbright WP7113ID数据手册:极性与额定条件
资料来源与版本说明
按指定板级修订、框架与上游源码制作。真实构建、主机辅助检查与实板行为分别记录;没有把主机测试或示意图写成本站硬件结果。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。