做3D打印上位机这件事,我是从“用别人的Web界面觉得不过瘾”开始的。Klipper这个固件生态有个特点:几乎所有常见前端(Mainsail、Fluidd)都是浏览器里的Web应用,功能很全,但想按自己的习惯定制监控界面、做本地化、甚至离线直接连串口调试,Web那套反而束手束脚。于是我用C++/Qt写了一个基于Klipper的远程上位机系统,把通信解析、打印参数设置、实时状态展示全部纳进一个桌面程序里。这套思路也适用于任何想深入Klipper源码、自己接入通信协议的人,或者单纯想在桌面上用一个更顺手的打印控制台。下面是我从零搭建这套系统的完整记录,含踩坑。
1. 整体设计与思路拆解
1.1 Klipper生态与上位机的位置
Klipper固件跟传统固件(比如Marlin)最大的区别是:真正的运动规划、温度控制策略都跑在主机端(树莓派、PC),单片机端只负责输出脉冲、读取传感器。所以“上位机”这个词其实要分三层来理解。
第一层是用户界面,也就是我写的Qt程序。第二层是Klipper自家配套的API服务——Moonraker,它把Klipper内部状态包装成HTTP/WebSocket接口,是社区事实上的API网关。第三层是Klipper核心进程klippy,真正执行G代码、做运动规划和温度控制的程序。
我刚开始犯过一个认知错误:以为上位机要直接打串口跟主控板的MCU通信。后来翻Klipper源码才明白,如果需要频繁读温度、发G代码,正确姿势是走Moonraker。上位机通过HTTP请求查询状态,通过WebSocket订阅推送,把G代码交给klippy执行。这样既安全又稳定,也避免了直接接触底层协议带来的兼容性问题。
1.2 为什么最终选择Qt技术栈
我试过用Python写控制台脚本,也试过改Fluidd的界面。最后回桌面程序还是选了Qt。
跨平台是首要理由。Windows上做主力监控、Linux服务器上部署、macOS上调试,同一套代码都能跑,不用为不同系统单独开发。Qt的网络模块很成熟,QNetworkAccessManager负责HTTP请求,QWebSocket负责订阅推送,QSerialPort在需要串口调试时也能直接顶上。
自绘控件这块更是Qt的强项。温度曲线用QChart就能画得挺好看,进度条可以用QPainter定制,比在Web里折腾CSS和Canvas效率高得多。而且Qt发布成独立exe或AppImage很简单,不依赖Node服务,装完就能用。
1.3 系统架构与模块划分
我最终把系统拆成四块:
- UI层:主窗口、温度面板、打印面板、参数设置面板。
- 客户端层:PrinterClient,负责HTTP请求、WebSocket订阅、断线重连。
- 数据层:JSON解析、QSettings本地配置、打印任务记录。
- 业务层:命令构造、参数下发、打印机状态聚合。
UI层绝不直接访问网络,所有网络请求都通过PrinterClient封装。这样换打印机、换固件版本只改客户端层,界面不用动。
这是踩过一次坑总结出来的教训。最开始版本,我把QWebSocket直接new在MainWindow里,结果界面卡顿、析构崩溃。后来把网络通讯独立成对象,丢到子线程跑,问题才解决。具体线程模型后面会详细说。
2. 核心原理:Klipper通信协议与源码解析
2.1 Moonraker API:状态查询、对象订阅与命令下发
先说HTTP接口。查询喷嘴目标温度和实时温度,请求路径:
GET /printer/objects/query?extruder=temperature,target返回的JSON结构:
{ "result": { "status": { "extruder": { "temperature": 210.4, "target": 220.0 } } } }这种方式适合定时轮询,比如每2秒拉一次温度。
如果希望实时刷新,更优雅的是WebSocket订阅。先连接ws://主机地址:7125/websocket,然后发送JSON-RPC订阅请求:
{ "jsonrpc": "2.0", "method": "printer.objects.subscribe", "params": { "objects": { "extruder": ["temperature", "target"], "heater_bed": ["temperature", "target"], "print_stats": null } }, "id": 10 }之后Moonraker会持续推送notify_status_update消息,里面只带变化的字段。订阅时把关键对象挂上,上位机就能以事件驱动方式刷新温度面板,不用频繁轮询。
G代码命令下发的接口是POST /printer/gcode/script,参数为script=M220 S90。同样可以通过WebSocket发送printer.gcode.script方法。我实际测试下来,WebSocket通道延迟更低,命令执行结果也更完整,所以最终下发命令统一走WebSocket。
2.2 从Klipper源码理解参数设置背后发生了什么
如果只调用API,其实不用看源码。但要做一套真正懂打印的上位机,比如要做打印速度百分比调节、流量百分比调节、压力提前补偿,就必须明白Klipper里这些参数在哪一层生效。
M220是设置速度因子的命令,代码在klippy/extras/gcode.py里。它会作用到所有后续运动命令,最终影响运动规划的速度。M221设置挤出倍率,影响挤出电机流量。SET_PRESSURE_ADVANCE设置压强提前值,在klippy/extras/pressure_advance/里实现,是提高高速打印质量的进阶参数。
在源码里搜索cmd_M220、cmd_M221能很快定位到具体实现。跟着源码走一遍,比看文档更有底。搞懂这套逻辑之后,上位机的参数设置面板就不是盲填数值,而是清楚每个参数会带来什么机械特性。
比如M220设到200%,如果打印机机械结构刚性不足,高速打印时就能明显看到振纹。再比如流量因子设到150%,打印表面大概率会过挤出,产生堆料。上位机开发人员理解这些,才能在界面上加合理的限制和提示。
2.3 通信数据解析的通用套路
Klipper/Moonraker返回的数据统一是JSON。Qt端我统一用QJsonDocument解析,但有个细节很容易忽略:WebSocket推送的消息里,不同事件类型结构完全不一样。
notify_status_update的结构和notify_gcode_response就完全不同。前者params[0]是对象状态字段,后者params[0]是G代码响应文本。所以不能一进来就取固定字段。我的做法是先判断method字段,再进入不同解析器。
void PrinterClient::onTextMessageReceived(const QString &message) { QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8()); QJsonObject root = doc.object(); QString method = root.value("method").toString(); if (method == "notify_status_update") { // 先取params[0]对象,再逐字段更新 QJsonArray params = root.value("params").toArray(); QJsonObject status = params[0].toObject(); updatePrinterStatus(status); } else if (method == "notify_gcode_response") { // 处理G代码响应/错误 QJsonArray params = root.value("params").toArray(); QString response = params[0].toString(); emit gcodeResponseReceived(response); } else { // 处理带id的RPC响应 int id = root.value("id").toInt(); QJsonObject result = root.value("result").toObject(); emit rpcResponseReceived(id, result); } }别把一个方法写到底,拆成多个槽函数,后面维护逻辑清晰得多。这也是我重构过几次之后的教训:一开始在图省事,所有解析逻辑堆在一个函数里,加新功能时改一处坏三处,最后只能推倒重来。
3. 基于Qt的上位机实现细节
3.1 开发环境准备与模块选择
我用的Qt版本是5.15.2 LTS,Windows上开发、Linux服务器上部署。这里有个容易踩的坑:如果只需要网络和UI功能,安装Qt时不需要勾选SerialPort模块,但很多从旧项目搬过来的代码会有#include <QSerialPort>残留,一编译就报unknown module(s) in qt: serialport。
解决方案有两个:重新运行Qt安装器勾选SerialPort模块,或者把代码里的残留include和.pro里的QT += serialport删掉。我的项目初期调试时接串口直连过MCU,所以安装了SerialPort模块。如果你的项目也用不到,不用装也没关系。
工程结构建议:
klipper-qt/ ├── KlipperQt.pro ├── main.cpp ├── mainwindow.h/cpp ├── printerclient.h/cpp ├── tempchart.h/cpp ├── printpanel.h/cpp └── settings.h/cpp.pro文件里需要添加:
QT += core gui network websockets charts CONFIG += c++113.2 网络通信层的线程模型与断线重连
最开始版本,我把QWebSocket直接new在MainWindow里,跑起来后发现:温度推送每秒钟可能十几条,如果UI层数据量大(比如在画几百个点的曲线),窗口操作会明显掉帧。
后来按标准做法,把网络对象放到独立线程里:
PrinterClient *client = new PrinterClient(); QThread *netThread = new QThread(); client->moveToThread(netThread); netThread->start();注意moveToThread之后,PrinterClient里的QWebSocket的创建和连接必须在子线程内完成。我通常用QMetaObject::invokeMethod(client, "connectToHost", Qt::QueuedConnection)来触发,避免跨线程直接调用出问题。
数据从子线程回到UI,靠信号槽自动处理,用默认的AutoConnection,跨线程时会自动切到QueuedConnection,UI线程照样能安全收到数据包。
断线重连是远程监控的刚需。我在PrinterClient里放了个QTimer,每5秒检查一次连接状态。如果QWebSocket状态不是ConnectedState,就尝试重连。连续重连失败计数到3次时,降低频率到30秒一次,避免日志刷屏。
void PrinterClient::checkConnection() { if (m_webSocket.state() != QAbstractSocket::ConnectedState) { m_reconnectCount++; if (m_reconnectCount > 3) { m_checkTimer->setInterval(30000); } m_webSocket.open(QUrl(m_serverUrl)); } else { m_reconnectCount = 0; if (m_checkTimer->interval() != 5000) { m_checkTimer->setInterval(5000); } } }3.3 打印参数设置的完整下发流程
这里用最常用的例子:用户在面板拖动速度因子到85%。整套流程分四步。
UI层拿到滑条值,通过emit setSpeedFactor(85)信号发给PrinterClient。PrinterClient内部构造M220 S85命令。然后通过WebSocket发送JSON-RPC:
{ "jsonrpc": "2.0", "method": "printer.gcode.script", "params": { "script": "M220 S85" }, "id": 21 }等Klipper返回result后,把当前速度因子保存到本地配置,下次启动自动恢复。
安全校验不能少。速度因子我限制在10~300之间,温度限制在0~350,执行前在UI层先做一次范围判断,在PrinterClient里再做一次,双重保险。
这里有个很有意思的问题:如果打印过程中直接M220修改速度因子,Klipper会立刻调整所有后续运动的速度。如果在打印暂停状态下修改,则不会影响当次打印,只影响后续。上位机状态机设计时要区分清楚,不能让用户误以为暂停时调了参数就能生效。我加了一行提示文案“打印中生效,暂停时不生效”,用户体验好很多。
4. 实时监控与打印管理体验
4.1 实时温度曲线与打印进度显示
温度曲线用Qt Charts的QChart来实现。这里有个关键优化点:不要每条推送都append一个新点,否则画几千个点之后明显卡顿。
我的做法是维护一个固定容量的QLineSeries,比如3600个点,只保留最近一小时数据。新数据到来时用replace整个序列,这样滑动窗口看起来就像不断移动的曲线,实际上底层数据量固定,CPU占用很低。
// 假设 m_series 是 QLineSeries* void TempChart::updateTemperature(double temp, double target) { QDateTime now = QDateTime::currentDateTime(); qreal x = now.toMSecsSinceEpoch(); m_tempSeries->replace(m_tempSeries->count(), x, temp); m_targetSeries->replace(m_targetSeries->count(), x, target); // 只保留最近3600个点 if (m_tempSeries->count() > 3600) { m_tempSeries->remove(0); m_targetSeries->remove(0); } }进度计算可以从Moonraker的virtual_sdcard.progress直接拿到0~1的浮点数,也可以从print_stats.state判断是否正在打印。我给进度条做了自定义绘制,把百分比、已打印时长、剩余打印时间(按已打印时长和进度估算)都画上去,比自带QProgressBar好看也更有用。
4.2 打印任务与文件管理:上传、列表、启动
上位机还要管打印文件。我实现了三个基础功能。
上传G代码文件,调用POST /server/files/upload,multipart/form-data格式,Qt里用QHttpMultiPart构造。获取文件列表,调用GET /server/files/list?root=gcodes,返回JSON数组。启动打印,调用POST /printer/print/start,参数filename=xxx.gcode。
QNetworkRequest request(QUrl("http://" + host + "/server/files/upload")); QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); QHttpPart filePart; filePart.setHeader(QNetworkRequest::ContentDispositionHeader, QStringLiteral("form-data; name=\"file\"; filename=\"%1\"").arg(fileName)); filePart.setBody(gcodeContent); multiPart->append(filePart); QNetworkReply *reply = m_http.post(request, multiPart);暂停和取消打印分别调用POST /printer/print/pause和POST /printer/print/cancel,这里不展开。
文件管理这块有个体验细节:打印文件名经常带中文,Moonraker对URL编码有要求,Qt里要用QUrl::toPercentEncoding(filename)转码后再拼URL,否则会偶发404。
4.3 多打印机配置与本地化
既然用了Qt,顺手把多打印机配置和界面国际化也做了。
QSettings里保存打印机列表,每个item包含主机地址、端口、打印机名称。切换打印机时,PrinterClient断开旧连接、连到新主机,UI所有面板重置为加载状态。这个过程用信号槽串联,避免在槽函数里直接操作其他对象造成耦合。
国际化这块比较微妙。网上很多Qt中文资料都在强调tr(),但实际做的时候要注意:动态字符串拼接不要写tr("xxx" + variable),应该用QStringLiteral或.arg(),否则翻译文件匹配不上。
statusLabel->setText(tr("已连接:%1").arg(printerName));而不是:
statusLabel->setText(tr("已连接:" + printerName));4.4 热词里的“qt绘图效率”与曲线刷新
最近搜资料还看到很多人问 “qt绘图效率”、“qchart实现图片缩放” 这类问题。确实,Qt Charts在大量数据点下性能会下降,除了上面说的定长序列,还有一个优化策略:降低刷新频率。
温度推送过来先缓存,UI里搞个定时器5秒刷新一次曲线。这样温度数据本身是实时的,画面更新是平滑的,CPU占用明显下降。如果追求极致流畅,可以把QChartView的渲染模式设置为QChart::GLWidget模式,直接走OpenGL加速。
如果只是简单画线,用QPainter自己画也不难,固定横轴是时间戳,纵轴是温度。QPainter画几千个点的折线非常快,比QLineSeries在普通模式下的效率还高。我最终实现时选择了QChart,因为要兼顾图例、坐标轴缩放和tooltip,省去自己造轮子的工作量。
4.5 Qt与嵌入式联动:从“上位机”到“远程控制”
还有一个场景是远程控制。远程上位机的意义不只是本地监控,还包括在局域网甚至互联网里控制打印机。我在这套系统的网络层里预留了远程控制接口:Qt上位机先把命令发到本地转发服务,再由转发服务转发到Moonraker。这样即使人不在打印机旁边,也能通过转发的WebSocket查看状态、修改参数、启停打印。
做好远程控制就要考虑权限和并发。我加了一个简单的token校验机制,上位机连接时带上token,转发服务验证通过才允许订阅和命令。这套机制不复杂,但能解决“家里打印机被局域网内其他人乱操作”的尴尬。如果你只是在纯局域网内跑,不开放公网端口,风险可控,不接远程转发也一样用。
5. 常见问题与排查技巧实录
5.1 通信连接失败的排查清单
我遇到过几类典型问题,整理成表方便对照:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| WebSocket握手失败 | Moonraker端口不对 | 检查moonraker.conf配置,默认端口7125 |
| HTTP请求超时 | 网络不通或防火墙拦截 | 先ping通主机,再telnet 7125端口验证 |
| 连接正常但订阅无推送 | 订阅请求参数写错了对象名 | 用Moonraker官方控制台手动发一次订阅验证 |
| 提交G代码没反应 | klippy处于打印暂停或错误状态 | 调用接口先查询打印状态,再决定是否允许提交 |
| 中文文件名上传失败 | URL未做百分号编码 | 用QUrl::toPercentEncoding转码后再请求 |
这个排查过程里最有价值的一条经验是:遇到WebSocket连接问题,先别改代码,先用系统自带的工具验证一遍服务是否正常。比如直接curl一下HTTP接口,用websocat测一下Ws连接,确认问题在服务端还是客户端,能省下很多冤枉时间。
5.2 Qt编译与打包踩坑
unknown module(s) in qt: serialport这种情况,大部分是安装Qt时没勾选对应模块。解决方法很简单,重新运行Qt安装器,勾选需要的模块就行。
发布exe时,用windeployqt经常漏掉QtCharts的库。需要手动把Qt5Charts.dll、Qt5Network.dll复制到发布目录,或者给windeployqt传参--no-system-d3d-compiler之类的选项,后面自己仔细核对一遍。
跨平台部署时注意,Linux上用linuxdeployqt处理,或者直接使用系统包管理器安装Qt依赖。打包前ldd检查一下可执行文件依赖,缺哪个库就补哪个。我遇到过Linux下跑起来后没有libQt5WebSockets.so.5的情况,就是因为漏装了WebSocket模块。
5.3 状态显示刷新的细节问题
有个细节让我调试了很久:温度数值会偶尔跳回0。后来发现不是打印机温度真的降了,而是订阅消息解析时把extruder和heater_bed的对象搞混了,请求字段写错导致温度清零。
经验是:做状态聚合时,每个字段更新前先判断JSON里是否有这个key,没有就跳过,不要默认填0。填充走势图时同理,缺数据就留空不补0,曲线才真实反映打印过程。
void updateExtruderTemperature(const QJsonObject &status) { QJsonObject extruderObj = status.value("extruder").toObject(); if (extruderObj.contains("temperature")) { double temp = extruderObj.value("temperature").toDouble(0.0); emit extruderTempChanged(temp); } // 如果没有temperature字段,说明未变化或暂不可用,不发出信号 }还有一点:Qt Charts默认在主线程更新。如果温度推送太频繁,建议定时5秒合并一次数据再刷新曲线,CPU占用能降下来一大截。
5.4 订阅数据量与性能平衡
Moonraker的状态推送默认是差量推送,但如果你订阅了太多对象,每条推送的频率也会跟着涨。我调试时曾一次性订阅了所有Klipper对象,温度、打印进度、风扇转速、电机位置全挂上,结果WebSocket每秒钟收到几十条消息,UI线程直接满负荷。
后来做了个取舍:实时要求高的对象(温度、打印进度)走WebSocket订阅,低频对象(比如风扇转速、传感器状态)走HTTP定时轮询,每5秒拉一次。这样WebSocket流量下降一大半,UI也流畅很多。这个分层设计,是这套系统性能优化的核心配置之一。
5.5 Qt与Klipper版本兼容性
Klipper和Moonraker更新很快,API偶尔会变。我在项目中维护了一个小的API适配层,把状态查询、命令下发、文件管理这几个核心请求封装成函数,内部拿JSON解析结果时代码里写上备注“Moonraker v0.8+”,如果升级版本后再遇到字段缺失,只需要返回这一层去修改。
Qt版本如果用的是5.15 LTS,QtCharts在Windows下表现没问题,Linux下如果遇到缺库,用apt安装libqt5charts5-dev就能解决。Qt 6系列里Qt Charts是独立在/opt/Qt里的,安装路径别选错。总之,不要盲目追新版本,稳定最重要。
6. 个人体会与扩展方向
整套系统做到能稳定跑起来之后,最大的感受是:Klipper生态的价值不只是打印效果好,它的API设计把“监控”和“控制”拆得很干净,这让桌面上位机开发难度比想象中低不少。
我踩过的坑大多不在Klipper,而在Qt的线程模型和模块配置上。如果你也想做一套自己的上位机,建议先从WebSocket订阅状态开始,一步步把打印、暂停、参数调整加进去。等跑通一遍,你对Klipper源码的阅读能力、对参数底层逻辑的理解,都会比只看教程快得多。
后续拓展方向也有不少:可以加入摄像头流(Moonraker直接支持mjpeg-streamer接入),可以写独立的“打印预测”模块(基于历史耗材和层高估算剩余时间),甚至可以接语音控制。三思而后行,先把通信层和参数层吃透,剩下的功能都是水到渠成。最后再分享一个小技巧:调试阶段把WebSocket收到的原始消息打印到日志文件里,排查问题会非常舒服。