浏览器通过局域网控制设备,MicroPython 提供 HTTP 服务。
[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/
常见的物联网做法是「设备 → 云平台 → App」,链路长、依赖多,还得注册账号。 这个项目把中间两层全部砍掉:ESP32-C3 自己就是一个 Web 服务器, 控制界面也是它发出来的。浏览器是每台手机都有的通用客户端,于是「安装 App」 这件事直接消失了。
整台设备上只有一个执行流。没有 threading,没有锁,也没有任务调度器——
用 uselect.poll() 多路复用,一个循环里同时处理 HTTP 连接、刷新 OLED、
推进舵机、回收内存。屏幕是 I²C 的 SH1106,舵机走 50Hz PWM,
两者与网络共用同一个主循环,互不阻塞。
代码里没有引入任何第三方库,只用了 MicroPython 标准库里的
socket、uselect、network、framebuf
和 machine。
| 元件 | 规格 | 作用 |
|---|---|---|
| ESP32-C3 开发板 | RISC-V 单核 160MHz · Wi-Fi 4 | 主控,跑 MicroPython 与 Web 服务器 |
| 1.3" OLED | SH1106 · 128×64 单色 · I²C | 显示 IP、运行时长、当前角度 |
| SG90 舵机 | 50Hz PWM · 0–180° | 被控制的对象 |
| 5V 2A 电源 | 充电宝或独立适配器 | 给舵机供电 |
| 引脚 | 接到 | 说明 |
|---|---|---|
| OLED VCC / GND | 3V3 / GND | 屏幕供电 |
| OLED SDA | GPIO8 | I²C 数据 |
| OLED SCL | GPIO9 | I²C 时钟,400kHz |
| 舵机 橙(信号) | GPIO4 | PWM 脉冲 |
| 舵机 红(VCC) | 外部 5V | 不要接开发板的 5V |
| 舵机 棕(GND) | GND | 必须与开发板共地 |
用 uselect.poll() 而不是 threading。主循环最多等 1 秒就返回,
没有连接进来时继续往下跑。整台设备只有一个执行流——没有锁,没有竞态,
也没有线程调度开销。这在只有几百 KB 内存的芯片上是决定性的。
HTML、CSS、JavaScript 全部作为字符串常量烧进固件。设备不需要向任何地方注册,
也不需要外网。浏览器请求 / 就把整个控制台拿回去。
断网、拔掉路由器,只要同一个局域网就能用。
oninput 只更新本地数字显示,停手 80ms 后才真正发请求。
不这么做的话,拖动一次滑块会瞬间产生几十个 HTTP 请求,
把 listen(3) 的连接队列直接撑爆,表现为网页卡死。
继承 framebuf.FrameBuffer,白嫖它的画点、画线、画矩形和文字渲染,
只实现「怎么把这块 buffer 送上屏幕」。关键坑:SH1106 内部 RAM 是 132 列,
可见区从第 2 列开始,刷屏必须带 +2 列偏移。
连 Wi-Fi 最多重试 10 秒,失败就切到 AP 模式,自己开一个热点。 设备永远不会因为「找不到那台路由器」而变成一块砖—— 现场演示时没有网络也能连上去。
duty_ns() 直接给脉冲宽度:0.5ms 对应 0°,2.5ms 对应 180°。
比换算成 0–1023 的占空比直观得多,也没有整数除法带来的精度损失。
角度还会先夹到 0–180,越界值不会让舵机撞到限位。
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()
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
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)
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
所有状态都显示在 OLED 上,不额外占用 GPIO——这样即使板载 LED 和 I²C 引脚冲突也不受影响。
四行文字分别对齐到 0 / 16 / 32 / 48 像素,因为 8×8 字库在 MONO_VLSB
下必须按 8 的整数倍对齐,否则画面会花。
上图为按真实字库还原的示意。真机照片待补。
关于迭代:第一版走的是串口路线——开发板监听串口,PC 上用 Tkinter
画个滑块界面,通过 USB 线发角度过去。能跑,但必须插着线,而且换台电脑就得重装
Python 环境和串口驱动;给朋友演示时光配环境就花了十分钟。第二版干脆把服务器做进设备本身,
「装环境」这件事就消失了,协议也从自定义串口文本换成了 HTTP——调试时
curl 一下就能验证。早期那版放在 legacy/ 里留作对照。
接口:设备对外只有三个路由——
GET / 返回控制台页面,
GET /angle?deg=90 转动舵机并返回 JSON,
GET /status 返回角度、IP、运行秒数和可用内存。
网页每 3 秒轮询一次 /status 刷新显示。