断电续打 PLR:快照、恢复与五个致命 bug
PLR(power loss resume)不是"加个模块"就完事:它调用 Klipper 的私有 API,靠周期快照记住 G-code 的字节位置。 默认 5 秒一存,最坏丢 5 秒走线(100 mm/s ≈ 0.5 m),但因为是从字节位置续读,不会漏整层。 真正会废件的不是丢那几秒,而是恢复流程里的五个 bug:配置没挂、补偿值符号反、
RESUME丢了MOVE=1、 1.1.0 在标准 Klipper 上必崩、1.2.0 在 Klipper 0.12 上必崩。
1. 快照里到底存了什么
| 字段 | 说明 |
|---|---|
| G-code 路径 | 哪个文件,用于恢复时重新打开同一份 |
| 文件字节位置 | 断点位置,不是层号、也不是行号 |
| 加热器 | 各段目标温度 |
| 风扇 | 各部分风扇占空比 |
| 速度倍率 / 挤出倍率 | 恢复后要还原的倍率 |
| 当前坐标 | 工具头位置 |
写入方式值得单独说:先写 .tmp → fsync → 原子替换。掉电恰好发生在写入瞬间时,读到的要么是旧快照、要么是新快照,
不会读到一个写了一半的文件。这是"断电续打"能成立的基础,比存什么字段更关键。
为什么用字节位置而不是层号。 从字节位置继续读,重打的起点是断电那一刻的指令,所以不会整层重打,也不会漏整层; 代价是接缝处可能轻微叠料——因为断电瞬间那一段路径可能已经挤出过一部分料。
恢复时的动作顺序也决定了故障会出现在哪一步:先读快照 → 重新打开同一份 G-code → 定位到记录的字节位置 →
还原加热器/风扇/速度/挤出倍率 → 把轴位置告知运动队列(set_position(..., homing_axes=…))→ 从断点继续下发指令。
第 5 步是"逻辑坐标与物理位置对齐"的唯一入口,也是版本兼容问题最集中的地方(见 4.5)。
2. 三个参数的真实含义(别记反)
| 参数 | 真正作用 |
|---|---|
save_interval: 5.0 |
每 5 秒存一次快照(路径 + 文件字节位置 + 加热器/风扇/速度/挤出倍率/坐标) |
z_snapshot_min_interval: 1.5 |
Z 一变就额外存一次,最快 1.5 s 一次;换层基本都会补一次 |
layer_count: 2 |
不是快照频率,而是"续打后先低速低风扇跑 2 层",再恢复风扇/速度/挤出倍率 |
layer_count 是最容易记反的一个。它不是"每 2 层存一次",存频率由 save_interval 和 z_snapshot_min_interval 决定。
把它调大不会更安全,只会让恢复后的前几层更慢——它的用途是让熔池/压力在断点处重新稳定,属于恢复质量参数,不是记录频率参数。
z_snapshot_min_interval 存在的意义是限速:Z 每变一次都写盘,调平微动阶段会把 SD 卡写爆。所以它和 save_interval
是一对——一个管时间维度,一个管"Z 变化"这个高价值事件,同时给写入量设上限。
折算一下写入量:save_interval: 5.0 一小时就是 720 次定时快照,再加上换层时按 1.5 s 补的那几次。
这也解释了为什么默认值不是 1.0——把窗口缩到 1 秒,写入量直接变 5 倍,而换来的是"少丢 4 秒"。
3. 两种检测模式:软件定时 vs 硬件 PF
| 模式 | 配置 | 断电那一刻 | 最坏损失 |
|---|---|---|---|
| A:硬件断电检测 | power_pin 指向 PSU 的 PF(power fail)信号 |
立刻保存快照 | 接近零 |
| B:纯软件定时 | power_pin: none |
不保存,靠上一次快照 | 一个 save_interval(默认 5 s) |
机队现状是全部 power_pin: none,也就是全靠软件定时。5 秒在 100 mm/s 下约 0.5 m 走线,听起来不少,
但因为是从字节位置续读,表现为接缝处一小段叠料,而不是缺一层。
想更准有两条路:把 save_interval 降到 1.0–2.0(写入频率翻 2.5–5 倍,要考虑 SD 卡寿命),
或者把 PSU 的 PF 信号接进来走模式 A。后者是唯一能在"断电瞬间"动作的方案,前者只是把窗口缩小。
4. 五个致命 bug 与判别方法
先说为什么这类 bug 一定会有:PLR 读的是 printer 对象上的私有方法,而私有方法不属于 Klipper 的对外契约。
上游或 fork 只要动一次 Printer 类,模块就在下一次周期任务里炸——同一份 power_loss_resume.py 在 FlyOS / Kalico 分支上正常,
在标准 Klipper 上必崩,反过来也一样。这不是"这个包写坏了",是"它依赖了一个会变的东西"。
所以版本组合必须当成配置项来管理,而不是"装好就不管"。
4.1 [include plr.cfg] 缺失:装了但没生效
模块文件在、脚本也跑了,运行时对象却不存在——因为 printer.cfg 里没有 [include plr.cfg]。
机队盘点里有两台就是这样:包装了、版本也对,实际没有任何断电续打能力。
判定:不看"包装没装",只看 printer.cfg 的 [include] 列表里有没有 plr.cfg。"装了" ≠ "生效"。
4.2 paused_recover_z 是负值:补偿永远不执行
配置里写的是 paused_recover_z: -2.0,而代码条件是:
if is_paused and paused_recover_z > 0.0:
负值永远不满足 > 0.0 ⇒ 补偿分支一次都没跑过。要让它等于 PAUSE 宏的抬升量(机队宏是 10 mm),
应写 -10,并且把判断改成 != 0.0——否则改成 -10 依然不会执行,只是从一个静默失效换成另一个静默失效。
4.3 RESUME 宏丢了 MOVE=1:物理位置不回来
RESTORE_GCODE_STATE NAME=PAUSE_state 只恢复逻辑坐标,不动工具头。少了 MOVE=1 的后果是:
逻辑上回到 Z,物理上还在 Z+10。而 PLR 快照又把"抬升后"的位置当成断点 ⇒ 自动续打也整件高 10 mm。
两个问题(4.2 与 4.3)叠加时,表现不是误差相加,而是同一个 10 mm 被固化两次:手动 RESUME 高 10 mm,
自动续打同样高 10 mm,而且因为快照记的就是抬升后的位置,第二次续打还会继续带着这个偏差。
正确写法:
[gcode_macro RESUME]
gcode:
RESTORE_GCODE_STATE NAME=PAUSE_state MOVE=1
4.4 PLR 1.1.0 在标准 Klipper 上必崩
1.1.0 调用的是 printer._is_printing() / printer._is_paused()——FlyOS / Kalico 分支独有的接口。
标准 Klipper 的 Printer 类没有这两个方法:
File ".../klippy/extras/power_loss_resume.py", line 239, in _save_power_loss_info
if is_power_loss and self.printer._is_printing():
AttributeError: 'Printer' object has no attribute '_is_printing'
Transition to shutdown state: Unhandled exception during run
第二次同类抛在 line 283("is_paused": self.printer._is_paused())。一启用就每 5 秒抛一次,klippy 直接 shutdown。
为什么容易误判:异常发生在 work_handler 周期任务里,UI 会把它显示成"某轴/调平报错",日志里根本没有那个轴的错误。
先看 traceback 最里层,别信 UI 的报错标题。
最小改动两行(改前 cp power_loss_resume.py{,.bak},改完 python3 -m py_compile 再重启):
# 原:
self.printer._is_printing() # line 239
"is_paused": self.printer._is_paused(), # line 283
# 改为:
self.print_stats.state in ('printing', 'paused')
(self.print_stats.state == "paused")
排查时用 grep -n "printer\._" power_loss_resume.py 一次把所有私有 API 调用找齐,别只修第一处——
修了 239 忘了 283,下一次还是在恢复流程里炸。
但机队层面的正确修法不是逐个补接口,而是换成同版 1.2.0 + 配套 virtual_sdcard.py:
私有 API 调用点会随版本增加,逐个补是补不完的。
4.5 PLR 1.2.0 与 Klipper 0.12 不兼容:定时炸弹
这是危害最大的一条,因为它是版本组合问题,不是"某台机器配错了"。
power_loss_resume.py:502 给 toolhead.set_position(..., homing_axes="xyz") 传的是字符串,
而 Klipper v0.12.0-401 要的是整数轴号 ⇒ 内部 self.rails["x"] 抛:
TypeError: list indices must be integers
然后 shutdown。触发时机是断电续打恢复流程,不是随机——也就是说,机器平时看起来一切正常, 只有真断电、真的去续打时才炸。
影响面:机队 15 台 1.2.0 只要 Klipper 升到 0.12 就会踩。升级是迟早的事,所以这是定时炸弹而不是偶发故障。
修法(1.2.1):运行时探测本机 Klipper 源码,新版传 [0,1,2],旧版回退 "xyz"——一份包同时兼容新老。
版本号 1.2.1+plrapi01,同步更新 catalog 的 version / sha256 / size 三个字段。
4.6 三行判别表:先分清是软件还是接线
上机排查时,先把现象归到这三类,再决定动不动机械部分:
| 现象 | 结论 |
|---|---|
日志周期刷 AttributeError: 'Printer' object has no attribute '_is_printing' |
fork API 漂移,纯软件问题,与电机/接线无关 |
打印一开始就 Unhandled exception during run → shutdown |
先看是不是 PLR/本地魔改模块抛的(traceback 最里层) |
有代码、没有 [include] |
功能静默失效,不报错但也什么都不做 |
三条里只有第二条需要看日志细节,前两条一句话能定性。反过来说,凡是"某轴报错""调平失败"这类标题, 都要先排除是 PLR 在周期任务里抛异常把整机拖进 shutdown——UI 的标题只反映最后状态,不反映第一现场。
5. 安装器自己的两个硬伤(1.2.0 → 1.2.1)
这两个坑和 PLR 逻辑无关,但同样能让机器起不来,而且原作者机器上刚好没触发。
| 硬伤 | 症状 | 1.2.1 修法 |
|---|---|---|
plr-install.sh 硬追加 [include plr.cfg] 到 printer.cfg 末尾(在 SAVE_CONFIG 之后) |
SAVE_CONFIG 段被忽略、probe z_offset 丢失 ⇒ Klipper 直接起不来 |
写前检测 SAVE_CONFIG,有就插到它之前,没有才追加;写失败自动从备份回滚 |
整份覆盖目标机的 screen.py |
包里的 screen.py 来自另一版本,碰上 KlipperScreen v0.4.5(use_dpms 收 bool)⇒ 屏幕无限重启 |
改成只向原版 screen.py 幂等注入两处 power_loss_resume 订阅(dict 键 + ws items);弹窗本体仍用 main_menu.py |
为什么"插到 SAVE_CONFIG 之前"这么重要:Klipper 的 SAVE_CONFIG 是一个必须位于文件末尾的块,写在它后面的内容会被忽略。
把 include 追加到末尾,等于同时废掉了校准数据,症状表现为"配置丢失 + 启动失败",很难一眼归到安装脚本身上。
幂等验收(两条都要过):
- 同一台机再跑一遍安装 ⇒ 输出「printer.cfg 已包含 plr.cfg,跳过追加」「已注入订阅」,klipper
ready、z_offset 6.65、屏幕active。 - 注入逻辑跑两遍 ⇒ 必须输出「无需注入」+
py_compile通过;插入逻辑要覆盖「有 SAVE_CONFIG / 无 / 已存在」三种。
6. 前端与弹窗:官方版没有这个逻辑
网页弹窗必须用定制 Mainsail v2.17.0(覆盖官方 v2.16.0)——官方版根本没有续打弹窗逻辑,
不是"配置一下就有"。KlipperScreen 侧同理,续打面板只在定制件里。
恢复成功时看到的是触摸屏弹出 + 日志一行:
Showing dialog Unfinished tasks detected plr_motion2…
⚠️ KlipperScreen 的坑独立存在:屏幕崩了会无限重启,看起来像整机故障。先看 klipperscreen.log 再动 Klipper——
这时候 Klipper 往往好好的,动它只会把问题扩大。
7. 部署与验收
部署口径:
- 唯一源是仓库(当前 1.2.1),设备上只留运行文件;旧配置备份到
.plr_backup/<时间戳>/。 - 包内容:
power_loss_resume.py+ 定制virtual_sdcard.py(按 Z 变化打快照)+plr.cfg模板(安装时自动追加 include) - 定制前端(含续打弹窗,约 24 MB)+ KlipperScreen 续打面板 + 可选 U 盘 G-code 自动挂载(udev + systemd)+ 卸载脚本。 装之前先看一眼设备剩余空间——前端这一项就是二十几兆,小容量 eMMC 上要先清。卸载脚本要一起留着, 否则回退只能靠手删文件,删漏一处就留下一个半启用的模块。
- 一条命令部署(远程
--host/--user、本机--local,加--no-usb-print跳过 U 盘挂载)。 - 原厂那套极简包(96 KB,只有
install.sh+ KlipperScreen 补丁 +API_REFERENCE.md,没有前端、没有 U 盘挂载) 已被完全覆盖,只留作 API 对照,不要再往新机上装。 - ⚠️ 不要点 Mainsail/Fluidd 里的 Klipper 更新:带 PLR 魔改的机器一更新就冲掉补丁。
改之前先确认空闲:print_stats.state 非 printing 且 virtual_sdcard.is_active=false。注意 ready ≠ 空闲。
验收清单——不能只看服务活着:
| # | 项 | 判据 |
|---|---|---|
| 1 | 配置生效 | [include plr.cfg] 存在,且在 SAVE_CONFIG 之前 |
| 2 | 模块加载 | klippy.log 没有每 5 s 刷 AttributeError |
| 3 | 真断电 | 断电 → 上电 → 弹窗出现 → state 恢复 → 无 shutdown |
| 4 | 位置 | 续打首层与断电层对齐,接缝不叠料、不错层 |
| 5 | 屏幕 | klipperscreen.log 无重启循环 |
第 3 条是这套东西唯一的真验收。1.2.0 的两个私有 API bug 就是典型:机器看起来"正常",只有真断电才发现续打没生效——
因为断电那一刻保存失败,配置里只写进了 {"power_loss_resume": false},续打静默失效。
8. 机队盘点怎么做
一次完整盘点(21 台)的实测结果:18 台装了包,3 台用 FlyOS 自带机制;版本上 15 台 1.2.0、3 台 1.1.0;
另有 2 台"装了但没启用"(printer.cfg 里没有 [include plr.cfg],等于白装,已补上)。
盘点不要靠记忆,逐台读三处:printer.cfg 的 [include] 列表、包版本文件、klippy.log 首启行有没有异常。
三者缺一,就会出现"清单上有、机器上没有"的假账。
分版本处理:15 台 1.2.0 都要升 1.2.1(踩 homing_axes 那条路径);3 台 1.1.0 不动——它们没有这条调用路径,
换包反而引入新风险。排队升级时每台后跟一条 restart_klipper,打印中的自动跳过并重排。
9. 配置片段
[power_loss_resume]
save_interval: 5.0 # 5 秒一次定时快照
z_snapshot_min_interval: 1.5 # Z 变化额外存,最快 1.5 s;防止调平微动写爆卡
layer_count: 2 # 续打后先低速低风扇跑 2 层,不是快照频率
paused_recover_z: -10.0 # 与 PAUSE 宏抬升量一致;条件须改成 != 0.0
power_pin: none # none = 模式 B 纯软件定时;接 PSU PF 信号则走模式 A
# printer.cfg —— 必须插在 SAVE_CONFIG 之前,不能追加到文件末尾
[include plr.cfg]
# ... 其余配置 ...
[gcode_macro RESUME]
gcode:
RESTORE_GCODE_STATE NAME=PAUSE_state MOVE=1