From 6dbb4ca3fb44b47259d0065c6ec24b5d43c22151 Mon Sep 17 00:00:00 2001 From: MangaValk Date: Sat, 10 Oct 2026 13:45:11 +0200 Subject: [PATCH] Initial import of my Klipper macros, modules and scripts Macros as they run on the printer (Voron 2.4 + AFC Box Turtle + Blobifier + Beacon + bd_pressure + bdwidth + iHeater), the Klipper modules they need (xy_drift, bdwidth_status, bdpressure_check), MCU firmware update scripts, install.sh with Moonraker update manager support, and READMEs. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 3 + README.md | 150 +++++ extras/bdpressure_check.py | 107 ++++ extras/bdwidth_status.py | 71 +++ extras/xy_drift.py | 104 ++++ install.sh | 98 ++++ macros/afc_macros.cfg | 193 +++++++ macros/bd_pa_afc_spoolman.cfg | 654 ++++++++++++++++++++++ macros/bd_pa_create_spoolman_field.sh | 7 + macros/bd_pa_write_spoolman.sh | 22 + macros/bedfans.cfg | 171 ++++++ macros/drying.cfg | 300 ++++++++++ macros/heatsoak.cfg | 301 ++++++++++ macros/iHeater.cfg | 259 +++++++++ macros/klipper-macros.cfg | 26 + macros/macros.cfg | 278 +++++++++ macros/macros_klipperscreen.cfg | 174 ++++++ macros/motion.cfg | 40 ++ macros/purge_check.cfg | 155 +++++ macros/purge_until_empty.cfg | 127 +++++ macros/stealthburner_led_effects_barf.cfg | 350 ++++++++++++ scripts/README.md | 88 +++ scripts/update_all_mcus.sh | 118 ++++ scripts/update_katapult_all_mcus.sh | 110 ++++ 24 files changed, 3906 insertions(+) create mode 100644 .gitattributes create mode 100644 README.md create mode 100644 extras/bdpressure_check.py create mode 100644 extras/bdwidth_status.py create mode 100644 extras/xy_drift.py create mode 100755 install.sh create mode 100644 macros/afc_macros.cfg create mode 100644 macros/bd_pa_afc_spoolman.cfg create mode 100755 macros/bd_pa_create_spoolman_field.sh create mode 100755 macros/bd_pa_write_spoolman.sh create mode 100644 macros/bedfans.cfg create mode 100644 macros/drying.cfg create mode 100644 macros/heatsoak.cfg create mode 100644 macros/iHeater.cfg create mode 100644 macros/klipper-macros.cfg create mode 100644 macros/macros.cfg create mode 100644 macros/macros_klipperscreen.cfg create mode 100644 macros/motion.cfg create mode 100644 macros/purge_check.cfg create mode 100644 macros/purge_until_empty.cfg create mode 100644 macros/stealthburner_led_effects_barf.cfg create mode 100644 scripts/README.md create mode 100755 scripts/update_all_mcus.sh create mode 100755 scripts/update_katapult_all_mcus.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e9f7c96 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Everything runs on Linux (Klipper host): keep LF line endings, +# also when the repo is edited on Windows. +* text=auto eol=lf diff --git a/README.md b/README.md new file mode 100644 index 0000000..52d1d47 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# klipper-macros + +My Klipper macros, the small Klipper modules they need, and helper scripts, +from a Voron 2.4 (350) with an AFC Box Turtle (5 lanes), Blobifier, Beacon, +Nitehawk-SB toolboard, bd_pressure, bdwidth and an iHeater chamber heater. + +Installs with one script and updates through Moonraker's update manager. + +## Contents + +### Macros (`macros/`, included through `klipper-macros.cfg`) + +| File | Macros | Needs | +|---|---|---| +| `macros.cfg` | `PRINT_WARMUP`, `PRINT_START`, `PRINT_END`, `SOAK_MY_BED`, `[idle_timeout]` | Beacon, AFC, Blobifier, `HEAT_SOAK`, iHeater, bdwidth, bd_pressure PA macros, soak-my-bed | +| `afc_macros.cfg` | `AFC_XY_SAFETY_CHECK`, `_AFC_STORE_HOME_REFERENCE`, `AFC_XY_RESUME`, `LANE_FEED_UNTIL_HUB_OPEN`, `LANE_FEED_ABORT`, `HOME`, `SET_ACTIVE_SPOOL`, `CLEAR_ACTIVE_SPOOL` | AFC, `extras/xy_drift.py` (CoreXY) | +| `purge_check.cfg` | `BLOBIFIER_CHECKED`: checks every AFC purge with bd_pressure (and optionally bdwidth motion) | Blobifier, AFC, `extras/bdpressure_check.py` + bd_pressure load-check firmware, `extras/bdwidth_status.py` | +| `purge_until_empty.cfg` | `PURGE_UNTIL_EMPTY`: purges a snapped filament out until there is no more nozzle pressure | same as `purge_check.cfg` | +| `bd_pa_afc_spoolman.cfg` + `bd_pa_*.sh` | `LANE_CALIBRATE_PA`, `PA_CALIBRATE_ALL_LANES`, `LANE_RECAL_PA`, `SET_SPOOL_ID`, `PA_STATUS`: pressure advance per lane/spool with bd_pressure, cached locally and in Spoolman | bd_pressure, AFC, Spoolman, `gcode_shell_command` | +| `motion.cfg` | PIR motion sensor (`[gcode_button am312]`) switching the case light | motion sensor on a pin, `[output_pin caselight]` | +| `drying.cfg` | `START_DRYING`, `STOP_DRYING`, `STATUS_DRYING`: dry filament on the heated bed | bed fans macros, chamber heater (optional) | +| `bedfans.cfg` | Bed fan control through `SET_HEATER_TEMPERATURE`/`M190`/`M140` overrides | `[fan_generic BedFans]` | +| `heatsoak.cfg` | `HEAT_SOAK`, `STOP_HEAT_SOAK`, `CANCEL_HEAT_SOAK`, `HEAT_SOAK_RESUME`, `RESUME` | — | +| `iHeater.cfg` | iHeater MCU, heater, sensors, `M141`, `M191`, `iHEATER_ON`, `iHEATER_OFF` | iHeater chamber heater | +| `stealthburner_led_effects_barf.cfg` | Stealthburner LED status macros (`_status_*`) | `led_effect`, `[neopixel sb_leds]` | +| `macros_klipperscreen.cfg` | `PAUSE`, `RESUME`, `CANCEL_PRINT`, `LOAD_FILAMENT`, `UNLOAD_FILAMENT`, `M600` | — | + +### Klipper modules (`extras/`) + +| File | For | +|---|---| +| `xy_drift.py` | `AFC_XY_SAFETY_CHECK`: measures lost XY steps from the stepper step counts at the endstop triggers (CoreXY). The toolhead position after homing cannot show lost steps. | +| `bdwidth_status.py` | exposes bdwidth's motion counter and width to macros | +| `bdpressure_check.py` | bd_pressure load check (`BDP_CHECK_START/STOP/QUERY`). Needs the load-check firmware from the bd_pressure `load-check-extension` branch; on stock firmware it reports "not available" and does nothing. | + +### Scripts (`scripts/`) + +Firmware update scripts for all MCUs (Klipper and Katapult). They contain +this printer's CAN UUIDs and serial IDs: see [scripts/README.md](scripts/README.md) +before using them on another printer. + +## Install + +On the Klipper host: + +```bash +cd ~ && git clone https://mangavalk.com/mangavalk/klipper-macros.git +~/klipper-macros/install.sh +``` + +`install.sh`: + +- links `extras/*.py` into `~/klipper/klippy/extras/` (and hides them from + Klipper's update manager), +- links `macros/` to `~/printer_data/config/klipper-macros`, so the files show + up in Mainsail/Fluidd under `klipper-macros/`, +- makes the shell scripts executable, +- adds this section to `moonraker.conf` (once, with a backup): + +```ini +[update_manager klipper-macros] +type: git_repo +path: ~/klipper-macros +origin: https://mangavalk.com/mangavalk/klipper-macros.git +primary_branch: main +managed_services: klipper +install_script: install.sh +``` + +Then: + +1. In `printer.cfg`, after `[include mainsail.cfg]`: + ```ini + [include klipper-macros/klipper-macros.cfg] + ``` +2. Restart the **Klipper service** (`sudo systemctl restart klipper`, or + Mainsail → services → klipper → restart). A `RESTART` or + `FIRMWARE_RESTART` does not load new or changed Python modules. +3. Restart Moonraker once, so the update manager shows `klipper-macros`. + +Updates then come through Moonraker; it re-runs `install.sh` and restarts +Klipper. + +### Switching from copies in the config folder + +If these macros already exist as files in your config folder (as on the +original printer: `Macros/*.cfg` and `bd_pa_afc_spoolman.cfg`), remove those +includes when adding `klipper-macros.cfg`, otherwise the macros are defined +twice and the old copy may win: + +- remove `[include bd_pa_afc_spoolman.cfg]` from `printer.cfg`, +- replace `[include ./Macros/macros.cfg]` with + `[include klipper-macros/klipper-macros.cfg]`, +- keep including what is not in this repo yourself, for example + `[include Macros/timelapse.cfg]` and `[include Macros/TEST_SPEED.cfg]`. + +`bd_pa_variables.cfg` (saved PA values) stays where it is, in the config +folder; it is data, not part of the repo. + +## Adapting to another printer + +These are the settings of one printer. Before using a file, check: + +- **Names:** `fila_width_0` (bdwidth), `bd_pa` (bd_pressure), `sb_leds`, + `caselight`, `BedFans`, `iHeater_H`, lane names `lane1..lane5`. +- **Pins and serial IDs:** `motion.cfg` (`PC0`), `iHeater.cfg` (serial ID). +- **Positions:** the bed centre `X175 Y175` (350 mm bed) in `HOME` + (`afc_macros.cfg`) and in `PRINT_WARMUP`/`PRINT_START` (`macros.cfg`). + `AFC_XY_SAFETY_CHECK` uses the axis limits from your config. +- **Settings at the top of a file** (`variable_...`): `purge_check.cfg` + (`mode: "enforce"`, `motion_check: False` because this printer's bdwidth + motion reading is unreliable), `bd_pa_afc_spoolman.cfg` (`spoolman_url`), + `bedfans.cfg`, `drying.cfg`. +- `xy_drift.py` is for CoreXY only. + +Comment out files you do not need in `klipper-macros.cfg`. + +## Uninstall + +```bash +~/klipper-macros/install.sh --uninstall +``` + +Then remove the include from `printer.cfg` and the +`[update_manager klipper-macros]` section from `moonraker.conf`, and restart +the Klipper service. + +## Credits + +Some files started from other projects and were modified here; their +original licenses apply to those parts: + +| File | Based on | +|---|---| +| `stealthburner_led_effects_barf.cfg` | [tanaes/whopping_Voron_mods](https://github.com/tanaes/whopping_Voron_mods) (Rainbow Barf logo LEDs) | +| `bedfans.cfg` | Ellis' bed fan macros ([ellis3dp.com](https://ellis3dp.com/Print-Tuning-Guide/)) | +| `heatsoak.cfg` | community `HEAT_SOAK` macro | +| `iHeater.cfg` | iHeater vendor configuration | +| `drying.cfg` | community filament drying macro | +| `macros_klipperscreen.cfg` | [KlipperScreen recommended macros](https://klipperscreen.readthedocs.io/en/latest/macros/#recommended) | +| `scripts/` | [Voron docs: automating MCU updates](https://docs.vorondesign.com/community/howto/drachenkatze/automating_klipper_mcu_updates.html), [Esoterical's CANBus guide](https://canbus.esoterical.online/) | + +Uses: [Klipper](https://github.com/Klipper3d/klipper), +[AFC-Klipper-Add-On](https://github.com/ArmoredTurtle/AFC-Klipper-Add-On), +[Blobifier](https://github.com/Dendrowen/Blobifier), +[bd_pressure](https://github.com/markniu/bd_pressure), +[bdwidth](https://github.com/markniu/bdwidth), +[klipper-led_effect](https://github.com/julianschill/klipper-led_effect), +[soak-my-bed](https://github.com/marcofailli/soak-my-bed), +[Spoolman](https://github.com/Donkie/Spoolman). diff --git a/extras/bdpressure_check.py b/extras/bdpressure_check.py new file mode 100644 index 0000000..ca5d350 --- /dev/null +++ b/extras/bdpressure_check.py @@ -0,0 +1,107 @@ +# Load check using the bd_pressure firmware extension (bdp_check_ext.patch). +# +# Uses the I2C connection the existing [bdpressure ] section already +# set up. Before writing anything it reads the extension marker (bytes +# 54-55 = 0xBD 0x01). On stock firmware the marker is missing and every +# command only reports "not available": nothing is written, because +# writing past the stock register table would land in the sensor's RAM. +# +# Install: copy to ~/klipper/klippy/extras/bdpressure_check.py +# +# Config: +# [bdpressure_check] +# sensor: bd_pa # name after "bdpressure" in [bdpressure bd_pa] +# +# Commands (run them after M400, they act immediately, not in the move queue): +# BDP_CHECK_START freeze the baseline and start measuring +# BDP_CHECK_STOP stop measuring and read the result +# BDP_CHECK_QUERY read the current values without stopping +# +# Status (printer.bdpressure_check), updated by the commands above: +# available, running, peak, above_ms, longest_ms, delta + +REG_EXT = 54 # first byte of the extension +REG_CTRL = 56 +EXT_LEN = 12 # bytes 54-65 +EXT_MAGIC = (0xBD, 0x01) + + +class BDPressureCheck: + def __init__(self, config): + self.printer = config.get_printer() + self.sensor_name = 'bdpressure ' + config.get('sensor', 'bd_pa') + self.i2c = None + self.status = {'available': False, 'running': False, 'peak': 0, + 'above_ms': 0, 'longest_ms': 0, 'delta': 0} + self.printer.register_event_handler('klippy:connect', + self._handle_connect) + gcode = self.printer.lookup_object('gcode') + gcode.register_command('BDP_CHECK_START', self.cmd_START, + desc="Start a bd_pressure load check") + gcode.register_command('BDP_CHECK_STOP', self.cmd_STOP, + desc="Stop the bd_pressure load check and read it") + gcode.register_command('BDP_CHECK_QUERY', self.cmd_QUERY, + desc="Read the bd_pressure load check values") + + def _handle_connect(self): + sensor = self.printer.lookup_object(self.sensor_name) + self.i2c = getattr(sensor, 'i2c', None) + + def _read(self): + if self.i2c is None: + self.status['available'] = False + return False + data = bytearray(self.i2c.i2c_read([REG_EXT], EXT_LEN)['response']) + if len(data) < EXT_LEN or tuple(data[0:2]) != EXT_MAGIC: + self.status['available'] = False + return False + u16 = lambda i: data[i] | (data[i + 1] << 8) + delta = u16(10) + self.status.update({ + 'available': True, + 'running': bool(data[3] & 1), + 'peak': u16(4), + 'above_ms': u16(6), + 'longest_ms': u16(8), + 'delta': delta - 0x10000 if delta & 0x8000 else delta, + }) + return True + + def _respond(self, gcmd, prefix): + s = self.status + if not s['available']: + gcmd.respond_info("%s: bd_pressure check extension not found " + "(stock firmware?), nothing done" % prefix) + return + gcmd.respond_info("%s: running=%d peak=%d above=%d ms longest=%d ms " + "delta=%d" % (prefix, s['running'], s['peak'], + s['above_ms'], s['longest_ms'], + s['delta'])) + + def cmd_START(self, gcmd): + # The values read here are still the previous check's, so only + # confirm the start instead of printing them. + if self._read(): + self.i2c.i2c_write([REG_CTRL, 1]) + self.status.update({'running': True, 'peak': 0, 'above_ms': 0, + 'longest_ms': 0}) + gcmd.respond_info("BDP_CHECK_START: started") + return + self._respond(gcmd, "BDP_CHECK_START") + + def cmd_STOP(self, gcmd): + if self._read(): + self.i2c.i2c_write([REG_CTRL, 0]) + self._read() + self._respond(gcmd, "BDP_CHECK_STOP") + + def cmd_QUERY(self, gcmd): + self._read() + self._respond(gcmd, "BDP_CHECK_QUERY") + + def get_status(self, eventtime): + return dict(self.status) + + +def load_config(config): + return BDPressureCheck(config) diff --git a/extras/bdwidth_status.py b/extras/bdwidth_status.py new file mode 100644 index 0000000..d52263c --- /dev/null +++ b/extras/bdwidth_status.py @@ -0,0 +1,71 @@ +# Expose bdwidth's motion counter and width reading to gcode macros. +# +# bdwidth.py replaces its own get_status() with the runout helper's, so +# macros only see filament_detected/enabled. This read-only wrapper looks +# up the existing [bdwidth ] object and reports the values it +# already keeps. It never talks to the sensor, so bdwidth.py stays +# untouched and updates to bdwidth do not overwrite this file. +# +# Install: copy to ~/klipper/klippy/extras/bdwidth_status.py +# +# Config: +# [bdwidth_status] +# sensor: fila_width_0 # name after "bdwidth" in [bdwidth fila_width_0] +# +# Status (printer.bdwidth_status): +# total_move - motion counter since Klipper start (raw counts, signed) +# last_motion - counts seen in the last sample +# diameter - last width reading in mm (0.0 on a bad reading) +# name - the sensor name, for SET_BDWIDTH NAME=... +# reading - True while bdwidth is polling the sensor (after +# SET_BDWIDTH ... ENABLE..., until DISABLE) +# enabled - runout response on (SET_FILAMENT_SENSOR ENABLE=1/0); +# says nothing about whether bdwidth is reading +# active - "motion", "width", "all" or "disable" (SET_BDWIDTH). After +# a Klipper restart it shows the config value, but bdwidth +# only starts reading on the first SET_BDWIDTH ... ENABLE +# linear_motion - counts per mm (motion_linear_coefficient) +# sample_time - seconds between sensor reads + + +class BDWidthStatus: + def __init__(self, config): + self.printer = config.get_printer() + self.name = config.get('sensor', 'fila_width_0') + self.sensor_name = 'bdwidth ' + self.name + self.sensor = None + self.printer.register_event_handler('klippy:connect', + self._handle_connect) + + def _handle_connect(self): + self.sensor = self.printer.lookup_object(self.sensor_name) + + def get_status(self, eventtime): + s = self.sensor + # bdwidth's own get_status() is the runout helper's, which carries + # the SET_FILAMENT_SENSOR enable flag + try: + enabled = bool(s.get_status(eventtime).get('enabled', True)) + except Exception: + enabled = True + # bdwidth reads from a reactor timer that only SET_BDWIDTH ... ENABLE + # starts and DISABLE stops; is_active alone does not show it + timer = getattr(s, 'extrude_factor_update_timer', None) + never = self.printer.get_reactor().NEVER + reading = (timer is not None and getattr(timer, 'waketime', never) != never + and 'disable' not in str(getattr(s, 'is_active', 'disable'))) + return { + 'name': self.name, + 'reading': reading, + 'enabled': enabled, + 'total_move': getattr(s, 'actual_total_move', 0), + 'last_motion': getattr(s, 'lastMotionReading', 0), + 'diameter': round(getattr(s, 'lastFilamentWidthReading', 0.), 3), + 'active': str(getattr(s, 'is_active', 'disable')), + 'linear_motion': getattr(s, 'linear_motion', 42.8), + 'sample_time': getattr(s, 'sample_time', 1.), + } + + +def load_config(config): + return BDWidthStatus(config) diff --git a/extras/xy_drift.py b/extras/xy_drift.py new file mode 100644 index 0000000..981653b --- /dev/null +++ b/extras/xy_drift.py @@ -0,0 +1,104 @@ +# Measure lost XY steps on a CoreXY printer by re-homing. +# +# Homing resets Klipper's idea of the position, so the toolhead position +# after G28 is always the endstop position and cannot show lost steps. The +# stepper drivers' step counters (MCU positions) are not reset by homing: +# when no steps were lost, the endstop triggers at the same step count as +# last time; every lost step shows up as a difference. This module records +# the step counts at each X and Y endstop trigger and reports the drift in +# mm against a stored reference. +# +# Install: copy to ~/klipper/klippy/extras/xy_drift.py and add [xy_drift] +# +# Commands: +# XY_DRIFT_STORE use the last X and Y homing as the reference +# XY_DRIFT_QUERY print the drift of the last homing against the reference +# +# Status (printer.xy_drift): +# ref_set a reference is stored (cleared by a Klipper restart, +# which also resets the step counters) +# homed_x, homed_y the axis was homed since the reference was stored +# x, y drift in mm of the last homing against the reference +# (positive = Klipper had moved further than the head did) + +import logging + + +class XYDrift: + def __init__(self, config): + self.printer = config.get_printer() + self.sx = self.sy = None + self.last = {} # axis -> kinematic position (mm) at the trigger + self.ref = {} + self.since_ref = set() + self.printer.register_event_handler('klippy:connect', + self._handle_connect) + self.printer.register_event_handler('homing:home_rails_end', + self._handle_home_rails_end) + gcode = self.printer.lookup_object('gcode') + gcode.register_command('XY_DRIFT_STORE', self.cmd_STORE, + desc="Store the last XY homing as drift reference") + gcode.register_command('XY_DRIFT_QUERY', self.cmd_QUERY, + desc="Report XY drift against the reference") + + def _handle_connect(self): + kin = self.printer.lookup_object('toolhead').get_kinematics() + steppers = {s.get_name(): s for s in kin.get_steppers()} + self.sx = steppers.get('stepper_x') + self.sy = steppers.get('stepper_y') + if self.sx is None or self.sy is None: + raise self.printer.config_error( + "xy_drift needs stepper_x and stepper_y (CoreXY)") + + def _handle_home_rails_end(self, homing_state, rails): + # This event also comes from other modules (Beacon sends it with no + # rails and a state that raises on get_trigger_position), so only + # handle X/Y homing and never let an error escape: an exception + # here shuts Klipper down. + try: + names = [rail.get_name() for rail in rails] + axes = [axis for axis, st in (('x', self.sx), ('y', self.sy)) + if st is not None and st.get_name() in names] + if not axes: + return + a = (homing_state.get_trigger_position(self.sx.get_name()) + * self.sx.get_step_dist()) + b = (homing_state.get_trigger_position(self.sy.get_name()) + * self.sy.get_step_dist()) + except Exception: + logging.exception("xy_drift: could not read trigger positions") + return + # CoreXY: x = (a + b) / 2, y = (a - b) / 2 + pos = {'x': 0.5 * (a + b), 'y': 0.5 * (a - b)} + for axis in axes: + self.last[axis] = pos[axis] + self.since_ref.add(axis) + + def _drift(self, axis): + if axis in self.ref and axis in self.last: + return round(self.last[axis] - self.ref[axis], 4) + return 0. + + def cmd_STORE(self, gcmd): + if 'x' not in self.last or 'y' not in self.last: + raise gcmd.error("XY_DRIFT_STORE: home X and Y first") + self.ref = dict(self.last) + self.since_ref = set() + gcmd.respond_info("XY_DRIFT: reference stored") + + def cmd_QUERY(self, gcmd): + if not self.ref: + gcmd.respond_info("XY_DRIFT: no reference stored") + return + gcmd.respond_info("XY_DRIFT: X=%.3f mm Y=%.3f mm" % ( + self._drift('x'), self._drift('y'))) + + def get_status(self, eventtime): + return {'ref_set': bool(self.ref), + 'homed_x': 'x' in self.since_ref, + 'homed_y': 'y' in self.since_ref, + 'x': self._drift('x'), 'y': self._drift('y')} + + +def load_config(config): + return XYDrift(config) diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8650dca --- /dev/null +++ b/install.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Install klipper-macros on a Klipper host (Raspberry Pi etc.) +# +# cd ~ && git clone https://mangavalk.com/mangavalk/klipper-macros.git +# ~/klipper-macros/install.sh +# +# What it does (safe to run again, Moonraker runs it after every update): +# 1. links extras/*.py into ~/klipper/klippy/extras/ +# 2. links macros/ to ~/printer_data/config/klipper-macros +# 3. makes the shell scripts executable +# 4. adds an [update_manager klipper-macros] section to moonraker.conf +# (once, with a backup of moonraker.conf) +# +# It does not edit printer.cfg; see README.md for the include line. +# +# ~/klipper-macros/install.sh --uninstall remove the links again +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +KLIPPER_DIR="${KLIPPER_DIR:-$HOME/klipper}" +CONFIG_DIR="${CONFIG_DIR:-$HOME/printer_data/config}" +MOONRAKER_CONF="$CONFIG_DIR/moonraker.conf" +LINK_NAME="$CONFIG_DIR/klipper-macros" +EXTRAS_DIR="$KLIPPER_DIR/klippy/extras" +ORIGIN="$(git -C "$REPO_DIR" remote get-url origin 2>/dev/null || echo "https://mangavalk.com/mangavalk/klipper-macros.git")" + +die() { echo "ERROR: $*" >&2; exit 1; } + +[[ -d $EXTRAS_DIR ]] || die "Klipper not found at $KLIPPER_DIR (set KLIPPER_DIR=...)" +[[ -d $CONFIG_DIR ]] || die "config folder not found at $CONFIG_DIR (set CONFIG_DIR=...)" + +if [[ ${1:-} == --uninstall ]]; then + for py in "$REPO_DIR"/extras/*.py; do + target="$EXTRAS_DIR/$(basename "$py")" + if [[ -L $target && $(readlink -f "$target") == "$(readlink -f "$py")" ]]; then + rm "$target" && echo "removed $target" + fi + done + [[ -L $LINK_NAME ]] && rm "$LINK_NAME" && echo "removed $LINK_NAME" + echo "Remove [include klipper-macros/...] from printer.cfg and the" + echo "[update_manager klipper-macros] section from moonraker.conf yourself," + echo "then restart the Klipper service." + exit 0 +fi + +# 1. Klipper modules +for py in "$REPO_DIR"/extras/*.py; do + target="$EXTRAS_DIR/$(basename "$py")" + if [[ -e $target && ! -L $target ]]; then + mv "$target" "$target.bak" && echo "existing $target moved to $target.bak" + fi + ln -sfn "$py" "$target" + echo "linked $target" +done +# Klipper's update manager would report the links as untracked files +exclude="$KLIPPER_DIR/.git/info/exclude" +if [[ -f $exclude ]]; then + for py in "$REPO_DIR"/extras/*.py; do + line="klippy/extras/$(basename "$py")" + grep -qxF "$line" "$exclude" || echo "$line" >> "$exclude" + done +fi + +# 2. Macros +if [[ -e $LINK_NAME && ! -L $LINK_NAME ]]; then + die "$LINK_NAME exists and is not a link; move it away first" +fi +ln -sfn "$REPO_DIR/macros" "$LINK_NAME" +echo "linked $LINK_NAME" + +# 3. Scripts +chmod +x "$REPO_DIR"/macros/*.sh "$REPO_DIR"/scripts/*.sh "$REPO_DIR"/install.sh + +# 4. Moonraker update manager +if [[ -f $MOONRAKER_CONF ]] && ! grep -q '^\[update_manager klipper-macros\]' "$MOONRAKER_CONF"; then + cp "$MOONRAKER_CONF" "$MOONRAKER_CONF.bak.$(date +%Y%m%d_%H%M%S)" + cat >> "$MOONRAKER_CONF" < lim or d.y|abs > lim) %} + {% if not d.ref_set %} + RESPOND PREFIX="AFC" MSG="XY check: no reference yet (Klipper restarted?), storing one now" + {% else %} + RESPOND PREFIX="AFC" MSG="XY drift since last check: X={'%.3f' % d.x} Y={'%.3f' % d.y} mm (limit {lim})" + {% endif %} + # New reference from this homing, so the next check reports only new drift + XY_DRIFT_STORE + {% if not drift %} + {% if d.ref_set %} + RESPOND PREFIX="AFC" MSG="Position OK" + {% endif %} + G1 X{th.axis_maximum.x - 10} Y{th.axis_maximum.y - 10} F5000 + {% elif cfg.soft_fail|int == 1 %} + RESPOND TYPE=error MSG="AFC: XY drift detected! X={'%.3f' % d.x} Y={'%.3f' % d.y} mm. Axes are re-homed, print PAUSED. Run AFC_XY_RESUME to continue, or CANCEL_PRINT." + M117 AFC: XY drift! See console. Paused. + PAUSE + {% else %} + RESPOND TYPE=error MSG="AFC: XY drift X={'%.3f' % d.x} Y={'%.3f' % d.y} mm -> M112" + M112 + {% endif %} + +[gcode_macro AFC_XY_RESUME] +description: Acknowledge XY drift warning and resume print +gcode: + RESPOND PREFIX="AFC" MSG="User acknowledged drift, resuming." + # Axes were re-homed by the check, so AFC's resume returns to the + # correct print position. + RESUME + +##################################################################### +## unloading help +##################################################################### + +[gcode_macro LANE_FEED_UNTIL_HUB_OPEN] +variable_lane: 1 +variable_step: -50 +variable_max_dist: 2000 +variable_moved: 0 +variable_active: 0 + +gcode: + {% set lane = params.LANE|default(1)|int %} + {% set step = params.STEP|default(-50)|float %} + {% set max_dist = params.MAX_DIST|default(2000)|float %} + + SET_GCODE_VARIABLE MACRO=LANE_FEED_UNTIL_HUB_OPEN VARIABLE=lane VALUE={lane} + SET_GCODE_VARIABLE MACRO=LANE_FEED_UNTIL_HUB_OPEN VARIABLE=step VALUE={step} + SET_GCODE_VARIABLE MACRO=LANE_FEED_UNTIL_HUB_OPEN VARIABLE=max_dist VALUE={max_dist} + SET_GCODE_VARIABLE MACRO=LANE_FEED_UNTIL_HUB_OPEN VARIABLE=moved VALUE=0 + SET_GCODE_VARIABLE MACRO=LANE_FEED_UNTIL_HUB_OPEN VARIABLE=active VALUE=1 + + RESPOND PREFIX="info" MSG="START lane={lane} step={step} max={max_dist}" + + UPDATE_DELAYED_GCODE ID=_lane_feed_loop DURATION=0.05 + +[delayed_gcode _lane_feed_loop] +gcode: + {% set m = printer["gcode_macro LANE_FEED_UNTIL_HUB_OPEN"] %} + + {% if m.active == 1 %} + + LANE_MOVE LANE=lane{m.lane} DISTANCE={m.step} + + {% set new_moved = m.moved + m.step %} + SET_GCODE_VARIABLE MACRO=LANE_FEED_UNTIL_HUB_OPEN VARIABLE=moved VALUE={new_moved} + + RESPOND PREFIX="info" MSG="MOVE lane={m.lane} step={m.step} moved={new_moved}" + + QUERY_ENDSTOPS + + UPDATE_DELAYED_GCODE ID=_lane_eval DURATION=0.03 + + {% else %} + RESPOND PREFIX="info" MSG="feed_loop inactive" + {% endif %} + +[delayed_gcode _lane_eval] +gcode: + {% set m = printer["gcode_macro LANE_FEED_UNTIL_HUB_OPEN"] %} + + {% if m.active == 1 %} + + {% set hub = printer.query_endstops.last_query.get("hub", "unknown") %} + + # RESPOND PREFIX="info" MSG="QUERY hub={hub} raw={printer.query_endstops.last_query.get('hub', 'MISSING')}" + # RESPOND PREFIX="info" MSG="last_query: {printer.query_endstops.last_query}" + + {% if hub == "open" or hub == 0 %} + RESPOND PREFIX="info" MSG="HUB TRIGGERED → STOP" + + SET_GCODE_VARIABLE MACRO=LANE_FEED_UNTIL_HUB_OPEN VARIABLE=active VALUE=0 + + LANE_MOVE LANE=lane{m.lane} DISTANCE=-100 + + {% elif m.moved|abs >= m.max_dist %} + + RESPOND PREFIX="warning" MSG="MAX DIST STOP reached={m.moved}" + + SET_GCODE_VARIABLE MACRO=LANE_FEED_UNTIL_HUB_OPEN VARIABLE=active VALUE=0 + + {% else %} + + RESPOND PREFIX="info" MSG="CONTINUE" + + UPDATE_DELAYED_GCODE ID=_lane_feed_loop DURATION=0.03 + + {% endif %} + + {% else %} + RESPOND PREFIX="info" MSG="eval inactive" + {% endif %} + +[gcode_macro LANE_FEED_ABORT] +gcode: + SET_GCODE_VARIABLE MACRO=LANE_FEED_UNTIL_HUB_OPEN VARIABLE=active VALUE=0 + UPDATE_DELAYED_GCODE ID=_lane_feed_loop DURATION=0 + UPDATE_DELAYED_GCODE ID=_lane_eval DURATION=0 + RESPOND PREFIX="warning" MSG="ABORTED" + +[gcode_macro HOME] +gcode: + G90 + G4 P2000 + G28 X0 Y0 Z0 + G1 X175 Y175 Z10 F1200 + +[gcode_macro SET_ACTIVE_SPOOL] +gcode: + {% if params.ID %} + {% set id = params.ID|int %} + {action_call_remote_method( + "spoolman_set_active_spool", + spool_id=id + )} + {% else %} + {action_respond_info("Parameter 'ID' is required")} + {% endif %} + +[gcode_macro CLEAR_ACTIVE_SPOOL] +gcode: + {action_call_remote_method( + "spoolman_set_active_spool", + spool_id=None + )} + +# [gcode_macro TEST_AMS_STRESS] +# gcode: +# {% for i in range(500) %} +# T0 ; Load tool 0 +# G4 P5000 ; Wait 5s +# T1 ; Unload +# G4 P3000 ; Wait 3s +# {% endfor %} \ No newline at end of file diff --git a/macros/bd_pa_afc_spoolman.cfg b/macros/bd_pa_afc_spoolman.cfg new file mode 100644 index 0000000..efb0973 --- /dev/null +++ b/macros/bd_pa_afc_spoolman.cfg @@ -0,0 +1,654 @@ +######################################################################## +# BD Pressure Advance x AFC BoxTurtle x Spoolman Integration +# --------------------------------------------------------------- +# Built and verified using ONLY documented/source-confirmed APIs: +# +# bd_pressure (https://github.com/markniu/bd_pressure): +# PA_CALIBRATE NOZZLE_TEMP= MAX_VOLUMETRIC= ACC_WALL= +# TRAVEL_SPEED= ACC_TO_DECEL_FACTOR= +# - filament must already be loaded into the nozzle before calling +# - the command itself homes, heats, calibrates, and calls +# SET_PRESSURE_ADVANCE -- no separate heating step is needed +# Source: https://pandapi3d.cn/bdpressure/home +# +# AFC-Klipper-Add-On (https://github.com/ArmoredTurtle/AFC-Klipper-Add-On): +# printer.AFC.current_load -- a live status field (confirmed in +# klippy/extras/AFC.py get_status(): str['current_load'] = self.current) +# that always reflects whichever lane is currently loaded in the +# toolhead, regardless of how it got there (T0-T4, CHANGE_TOOL, +# BT_CHANGE_TOOL, or AFC's own infinite-spool runout remapping). +# Confirmed working live on this printer via a test macro. +# SET_SPOOL_ID LANE=.. SPOOL_ID=.. -- assigns a spool, AFC then pulls +# material/color/weight/temps from Spoolman. +# Source: https://www.armoredturtle.xyz/docs/afc-klipper-add-on/ +# and confirmed directly against installed AFC source. +# +# Klipper core (https://www.klipper3d.org/Command_Templates.html): +# [save_variables] + SAVE_VARIABLE / printer.save_variables.variables +# -- persists the PA cache and lane->spool_id tracking across +# restarts. No shell_command/curl/Moonraker REST calls needed. +# [delayed_gcode] + UPDATE_DELAYED_GCODE -- used to poll +# printer.AFC.current_load every 2 seconds and detect tool changes +# (see "WHY POLLING" below for why this is the mechanism, instead +# of intercepting a tool-change command directly). +# +# WHY POLLING INSTEAD OF INTERCEPTING A TOOL-CHANGE COMMAND +# --------------------------------------------------------------------- +# This was not the first design tried. Three other approaches were +# attempted and ruled out, each one verified directly against this +# printer's installed AFC Python source (not just AFC's docs, which +# turned out to describe behavior that didn't match the installed +# version in two separate cases): +# +# 1. AFC_POST_PREP (an AFC-documented "fires after every lane load" +# macro hook): verified against source (AFC_lane.py) that this is +# tied to a lane's prep+load SENSOR transition (filament freshly +# inserted into the physical lane) and to an unset-by-default +# `post_prep_macro` config option -- not to a tool change moving +# filament into the toolhead. An empirical test on a real tool +# change confirmed it never fires for that case. +# +# 2. Wrapping CHANGE_TOOL with rename_existing: CHANGE_TOOL the gcode +# command is real (registered via register_command in AFC.py) and +# the wrapper DOES work when CHANGE_TOOL is called by that exact +# name -- confirmed working live. But T0-T4 (what slicers and +# normal use actually send) do NOT route through it: verified in +# AFC_functions.py that T(n) commands are registered with +# `self.afc.gcode.register_command(cur_lane.map, self.afc.cmd_CHANGE_TOOL, ...)` +# -- a direct reference to the original Python method captured at +# registration time. Renaming the "CHANGE_TOOL" dispatcher key has +# zero effect on these separately-registered "T0".."T4" keys. +# Confirmed empirically: CHANGE_TOOL LANE=lane1 typed directly +# applied cached PA correctly; T1 typed directly did not. +# +# 3. Wrapping T0-T4 directly with rename_existing: ruled out for two +# reasons. First, T(n) commands are registered dynamically at PREP +# time (a runtime event), not at config-load time, so +# rename_existing (config-load-time only) can't reliably target +# them. Second, even if that were solved, AFC's SET_MAP / +# RESET_AFC_MAPPING / infinite-spool-runout features rely on which +# lane a given T(n) points to being mutable at runtime -- so +# statically pinning T0-T4 via `map:` in lane config would fight +# that real, separate AFC feature. +# +# Given all of that, polling printer.AFC.current_load (a plain status +# read with no dependency on which command triggered the change) is the +# most robust mechanism available: it works identically regardless of +# whether a tool change was triggered by T0-T4, CHANGE_TOOL, +# BT_CHANGE_TOOL, or AFC's internal runout remapping, and needs no +# load-order considerations at all. The cost is a small polling delay +# (up to ~2 seconds) between a tool change finishing and PA being +# applied, instead of it happening in the same instant. +# +# OTHER PAST MISTAKES, FOR REFERENCE +# --------------------------------------------------------------------- +# - "BD_SENSOR_CALIBRATE_PA" is not a real command. Real: PA_CALIBRATE, +# with required parameters (see above). +# - "printer.afc.lanes[lane].spool_id" is not a real, readable Klipper +# status path. AFC's per-lane spool data partly lives in Moonraker's +# database (namespace "lane_data"), which a Klipper gcode_macro +# cannot reach directly (no HTTP from Jinja2 templates). This file +# tracks lane->spool_id itself instead (see _PA_TRACK_LANE_SPOOL). +# - SET_SPOOL_ID is NOT wrapped with rename_existing, because it is +# registered via register_command (confirmed against installed AFC +# source) and wrapping it failed with "Existing command 'SET_SPOOL_ID' +# not found in gcode_macro rename" when this file's [include] line +# in printer.cfg appeared BEFORE AFC's own includes. The +# companion-macro approach (_PA_TRACK_LANE_SPOOL, called manually +# right after SET_SPOOL_ID) has no such load-order dependency, at +# the small cost of one extra line each time you assign a spool. +# - Calibration nozzle temp comes from printer.extruder.target (a +# documented Klipper status field) at calibration time. AFC already +# sets this from Spoolman's nozzle_temp field when a lane is loaded +# (AFC's own documented "Setting extruder temp" feature), so this +# answers "can the calibration temp come from Spoolman" with a real +# yes, via a path that needs no new plumbing. +# +# KNOWN LIMITATIONS / THINGS TO VERIFY ON YOUR PRINTER +# --------------------------------------------------------------------- +# 1) You must remember to call _PA_TRACK_LANE_SPOOL LANE=.. SPOOL_ID=.. +# every time you assign a spool to a lane (right after SET_SPOOL_ID, +# or after assigning via the Mainsail/Fluidd spool UI). If you skip +# this, LANE_CALIBRATE_PA and the auto-apply poller will report +# "no spool_id recorded yet" for that lane. PA_STATUS shows you which +# lanes are missing this. +# +# 2) PA is applied up to ~2 seconds after a tool change completes, not +# instantly, because of the polling interval. If you need it tighter, +# lower the DURATION values in _PA_POLL_LANE_CHANGE below (the +# initial_duration and both UPDATE_DELAYED_GCODE DURATION values), +# at the cost of slightly more frequent background checks. +# +# 3) If printer.AFC ever doesn't exist (AFC failed to load, or a future +# AFC version renames its status object), _PA_POLL_LANE_CHANGE will +# print one warning message and then go silent -- it will NOT spam +# the console, and it will NOT apply PA automatically until this is +# resolved. PA_STATUS and manual LANE_CALIBRATE_PA calls still work +# fine in that case; only the automatic apply-on-tool-change part is +# affected. + +# REQUIRED SETUP +# --------------------------------------------------------------------- +# 1) [save_variables] is declared below in this same file (see the +# [save_variables] section right after this header) -- you do NOT +# need to add it to printer.cfg separately. +# IMPORTANT: if you already have a [save_variables] section +# somewhere else in your config (printer.cfg or another include), +# DELETE the one below -- Klipper will error on a duplicate section +# if it appears twice anywhere in your merged config. Only one +# [save_variables] section is allowed printer-wide, but it stores +# ALL variables from ALL macros in one shared file, so having it +# declared once, here, works fine for everything in your setup. +# +# 2) Make sure moonraker.conf has [spoolman] configured so AFC can pull +# nozzle_temp/material from Spoolman when a lane is loaded: +# [spoolman] +# server: http://:7912 +# sync_rate: 5 +# +# 3) Add to printer.cfg: +# [include bd_pa_afc_spoolman.cfg] +# (Include order relative to AFC no longer matters for this version +# -- the polling approach has no load-order dependency, unlike the +# CHANGE_TOOL/SET_SPOOL_ID wrapper attempts described above.) +# +# 4) Whenever you assign a spool to a lane -- via SET_SPOOL_ID, or via +# the Mainsail/Fluidd spool-assignment UI -- also call +# _PA_TRACK_LANE_SPOOL with the same lane and spool_id, e.g.: +# SET_SPOOL_ID LANE=lane1 SPOOL_ID=12345 +# _PA_TRACK_LANE_SPOOL LANE=lane1 SPOOL_ID=12345 +# This is what lets the PA cache know which spool_id is in which +# lane (see KNOWN LIMITATIONS #1 above for why this is manual). +# +# USAGE +# --------------------------------------------------------------------- +# After assigning spools to lanes (step 4 above), in PRINT_START, +# after your PREP call, run: +# PA_CALIBRATE_ALL_LANES LANES="lane1,lane2,lane3,lane4,lane5" +# This calibrates (or restores from cache) PA for every lane listed +# that currently has a spool_id recorded. +# +# Per-lane PA is then applied automatically within ~2 seconds of every +# tool change, via the _PA_POLL_LANE_CHANGE background poller -- no +# action needed on your part once it's running. +# +# To force a re-calibration of one lane: +# LANE_RECAL_PA LANE=lane2 +# +# To see what's cached, and which lanes are missing a tracked +# spool_id: +# PA_STATUS +######################################################################## + +# --------------------------------------------------------------------------- +# Persistent variable storage -- required by SAVE_VARIABLE/printer.save_variables +# used throughout this file for the PA cache and lane->spool_id tracking. +# DELETE THIS SECTION if you already have a [save_variables] section +# defined anywhere else in your config (only one is allowed printer-wide). +# --------------------------------------------------------------------------- +[save_variables] +filename: ~/printer_data/config/bd_pa_variables.cfg +# The file above does not need to exist yet -- Klipper creates it on +# first SAVE_VARIABLE call and reloads it on every restart. + + + +# --------------------------------------------------------------------------- +# USER CONFIGURATION -- edit these to match your printer +# --------------------------------------------------------------------------- +[gcode_macro _PA_CFG] +description: Central config -- edit values here +variable_lane_names: "lane1,lane2,lane3,lane4,lane5" +# Fallback nozzle temp (C) used ONLY if the extruder's target temp reads 0 +# at calibration time (e.g. you called this macro before AFC set a temp) +variable_fallback_nozzle_temp: 260 +# bd_pressure PA_CALIBRATE parameters -- tune to your printer/filament. +# See https://pandapi3d.cn/bdpressure/home for guidance on these values. +variable_max_volumetric: 25 +variable_acc_wall: 5000 +variable_travel_speed: 300 +variable_acc_to_decel_factor: 50 +# Minimum PA delta before we bother re-saving the cached value +variable_write_threshold: 0.002 +# Spoolman URL and the custom "extra" field name used to push calibrated +# PA values back to the spool record (so the value travels with the +# spool, not just with this printer's local save_variables cache). +# Create the field once with: _PA_SETUP_SPOOLMAN_FIELD (see below). +variable_spoolman_url: "http://localhost:7912" +variable_spoolman_field: "pa_value" +gcode: # intentionally empty -- variables only + + +# --------------------------------------------------------------------------- +# Spoolman write-back: push a calibrated PA value to a spool's "extra" +# field, so the value travels with the spool (e.g. if you move it to a +# different printer, or reinstall save_variables from scratch). +# +# Requires the custom field to exist once -- run _PA_SETUP_SPOOLMAN_FIELD +# (below) a single time, or create it manually: +# curl -X POST http://:7912/api/v1/field/spool/pa_value -H "Content-Type: application/json" -d '{"name": "Pressure Advance", "field_type": "float", "default_value": "0"}' +# +# UNVERIFIED -- needs live testing the same way everything else in this +# file has needed it. In particular: confirm curl and python3 are both +# available in the shell shell_command runs in (they're standard on +# MainsailOS/FluiddOS, but worth confirming), and confirm the merge-write +# below doesn't clobber any other custom Spoolman fields you may have. +# --------------------------------------------------------------------------- +[gcode_shell_command _pa_write_spoolman] +# HISTORICAL NOTE ON ARGUMENT OFFSET (no longer applicable here, kept +# for context): an earlier version of this command ran the script +# inline via `bash -c '