mirror of
https://github.com/Klipper4FlashForge/firmware.git
synced 2026-10-23 05:42:17 -05:00
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>
215 lines
8.4 KiB
Python
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)
|