初识 ESP32:从 TTY 控制再到网络控制实践
很早之前,我就有过想玩物联网、通过程序控制硬件的想法。但是因为自己的拖延症,以及一直没有遇到什么合适的契机,这个想法始终停留在“下次一定”的阶段。好巧不巧,最近正好接到了一个与物联网控制有关的活,要求实现某些硬件产品的程序化联网控制。这下好了,需求和兴趣直接对上电波了。既然迟早都要研究,不如趁这个机会入坑,先拿一块开发板练练手。
关于物联网控制 MCU 的选型,我和 AI 交流了很久,了解到目前有不少方案可以实现程序对硬件的控制,例如 STM32、ESP32,以及各种能够运行 Linux 的 ARM 开发板。而综合开发难度、成本以及我自己的需求来看,最合适的还是 ESP32。
原因主要有以下几点:
- 无线连接方便。 ESP32 系列普遍提供 Wi-Fi,部分型号还支持蓝牙,非常适合做 IoT 设备。
- 软件生态成熟。 乐鑫提供了 ESP-IDF 官方开发框架,大量组件开源,Linux 下有完整的开发工具链,既可以写 C/C++,也能使用 MicroPython。
- 硬件成本低。 淘宝上普通 ESP32 开发板通常十几元就能买到,某些入门型号甚至不到十元,试错成本很低。
- 不需要从零写驱动。 官方 SDK 已经封装了不少硬件初始化和外设操作,上手难度比我原本想象的低不少。
很好啊,啪的一下,我就下单了。
而且本着“可以不用,但不能没有”的精神,反正也多不了几个钱,直接选了一块配置比较高的:
ESP32-S3-N16R8
其主要硬件配置如下:
| 项目 | 配置 |
|---|---|
| SoC | ESP32-S3 |
| CPU | 双核 Xtensa LX7,最高 240 MHz |
| 内部 SRAM | 512 KB |
| Flash | 16 MB |
| PSRAM | 8 MB |
| Wi-Fi | 2.4 GHz 802.11 b/g/n |
| 蓝牙 | Bluetooth LE 5 |
| USB | 原生 USB、USB Serial/JTAG |
| 开发环境 | ESP-IDF、Arduino、MicroPython |
N16R8 的含义也很简单:N16 代表 16 MB Flash,R8 代表 8 MB PSRAM。
对于一个准备拿来控制 LED、传感器和网络通信的设备来说,这配置多少有点性能过剩。不过考虑到以后还打算折腾音频播放、网络服务,倒也不算浪费。
没几天,开发板就到祸了,开玩!
插电,开机
开发板拿到手,第一步当然是插电,开机,500mA 大电牛(误)
我日常使用的是 Manjaro Linux,因此可以直接通过 dmesg 观察 USB 设备的连接情况。
先打开一个终端:
sudo dmesg -w随后用 Type-C 数据线连接电脑和开发板。很快,内核日志就出现了新设备:
[ 6548.953163] usb 1-2.1: new full-speed USB device number 9 using xhci_hcd
[ 6549.091140] usb 1-2.1: New USB device found, idVendor=303a, idProduct=4001, bcdDevice= 1.00
[ 6549.091150] usb 1-2.1: New USB device strings: Mfr=1, Product=2, SerialNumber=3
[ 6549.091153] usb 1-2.1: Product: Espressif Device
[ 6549.091157] usb 1-2.1: Manufacturer: Espressif Systems
[ 6549.091160] usb 1-2.1: SerialNumber: 123456
[ 6549.164312] cdc_acm 1-2.1:1.0: ttyACM0: USB ACM device非常顺利,Linux 已经认出了这块板子,并且创建了一个设备节点:
/dev/ttyACM0这里简单介绍一下 TTY:在 Linux 中,TTY 是一类终端设备接口的统称。串口设备可以通过 TTY 接口与用户空间程序通信。其中,ttyACM0 通常表示一个使用 USB CDC ACM 协议的虚拟串口;ttyUSB0 则常见于 CH340、CP210x 等 USB 转串口设备。
虽然表现形式很像终端,但这里要特别强调:
有 TTY 不代表有 Shell。
TTY 只是通信接口,设备内部究竟运行什么、能够接收什么指令,完全取决于它的固件。
另外,我这块开发板比较有意思,它有两个 Type-C 接口,一个连接 ESP32-S3 的原生 USB,另一个通过 CH343P USB-UART 芯片进行通信。
我当前使用的是前者,也就是 ESP32-S3 的原生 USB。
开发板通电后,板载 RGB LED 已经开始循环变换颜色。看起来卖家还预装了一份灯效 Demo。既然 USB 能识别,那下一步当然是试试能不能和这颗小芯片交流交流,控制一下这个在变化的板载 RGB LED。
尝试进行 TTY 连接
确定 /dev/ttyACM0 存在以后,我就想尝试直接连接进去,在 Conda 环境里安装 Python 的 pyserial:
pip install pyserial 然后尝试连接开发板,执行:
python -m serial.tools.miniterm /dev/ttyACM0 115200结果迎面而来的不是交互终端,而是:
could not open port '/dev/ttyACM0': [Errno 13] could not open port /dev/ttyACM0: [Errno 13] Permission denied: '/dev/ttyACM0'好家伙,新手村第一个坑就来了。
Linux 的串口权限
检查设备节点:
ls -l /dev/ttyACM0得到:
crw-rw---- 1 root uucp 166, 0 10月30日 10:37 /dev/ttyACM0问题很清楚,串口设备属于 root:uucp,而当前用户并没有加入 uucp 组。
因此可以执行:
sudo usermod -aG uucp "$USER"但还有一个小坑:用户组修改不会立即更新当前登录会话的权限。
正常来说需要注销桌面并重新登录。不过为了不打断手上的活,也可以执行:
newgrp uucp然后重新激活 Conda 环境,这样就可以在新的 Shell 中获得对应组权限了。
如果只是临时测试,也可以使用:
sudo setfacl -m "u:$(id -un):rw" /dev/ttyACM0不过这种方式只修改当前设备节点的权限,USB 重新枚举后可能失效。
再次尝试执行:
python -m serial.tools.miniterm /dev/ttyACM0 115200然后成功连接,终端持续输出:
--- Miniterm on /dev/ttyACM0 115200,8,N,1 ---
--- Quit: Ctrl+] | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H ---
50%G
50%B
50%R
50%G
50%B
50%R
50%G
50%B
50%R
50%G
50%B
50%R
50%G
50%B
50%R
50%G
50%B
50%R把权限问题解决之后,我又尝试了一个比较底层的操作:使用乐鑫官方的 esptool 查询芯片信息。
先安装工具:
pip install esptool 然后:
python -m esptool \
--port /dev/ttyACM0 \
chip_id结果又翻车了:
esptool.py v4.12.0
Serial port /dev/ttyACM0
Connecting......................................
A fatal error occurred: Failed to connect to Espressif device: No serial data received.
For troubleshooting steps visit: https://docs.espressif.com/projects/esptool/en/latest/troubleshooting.html这次不是权限问题,而是 ESP32 当前运行的 USB 固件接口,并没有响应 esptool 的下载协议。
原来还有 BOOT 模式
研究了一下才发现,ESP32 开发板上面的两个按钮还真不是摆设:一个是 BOOT,另一个是 RST。
对于 ESP32-S3 来说,BOOT 按钮通常用于拉低 GPIO0,RST 则用于复位芯片。复位时芯片会检查启动配置引脚的电平,从而决定正常启动还是进入 ROM 下载模式。
所以进入下载模式的方法是:
- 按住 BOOT。
- 短按 RST。
- 松开 RST,再松开 BOOT。
观察持续更新的 dmesg -w,设备的识别果然发生了变化:
[ 9377.909465] usb 1-2.1: new full-speed USB device number 11 using xhci_hcd
[ 9378.035284] usb 1-2.1: New USB device found, idVendor=303a, idProduct=1001, bcdDevice= 1.01
[ 9378.035295] usb 1-2.1: New USB device strings: Mfr=1, Product=2, SerialNumber=3
[ 9378.035299] usb 1-2.1: Product: USB JTAG/serial debug unit
[ 9378.035303] usb 1-2.1: Manufacturer: Espressif
[ 9378.035307] usb 1-2.1: SerialNumber: 1C:29:04:3D:E4:DC
[ 9378.082382] cdc_acm 1-2.1:1.0: ttyACM0: USB ACM device原来的 idVendor=303a, idProduct=4001 变成了 idVendor=303a, idProduct=1001。
再次使用 esptool:
python -m esptool \
--port /dev/ttyACM0 \
--before no_reset \
chip_id这次终于成功了:
esptool.py v4.12.0
Serial port /dev/ttyACM0
WARNING: Pre-connection option "no_reset" was selected. Connection may fail if the chip is not in bootloader or flasher stub mode.
Connecting...
Detecting chip type... ESP32-S3
Chip is ESP32-S3 (QFN56) (revision v0.2)
Features: WiFi, BLE, Embedded PSRAM 8MB (AP_3v3)
Crystal is 40MHz
USB mode: USB-Serial/JTAG
MAC: 1c:29:04:3d:e4:dc
Uploading stub...
Running stub...
Stub running...
Warning: ESP32-S3 has no Chip ID. Reading MAC instead.
MAC: 1c:29:04:3d:e4:dc
Hard resetting via RTS pin...随后继续读取 Flash 信息:
python -m esptool \
--port /dev/ttyACM0 \
--before no_reset \
flash_id结果显示:
esptool.py v4.12.0
Serial port /dev/ttyACM0
WARNING: Pre-connection option "no_reset" was selected. Connection may fail if the chip is not in bootloader or flasher stub mode.
Connecting...
Detecting chip type... ESP32-S3
Chip is ESP32-S3 (QFN56) (revision v0.2)
Features: WiFi, BLE, Embedded PSRAM 8MB (AP_3v3)
Crystal is 40MHz
USB mode: USB-Serial/JTAG
MAC: 1c:29:04:3d:e4:dc
Stub is already running. No upload is necessary.
Manufacturer: 68
Device: 4018
Detected flash size: 16MB
Flash type set in eFuse: quad (4 data lines)
Flash voltage set by eFuse to 3.3V
Hard resetting via RTS pin...很好,16 MB Flash 和 8 MB PSRAM 都与购买时的标称配置吻合。
这里的 Stub running 也值得一提:esptool 可以把一个辅助程序上传到 ESP32 的 RAM 中运行,用它完成 Flash 查询、读写等操作。也就是说,这时候我们已经不只是通过 USB 读到了设备名称,而是真正与芯片的底层下载机制建立了通信。
不过我暂时还不打算刷固件,因为开发板本身已经预装了一个 Demo,我更想先看看它到底在运行什么。
原来预装了 MicroPython
断开设备,重新连接,回到正常启动模式之后,再次使用 miniterm:
python -m serial.tools.miniterm /dev/ttyACM0 115200依然是之前第一次连接的情况,终端不断刷出:
--- Miniterm on /dev/ttyACM0 115200,8,N,1 ---
--- Quit: Ctrl+] | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H ---
50%B
50%R
50%G
50%B
50%R
50%G
50%B
50%R
50%G
50%B
50%R
50%G
50%B
50%R
50%G显然,这就是板载 RGB LED 演示程序输出的日志,因为与此同时板子上面的 RGB LED 灯珠也跟随输出做出了相同的变化。
看到输出很像终端在运行什么程序进行输出,我随手按了一下 Ctrl+C,结果灯珠停止了变化,串口出现:
Traceback (most recent call last):
File "main.py", line 18, in
KeyboardInterrupt:
MicroPython v1.19.1 on 2022-06-18; ESP32S3 module with ESP32S3
Type "help()" for more information.
>>>好家伙,原来开发板里面刷的是 MicroPython!也就是说,这块 ESP32-S3 现在运行着一个能够直接通过串口交互的 Python 解释器。刚才的 Ctrl+C 中断了正在运行的 LED Demo,随后进入了 MicroPython 的 REPL 环境,直接输入 Python 代码就能执行。
比如:
>>> import os
>>> print(os.uname())返回:
(sysname='esp32', nodename='esp32', release='1.19.1', version='v1.19.1 on 2022-06-18', machine='ESP32S3 module with ESP32S3')再查询一下 CPU 当前频率:
>>> import machine
>>> print(machine.freq())
160000000当前是 160 MHz,并没有运行在最高 240 MHz。
我甚至还能读取开发板 Flash 文件系统中的文件:
>>> print(os.listdir())
['boot.py', 'main.py']这就更有意思了:原来 MicroPython 固件提供了一个小型文件系统,可以存放多个 Python 脚本。其中 boot.py 通常负责启动初始化,main.py 则是开机自动执行的应用入口。这和传统的“一次烧进去一个程序”的印象不太一样,它虽然不是 Linux,但确实可以保存多个程序文件,并通过 Python 解释器执行。
顺便说一下,我一开始还尝试继续按 Ctrl+C,想从 Python 退到更底层的 Shell,后来才发现,这里和 Linux 完全不同。Linux 上的 Python 通常是 Shell 启动的一个进程,退出后自然回到 Bash 或 Zsh。而 MicroPython 本身就是当前固件提供的主要交互环境,它下面没有一个等着接管控制权的 Bash。所以 Ctrl+C 只会中断 Python 程序,不会让你掉进一个隐藏的 Linux Shell。
如果要退出电脑上的 miniterm,需要按:
Ctrl + ]而 machine.reset() 则会让 ESP32 复位。由于原生 USB 也会随之重新初始化,Linux 端的串口连接可能断开,miniterm 甚至会抛出异常。
xception in thread rx:
Traceback (most recent call last):
File "/run/media/chocola/DATA/Python_Envs/envs/get_Music/lib/python3.9/site-packages/serial/tools/miniterm.py", line 499, in reader
data = self.serial.read(self.serial.in_waiting or 1)
File "/run/media/chocola/DATA/Python_Envs/envs/get_Music/lib/python3.9/site-packages/serial/serialposix.py", line 595, in read
raise SerialException(
serial.serialutil.SerialException: device reports readiness to read but returned no data (device disconnected or multiple access on port?)
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/run/media/chocola/DATA/Python_Envs/envs/get_Music/lib/python3.9/threading.py", line 980, in _bootstrap_inner
self.run()
File "/run/media/chocola/DATA/Python_Envs/envs/get_Music/lib/python3.9/threading.py", line 917, in run
self._target(*self._args, **self._kwargs)
File "/run/media/chocola/DATA/Python_Envs/envs/get_Music/lib/python3.9/site-packages/serial/tools/miniterm.py", line 510, in reader
self.console.cancel()
File "/run/media/chocola/DATA/Python_Envs/envs/get_Music/lib/python3.9/site-packages/serial/tools/miniterm.py", line 219, in cancel
fcntl.ioctl(self.fd, termios.TIOCSTI, b'\0')
OSError: [Errno 5] Input/output error第一次看到一大段 Python Traceback 还以为又炸了,实际上只是电脑端正在读取的 USB 串口突然消失了,虚惊一场。
通过 TTY 控制 RGB LED 灯珠
既然已经进入了 MicroPython REPL,那当然不能只停留在 print("Hello World")。开发板上已经有一颗 RGB LED,而且正在循环变色。不如直接接管它试试。
先看看卖家预装的 main.py:
print(open("main.py").read())读取后发现,它的核心逻辑非常简单:
from machine import Pin
from neopixel import NeoPixel
import time
pin = Pin(48, Pin.OUT)
np = NeoPixel(pin, 1)
np[0] = (10,0,0)
np.write()
r, g, b = np[0]
print("RGB Demo")
while True:
print("50%R")
np[0] = (127, 0, 0)
np.write()
time.sleep_ms(1000)
print("50%G")
np[0] = (0, 127, 0)
np.write()
time.sleep_ms(1000)
print("50%B")
np[0] = (0, 0, 127)
np.write()
time.sleep_ms(1000)原来 LED 连接在 GPIO48,使用的是 WS2812 类型的可编程 RGB 灯珠。虽然看上去是一颗普通的 LED,但它内部实际上包含了控制电路,可以通过单线数字信号设置红、绿、蓝三个通道的亮度,这里也体现了开发板和 SoC 的区别。GPIO48 是 ESP32-S3 芯片本身提供的引脚,但开发板厂商选择将 WS2812 接到 GPIO48,这是开发板 PCB 的设计决定,换一块不同厂家、不同版本的开发板,RGB LED 连接的 GPIO 就可能不一样。
知道了引脚,直接开玩。
from machine import Pin
from neopixel import NeoPixel
led = NeoPixel(Pin(48, Pin.OUT), 1)
led[0] = (255, 0, 0)
led.write()红色。
led[0] = (0, 255, 0)
led.write()绿色。
led[0] = (0, 0, 255)
led.write()蓝色。
再来一个二次元浓度比较高的颜色:
led[0] = (255, 102, 204)
led.write()粉色!
这里的 RGB 值分别表示三个颜色通道,范围是 0~255。
最后执行:
led[0] = (0, 0, 0)
led.write()就可以关闭灯珠。
第一次通过自己输入的程序让实体硬件发生变化,感觉确实挺奇妙的。过去写的程序大多只能在屏幕上显示东西,现在终于开始对物理世界动手动脚了。不过也要注意,NeoPixel 只是 MicroPython 对 WS2812 通信协议的封装。实际底层还涉及严格的数字信号时序,并不是把 GPIO 简单拉高就能控制颜色。这也是 ESP32 好上手的原因之一:很多复杂的硬件操作,框架已经帮忙处理好了。
那开发板上的其他 LED 呢?
我后来又研究了商家提供的开发板结构图,发现除了 WS2812,旁边还有三颗小 LED,分别标记为:
PWR
TX
RX原来它们并不是三个可以随意控制的 GPIO 灯珠:PWR 是电源指示灯,TX 和 RX 则是与板载 CH343P USB-UART 电路相关的通信指示灯。所以,如果想继续玩更多硬件,还是需要通过开发板两侧引出的 GPIO,外接 LED、传感器、电机、屏幕等元件。但仅仅靠这块开发板自带的 RGB 灯珠,其实就已经能够实现不少玩法了,比如让它显示网络状态、模拟呼吸灯、制作动态渐变,或者通过服务器远程控制颜色。
说到服务器……
尝试给开发板连接 Wi-Fi
既然这块 ESP32-S3 自带 Wi-Fi,那下一步当然是尝试联网,这一步比想象中简单得多。MicroPython 已经提供了 network 模块,不需要自己去处理射频驱动或 802.11 协议。
直接在 REPL 里面输入:
import network
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
print(wlan.scan())第一次扫描,我就看到了附近的无线网络,结果大致如下:
[(b'MikuNet', b'\xb8E\xf4\x1a\xe7\x12', 6, -36, 3, False), (b'GXU-WLAN', b'\xb8E\xf4\x1c\x8fR', 11, -52, 0, False), (b'GXU-WLAN', b'\xb8E\xf4\x1a\xdf\x92', 11, -68, 0, False), (b'GXU-WLAN', b'\x92*\xcc\xe4\x9a\xae', 1, -70, 0, False)]这一刻才真正感觉到,手里这个小小的开发板确实是一台能够独立进行无线通信的计算机。
wlan.scan() 返回的是扫描到的 AP 列表,每个条目通常包含 SSID、BSSID、信道、RSSI、认证方式和隐藏状态。
例如:
(ssid, bssid, channel, RSSI, authmode, hidden)其中 RSSI 是信号强度,单位为 dBm,比如 -36 dBm 就属于很强的信号,-70 dBm 则已经比较弱。
扫描结果中还出现了好几个同名的 GXU-WLAN,但 BSSID 不同。这说明附近有多个无线接入点广播同一个 SSID,并不意味着 ESP32 重复扫描到了同一台设备。而且 GXU-WLAN 显示为开放网络,也不代表连接后就能直接访问互联网,校园网络可能还需要门户认证。
另外,ESP32-S3 仅支持 2.4 GHz Wi-Fi,因此不会扫描到真正工作在 5 GHz 频段的热点。
正式连接 Wi-Fi
接下来连接自己的无线网络:
wlan.connect("MikuNet", "www.nekopara.uk")随后查询连接状态:
print(wlan.isconnected())返回:
True再查看 IP 地址:
print(wlan.ifconfig())输出:
('192.168.8.166', '255.255.255.0', '192.168.8.1', '192.168.8.1')成功了!ESP32 已经通过 DHCP 获得了局域网地址:
192.168.8.166
到这里,ESP32 已经具备了独立访问局域网的能力,它不再需要一直通过 USB 接收电脑指令,也可以自己运行网络程序。当然,MicroPython 中执行 wlan.connect() 只是当前运行状态下建立连接。想让它断电重启后自动联网,还需要把对应逻辑写入启动脚本。
不过这就引出了一个更有意思的问题:既然 ESP32 都有 IP 地址了,那么能不能直接让它运行一个 HTTP Web 服务器?让浏览器访问 ESP32,再通过网页操作那颗 RGB LED?
答案当然是可以。
尝试通过 HTTP 网页控制灯珠
ESP32-S3 N16R8 对于这种简单的嵌入式 Web 服务来说,硬件资源绰绰有余,不过 ESP32 并不能直接运行常规的 Nginx 或 Apache。我们使用的是 MicroPython 自带的 socket 模块,在设备内部实现一个轻量级 HTTP 服务器。
整体逻辑很简单:
电脑 / 手机浏览器
|
| HTTP 请求
v
ESP32-S3 Wi-Fi
|
v
MicroPython socket
|
v
解析颜色控制命令
|
v
GPIO48 -> WS2812 RGB LED为了让这个实验更有意思,我还设计了一个简单的网页控制台,准备支持:
- 浏览器原生取色轮盘。
- HTML HEX 颜色码,比如初音绿
#39C5BB。 - RGB 三通道数值控制。
- 亮度调节。
- 红、绿、蓝等预设颜色。
- 直接通过 HTTP API 控制。
让 ChatGPT 写了一版 Demo:
# ESP32-S3 N16R8 + WS2812 on GPIO48
# MicroPython 1.19.1; LAN-only demo without authentication.
import network
import socket
import time
from machine import Pin
from neopixel import NeoPixel
# Fill in your own credentials locally; do not publish this file with the password.
WIFI_SSID = 'MikuNet'
WIFI_PASSWORD = 'www.nekopara.uk'
PORT = 80
led = NeoPixel(Pin(48, Pin.OUT), 1)
current_hex = '39C5BB'
brightness = 40
def draw():
c = [int(current_hex[i:i+2], 16) for i in (0, 2, 4)]
led[0] = tuple((x * brightness + 50) // 100 for x in c)
led.write()
def connect_wifi():
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if not wlan.isconnected():
wlan.connect(WIFI_SSID, WIFI_PASSWORD)
for _ in range(80):
if wlan.isconnected():
break
time.sleep_ms(250)
if not wlan.isconnected():
raise RuntimeError('Wi-Fi connection failed')
return wlan
PAGE = '''
Neko RGB 控制台
Neko RGB 控制台
ESP32-S3 · GPIO48 · WS2812
选色轮盘(点击打开系统颜色选择器)
HTML HEX 颜色代码(例如 #39C5BB)
RGB 数值(0–255)
亮度:40%
等待操作
'''
def respond(conn, status, content_type, body):
header = ('HTTP/1.1 %s\r\nContent-Type: %s\r\nContent-Length: %d\r\n'
'Cache-Control: no-store\r\nConnection: close\r\n\r\n') % (status, content_type, len(body))
conn.write(header.encode())
conn.write(body)
def serve():
global current_hex, brightness
wlan = connect_wifi()
draw()
server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', PORT))
server.listen(3)
print('Open http://%s:%d/' % (wlan.ifconfig()[0], PORT))
try:
while True:
conn, addr = server.accept()
try:
conn.settimeout(3)
request = conn.recv(1024)
parts = request.split(b' ', 2)
if len(parts) < 2:
continue
path = parts[1].decode()
if path.startswith('/api/set?'):
args = {}
for pair in path.split('?', 1)[1].split('&'):
if '=' in pair:
k, v = pair.split('=', 1)
args[k] = v
new_hex = args.get('hex', '')
new_b = args.get('brightness', '')
if len(new_hex) != 6 or any(ch not in '0123456789abcdefABCDEF' for ch in new_hex):
respond(conn, '400 Bad Request', 'text/plain', b'Invalid HEX')
elif not new_b.isdigit() or int(new_b) > 100:
respond(conn, '400 Bad Request', 'text/plain', b'Invalid brightness')
else:
current_hex = new_hex.upper()
brightness = int(new_b)
draw()
respond(conn, '200 OK', 'application/json', b'{"ok":true}')
elif path == '/':
respond(conn, '200 OK', 'text/html; charset=utf-8', PAGE.encode())
else:
respond(conn, '404 Not Found', 'text/plain', b'Not Found')
except Exception as exc:
print('Request error:', exc)
finally:
conn.close()
finally:
server.close()
serve()在浏览器打开 ESP32 的局域网 IP 地址就可以在网页进行控制,另外,也可以通过 Linux 终端发送:
curl "http://192.168.8.166/api/set?hex=FF0000&brightness=50"就可以让服务器将颜色设置为红色,亮度为 50%。
这套控制方案的一个明显优点,是任何可以发送 HTTP 请求的程序,都能通过接口控制 ESP32,不管是浏览器、Linux Shell,还是自己的服务器程序,都可以使用同一套 API。
但是,这个阶段的 socket Web 服务器仍然只是实验原型,还没有进行长期稳定性和高并发测试,它也没有用户身份认证,因此只适合可信局域网测试,不能直接向公网开放。
虽然 HTTP 已经能覆盖很多控制需求,但如果未来要让大量 IoT 设备与中央服务器保持通信,这种方式还有改进空间。比如服务器需要知道设备在线还是离线、设备状态变化时主动上报,以及多个客户端同时接收消息等,这就轮到另一个物联网领域的经典协议——MQTT 登场了。
尝试搭建 MQTT 物联网环境
实际上,MQTT 才是很多物联网设备常用的通信协议之一,它和 HTTP 最大的区别,是采用了 发布 / 订阅(Publish / Subscribe) 通信模型:客户端之间不需要直接互相建立控制连接,而是把消息交给一个中间服务器——MQTT Broker。设备和控制端都连接到 Broker,再通过 Topic 进行消息交换。
例如:
MQTT Broker
|
+---------+---------+
| |
v v
控制客户端 ESP32-S3
发布控制指令 订阅控制指令
发布设备状态假设 ESP32 订阅了:
neko/esp32/rgb/set那么任何有权限连接 Broker 的客户端,都可以向这个 Topic 发布颜色指令。
例如:
#FF0000ESP32 收到消息后,把 LED 设置为红色,执行完成,再向另一个 Topic 发布当前状态:
neko/esp32/rgb/state消息内容:
#FF0000这样控制端就能够知道设备当前的颜色。
相比 HTTP 直接请求,这种模型更适合设备数量增加、控制端与设备分离,以及状态主动上报的场景。
第一步:搭建 MQTT Broker
既然我本身就是 Linux 用户,那肯定优先考虑开源的 Mosquitto,在 Manjaro 上安装:
sudo pacman -S mosquitto为了避免一开始就修改太多系统配置,我准备先使用独立的配置文件,在前台启动 Broker。
先创建:
mosquitto.conf写入:
listener 1883 192.168.8.124
allow_anonymous true
persistence false
log_dest stdout然后执行:
mosquitto -c ./mosquitto.conf -v这样就启动了一个监听 TCP 1883 端口的 MQTT Broker。注意:这里为了方便实验,暂时允许匿名访问。但正式部署时应当配置身份认证、Topic ACL,并根据网络环境启用 TLS 或其他安全接入方案。
第二步:让 ESP32 连接 Broker
先让 ESP32 装上 MQTT 客户端库,执行:
mpremote connect /dev/ttyACM0 mip install --no-mpy umqtt.simple验证安装情况:
mpremote connect /dev/ttyACM0 exec \
"from umqtt.simple import MQTTClient; print('MQTT OK')"如果没问题,会返回:
MQTT OK然后写一个用于 ESP32 的 MQTT 客户端程序mqtt_rgb.py:
# MicroPython ESP32-S3 MQTT RGB demo (WS2812 on GPIO48)
import network
import time
from machine import Pin
from neopixel import NeoPixel
from umqtt.simple import MQTTClient
# Edit these three settings; keep private
WIFI_SSID = 'MikuNet'
WIFI_PASSWORD = 'www.nekopara.uk'
BROKER = '192.168.8.124' # Manjaro computer's LAN IP (NOT the ESP32 IP)
CLIENT_ID = b'neko-esp32-s3-rgb'
TOPIC_SET = b'neko/esp32/rgb/set'
TOPIC_STATE = b'neko/esp32/rgb/state'
TOPIC_STATUS = b'neko/esp32/status'
led = NeoPixel(Pin(48, Pin.OUT), 1)
led[0] = (0, 0, 0)
led.write()
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if not wlan.isconnected():
wlan.connect(WIFI_SSID, WIFI_PASSWORD)
for _ in range(60):
if wlan.isconnected():
break
time.sleep_ms(250)
if not wlan.isconnected():
raise RuntimeError('Wi-Fi connection failed')
print('Wi-Fi:', wlan.ifconfig())
client = MQTTClient(CLIENT_ID, BROKER, port=1883, keepalive=30)
def on_message(topic, message):
try:
value = message.decode().strip().upper()
if value == 'OFF':
value = '#000000'
if len(value) != 7 or value[0] != '#':
print('Invalid color:', value)
return
rgb = tuple(int(value[i:i+2], 16) for i in (1, 3, 5))
led[0] = rgb
led.write()
client.publish(TOPIC_STATE, value.encode(), retain=True)
print('RGB:', value)
except (ValueError, TypeError) as error:
print('Bad command:', error)
client.set_callback(on_message)
client.set_last_will(TOPIC_STATUS, b'offline', retain=True)
client.connect()
client.subscribe(TOPIC_SET)
client.publish(TOPIC_STATUS, b'online', retain=True)
print('MQTT connected to', BROKER)
print('Listening on', TOPIC_SET)
try:
last_ping = time.ticks_ms()
while True:
client.check_msg()
if time.ticks_diff(time.ticks_ms(), last_ping) >= 10000:
client.ping()
last_ping = time.ticks_ms()
time.sleep_ms(50)
except KeyboardInterrupt:
print('Interrupted')
finally:
try:
client.publish(TOPIC_STATUS, b'offline', retain=True)
client.disconnect()
except Exception:
pass把程序推送到 ESP32 运行:
mpremote connect /dev/ttyACM0 run mqtt_rgb.py第三步:在 Linux 终端发送 MQTT 指令
有了 Broker 和 ESP32 客户端,接下来就可以使用 Mosquitto 提供的命令行工具进行测试。首先打开一个终端,订阅设备状态:
mosquitto_sub \
-h 192.168.8.124 \
-t 'neko/esp32/#' \
-v这里的 # 是 MQTT 多级主题通配符,可以接收对应主题层级下的消息。然后在另一个终端发布控制指令:
mosquitto_pub \
-h 192.168.8.124 \
-t 'neko/esp32/rgb/set' \
-m '#39C5BB'这条命令的意思就是:向 Broker 发布一条消息,主题为 neko/esp32/rgb/set,消息内容为 #39C5BB。ESP32 订阅该主题后,就可以根据消息内容改变灯珠颜色,再通过 state Topic 上报结果。
为了让设备管理更完整,我还增加一个状态主题:
neko/esp32/status其中:
online
offline分别表示设备在线与离线。
MQTT 本身还有一个非常适合 IoT 的功能:Last Will and Testament(遗嘱消息)。客户端可以在连接 Broker 时设置遗嘱。当设备异常断线,而不是主动正常断开连接时,Broker 可以自动向指定 Topic 发布离线消息,这就比单纯依靠 HTTP 请求判断设备是否存在更方便了。
不过需要注意,MQTT 的 QoS 0/1/2 解决的是不同程度的消息交付保障问题,并不意味着硬件动作一定成功。真正要确认 RGB 灯已经更新,仍然应该由设备主动上报执行后的状态。
这就算是一套 IoT 控制系统了吗?
从系统结构上来说,已经具备了 IoT 控制系统最基本的雏形:
Linux 控制端
|
| Publish
v
Mosquitto Broker
|
| Subscribe
v
ESP32-S3
|
| GPIO48
v
WS2812 RGB LED
|
| 状态反馈
v
Mosquitto Broker
|
v
Linux 控制端一个简单的闭环就这样形成了。
当然,这部分目前主要完成的是实验程序和通信方案的搭建,仍需要进行实际端到端联调和长时间稳定性测试,暂时还谈不上生产可用。后续如果要作为正式 IoT 控制系统使用,还需要解决身份认证、消息格式规范、断线重连、OTA 固件更新、设备发现以及设备状态持久化等问题。再往后,还可以研究 Home Assistant MQTT Discovery、Sparkplug B 等更完整的 IoT 通信规范。
不过,这些就不是一篇新手入门博客能全部展开的内容了。
写到最后
回顾这次折腾过程,最开始我只是想买一块 ESP32 开发板,试试所谓的“程序控制硬件”到底是怎么一回事,结果没想到,从插上 USB 的那一刻开始,就有不少东西值得研究。先是 Linux 串口权限问题,随后又踩到了 ESP32 正常启动模式和 ROM 下载模式的区别。好不容易连上 TTY,又发现开发板运行的居然是 MicroPython,还能直接进入 REPL,读取 Flash 文件系统中的 Python 脚本。接着,从控制一颗小小的 RGB LED 开始,尝试操作 GPIO,再通过 Wi-Fi 扫描和连接,让开发板真正获得了属于自己的局域网 IP。最后,又将 HTTP Web 控制和 MQTT 发布/订阅通信作为进一步的实践方向,尝试建立一套最基本的物联网控制模型。这趟流程下来,我对 MCU 的认识也发生了很大变化。
以前总觉得嵌入式开发是一个非常底层、非常复杂的领域,需要自己操作寄存器、配置时钟、研究各种电气协议,似乎和普通的软件开发隔着一层很厚的墙,但实际接触 ESP32 后才发现,现代 MCU 的开发框架已经成熟了很多。尤其是 ESP-IDF 和 MicroPython 这样的软件环境,已经帮开发者封装了大量底层硬件操作。对开发者来说,控制一个 GPIO、建立 Wi-Fi 连接、收发 MQTT 消息,并不需要从头理解整个芯片的工作机制。
当然,这不意味着嵌入式开发没有难度:当程序需要长期稳定运行,或者涉及低功耗、实时控制、电源管理、音频处理和各种外设时,底层知识仍然非常重要。和 Linux 不同,ESP32 通常没有完整的用户态进程隔离,没有我们习惯的 Bash,也不能随意把现成的 Linux 程序丢进去运行。它是一台计算机,但它的设计理念和使用方式,与普通 PC 有着很大的区别。
而我这次购买的 ESP32-S3 N16R8,实际上只发挥了它非常小的一部分能力——16 MB Flash、8 MB PSRAM、双核 CPU,最后拿来点一颗 RGB 灯珠,多少有点高配电脑拿来扫雷的美感了。不过至少这次没有继续陷入“买了设备,放着吃灰”的经典循环。
从第一次通过 TTY 中断 LED Demo,到实际扫描 Wi-Fi、连接网络,再到整理 HTTP 和 MQTT 控制方案,这次实践确实让我第一次直观感受到:
原来让自己写的程序真正控制物理世界,是这么有意思的一件事。