The first boot imports the factory calibration and keeps it

auto_import already loaded firmwareExe's per-unit numbers whenever no tool
had a saved nozzle position -- but only live, so every boot re-imported,
TOOL_OFFSET_STATUS nagged about an unsaved calibration, and the SAVE_CONFIG
was homework for the user. Now the first successful import persists itself:
ff_legacy schedules SAVE_CONFIG one reactor callback after klippy:ready, the
restart that save causes lands seconds into the first boot -- before
HelixScreen is anywhere near its setup wizard -- and the saved nozzle
positions are exactly what stops the import from ever running again.

Guarded on three sides: nothing is saved when something else was already
staged before the import (not ours to commit), when the JSON put no tool on
its feet (a restart with nothing to keep), or when auto_save is off in
[ff_legacy], which restores the old live-but-unsaved contract. A failed save
never restarted klippy, so there is no loop behind any of it.

The extras finally get a klippy-free unit lane:
test/integration/test_ff_legacy.py stubs the four objects ff_legacy touches
and pins the once-ness, both guards, and the failure path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oleksandr ShyshatskyiandClaude Fable 5 committed 2026-08-24 23:06:03 +03:00
1 parent e36219cf40
commit 64ea5a84a9
7 files changed
+354 -47

No files matched your search

+5 -8
View File
@@ -137,15 +137,12 @@ emergency stop within reach.
## Before your first print: calibrate
**Prints refuse to start on an uncalibrated toolchanger — by design.** The
sequence is short, and the first part of it may already have happened at boot;
the status commands tell you.
sequence is short, and the first part happens without you.
1. Import your unit's factory calibration once, then persist it:
```gcode
FF_IMPORT_FIRMWARE_CONFIG
SAVE_CONFIG
```
1. Your unit's factory calibration imports itself on the first boot after the
flash and is saved automatically — Klipper restarts once, before the
screen is up, and that is the last you hear of it. Check it took:
`TOOL_OFFSET_STATUS` should show a nozzle triple for every tool.
2. **Take the PEI sheet off** — the calibration station sits below the bed
plane — then home and calibrate:
+6 -2
View File
@@ -220,8 +220,12 @@ print.
The `.cfg` includes are wired up **for** you: `printer.base.cfg` ends with the
seven `[include ff-*.cfg]` lines, so a flash brings the mod up by itself. Your
`printer.cfg` is not touched. Run `FF_IMPORT_FIRMWARE_CONFIG` once after the
first flash to pull in the factory dock and nozzle numbers.
`printer.cfg` is not touched. The factory dock and nozzle numbers are pulled
in for you too: on the first boot after the flash, `[ff_legacy]` imports
firmwareExe's per-unit JSON and persists it with its own `SAVE_CONFIG`, so
Klipper restarts once, right after coming up — before the screen is on its
feet. `TOOL_OFFSET_STATUS` afterwards should show a nozzle triple for every
tool and no `NOT CALIBRATED`.
The included files are the mod's, and every update overwrites them without
asking — `ff-*.cfg` and `printer.base.cfg` alike. Do not edit them. Put changes
+24 -19
View File
@@ -27,10 +27,10 @@ documented in the file headers.
| [`payload/klipper/extras/ff_print.py`](../payload/klipper/extras/ff_print.py) | `/usr/prog/klipper/klippy/extras/` | `[ff_print]` — takes over `SDCARD_PRINT_FILE` and `M23`, reads bed/nozzle/initial tool/first-layer height out of the file itself, and calls `FF_BEFORE_PRINT_START` before the file's first line and `FF_AFTER_PRINT_END` once the job leaves the printing state. Declared in `ff-print-macros.cfg`; holds no policy of its own |
| [`payload/klipper/extras/ff_tool.py`](../payload/klipper/extras/ff_tool.py) | `/usr/prog/klipper/klippy/extras/` | `[ff_tool n]` — one section per tool; `dock_x/dock_y`, `nozzle_x/y/z` and `z_adjust` are all autosaved (import or calibration + `SAVE_CONFIG`) |
| [`payload/klipper/extras/ff_tool_offset.py`](../payload/klipper/extras/ff_tool_offset.py) | `/usr/prog/klipper/klippy/extras/` | `TOOL_OFFSET_CALIBRATE` / `STATION_CALIBRATE` / `TOOL_OFFSET_STATUS` — the touchscreen's nozzle XY/Z offset calibration, recovered from the binary and reimplemented in Klipper |
| [`payload/klipper/extras/ff_legacy.py`](../payload/klipper/extras/ff_legacy.py) | `/usr/prog/klipper/klippy/extras/` | `FF_IMPORT_FIRMWARE_CONFIG` — one-shot import of the factory/touchscreen JSON into Klipper config. Needed once, at install |
| [`payload/klipper/extras/ff_legacy.py`](../payload/klipper/extras/ff_legacy.py) | `/usr/prog/klipper/klippy/extras/` | `FF_IMPORT_FIRMWARE_CONFIG` — one-shot import of the factory/touchscreen JSON into Klipper config. Runs (and saves) by itself on the first boot; the command remains for doing it by hand |
| [`payload/klipper/config/ff-toolchange.cfg`](../payload/klipper/config/ff-toolchange.cfg) | `/usr/data/config/` | empty `[ff_tool 0..3]` sections (the per-unit dock/nozzle data is autosaved, nothing unit-specific ships), `[ff_toolchange]` feeds/geometry, the `G28` dock-first wrapper |
| [`payload/klipper/config/ff-tool-offset.cfg`](../payload/klipper/config/ff-tool-offset.cfg) | `/usr/data/config/` | `[ff_tool_offset]` — probe geometry and guards for the calibration commands |
| [`payload/klipper/config/ff-legacy.cfg`](../payload/klipper/config/ff-legacy.cfg) | `/usr/data/config/` | `[ff_legacy]` — stays included permanently; with `auto_import` it re-imports on every startup until a tool has a saved nozzle position, then does nothing |
| [`payload/klipper/config/ff-legacy.cfg`](../payload/klipper/config/ff-legacy.cfg) | `/usr/data/config/` | `[ff_legacy]` — stays included permanently; with `auto_import` it imports at startup until a tool has a saved nozzle position, and with `auto_save` the first import `SAVE_CONFIG`s itself (one restart, before the UI is up), after which it does nothing |
| [`payload/klipper/config/ff-print-macros.cfg`](../payload/klipper/config/ff-print-macros.cfg) | `/usr/data/config/` | `START_PRINT` / `END_PRINT` / `PAUSE` / `RESUME` / `CANCEL_PRINT`, reconstructed from the app's sequences, plus the `_FF_PREFLIGHT` calibration and tool-presence gate; declares `[ff_print]` and the `FF_BEFORE_PRINT_START` / `FF_AFTER_PRINT_END` entry points it calls |
| [`payload/klipper/config/ff-filament.cfg`](../payload/klipper/config/ff-filament.cfg) | `/usr/data/config/` | `LOAD_FILAMENT` / `UNLOAD_FILAMENT` / `PURGE` — the touchscreen's filament-load sequence (grab tool, purge chute, feed) recovered from the binary; unload is a designed retract (the stock app has none) |
| [`payload/klipper/config/ff-runout.cfg`](../payload/klipper/config/ff-runout.cfg) | `/usr/data/config/` | Runout / clog handling: gives the stock `fd_ex*` / `fm_ex*` sensors a `runout_gcode` that pauses a Mainsail print when the **mounted** tool runs out or clogs (the app's E0162 / E0163, reported here in plain words); `ff_toolchange` arms only the mounted tool's sensors |
@@ -96,7 +96,8 @@ file. That is also why the includes live in `printer.base.cfg` and not there
— and it makes the undo button work, since flashing the stock FlashForge
package restores its own `printer.base.cfg` and the includes go with it.
Flash, then jump to step 5 below to import your unit's calibration.
Flash, and your unit's calibration imports itself on the first boot — step 5
below says how, and step 6 how to check it.
### By hand
@@ -127,7 +128,7 @@ scp payload/klipper/config/ff-*.cfg \
[include ff-print-macros.cfg]
[include ff-runout.cfg] ; after printer.base.cfg (overrides its sensor sections)
[include ff-chamber.cfg] ; M141/M191; follows whatever printer.chamber.cfg declared
[include ff-legacy.cfg] ; harmless to leave in — see step 8
[include ff-legacy.cfg] ; harmless to leave in — see step 7
```
> **If you put these in `printer.cfg` instead, they must go ABOVE the
@@ -148,30 +149,34 @@ scp payload/klipper/config/ff-*.cfg \
4. `RESTART` (or reboot).
5. Import the factory/touchscreen calibration once:
5. The factory/touchscreen calibration imports itself on that startup:
`[ff_legacy]` reads `extruder.json` / `test.json` / `zoffset.json`, stages
**your unit's** dock coordinates, nozzle and station values, station start
point and any per-tool Z tune, and — with `auto_save`, the default —
persists them with its own `SAVE_CONFIG` into `printer.cfg`'s `#*#` block.
That save restarts Klipper once more, straight after ready, which on a
flashed printer lands before HelixScreen is up: the wizard never meets an
uncalibrated machine. The manual form, for inspecting or with
`auto_save: False`:
```gcode
FF_IMPORT_FIRMWARE_CONFIG
FF_IMPORT_FIRMWARE_CONFIG ; APPLY=0 to only print, stage nothing
SAVE_CONFIG
```
This reads `extruder.json` / `test.json` / `zoffset.json` and stages
**your unit's** dock coordinates, nozzle and station values, station
start point and any per-tool Z tune for `SAVE_CONFIG`. Nothing to
paste; it only prints an `[ff_toolchange]` snippet if a feed in the JSON
differs from the running config (it does not on a stock unit).
Nothing to paste either way; the import only prints an `[ff_toolchange]`
snippet if a feed in the JSON differs from the running config (it does not
on a stock unit).
6. `SAVE_CONFIG` — persists the imported values into `printer.cfg`'s `#*#`
block and restarts.
7. `TOOLCHANGE_STATUS` and `TOOL_OFFSET_STATUS` — every tool should show a
6. `TOOLCHANGE_STATUS` and `TOOL_OFFSET_STATUS` — every tool should show a
nozzle triple and a dock position, the station `z` must be present;
nothing should say `NOT CALIBRATED`, "no dock position" or "unsaved
calibration pending".
8. Optionally remove the `[include ff-legacy.cfg]` line and `RESTART`. Not
required: the import is one-shot, and `ff_legacy`'s startup auto-import
returns early as soon as any tool has a nozzle position, so the section is
inert once step 6 has run. A flashed printer keeps it included.
7. Optionally remove the `[include ff-legacy.cfg]` line and `RESTART`. Not
required: the import returns early as soon as any tool has a nozzle
position, so the section is inert once the import has been saved. A
flashed printer keeps it included.
## Verify
+9 -5
View File
@@ -21,11 +21,14 @@
#
# Keep it included (payload/klipper/extras/ff_legacy.py must be in
# /usr/prog/klipper/klippy/extras/). With auto_import (default) the stock
# firmware's nozzle/station/dock numbers are loaded at every startup for as
# long as no [ff_tool n] has a saved nozzle position, so a fresh install
# starts with the calibration firmwareExe already made and a setup wizard can
# skip it. SAVE_CONFIG (after a recalibration, or by hand) persists the
# values and ends the auto import. Manual form:
# firmware's nozzle/station/dock numbers are loaded at startup for as long as
# no [ff_tool n] has a saved nozzle position, so a fresh install starts with
# the calibration firmwareExe already made and a setup wizard can skip it.
# With auto_save (also default) that first import is persisted on the spot:
# klipper runs SAVE_CONFIG itself and restarts once, right after the first
# boot's ready and before the screen is up, and neither runs again. Set
# auto_save off to keep the values live-but-unsaved until a SAVE_CONFIG of
# your own. Manual form:
#
# FF_IMPORT_FIRMWARE_CONFIG ; reads /usr/data/firmwareRes/config
# SAVE_CONFIG ; persists nozzle_*/station_*/z_adjust
@@ -39,4 +42,5 @@
[ff_legacy]
#auto_import: True
#auto_save: True
#firmware_config_dir: /usr/data/firmwareRes/config
+5 -5
View File
@@ -50,11 +50,11 @@
# like pid_* do.
#
# First install on a machine that was calibrated from the touchscreen: nothing
# to include -- printer.base.cfg already ships ff-legacy.cfg, and its
# auto_import runs FF_IMPORT_FIRMWARE_CONFIG on every startup until a tool has
# a saved nozzle position. Run SAVE_CONFIG to keep the staged factory
# dock/nozzle/station numbers. Check with TOOLCHANGE_STATUS before moving
# anything.
# to include -- printer.base.cfg already ships ff-legacy.cfg, whose
# auto_import runs FF_IMPORT_FIRMWARE_CONFIG at startup until a tool has a
# saved nozzle position, and whose auto_save then runs SAVE_CONFIG itself on
# the first boot (one restart) to keep the staged factory dock/nozzle/station
# numbers. Check with TOOLCHANGE_STATUS before moving anything.
#
# Tool-to-tool offsets are DIFFERENCES of nozzle_* against offset_base (T0),
# exactly as CommMgr::setGrabGcodeOffsetMgr computes them:
+50 -8
View File
@@ -3,8 +3,13 @@
# [ff_legacy] is shipped permanently (printer.base.cfg includes ff-legacy.cfg).
# With auto_import -- the default -- _handle_ready re-imports on EVERY
# klippy:ready until some tool has a saved nozzle position, so a fresh install
# comes up with the calibration firmwareExe already made. Run SAVE_CONFIG once
# to persist it; after that the import finds a saved nozzle and does nothing.
# comes up with the calibration firmwareExe already made. With auto_save --
# also the default -- a successful first import runs SAVE_CONFIG by itself:
# klipper restarts once, right after the first ready and before anyone has had
# time to reach a screen, and comes back with the values persisted -- after
# which the import finds a saved nozzle and neither runs again. Setting
# auto_save off restores the old contract: values applied live each boot,
# SAVE_CONFIG left to you.
#
# FF_IMPORT_FIRMWARE_CONFIG [DIR=/usr/data/firmwareRes/config] [APPLY=1]
#
@@ -124,10 +129,12 @@ class FFLegacy:
# auto_import: while no [ff_tool n] carries a nozzle position (nothing
# calibrated or saved yet), load the stock firmware's numbers at
# startup so the printer -- and a UI's setup wizard -- sees the
# calibration firmwareExe already made. Applied live and staged; the
# next SAVE_CONFIG (e.g. after a recalibration) persists it, after
# which this no longer triggers.
# calibration firmwareExe already made. Applied live and staged; with
# auto_save the staging is persisted immediately (see _handle_ready),
# otherwise the next SAVE_CONFIG (e.g. after a recalibration) does it.
# Either way, once saved this no longer triggers.
self.auto_import = config.getboolean('auto_import', True)
self.auto_save = config.getboolean('auto_save', True)
self.auto_import_dir = config.get('firmware_config_dir',
FIRMWARE_CONFIG_DIR)
self.printer.register_event_handler('klippy:ready',
@@ -143,15 +150,50 @@ class FFLegacy:
if not os.path.exists(os.path.join(self.auto_import_dir,
'extruder.json')):
return
# Read BEFORE the import stages anything: SAVE_CONFIG commits every
# staged value, so if something else is already pending it is not ours
# to commit and the automatic save stands down.
reactor = self.printer.get_reactor()
configfile = self.printer.lookup_object('configfile')
pending_before = configfile.get_status(
reactor.monotonic()).get('save_config_pending')
try:
out = self._import(self.auto_import_dir, apply=True)
except Exception as e:
logging.warning("%s: auto import failed: %s", self.name, e)
return
logging.info("%s: auto import\n%s", self.name, "\n".join(out))
self.gcode.respond_info(
"%s: imported the stock firmware's calibration (%s); run"
" SAVE_CONFIG to keep it" % (self.name, self.auto_import_dir))
# Only restart for values that actually landed: a JSON with no tool
# data stages nothing worth a restart, and calibrated() turning true
# is also what stops this from ever running a second time.
landed = any(
t is not None and t.calibrated()
for t in (self.printer.lookup_object('ff_tool %d' % n, None)
for n in range(EXTRUDER_COUNT)))
if self.auto_save and landed and not pending_before:
self.gcode.respond_info(
"%s: imported the stock firmware's calibration (%s);"
" persisting with SAVE_CONFIG -- klipper restarts once"
% (self.name, self.auto_import_dir))
# After the ready handlers, not during them: SAVE_CONFIG restarts
# klippy, and the other handlers deserve to finish first. One
# reactor callback later is still seconds before any UI is up.
reactor.register_callback(self._auto_save)
else:
self.gcode.respond_info(
"%s: imported the stock firmware's calibration (%s); run"
" SAVE_CONFIG to keep it" % (self.name, self.auto_import_dir))
def _auto_save(self, eventtime):
try:
self.gcode.run_script("SAVE_CONFIG")
except Exception as e:
# A failed save does not restart, so there is no loop here: the
# printer runs on the live-applied values and the next boot tries
# again, exactly as a manual SAVE_CONFIG failure would leave it.
logging.warning("%s: automatic SAVE_CONFIG failed (%s) -- the"
" imported values are live but unsaved; run"
" SAVE_CONFIG by hand", self.name, e)
cmd_FF_IMPORT_FIRMWARE_CONFIG_help = (
"Import firmwareExe's extruder/test/zoffset.json into [ff_tool n] /"
+255
View File
@@ -0,0 +1,255 @@
"""ff_legacy's automatic import-and-save at first boot.
The contract under test: on a printer fresh from stock firmware -- no
[ff_tool n] has a saved nozzle position -- the first klippy:ready imports
firmwareExe's per-unit JSON and, with auto_save (the default), persists it
with a SAVE_CONFIG that klipper issues itself. Once. The restart that save
causes is exactly the point: it happens right after ready, before a UI is up,
so the wizard never sees an uncalibrated machine.
These run against payload/klipper/extras/ff_legacy.py with the klippy objects
stubbed -- the extra only touches gcode, configfile, reactor and the ff_tool
objects, all narrow enough to fake honestly. The replica lane cannot cover
this: klippy itself does not run there.
"""
import importlib.util
import json
import os
import pytest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _load_ff_legacy():
path = os.path.join(ROOT, "payload", "klipper", "extras", "ff_legacy.py")
spec = importlib.util.spec_from_file_location("ff_legacy", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
ff_legacy = _load_ff_legacy()
class FakeReactor:
def __init__(self):
self.callbacks = []
def monotonic(self):
return 0.0
def register_callback(self, cb):
self.callbacks.append(cb)
def run_pending(self):
cbs, self.callbacks = self.callbacks, []
for cb in cbs:
cb(self.monotonic())
class FakeConfigfile:
def __init__(self):
self.staged = {}
self.pending = False
def set(self, section, option, value):
self.staged[(section, option)] = value
self.pending = True
def get_status(self, eventtime):
return {"save_config_pending": self.pending}
class FakeGcode:
def __init__(self):
self.commands = {}
self.messages = []
self.scripts = []
self.fail_scripts = False
def register_command(self, name, fn, desc=None):
self.commands[name] = fn
def respond_info(self, msg):
self.messages.append(msg)
def run_script(self, script):
if self.fail_scripts:
raise RuntimeError("shutdown")
self.scripts.append(script)
class FakeTool:
def __init__(self):
self.nozzle = None
self.dock_x = None
self.dock_y = None
self.z_adjust = 0.0
def calibrated(self):
return self.nozzle is not None
class FakePrinter:
def __init__(self, objects, reactor):
self.objects = objects
self.reactor = reactor
self.handlers = {}
def lookup_object(self, name, default="raise"):
if name in self.objects:
return self.objects[name]
if default == "raise":
raise KeyError(name)
return default
def register_event_handler(self, event, fn):
self.handlers[event] = fn
def get_reactor(self):
return self.reactor
class FakeConfig:
def __init__(self, printer, values):
self.printer = printer
self.values = values
def get_printer(self):
return self.printer
def get_name(self):
return "ff_legacy"
def getboolean(self, name, default):
return self.values.get(name, default)
def get(self, name, default=None):
return self.values.get(name, default)
def write_firmware_json(directory):
"""The three files firmwareExe keeps, in their real not-quite-JSON shape:
a JSON object followed by a trailing C comment."""
ext = {"x_station_pos": 100.0, "y_station_pos": 200.0, "z_station_pos": -3.0}
for n in range(4):
sfx = "" if n == 0 else "%d" % n
ext["x_check_pos" + sfx] = 10.0 + n
ext["y_check_pos" + sfx] = 20.0 + n
ext["t%d_offset_x" % n] = 1.0 + n
ext["t%d_offset_y" % n] = 2.0 + n
ext["t%d_offset_z" % n] = 3.0 + n
for name, obj in (("extruder", ext), ("test", {}), ("zoffset", {})):
with open(os.path.join(directory, name + ".json"), "w") as fh:
fh.write(json.dumps(obj) + "\n/* firmwareExe trailer */\n")
@pytest.fixture
def rig(tmp_path):
write_firmware_json(str(tmp_path))
reactor = FakeReactor()
gcode = FakeGcode()
configfile = FakeConfigfile()
objects = {"gcode": gcode, "configfile": configfile}
for n in range(4):
objects["ff_tool %d" % n] = FakeTool()
printer = FakePrinter(objects, reactor)
return printer, reactor, gcode, configfile, str(tmp_path)
def make(printer, values):
config = FakeConfig(printer, values)
return ff_legacy.FFLegacy(config)
def ready(printer):
printer.handlers["klippy:ready"]()
def test_first_boot_imports_and_saves(rig):
printer, reactor, gcode, configfile, jdir = rig
make(printer, {"firmware_config_dir": jdir})
ready(printer)
# applied live: every tool got its nozzle triple
for n in range(4):
assert printer.objects["ff_tool %d" % n].nozzle == (1.0 + n, 2.0 + n, 3.0 + n)
# staged for the save that is about to happen
assert ("ff_tool 0", "nozzle_x") in configfile.staged
assert ("ff_tool_offset", "station_z") in configfile.staged
# the save is deferred to after the ready handlers, then actually issued
assert gcode.scripts == []
assert len(reactor.callbacks) == 1
reactor.run_pending()
assert gcode.scripts == ["SAVE_CONFIG"]
assert any("restarts once" in m for m in gcode.messages)
def test_never_a_second_time(rig):
printer, reactor, gcode, configfile, jdir = rig
make(printer, {"firmware_config_dir": jdir})
ready(printer)
reactor.run_pending()
# after the save-restart the tools come back calibrated; ready again must
# neither re-import nor schedule another save -- this is the "once"
ready(printer)
assert reactor.callbacks == []
assert gcode.scripts == ["SAVE_CONFIG"]
def test_auto_save_off_keeps_the_old_contract(rig):
printer, reactor, gcode, configfile, jdir = rig
make(printer, {"firmware_config_dir": jdir, "auto_save": False})
ready(printer)
assert reactor.callbacks == []
assert gcode.scripts == []
assert any("run SAVE_CONFIG to keep it" in m for m in gcode.messages)
# the values are still applied live, exactly as before
assert printer.objects["ff_tool 0"].nozzle == (1.0, 2.0, 3.0)
def test_someone_elses_pending_save_is_not_committed(rig):
printer, reactor, gcode, configfile, jdir = rig
configfile.pending = True # staged by something else before ready
make(printer, {"firmware_config_dir": jdir})
ready(printer)
assert reactor.callbacks == []
assert gcode.scripts == []
assert any("run SAVE_CONFIG to keep it" in m for m in gcode.messages)
def test_no_json_no_import_no_restart(rig, tmp_path):
printer, reactor, gcode, configfile, _ = rig
empty = tmp_path / "empty"
empty.mkdir()
make(printer, {"firmware_config_dir": str(empty)})
ready(printer)
assert reactor.callbacks == []
assert configfile.staged == {}
assert gcode.messages == []
def test_nothing_landed_no_restart(rig, tmp_path):
# extruder.json exists but carries no usable tool data: importing stages
# nothing a restart would be worth, so no save is scheduled.
printer, reactor, gcode, configfile, _ = rig
bare = tmp_path / "bare"
bare.mkdir()
with open(os.path.join(str(bare), "extruder.json"), "w") as fh:
fh.write('{"irrelevant": 1}\n/* trailer */\n')
make(printer, {"firmware_config_dir": str(bare)})
ready(printer)
assert reactor.callbacks == []
assert gcode.scripts == []
def test_failed_save_does_not_raise(rig):
# SAVE_CONFIG can refuse (read-only config, a conflict); the callback must
# swallow it -- the values are live, the next boot retries, and a failed
# save never restarted klippy so there is no loop to fear.
printer, reactor, gcode, configfile, jdir = rig
gcode.fail_scripts = True
make(printer, {"firmware_config_dir": jdir})
ready(printer)
reactor.run_pending() # must not raise
assert gcode.scripts == []