From 382fa5f2cbe797b32ef0d48b4a3f71317beacb0b Mon Sep 17 00:00:00 2001 From: Oleksandr Shyshatskyi Date: Sun, 23 Aug 2026 22:32:33 +0300 Subject: [PATCH] ff_print: optional automatic prepare, off by default Adds [ff_print] (payload/klipper/extras/ff_print.py): it takes over SDCARD_PRINT_FILE and M23, reads what the file says about itself, and calls two ordinary macros -- FF_BEFORE_PRINT_START and FF_AFTER_PRINT_END -- before chaining to the builtin. It holds no print policy; both macros live in ff-print-macros.cfg and can be redefined. This is the idea 1545ae1 reverted, hooked differently. That layer hooked the steps piecemeal -- homing on SDCARD_PRINT_FILE, the Z offset on M109 -- and what killed it was the bed: the stock block has no M190, so it would have started on a cold one. This does not reproduce the sequence at scattered hook points; it invokes the whole of START_PRINT at one point that runs before the file's first line, so M190, the mesh, the post-heat G28 Z, the tool gate and the clean all happen in order. FF_BEFORE_PRINT_START.prepare is 0, so nothing changes: the shipped Orca profile calls START_PRINT itself and stays the only path. Setting it to 1 suits a profile that prepares nothing -- FlashForge's stock block. It must NOT be set for a profile that homes for itself: a generic Orca block runs G28 after M140/M104, and the G28 wrapper docks a mounted tool before homing Z, so it would dock the tool the prepare just grabbed. That profile needs no implicit prepare anyway -- it homes and waits for the bed on its own. Metadata comes from the file's own commands, as the app's parser did (its string table lists M104/M109/M140/M190/M141/M191 and ;HEIGHT: among the keys it scanned): bed from the first M140/M190, nozzle from the first M104/M109, initial tool from the first Tn, first-layer height from ;HEIGHT:. Nothing is read from the slicer's config block, so there is no plate-type table and no bed_temperature_formula to go stale, and no reason to read the far end of a 27 MB file. That route also has a trap: on a real file `; first_layer_bed_temperature` read 55 where M140 said 80. Per-tool clean temperatures are not in the file either. The app kept a material per slot and looked the temperature up in its own table; that table is already ported as _FF_FILAMENT.temps, so the missing half is now _FF_FILAMENT.tool_material. Independently of all that, START_PRINT loses its worst argument: TOOLS now carries each tool's clean temperature as `0:220,2:240`, replacing the parallel positional TEMPS= list and its index-alignment trap (TEMPS= still accepted). The Orca snippet emits four {if is_extruder_used[n]} branches instead of eight. _FF_REQUIRE_CALIBRATION and _FF_REQUIRE_TOOLS parsed the same list and walked the same tools, so they merge into _FF_PREFLIGHT. The SDCARD_PRINT_FILE macro override is gone -- two wrappers would fight over the same rename -- and its flag resets and uncalibrated refusal moved into FF_BEFORE_PRINT_START unchanged. test-macros.py is 32 macro bodies over 7 files. Untested on hardware. Co-Authored-By: Claude Opus 5 (1M context) --- assets/orca/machine-start-gcode.txt | 8 +- payload/klipper/config/ff-filament.cfg | 5 + payload/klipper/config/ff-print-macros.cfg | 197 ++++++++++++---- payload/klipper/config/ff-toolchange.cfg | 22 +- payload/klipper/extras/ff_print.py | 255 +++++++++++++++++++++ payload/klipper/extras/ff_toolchange.py | 2 +- 6 files changed, 423 insertions(+), 66 deletions(-) create mode 100644 payload/klipper/extras/ff_print.py diff --git a/assets/orca/machine-start-gcode.txt b/assets/orca/machine-start-gcode.txt index 924b7d2..62d2fcc 100644 --- a/assets/orca/machine-start-gcode.txt +++ b/assets/orca/machine-start-gcode.txt @@ -5,9 +5,11 @@ M106 P2 S0 ; Center_Fresch_Air_Input_Fan_for_PLA30%_(80_0-255) M106 S0 ; Model_Fan_(Heat_Break_Cooler_80-255)100%=255 M106 P3 S0 ; Filter_Unit_Fan_0-255(ABS=255) M104 S[nozzle_temperature_initial_layer] T[initial_extruder] ; preheat in parallel (the clean re-sets it) -; TOOLS = every extruder the file uses (presence gate + pre-print nozzle clean), -; TEMPS = each tool's first-layer temperature, indexed by tool (0 = unused). -START_PRINT BED=[bed_temperature_initial_layer_single] TOOL=[initial_extruder] NOZZLE=[nozzle_temperature_initial_layer] LAYER=[layer_height] LEVEL=0 TOOLS={if is_extruder_used[0]}0,{endif}{if is_extruder_used[1]}1,{endif}{if is_extruder_used[2]}2,{endif}{if is_extruder_used[3]}3{endif} TEMPS={if is_extruder_used[0]}{nozzle_temperature_initial_layer[0]}{else}0{endif},{if is_extruder_used[1]}{nozzle_temperature_initial_layer[1]}{else}0{endif},{if is_extruder_used[2]}{nozzle_temperature_initial_layer[2]}{else}0{endif},{if is_extruder_used[3]}{nozzle_temperature_initial_layer[3]}{else}0{endif} CLEAN=1 +; TOOLS = every extruder the file uses, each with its first-layer +; temperature: the presence gate (refused before any heat or motion) and +; the pre-print nozzle clean. Expanded per extruder because Orca has no +; join over is_extruder_used. +START_PRINT BED=[bed_temperature_initial_layer_single] TOOL=[initial_extruder] NOZZLE=[nozzle_temperature_initial_layer] LAYER=[layer_height] LEVEL=0 TOOLS={if is_extruder_used[0]}0:{nozzle_temperature_initial_layer[0]},{endif}{if is_extruder_used[1]}1:{nozzle_temperature_initial_layer[1]},{endif}{if is_extruder_used[2]}2:{nozzle_temperature_initial_layer[2]},{endif}{if is_extruder_used[3]}3:{nozzle_temperature_initial_layer[3]}{endif} CLEAN=1 G90 M83 M109 S[nozzle_temperature_initial_layer] T[initial_extruder] diff --git a/payload/klipper/config/ff-filament.cfg b/payload/klipper/config/ff-filament.cfg index 36e02f1..a38cabf 100644 --- a/payload/klipper/config/ff-filament.cfg +++ b/payload/klipper/config/ff-filament.cfg @@ -108,6 +108,11 @@ variable_temps: {'PLA': 220, 'PETG': 240, 'PLA-CF': 220, 'PETG-CF': 240, 'TPU-64D': 220, 'PC': 260, 'PA': 260, 'PC-ABS': 260, 'PPS-CF': 290, 'CUSTOM': 220} variable_default_temp: 220 +# What is loaded in each tool. The app kept the same thing per slot and looked +# the operation temperature up in the table above (getFilamentOperationTemp); +# nothing in the G-code file tells us, so it is configured here. Used by the +# nozzle clean when START_PRINT is not given an explicit TEMPS=. +variable_tool_material: ['PLA', 'PLA', 'PLA', 'PLA'] # State shared between the helpers of one operation. variable_tool: -1 variable_in_print: 0 diff --git a/payload/klipper/config/ff-print-macros.cfg b/payload/klipper/config/ff-print-macros.cfg index 73a210c..51d1b17 100644 --- a/payload/klipper/config/ff-print-macros.cfg +++ b/payload/klipper/config/ff-print-macros.cfg @@ -15,6 +15,14 @@ # [include /usr/data/config/ff-filament.cfg] # [include /usr/data/config/ff-print-macros.cfg] # +# Add [ff_print] (klippy-extras/ff_print.py) to have prints started from +# Mainsail prepare the machine automatically, with no slicer changes at all: +# [ff_print] +# #before_macro: FF_BEFORE_PRINT_START +# #after_macro: FF_AFTER_PRINT_END +# #hook_commands: SDCARD_PRINT_FILE, M23 +# It must come after [virtual_sdcard], whose commands it wraps. +# # Deliberate divergences from firmwareExe (documented in OKF/61): # * resume reheats all tools in parallel, not e1+e2 then e3+e4 staged # * no dmesg/meminfo dumps, no drop_caches shell calls @@ -37,13 +45,17 @@ # see orca/machine-start-gcode.txt for the full snippet): # START_PRINT BED=[bed_temperature_initial_layer_single] TOOL=[initial_extruder] \ # NOZZLE=[nozzle_temperature_initial_layer] LAYER=[layer_height] LEVEL=0 \ -# TOOLS=0,2 TEMPS=220,0,240,0 CLEAN=1 -# TOOLS every tool the file uses (comma list; Orca: is_extruder_used[n]). +# TOOLS=0:220,2:240 +# TOOLS every tool the file uses, each with its clean temperature +# ("0:220,2:240"; a bare "0,2" is fine and falls back to NOZZLE). # Each must be docked or mounted or the job is refused before any # heating/homing (app: E0165 "T: %d, no install"). Defaults to TOOL. -# TEMPS per-tool clean temperature, indexed by tool number (0 or missing -# = use NOZZLE). App: getFilamentOperationTemp = the slot's material -# table temperature. +# The clean temperature is the app's getFilamentOperationTemp, i.e. +# the slot's material-table temperature. +# TEMPS optional positional override of the clean temperatures, indexed by +# tool number (0 = fall back). Without it each tool is cleaned at the +# temperature its configured material maps to in the app's table -- +# _FF_FILAMENT.tool_material indexed into _FF_FILAMENT.temps. # CLEAN 1 (default): purge + wipe every tool in TOOLS while the bed heats # (app: clearNozzlePrint). 0 skips it. # SOAK seconds to dwell after the bed reaches target (app: 5 min). 0 = none. @@ -56,25 +68,35 @@ # does NOT put the nozzle at the bed plane -- without this offset (~+3.2 mm # on this unit) the first layer is driven into the plate. # --------------------------------------------------------------------------- -# Refuse BEFORE heating/homing/grabbing if the Z frame cannot be established. -# The eddy-homed Z is ~3.2 mm low; with no nozzle_z/station_z the print-start -# offset cannot be computed and the first layer goes into the plate. -# TOOLS= (comma list) checks every listed tool; TOOL= a single one; neither -# -> every tool must be calibrated. -[gcode_macro _FF_REQUIRE_CALIBRATION] +# _FF_PREFLIGHT TOOLS= [TOOL=] +# Both pre-motion gates in one pass; refuses BEFORE any heating/homing/grab. +# 1. Calibration -- the eddy-homed Z is ~3.2 mm low, so with no nozzle_z / +# station_z the print-start offset cannot be computed and the first layer +# goes into the plate. TOOLS= checks every listed tool, TOOL= adds one; +# with neither, every tool must be calibrated. _FF_JOB.allow_uncalibrated +# overrides this gate only. +# 2. Tool presence (app: prepareForEddy @0x9f2084-0x9f2130, checkInLocation +# -> "T: %d, no install" -> E0165). A tool is available when its dock +# switch is pressed or it is the one on the carriage. +[gcode_macro _FF_PREFLIGHT] gcode: {% set tool = params.TOOL|default(-1)|int %} {% set tools = [] %} {% for s in (params.TOOLS|default('')|string).split(',') %} - {% if s|trim != '' %}{% set _ = tools.append(s|trim|int) %}{% endif %} + {% if s|trim != '' and s|trim|int not in tools %}{% set _ = tools.append(s|trim|int) %}{% endif %} {% endfor %} {% if tool >= 0 and tool not in tools %}{% set _ = tools.append(tool) %}{% endif %} {% set tc = printer.ff_toolchange %} - {% set missing = [] %} + {% set uncalibrated = [] %} + {% set absent = [] %} {% for t in tools %} - {% if t not in tc.calibrated_tools %}{% set _ = missing.append(t) %}{% endif %} + {% if t < 0 or t > 3 %} + { action_raise_error("TOOLS: tool %s out of range 0..3" % t) } + {% endif %} + {% if t not in tc.calibrated_tools %}{% set _ = uncalibrated.append(t) %}{% endif %} + {% if t not in tc.docked_tools and t != tc.current_tool %}{% set _ = absent.append(t) %}{% endif %} {% endfor %} - {% set ok = (tools|length > 0 and missing|length == 0 and tc.station_z is not none) + {% set ok = (tools|length > 0 and uncalibrated|length == 0 and tc.station_z is not none) or (tools|length == 0 and tc.print_offset_ready) %} {% if not ok and not printer["gcode_macro _FF_JOB"].allow_uncalibrated %} { action_raise_error("Not calibrated: station_z=%s, calibrated tools %s, needed %s. " @@ -83,32 +105,13 @@ gcode: "computed and the nozzle would be driven ~3 mm into the plate." % (tc.station_z, tc.calibrated_tools, tools)) } {% endif %} - -# Tool-presence gate (app: prepareForEddy @0x9f2084-0x9f2130, checkInLocation -# -> "T: %d, no install" -> E0165). A tool is available when its dock switch -# is pressed or it is the one on the carriage. Refuses before any motion. -[gcode_macro _FF_REQUIRE_TOOLS] -gcode: - {% set tools = [] %} - {% for s in (params.TOOLS|default('')|string).split(',') %} - {% if s|trim != '' %}{% set _ = tools.append(s|trim|int) %}{% endif %} - {% endfor %} - {% set tc = printer.ff_toolchange %} {% if tools|length > 0 and not tc.state_ok %} { action_raise_error("Toolchanger state not ok: %s" % tc.state_reason) } {% endif %} - {% set missing = [] %} - {% for t in tools %} - {% if t < 0 or t > 3 %} - { action_raise_error("TOOLS: tool %s out of range 0..3" % t) } - {% elif t not in tc.docked_tools and t != tc.current_tool %} - {% set _ = missing.append(t) %} - {% endif %} - {% endfor %} - {% if missing|length > 0 %} + {% if absent|length > 0 %} { action_raise_error("Tool(s) %s not installed (dock switch not pressed, not mounted); " "docked %s, mounted T%s. Install them or re-slice without them." - % (missing, tc.docked_tools, tc.current_tool)) } + % (absent, tc.docked_tools, tc.current_tool)) } {% endif %} # --------------------------------------------------------------------------- @@ -135,7 +138,9 @@ gcode: {% if s|trim != '' %}{% set _ = temps.append(s|trim|float) %}{% endif %} {% endfor %} {% for tool in tools %} - {% set temp = temps[tool] if tool < temps|length and temps[tool] > 0 else fallback %} + {% set mat = (ff.tool_material[tool]|upper) if tool < ff.tool_material|length else '' %} + {% set by_material = ff.temps[mat] if mat in ff.temps else fallback %} + {% set temp = temps[tool] if tool < temps|length and temps[tool] > 0 else by_material %} {% set extruder = printer["tool T%d" % tool].extruder %} {% set nozzle = printer.configfile.settings[extruder].nozzle_diameter|float %} {% set speed = ff.load_feed_thin if nozzle <= 0.25 else ff.load_feed %} @@ -164,28 +169,36 @@ gcode: {% set layer = params.LAYER|default(0)|float %} {% set clean = params.CLEAN|default(1)|int %} {% set soak = params.SOAK|default(0)|float %} + ; TOOLS takes "0,2" or, as the slicer emits it, "0:220,2:240" -- each used + ; tool with its own clean temperature. TEMPS= (positional, 0 = use NOZZLE) + ; is still accepted for files sliced before the paired form existed. {% set tools = [] %} + {% set tmap = {} %} {% for s in (params.TOOLS|default('')|string).split(',') %} - {% if s|trim != '' and s|trim|int not in tools %}{% set _ = tools.append(s|trim|int) %}{% endif %} + {% if s|trim != '' %} + {% set pair = s.split(':') %} + {% set t = pair[0]|trim|int %} + {% if t not in tools %}{% set _ = tools.append(t) %}{% endif %} + {% if pair|length > 1 and pair[1]|trim != '' %} + {% set _ = tmap.update({t: pair[1]|trim|float}) %} + {% endif %} + {% endif %} {% endfor %} {% if tools|length == 0 and tool >= 0 %}{% set _ = tools.append(tool) %}{% endif %} {% set tool_list = tools|join(',') %} - _FF_REQUIRE_CALIBRATION TOOL={tool}{% if tool_list %} TOOLS={tool_list}{% endif %} - {% if tool_list %} - _FF_REQUIRE_TOOLS TOOLS={tool_list} ; app: E0165 before any motion - {% endif %} + {% set temps = [tmap.get(0, 0), tmap.get(1, 0), tmap.get(2, 0), tmap.get(3, 0)]|join(',') + if tmap else params.TEMPS|default('')|string|trim %} + _FF_PREFLIGHT TOOL={tool}{% if tool_list %} TOOLS={tool_list}{% endif %} G90 M82 BED_MESH_CLEAR - M400 G28 SET_GCODE_OFFSET X=0 Y=0 MOVE=1 MOVE_SPEED=600 - SET_IDLE_TIMEOUT TIMEOUT=1800000 + SET_IDLE_TIMEOUT TIMEOUT=864000 ; app value during print {% if bed > 0 %} M140 S{bed} ; app: heatManager(bed) -- heats during the clean {% endif %} {% if clean and tools|length > 0 %} - {% set temps = params.TEMPS|default('')|string|trim %} _FF_NOZZLE_CLEAN TOOLS={tool_list}{% if temps %} TEMPS={temps}{% endif %} TEMP={nozzle} M106 P1 S0 ; app @0x9f27a4 {% endif %} @@ -217,7 +230,6 @@ gcode: {% else %} TOOLCHANGE_SET_PRINT_OFFSET NOZZLE={nozzle} BED={bed} LAYER={layer} {% endif %} - SET_IDLE_TIMEOUT TIMEOUT=864000 ; app value during print # --------------------------------------------------------------------------- # Common end-of-print block: the app's single exit sequence @0x7a25f0, run for @@ -268,6 +280,7 @@ gcode: SET_GCODE_OFFSET Z=0 MOVE=1 M18 SET_IDLE_TIMEOUT TIMEOUT=600 + SET_GCODE_VARIABLE MACRO=_FF_JOB VARIABLE=ended VALUE=1 # Slicer machine end gcode: just call END_PRINT [gcode_macro END_PRINT] @@ -293,6 +306,9 @@ variable_mainsail: 0 # point both refuse on an uncalibrated machine). Only for deliberate tests: # SET_GCODE_VARIABLE MACRO=_FF_JOB VARIABLE=allow_uncalibrated VALUE=1 variable_allow_uncalibrated: 0 +# Set by _FF_PRINT_END so the automatic FF_AFTER_PRINT_END does not run the +# exit block a second time after END_PRINT or CANCEL_PRINT already did. +variable_ended: 0 gcode: [gcode_macro CANCEL_PRINT] @@ -363,3 +379,92 @@ gcode: {% endif %} BASE_RESUME {% endif %} + +# --------------------------------------------------------------------------- +# Automatic print lifecycle -- the two entry points [ff_print] calls. +# +# ff_print.py wraps SDCARD_PRINT_FILE and M23, reads the slicer metadata out of +# the file itself, and calls these macros. It holds no policy of its own: +# change what happens around a print by redefining these, or by pointing +# [ff_print] before_macro:/after_macro: at macros of your own. Everything it +# derived is also readable as printer.ff_print.* (tools, nozzle_temps, tool, +# nozzle, bed, layer, tools_param, filename, origin). +# +# FF_BEFORE_PRINT_START runs BEFORE the file is fed, which is the entire point: +# the stock slicer profile's start G-code contains no G28, so its first move +# (G1 Z5 F2400) requires the machine to be homed already. The touchscreen app +# had the same ordering -- it prepared, then sent M23/M24. +# ORIGIN SDCARD_PRINT_FILE (Moonraker/Mainsail) or M23 (touchscreen). +# BED TOOL NOZZLE LAYER derived from the file's own M140/M104/Tn/;HEIGHT:; +# passed through to START_PRINT verbatim, which ignores ORIGIN. +# TOOLS is NOT derived -- nothing in the file names the tool set +# without reading the slicer's config block, so a print prepared +# this way cleans and gates the initial tool only (which is also +# all the app checked at prepare time; the rest fail at grab). +# Pass TOOLS= yourself for the stronger multi-tool gate. +# --------------------------------------------------------------------------- +[gcode_macro FF_BEFORE_PRINT_START] +# OFF by default, so nothing changes for the shipped Orca profile: it calls +# START_PRINT itself (assets/orca/machine-start-gcode.txt) and preparing here +# as well would re-home and re-purge every tool. The print Z offset and T +# are both idempotent, so a double prepare wastes time and filament rather +# than misplacing the first layer -- but there is no reason to pay it. +# +# Set to 1 only for a profile whose start block prepares NOTHING -- notably +# FlashForge's stock one, which homes nowhere and assumes a machine the +# touchscreen app already prepared. With this on, such a file homes, waits +# for the bed, cleans and grabs before its first line. That is what the +# reverted ff-orca-compat.cfg layer (1545ae1) could not do: it hooked the +# steps piecemeal and never waited for the bed. Here the whole START_PRINT +# runs, in order, before the file is fed. +# +# DO NOT set this for a profile whose start block homes for itself. A generic +# Orca block does `G28` after its M140/M104, and our G28 wrapper docks a +# mounted tool before homing Z -- so it would dock the tool this just grabbed +# and print with an empty carriage. Such a profile needs no implicit prepare +# anyway: it already homes and has its own M190. The toolchanger-specific +# steps it still lacks (tool gate, nozzle clean, print Z offset, bed mesh) are +# what an explicit START_PRINT call is for. +# +# Nor set it while the slicer profile calls START_PRINT itself: the print Z +# offset and T are idempotent, so a double prepare misplaces nothing, but +# it re-homes and re-purges every tool for no reason. +variable_prepare: 0 +# The stock touchscreen app runs its own prepare sequence before M23, so +# preparing again there would repeat the homing, clean and grab. Set this to 1 +# if your screen does not prepare for itself. +variable_prepare_on_m23: 0 +gcode: + {% set origin = params.ORIGIN|default('')|string %} + {% set me = printer["gcode_macro FF_BEFORE_PRINT_START"] %} + {% if origin == 'SDCARD_PRINT_FILE' %} + _FF_PREFLIGHT ; refuse an uncalibrated machine + ; before the file is even loaded, + ; as the old SDCARD_PRINT_FILE + ; wrapper did. START_PRINT calls + ; it again with the tool list. + SDCARD_CLEAR_REFUELLING ; stale touchscreen flags -- see + SDCARD_ENABLE_FFM ENABLE=0 ; ff-toolchange.cfg for why these + SDCARD_SET_NEED_CHECK_EX CHECK=0 ; are load-bearing + SDCARD_NO_FILAMENT_CHECK_EX CHECK=0 + SET_GCODE_VARIABLE MACRO=_FF_JOB VARIABLE=mainsail VALUE=1 + {% endif %} + {% if not me.prepare %} + ; explicit path: the slicer profile calls START_PRINT itself + {% elif origin == 'M23' and not me.prepare_on_m23 %} + { action_respond_info("ff_print: touchscreen print, the app prepares itself") } + {% else %} + START_PRINT {rawparams} + {% endif %} + +# FF_AFTER_PRINT_END -- called once the job leaves the printing state. +# STATE complete | cancelled | error +# The exit block is skipped when END_PRINT (slicer footer) or CANCEL_PRINT +# already ran it -- both set _FF_JOB.ended themselves. +[gcode_macro FF_AFTER_PRINT_END] +gcode: + {% if not printer["gcode_macro _FF_JOB"].ended %} + _FF_PRINT_END + {% endif %} + SET_GCODE_VARIABLE MACRO=_FF_JOB VARIABLE=ended VALUE=0 + SET_GCODE_VARIABLE MACRO=_FF_JOB VARIABLE=mainsail VALUE=0 diff --git a/payload/klipper/config/ff-toolchange.cfg b/payload/klipper/config/ff-toolchange.cfg index 2fd6b47..f53bcde 100644 --- a/payload/klipper/config/ff-toolchange.cfg +++ b/payload/klipper/config/ff-toolchange.cfg @@ -209,22 +209,12 @@ stop_macro: MOTOR_STOP # remap flags). Those commands exist only in the fork; on the upstream # virtual_sdcard we now ship they are unknown commands. FIXME. # --------------------------------------------------------------------------- -[gcode_macro SDCARD_PRINT_FILE] -rename_existing: _FF_SDCARD_PRINT_FILE -gcode: - {% set tc = printer.ff_toolchange %} - {% if not tc.print_offset_ready and not printer["gcode_macro _FF_JOB"].allow_uncalibrated %} - { action_raise_error("Refusing to print: station_z=%s, calibrated tools %s. " - "Run STATION_CALIBRATE / TOOL_OFFSET_CALIBRATE (or FF_IMPORT_FIRMWARE_CONFIG) " - "and SAVE_CONFIG first -- without them the nozzle would be driven ~3 mm into the plate." - % (tc.station_z, tc.calibrated_tools)) } - {% endif %} - SDCARD_CLEAR_REFUELLING - SDCARD_ENABLE_FFM ENABLE=0 - SDCARD_SET_NEED_CHECK_EX CHECK=0 - SDCARD_NO_FILAMENT_CHECK_EX CHECK=0 - SET_GCODE_VARIABLE MACRO=_FF_JOB VARIABLE=mainsail VALUE=1 - _FF_SDCARD_PRINT_FILE {rawparams} +# MOVED: this wrapper is now [ff_print] (klippy-extras/ff_print.py), which +# takes SDCARD_PRINT_FILE over in Python at klippy:connect and calls +# FF_BEFORE_PRINT_START (ff-print-macros.cfg) before chaining to the builtin. +# Two wrappers on one command would fight over the rename, so only one may +# exist: the flag resets and the uncalibrated refusal that lived here are now +# the SDCARD_PRINT_FILE branch of FF_BEFORE_PRINT_START, unchanged. # --------------------------------------------------------------------------- # Homing Z with a head on the carriage rams the nozzle into the plate: Z diff --git a/payload/klipper/extras/ff_print.py b/payload/klipper/extras/ff_print.py new file mode 100644 index 0000000..bb365f4 --- /dev/null +++ b/payload/klipper/extras/ff_print.py @@ -0,0 +1,255 @@ +# Print-start / print-end entry points for the FlashForge Creator 5 Pro. +# +# Why this exists +# The stock slicer profile's start G-code carries no toolchanger information +# and, crucially, no G28 -- its only motion is `G1 Z5 F2400`. On an unhomed +# machine that raises "Must home axis first", so the file cannot be the thing +# that prepares the machine: something must home, clean and grab a tool +# BEFORE the file's first line. The touchscreen app did exactly that -- it +# ran its whole prepare sequence and only then sent M23/M24 (see +# OKF/61-print-lifecycle-verified.md). It never subscribed to a print-start +# event, because it *was* the thing starting the print. +# +# This module restores that ordering for prints started from Moonraker / +# Mainsail, by wrapping the commands that begin a print. Doing it here +# rather than in the slicer means the stock OrcaSlicer profile needs no +# modification and already-sliced files keep working. +# +# Why not idle_timeout:printing +# `idle_timeout:printing` is emitted from handle_sync_print_time (a +# toolhead:sync_print_time handler), i.e. synchronously inside motion +# scheduling -- not a safe place to run a G-code script, which is why +# Klipper runs its own idle gcode from a reactor timer instead. It also +# fires only once the toolhead syncs, which for a stock file is the very +# `G1 Z5` that fails on unhomed axes: the event would arrive at the same +# instant the print dies. There is no usable window. +# +# What this module does NOT do +# It contains no print policy at all. It resolves the file, reads the +# slicer metadata, and calls two ordinary G-code macros: +# +# FF_BEFORE_PRINT_START ORIGIN= [BED=] [TOOL=] [NOZZLE=] [LAYER=] +# [TOOLS=:,...] +# FF_AFTER_PRINT_END STATE= +# +# Both are defined in ff-print-macros.cfg and may be redefined by the user. +# Everything derived from the file is also published in get_status, so a +# redefined macro can read printer.ff_print.* instead of the parameters. +# +# What is read, and why this way +# Only the head of the file, and only what the file states in its own +# commands. The app's parser did the same -- its string table (firmwareExe +# ~138069) lists M104/M109/M140/M190/M141/M191 and `;HEIGHT:` among the keys +# it scanned, and the fields it produced: fisrNozzleIndex (sic), nozzleTemp, +# bedTemp, chamberTemp, layerHeight. +# +# bed the file's first `M140`/`M190 S` +# nozzle the file's first `M104`/`M109 S` +# first tool the first bare `Tn` -- the file's initial extruder, NOT the +# lowest-numbered one it uses; two otherwise identical files +# here start on T0 and T2 respectively +# layer the first `;HEIGHT:` -- the FIRST layer's height, which is +# what the print Z offset's thin-layer term actually wants +# +# Nothing is read from the slicer's config block. That keeps this +# slicer-agnostic: no table of plate types, no `bed_temperature_formula`, +# nothing that breaks when a profile or slicer version changes, and no need +# to read the far end of a 27 MB file. (The config-block route also has a +# trap: on a real file `; first_layer_bed_temperature` read 55 where `M140` +# said 80.) +# +# Per-tool clean temperatures are NOT taken from the file. The app kept a +# material per slot and looked the temperature up in its own table; that +# table is already ported as _FF_FILAMENT.temps, so the material per tool is +# configured alongside it as _FF_FILAMENT.tool_material. +# +# All of the above sits within ~8 KB of the start of the file; HEAD_BYTES is a +# wide margin around that, not a guess. + +import logging +import os +import re + +EXTRUDER_COUNT = 4 + +# Bounded reads: measured offsets are ~8 KB (first Tn) and ~24 KB (config +# block) on real files; these give a wide margin without loading a 27 MB file. +HEAD_BYTES = 256 * 1024 +TAIL_BYTES = 256 * 1024 + +# print_stats states that mean the job is over (as opposed to paused mid-print). +FINISHED_STATES = ('complete', 'cancelled', 'error') + + +def _parse_metadata(path): + """Read what the file says about itself, from its own commands. + + Returns a dict with whatever could be derived; missing keys simply are + not present. Never raises -- a file we cannot read just yields {}, and + the macro then runs with no derived parameters.""" + try: + with open(path, 'rb') as f: + head = f.read(HEAD_BYTES).decode('utf-8', 'replace') + except Exception: + logging.exception("ff_print: cannot read '%s'", path) + return {} + + meta = {} + + # Bed and nozzle: the file's own first heat command, the way the app's + # parser did it (it scanned M104/M109/M140/M190/M141/M191 and reported + # nozzleTemp / bedTemp / chamberTemp). + bed = re.search(r'^M1[49]0 S([0-9]+(?:\.[0-9]+)?)', head, re.M) + if bed is not None and float(bed.group(1)) > 0: + meta['bed'] = int(float(bed.group(1))) + hot = re.search(r'^M10[49] S([0-9]+(?:\.[0-9]+)?)', head, re.M) + if hot is not None and float(hot.group(1)) > 0: + meta['nozzle'] = int(float(hot.group(1))) + + # The file's initial extruder (the app's "fisrNozzleIndex", sic). + first = re.search(r'^T([0-%d])\b' % (EXTRUDER_COUNT - 1), head, re.M) + if first is not None: + meta['tool'] = int(first.group(1)) + + # First-layer height, from the per-layer marker the slicer emits in the + # body -- one of the three keys the app looked for, and the only one that + # appears near the start of the file. This feeds the print Z offset's + # thin-layer term, which is a FIRST-layer correction, so the first layer's + # height is the value that belongs there. + layer = re.search(r'^;HEIGHT:([0-9.]+)', head, re.M) + if layer is not None: + try: + meta['layer'] = float(layer.group(1)) + except ValueError: + pass + + return meta + + +class FFPrint: + def __init__(self, config): + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.gcode = self.printer.lookup_object('gcode') + self.before_macro = config.get('before_macro', 'FF_BEFORE_PRINT_START') + self.after_macro = config.get('after_macro', 'FF_AFTER_PRINT_END') + self.hook_commands = [c.strip().upper() for c + in config.getlist('hook_commands', + ['SDCARD_PRINT_FILE', 'M23']) + if c.strip()] + self.prev_handlers = {} + self.meta = {} + self.filename = None + self.origin = None + # Latch: only a print WE announced may fire the end macro. + self.active = False + self.end_state = 'complete' + self.end_timer = None + self.printer.register_event_handler('klippy:connect', + self._handle_connect) + self.printer.register_event_handler('idle_timeout:ready', + self._handle_ready) + + def _handle_connect(self): + # Same rename dance gcode_macro uses for rename_existing: take the + # command over and keep the previous handler to chain to. + for cmd in self.hook_commands: + prev = self.gcode.register_command(cmd, None) + if prev is None: + raise self.printer.config_error( + "ff_print: command '%s' is not registered -- [ff_print]" + " must be loaded after [virtual_sdcard]" % (cmd,)) + self.prev_handlers[cmd] = prev + self.gcode.register_command( + cmd, self._make_handler(cmd), + desc="%s (ff_print: runs %s first)" + % (cmd, self.before_macro)) + self.end_timer = self.reactor.register_timer(self._run_end_macro) + + def _make_handler(self, cmd): + def handler(gcmd): + self._cmd_start(cmd, gcmd) + return handler + + def get_status(self, eventtime): + return { + 'filename': self.filename, + 'origin': self.origin, + 'active': self.active, + 'tool': self.meta.get('tool'), + 'nozzle': self.meta.get('nozzle'), + 'bed': self.meta.get('bed'), + 'layer': self.meta.get('layer'), + } + + def _resolve(self, gcmd, cmd): + """Path of the file this command is about, mirroring virtual_sdcard's + own resolution (a plain join under sdcard_dirname).""" + if cmd == 'M23': + name = gcmd.get_raw_command_parameters().strip() + else: + name = gcmd.get('FILENAME', '') + name = name.strip() + if name.startswith('/'): + name = name[1:] + if not name: + return None + vsd = self.printer.lookup_object('virtual_sdcard', None) + if vsd is None: + return None + return os.path.join(vsd.sdcard_dirname, name) + + def _cmd_start(self, cmd, gcmd): + path = self._resolve(gcmd, cmd) + self.filename = path + self.origin = cmd + self.meta = _parse_metadata(path) if path else {} + if self.meta: + logging.info("ff_print: %s -> %s", path, self.meta) + else: + logging.info("ff_print: %s -> no slicer metadata found", path) + + params = ['ORIGIN=%s' % (cmd,)] + for key, name, fmt in (('bed', 'BED', '%d'), ('tool', 'TOOL', '%d'), + ('nozzle', 'NOZZLE', '%d'), + ('layer', 'LAYER', '%s')): + if self.meta.get(key) is not None: + params.append('%s=%s' % (name, fmt % (self.meta[key],))) + + # Let the macro raise: a refusal here must stop the print BEFORE the + # base command loads and resumes the file. + self.gcode.run_script_from_command( + '%s %s' % (self.before_macro, ' '.join(params))) + # Arm the end latch only once prepare succeeded. + self.active = True + self.prev_handlers[cmd](gcmd) + + def _handle_ready(self, print_time): + """idle_timeout:ready fires whenever the queue drains -- including a + long M190 mid-print and any manual jog. Only a job we announced, and + only one print_stats calls finished, is a real end of print.""" + if not self.active: + return + stats = self.printer.lookup_object('print_stats', None) + if stats is None: + return + state = stats.get_status(self.reactor.monotonic()).get('state') + if state not in FINISHED_STATES: + return + self.active = False + self.end_state = state + # Run the macro from a timer, not from this event: the handler runs in + # the idle_timeout timeout path and should not block on a G-code script. + self.reactor.update_timer(self.end_timer, self.reactor.NOW) + + def _run_end_macro(self, eventtime): + state = self.end_state + try: + self.gcode.run_script('%s STATE=%s' % (self.after_macro, state)) + except Exception: + logging.exception("ff_print: %s failed", self.after_macro) + return self.reactor.NEVER + + +def load_config(config): + return FFPrint(config) diff --git a/payload/klipper/extras/ff_toolchange.py b/payload/klipper/extras/ff_toolchange.py index 7f4c313..b0a1f39 100644 --- a/payload/klipper/extras/ff_toolchange.py +++ b/payload/klipper/extras/ff_toolchange.py @@ -1278,7 +1278,7 @@ class FFToolchange: 'print_offset_ready': self.print_offset_ready(), # Tools currently sitting in their docks (dock switch # pressed). A tool is available for a print when it is - # docked or is the mounted one (_FF_REQUIRE_TOOLS). + # docked or is the mounted one (_FF_PREFLIGHT). 'docked_tools': [i for i in range(EXTRUDER_COUNT) if self._in_location(i, eventtime)], # Tool whose runout/clog sensors are enabled (-1 = none)