设计一个迷你显示器,由M5Stack AtomS3r提供动力
我有一台M5Stack AtomS3R,放在一个盒子里。它是一个24×24×27毫米的立方体,前面玻璃后面是ESP32-S3芯片,128×128像素的显示屏,整个正面都是一块按钮。内置BMI270六轴IMU和8MB的PSRAM。看起来很可爱,但一直只在闪烁LED灯。
这个想法源于我在玩游戏或运行长时间构建时的困扰:我想要一个小小的第二屏幕,用于显示生命值、Discord通知、CPU图表、进度日志,甚至整个显示器画面。这样我就可以把小屏幕粘在主显示屏边缘,快速查看。我觉得如果能做到的话,那为什么不呢?
但把线缆接到这么小的立方体上太傻了。而且它还把东西固定在你的桌面上。AtomS3R有Wi-Fi,所以能不能通过Wi-Fi快速推送实时视频,让画面看起来像直播一样?不是幻灯片或动画那种效果。
这是可能的,但仅因为目标尺寸很小——128x128 是 16,384 像素,大约占 1080p 图像的 0.4%。以质量 65 的 JPEG 格式压缩,大约需要 4 到 5 KB,相当于 3 到 4 个 UDP 数据包。在 30fps 下,这相当于 1.2 Mbps,对 5 GHz 网络来说完全不成问题。整个项目之所以能运行,是因为我从未刻意追求压缩效率,我只是让图像足够小,使得通过 UDP 发送原始 JPEG 已经足够快了。
材料
•M5Stack AtomS3R
•MacOS 或 Windows
•良好的2.4 GHz或5 GHz网络。
软件
•Arduino IDE
•Python 3.9+
•Python 包:'mss'、'pillow'、'pynput',以及可选的 'opencv-python' 和 'numpy'
它是如何工作的
在开始任何设计之前,先了解事物的结构是值得的,因为后续的所有设计决策都源于此。
两个单向重复流。无 TCP,无握手,无协议协商
为什么选择UDP而不是TCP
TCP 保证数据包丢失时仍能送达,但整个过程会暂停直到重传,这在实时视频中完全不适用。如果第47帧丢失了数据包,我并不希望视频流在重新发送期间卡顿200毫秒。等到数据包到达时,第48或49帧可能已经发生了。丢弃的帧是不可见的,而卡顿的视频流则非常明显。
UDP 不会重传,如果收到的数据帧不完整,系统会直接丢弃,下一帧将自动接替。在 30 帧/秒的帧率下,丢失一帧会导致 33 毫秒的延迟,而这种延迟你无法察觉。
为什么选择JPEG而不是原始像素。
一个原始的128x128 RGB565帧为32,768字节。以30帧每秒的速度,这相当于7.9兆比特的UDP传输,但每帧需要24个数据包,只要其中任何一个数据包丢失,整个帧就会失效。使用JPEG 65质量时,同一帧可压缩至4.5KB,减少了4个数据包,意味着丢失几毫秒的可能性大幅降低,解码时间也因此节省下来。
这正是人们容易忽略的反直觉之处:压缩会使数据流更可靠,而不仅仅是更小,因为每个帧中的数据包丢失概率会随着数据包数量增加而累积。
为什么使用8字节头的分块
UDP数据报在技术上可以达到64KB,但通过以太网或Wi-Fi时,超过1500字节的网络MTU的数据包会被IP协议分片处理。如果任一IP分片丢失,整个数据报就会被丢弃。因此,我自行将数据包分片为1400字节,这个大小完全低于MTU,然后由固件负责重新组装。这样,即使某一块数据丢失,也只损失一个帧,而且我可以精确控制整个过程发生的具体情况。
标题为8字节
这就是完整的协议。'frame_id' 通知接收方发送方已进入新的帧。'packet_index' 指明该数据块在缓冲区中的位置。'packet_total' 表示帧何时完成。'payload-len' 处理最后较短的数据块。
闪存固件
安装板支持和库
打开 Arduino IDE
•工具 - 板子 - 板子管理器 - 搜索 'esp32' - 通过 Espressif Systems 安装 esp32
•工具 - 管理库 - 搜索 'M5Unified' - 安装 M5Unified - 它会自动包含 'M5GFX',即负责 JPEG 解码和面板驱动的图形库。
设置您的WiFi凭据
打开“firmware/AtomS3R_Display.ino”文件,并编辑顶部的两行代码;
上传
插入 Atom 并点击上传
了解固件
当你成功设置好固件后,这一步将说明它实际在做什么。
87KB 对于一个微控制器来说太大了,因此被存储在PSRAM中。
64个数据包×1400字节是帧大小的硬上限。实际上,帧通常包含3到5个数据包。预留的缓冲空间使得即使将“JPEG_QUALITY”调高至90以获取更清晰的静态图像,也不会导致文件过大。如果某个帧超出该限制,Python端会拒绝发送该帧,并提示你降低质量,这比神秘的损坏更清晰地表明了问题所在。
数据包处理
这两行验证代码并非出于偏执。这是将套接字绑定到局域网中某个UDP端口的,网络上的任何数据都可能发送垃圾数据,而一个错误的“索引”会导致下方的“memcpy”操作发生越界写入。务必在使用索引前进行验证。
这是“不要等待过时帧”的实现方式。四行代码:一旦新帧的包到达,我们之前半成品的内容就会被丢弃,没有超时调整,也没有缓冲。新的帧ID就是旧帧永远不会完成的信号。
因为除了最后一个块外,每个块的大小都是1400字节,所以目标偏移量就是idx 'CHUNK_SIZE',无需长度表,也无排序要求,数据包可以乱序到达,但最终都会被正确放置。这是一个很不错的特性,可以免费获得。
排空插座
“while”循环至关重要。如果处理器短暂地比原子处理器快,数据包就会在网络栈中排队。如果每次“loop()”迭代只处理一个数据包,那么这个队列就会不断增长,延迟也会逐渐上升,直到无法使用。每经过一次循环都清空所有待处理的数据包,意味着显示屏始终显示最新的完整帧,而任何积压的数据都会立即被清除。
无泪绘画
直接解码到面板时,图像会以每秒30帧的速度从上到下显示可见的撕裂效果。解码为内部RAM中的128x128精灵,因此每一帧都会自动呈现。
主循环
那个条件性的“delay(1)”是一个虽小但有效的优化。当帧在流动时,我们不会进入睡眠状态,而是直接返回到清空套接字的操作。在空闲状态下,我们保持了1毫秒的运行时间,以便让实时操作系统和Wi-Fi栈获得CPU资源。如果无条件地睡眠1毫秒,就会限制可实现的帧率,并增加抖动。
按钮
AtomS3r 的整个前面板玻璃只有一个按钮“BtnA”。M5Unified 为您提供
点击与按住检测免费使用
一个物理输入,两个功能,如此小巧的设备,却要承担整个UI预算。
一句话,容易被忽略
设置电脑直播器
了解Streamer
将 'ATOM_IP' 设置为 atom 显示的绿色部分。这是唯一必须进行的编辑。
屏幕截图
'mss' 是负责抓取的库,它是在每个操作系统原生截图 API 上的轻量级封装,其快速的 600x600 图像处理只需几毫秒。而 'PIL.ImageGrab' 和 'pyautogui' 都要慢得多,会成为瓶颈。
默认行为是显示器上输入的最大正方形区域。由于屏幕为方形,因此捕捉一个正方形区域可避免失真。若要镜像特定窗口,请设置其坐标。
放大模式
一个200x200的方框,当光标移动时会放大到128x128的面板上,通过硬件循环嵌套的“最大”和“最小”夹子将其固定在显示器上,这样当光标位于屏幕角落时,就不会尝试获取屏幕边缘的像素。
这最初是作为调试工具,后来却成了真正最有趣的模式。
编码
OpenCV 是可选的,但强烈推荐使用。它比 Pillow 快 3-5 倍,这一点在你设定 30fps 时尤为重要。脚本会检测到导入时间并自动选择路径,因此仅使用 Pillow 的设置仍然有效。
分块和发送
这里有一个小细节,处理起来花了我一段时间。
画面节奏
绝对截止时间的节拍控制,而不是每次迭代都使用“sleep(1/30)”固定间隔睡眠,会导致漂移,因为捕获编码工作并非免费且也不恒定。
运行
原子应该在大约一秒钟内点亮你的屏幕。
结果
在 Mac 上进行了端到端测试,效果与承诺的数字大致相当。第一次运行时触发了 macOS 的屏幕录制提示,Atom 程序随即亮起,并在第二轮中显示了屏幕内容。
5 GHz 上的稳态
真正的考验是让它开着,用CS2玩一局,原子从屏幕中间镜像出一角。整个游戏帧率稳定,画面在快速的摄像机摆动中依然流畅,而小面板上的文字也勉强能看清。
还没完成,不过我已经测试得相当不错了。游戏旁边的迷你显示器,功能正常。128x128的限制让整个系统易于构建,也使得它在完全符合我想要测试的内容时,具有极佳的可读性。
本文编译自hackster.io





