初识 ESP32:从 TTY 控制再到网络控制实践封面图

初识 ESP32:从 TTY 控制再到网络控制实践

很早之前,我就有过想玩物联网、通过程序控制硬件的想法。但是因为自己的拖延症,以及一直没有遇到什么合适的契机,这个想法始终停留在“下次一定”的阶段。好巧不巧,最近正好接到了一个与物联网控制有关的活,要求实现某些硬件产品的程序化联网控制。这下好了,需求和兴趣直接对上电波了。既然迟早都要研究,不如趁这个机会入坑,先拿一块开发板练练手。
关于物联网控制 MCU 的选型,我和 AI 交流了很久,了解到目前有不少方案可以实现程序对硬件的控制,例如 STM32、ESP32,以及各种能够运行 Linux 的 ARM 开发板。而综合开发难度、成本以及我自己的需求来看,最合适的还是 ESP32。
原因主要有以下几点:

  • 无线连接方便。 ESP32 系列普遍提供 Wi-Fi,部分型号还支持蓝牙,非常适合做 IoT 设备。
  • 软件生态成熟。 乐鑫提供了 ESP-IDF 官方开发框架,大量组件开源,Linux 下有完整的开发工具链,既可以写 C/C++,也能使用 MicroPython。
  • 硬件成本低。 淘宝上普通 ESP32 开发板通常十几元就能买到,某些入门型号甚至不到十元,试错成本很低。
  • 不需要从零写驱动。 官方 SDK 已经封装了不少硬件初始化和外设操作,上手难度比我原本想象的低不少。

很好啊,啪的一下,我就下单了。

而且本着“可以不用,但不能没有”的精神,反正也多不了几个钱,直接选了一块配置比较高的:

ESP32-S3-N16R8

其主要硬件配置如下:

项目配置
SoCESP32-S3
CPU双核 Xtensa LX7,最高 240 MHz
内部 SRAM512 KB
Flash16 MB
PSRAM8 MB
Wi-Fi2.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、传感器和网络通信的设备来说,这配置多少有点性能过剩。不过考虑到以后还打算折腾音频播放、网络服务,倒也不算浪费。

没几天,开发板就到祸了,开玩!
01.jpg

插电,开机

开发板拿到手,第一步当然是插电,开机,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 下载模式。
所以进入下载模式的方法是:

  1. 按住 BOOT。
  2. 短按 RST。
  3. 松开 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 <module>
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 = '''<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Neko RGB 控制台</title>
<style>
:root{color-scheme:dark;font-family:system-ui,-apple-system,sans-serif}
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:#131524;color:#f4f4ff;padding:18px}
.card{width:min(100%,440px);background:#22253a;border:1px solid #3a405d;border-radius:20px;padding:26px;box-shadow:0 18px 60px #0005}
h1{font-size:22px;margin:0 0 6px}p{color:#b6bdd9;font-size:14px;margin:0 0 18px}
.preview{height:110px;border-radius:14px;margin:18px 0;background:#ff66cc;border:1px solid #ffffff35}
label{display:block;font-size:13px;color:#c7cbe0;margin:17px 0 7px}
input[type=color]{width:100%;height:90px;border:0;padding:0;background:transparent;cursor:pointer}
input[type=text],input[type=number]{width:100%;background:#141827;color:white;border:1px solid #515774;border-radius:10px;padding:11px 12px;font-size:16px}
input[type=range]{width:100%;accent-color:#ff78bb}.row{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
small{color:#b9c3db}button{background:#fa8fbe;color:#23162a;border:0;border-radius:9px;padding:11px;cursor:pointer;font-weight:700}
.status{margin-top:18px;color:#a4e5ca;font-size:13px}
</style></head>
<body><main class="card"><h1>Neko RGB 控制台</h1><p>ESP32-S3 · GPIO48 · WS2812</p>
<div class="preview" id="preview"></div>
<label>选色轮盘(点击打开系统颜色选择器)</label><input type="color" id="wheel" value="#ff66cc">
<label>HTML HEX 颜色代码(例如 #39C5BB)</label><input id="hex" type="text" maxlength="7" value="#39C5BB" spellcheck="false">
<label>RGB 数值(0–255)</label><div class="row"><input id="r" type="number" min="0" max="255" value="255"><input id="g" type="number" min="0" max="255" value="102"><input id="b" type="number" min="0" max="255" value="204"></div>
<label>亮度:<span id="bn">40</span>%</label><input type="range" id="brightness" min="0" max="100" value="40">
<button id="off">熄灯</button> <button id="on">恢复亮度</button>
<div class="status" id="status">等待操作</div></main>
<script>
const $=x=>document.getElementById(x);let timer;
const clamp=n=>Math.max(0,Math.min(255,Number.isFinite(n)?n:0));
const toHex=n=>clamp(n).toString(16).padStart(2,'0').toUpperCase();
function paint(hex){$('wheel').value=hex;$('hex').value=hex.toUpperCase();
 $('r').value=parseInt(hex.slice(1,3),16);$('g').value=parseInt(hex.slice(3,5),16);$('b').value=parseInt(hex.slice(5,7),16);
 $('preview').style.background=hex;$('bn').textContent=$('brightness').value;sendSoon();}
function sendSoon(){clearTimeout(timer);timer=setTimeout(async()=>{
 const hex=$('wheel').value.slice(1).toUpperCase(),br=$('brightness').value;
 try{let res=await fetch('/api/set?hex='+hex+'&brightness='+br,{cache:'no-store'});
 if(!res.ok)throw Error(res.status);$('status').textContent='已发送:#'+hex+' · '+br+'%';}
 catch(e){$('status').textContent='发送失败:'+e.message;}
 },100);}
$('wheel').addEventListener('input',e=>paint(e.target.value));
$('hex').addEventListener('input',e=>{let s=e.target.value.trim();if(!s.startsWith('#'))s='#'+s;
 if(/^#[0-9a-fA-F]{6}$/.test(s))paint(s);});
for(let k of ['r','g','b'])$(k).addEventListener('input',()=>{
 const hex='#'+['r','g','b'].map(c=>toHex(parseInt($(c).value)||0)).join('');paint(hex);});
$('brightness').addEventListener('input',()=>{$('bn').textContent=$('brightness').value;sendSoon();});
$('off').addEventListener('click',()=>{$('brightness').value=0;$('bn').textContent='0';sendSoon();});
$('on').addEventListener('click',()=>{$('brightness').value=40;$('bn').textContent='40';sendSoon();});
$('preview').style.background=$('wheel').value;
</script></body></html>'''

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 发布颜色指令。
例如:

#FF0000

ESP32 收到消息后,把 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 控制方案,这次实践确实让我第一次直观感受到:

原来让自己写的程序真正控制物理世界,是这么有意思的一件事。

评论区 (0)