Files
firmware/test/integration/test_gcode.py
T
Oleksandr ShyshatskyiandClaude Opus 5 30b21262ed Ship the two verification files, not the thing that wrote one
The generator was a build step for an artifact that is written once and
then read by a person standing at a printer. Keeping it meant the file
you send to the machine did not exist in the repo, and the header of the
file you did get told you to re-run a script instead of telling you what
to watch. gcode/ now holds the G-code itself and bin/gen-test-gcode.py
is gone.

Added alongside it: creator5-safe-moves.gcode, the feature print with
the heat and the filament taken out. Once homed, nothing in it goes
below Z50, nothing extrudes, no heater is ever given a target. It homes,
laps the bed slow then fast, runs the diagonals a failing corexy motor
turns into an L, changes tools, sweeps the fans and checks the chamber
gate -- so that a dock that does not latch or a Z offset that is wrong
is found 50 mm up rather than in the plate.

It names its tools with SELECT_TOOL rather than a bare `Tn`, and carries
no M104 or M140. ff_print scans a file's head for exactly those to
decide what to prepare, so this one leaves it nothing to find and is
safe whether FF_BEFORE_PRINT_START.prepare is 0 or 1. The feature print
still wants it at 0.

test_gcode.py reads the committed files now instead of running a
generator, and covers both. The safe file gets the two checks that are
its whole reason to exist -- never below Z50, and never a heater target
or an E word -- because those are exactly the properties a later edit
would break while leaving a file that still runs and still looks safe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:05:01 +03:00

215 lines
8.4 KiB
Python

"""The files in gcode/ must name commands this firmware has.
gcode/ holds the two files you send to a printer to prove the mod works:
creator5-safe-moves.gcode, which is cold and stays 50 mm above the plate, and
creator5-feature-test.gcode, which prints. They are hand-maintained G-code,
which makes them the one artifact in the repo that goes stale silently: rename
START_PRINT or drop TOOL_OFFSET_STATUS and both files still look perfectly
well-formed, and then die on the printer four minutes into a heat-up with a
tool already grabbed.
So the checks here are the ones the files cannot make for themselves -- that
every command in them is one the shipped configs and extras define, and that
every coordinate is one the machine can reach. The safe file gets two more,
because "cold, and never below Z50" is its entire reason to exist and is
exactly the property an edit would quietly break.
All of it reads the real payload; none of it needs the proprietary package.
"""
import os
import re
import pytest
from ffcfg import sections
# Klipper's own commands. Everything else in the files has to come from the
# payload, which is what test_every_command_is_defined proves.
KLIPPER_BUILTINS = {"SET_PRESSURE_ADVANCE"}
FEATURE = "creator5-feature-test.gcode"
SAFE = "creator5-safe-moves.gcode"
# The floor the safe file promises in its own header.
SAFE_Z = 50.0
MACRO = re.compile(r"^gcode_macro (\S+)$")
REGISTERED = re.compile(r"register_command\(\s*['\"]([A-Z0-9_]+)['\"]")
# A G-code word: G1, M106, T2. Anything else is a named command.
GCODE_WORD = re.compile(r"^[GMT]\d+$")
COORD = re.compile(r"\b([XYZE])(-?\d+(?:\.\d+)?)")
@pytest.fixture(scope="session")
def gcodedir(root):
return os.path.join(root, "gcode")
@pytest.fixture(scope="session")
def files(gcodedir):
"""Every shipped file, by name."""
out = {}
for name in sorted(os.listdir(gcodedir)):
if name.endswith(".gcode"):
path = os.path.join(gcodedir, name)
out[name] = open(path, encoding="utf-8").read().splitlines()
assert set(out) == {FEATURE, SAFE}, sorted(out)
return out
@pytest.fixture(scope="session")
def defined(root, cfgdir):
"""Every command name the payload defines, config and Python alike."""
names = set(KLIPPER_BUILTINS)
for cfg in sorted(os.listdir(cfgdir)):
if not cfg.endswith(".cfg"):
continue
for section, _ in sections(os.path.join(cfgdir, cfg)):
m = MACRO.match(section)
if m:
names.add(m.group(1).upper())
extras = os.path.join(root, "payload", "klipper", "extras")
for py in sorted(os.listdir(extras)):
if py.endswith(".py"):
src = open(os.path.join(extras, py), encoding="utf-8").read()
names.update(REGISTERED.findall(src))
return names
@pytest.fixture(scope="session")
def limits(cfgdir):
"""position_min/position_max per axis, from the printer's own config."""
out = {}
path = os.path.join(cfgdir, "printer.base.cfg")
for section, opts in sections(path):
if section.startswith("stepper_"):
axis = section.split("_", 1)[1].upper()
lo = opts.get("position_min")
hi = opts.get("position_max")
if lo is not None and hi is not None:
out[axis] = (float(lo.split(";")[0]), float(hi.split(";")[0]))
assert set(out) == {"X", "Y", "Z"}, out
return out
def commands(lines):
"""(lineno, word, rest) for each executable line."""
for n, raw in enumerate(lines, 1):
line = raw.split(";", 1)[0].strip()
if not line:
continue
word, _, rest = line.partition(" ")
yield n, word.upper(), rest
@pytest.mark.parametrize("name", [FEATURE, SAFE])
def test_every_command_is_defined(files, defined, name):
"""A macro rename must break this file here, not on the printer."""
unknown = sorted({word for _, word, _ in commands(files[name])
if not GCODE_WORD.match(word) and word not in defined})
assert not unknown, (
"%s calls commands nothing in payload/ defines: %s"
% (name, ", ".join(unknown)))
@pytest.mark.parametrize("name", [FEATURE, SAFE])
def test_moves_stay_inside_the_machine(files, limits, name):
"""Every coordinate within the axis limits Klipper is configured with.
Both files keep the printed and traversed shapes well inside the bed, but
the feature print's prime lines deliberately reach out to X256 where the
machine's own start block primes, so "inside the bed" is the wrong bound to
assert. The stepper limits are the real one: past them Klipper aborts.
"""
bad = []
for n, word, rest in commands(files[name]):
if word not in ("G0", "G1"):
continue
for axis, value in COORD.findall(rest):
if axis == "E":
continue
lo, hi = limits[axis]
if not lo <= float(value) <= hi:
bad.append("line %d: %s%s outside %s %.1f..%.1f"
% (n, axis, value, axis, lo, hi))
assert not bad, "%s:\n%s" % (name, "\n".join(bad))
@pytest.mark.parametrize("name", [FEATURE, SAFE])
def test_extrusion_is_never_left_absolute(files, name):
"""M83 is set once and must stay set.
An M82 after it turns the first E of the next move into an absolute
target -- a single retraction becomes a 100 mm unspooling.
"""
for n, word, _ in commands(files[name]):
assert word != "M82", "%s line %d switches to absolute E" % (name, n)
def test_feature_print_calls_the_macros_it_exists_to_test(files, defined):
"""Without this the file could call nothing but builtins.
That is still a well-formed print, and a worthless verification.
"""
called = {word for _, word, _ in commands(files[FEATURE])}
for required in ("START_PRINT", "END_PRINT", "TOOLCHANGE_STATUS",
"TOOL_OFFSET_STATUS", "M141"):
assert required in called, "%s does not exercise %s" % (FEATURE, required)
def test_feature_print_declares_every_tool_it_uses(files):
"""START_PRINT TOOLS= is the preflight gate: it has to list every tool.
A tool missing from TOOLS= is not purged, not wiped and not checked for
presence -- it fails at its grab, mid-print, with filament down.
"""
lines = files[FEATURE]
used = {int(word[1:]) for _, word, _ in commands(lines)
if re.match(r"^T\d$", word)}
start = [rest for _, word, rest in commands(lines) if word == "START_PRINT"]
assert len(start) == 1, start
m = re.search(r"TOOLS=(\S+)", start[0])
assert m, start[0]
declared = {int(part.split(":")[0]) for part in m.group(1).split(",")}
assert declared == used, (
"START_PRINT TOOLS=%s but the file uses %s"
% (sorted(declared), sorted(used)))
def test_safe_file_never_descends_below_its_floor(files):
"""The whole promise of the safe file, in one assertion.
G28 itself has to touch the eddy sensor, and carries no Z word; every
commanded Z after it is bounded here.
"""
bad = []
for n, word, rest in commands(files[SAFE]):
if word not in ("G0", "G1"):
continue
for axis, value in COORD.findall(rest):
if axis == "Z" and float(value) < SAFE_Z:
bad.append("line %d: Z%s is below the Z%.0f floor"
% (n, value, SAFE_Z))
assert not bad, "\n".join(bad)
def test_safe_file_is_cold_and_extrudes_nothing(files):
"""No E word, and no heater ever given a target.
"No filament needed" is a promise made to someone standing at a machine
they have just flashed, and an M104 slipped in later would break it
silently -- the file would still run, and still look safe.
"""
for n, word, rest in commands(files[SAFE]):
assert word not in ("M104", "M109", "M140", "M190", "M191",
"SET_HEATER_TEMPERATURE"), (
"line %d gives a heater a target: %s %s" % (n, word, rest))
if word in ("G0", "G1"):
assert "E" not in {a for a, _ in COORD.findall(rest)}, (
"line %d extrudes: %s %s" % (n, word, rest))
# M141 is the exception and is deliberate: the chamber gate is the thing
# being tested, and the file sets it straight back to 0.
chamber = [rest for _, word, rest in commands(files[SAFE]) if word == "M141"]
assert chamber and chamber[-1].strip() == "S0", (
"the safe file must leave the chamber off, got %s" % chamber)