diff --git a/README.md b/README.md index 3cb019c..f29ab47 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/hardware-testing.md b/docs/hardware-testing.md index 4969eb3..5f0dbda 100644 --- a/docs/hardware-testing.md +++ b/docs/hardware-testing.md @@ -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 diff --git a/docs/toolchange.md b/docs/toolchange.md index e1797c1..2272a36 100644 --- a/docs/toolchange.md +++ b/docs/toolchange.md @@ -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 diff --git a/payload/klipper/config/ff-legacy.cfg b/payload/klipper/config/ff-legacy.cfg index cb8f474..b2f9610 100644 --- a/payload/klipper/config/ff-legacy.cfg +++ b/payload/klipper/config/ff-legacy.cfg @@ -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 diff --git a/payload/klipper/config/ff-toolchange.cfg b/payload/klipper/config/ff-toolchange.cfg index 294b6f6..c150c65 100644 --- a/payload/klipper/config/ff-toolchange.cfg +++ b/payload/klipper/config/ff-toolchange.cfg @@ -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: diff --git a/payload/klipper/extras/ff_legacy.py b/payload/klipper/extras/ff_legacy.py index 88ebe06..0bc70c2 100644 --- a/payload/klipper/extras/ff_legacy.py +++ b/payload/klipper/extras/ff_legacy.py @@ -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] /" diff --git a/test/integration/test_ff_legacy.py b/test/integration/test_ff_legacy.py new file mode 100644 index 0000000..120d44c --- /dev/null +++ b/test/integration/test_ff_legacy.py @@ -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 == []