ESP32-C3 物联网控制终端 PROJECT 01 ← 返回 HQ
// ESP32-C3 · MicroPython

ESP32-C3
Web 控制终端

浏览器通过局域网控制设备,MicroPython 提供 HTTP 服务。

MicroPython ESP32-C3 SH1106 OLED SG90 PWM 零第三方依赖 零中间服务器
boot.log
[BOOT] ESP32-C3 rev0.4 · MicroPython v1.23
[I2C ] scan 0x3C ACK · SH1106 128x64 已就绪
[PWM ] SG90 on GPIO4 @ 50Hz · 初始 90°
[WIFI] connecting to ****** ...
[WIFI] connected 192.168.1.42 · RSSI -58dBm
[HTTP] listening on 0.0.0.0:80
[MEM ] free 41260 bytes
[READY] 等待连接 —— 浏览器打开 http://192.168.1.42/
01

项目概述

单文件固件 · 344 行

常见的物联网做法是「设备 → 云平台 → App」,链路长、依赖多,还得注册账号。 这个项目把中间两层全部砍掉:ESP32-C3 自己就是一个 Web 服务器, 控制界面也是它发出来的。浏览器是每台手机都有的通用客户端,于是「安装 App」 这件事直接消失了。

整台设备上只有一个执行流。没有 threading,没有锁,也没有任务调度器—— 用 uselect.poll() 多路复用,一个循环里同时处理 HTTP 连接、刷新 OLED、 推进舵机、回收内存。屏幕是 I²C 的 SH1106,舵机走 50Hz PWM, 两者与网络共用同一个主循环,互不阻塞。

代码里没有引入任何第三方库,只用了 MicroPython 标准库里的 socket、uselect、network、framebuf 和 machine。

02

系统架构

没有中间层,链路只有一跳
浏览器
手机 / 电脑 · 无需安装任何东西
ESP32-C3 · 单线程主循环
  • uselect.poll(1000) —— 最多阻塞 1 秒,没有连接就继续往下跑
  • 有新连接 → accept() 并注册进 poller
  • 有数据 → 解析请求行 → 路由 → 回响应 → 关闭连接
  • 空闲 → 刷新 OLED + gc.collect()
SH1106 OLED
I²C 400kHz · GPIO8/9
128×64 状态显示
SG90 舵机
PWM 50Hz · GPIO4
0–180° 角度输出
03

硬件与接线

总成本约 30 元
元件规格作用
ESP32-C3 开发板RISC-V 单核 160MHz · Wi-Fi 4主控,跑 MicroPython 与 Web 服务器
1.3" OLEDSH1106 · 128×64 单色 · I²C显示 IP、运行时长、当前角度
SG90 舵机50Hz PWM · 0–180°被控制的对象
5V 2A 电源充电宝或独立适配器给舵机供电
引脚接到说明
OLED VCC / GND3V3 / GND屏幕供电
OLED SDAGPIO8I²C 数据
OLED SCLGPIO9I²C 时钟,400kHz
舵机 橙(信号)GPIO4PWM 脉冲
舵机 红(VCC)外部 5V不要接开发板的 5V
舵机 棕(GND)GND必须与开发板共地
供电注意:SG90 堵转电流可以超过 1A。如果直接用开发板的 5V 引脚带舵机, 转到大角度时电压会被拉垮,芯片直接复位。舵机必须独立供电,但 GND 一定要和开发板连在一起, 否则 PWM 信号没有参考电平。
04

技术要点

6 个值得展开讲的点
01 / CONCURRENCY

单线程多路复用

用 uselect.poll() 而不是 threading。主循环最多等 1 秒就返回, 没有连接进来时继续往下跑。整台设备只有一个执行流——没有锁,没有竞态, 也没有线程调度开销。这在只有几百 KB 内存的芯片上是决定性的。

02 / ARCHITECTURE

设备即服务器

HTML、CSS、JavaScript 全部作为字符串常量烧进固件。设备不需要向任何地方注册, 也不需要外网。浏览器请求 / 就把整个控制台拿回去。 断网、拔掉路由器,只要同一个局域网就能用。

03 / FRONTEND

滑块防抖

oninput 只更新本地数字显示,停手 80ms 后才真正发请求。 不这么做的话,拖动一次滑块会瞬间产生几十个 HTTP 请求, 把 listen(3) 的连接队列直接撑爆,表现为网页卡死。

04 / DRIVER

自己写显示驱动

继承 framebuf.FrameBuffer,白嫖它的画点、画线、画矩形和文字渲染, 只实现「怎么把这块 buffer 送上屏幕」。关键坑:SH1106 内部 RAM 是 132 列, 可见区从第 2 列开始,刷屏必须带 +2 列偏移。

05 / ROBUSTNESS

STA → AP 自动降级

连 Wi-Fi 最多重试 10 秒,失败就切到 AP 模式,自己开一个热点。 设备永远不会因为「找不到那台路由器」而变成一块砖—— 现场演示时没有网络也能连上去。

06 / PWM

纳秒级舵机控制

duty_ns() 直接给脉冲宽度:0.5ms 对应 0°,2.5ms 对应 180°。 比换算成 0–1023 的占空比直观得多,也没有整数除法带来的精度损失。 角度还会先夹到 0–180,越界值不会让舵机撞到限位。

05

核心代码

摘自 projects/esp32-c3/main.py
main.py主循环 · 单线程多路复用
poller = uselect.poll()
poller.register(server, uselect.POLLIN)

while True:
    for fd, _ev in poller.poll(1000):        # 最多等 1 秒,然后继续往下走
        if fd == server.fileno():
            conn, _addr = server.accept()    # 新连接进来了
            conn.setblocking(False)
            poller.register(conn, uselect.POLLIN)
            continue

        req = fd.read(1024)                  # 已有连接发来了请求
        if req:
            fd.send(handle_request(req.decode()).encode())
        poller.unregister(fd)
        fd.close()

    # 这一秒没人连进来,就顺手把屏幕刷了、内存收了
    if time.time() - last_draw >= 1:
        last_draw = time.time()
        screen("WiFi OK", "IP " + _ip,
               "HTTP :80  {}s".format(int(time.time() - boot_time)),
               "Angle {}  {}KB".format(angle, gc.mem_free() // 1024))
        gc.collect()
main.py网络 · STA 失败自动降级 AP
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.connect(WIFI_SSID, WIFI_PASS)

for _ in range(20):                     # 最多等 10 秒
    if sta.isconnected():
        return sta.ifconfig()[0], True
    time.sleep_ms(500)

# 连不上也没关系:自己开一个热点,设备永远不会变成一块砖
sta.active(False)
ap = network.WLAN(network.AP_IF)
ap.active(True)
ap.config(essid=AP_SSID, password=AP_PASS)
return ap.ifconfig()[0], False
main.py驱动 · SH1106 刷屏与列偏移
def show(self):
    """把 buffer 刷到屏幕。0x40 表示后面跟的是显存数据。"""
    for page in range(OLED_H // 8):
        # SH1106 内部 RAM 有 132 列,可见区从第 2 列开始 —— 必须 +2 偏移。
        # 这是它和 SSD1306 最容易踩的坑,写错了整个画面会横向错位。
        self._cmd(0xB0 + page, 0x02, 0x10)
        start = page * OLED_W
        chunk = bytearray(OLED_W + 1)
        chunk[0] = 0x40
        chunk[1:] = self.buffer[start:start + OLED_W]
        self.i2c.writeto(self.addr, chunk)
main.py执行器 · 纳秒级 PWM 角度控制
SERVO_FREQ = 50            # SG90 标准 50Hz,周期 20ms
SERVO_MIN_NS = 500_000     # 0.5ms 脉冲 -> 0°
SERVO_MAX_NS = 2_500_000   # 2.5ms 脉冲 -> 180°

def set_angle(deg):
    """转动舵机。duty_ns 直接给纳秒级占空比,比换算 duty() 更直观。"""
    deg = max(0, min(180, int(deg)))        # 越界值夹住,不让舵机撞限位
    servo.duty_ns(int(SERVO_MIN_NS + deg / 180 * (SERVO_MAX_NS - SERVO_MIN_NS)))
    return deg
06

屏幕状态

OLED 上电后依次呈现的四种画面

所有状态都显示在 OLED 上,不额外占用 GPIO——这样即使板载 LED 和 I²C 引脚冲突也不受影响。 四行文字分别对齐到 0 / 16 / 32 / 48 像素,因为 8×8 字库在 MONO_VLSB 下必须按 8 的整数倍对齐,否则画面会花。

① 上电启动
ESP32-C3 WiFi... connecting
② 联网成功
WiFi OK IP 192.168.1.42 HTTP :80 Angle 90
③ 运行中(秒数在走)
WiFi OK IP 192.168.1.42 HTTP :80 127s Angle 135 38KB
④ 无路由器时降级热点
AP MODE IP 192.168.4.1 HTTP :80 12s Angle 90 41KB

上图为按真实字库还原的示意。真机照片待补。

07

项目结构

源码位于本站 projects/esp32-c3/
esp32-c3/ ├── main.py # 固件:OLED 驱动 + 舵机 PWM + 网页服务器 ├── config.example.py # 配置模板(Wi-Fi / 热点) ├── .gitignore # 排除 config.py,凭据不进版本库 ├── README.md # 接线表 · 烧录步骤 · 技术说明 └── legacy/ ├── serial_firmware.py # 早期方案:串口接收角度指令 └── serial_gui.py # 早期方案:PC 端 Tkinter 上位机

关于迭代:第一版走的是串口路线——开发板监听串口,PC 上用 Tkinter 画个滑块界面,通过 USB 线发角度过去。能跑,但必须插着线,而且换台电脑就得重装 Python 环境和串口驱动;给朋友演示时光配环境就花了十分钟。第二版干脆把服务器做进设备本身, 「装环境」这件事就消失了,协议也从自定义串口文本换成了 HTTP——调试时 curl 一下就能验证。早期那版放在 legacy/ 里留作对照。

接口:设备对外只有三个路由—— GET / 返回控制台页面, GET /angle?deg=90 转动舵机并返回 JSON, GET /status 返回角度、IP、运行秒数和可用内存。 网页每 3 秒轮询一次 /status 刷新显示。