联网与数据交换 / 局域网控制发布 内容更新

用手机控制 ESP32 小灯:做第一个局域网页面

把普通LED接到GPIO4,用固定Arduino core提供网页与状态API。处理错误参数、刷新读回和断网重连,区分HTTP成功、引脚电平与实体灯。

读完能做什么
在同网网页发送明确开/关,并以设备状态与实体灯分别验收。
开始之前
已完成N01连接状态与P01普通LED,能配置私有网络凭据。
所需器材
指定S3板、WP7113ID红LED与330Ω电阻、同网手机/电脑与2.4GHz AP、面包板、短导线、USB数据线
版本组合
框架:Arduino-ESP32 3.3.12 / CLI1.5.1服务:core内置WebServer,HTTP80灯回路:GPIO4 + WP7113ID + 330Ω
适用范围

官方 ESP32-S3-DevKitC-1 v1.1 / ESP32-S3-WROOM-1-N8R8;Arduino3.3.12,自带WebServer,2.4GHz可信局域网与GPIO4普通LED。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/networking-product.md#n02;逐项实际阅读、固定来源及限制。验证日期:
构建 / 配置已通过
docs/esp32-evidence/logs/n02-build.log;实际编译成功,摘要见networking-product-builds.json。对象:官方 ESP32-S3-DevKitC-1 v1.1 / ESP32-S3-WROOM-1-N8R8版本:Arduino / 3.3.12验证日期:
仿真不适用
没有器件级或无线仿真;主机协议fixture与源码检查另列,不冒充ESP32仿真。
本站实板待验证
本站尚未执行这套组合的烧录、联网、传感器与实体动作;正文提供独立验收步骤。

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 局域网控制

先把目标定为同网控制一盏普通灯

这一篇只在手机、电脑与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数据手册:极性与额定条件

02 / 局域网控制

延续同一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-4330Ω → 普通红LED阳极
GND / J1-22LED阴极
USB-to-UART数据、供电与115200串口
做到这里,你应该看到
P01能独立运行;私有凭据文件没有被加入仓库或下载包。

参考:[4] Arduino-ESP32 3.3.12 Wi-Fi STA实现[5] 乐鑫 DevKitC-1 v1.1 固定文档:修订、供电与排针[6] Kingbright WP7113ID数据手册:极性与额定条件

03 / 局域网控制

联网交给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实现

04 / 局域网控制

读状态用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=1400;GET不改输出
做到这里,你应该看到
能用curl分别得到成功、400与404,并对照灯和状态是否一致。

参考:[1] Arduino-ESP32 3.3.12 WebServer公开接口[2] Arduino-ESP32 3.3.12 WebServer请求与响应实现

05 / 局域网控制

先编译,再用实际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 固定文档:修订、供电与排针

06 / 局域网控制

完整程序让请求、状态与重连对应同一份实现

下方程序和下载包是同一份源码。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, &notice, 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, &notice, 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实现

07 / 局域网控制

把实体、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数据手册:极性与额定条件

BACK TO THE SOURCE

资料来源与版本说明

按指定板级修订、框架与上游源码制作。真实构建、主机辅助检查与实板行为分别记录;没有把主机测试或示意图写成本站硬件结果。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  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实现
  5. 乐鑫 DevKitC-1 v1.1 固定文档:修订、供电与排针
  6. Kingbright WP7113ID数据手册:极性与额定条件