ytomasch/gantry

GitHub: ytomasch/gantry

FlashForge 风格 3D 打印机网络协议客户端,解决了无文档的上传格式和固件单会话死锁问题,内置序列化保护和模拟打印机。

Stars: 0 | Forks: 0

# gantry 这是一个用于 FlashForge 风格 3D 打印机网络协议(在端口 8899 上进行通信——包括状态查询、控制和文件上传)的客户端。之所以编写它,是因为上传部分在任何地方都没有文档记录,而控制部分如果出错将导致打印机变砖。 已针对捆绑的模拟打印机进行测试,因此您无需真机即可进行开发。 ``` export GANTRY_HOST=192.0.2.10 gantry info gantry status gantry upload part.gx --start ``` ## 可能会搞坏你打印机的部分 **一个连接。同一时间只能有一个正在执行的命令。始终如此。** 该固件只维持单一会话。如果在已有一个活跃连接时打开第二个连接,或者在没有等待每个 `ok` 返回的情况下连续写入多个命令,它就会死机。从那一刻起,它会针对*每一个*请求、每一个新连接都回复: ``` CMD M601 Received. Control failed. ok ``` 并且会一直保持这种状态。`~M602` 无法清除它。`~M601` 无法清除它。重启你的机器也无法清除它。**这是固件死机,唯一的解决办法是对打印机进行物理断电重启。** 没有任何远程恢复的方法。 这并不是罕见的极端情况。第一次尝试在单次写入中轮询四个命令并为每次轮询打开两个连接时,就在第一次运行中触发了此问题。 这就是为什么序列化被直接内置到这个库中,而不仅仅是作为一种建议写下来。每个操作——状态轮询、控制命令、上传——都排队进入同一个 promise 链中,并且连接之间有冷却时间: ``` const printer = new Printer('192.0.2.10'); // These do NOT race. They queue, in order, one connection at a time. await Promise.all([ printer.status(), printer.light(0, 0, 0), printer.upload('part.gx', buf), printer.status(), ]); ``` 你无法通过此 API 重叠执行两个操作。它唯一无法保护你免受的,是针对同一台打印机运行*两个客户端*——另一个进程,或者在供应商自己的 Web UI 上保持打开的浏览器标签页。千万别这么做。 ### 有两种死机情况,且只有一种可以恢复 | 你的操作 | 导致的结果 | 恢复方式 | |---|---|---| | 重叠连接,或流水线化命令 | 对所有内容永久返回 `Control failed.` | **物理断电重启** | | 在 `M28` 之后将文件作为原始流发送 | 固件等待一个永远不会到来的帧头 | 关闭 socket | 第二种是在开发上传程序时会遇到的情况,它是可幸免于难的。而第一种则会让你不得不从椅子上站起来(去处理打印机)。 ## 帧上传 在 `~M28` 之后,连接不再是 ASCII 格式,而是变成了二进制帧流。这是没有文档记录的部分,它是通过在供应商的切片软件和打印机之间放置一个透明代理并读取字节数据恢复出来的——`tools/ff-proxy.py` 就是那个代理。 每一帧都**正好是 4112 字节**: ``` offset size field 0 4 magic 5a 5a a5 a5 4 4 frame counter, big-endian, from 0 8 4 length, big-endian 12 4 crc32, big-endian 16 4096 payload ``` **陷阱:** payload *始终*是 4096 字节。文件的最后一个数据块几乎永远不会正好是这个大小,因此它会被**零填充**至 4096 字节。但是 `length` 和 `crc` 字段**没有**被填充——它们描述的是真实的字节数。如果最后一个数据块是 1679 字节,`length` 就是 1679,并且 CRC 覆盖的是这 1679 字节,而网络传输的 payload 仍然是完整的 4096 字节。 因此,该帧是一个携带可变大小 payload 的定长信封,并且头部故意与 payload 大小不一致。如果发送一个*较短的*最后一帧——这是显而易见的做法——打印机就会永远等待缺失的字节。 `~M28 ` 有两个值得了解的后果: - **大小必须精确。** 这是打印机用来修剪掉最后一帧多余填充的方式。 - **这也是打印机知道何时开始重新监听 ASCII 命令的方式。** 如果少写了,你剩余的帧就会被当作命令解析。如果多写了,它就会等待永远不会到达的帧。 **滑动窗口流量控制不是可选项。** 超过大约 389 帧后,固件的接收缓冲区就会溢出,并默默丢弃后续帧。该客户端最多同时保留 8 个未确认的帧在传输中,这正是供应商的客户端所做的。 完整序列: ``` ~M601 S1 take control ~M28 0:/user/name open for write — RAW size, not padded size 4112 bytes each, windowed ~M29 close ~M23 0:/user/name optional: start printing ~M602 release control ``` ## 更琐碎的细节 - **只读查询不需要控制权。** `M105`、`M119`、`M27`、`M114` 和 `M115` 在没有 `M601` 的情况下都会正常响应。为了读取温度而获取控制权,意味着持有一个你根本不需要的会话。 - **每个回复都以一个完全为 `ok` 的行结束。** 等待它。不要等待字节数或超时。 - **帧确认是匿名的。** 打印机针对每个帧发送 `ok`,没有任何内容可以用于匹配,因此计算它们的数量是你唯一能获得的背压信号。 - **`.gx` 是一种已公开文档的格式**,`src/gx.js` 实现了公布的布局——它出现在这里是因为你需要它,而不是因为它是在这里被发现的。 一个未公开的细节是:打印机真正需要的只是一个格式正确的 80×60 BMP 缩略图和一个正确的 g-code 偏移量。头文件中的其他内容都是装饰性的,错误的耗材(filaent)估算值也不会影响正常打印。 ## 模拟打印机 `mock/printer.js` 实现了该协议,包括两种死机情况。它拒绝在这方面表现得“温和”:如果你流水线化命令或打开第二个连接,它就会永久死机,直到你调用 `powerCycle()`——就像真实的打印机迫使你走到房间另一头去操作一样。 ``` node mock/printer.js 8899 GANTRY_HOST=127.0.0.1 gantry status ``` 这就是你如何在不拿真机冒险的情况下测试上传程序。 ## 验证 - **针对模拟测试机通过了 22 项测试**(`npm test`),涵盖了对于是否为 4096 整数倍的文件的字节级完全一致的往返测试,其中一个小于单个帧,另一个大到足以演练该窗口机制。 - **该测试套件经过了变异测试。** 故意移除最后一帧的填充会导致 8 个断言失败——其中包括正确地*排除了*大小为精确整数倍的往返测试,因为该文件没有被填充的部分帧。 - **这些协议规则来自于字节级的抓包**,抓取自供应商的切片软件与真实的 Monoprice Voxel(换标的 FlashForge Adventurer)之间的通信,这里的逻辑提取自一个曾在该机器上驱动过真实打印任务的客户端。 - **这种提取尚未在硬件上重新验证过。** 它只针对模拟测试机进行了验证。考虑到其失败模式是需要拔掉电源的打印机,这感觉像是一个适可而止的绝佳节点。 有一件事明确**没有**经过测试:将最后一帧的长度声明为 4096,并对填充后的 payload 进行 CRC 校验。该帧在内部是逻辑一致的,因此在帧级别上没有任何机制能检测出问题,而且一台采用拼接并修剪方式的打印机会接受它。它可能是可幸免于难的。无论如何,该客户端发送的是真实的余数,因为这是供应商的客户端发送的内容,且没有理由偏离固件最初所适配的那个版本。 ## 兼容性 基于固件版本为 1.1.5 的 Monoprice Voxel 编写。相同的协议被用于 FlashForge 的 Adventurer 系列及其换标产品。如果你在别的机器上运行它,模拟器无法告诉你你的机器是否认可它——只有抓包才能做到。 ## 许可证 MIT — 见 [LICENSE](LICENSE)。版权所有 (c) 2026 Amur Labs LLC。
标签:3D打印, GNU通用公共许可证, MITM代理, Node.js, 内核驱动, 固件控制, 客户端, 数据可视化, 暗色界面, 物联网, 网络协议, 自定义脚本