diff --git a/README.md b/README.md index f8e8c9590c5b..851fd6b667d7 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,10 @@ Klipper software is Free Software. See the [license](COPYING) or read the [documentation](https://www.klipper3d.org/Overview.html). We depend on the generous support from our [sponsors](https://www.klipper3d.org/Sponsors.html). + + + +This fork of the Klipper firmware project includes the following software: + +- [Happy-Hare](https://github.com/moggieuk/Happy-Hare) : Universal MMU for Klipper. +- [Beacon Module](https://beacon3d.com): Eddy current surface scanner diff --git a/klippy/extras/beacon.py b/klippy/extras/beacon.py new file mode 100644 index 000000000000..6e9912cf3124 --- /dev/null +++ b/klippy/extras/beacon.py @@ -0,0 +1,3944 @@ +# Beacon eddy current scanner support +# +# Copyright (C) 2020-2023 Matt Baker +# Copyright (C) 2020-2023 Lasse Dalegaard +# Copyright (C) 2023 Beacon +# +# This file may be distributed under the terms of the GNU GPLv3 license. +import threading +import multiprocessing +import subprocess +import os +import importlib +import traceback +import logging +import chelper +import pins +import math +import time +import queue +import struct +import numpy as np +import copy +import collections +import itertools +from numpy.polynomial import Polynomial +from . import manual_probe +from . import probe +from . import bed_mesh +from . import thermistor +from . import adxl345 +from .homing import HomingMove +from mcu import MCU, MCU_trsync +from clocksync import SecondarySync +import configfile +import msgproto + +STREAM_BUFFER_LIMIT_DEFAULT = 100 +STREAM_TIMEOUT = 1.0 +API_DUMP_FIELDS = ["dist", "temp", "pos", "freq", "time"] + +TRSYNC_TIMEOUT_DEFAULT = 0.025 + + +class BeaconProbe: + def __init__(self, config, sensor_id): + self.id = sensor_id + self.printer = printer = config.get_printer() + self.reactor = printer.get_reactor() + self.name = config.get_name() + self.gcode = printer.lookup_object("gcode") + + self.speed = config.getfloat("speed", 5.0, above=0.0) + self.lift_speed = config.getfloat("lift_speed", self.speed, above=0.0) + self.backlash_comp = config.getfloat("backlash_comp", 0.5) + + self.x_offset = config.getfloat("x_offset", 0.0) + self.y_offset = config.getfloat("y_offset", 0.0) + + self.trigger_distance = config.getfloat("trigger_distance", 2.0) + self.trigger_dive_threshold = config.getfloat("trigger_dive_threshold", 1.0) + self.trigger_hysteresis = config.getfloat("trigger_hysteresis", 0.006) + self.z_settling_time = config.getint("z_settling_time", 5, minval=0) + self.default_probe_method = config.getchoice( + "default_probe_method", + {"contact": "contact", "proximity": "proximity"}, + "proximity", + ) + + # If using paper for calibration, this would be .1mm + self.cal_nozzle_z = config.getfloat("cal_nozzle_z", 0.1) + self.cal_floor = config.getfloat("cal_floor", 0.2) + self.cal_ceil = config.getfloat("cal_ceil", 5.0) + self.cal_speed = config.getfloat("cal_speed", 1.0) + self.cal_move_speed = config.getfloat("cal_move_speed", 10.0) + + self.autocal_max_speed = config.getfloat("autocal_max_speed", 10) + self.autocal_speed = config.getfloat("autocal_speed", 3) + self.autocal_accel = config.getfloat("autocal_accel", 100) + self.autocal_retract_dist = config.getfloat("autocal_retract_dist", 2) + self.autocal_retract_speed = config.getfloat("autocal_retract_speed", 10) + self.autocal_sample_count = config.getfloat("autocal_sample_count", 3) + self.autocal_tolerance = config.getfloat("autocal_tolerance", 0.008) + self.autocal_max_retries = config.getfloat("autocal_max_retries", 3) + + self.contact_latency_min = config.getint("contact_latency_min", 0) + self.contact_sensitivity = config.getint("contact_sensitivity", 0) + + self.trsync_timeout = config.getfloat( + "trsync_timeout", TRSYNC_TIMEOUT_DEFAULT, maxval=0.25 + ) + + self.skip_firmware_version_check = config.getboolean( + "skip_firmware_version_check", False + ) + + # Load models + self.model = None + self.models = {} + self.model_temp_builder = BeaconTempModelBuilder.load(config) + self.model_temp = None + self.fmin = None + self.default_model_name = config.get("default_model_name", "default") + self.model_manager = ModelManager(self) + + # Temperature sensor integration + self.last_temp = 0 + self.last_mcu_temp = None + self.measured_min = 99999999.0 + self.measured_max = 0.0 + self.mcu_temp = None + self.thermistor = None + + self.last_sample = None + self.last_received_sample = None + self.last_z_result = 0 + self.last_probe_position = (0, 0) + self.last_probe_result = None + self.last_offset_result = None + self.last_poke_result = None + self.last_contact_msg = None + self.hardware_failure = None + + self.mesh_helper = BeaconMeshHelper.create(self, config) + self.homing_helper = BeaconHomingHelper.create(self, config) + self.accel_helper = None + self.accel_config = BeaconAccelConfig(config, sensor_id) + + self._stream_en = 0 + self._stream_timeout_timer = self.reactor.register_timer(self._stream_timeout) + self._stream_callbacks = {} + self._stream_latency_requests = {} + self._stream_buffer = [] + self._stream_buffer_count = 0 + self._stream_buffer_limit = STREAM_BUFFER_LIMIT_DEFAULT + self._stream_buffer_limit_new = self._stream_buffer_limit + self._stream_samples_queue = queue.Queue() + self._stream_flush_event = threading.Event() + self._log_stream = None + self._data_filter = AlphaBetaFilter( + config.getfloat("filter_alpha", 0.5), + config.getfloat("filter_beta", 0.000001), + ) + self.trapq = None + self.mod_axis_twist_comp = None + self.get_z_compensation_value = lambda pos: 0.0 + + mainsync = printer.lookup_object("mcu")._clocksync + self._mcu = MCU(config, SecondarySync(self.reactor, mainsync)) + orig_stats = self._mcu.stats + + def beacon_mcu_stats(eventtime): + show, value = orig_stats(eventtime) + value += " " + self._extend_stats() + return show, value + + self._mcu.stats = beacon_mcu_stats + printer.add_object("mcu " + self.name, self._mcu) + self.cmd_queue = self._mcu.alloc_command_queue() + self._endstop_shared = BeaconEndstopShared(self) + self.mcu_probe = BeaconEndstopWrapper(self) + self.mcu_contact_probe = BeaconContactEndstopWrapper(self, config) + self._current_probe = "proximity" + + self.beacon_stream_cmd = None + self.beacon_set_threshold = None + self.beacon_home_cmd = None + self.beacon_stop_home_cmd = None + self.beacon_nvm_read_cmd = None + self.beacon_contact_home_cmd = None + self.beacon_contact_query_cmd = None + self.beacon_contact_stop_home_cmd = None + self.beacon_contact_set_latency_min_cmd = None + self.beacon_contact_set_sensitivity_cmd = None + + # Register z_virtual_endstop + register_as_probe = config.getboolean( + "register_as_probe", sensor_id.is_unnamed() + ) + if register_as_probe: + printer.lookup_object("pins").register_chip("probe", self) + self.printer.add_object("probe", BeaconProbeWrapper(self)) + + # Register event handlers + printer.register_event_handler("klippy:connect", self._handle_connect) + printer.register_event_handler("klippy:shutdown", self.force_stop_streaming) + self._mcu.register_config_callback(self._build_config) + self.compat_mcu_register_response( + self._handle_beacon_data, + "beacon_data samples=%c start_clock=%u delta_clock=%u data=%*s", + ) + self.compat_mcu_register_response( + self._handle_beacon_status, + "beacon_status mcu_temp=%u supply_voltage=%u coil_temp=%u status=%u", + ) + self.compat_mcu_register_response( + self._handle_beacon_contact, + "beacon_contact armed_clock=%u trigger_clock=%u detect_clock=%u latency=%c error=%c", + ) + + # Register webhooks + self._api_dump = APIDumpHelper( + printer, + lambda: self.streaming_session(self._api_dump_callback, latency=50), + lambda stream: stream.stop(), + None, + ) + sensor_id.register_endpoint("beacon/status", self._handle_req_status) + sensor_id.register_endpoint("beacon/dump", self._handle_req_dump) + + # Register gcode commands + sensor_id.register_command( + "BEACON_STREAM", self.cmd_BEACON_STREAM, desc=self.cmd_BEACON_STREAM_help + ) + sensor_id.register_command( + "BEACON_QUERY", self.cmd_BEACON_QUERY, desc=self.cmd_BEACON_QUERY_help + ) + sensor_id.register_command( + "BEACON_CALIBRATE", + self.cmd_BEACON_CALIBRATE, + desc=self.cmd_BEACON_CALIBRATE_help, + ) + sensor_id.register_command( + "BEACON_ESTIMATE_BACKLASH", + self.cmd_BEACON_ESTIMATE_BACKLASH, + desc=self.cmd_BEACON_ESTIMATE_BACKLASH_help, + ) + prefixed_probe_commands = config.getboolean("prefixed_probe_commands", False) + probe_cmd_prefix = "BEACON_" if prefixed_probe_commands else "" + sensor_id.register_command( + probe_cmd_prefix + "PROBE", self.cmd_PROBE, desc=self.cmd_PROBE_help + ) + sensor_id.register_command( + probe_cmd_prefix + "PROBE_ACCURACY", + self.cmd_PROBE_ACCURACY, + desc=self.cmd_PROBE_ACCURACY_help, + ) + sensor_id.register_command( + probe_cmd_prefix + "Z_OFFSET_APPLY_PROBE", + self.cmd_Z_OFFSET_APPLY_PROBE, + desc=self.cmd_Z_OFFSET_APPLY_PROBE_help, + ) + sensor_id.register_command( + "BEACON_POKE", self.cmd_BEACON_POKE, desc=self.cmd_BEACON_POKE_help + ) + sensor_id.register_command( + "BEACON_AUTO_CALIBRATE", + self.cmd_BEACON_AUTO_CALIBRATE, + desc=self.cmd_BEACON_AUTO_CALIBRATE_help, + ) + sensor_id.register_command( + "BEACON_OFFSET_COMPARE", + self.cmd_BEACON_OFFSET_COMPARE, + desc=self.cmd_BEACON_OFFSET_COMPARE_help, + ) + if sensor_id.is_unnamed(): + self._hook_probing_gcode(config, "z_tilt", "Z_TILT_ADJUST") + self._hook_probing_gcode(config, "quad_gantry_level", "QUAD_GANTRY_LEVEL") + self._hook_probing_gcode(config, "screws_tilt_adjust", "SCREWS_TILT_ADJUST") + self._hook_probing_gcode(config, "delta_calibrate", "DELTA_CALIBRATE") + + # Qidi Klipper compatibility + self.vibrate = 0 + + # Event handlers + + def _handle_connect(self): + self.phoming = self.printer.lookup_object("homing") + self.mod_axis_twist_comp = self.printer.lookup_object( + "axis_twist_compensation", None + ) + if self.mod_axis_twist_comp: + if hasattr(self.mod_axis_twist_comp, "get_z_compensation_value"): + self.get_z_compensation_value = lambda pos: ( + self.mod_axis_twist_comp.get_z_compensation_value(pos) + ) + elif hasattr(manual_probe, "ProbeResult"): + + def _update_compensation(pos): + cpos = [self.compat_create_probe_result(pos)] + bed_z = cpos[0].bed_z + self.mod_axis_twist_comp._update_z_compensation_value(cpos) + return cpos[0].bed_z - bed_z + + self.get_z_compensation_value = _update_compensation + else: + + def _update_compensation(pos): + cpos = list(pos) + self.mod_axis_twist_comp._update_z_compensation_value(cpos) + return cpos[2] - pos[2] + + self.get_z_compensation_value = _update_compensation + + if self.model is None: + self.model = self.models.get(self.default_model_name, None) + + def _check_mcu_version(self): + if self.skip_firmware_version_check: + return "" + updater = os.path.join(self.id.tracker.home_dir(), "update_firmware.py") + if not os.path.exists(updater): + logging.info( + "Could not find Beacon firmware update script, won't check for update." + ) + return "" + serialport = self.compat_serial_port(self._mcu) + + parent_conn, child_conn = multiprocessing.Pipe() + + def do(): + try: + output = subprocess.check_output( + [updater, "check", serialport], universal_newlines=True + ) + child_conn.send((False, output.strip())) + except Exception: + child_conn.send((True, traceback.format_exc())) + child_conn.close() + + child = multiprocessing.Process(target=do) + child.daemon = True + child.start() + eventtime = self.reactor.monotonic() + while child.is_alive(): + eventtime = self.reactor.pause(eventtime + 0.1) + is_err, result = parent_conn.recv() + child.join() + parent_conn.close() + if is_err: + logging.info("Executing Beacon update script failed: %s", result) + elif result != "": + self.gcode.respond_raw("!! " + result + "\n") + pconfig = self.printer.lookup_object("configfile") + try: + pconfig.runtime_warning(result) + except AttributeError: + logging.info(result) + return result + return "" + + def _build_config(self): + version_info = self._check_mcu_version() + + try: + self.beacon_stream_cmd = self._mcu.lookup_command( + "beacon_stream en=%u", cq=self.cmd_queue + ) + self.beacon_set_threshold = self._mcu.lookup_command( + "beacon_set_threshold trigger=%u untrigger=%u", cq=self.cmd_queue + ) + self.beacon_home_cmd = self._mcu.lookup_command( + "beacon_home trsync_oid=%c trigger_reason=%c trigger_invert=%c", + cq=self.cmd_queue, + ) + self.beacon_stop_home_cmd = self._mcu.lookup_command( + "beacon_stop_home", cq=self.cmd_queue + ) + self.beacon_nvm_read_cmd = self._mcu.lookup_query_command( + "beacon_nvm_read len=%c offset=%hu", + "beacon_nvm_data bytes=%*s offset=%hu", + cq=self.cmd_queue, + ) + self.beacon_contact_home_cmd = self._mcu.lookup_command( + "beacon_contact_home trsync_oid=%c trigger_reason=%c trigger_type=%c", + cq=self.cmd_queue, + ) + self.beacon_contact_query_cmd = self._mcu.lookup_query_command( + "beacon_contact_query", + "beacon_contact_state triggered=%c detect_clock=%u", + cq=self.cmd_queue, + ) + self.beacon_contact_stop_home_cmd = self._mcu.lookup_command( + "beacon_contact_stop_home", + cq=self.cmd_queue, + ) + try: + self.beacon_contact_set_latency_min_cmd = self._mcu.lookup_command( + "beacon_contact_set_latency_min latency_min=%c", + cq=self.cmd_queue, + ) + except msgproto.error: + pass + try: + self.beacon_contact_set_sensitivity_cmd = self._mcu.lookup_command( + "beacon_contact_set_sensitivity sensitivity=%c", + cq=self.cmd_queue, + ) + except msgproto.error: + pass + + constants = self._mcu.get_constants() + + self._mcu_freq = self._mcu.get_constant_float("CLOCK_FREQ") + + self.inv_adc_max = 1.0 / constants.get("ADC_MAX") + self.temp_smooth_count = constants.get("BEACON_ADC_SMOOTH_COUNT") + self.thermistor = thermistor.Thermistor(10000.0, 0.0) + self.thermistor.setup_coefficients_beta(25.0, 47000.0, 4101.0) + + self.toolhead = self.printer.lookup_object("toolhead") + self.kinematics = self.toolhead.get_kinematics() + self.trapq = self.toolhead.get_trapq() + + self.mcu_temp = BeaconMCUTempHelper.build_with_nvm(self) + self.model_temp = self.model_temp_builder.build_with_nvm(self) + if self.model_temp: + self.fmin = self.model_temp.fmin + if self.model is None: + self.model = self.models.get(self.default_model_name, None) + if self.model: + self._apply_threshold() + + if self.beacon_stream_cmd is not None: + self.beacon_stream_cmd.send([1 if self._stream_en else 0]) + if self._stream_en: + curtime = self.reactor.monotonic() + self.reactor.update_timer( + self._stream_timeout_timer, curtime + STREAM_TIMEOUT + ) + else: + self.reactor.update_timer( + self._stream_timeout_timer, self.reactor.NEVER + ) + + if constants.get("BEACON_HAS_ACCEL", 0) == 1: + logging.info("Enabling Beacon accelerometer") + if self.accel_helper is None: + self.accel_helper = BeaconAccelHelper( + self, self.accel_config, constants + ) + else: + self.accel_helper.reinit(constants) + + except msgproto.error as e: + if version_info != "": + raise msgproto.error(version_info + "\n\n" + str(e)) + raise + + def _extend_stats(self): + parts = [ + "coil_temp=%.1f" % (self.last_temp,), + "refs=%d" % (self._stream_en,), + ] + if self.last_mcu_temp is not None: + mcu_temp, supply_voltage = self.last_mcu_temp + parts.append("mcu_temp=%.2f" % (mcu_temp,)) + parts.append("supply_voltage=%.3f" % (supply_voltage,)) + + return " ".join(parts) + + def _api_dump_callback(self, sample): + tmp = [sample.get(key, None) for key in API_DUMP_FIELDS] + self._api_dump.buffer.append(tmp) + + # Virtual endstop + + def setup_pin(self, pin_type, pin_params): + if pin_type != "endstop" or pin_params["pin"] != "z_virtual_endstop": + raise pins.error("Probe virtual endstop only useful as endstop pin") + if pin_params["invert"] or pin_params["pullup"]: + raise pins.error("Can not pullup/invert probe virtual endstop") + return self.mcu_probe + + # Probe interface + + def multi_probe_begin(self): + self._start_streaming() + + def multi_probe_end(self): + self._stop_streaming() + + def get_offsets(self, _gcmd=None): + if self._current_probe == "contact": + return 0, 0, 0 + else: + return self.x_offset, self.y_offset, self.trigger_distance + + def get_lift_speed(self, gcmd=None): + if gcmd is not None: + return gcmd.get_float("LIFT_SPEED", self.lift_speed, above=0.0) + return self.lift_speed + + def run_probe(self, gcmd): + method = gcmd.get("PROBE_METHOD", self.default_probe_method).lower() + self._current_probe = method + if method == "proximity": + return self._run_probe_proximity(gcmd) + elif method == "contact": + self._start_streaming() + try: + return self._run_probe_contact(gcmd) + finally: + self._stop_streaming() + else: + raise gcmd.error("Invalid PROBE_METHOD, valid choices: proximity, contact") + + def _move_to_probing_height(self, speed): + target = self.trigger_distance + top = target + self.backlash_comp + cur_z = self.toolhead.get_position()[2] + if cur_z < top: + self.toolhead.manual_move([None, None, top], speed) + self.toolhead.manual_move([None, None, target], speed) + self.toolhead.wait_moves() + + def _run_probe_proximity(self, gcmd): + if self.model is None: + raise self.printer.command_error("No Beacon model loaded") + + speed = gcmd.get_float("PROBE_SPEED", self.speed, above=0.0) + allow_faulty = gcmd.get_int("ALLOW_FAULTY_COORDINATE", 0) != 0 + toolhead = self.printer.lookup_object("toolhead") + curtime = self.reactor.monotonic() + if "z" not in toolhead.get_status(curtime)["homed_axes"]: + raise self.printer.command_error("Must home before probe") + + self._start_streaming() + try: + return self._probe(speed, allow_faulty=allow_faulty) + finally: + self._stop_streaming() + + def _probing_move_to_probing_height(self, speed): + curtime = self.reactor.monotonic() + status = self.kinematics.get_status(curtime) + pos = self.toolhead.get_position() + pos[2] = status["axis_minimum"][2] + try: + self.phoming.probing_move(self.mcu_probe, pos, speed) + except self.printer.command_error as e: + reason = str(e) + if "Timeout during probing move" in reason: + reason += probe.HINT_TIMEOUT + raise self.printer.command_error(reason) + + def _probe(self, speed, num_samples=10, allow_faulty=False): + target = self.trigger_distance + tdt = self.trigger_dive_threshold + dist, samples = self._sample(5, num_samples) + + x, y = samples[0]["pos"][0:2] + if self._is_faulty_coordinate(x, y, True): + msg = "Probing within a faulty area" + if not allow_faulty: + raise self.printer.command_error(msg) + else: + self.gcode.respond_raw("!! " + msg + "\n") + + if dist > target + tdt: + # If we are above the dive threshold right now, we'll need to + # do probing move and then re-measure + self._probing_move_to_probing_height(speed) + dist, samples = self._sample(self.z_settling_time, num_samples) + elif math.isinf(dist) and dist < 0: + # We were below the valid range of the model + msg = "Attempted to probe with Beacon below calibrated model range" + raise self.printer.command_error(msg) + elif self.toolhead.get_position()[2] < target - tdt: + # We are below the probing target height, we'll move to the + # correct height and take a new sample. + self._move_to_probing_height(speed) + dist, samples = self._sample(self.z_settling_time, num_samples) + + pos = samples[0]["pos"] + + self.gcode.respond_info( + "probe at %.3f,%.3f,%.3f is z=%.6f" % (pos[0], pos[1], pos[2], dist) + ) + + return [pos[0], pos[1], pos[2] + target - dist] + + def _run_probe_contact(self, gcmd): + self.toolhead.wait_moves() + speed = gcmd.get_float( + "PROBE_SPEED", self.autocal_speed, above=0.0, maxval=self.autocal_max_speed + ) + lift_speed = self.get_lift_speed(gcmd) + sample_count = gcmd.get_int("SAMPLES", self.autocal_sample_count, minval=1) + retract_dist = gcmd.get_float( + "SAMPLE_RETRACT_DIST", self.autocal_retract_dist, minval=1 + ) + tolerance = gcmd.get_float( + "SAMPLES_TOLERANCE", self.autocal_tolerance, above=0.0 + ) + max_retries = gcmd.get_int( + "SAMPLES_TOLERANCE_RETRIES", self.autocal_max_retries, minval=0 + ) + samples_result = gcmd.get("SAMPLES_RESULT", "mean") + drop_n = gcmd.get_int("SAMPLES_DROP", 0, minval=0) + retries = 0 + samples = [] + + posxy = self.toolhead.get_position()[:2] + + self.mcu_contact_probe.activate_gcode.run_gcode_from_command() + try: + while len(samples) < sample_count: + pos = self._probe_contact(speed) + self.toolhead.manual_move(posxy + [pos[2] + retract_dist], lift_speed) + if drop_n > 0: + drop_n -= 1 + continue + samples.append(pos[2]) + spread = max(samples) - min(samples) + if spread > tolerance: + if retries >= max_retries: + raise gcmd.error("Probe samples exceed sample_tolerance") + gcmd.respond_info("Probe samples exceed tolerance. Retrying...") + samples = [] + retries += 1 + if samples_result == "median": + return posxy + [median(samples)] + else: + return posxy + [float(np.mean(samples))] + finally: + self.mcu_contact_probe.deactivate_gcode.run_gcode_from_command() + + def _probe_contact(self, speed): + self.toolhead.get_last_move_time() + self._sample_async() + start_pos = self.toolhead.get_position() + hmove = HomingMove(self.printer, [(self.mcu_contact_probe, "contact")]) + pos = start_pos[:] + pos[2] = -2 + try: + epos = hmove.homing_move(pos, speed, probe_pos=True)[:3] + except self.printer.command_error as e: + if self.printer.is_shutdown(): + reason = "Probing failed due to printer shutdown" + else: + reason = str(e) + if "Timeout during probing move" in reason: + reason += probe.HINT_TIMEOUT + raise self.printer.command_error(reason) + epos[2] += self.get_z_compensation_value(pos) + self.gcode.respond_info( + "probe at %.3f,%.3f is z=%.6f" % (epos[0], epos[1], epos[2]) + ) + return epos[:3] + + # Accelerometer interface + + def start_internal_client(self): + if not self.accel_helper: + msg = "This Beacon has no accelerometer" + raise self.printer.command_error(msg) + return self.accel_helper.start_internal_client() + + # Calibration routines + + def _start_calibration(self, gcmd): + allow_faulty = gcmd.get_int("ALLOW_FAULTY_COORDINATE", 0) != 0 + nozzle_z = gcmd.get_float("NOZZLE_Z", self.cal_nozzle_z) + if gcmd.get("SKIP_MANUAL_PROBE", None) is not None: + kin = self.kinematics + kin_spos = { + s.get_name(): s.get_commanded_position() for s in kin.get_steppers() + } + kin_pos = kin.calc_position(kin_spos) + if self._is_faulty_coordinate(kin_pos[0], kin_pos[1]): + msg = "Calibrating within a faulty area" + if not allow_faulty: + raise gcmd.error(msg) + else: + gcmd.respond_raw("!! " + msg + "\n") + self._calibrate(gcmd, kin_pos, nozzle_z, False) + else: + curtime = self.printer.get_reactor().monotonic() + kin_status = self.toolhead.get_status(curtime) + if "xy" not in kin_status["homed_axes"]: + raise self.printer.command_error("Must home X and Y before calibration") + + kin_pos = self.toolhead.get_position() + if self._is_faulty_coordinate(kin_pos[0], kin_pos[1]): + msg = "Calibrating within a faulty area" + if not allow_faulty: + raise gcmd.error(msg) + else: + gcmd.respond_raw("!! " + msg + "\n") + + forced_z = False + if "z" not in kin_status["homed_axes"]: + self.toolhead.get_last_move_time() + pos = self.toolhead.get_position() + pos[2] = ( + kin_status["axis_maximum"][2] + - 2.0 + - gcmd.get_float("CEIL", self.cal_ceil) + ) + self.compat_toolhead_set_position_homing_z(self.toolhead, pos) + forced_z = True + + def cb(kin_pos): + return self._calibrate(gcmd, kin_pos, nozzle_z, forced_z) + + manual_probe.ManualProbeHelper(self.printer, gcmd, cb) + + def _calibrate(self, gcmd, kin_pos, cal_nozzle_z, forced_z, is_auto=False): + if kin_pos is None: + if forced_z: + kin = self.kinematics + self.compat_kin_note_z_not_homed(kin) + return + + gcmd.respond_info("Beacon calibration starting") + cal_floor = gcmd.get_float("FLOOR", self.cal_floor) + cal_ceil = gcmd.get_float("CEIL", self.cal_ceil) + cal_speed = gcmd.get_float("DESCEND_SPEED", self.cal_speed) + move_speed = gcmd.get_float("MOVE_SPEED", self.cal_move_speed) + model_name = gcmd.get("MODEL_NAME", "default") + + toolhead = self.toolhead + toolhead.wait_moves() + + # Move coordinate system to nozzle location + self.toolhead.get_last_move_time() + curpos = toolhead.get_position() + curpos[2] = cal_nozzle_z + toolhead.set_position(curpos) + + # Move over to probe coordinate and pull out backlash + curpos[2] = cal_ceil + self.backlash_comp + toolhead.manual_move(curpos, move_speed) # Up + curpos[0] -= self.x_offset + curpos[1] -= self.y_offset + toolhead.manual_move(curpos, move_speed) # Over + curpos[2] = cal_ceil + toolhead.manual_move(curpos, move_speed) # Down + toolhead.wait_moves() + + samples = [] + + def cb(sample): + samples.append(sample) + + # Descend while sampling + toolhead.flush_step_generation() + try: + self._start_streaming() + self._sample_printtime_sync(50) + with self.streaming_session(cb): + self._sample_printtime_sync(50) + toolhead.dwell(0.250) + curpos[2] = cal_floor + toolhead.manual_move(curpos, cal_speed) + toolhead.flush_step_generation() + self._sample_printtime_sync(50) + finally: + self._stop_streaming() + + # Fit the sampled data + z_offset = [s["pos"][2] for s in samples] + freq = [s["freq"] for s in samples] + temp = [s["temp"] for s in samples] + inv_freq = [1 / f for f in freq] + poly = Polynomial.fit(inv_freq, z_offset, 9) + temp_median = median(temp) + self.model = BeaconModel( + model_name, self, poly, temp_median, min(z_offset), max(z_offset) + ) + self.models[self.model.name] = self.model + self.model.save(self, not is_auto) + self._apply_threshold() + + # Dump calibration curve + fn = "/tmp/beacon-calibrate-" + time.strftime("%Y%m%d_%H%M%S") + ".csv" + with open(fn, "w") as f: + f.write("freq,z,temp\n") + for i in range(len(freq)): + f.write("%.5f,%.5f,%.3f\n" % (freq[i], z_offset[i], temp[i])) + + gcmd.respond_info( + "Beacon calibrated at %.3f,%.3f from " + "%.3f to %.3f, speed %.2f mm/s, temp %.2fC" + % (curpos[0], curpos[1], cal_floor, cal_ceil, cal_speed, temp_median) + ) + + # Internal + + def _update_thresholds(self, moving_up=False): + self.trigger_freq = self.dist_to_freq(self.trigger_distance, self.last_temp) + self.untrigger_freq = self.trigger_freq * (1 - self.trigger_hysteresis) + + def _apply_threshold(self, moving_up=False): + self._update_thresholds() + trigger_c = int(self.freq_to_count(self.trigger_freq)) + untrigger_c = int(self.freq_to_count(self.untrigger_freq)) + if self.beacon_set_threshold is not None: + self.beacon_set_threshold.send([trigger_c, untrigger_c]) + + def _register_model(self, name, model): + if name in self.models: + raise self.printer.config_error( + "Multiple Beacon models with samename '%s'" % (name,) + ) + self.models[name] = model + + def _is_faulty_coordinate(self, x, y, add_offsets=False): + if not self.mesh_helper: + return False + return self.mesh_helper._is_faulty_coordinate(x, y, add_offsets) + + def _handle_beacon_status(self, params): + if self.mcu_temp is not None: + self.last_mcu_temp = self.mcu_temp.compensate( + self, params["mcu_temp"], params["supply_voltage"] + ) + if self.thermistor is not None: + self.last_temp = self.thermistor.calc_temp( + params["coil_temp"] / self.temp_smooth_count * self.inv_adc_max + ) + + def _handle_beacon_contact(self, params): + self.last_contact_msg = params + + def _hook_probing_gcode(self, config, module, cmd): + if not config.has_section(module): + return + section = config.getsection(module) + mod = self.printer.load_object(section, module) + if mod is None: + return + orig = self.gcode.register_command(cmd, None) + + def cb(gcmd): + self._current_probe = gcmd.get( + "PROBE_METHOD", self.default_probe_method + ).lower() + return orig(gcmd) + + self.gcode.register_command(cmd, cb) + + # Streaming mode + + def _check_hardware(self, sample): + if not self.hardware_failure: + msg = None + if sample["data"] == 0xFFFFFFF: + msg = "coil is shorted or not connected" + elif self.fmin is not None and sample["freq"] > 1.35 * self.fmin: + msg = "coil expected max frequency exceeded" + if msg: + msg = "Beacon hardware issue: " + msg + self.hardware_failure = msg + logging.error(msg) + if self._stream_en: + self.printer.invoke_shutdown(msg) + else: + self.gcode.respond_raw("!! " + msg + "\n") + elif self._stream_en: + self.printer.invoke_shutdown(self.hardware_failure) + + def _clock32_to_time(self, clock): + clock64 = self._mcu.clock32_to_clock64(clock) + return self._mcu.clock_to_print_time(clock64) + + def _start_streaming(self): + if self._stream_en == 0 and self.beacon_stream_cmd is not None: + self.beacon_stream_cmd.send([1]) + curtime = self.reactor.monotonic() + self.reactor.update_timer( + self._stream_timeout_timer, curtime + STREAM_TIMEOUT + ) + self._stream_en += 1 + self._data_filter.reset() + self._stream_flush() + + def _stop_streaming(self): + self._stream_en -= 1 + if self._stream_en == 0: + self.reactor.update_timer(self._stream_timeout_timer, self.reactor.NEVER) + if self.beacon_stream_cmd is not None: + self.beacon_stream_cmd.send([0]) + self._stream_flush() + + def force_stop_streaming(self): + self.reactor.update_timer(self._stream_timeout_timer, self.reactor.NEVER) + if self.beacon_stream_cmd is not None: + self.beacon_stream_cmd.send([0]) + self._stream_flush() + + def _stream_timeout(self, eventtime): + if self._stream_flush(): + return eventtime + STREAM_TIMEOUT + if not self._stream_en: + return self.reactor.NEVER + if not self.printer.is_shutdown(): + msg = "Beacon sensor not receiving data" + logging.error(msg) + self.printer.invoke_shutdown(msg) + return self.reactor.NEVER + + def request_stream_latency(self, latency): + next_key = 0 + if self._stream_latency_requests: + next_key = max(self._stream_latency_requests.keys()) + 1 + new_limit = STREAM_BUFFER_LIMIT_DEFAULT + self._stream_latency_requests[next_key] = latency + min_requested = min(self._stream_latency_requests.values()) + if min_requested < new_limit: + new_limit = min_requested + if new_limit < 1: + new_limit = 1 + self._stream_buffer_limit_new = new_limit + return next_key + + def drop_stream_latency_request(self, key): + self._stream_latency_requests.pop(key, None) + new_limit = STREAM_BUFFER_LIMIT_DEFAULT + if self._stream_latency_requests: + min_requested = min(self._stream_latency_requests.values()) + if min_requested < new_limit: + new_limit = min_requested + if new_limit < 1: + new_limit = 1 + self._stream_buffer_limit_new = new_limit + + def streaming_session(self, callback, completion_callback=None, latency=None): + return StreamingHelper(self, callback, completion_callback, latency) + + def _stream_flush_message(self, msg): + last = None + for sample in msg: + clock, data = sample + temp = self.last_temp + if self.model_temp is not None and not (-40 < temp < 180): + msg = ( + "Beacon temperature sensor faulty(read %.2f C)," + " disabling temperature compensation" % (temp,) + ) + logging.error(msg) + self.gcode.respond_raw("!! " + msg + "\n") + self.model_temp = None + if temp: + self.measured_min = min(self.measured_min, temp) + self.measured_max = max(self.measured_max, temp) + + clock = self._mcu.clock32_to_clock64(clock) + time = self._mcu.clock_to_print_time(clock) + self._data_filter.update(time, data) + data_smooth = self._data_filter.value() + freq = self.count_to_freq(data_smooth) + dist = self.freq_to_dist(freq, temp) + pos = self._get_position_at_time(time) + if pos is not None: + if dist is not None: + dist -= self.get_z_compensation_value(pos) + last = sample = { + "temp": temp, + "clock": clock, + "time": time, + "data": data, + "data_smooth": data_smooth, + "freq": freq, + "dist": dist, + } + if pos is not None: + sample["pos"] = pos + self._check_hardware(sample) + + if len(self._stream_callbacks) > 0: + for cb in list(self._stream_callbacks.values()): + cb(sample) + if last is not None: + last = last.copy() + dist = last["dist"] + if dist is None or np.isinf(dist) or np.isnan(dist): + del last["dist"] + self.last_received_sample = last + + def _stream_flush(self): + self._stream_flush_event.clear() + updated_timer = False + while True: + try: + samples = self._stream_samples_queue.get_nowait() + updated_timer = False + for sample in samples: + if not updated_timer: + curtime = self.reactor.monotonic() + self.reactor.update_timer( + self._stream_timeout_timer, curtime + STREAM_TIMEOUT + ) + updated_timer = True + self._stream_flush_message(sample) + except queue.Empty: + return updated_timer + + def _stream_flush_schedule(self): + force = self._stream_en == 0 # When streaming is disabled, let all through + if self._stream_buffer_limit_new != self._stream_buffer_limit: + force = True + self._stream_buffer_limit = self._stream_buffer_limit_new + if not force and self._stream_buffer_count < self._stream_buffer_limit: + return + self._stream_samples_queue.put_nowait(self._stream_buffer) + self._stream_buffer = [] + self._stream_buffer_count = 0 + if self._stream_flush_event.is_set(): + return + self._stream_flush_event.set() + self.reactor.register_async_callback(lambda e: self._stream_flush()) + + def _handle_beacon_data(self, params): + if self.trapq is None: + return + + buf = bytearray(params["data"]) + sample_count = params["samples"] + start_clock = params["start_clock"] + delta_clock = ( + params["delta_clock"] / (sample_count - 1) if sample_count > 1 else 0 + ) + + samples = [] + data = 0 + for i in range(0, sample_count): + if buf[0] & 0x80 == 0: + delta = ((buf[0] & 0x7F) << 8) + buf[1] + data = data + delta - ((buf[0] & 0x40) << 9) + buf = buf[2:] + else: + data = (buf[0] & 0x7F) << 24 | buf[1] << 16 | buf[2] << 8 | buf[3] + buf = buf[4:] + clock = start_clock + int(round(i * delta_clock)) + samples.append((clock, data)) + + self._stream_buffer.append(samples) + self._stream_buffer_count += len(samples) + self._stream_flush_schedule() + + def _get_position_at_time(self, print_time): + kin = self.kinematics + pos = { + s.get_name(): s.mcu_to_commanded_position( + s.get_past_mcu_position(print_time) + ) + for s in kin.get_steppers() + } + return kin.calc_position(pos) + + def _sample_printtime_sync(self, skip=0, count=1): + move_time = self.toolhead.get_last_move_time() + settle_clock = self._mcu.print_time_to_clock(move_time) + samples = [] + total = skip + count + + def cb(sample): + if sample["clock"] >= settle_clock: + samples.append(sample) + if len(samples) >= total: + raise StopStreaming + + with self.streaming_session(cb, latency=skip + count) as ss: + ss.wait() + + samples = samples[skip:] + + if count == 1: + return samples[0] + else: + return samples + + def _sample(self, skip, count): + samples = self._sample_printtime_sync(skip, count) + return (median([s["dist"] for s in samples]), samples) + + def _sample_async(self, count=1): + samples = [] + + def cb(sample): + samples.append(sample) + if len(samples) >= count: + raise StopStreaming + + with self.streaming_session(cb, latency=count) as ss: + ss.wait() + + if count == 1: + return samples[0] + else: + return samples + + def count_to_freq(self, count): + return count * self._mcu_freq / (2**28) + + def freq_to_count(self, freq): + return freq * (2**28) / self._mcu_freq + + def dist_to_freq(self, dist, temp): + if self.model is None: + return None + return self.model.dist_to_freq(dist, temp) + + def freq_to_dist(self, freq, temp): + if self.model is None: + return None + return self.model.freq_to_dist(freq, temp) + + def get_status(self, eventtime): + model = None + if self.model is not None: + model = self.model.name + return { + "last_sample": self.last_sample, + "last_received_sample": self.last_received_sample, + "last_z_result": self.last_z_result, + "last_probe_position": self.last_probe_position, + "last_probe_result": self.last_probe_result, + "last_offset_result": self.last_offset_result, + "last_poke_result": self.last_poke_result, + "model": model, + } + + # Webhook handlers + + def _handle_req_status(self, web_request): + temp = None + sample = self._sample_async() + out = { + "freq": sample["freq"], + "dist": sample["dist"], + } + temp = sample["temp"] + if temp is not None: + out["temp"] = temp + web_request.send(out) + + def _handle_req_dump(self, web_request): + self._api_dump.add_web_client(web_request) + web_request.send({"header": API_DUMP_FIELDS}) + + # Compat wrappers + + def compat_toolhead_set_position_homing_z(self, toolhead, pos): + func = toolhead.set_position + kind = tuple + if hasattr(func, "__defaults__"): # Python 3 + kind = type(func.__defaults__[0]) + else: # Python 2 + kind = type(func.func_defaults[0]) + if kind is str: + return toolhead.set_position(pos, homing_axes="z") + else: + return toolhead.set_position(pos, homing_axes=[2]) + + def compat_kin_note_z_not_homed(self, kin): + if hasattr(kin, "note_z_not_homed"): + kin.note_z_not_homed() + elif hasattr(kin, "clear_homing_state"): + kin.clear_homing_state("z") + + def compat_serial_port(self, mcu): + if hasattr(mcu, "_serialport"): + return mcu._serialport + elif hasattr(mcu, "_conn_helper"): + return mcu._conn_helper.get_serialport()[0] + else: + raise Exception("Could not determine serial port") + + probe_result_builder = None + + def compat_create_probe_result(self, test_pos): + if BeaconProbe.probe_result_builder is None: + if hasattr(manual_probe, "ProbeResult"): + BeaconProbe.probe_result_builder = prb_proberesult + else: + BeaconProbe.probe_result_builder = prb_identity + return BeaconProbe.probe_result_builder(self, test_pos) + + def compat_mcu_register_response(self, cb, msg, oid=None): + if hasattr(self._mcu, "register_serial_response"): + return self._mcu.register_serial_response(cb, msg, oid) + else: + name = msg.split()[0] + return self._mcu.register_response(cb, name, oid) + + # GCode command handlers + + cmd_PROBE_help = "Probe Z-height at current XY position" + + def cmd_PROBE(self, gcmd): + self.last_probe_result = "failed" + pos = self.run_probe(gcmd) + gcmd.respond_info("Result is z=%.6f" % (pos[2],)) + offset = self.get_offsets() + self.last_z_result = pos[2] - offset[2] + self.last_probe_position = (pos[0] - offset[0], pos[1] - offset[1]) + self.last_probe_result = "ok" + + cmd_BEACON_CALIBRATE_help = "Calibrate beacon response curve" + + def cmd_BEACON_CALIBRATE(self, gcmd): + self._start_calibration(gcmd) + + cmd_BEACON_ESTIMATE_BACKLASH_help = "Estimate Z axis backlash" + + def cmd_BEACON_ESTIMATE_BACKLASH(self, gcmd): + # Get to correct Z height + overrun = gcmd.get_float("OVERRUN", 1.0) + speed = gcmd.get_float("PROBE_SPEED", self.speed, above=0.0) + cur_z = self.toolhead.get_position()[2] + self.toolhead.manual_move([None, None, cur_z + overrun], speed) + self.run_probe(gcmd) + + lift_speed = self.get_lift_speed(gcmd) + target = gcmd.get_float("Z", self.trigger_distance) + + num_samples = gcmd.get_int("SAMPLES", 20) + wait = self.z_settling_time + + samples_up = [] + samples_down = [] + + next_dir = -1 + + try: + self._start_streaming() + + cur_dist, _samples = self._sample(wait, 10) + pos = self.toolhead.get_position() + missing = target - cur_dist + target = pos[2] + missing + gcmd.respond_info("Target kinematic Z is %.3f" % (target,)) + + if target - overrun < 0: + raise gcmd.error("Target minus overrun must exceed 0mm") + + while len(samples_up) + len(samples_down) < num_samples: + liftpos = [None, None, target + overrun * next_dir] + self.toolhead.manual_move(liftpos, lift_speed) + liftpos = [None, None, target] + self.toolhead.manual_move(liftpos, lift_speed) + self.toolhead.wait_moves() + dist, _samples = self._sample(wait, 10) + {-1: samples_up, 1: samples_down}[next_dir].append(dist) + next_dir = next_dir * -1 + + finally: + self._stop_streaming() + + res_up = median(samples_up) + res_down = median(samples_down) + + gcmd.respond_info( + "Median distance moving up %.5f, down %.5f, " + "delta %.5f over %d samples" + % (res_up, res_down, res_down - res_up, num_samples) + ) + + cmd_BEACON_QUERY_help = "Take a sample from the sensor" + + def cmd_BEACON_QUERY(self, gcmd): + sample = self._sample_async() + last_value = sample["freq"] + dist = sample["dist"] + temp = sample["temp"] + self.last_sample = { + "time": sample["time"], + "value": last_value, + "temp": temp, + "dist": None if dist is None or np.isinf(dist) or np.isnan(dist) else dist, + } + if dist is None: + gcmd.respond_info( + "Last reading: %.2fHz, %.2fC, no model" + % ( + last_value, + temp, + ) + ) + else: + gcmd.respond_info( + "Last reading: %.2fHz, %.2fC, %.5fmm" % (last_value, temp, dist) + ) + + cmd_BEACON_STREAM_help = "Enable Beacon Streaming" + + def cmd_BEACON_STREAM(self, gcmd): + if self._log_stream is not None: + self._log_stream.stop() + self._log_stream = None + gcmd.respond_info("Beacon Streaming disabled") + else: + f = None + completion_cb = None + fn = gcmd.get("FILENAME") + f = open(fn, "w") + + def close_file(): + f.close() + + completion_cb = close_file + f.write("time,data,data_smooth,freq,dist,temp,pos_x,pos_y,pos_z\n") + + def cb(sample): + pos = sample.get("pos", None) + obj = "%.4f,%d,%.2f,%.5f,%.5f,%.2f,%s,%s,%s\n" % ( + sample["time"], + sample["data"], + sample["data_smooth"], + sample["freq"], + sample["dist"], + sample["temp"], + "%.3f" % (pos[0],) if pos is not None else "", + "%.3f" % (pos[1],) if pos is not None else "", + "%.3f" % (pos[2],) if pos is not None else "", + ) + f.write(obj) + + self._log_stream = self.streaming_session(cb, completion_cb) + gcmd.respond_info("Beacon Streaming enabled") + + cmd_PROBE_ACCURACY_help = "Probe Z-height accuracy at current XY position" + + def cmd_PROBE_ACCURACY(self, gcmd): + speed = gcmd.get_float("PROBE_SPEED", self.speed, above=0.0) + lift_speed = self.get_lift_speed(gcmd) + sample_count = gcmd.get_int("SAMPLES", 10, minval=1) + sample_retract_dist = gcmd.get_float("SAMPLE_RETRACT_DIST", 0) + allow_faulty = gcmd.get_int("ALLOW_FAULTY_COORDINATE", 0) != 0 + pos = self.toolhead.get_position() + gcmd.respond_info( + "PROBE_ACCURACY at X:%.3f Y:%.3f Z:%.3f" + " (samples=%d retract=%.3f" + " speed=%.1f lift_speed=%.1f)\n" + % ( + pos[0], + pos[1], + pos[2], + sample_count, + sample_retract_dist, + speed, + lift_speed, + ) + ) + + start_height = self.trigger_distance + sample_retract_dist + liftpos = [None, None, start_height] + self.toolhead.manual_move(liftpos, lift_speed) + + self.multi_probe_begin() + positions = [] + while len(positions) < sample_count: + pos = self._probe(speed, allow_faulty=allow_faulty) + positions.append(pos) + self.toolhead.manual_move(liftpos, lift_speed) + self.multi_probe_end() + + zs = [p[2] for p in positions] + max_value = max(zs) + min_value = min(zs) + range_value = max_value - min_value + avg_value = sum(zs) / len(positions) + median_ = median(zs) + + deviation_sum = 0 + for i in range(len(zs)): + deviation_sum += pow(zs[2] - avg_value, 2.0) + sigma = (deviation_sum / len(zs)) ** 0.5 + + gcmd.respond_info( + "probe accuracy results: maximum %.6f, minimum %.6f, range %.6f, " + "average %.6f, median %.6f, standard deviation %.6f" + % (max_value, min_value, range_value, avg_value, median_, sigma) + ) + + cmd_Z_OFFSET_APPLY_PROBE_help = "Adjust the probe's z_offset" + + def cmd_Z_OFFSET_APPLY_PROBE(self, gcmd): + gcode_move = self.printer.lookup_object("gcode_move") + offset = gcode_move.get_status()["homing_origin"].z + + if offset == 0: + self.gcode.respond_info("Nothing to do: Z Offset is 0") + return + + if not self.model: + raise self.gcode.error( + "You must calibrate your model first, use BEACON_CALIBRATE." + ) + + # We use the model code to save the new offset, but we can't actually + # apply that offset yet because the gcode_offset is still in effect. + # If the user continues to do stuff after this, the newly set model + # offset would compound with the gcode offset. To ensure this doesn't + # happen, we revert to the old model offset afterwards. + # Really, the user should just be calling `SAVE_CONFIG` now. + old_offset = self.model.offset + self.model.offset += offset + self.model.save(self, False) + gcmd.respond_info( + "Beacon model offset has been updated, new value is %.5f\n" + "You must run the SAVE_CONFIG command now to update the\n" + "printer config file and restart the printer." % (self.model.offset,) + ) + self.model.offset = old_offset + + cmd_BEACON_POKE_help = "Poke the bed" + + def cmd_BEACON_POKE(self, gcmd): + top = gcmd.get_float("TOP", 5) + bottom = gcmd.get_float("BOTTOM", -0.3) + speed = gcmd.get_float("SPEED", 3, maxval=self.autocal_max_speed) + + pos = self.toolhead.get_position() + gcmd.respond_info( + "Poke test at (%.3f,%.3f), from %.3f to %.3f, at %.3f mm/s" + % (pos[0], pos[1], top, bottom, speed) + ) + + self.last_probe_result = "failed" + self.toolhead.manual_move([None, None, top], 100.0) + self.toolhead.wait_moves() + self.toolhead.dwell(0.5) + + ts = time.strftime("%Y%m%d_%H%M%S") + fn = "/tmp/poke_%s_%.3f_%.3f-%.3f.csv" % (ts, speed, top, bottom) + with open(fn, "w") as f: + f.write("time,data,data_smooth,freq,dist,temp,pos_x,pos_y,pos_z\n") + + def cb(sample): + pos = sample.get("pos", None) + obj = "%.6f,%d,%.2f,%.5f,%.5f,%.2f,%s,%s,%s\n" % ( + sample["time"], + sample["data"], + sample["data_smooth"], + sample["freq"], + sample["dist"], + sample["temp"], + "%.3f" % (pos[0],) if pos is not None else "", + "%.3f" % (pos[1],) if pos is not None else "", + "%.5f" % (pos[2],) if pos is not None else "", + ) + f.write(obj) + + with self.streaming_session(cb): + self._sample_async() + self.toolhead.get_last_move_time() + pos = self.toolhead.get_position() + self.mcu_contact_probe.activate_gcode.run_gcode_from_command() + try: + hmove = HomingMove( + self.printer, [(self.mcu_contact_probe, "contact")] + ) + pos[2] = bottom + epos = hmove.homing_move(pos, speed, probe_pos=True)[:3] + self.toolhead.wait_moves() + spos = self.toolhead.get_position()[:3] + armpos = self._get_position_at_time( + self._clock32_to_time(self.last_contact_msg["armed_clock"]) + ) + gcmd.respond_info("Armed at: z=%.5f" % (armpos[2],)) + gcmd.respond_info( + "Triggered at: z=%.5f with latency=%d" + % (epos[2], self.last_contact_msg["latency"]) + ) + gcmd.respond_info( + "Overshoot: %.3f um" % ((epos[2] - spos[2]) * 1000.0,) + ) + self.last_probe_result = "ok" + self.last_poke_result = { + "target_position": pos, + "arming_z": armpos[2], + "trigger_z": epos[2], + "stopped_z": spos[2], + "latency": self.last_contact_msg["latency"], + "error": self.last_contact_msg["error"], + } + except self.printer.command_error: + if self.printer.is_shutdown(): + raise self.printer.command_error( + "Homing failed due to printer shutdown" + ) + raise + finally: + self.mcu_contact_probe.deactivate_gcode.run_gcode_from_command() + self.toolhead.manual_move([None, None, top], 100.0) + self.toolhead.wait_moves() + + cmd_BEACON_AUTO_CALIBRATE_help = "Automatically calibrates the Beacon probe" + + def cmd_BEACON_AUTO_CALIBRATE(self, gcmd): + speed = gcmd.get_float( + "SPEED", self.autocal_speed, above=0, maxval=self.autocal_max_speed + ) + desired_accel = gcmd.get_float("ACCEL", self.autocal_accel, minval=1) + retract_dist = gcmd.get_float("RETRACT", self.autocal_retract_dist, minval=1) + retract_speed = gcmd.get_float( + "RETRACT_SPEED", self.autocal_retract_speed, minval=1 + ) + sample_count = gcmd.get_int("SAMPLES", self.autocal_sample_count, minval=1) + tolerance = gcmd.get_float( + "SAMPLES_TOLERANCE", self.autocal_tolerance, above=0.0 + ) + max_retries = gcmd.get_int( + "SAMPLES_TOLERANCE_RETRIES", self.autocal_max_retries, minval=0 + ) + + curtime = self.reactor.monotonic() + kin = self.kinematics + kin_status = kin.get_status(curtime) + if "x" not in kin_status["homed_axes"] or "y" not in kin_status["homed_axes"]: + raise gcmd.error("Must home X and Y axes first") + + self.last_probe_result = "failed" + force_pos = self.toolhead.get_position()[:] + home_pos = force_pos[:] + amin, amax = kin_status["axis_minimum"][2], kin_status["axis_maximum"][2] + force_pos[2] = amax + home_pos[2] = amin + + stop_samples = [] + + old_max_accel = self.toolhead.get_status(curtime)["max_accel"] + gcode = self.printer.lookup_object("gcode") + + def set_max_accel(value): + gcode.run_script_from_command("SET_VELOCITY_LIMIT ACCEL=%.3f" % (value,)) + + homing_state = BeaconHomingState() + self.printer.send_event("homing:home_rails_begin", homing_state, []) + self.mcu_contact_probe.activate_gcode.run_gcode_from_command() + try: + self.compat_toolhead_set_position_homing_z(self.toolhead, force_pos) + skip_next = True + retries = 0 + while len(stop_samples) < sample_count: + if skip_next: + gcmd.respond_info("Initial approach") + else: + gcmd.respond_info( + "Collecting sample %d/%d" + % (len(stop_samples) + 1, sample_count) + ) + self.toolhead.wait_moves() + set_max_accel(desired_accel) + try: + hmove = HomingMove( + self.printer, [(self.mcu_contact_probe, "contact")] + ) + epos = hmove.homing_move(home_pos, speed, probe_pos=True) + except self.printer.command_error: + if self.printer.is_shutdown(): + raise self.printer.command_error( + "Homing failed due to printer shutdown" + ) + raise + finally: + set_max_accel(old_max_accel) + + retract_pos = self.toolhead.get_position()[:] + retract_pos[2] += retract_dist + if retract_pos[2] > amax: + retract_pos[2] = amax + self.toolhead.move(retract_pos, retract_speed) + self.toolhead.dwell(1.0) + + if not skip_next: + stop_samples.append(epos[2]) + mean = np.mean(stop_samples) + delta = max([abs(v - mean) for v in stop_samples]) + if delta > tolerance: + if retries >= max_retries: + raise gcmd.error( + "Sample spread too large(%.4f > %.4f)" + % (delta, tolerance) + ) + gcmd.respond_info( + "Sample spread too large(%.4f > %.4f), restarting" + % (delta, tolerance) + ) + retries += 1 + stop_samples = [] + skip_next = True + else: + skip_next = False + + gcmd.respond_info( + "Collected %d samples, %.4f sd" + % (len(stop_samples), np.std(stop_samples)) + ) + + current_delta = force_pos[2] - self.toolhead.get_position()[2] + true_zero_delta = force_pos[2] - np.mean(stop_samples) + + force_pos[2] = float(true_zero_delta - current_delta) + self.toolhead.set_position(force_pos) + + self.toolhead.wait_moves() + self.toolhead.flush_step_generation() + self.last_probe_result = "ok" + self.printer.send_event("homing:home_rails_end", homing_state, []) + if gcmd.get_int("SKIP_MODEL_CREATION", 0) == 0: + self._calibrate(gcmd, force_pos, force_pos[2], True, True) + + except self.printer.command_error: + self.compat_kin_note_z_not_homed(kin) + raise + finally: + self.mcu_contact_probe.deactivate_gcode.run_gcode_from_command() + + cmd_BEACON_OFFSET_COMPARE_help = ( + "Measures offset between contact and proximity measurements" + ) + + def cmd_BEACON_OFFSET_COMPARE(self, gcmd): + top = gcmd.get_float("TOP", 2) + + self.last_probe_result = "failed" + self.toolhead.get_last_move_time() + self._sample_async() + start_pos = self.toolhead.get_position() + + params = { + "SAMPLES_DROP": 1, + "SAMPLES": 3, + } + params.update(gcmd.get_command_parameters()) + + # Do contact move + epos = self._run_probe_contact( + self.gcode.create_gcode_command( + "PROBE", + "PROBE", + params, + ) + ) + + # Up + self.toolhead.manual_move([None, None, top + 0.5], 100.0) + + # Over + pos = start_pos[:2] + pos[0] -= self.x_offset + pos[1] -= self.y_offset + self.toolhead.manual_move(pos, 100.0) + self.toolhead.wait_moves() + + # Down + self.toolhead.manual_move([None, None, 2.0], 100.0) + + # Query + dist, _samples = self._sample(self.z_settling_time, 10) + dist = 2.0 - dist + + # Back + self.toolhead.manual_move(start_pos, 100.0) + self.toolhead.wait_moves() + + delta = epos[2] - dist + gcmd.respond_info("Comparing @ %.4f,%.4f" % (start_pos[0], start_pos[1])) + gcmd.respond_info("Contact: %.5f mm" % (epos[2],)) + gcmd.respond_info("Proximity: %.5f mm" % (dist,)) + gcmd.respond_info("Delta: %.3f um" % (delta * 1000,)) + self.last_probe_result = "ok" + self.last_offset_result = { + "position": (start_pos[0], start_pos[1], epos[2]), + "delta": delta, + } + + +def prb_proberesult(beacon, test_pos): + x, y, z = beacon.get_offsets() + return manual_probe.ProbeResult( + test_pos[0] + x, + test_pos[1] + y, + test_pos[2] - z, + test_pos[0], + test_pos[1], + test_pos[2], + ) + + +def prb_identity(beacon, test_pos): + return test_pos + + +class BeaconModel: + @classmethod + def load(cls, name, config, beacon): + coef = config.getfloatlist("model_coef") + temp = config.getfloat("model_temp") + domain = config.getfloatlist("model_domain", count=2) + [min_z, max_z] = config.getfloatlist("model_range", count=2) + offset = config.getfloat("model_offset", 0.0) + poly = Polynomial(coef, domain) + return BeaconModel(name, beacon, poly, temp, min_z, max_z, offset) + + def __init__(self, name, beacon, poly, temp, min_z, max_z, offset=0): + self.name = name + self.beacon = beacon + self.poly = poly + self.min_z = min_z + self.max_z = max_z + self.temp = temp + self.offset = offset + + def save(self, beacon, show_message=True): + configfile = beacon.printer.lookup_object("configfile") + sensor_name = "" if beacon.id.is_unnamed() else "sensor %s " % (beacon.id.name) + section = "beacon " + sensor_name + "model " + self.name + configfile.set(section, "model_coef", ",\n ".join(map(str, self.poly.coef))) + configfile.set(section, "model_domain", ",".join(map(str, self.poly.domain))) + configfile.set(section, "model_range", "%f,%f" % (self.min_z, self.max_z)) + configfile.set(section, "model_temp", "%f" % (self.temp)) + configfile.set(section, "model_offset", "%.5f" % (self.offset,)) + if show_message: + beacon.gcode.respond_info( + "Beacon calibration for model '%s' has " + "been updated\nfor the current session. The SAVE_CONFIG " + "command will\nupdate the printer config file and restart " + "the printer." % (self.name,) + ) + + def freq_to_dist_raw(self, freq): + [begin, end] = self.poly.domain + invfreq = 1 / freq + if invfreq > end: + return float("inf") + elif invfreq < begin: + return float("-inf") + else: + return float(self.poly(invfreq) - self.offset) + + def freq_to_dist(self, freq, temp): + if self.temp is not None and self.beacon.model_temp is not None: + freq = self.beacon.model_temp.compensate(freq, temp, self.temp) + return self.freq_to_dist_raw(freq) + + def dist_to_freq_raw(self, dist, max_e=0.00000001): + if dist < self.min_z or dist > self.max_z: + msg = ( + "Attempted to map out-of-range distance %f, valid range " + "[%.3f, %.3f]" % (dist, self.min_z, self.max_z) + ) + raise self.beacon.printer.command_error(msg) + dist += self.offset + [begin, end] = self.poly.domain + for _ in range(0, 50): + f = (end + begin) / 2 + v = self.poly(f) + if abs(v - dist) < max_e: + return float(1.0 / f) + elif v < dist: + begin = f + else: + end = f + raise self.beacon.printer.command_error("Beacon model convergence error") + + def dist_to_freq(self, dist, temp, max_e=0.00000001): + freq = self.dist_to_freq_raw(dist, max_e) + if self.temp is not None and self.beacon.model_temp is not None: + freq = self.beacon.model_temp.compensate(freq, self.temp, temp) + return freq + + +class BeaconMCUTempHelper: + def __init__(self, temp_room, temp_hot, ref_room, ref_hot, adc_room, adc_hot): + self.temp_room = temp_room + self.temp_hot = temp_hot + self.ref_room = ref_room + self.ref_hot = ref_hot + self.adc_room = adc_room + self.adc_hot = adc_hot + + def compensate(self, beacon, mcu_temp, supply): + temp_mcu_uncomp = self.temp_room + (self.temp_hot - self.temp_room) * ( + mcu_temp / beacon.temp_smooth_count - self.adc_room * self.ref_room + ) / (self.adc_hot * self.ref_hot - self.adc_room * self.ref_room) + ref_comp = self.ref_room + (self.ref_hot - self.ref_room) * ( + temp_mcu_uncomp - self.temp_room + ) / (self.temp_hot - self.temp_room) + temp_mcu_comp = self.temp_room + (self.temp_hot - self.temp_room) * ( + mcu_temp / beacon.temp_smooth_count * ref_comp + - self.adc_room * self.ref_room + ) / (self.adc_hot * self.ref_hot - self.adc_room * self.ref_room) + supply_voltage = ( + 4.0 * supply * ref_comp / beacon.temp_smooth_count * beacon.inv_adc_max + ) + return (temp_mcu_comp, supply_voltage) + + @classmethod + def build_with_nvm(cls, beacon): + nvm_data = beacon.beacon_nvm_read_cmd.send([8, 65534]) + if nvm_data["offset"] == 65534: + lower, upper = struct.unpack("> 8) & 0xF) + temp_hot = ((lower >> 12) & 0xFF) + 0.1 * ((lower >> 20) & 0xF) + adc_room = (upper >> 8) & 0xFFF + adc_hot = (upper >> 20) & 0xFFF + ref_room_raw, ref_hot_raw = struct.unpack(" 0: + vk = vk + self.beta / dt * rk + self.xl = xk + self.vl = vk + return xk + + def value(self): + return self.xl + + +class StreamingHelper: + def __init__(self, beacon, callback, completion_callback, latency): + self.beacon = beacon + self.cb = callback + self.completion_cb = completion_callback + self.completion = self.beacon.reactor.completion() + + self.latency_key = None + if latency is not None: + self.latency_key = self.beacon.request_stream_latency(latency) + + self.beacon._stream_callbacks[self] = self._handle + self.beacon._start_streaming() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + + def _handle(self, sample): + try: + self.cb(sample) + except StopStreaming: + self.completion.complete(()) + + def stop(self): + if self not in self.beacon._stream_callbacks: + return + del self.beacon._stream_callbacks[self] + self.beacon._stop_streaming() + if self.latency_key is not None: + self.beacon.drop_stream_latency_request(self.latency_key) + if self.completion_cb is not None: + self.completion_cb() + + def wait(self): + self.completion.wait() + self.stop() + + +class StopStreaming(Exception): + pass + + +class BeaconProbeWrapper: + def __init__(self, beacon): + self.beacon = beacon + self.results = None + + def multi_probe_begin(self): + return self.beacon.multi_probe_begin() + + def multi_probe_end(self): + return self.beacon.multi_probe_end() + + def get_offsets(self, _gcmd=None): + return self.beacon.get_offsets() + + def get_lift_speed(self, gcmd=None): + return self.beacon.get_lift_speed(gcmd) + + def run_probe(self, gcmd, *args, **kwargs): + result = self.beacon.run_probe(gcmd) + result = self.beacon.compat_create_probe_result(result) + if self.results is not None: + self.results.append(result) + return result + + def get_probe_params(self, gcmd=None): + return {"lift_speed": self.beacon.get_lift_speed(gcmd)} + + def start_probe_session(self, gcmd): + self.multi_probe_begin() + self.results = [] + return self + + def end_probe_session(self): + self.results = None + self.multi_probe_end() + + def pull_probed_results(self): + results = self.results + if results is None: + return [] + else: + self.results = [] + return results + + def get_status(self, eventtime): + return {"name": "beacon"} + + +class BeaconTempWrapper: + def __init__(self, beacon): + self.beacon = beacon + + def get_temp(self, eventtime): + return self.beacon.last_temp, 0 + + def get_status(self, eventtime): + return { + "temperature": round(self.beacon.last_temp, 2), + "measured_min_temp": round(self.beacon.measured_min, 2), + "measured_max_temp": round(self.beacon.measured_max, 2), + } + + +class BeaconEndstopShared: + def __init__(self, beacon): + self.beacon = beacon + + ffi_main, ffi_lib = chelper.get_ffi() + self._trdispatch = ffi_main.gc(ffi_lib.trdispatch_alloc(), ffi_lib.free) + self._trsync = MCU_trsync(self.beacon._mcu, self._trdispatch) + self._trsyncs = [self._trsync] + + beacon.printer.register_event_handler( + "klippy:mcu_identify", self._handle_mcu_identify + ) + + def _handle_mcu_identify(self): + self.toolhead = self.beacon.printer.lookup_object("toolhead") + kin = self.toolhead.get_kinematics() + for stepper in kin.get_steppers(): + if stepper.is_active_axis("z"): + self.add_stepper(stepper) + + def add_stepper(self, stepper): + trsyncs = {trsync.get_mcu(): trsync for trsync in self._trsyncs} + stepper_mcu = stepper.get_mcu() + trsync = trsyncs.get(stepper_mcu) + if trsync is None: + trsync = MCU_trsync(stepper_mcu, self._trdispatch) + self._trsyncs.append(trsync) + trsync.add_stepper(stepper) + # Check for unsupported multi-mcu shared stepper rails, duplicated + # from MCU_endstop + sname = stepper.get_name() + if sname.startswith("stepper_"): + for ot in self._trsyncs: + for s in ot.get_steppers(): + if ot is not trsync and s.get_name().startswith(sname[:9]): + raise self.beacon.printer.config_error( + "Multi-mcu homing not supported on multi-mcu shared axis" + ) + + def get_steppers(self): + return [s for trsync in self._trsyncs for s in trsync.get_steppers()] + + def trsync_start(self, print_time): + self._trigger_completion = self.beacon.reactor.completion() + expire_timeout = self.beacon.trsync_timeout + for i, trsync in enumerate(self._trsyncs): + try: + trsync.start(print_time, self._trigger_completion, expire_timeout) + except TypeError: + offset = float(i) / len(self._trsyncs) + trsync.start( + print_time, offset, self._trigger_completion, expire_timeout + ) + ffi_main, ffi_lib = chelper.get_ffi() + ffi_lib.trdispatch_start(self._trdispatch, self._trsync.REASON_HOST_REQUEST) + + def trsync_stop(self, home_end_time): + self._trsync.set_home_end_time(home_end_time) + if self.beacon._mcu.is_fileoutput(): + self._trigger_completion.complete(True) + self._trigger_completion.wait() + ffi_main, ffi_lib = chelper.get_ffi() + ffi_lib.trdispatch_stop(self._trdispatch) + res = [trsync.stop() for trsync in self._trsyncs] + if any([r == self._trsync.REASON_COMMS_TIMEOUT for r in res]): + cmderr = self.beacon.printer.command_error + raise cmderr("Communication timeout during homing") + if res[0] != self._trsync.REASON_ENDSTOP_HIT: + return 0.0 + return None + + +class BeaconEndstopWrapper: + def __init__(self, beacon): + self.beacon = beacon + self._shared = beacon._endstop_shared + + printer = beacon.printer + printer.register_event_handler( + "homing:home_rails_begin", self._handle_home_rails_begin + ) + printer.register_event_handler( + "homing:home_rails_end", self._handle_home_rails_end + ) + + self.is_homing = False + + def _handle_home_rails_begin(self, homing_state, rails): + self.is_homing = False + + def _handle_home_rails_end(self, homing_state, rails): + if self.beacon.model is None: + return + + if not self.is_homing: + return + + if 2 not in homing_state.get_axes(): + return + + # After homing Z we perform a measurement and adjust the toolhead + # kinematic position. + dist, samples = self.beacon._sample(self.beacon.z_settling_time, 10) + if math.isinf(dist): + logging.error("Post-homing adjustment measured samples %s", samples) + raise self.beacon.printer.command_error( + "Toolhead stopped below model range" + ) + homing_state.set_homed_position([None, None, dist]) + + def get_mcu(self): + return self.beacon._mcu + + def add_stepper(self, stepper): + self._shared.add_stepper(stepper) + + def get_steppers(self): + return self._shared.get_steppers() + + def home_start( + self, print_time, sample_time, sample_count, rest_time, triggered=True + ): + if self.beacon.model is None: + raise self.beacon.printer.command_error("No Beacon model loaded") + + self.is_homing = True + self.beacon._apply_threshold() + self.beacon._sample_async() + + self._shared.trsync_start(print_time) + + etrsync = self._shared._trsync + self.beacon.beacon_home_cmd.send( + [ + etrsync.get_oid(), + etrsync.REASON_ENDSTOP_HIT, + 0, + ] + ) + return self._shared._trigger_completion + + def home_wait(self, home_end_time): + ret = self._shared.trsync_stop(home_end_time) + self.beacon.beacon_stop_home_cmd.send() + if ret is not None: + return ret + return home_end_time + + def query_endstop(self, print_time): + if self.beacon.model is None: + return 1 + self.beacon._mcu.print_time_to_clock(print_time) + sample = self.beacon._sample_async() + if self.beacon.trigger_freq <= sample["freq"]: + return 1 + else: + return 0 + + def get_position_endstop(self): + return self.beacon.trigger_distance + + +class BeaconContactEndstopWrapper: + def __init__(self, beacon, config): + self.beacon = beacon + self._shared = beacon._endstop_shared + + gcode_macro = beacon.printer.load_object(config, "gcode_macro") + self.activate_gcode = gcode_macro.load_template( + config, "contact_activate_gcode", "" + ) + self.deactivate_gcode = gcode_macro.load_template( + config, "contact_deactivate_gcode", "" + ) + self.max_hotend_temp = config.getfloat("contact_max_hotend_temperature", 180.0) + + def get_mcu(self): + return self.beacon._mcu + + def add_stepper(self, stepper): + self._shared.add_stepper(stepper) + + def get_steppers(self): + return self._shared.get_steppers() + + def home_start( + self, print_time, sample_time, sample_count, rest_time, triggered=True + ): + extruder = self.beacon.toolhead.get_extruder() + if extruder is not None: + curtime = self.beacon.reactor.monotonic() + cur_temp = extruder.get_heater().get_status(curtime)["temperature"] + if cur_temp >= self.max_hotend_temp: + raise self.beacon.printer.command_error( + "Current hotend temperature %.1f exceeds maximum allowed temperature %.1f" + % (cur_temp, self.max_hotend_temp) + ) + + self.is_homing = True + self.beacon._sample_async() + self._shared.trsync_start(print_time) + etrsync = self._shared._trsync + if self.beacon.beacon_contact_set_latency_min_cmd is not None: + self.beacon.beacon_contact_set_latency_min_cmd.send( + [self.beacon.contact_latency_min] + ) + if self.beacon.beacon_contact_set_sensitivity_cmd is not None: + self.beacon.beacon_contact_set_sensitivity_cmd.send( + [self.beacon.contact_sensitivity] + ) + self.beacon.beacon_contact_home_cmd.send( + [ + etrsync.get_oid(), + etrsync.REASON_ENDSTOP_HIT, + 0, + 0, + ] + ) + return self._shared._trigger_completion + + def home_wait(self, home_end_time): + try: + ret = self._shared.trsync_stop(home_end_time) + if ret is not None: + return ret + if self.beacon._mcu.is_fileoutput(): + return home_end_time + self.beacon.toolhead.wait_moves() + deadline = self.beacon.reactor.monotonic() + 0.5 + while True: + ret = self.beacon.beacon_contact_query_cmd.send([]) + if ret["triggered"] == 0: + now = self.beacon.reactor.monotonic() + if now >= deadline: + raise self.beacon.printer.command_error( + "Timeout getting contact time" + ) + self.beacon.reactor.pause(now + 0.001) + continue + time = self.beacon._clock32_to_time(ret["detect_clock"]) + ffi_main, ffi_lib = chelper.get_ffi() + data = ffi_main.new("struct pull_move[1]") + count = ffi_lib.trapq_extract_old(self.beacon.trapq, data, 1, 0.0, time) + if time >= home_end_time: + return 0.0 + if count: + accel = data[0].accel + if accel < 0: + logging.info("Contact triggered while decelerating") + raise self.beacon.printer.command_error( + "No trigger on probe after full movement" + ) + elif accel > 0: + raise self.beacon.printer.command_error( + "Contact triggered while accelerating" + ) + return time + finally: + self.beacon.beacon_contact_stop_home_cmd.send() + + def query_endstop(self, print_time): + return 0 + + def get_position_endstop(self): + return 0 + + +HOMING_AUTOCAL_CALIBRATE_ALWAYS = 0 +HOMING_AUTOCAL_CALIBRATE_UNHOMED = 1 +HOMING_AUTOCAL_CALIBRATE_NEVER = 2 +HOMING_AUTOCAL_CALIBRATE_CHOICES = { + "always": HOMING_AUTOCAL_CALIBRATE_ALWAYS, + "unhomed": HOMING_AUTOCAL_CALIBRATE_UNHOMED, + "never": HOMING_AUTOCAL_CALIBRATE_NEVER, +} +HOMING_AUTOCAL_METHOD_CONTACT = 0 +HOMING_AUTOCAL_METHOD_PROXIMITY = 1 +HOMING_AUTOCAL_METHOD_PROXIMITY_IF_AVAILABLE = 2 +HOMING_AUTOCAL_METHOD_CHOICES = { + "contact": HOMING_AUTOCAL_METHOD_CONTACT, + "proximity": HOMING_AUTOCAL_METHOD_PROXIMITY, + "proximity_if_available": HOMING_AUTOCAL_METHOD_PROXIMITY_IF_AVAILABLE, +} +HOMING_AUTOCAL_CHOICES_METHOD = {v: k for k, v in HOMING_AUTOCAL_METHOD_CHOICES.items()} + + +class BeaconHomingHelper: + @classmethod + def create(cls, beacon, config): + home_xy_position = config.getfloatlist("home_xy_position", None, count=2) + if home_xy_position is None: + return None + return BeaconHomingHelper(beacon, config, home_xy_position) + + def __init__(self, beacon, config, home_xy_position): + self.beacon = beacon + self.home_pos = home_xy_position + + for section in ["safe_z_home", "homing_override"]: + if config.has_section(section): + raise config.error( + "home_xy_position cannot be used with [%s]" % (section,) + ) + + self.z_hop = config.getfloat("home_z_hop", 0.0) + self.z_hop_speed = config.getfloat("home_z_hop_speed", 15.0, above=0.0) + self.xy_move_speed = config.getfloat("home_xy_move_speed", 50.0, above=0.0) + self.home_y_before_x = config.getboolean("home_y_before_x", False) + self.method = config.getchoice( + "home_method", HOMING_AUTOCAL_METHOD_CHOICES, "proximity" + ) + self.method_when_homed = config.getchoice( + "home_method_when_homed", + HOMING_AUTOCAL_METHOD_CHOICES, + HOMING_AUTOCAL_CHOICES_METHOD[self.method], + ) + self.autocal_create_model = config.getchoice( + "home_autocalibrate", HOMING_AUTOCAL_CALIBRATE_CHOICES, "always" + ) + + gcode_macro = beacon.printer.load_object(config, "gcode_macro") + self.tmpl_pre_xy = gcode_macro.load_template(config, "home_gcode_pre_xy", "") + self.tmpl_post_xy = gcode_macro.load_template(config, "home_gcode_post_xy", "") + self.tmpl_pre_x = gcode_macro.load_template(config, "home_gcode_pre_x", "") + self.tmpl_post_x = gcode_macro.load_template(config, "home_gcode_post_x", "") + self.tmpl_pre_y = gcode_macro.load_template(config, "home_gcode_pre_y", "") + self.tmpl_post_y = gcode_macro.load_template(config, "home_gcode_post_y", "") + self.tmpl_pre_z = gcode_macro.load_template(config, "home_gcode_pre_z", "") + self.tmpl_post_z = gcode_macro.load_template(config, "home_gcode_post_z", "") + + # Ensure homing is loaded so we can override G28 + beacon.printer.load_object(config, "homing") + self.gcode = gcode = beacon.gcode + self.prev_gcmd = gcode.register_command("G28", None) + gcode.register_command("G28", self.cmd_G28) + + def _maybe_zhop(self, toolhead): + if self.z_hop != 0: + curtime = self.beacon.reactor.monotonic() + kin = toolhead.get_kinematics() + kin_status = kin.get_status(curtime) + pos = toolhead.get_position() + + move = [None, None, self.z_hop] + if "z" not in kin_status["homed_axes"]: + pos[2] = 0 + self.beacon.compat_toolhead_set_position_homing_z(toolhead, pos) + toolhead.manual_move(move, self.z_hop_speed) + toolhead.wait_moves() + self.beacon.compat_kin_note_z_not_homed(kin) + elif pos[2] < self.z_hop: + toolhead.manual_move(move, self.z_hop_speed) + toolhead.wait_moves() + + def _run_hook(self, template, params, raw_params): + ctx = template.create_template_context() + ctx["params"] = params + ctx["rawparams"] = raw_params + template.run_gcode_from_command(ctx) + + def cmd_G28(self, gcmd): + toolhead = self.beacon.printer.lookup_object("toolhead") + orig_params = gcmd.get_command_parameters() + raw_params = gcmd.get_raw_command_parameters() + + self._maybe_zhop(toolhead) + + want_x, want_y, want_z = [gcmd.get(a, None) is not None for a in "XYZ"] + # No axes given => home them all + if not (want_x or want_y or want_z): + want_x = want_y = want_z = True + + if want_x or want_y: + self._run_hook(self.tmpl_pre_xy, orig_params, raw_params) + if self.home_y_before_x: + axis_order = "yx" + else: + axis_order = "xy" + for axis in axis_order: + if axis == "x" and want_x: + self._run_hook(self.tmpl_pre_x, orig_params, raw_params) + cmd = self.gcode.create_gcode_command("G28", "G28", {"X": "0"}) + self.prev_gcmd(cmd) + self._run_hook(self.tmpl_post_x, orig_params, raw_params) + elif axis == "y" and want_y: + self._run_hook(self.tmpl_pre_y, orig_params, raw_params) + cmd = self.gcode.create_gcode_command("G28", "G28", {"Y": "0"}) + self.prev_gcmd(cmd) + self._run_hook(self.tmpl_post_y, orig_params, raw_params) + self._run_hook(self.tmpl_post_xy, orig_params, raw_params) + + if want_z: + self._run_hook(self.tmpl_pre_z, orig_params, raw_params) + curtime = self.beacon.reactor.monotonic() + kin = toolhead.get_kinematics() + kin_status = kin.get_status(curtime) + if "xy" not in kin_status["homed_axes"]: + raise gcmd.error("Must home X and Y axes before homing Z") + + method = self.method + if "z" in kin_status["homed_axes"]: + method = self.method_when_homed + + # G28 is not normally an extended gcode, so we need this hack + args = gcmd.get_commandline().split(" ") + for arg in args: + kv = arg.split("=") + if len(kv) == 2 and kv[0].strip().lower() == "method": + method = HOMING_AUTOCAL_METHOD_CHOICES.get( + kv[1].strip().lower(), None + ) + if method is None: + raise gcmd.error( + "Invalid homing method, valid choices: proximity, proximity_if_available, contact" + ) + break + + pos = [self.home_pos[0], self.home_pos[1]] + + if method == HOMING_AUTOCAL_METHOD_PROXIMITY_IF_AVAILABLE: + if self.beacon.model is not None: + method = HOMING_AUTOCAL_METHOD_PROXIMITY + else: + method = HOMING_AUTOCAL_METHOD_CONTACT + + if method == HOMING_AUTOCAL_METHOD_CONTACT: + toolhead.manual_move(pos, self.xy_move_speed) + + calibrate = True + if self.autocal_create_model == HOMING_AUTOCAL_CALIBRATE_UNHOMED: + calibrate = "z" not in kin_status["homed_axes"] + elif self.autocal_create_model == HOMING_AUTOCAL_CALIBRATE_NEVER: + calibrate = False + + override = gcmd.get("CALIBRATE", None) + if override is not None: + if override.lower() in ["=0", "=no", "=false"]: + calibrate = False + else: + calibrate = True + + cmd = "BEACON_AUTO_CALIBRATE" + params = {} + if not calibrate: + params["SKIP_MODEL_CREATION"] = "1" + cmd = self.gcode.create_gcode_command(cmd, cmd, params) + self.beacon.cmd_BEACON_AUTO_CALIBRATE(cmd) + elif method == HOMING_AUTOCAL_METHOD_PROXIMITY: + pos[0] -= self.beacon.x_offset + pos[1] -= self.beacon.y_offset + toolhead.manual_move(pos, self.xy_move_speed) + cmd = self.gcode.create_gcode_command("G28", "G28", {"Z": "0"}) + self.prev_gcmd(cmd) + else: + raise gcmd.error("Invalid homing method '%s'" % (method,)) + self._maybe_zhop(toolhead) + self._run_hook(self.tmpl_post_z, orig_params, raw_params) + + +class BeaconHomingState: + def get_axes(self): + return [2] + + def get_trigger_position(self, stepper_name): + raise Exception("get_trigger_position not supported") + + def set_stepper_adjustment(self, stepper_name, adjustment): + pass + + def set_homed_position(self, pos): + pass + + +class BeaconMeshHelper: + @classmethod + def create(cls, beacon, config): + if config.has_section("bed_mesh"): + mesh_config = config.getsection("bed_mesh") + if mesh_config.get("mesh_radius", None) is not None: + return None # Use normal bed meshing for round beds + return BeaconMeshHelper(beacon, config, mesh_config) + else: + return None + + def __init__(self, beacon, config, mesh_config): + self.beacon = beacon + self.scipy = None + self.mesh_config = mesh_config + self.bm = self.beacon.printer.load_object(mesh_config, "bed_mesh") + + self.speed = mesh_config.getfloat("speed", 50.0, above=0.0, note_valid=False) + self.def_min_x, self.def_min_y = mesh_config.getfloatlist( + "mesh_min", count=2, note_valid=False + ) + self.def_max_x, self.def_max_y = mesh_config.getfloatlist( + "mesh_max", count=2, note_valid=False + ) + + if self.def_min_x > self.def_max_x: + self.def_min_x, self.def_max_x = self.def_max_x, self.def_min_x + if self.def_min_y > self.def_max_y: + self.def_min_y, self.def_max_y = self.def_max_y, self.def_min_y + + self.def_res_x, self.def_res_y = mesh_config.getintlist( + "probe_count", count=2, note_valid=False + ) + self.rri = mesh_config.getint( + "relative_reference_index", None, note_valid=False + ) + self.zero_ref_pos = mesh_config.getfloatlist( + "zero_reference_position", None, count=2 + ) + self.zero_ref_pos_cluster_size = config.getfloat( + "zero_reference_cluster_size", 1, minval=0 + ) + self.dir = config.getchoice( + "mesh_main_direction", {"x": "x", "X": "x", "y": "y", "Y": "y"}, "y" + ) + self.overscan = config.getfloat("mesh_overscan", -1, minval=0) + self.cluster_size = config.getfloat("mesh_cluster_size", 1, minval=0) + self.runs = config.getint("mesh_runs", 1, minval=1) + self.adaptive_margin = mesh_config.getfloat( + "adaptive_margin", 0, note_valid=False + ) + + contact_def_min = config.getfloatlist( + "contact_mesh_min", + default=None, + count=2, + ) + contact_def_max = config.getfloatlist( + "contact_mesh_max", + default=None, + count=2, + ) + + xo = self.beacon.x_offset + yo = self.beacon.y_offset + + def_contact_min = contact_def_min + if contact_def_min is None: + def_contact_min = ( + max(self.def_min_x - xo, self.def_min_x), + max(self.def_min_y - yo, self.def_min_y), + ) + + def_contact_max = contact_def_max + if contact_def_max is None: + def_contact_max = ( + min(self.def_max_x - xo, self.def_max_x), + min(self.def_max_y - yo, self.def_max_y), + ) + + min_x = def_contact_min[0] + max_x = def_contact_max[0] + min_y = def_contact_min[1] + max_y = def_contact_max[1] + self.def_contact_min = (min(min_x, max_x), min(min_y, max_y)) + self.def_contact_max = (max(min_x, max_x), max(min_y, max_y)) + + if self.zero_ref_pos is not None and self.rri is not None: + logging.info( + "beacon: both 'zero_reference_position' and " + "'relative_reference_index' options are specified. The" + " former will be used" + ) + + self.faulty_regions = [] + for i in list(range(1, 100, 1)): + start = mesh_config.getfloatlist( + "faulty_region_%d_min" % (i,), None, count=2 + ) + if start is None: + break + end = mesh_config.getfloatlist("faulty_region_%d_max" % (i,), count=2) + x_min = min(start[0], end[0]) + x_max = max(start[0], end[0]) + y_min = min(start[1], end[1]) + y_max = max(start[1], end[1]) + self.faulty_regions.append(Region(x_min, x_max, y_min, y_max)) + + self.exclude_object = None + beacon.printer.register_event_handler("klippy:connect", self._handle_connect) + + self.gcode = beacon.gcode + self.prev_gcmd = self.gcode.register_command("BED_MESH_CALIBRATE", None) + self.gcode.register_command( + "BED_MESH_CALIBRATE", + self.cmd_BED_MESH_CALIBRATE, + desc=self.cmd_BED_MESH_CALIBRATE_help, + ) + + cmd_BED_MESH_CALIBRATE_help = "Perform Mesh Bed Leveling" + + def cmd_BED_MESH_CALIBRATE(self, gcmd): + method = gcmd.get("METHOD", "beacon").lower() + probe_method = gcmd.get( + "PROBE_METHOD", self.beacon.default_probe_method + ).lower() + if probe_method != "proximity": + method = "automatic" + if method == "beacon": + self.calibrate(gcmd) + else: + # For backwards compatibility, ZRP is specified in probe coordinates. + # When in contact mode, we need to remove the offset first + if hasattr(self.bm.bmc, "zero_ref_pos"): + zrp = self.zero_ref_pos + if zrp is not None and probe_method == "contact": + zrp = (zrp[0] + self.beacon.x_offset, zrp[1] + self.beacon.y_offset) + self.bm.bmc.zero_ref_pos = zrp + # In contact mode, clamp MESH_MIN and MESH_MAX in case they aren't given, to + # ensure the requested area is safe to probe. This results in a slightly smaller + # mesh but guarantees it can be processed. + if probe_method == "contact": + params = gcmd.get_command_parameters() + extra_params = {} + if "MESH_MIN" not in params: + extra_params["MESH_MIN"] = ",".join(map(str, self.def_contact_min)) + if "MESH_MAX" not in params: + extra_params["MESH_MAX"] = ",".join(map(str, self.def_contact_max)) + if extra_params: + extra_params.update(params) + gcmd = self.gcode.create_gcode_command( + gcmd.get_command(), + gcmd.get_commandline() + + "".join([" " + k + "=" + v for k, v in extra_params.items()]), + extra_params, + ) + self.beacon._current_probe = probe_method + self.prev_gcmd(gcmd) + + def _handle_connect(self): + self.exclude_object = self.beacon.printer.lookup_object("exclude_object", None) + + if self.overscan < 0: + # Auto determine a safe overscan amount + toolhead = self.beacon.printer.lookup_object("toolhead") + curtime = self.beacon.reactor.monotonic() + status = toolhead.get_kinematics().get_status(curtime) + xo = self.beacon.x_offset + yo = self.beacon.y_offset + settings = { + "x": { + "range": [self.def_min_x - xo, self.def_max_x - xo], + "machine": [status["axis_minimum"][0], status["axis_maximum"][0]], + "count": self.def_res_y, + }, + "y": { + "range": [self.def_min_y - yo, self.def_max_y - yo], + "machine": [status["axis_minimum"][1], status["axis_maximum"][1]], + "count": self.def_res_x, + }, + }[self.dir] + + r = settings["range"] + m = settings["machine"] + space = (r[1] - r[0]) / (float(settings["count"] - 1)) + self.overscan = min( + [ + max(0, r[0] - m[0]), + max(0, m[1] - r[1]), + space + 2.0, # A half circle with 2mm lead in/out + ] + ) + + def _generate_path(self): + xo = self.beacon.x_offset + yo = self.beacon.y_offset + settings = { + "x": { + "range_aligned": [self.min_x - xo, self.max_x - xo], + "range_perpendicular": [self.min_y - yo, self.max_y - yo], + "count": self.res_y, + "swap_coord": False, + }, + "y": { + "range_aligned": [self.min_y - yo, self.max_y - yo], + "range_perpendicular": [self.min_x - xo, self.max_x - xo], + "count": self.res_x, + "swap_coord": True, + }, + }[self.dir] + + # We build the path in "normalized" coordinates and then simply + # swap x and y at the end if we need to + begin_a, end_a = settings["range_aligned"] + begin_p, end_p = settings["range_perpendicular"] + swap_coord = settings["swap_coord"] + step = (end_p - begin_p) / (float(settings["count"] - 1)) + points = [] + corner_radius = min(step / 2, self.overscan) + for i in range(0, settings["count"]): + pos_p = begin_p + step * i + even = i % 2 == 0 # If even we are going 'right', else 'left' + pa = (begin_a, pos_p) if even else (end_a, pos_p) + pb = (end_a, pos_p) if even else (begin_a, pos_p) + + line = (pa, pb) + + if len(points) > 0 and corner_radius > 0: + # We need to insert an overscan corner. Basically we insert + # a rounded rectangle to smooth out the transition and retain + # as much speed as we can. + # + # ---|---< + # / + # | + # \ + # ---|---> + # + # We just need to draw the two 90 degree arcs. They contain + # the endpoints of the lines connecting everything. + if even: + center = begin_a - self.overscan + corner_radius + points += arc_points( + center, pos_p - step + corner_radius, corner_radius, -90, -90 + ) + points += arc_points( + center, pos_p - corner_radius, corner_radius, -180, -90 + ) + else: + center = end_a + self.overscan - corner_radius + points += arc_points( + center, pos_p - step + corner_radius, corner_radius, -90, 90 + ) + points += arc_points( + center, pos_p - corner_radius, corner_radius, 0, 90 + ) + + points.append(line[0]) + points.append(line[1]) + + if swap_coord: + for i in range(len(points)): + x, y = points[i] + points[i] = (y, x) + + return points + + def calibrate(self, gcmd): + use_full = gcmd.get_int("USE_CONTACT_AREA", 0) == 0 + self.min_x, self.min_y = coord_fallback( + gcmd, + "MESH_MIN", + float_parse, + self.def_min_x if use_full else self.def_contact_min[0], + self.def_min_y if use_full else self.def_contact_min[1], + lambda v, d: max(v, d), + ) + self.max_x, self.max_y = coord_fallback( + gcmd, + "MESH_MAX", + float_parse, + self.def_max_x if use_full else self.def_contact_max[0], + self.def_max_y if use_full else self.def_contact_max[1], + lambda v, d: min(v, d), + ) + self.res_x, self.res_y = coord_fallback( + gcmd, + "PROBE_COUNT", + int, + self.def_res_x, + self.def_res_y, + lambda v, _d: max(v, 3), + ) + self.profile_name = gcmd.get("PROFILE", "default") + + if self.min_x > self.max_x: + self.min_x, self.max_x = ( + max(self.max_x, self.def_min_x), + min(self.min_x, self.def_max_x), + ) + if self.min_y > self.max_y: + self.min_y, self.max_y = ( + max(self.max_y, self.def_min_y), + min(self.min_y, self.def_max_y), + ) + + # If the user gave RRI _on gcode_ then use it, else use zero_ref_pos + # if we have it, and finally use config RRI if we have it. + rri = gcmd.get_int("RELATIVE_REFERENCE_INDEX", None) + if rri is not None: + self.zero_ref_mode = ("rri", rri) + elif self.zero_ref_pos is not None: + self.zero_ref_mode = ("pos", self.zero_ref_pos) + self.zero_ref_val = None + self.zero_ref_bin = [] + elif self.rri is not None: + self.zero_ref_mode = ("rri", self.rri) + else: + self.zero_ref_mode = None + + # If the user requested adaptive meshing, try to shrink the values we just configured + if gcmd.get_int("ADAPTIVE", 0): + if self.exclude_object is not None: + margin = gcmd.get_float("ADAPTIVE_MARGIN", self.adaptive_margin) + self._shrink_to_excluded_objects(gcmd, margin) + else: + gcmd.respond_info( + "Requested adaptive mesh, but [exclude_object] is not enabled. Ignoring." + ) + + self.step_x = (self.max_x - self.min_x) / (self.res_x - 1) + self.step_y = (self.max_y - self.min_y) / (self.res_y - 1) + + self.toolhead = self.beacon.toolhead + path = self._generate_path() + + probe_speed = gcmd.get_float("PROBE_SPEED", self.beacon.speed, above=0.0) + self.beacon._move_to_probing_height(probe_speed) + + speed = gcmd.get_float("SPEED", self.speed, above=0.0) + runs = gcmd.get_int("RUNS", self.runs, minval=1) + + try: + self.beacon._start_streaming() + + # Move to first location + x, y = path[0] + self.toolhead.manual_move([x, y, None], speed) + self.toolhead.wait_moves() + + self.beacon._sample_printtime_sync(5) + clusters = self._sample_mesh(gcmd, path, speed, runs) + + if self.zero_ref_mode and self.zero_ref_mode[0] == "pos": + # If we didn't collect anything, hop over to the zero point + # and sample. Otherwise, grab the median of what we collected. + if len(self.zero_ref_bin) == 0: + self._collect_zero_ref(speed, self.zero_ref_mode[1]) + else: + self.zero_ref_val = median(self.zero_ref_bin) + + finally: + self.beacon._stop_streaming() + + matrix = self._process_clusters(clusters, gcmd) + self._apply_mesh(matrix, gcmd) + + def _shrink_to_excluded_objects(self, gcmd, margin): + bound_min_x, bound_max_x = None, None + bound_min_y, bound_max_y = None, None + objects = self.exclude_object.get_status().get("objects", {}) + if len(objects) == 0: + return + + for obj in objects: + for point in obj["polygon"]: + bound_min_x = opt_min(bound_min_x, point[0]) + bound_max_x = opt_max(bound_max_x, point[0]) + bound_min_y = opt_min(bound_min_y, point[1]) + bound_max_y = opt_max(bound_max_y, point[1]) + bound_min_x -= margin + bound_max_x += margin + bound_min_y -= margin + bound_max_y += margin + + # Calculate original step size and apply the new bounds + orig_span_x = self.max_x - self.min_x + orig_span_y = self.max_y - self.min_y + + if bound_min_x >= self.min_x: + self.min_x = bound_min_x + if bound_max_x <= self.max_x: + self.max_x = bound_max_x + if bound_min_y >= self.min_y: + self.min_y = bound_min_y + if bound_max_y <= self.max_y: + self.max_y = bound_max_y + + # Update resolution to retain approximately the same step size as before + self.res_x = int( + math.ceil(self.res_x * (self.max_x - self.min_x) / orig_span_x) + ) + self.res_y = int( + math.ceil(self.res_y * (self.max_y - self.min_y) / orig_span_y) + ) + # Guard against bicubic interpolation with 3 points on one axis + min_res = 3 + if max(self.res_x, self.res_y) > 6 and min(self.res_x, self.res_y) < 4: + min_res = 4 + self.res_x = max(self.res_x, min_res) + self.res_y = max(self.res_y, min_res) + + self.profile_name = None + + def _fly_path(self, path, speed, runs): + # Run through the path + for i in range(runs): + p = path if i % 2 == 0 else reversed(path) + for x, y in p: + self.toolhead.manual_move([x, y, None], speed) + self.toolhead.dwell(0.251) + self.toolhead.wait_moves() + + def _collect_zero_ref(self, speed, coord): + xo, yo = self.beacon.x_offset, self.beacon.y_offset + x, y = coord + self.toolhead.manual_move([x - xo, y - yo, None], speed) + dist, _samples = self.beacon._sample(50, 10) + self.zero_ref_val = dist + + def _is_valid_position(self, x, y): + return self.min_x <= x <= self.max_x and self.min_y <= y <= self.min_y + + def _is_faulty_coordinate(self, x, y, add_offsets=False): + if add_offsets: + xo, yo = self.beacon.x_offset, self.beacon.y_offset + x += xo + y += yo + for r in self.faulty_regions: + if r.is_point_within(x, y): + return True + return False + + def _sample_mesh(self, gcmd, path, speed, runs): + cs = gcmd.get_float("CLUSTER_SIZE", self.cluster_size, minval=0.0) + zcs = self.zero_ref_pos_cluster_size + if not (self.zero_ref_mode and self.zero_ref_mode[0] == "pos"): + zcs = 0 + + min_x, min_y = self.min_x, self.min_y + xo, yo = self.beacon.x_offset, self.beacon.y_offset + + clusters = {} + total_samples = [0] + invalid_samples = [0] + + def cb(sample): + total_samples[0] += 1 + d = sample["dist"] + x, y, z = sample["pos"][:3] + x += xo + y += yo + + if d is None or math.isinf(d): + if self._is_valid_position(x, y): + invalid_samples[0] += 1 + return + + # Calculate coordinate of the cluster we are in + xi = int(round((x - min_x) / self.step_x)) + yi = int(round((y - min_y) / self.step_y)) + if xi < 0 or self.res_x <= xi or yi < 0 or self.res_y <= yi: + return + + # If there's a cluster size limit, apply it here + if cs > 0: + xf = xi * self.step_x + min_x + yf = yi * self.step_y + min_y + dx = x - xf + dy = y - yf + dist = math.sqrt(dx * dx + dy * dy) + if dist > cs: + return + + # If we are looking for a zero reference, check if we + # are close enough and if so, add to the bin. + if zcs > 0: + dx = x - self.zero_ref_mode[1][0] + dy = y - self.zero_ref_mode[1][1] + dist = math.sqrt(dx * dx + dy * dy) + if dist <= zcs: + self.zero_ref_bin.append(d) + + k = (xi, yi) + + if k not in clusters: + clusters[k] = [] + clusters[k].append(d) + + with self.beacon.streaming_session(cb): + self._fly_path(path, speed, runs) + + gcmd.respond_info( + "Sampled %d total points over %d runs" % (total_samples[0], runs) + ) + if invalid_samples[0]: + gcmd.respond_info( + "!! Encountered %d invalid samples!" % (invalid_samples[0],) + ) + gcmd.respond_info("Samples binned in %d clusters" % (len(clusters),)) + + return clusters + + def _process_clusters(self, raw_clusters, gcmd): + parent_conn, child_conn = multiprocessing.Pipe() + dump_file = gcmd.get("FILENAME", None) + + def do(): + try: + child_conn.send( + (False, self._do_process_clusters(raw_clusters, dump_file)) + ) + except Exception: + child_conn.send((True, traceback.format_exc())) + child_conn.close() + + child = multiprocessing.Process(target=do) + child.daemon = True + child.start() + reactor = self.beacon.reactor + eventtime = reactor.monotonic() + while child.is_alive(): + eventtime = reactor.pause(eventtime + 0.1) + is_err, result = parent_conn.recv() + child.join() + parent_conn.close() + if is_err: + raise Exception("Error processing mesh: %s" % (result,)) + else: + is_inner_err, inner_result = result + if is_inner_err: + raise gcmd.error(inner_result) + else: + return inner_result + + def _do_process_clusters(self, raw_clusters, dump_file): + if dump_file: + with open(dump_file, "w") as f: + f.write("x,y,xp,xy,dist\n") + for yi in range(self.res_y): + for xi in range(self.res_x): + cluster = raw_clusters.get((xi, yi), []) + xp = xi * self.step_x + self.min_x + yp = yi * self.step_y + self.min_y + for dist in cluster: + f.write("%d,%d,%f,%f,%f\n" % (xi, yi, xp, yp, dist)) + + mask = self._generate_fault_mask() + matrix, faulty_regions = self._generate_matrix(raw_clusters, mask) + if len(faulty_regions) > 0: + error, interpolator_or_msg = self._load_interpolator() + if error: + return (True, interpolator_or_msg) + matrix = self._interpolate_faulty( + matrix, faulty_regions, interpolator_or_msg + ) + err = self._check_matrix(matrix) + if err is not None: + return (True, err) + return (False, self._finalize_matrix(matrix)) + + def _generate_fault_mask(self): + if len(self.faulty_regions) == 0: + return None + mask = np.full((self.res_y, self.res_x), True) + for r in self.faulty_regions: + r_xmin = max(0, int(math.ceil((r.x_min - self.min_x) / self.step_x))) + r_ymin = max(0, int(math.ceil((r.y_min - self.min_y) / self.step_y))) + r_xmax = min( + self.res_x - 1, int(math.floor((r.x_max - self.min_x) / self.step_x)) + ) + r_ymax = min( + self.res_y - 1, int(math.floor((r.y_max - self.min_y) / self.step_y)) + ) + for y in range(r_ymin, r_ymax + 1): + for x in range(r_xmin, r_xmax + 1): + mask[(y, x)] = False + return mask + + def _generate_matrix(self, raw_clusters, mask): + faulty_indexes = [] + matrix = np.empty((self.res_y, self.res_x)) + for (x, y), values in raw_clusters.items(): + if mask is None or mask[(y, x)]: + matrix[(y, x)] = self.beacon.trigger_distance - median(values) + else: + matrix[(y, x)] = np.nan + faulty_indexes.append((y, x)) + return matrix, faulty_indexes + + def _load_interpolator(self): + if not self.scipy: + try: + self.scipy = importlib.import_module("scipy") + except ImportError: + msg = ( + "Could not load `scipy`. To install it, simply re-run " + "the Beacon `install.sh` script. This module is required " + "when using faulty regions when bed meshing." + ) + return (True, msg) + if hasattr(self.scipy.interpolate, "RBFInterpolator"): + + def rbf_interp(points, values, faulty): + return self.scipy.interpolate.RBFInterpolator(points, values, 64)( + faulty + ) + + return (False, rbf_interp) + else: + + def linear_interp(points, values, faulty): + return self.scipy.interpolate.griddata( + points, values, faulty, method="linear" + ) + + def _cluster_mean(self, data): + median_count = max(0, int(math.floor(len(data) / 6))) + return float(np.mean(np.sort(data)[median_count : len(data) - median_count])) + + def _interpolate_faulty(self, matrix, faulty_indexes, interpolator): + ys, xs = np.mgrid[0 : matrix.shape[0], 0 : matrix.shape[1]] + points = np.array([ys.flatten(), xs.flatten()]).T + values = matrix.reshape(-1) + good = ~np.isnan(values) + fixed = interpolator(points[good], values[good], faulty_indexes) + matrix[tuple(np.array(faulty_indexes).T)] = fixed + return matrix + + def _check_matrix(self, matrix): + empty_clusters = [] + for yi in range(self.res_y): + for xi in range(self.res_x): + if np.isnan(matrix[(yi, xi)]): + xc = xi * self.step_x + self.min_x + yc = yi * self.step_y + self.min_y + empty_clusters.append(" (%.3f,%.3f)[%d,%d]" % (xc, yc, xi, yi)) + if empty_clusters: + err = ( + "Empty clusters found\n" + "Try increasing mesh cluster_size or slowing down.\n" + "The following clusters were empty:\n" + ) + "\n".join(empty_clusters) + return err + else: + return None + + def _finalize_matrix(self, matrix): + z_offset = None + if self.zero_ref_mode and self.zero_ref_mode[0] == "rri": + rri = self.zero_ref_mode[1] + if rri < 0 or rri >= self.res_x * self.res_y: + rri = None + if rri is not None: + rri_x = rri % self.res_x + rri_y = int(math.floor(rri / self.res_x)) + z_offset = matrix[rri_y][rri_x] + elif self.zero_ref_mode and self.zero_ref_mode[0] == "pos": + z_offset = self.beacon.trigger_distance - self.zero_ref_val + + if z_offset is not None: + matrix = matrix - z_offset + return matrix.tolist() + + def _apply_mesh(self, matrix, gcmd): + params = self.bm.bmc.mesh_config.copy() + params["min_x"] = self.min_x + params["max_x"] = self.max_x + params["min_y"] = self.min_y + params["max_y"] = self.max_y + params["x_count"] = self.res_x + params["y_count"] = self.res_y + try: + mesh = bed_mesh.ZMesh(params) + except TypeError: + mesh = bed_mesh.ZMesh(params, self.profile_name) + try: + mesh.build_mesh(matrix) + except bed_mesh.BedMeshError as e: + raise self.gcode.error(str(e)) + self.bm.set_mesh(mesh) + self.gcode.respond_info("Mesh calibration complete") + if self.profile_name is not None: + self.bm.save_profile(self.profile_name) + + +class Region: + def __init__(self, x_min, x_max, y_min, y_max): + self.x_min = x_min + self.x_max = x_max + self.y_min = y_min + self.y_max = y_max + + def is_point_within(self, x, y): + return (x > self.x_min and x < self.x_max) and ( + y > self.y_min and y < self.y_max + ) + + +def arc_points(cx, cy, r, start_angle, span): + # Angle delta is determined by a max deviation(md) from 0.1mm: + # r * versin(d_a) < md + # versin(d_a) < md/r + # d_a < arcversin(md/r) + # d_a < arccos(1-md/r) + # We then determine how many of these we can fit in exactly + # 90 degrees(rounding up) and then determining the exact + # delta angle. + start_angle = start_angle / 180.0 * math.pi + span = span / 180.0 * math.pi + d_a = math.acos(1 - 0.1 / r) + cnt = int(math.ceil(abs(span) / d_a)) + d_a = span / float(cnt) + + points = [] + for i in range(cnt + 1): + ang = start_angle + d_a * float(i) + x = cx + math.cos(ang) * r + y = cy + math.sin(ang) * r + points.append((x, y)) + + return points + + +def coord_fallback(gcmd, name, parse, def_x, def_y, map=lambda v, d: v): + param = gcmd.get(name, None) + if param is not None: + try: + x, y = [parse(p.strip()) for p in param.split(",", 1)] + return map(x, def_x), map(y, def_y) + except Exception: + raise gcmd.error("Unable to parse parameter '%s'" % (name,)) + else: + return def_x, def_y + + +def float_parse(s): + v = float(s) + if math.isinf(v) or np.isnan(v): + raise ValueError("could not convert string to float: '%s'" % (s,)) + return v + + +def median(samples): + return float(np.median(samples)) + + +def opt_min(a, b): + if a is None: + return b + return min(a, b) + + +def opt_max(a, b): + if a is None: + return b + return max(a, b) + + +GRAVITY = 9.80655 +ACCEL_BYTES_PER_SAMPLE = 6 + +Accel_Measurement = collections.namedtuple( + "Accel_Measurement", ("time", "accel_x", "accel_y", "accel_z") +) + + +class BeaconAccelDummyConfig(object): + error = configfile.error + + def __init__(self, beacon, accel_config): + self.beacon = beacon + self.accel_config = accel_config + + def get_name(self): + return "beacon_accel " + ( + self.accel_config.accel_name or self.accel_config.default_name + ) + + def get_printer(self): + return self.beacon.printer + + +class BeaconAccelConfig(object): + def __init__(self, config, sensor_id): + self.default_scale = config.get("accel_scale", "") + axes = { + "x": (0, 1), + "-x": (0, -1), + "y": (1, 1), + "-y": (1, -1), + "z": (2, 1), + "-z": (2, -1), + } + axes_map = config.getlist("accel_axes_map", ("x", "y", "z"), count=3) + self.axes_map = [] + for a in axes_map: + a = a.strip() + if a not in axes: + raise config.error("Invalid accel_axes_map, unknown axes '%s'" % (a,)) + self.axes_map.append(axes[a]) + + self.default_name = ( + "beacon" if sensor_id.is_unnamed() else "beacon_" + sensor_id.name + ) + self.accel_name = config.get( + "accel_name", None if sensor_id.is_unnamed() else self.default_name + ) + + +class BeaconAccelHelper(object): + def __init__(self, beacon, config, constants): + self.beacon = beacon + self.config = config + + self._api_dump = APIDumpHelper( + beacon.printer, + lambda: self._start_streaming() or True, + lambda _: self._stop_streaming(), + self._api_update, + ) + beacon.id.register_endpoint("beacon/dump_accel", self._handle_req_dump) + cmd_helper = adxl345.AccelCommandHelper( + BeaconAccelDummyConfig(beacon, config), self + ) + if config.accel_name is None: + try: + cmd_helper.register_commands(None) + except beacon.printer.config_error: + pass + + self._stream_en = 0 + self._raw_samples = [] + self._last_raw_sample = (0, 0, 0) + self._sample_lock = threading.Lock() + + beacon.compat_mcu_register_response( + self._handle_accel_data, + "beacon_accel_data start_clock=%u delta_clock=%u data=%*s", + ) + beacon.compat_mcu_register_response( + self._handle_accel_state, "beacon_accel_state en=%c err=%c" + ) + + self.reinit(constants) + + def reinit(self, constants): + bits = constants.get("BEACON_ACCEL_BITS") + self._clip_values = (2 ** (bits - 1) - 1, -(2 ** (bits - 1))) + + self.accel_stream_cmd = self.beacon._mcu.lookup_command( + "beacon_accel_stream en=%c scale=%c", cq=self.beacon.cmd_queue + ) + # Ensure streaming mode is stopped + self.accel_stream_cmd.send([0, 0]) + + self._scales = self._fetch_scales(constants) + self._scale = self._select_scale() + logging.info("Selected Beacon accelerometer scale %s", self._scale["name"]) + + def _fetch_scales(self, constants): + enum = self.beacon._mcu.get_enumerations().get("beacon_accel_scales", None) + if enum is None: + return {} + + scales = {} + self.default_scale_name = self.config.default_scale + first_scale_name = None + for name, id in enum.items(): + try: + scale_val_name = "BEACON_ACCEL_SCALE_%s" % (name.upper(),) + scale_val_str = constants.get(scale_val_name) + scale_val = float(scale_val_str) + except Exception: + logging.error( + "Beacon accelerometer scale %s could not be processed", name + ) + scale_val = 1 # Values will be weird, but scale will work + + if id == 0: + first_scale_name = name + scales[name] = {"name": name, "id": id, "scale": scale_val} + + if not self.default_scale_name: + if first_scale_name is None: + logging.error("Could not determine default Beacon accelerometer scale") + else: + self.default_scale_name = first_scale_name + elif self.default_scale_name not in scales: + logging.error( + "Default Beacon accelerometer scale '%s' not found, using '%s'", + self.default_scale_name, + first_scale_name, + ) + self.default_scale_name = first_scale_name + + return scales + + def _select_scale(self): + scale = self._scales.get(self.default_scale_name, None) + if scale is None: + return {"name": "unknown", "id": 0, "scale": 1} + return scale + + def _handle_accel_data(self, params): + with self._sample_lock: + if self._stream_en: + self._raw_samples.append(params) + else: + self.accel_stream_cmd.send([0, 0]) + + def _handle_accel_state(self, params): + pass + + def _handle_req_dump(self, web_request): + cconn = self._api_dump.add_web_client( + web_request, + lambda buffer: list( + itertools.chain(*map(lambda data: data["data"], buffer)) + ), + ) + cconn.send({"header": ["time", "x", "y", "z"]}) + + # Internal helpers + + def _start_streaming(self): + if self._stream_en == 0: + self._raw_samples = [] + self.accel_stream_cmd.send([1, self._scale["id"]]) + self._stream_en += 1 + + def _stop_streaming(self): + self._stream_en -= 1 + if self._stream_en == 0: + self._raw_samples = [] + self.accel_stream_cmd.send([0, 0]) + + def _process_samples(self, raw_samples, last_sample): + raw = last_sample + (xp, xs), (yp, ys), (zp, zs) = self.config.axes_map + scale = self._scale["scale"] * GRAVITY + xs, ys, zs = xs * scale, ys * scale, zs * scale + + errors = 0 + samples = [] + + def process_value(low, high, last_value): + raw = high << 8 | low + if raw == 0x7FFF: + # Clipped value + return self._clip_values[0 if last_value >= 0 else 1] + return raw - ((high & 0x80) << 9) + + for sample in raw_samples: + tstart = self.beacon._clock32_to_time(sample["start_clock"]) + tend = self.beacon._clock32_to_time( + sample["start_clock"] + sample["delta_clock"] + ) + data = bytearray(sample["data"]) + count = int(len(data) / ACCEL_BYTES_PER_SAMPLE) + dt = (tend - tstart) / (count - 1) + for idx in range(0, count): + base = idx * ACCEL_BYTES_PER_SAMPLE + d = data[base : base + ACCEL_BYTES_PER_SAMPLE] + dxl, dxh, dyl, dyh, dzl, dzh = d + raw = ( + process_value(dxl, dxh, raw[0]), + process_value(dyl, dyh, raw[1]), + process_value(dzl, dzh, raw[2]), + ) + if raw[0] is None or raw[1] is None or raw[2] is None: + errors += 1 + samples.append(None) + else: + samples.append( + ( + tstart + dt * idx, + raw[xp] * xs, + raw[yp] * ys, + raw[zp] * zs, + ) + ) + return (samples, errors, raw) + + # APIDumpHelper callbacks + + def _api_update(self, dump_helper, eventtime): + with self._sample_lock: + raw_samples = self._raw_samples + self._raw_samples = [] + samples, errors, last_raw_sample = self._process_samples( + raw_samples, self._last_raw_sample + ) + if len(samples) == 0: + return + self._last_raw_sample = last_raw_sample + dump_helper.buffer.append( + { + "data": samples, + "errors": errors, + "overflows": 0, + } + ) + + # Accelerometer public interface + + def start_internal_client(self): + cli = AccelInternalClient(self.beacon.printer) + self._api_dump.add_client(cli._handle_data) + return cli + + def read_reg(self, reg): + raise self.beacon.printer.command_error("Not supported") + + def set_reg(self, reg, val, minclock=0): + raise self.beacon.printer.command_error("Not supported") + + def is_measuring(self): + return self._stream_en > 0 + + +class AccelInternalClient: + def __init__(self, printer): + self.printer = printer + self.toolhead = printer.lookup_object("toolhead") + self.is_finished = False + self.request_start_time = self.request_end_time = ( + self.toolhead.get_last_move_time() + ) + self.msgs = [] + self.samples = [] + + def _handle_data(self, msgs): + if self.is_finished: + return False + if len(self.msgs) >= 10000: # Limit capture length + return False + self.msgs.extend(msgs) + return True + + # AccelQueryHelper interface + + def finish_measurements(self): + self.request_end_time = self.toolhead.get_last_move_time() + self.toolhead.wait_moves() + self.is_finished = True + + def has_valid_samples(self): + for msg in self.msgs: + data = msg["data"] + first_sample_time = data[0][0] + last_sample_time = data[-1][0] + if ( + first_sample_time > self.request_end_time + or last_sample_time < self.request_start_time + ): + continue + return True + return False + + def get_samples(self): + if not self.msgs: + return self.samples + + total = sum([len(m["data"]) for m in self.msgs]) + count = 0 + self.samples = samples = [None] * total + for msg in self.msgs: + for samp_time, x, y, z in msg["data"]: + if samp_time < self.request_start_time: + continue + if samp_time > self.request_end_time: + break + samples[count] = Accel_Measurement(samp_time, x, y, z) + count += 1 + del samples[count:] + return self.samples + + def write_to_file(self, filename): + def do_write(): + try: + os.nice(20) + except Exception: + pass + with open(filename, "w") as f: + f.write("#time,accel_x,accel_y,accel_z\n") + samples = self.samples or self.get_samples() + for t, accel_x, accel_y, accel_z in samples: + f.write("%.6f,%.6f,%.6f,%.6f\n" % (t, accel_x, accel_y, accel_z)) + + write_proc = multiprocessing.Process(target=do_write) + write_proc.daemon = True + write_proc.start() + + +class APIDumpHelper: + def __init__(self, printer, start, stop, update): + self.printer = printer + self.start = start + self.stop = stop + self.update = update + self.interval = 0.05 + self.clients = [] + self.stream = None + self.timer = None + self.buffer = [] + + def _start_stop(self): + if not self.stream and self.clients: + self.stream = self.start() + reactor = self.printer.get_reactor() + self.timer = reactor.register_timer( + self._process, reactor.monotonic() + self.interval + ) + elif self.stream is not None and not self.clients: + self.stop(self.stream) + self.stream = None + self.printer.get_reactor().unregister_timer(self.timer) + self.timer = None + + def _process(self, eventtime): + if self.update is not None: + self.update(self, eventtime) + if self.buffer: + for cb in list(self.clients): + if not cb(self.buffer): + self.clients.remove(cb) + self._start_stop() + self.buffer = [] + return eventtime + self.interval + + def add_client(self, client): + self.clients.append(client) + self._start_stop() + + def add_web_client(self, web_request, formatter=lambda v: v): + cconn = web_request.get_client_connection() + template = web_request.get_dict("response_template", {}) + + def cb(items): + if cconn.is_closed(): + return False + tmp = dict(template) + tmp["params"] = formatter(items) + cconn.send(tmp) + return True + + self.add_client(cb) + return cconn + + +class BeaconTracker: + def __init__(self, config, printer): + self.config = config + self.printer = printer + self.sensors = {} + self.gcodes = {} + self.endpoints = {} + self.gcode = printer.lookup_object("gcode") + self.webhooks = printer.lookup_object("webhooks") + + def get_status(self, eventtime): + return {"sensors": list(self.sensors.keys())} + + def home_dir(self): + return os.path.dirname(os.path.realpath(__file__)) + + def add_sensor(self, name): + if name is None: + cfg = self.config.getsection("beacon") + else: + if not name.islower(): + raise self.config.error( + "Beacon sensor name must be all lower case, sensor name '%s' is not valid" + % (name,) + ) + cfg = self.config.getsection("beacon sensor " + name) + self.sensors[name] = sensor = BeaconProbe(cfg, BeaconId(name, self)) + coil_name = "beacon_coil" if name is None else "beacon_%s_coil" % (name,) + temp = BeaconTempWrapper(sensor) + self.printer.add_object("temperature_sensor " + coil_name, temp) + pheaters = self.printer.load_object(self.config, "heaters") + pheaters.available_sensors.append("temperature_sensor " + coil_name) + return sensor + + def get_or_add_sensor(self, name): + if name in self.sensors: + return self.sensors[name] + else: + return self.add_sensor(name) + + def register_gcode_command(self, sensor, cmd, func, desc): + if cmd not in self.gcodes: + handlers = self.gcodes[cmd] = {} + self.gcode.register_command( + cmd, lambda gcmd: self.dispatch_gcode(handlers, gcmd), desc=desc + ) + self.gcodes[cmd][sensor] = func + + def dispatch_gcode(self, handlers, gcmd): + sensor = gcmd.get("SENSOR", "") + if sensor == "": + sensor = None + handler = handlers.get(sensor, None) + if not handler: + if sensor is None: + raise gcmd.error( + "No default Beacon registered, provide SENSOR= option to select specific sensor." + ) + else: + raise gcmd.error( + "Requested sensor '%s' not found, specify a valid sensor." + % (sensor,) + ) + handler(gcmd) + + def register_endpoint(self, sensor, path, callback): + if path not in self.endpoints: + self.webhooks.register_endpoint(path, self.dispatch_webhook) + self.endpoints[path] = {} + self.endpoints[path][sensor] = callback + + def dispatch_webhook(self, req): + handlers = self.endpoints[req.method] + sensor = req.get("sensor", "") + if sensor == "": + sensor = None + handler = handlers.get(sensor, None) + if not handler: + if sensor is None: + raise req.error( + "No default Beacon registered, provide 'sensor' option to specify sensor." + ) + else: + raise req.error( + "Requested sensor '%s' not found, specify a valid or no sensor to use default" + % (sensor,) + ) + handler(req) + + +class BeaconId: + def __init__(self, name, tracker): + self.name = name + self.tracker = tracker + + def is_unnamed(self): + return self.name is None + + def register_command(self, cmd, func, desc): + self.tracker.register_gcode_command(self.name, cmd, func, desc) + + def register_endpoint(self, path, callback): + self.tracker.register_endpoint(self.name, path, callback) + + +def get_beacons(config): + printer = config.get_printer() + beacons = printer.lookup_object("beacons", None) + if beacons is None: + beacons = BeaconTracker(config, printer) + printer.add_object("beacons", beacons) + return beacons + + +def load_config(config): + return get_beacons(config).get_or_add_sensor(None) + + +def load_config_prefix(config): + beacons = get_beacons(config) + sensor = None + secname = config.get_name() + parts = secname[7:].split() + + if len(parts) != 0 and parts[0] == "sensor": + if len(parts) < 2: + raise config.error("Missing Beacon sensor name") + sensor = parts[1] + parts = parts[2:] + + beacon = beacons.get_or_add_sensor(sensor) + + if len(parts) == 0: + return beacon + + if parts[0] == "model": + if len(parts) != 2: + raise config.error("Missing Beacon model name in section '%s'" % (secname,)) + name = parts[1] + model = BeaconModel.load(name, config, beacon) + beacon._register_model(name, model) + return model + else: + raise config.error("Unknown beacon config directive '%s'" % (secname,)) diff --git a/klippy/extras/bed_custom_bound.py b/klippy/extras/bed_custom_bound.py index d239a9a78728..b93998e6dfcd 100644 --- a/klippy/extras/bed_custom_bound.py +++ b/klippy/extras/bed_custom_bound.py @@ -1,6 +1,12 @@ -import logging -import typing -from collections import OrderedDict +############################### +# Example configuration +# +# [bed_custom_bound] +# custom_boundary_x: 0.0, 500.0 +# custom_boundary_y: 0.0, 500.0 +# travel_speed: 50.0 +# park_xy: 0., 500 +############################### class BedCustomBound: @@ -8,11 +14,8 @@ def __init__(self, config): self.printer = config.get_printer() self.reactor = self.printer.get_reactor() self.gcode = self.printer.lookup_object("gcode") - - # * Register event handlers self.printer.register_event_handler("klippy:ready", self.handle_ready) - - # * Get module configs + self.debug = config.getint("debug", default=0) self.custom_boundary_x = None if config.getfloatlist("custom_boundary_x", None, count=2) is not None: self.custom_boundary_x = config.getfloatlist( @@ -30,11 +33,7 @@ def __init__(self, config): self.travel_speed = config.getfloat( "travel_speed", 100.0, above=1.0, minval=1.0, maxval=300.0 ) - - # * Variables - self.min_event_systime = self.reactor.NEVER self.default_limits_x = self.default_limits_y = None - # * Register new gcode commands self.gcode.register_command( "SET_CUSTOM_BOUNDARY", self.cmd_SET_CUSTOM_BOUNDARY, @@ -56,13 +55,12 @@ def cmd_SET_CUSTOM_BOUNDARY(self, gcmd): return if not self.custom_boundary_x or not self.custom_boundary_y: return - move_to_custom_pos = gcmd.get("MOVE_TO_PARK", False, parser=bool) - self.set_custom_boundary() + if move_to_custom_pos and self.park: self.toolhead.manual_move( - [self.park[0], self.park[1]], + [float(self.park[0]), float(self.park[1])], self.travel_speed, ) @@ -74,17 +72,19 @@ def restore_default_boundary(self, eventtime=None): return if not self.default_limits_x or not self.default_limits_y: return - self.gcode.respond_info( - "[CUSTOM BED BOUNDARY] Restoring printer boundary limits." - ) + if self.debug: + self.gcode.respond_info( + f"[CUSTOM BED BOUNDARY] Restoring printer boundary limits" + ) + kin = self.toolhead.get_kinematics() kin.limits[0] = ( - self.default_limits_x[0], - self.default_limits_y[1], + float(self.default_limits_x[0]), + float(self.default_limits_x[1]), ) # X min, X max kin.limits[1] = ( - self.default_limits_y[0], - self.default_limits_y[1], + float(self.default_limits_y[0]), + float(self.default_limits_y[1]), ) # Y min , Y max self.current_boundary = "default" return @@ -94,25 +94,28 @@ def set_custom_boundary(self, eventtime=None): return if not self.custom_boundary_x or not self.custom_boundary_y: return - self.gcode.respond_info( - "[CUSTOM BED BOUNDARY] Setting specified custom boundary" - ) + if self.debug: + self.gcode.respond_info( + "[CUSTOM BED BOUNDARY] Setting specified custom boundary" + ) kin = self.toolhead.get_kinematics() - self.default_limits_x, self.default_limits_y = ( - kin.limits[0], - kin.limits[1], - ) + + if not self.default_limits_x and not self.default_limits_y: + self.default_limits_x, self.default_limits_y = ( + kin.limits[0], + kin.limits[1], + ) kin.limits[0] = ( - self.custom_boundary_x[0], - self.custom_boundary_x[1], + float(self.custom_boundary_x[0]), + float(self.custom_boundary_x[1]), ) # X min, X max kin.limits[1] = ( - self.custom_boundary_y[0], - self.custom_boundary_y[1], + float(self.custom_boundary_y[0]), + float(self.custom_boundary_y[1]), ) # Y min , Y max + self.current_boundary = "custom" - return def move_to_park(self): if self.park: @@ -120,55 +123,15 @@ def move_to_park(self): [self.park[0], self.park[1]], self.travel_speed ) - def check_boundary_limits( - self, position: typing.Tuple[float, float], bound_type: str = "default" - ): + def check_boundary_limits(self, position: tuple[float, float]): + """Checks if a position is within the current kinematic limits.""" if not self.toolhead or not position: - return - + return {"x": True, "y": True} + kin = self.toolhead.get_kinematics() _limits = { - "x": True, - "y": True, + "x": kin.limits[0][0] <= position[0] <= kin.limits[0][1], + "y": kin.limits[1][0] <= position[1] <= kin.limits[1][1], } - - if ( - bound_type == "default" - and self.default_limits_x - and self.default_limits_y - ): - min_limit_x, max_limit_x = ( - self.default_limits_x[0], - self.default_limits_x[1], - ) - min_limit_y, max_limit_y = ( - self.default_limits_y[0], - self.default_limits_y[1], - ) - - if bound_type == "current": - kin = self.toolhead.get_kinematics() - min_limit_x, max_limit_x = kin.limits[0][0], kin.limits[0][1] - min_limit_y, max_limit_y = kin.limits[1][0], kin.limits[1][1] - if ( - bound_type == "custom" - and self.custom_boundary_x - and self.custom_boundary_y - ): - min_limit_x, max_limit_x = ( - self.custom_boundary_x[0], - self.custom_boundary_x[1], - ) - min_limit_y, max_limit_y = ( - self.custom_boundary_y[0], - self.custom_boundary_y[1], - ) - - if min_limit_x < position[0] or max_limit_x < position[0]: - _limits.update({"x": False}) - - if min_limit_y < position[1] or max_limit_y < position[1]: - _limits.update({"y": False}) - return _limits def get_status(self, eventtime=None): diff --git a/klippy/extras/bucket.py b/klippy/extras/bucket.py index 4f152cea97f7..0ad9262abb17 100644 --- a/klippy/extras/bucket.py +++ b/klippy/extras/bucket.py @@ -47,21 +47,7 @@ def move_to_bucket(self, split: typing.Optional["bool"] = False): return try: if self.custom_bed_bound_object: - _conf_bound = ( - self.custom_bed_bound_object.check_boundary_limits( - position=( - self.bucket_position[0], - self.bucket_position[1], - ) - ) - ) - if ( - not _conf_bound["x"] or not _conf_bound["y"] - ) and self.custom_bed_bound_object.get_status().get( - "status", "" - ) == "custom": - self.custom_bed_bound_object.restore_default_boundary() - + self.custom_bed_bound_object.restore_default_boundary() if not split: self.toolhead.manual_move( [self.bucket_position[0], self.bucket_position[1]], @@ -77,13 +63,7 @@ def move_to_bucket(self, split: typing.Optional["bool"] = False): self.travel_speed, ) - self.toolhead.wait_moves() - - if ( - self.custom_bed_bound_object - and self.custom_bed_bound_object.get_status().get("status", "") - == "default" - ): + if self.custom_bed_bound_object: self.custom_bed_bound_object.set_custom_boundary() except Exception as e: raise BucketMoveError( diff --git a/klippy/extras/mmu/__init__.py b/klippy/extras/mmu/__init__.py new file mode 100644 index 000000000000..14f95c9b5507 --- /dev/null +++ b/klippy/extras/mmu/__init__.py @@ -0,0 +1,21 @@ +# Happy Hare MMU Software +# Main module wrapper +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Firmware to control any Klipper based MMU +# +# Note that code is written in a system to support Python v2 given the widespread use in +# the klipper community. Hopefully this will change soon. +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +from .mmu import Mmu + +def load_config(config): + return Mmu(config) diff --git a/klippy/extras/mmu/mmu.py b/klippy/extras/mmu/mmu.py new file mode 100644 index 000000000000..485a0deffc29 --- /dev/null +++ b/klippy/extras/mmu/mmu.py @@ -0,0 +1,9081 @@ +# Happy Hare MMU Software +# Main module +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Main control class for any Klipper based MMU (includes filament driver/gear control) +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import gc, sys, ast, random, logging, time, contextlib, math, os.path, re, unicodedata, traceback +from statistics import stdev + +# Klipper imports +import chelper +from ..homing import Homing, HomingMove +from ..tmc import TMCCommandHelper + +# Happy Hare imports +from .. import mmu_machine +from ..mmu_machine import MmuToolHead +from ..mmu_sensors import MmuRunoutHelper + +# MMU subcomponent clases +from .mmu_shared import * +from .mmu_logger import MmuLogger +from .mmu_selector import * +from .mmu_test import MmuTest +from .mmu_utils import DebugStepperMovement, PurgeVolCalculator +from .mmu_sensor_manager import MmuSensorManager +from .mmu_led_manager import MmuLedManager +from .mmu_sync_feedback_manager import MmuSyncFeedbackManager +from .mmu_calibration_manager import MmuCalibrationManager +from .mmu_environment_manager import MmuEnvironmentManager + + +# Main klipper module +class Mmu: + VERSION = 3.42 # When this is revved, Happy Hare will instruct users to re-run ./install.sh. Sync with install.sh! + + BOOT_DELAY = 2.5 # Delay before running bootup tasks + + # Calibration steps + CALIBRATED_GEAR_0 = 0b00001 # Specifically rotation_distance for gate 0 + CALIBRATED_ENCODER = 0b00010 + CALIBRATED_SELECTOR = 0b00100 # Defaults true with VirtualSelector + CALIBRATED_BOWDENS = 0b01000 # Bowden length for all gates + CALIBRATED_GEAR_RDS = 0b10000 + CALIBRATED_ESSENTIAL = 0b01111 + CALIBRATED_ALL = 0b11111 + + TOOL_GATE_UNKNOWN = -1 + TOOL_GATE_BYPASS = -2 + + GATE_UNKNOWN = -1 + GATE_EMPTY = 0 + GATE_AVAILABLE = 1 # Available to load from either buffer or spool + GATE_AVAILABLE_FROM_BUFFER = 2 + + FILAMENT_POS_UNKNOWN = -1 + FILAMENT_POS_UNLOADED = 0 # Parked in gate + FILAMENT_POS_HOMED_GATE = 1 # Homed at either gate or gear sensor (currently assumed mutually exclusive sensors) + FILAMENT_POS_START_BOWDEN = 2 # Point of fast load portion + FILAMENT_POS_IN_BOWDEN = 3 # Some unknown position in the bowden + FILAMENT_POS_END_BOWDEN = 4 # End of fast load portion + FILAMENT_POS_HOMED_ENTRY = 5 # Homed at entry sensor + FILAMENT_POS_HOMED_EXTRUDER = 6 # Collision homing case at extruder gear entry + FILAMENT_POS_EXTRUDER_ENTRY = 7 # Past extruder gear entry + FILAMENT_POS_HOMED_TS = 8 # Homed at toolhead sensor + FILAMENT_POS_IN_EXTRUDER = 9 # In extruder past toolhead sensor + FILAMENT_POS_LOADED = 10 # Homed to nozzle + + DIRECTION_LOAD = 1 + DIRECTION_UNKNOWN = 0 + DIRECTION_UNLOAD = -1 + + FORM_TIP_NONE = 0 # Skip tip forming + FORM_TIP_SLICER = 1 # Slicer forms tips + FORM_TIP_STANDALONE = 2 # Happy Hare forms tips + + PURGE_NONE = 0 # Skip purging after load + PURGE_SLICER = 1 # Slicer purges on wipetower + PURGE_STANDALONE = 2 # Happy Hare purges + + ACTION_IDLE = 0 + ACTION_LOADING = 1 + ACTION_LOADING_EXTRUDER = 2 + ACTION_UNLOADING = 3 + ACTION_UNLOADING_EXTRUDER = 4 + ACTION_FORMING_TIP = 5 + ACTION_HEATING = 6 + ACTION_CHECKING = 7 + ACTION_HOMING = 8 + ACTION_SELECTING = 9 + ACTION_CUTTING_TIP = 10 # Cutting at toolhead e.g. _MMU_CUT_TIP macro + ACTION_CUTTING_FILAMENT = 11 # Cutting at MMU e.g. EREC cutting macro + ACTION_PURGING = 12 # Non slicer purging e.g. when running blobifier + + MACRO_EVENT_RESTART = "restart" # Params: None + MACRO_EVENT_GATE_MAP_CHANGED = "gate_map_changed" # Params: GATE changed or GATE=-1 for all + MACRO_EVENT_FILAMENT_GRIPPED = "filament_gripped" # Params: None + + # Standard sensor and endstop or pseudo endstop names + SENSOR_ENCODER = "encoder" # Fake Gate endstop + SENSOR_GATE = "mmu_gate" # Gate + SENSOR_GEAR_PREFIX = "mmu_gear" + + SENSOR_EXTRUDER_NONE = "none" # Fake Extruder endstop aka don't attempt home + SENSOR_EXTRUDER_COLLISION = "collision" # Fake Extruder endstop + SENSOR_EXTRUDER_ENTRY = "extruder" # Extruder entry sensor + SENSOR_GEAR_TOUCH = "mmu_gear_touch" # Stallguard based detection + + SENSOR_COMPRESSION = "filament_compression" # Filament sync-feedback compression detection + SENSOR_TENSION = "filament_tension" # Filament sync-feedback tension detection + SENSOR_PROPORTIONAL = "filament_proportional" # Proportional sync-feedback sensor + + SENSOR_TOOLHEAD = "toolhead" + SENSOR_EXTRUDER_TOUCH = "mmu_ext_touch" + + SENSOR_SELECTOR_TOUCH = "mmu_sel_touch" # For LinearSelector and LinearServoSelector + SENSOR_SELECTOR_HOME = "mmu_sel_home" # For LinearSelector and LinearServoSelector + SENSOR_PRE_GATE_PREFIX = "mmu_pre_gate" + + EXTRUDER_ENDSTOPS = [SENSOR_EXTRUDER_COLLISION, SENSOR_GEAR_TOUCH, SENSOR_EXTRUDER_ENTRY, SENSOR_EXTRUDER_NONE, SENSOR_COMPRESSION] + GATE_ENDSTOPS = [SENSOR_GATE, SENSOR_ENCODER, SENSOR_GEAR_PREFIX, SENSOR_EXTRUDER_ENTRY] + + # Statistics output types + GATE_STATS_STRING = "string" + GATE_STATS_PERCENTAGE = "percentage" + GATE_STATS_EMOTICON = "emoticon" + + GATE_STATS_TYPES = [GATE_STATS_STRING, GATE_STATS_PERCENTAGE, GATE_STATS_EMOTICON] + + # Levels of logging + LOG_ESSENTIAL = 0 + LOG_INFO = 1 + LOG_DEBUG = 2 + LOG_TRACE = 3 + LOG_STEPPER = 4 + LOG_LEVELS = ['ESSENTAL', 'INFO', 'DEBUG', 'TRACE', 'STEPPER'] + + # States of espooler motor + ESPOOLER_OFF = 'off' + ESPOOLER_REWIND = 'rewind' + ESPOOLER_ASSIST = 'assist' + ESPOOLER_PRINT = 'print' # Special in-print assist state for active gate + ESPOOLER_OPERATIONS = [ESPOOLER_OFF, ESPOOLER_REWIND, ESPOOLER_ASSIST, ESPOOLER_PRINT] + + # Name used to save gcode state + TOOLHEAD_POSITION_STATE = 'MMU_state' + + # Filament "grip" states + FILAMENT_UNKNOWN_STATE = -1 + FILAMENT_RELEASE_STATE = 0 + FILAMENT_DRIVE_STATE = 1 + FILAMENT_HOLD_STATE = 2 + + # mmu_vars.cfg variables + VARS_MMU_REVISION = "mmu__revision" + VARS_MMU_CALIB_CLOG_LENGTH = "mmu_calibration_clog_length" + VARS_MMU_ENABLE_ENDLESS_SPOOL = "mmu_state_enable_endless_spool" + VARS_MMU_ENDLESS_SPOOL_GROUPS = "mmu_state_endless_spool_groups" + VARS_MMU_TOOL_TO_GATE_MAP = "mmu_state_tool_to_gate_map" + VARS_MMU_GATE_STATUS = "mmu_state_gate_status" + VARS_MMU_GATE_MATERIAL = "mmu_state_gate_material" + VARS_MMU_GATE_COLOR = "mmu_state_gate_color" + VARS_MMU_GATE_FILAMENT_NAME = "mmu_state_gate_filament_name" + VARS_MMU_GATE_TEMPERATURE = "mmu_state_gate_temperature" + VARS_MMU_GATE_SPOOL_ID = "mmu_state_gate_spool_id" + VARS_MMU_GATE_SPEED_OVERRIDE = "mmu_state_gate_speed_override" + VARS_MMU_GATE_SELECTED = "mmu_state_gate_selected" + VARS_MMU_TOOL_SELECTED = "mmu_state_tool_selected" + VARS_MMU_LAST_TOOL = "mmu_state_last_tool" + VARS_MMU_FILAMENT_POS = "mmu_state_filament_pos" + VARS_MMU_FILAMENT_REMAINING = "mmu_state_filament_remaining" + VARS_MMU_FILAMENT_REMAINING_COLOR = "mmu_state_filament_remaining_color" + VARS_MMU_GATE_STATISTICS_PREFIX = "mmu_statistics_gate_" + VARS_MMU_SWAP_STATISTICS = "mmu_statistics_swaps" + VARS_MMU_COUNTERS = "mmu_statistics_counters" + + # Calibration data + VARS_MMU_ENCODER_RESOLUTION = "mmu_encoder_resolution" + VARS_MMU_GEAR_ROTATION_DISTANCES = "mmu_gear_rotation_distances" + VARS_MMU_CALIB_BOWDEN_LENGTHS = "mmu_calibration_bowden_lengths" # Per-gate calibrated bowden lengths + VARS_MMU_CALIB_BOWDEN_HOME = "mmu_calibration_bowden_home" # Was encoder, gate or gear sensor used as reference point + VARS_MMU_CALIB_BOWDEN_LENGTH = "mmu_calibration_bowden_length" # DEPRECATED (for upgrade only) + VARS_MMU_GEAR_ROTATION_DISTANCE = "mmu_gear_rotation_distance" # DEPRECATED (for upgrade only) + VARS_MMU_CALIB_PREFIX = "mmu_calibration_" # DEPRECATED (for upgrade only) + + # Mainsail/Fluid visualization of extruder colors and other attributes + T_MACRO_COLOR_ALLGATES = 'allgates' # Color from gate map (all tools). Will add spool_id if spoolman is enabled + T_MACRO_COLOR_GATEMAP = 'gatemap' # As per gatemap but hide empty tools. Will add spool_id if spoolman is enabled + T_MACRO_COLOR_SLICER = 'slicer' # Color from slicer tool map. Will add spool_id if spoolman is enabled + T_MACRO_COLOR_OFF = 'off' # Turn off color and spool_id association + T_MACRO_COLOR_OPTIONS = [T_MACRO_COLOR_GATEMAP, T_MACRO_COLOR_SLICER, T_MACRO_COLOR_ALLGATES, T_MACRO_COLOR_OFF] + + # Spoolman integration - modes of operation + SPOOLMAN_OFF = 'off' # Spoolman disabled + SPOOLMAN_READONLY = 'readonly' # Get filament attributes only + SPOOLMAN_PUSH = 'push' # Local gatemap is the source or truth + SPOOLMAN_PULL = 'pull' # Spoolman db is the source of truth + SPOOLMAN_OPTIONS = [SPOOLMAN_OFF, SPOOLMAN_READONLY, SPOOLMAN_PUSH, SPOOLMAN_PULL] + SPOOLMAN_CONFIG_ERROR = "Moonraker/spoolman may not be configured (check moonraker.log)" + + # Automap strategies + AUTOMAP_NONE = 'none' + AUTOMAP_FILAMENT_NAME = 'filament_name' + AUTOMAP_SPOOL_ID = 'spool_id' + AUTOMAP_MATERIAL = 'material' + AUTOMAP_CLOSEST_COLOR = 'closest_color' + AUTOMAP_COLOR = 'color' + AUTOMAP_OPTIONS = [AUTOMAP_NONE, AUTOMAP_FILAMENT_NAME, AUTOMAP_SPOOL_ID, AUTOMAP_MATERIAL, AUTOMAP_CLOSEST_COLOR, AUTOMAP_COLOR] + + EMPTY_GATE_STATS_ENTRY = {'pauses': 0, 'loads': 0, 'load_distance': 0.0, 'load_delta': 0.0, 'unloads': 0, 'unload_distance': 0.0, 'unload_delta': 0.0, 'load_failures': 0, 'unload_failures': 0, 'quality': -1.} + + W3C_COLORS = [('aliceblue','#F0F8FF'), ('antiquewhite','#FAEBD7'), ('aqua','#00FFFF'), ('aquamarine','#7FFFD4'), ('azure','#F0FFFF'), ('beige','#F5F5DC'), + ('bisque','#FFE4C4'), ('black','#000000'), ('blanchedalmond','#FFEBCD'), ('blue','#0000FF'), ('blueviolet','#8A2BE2'), ('brown','#A52A2A'), + ('burlywood','#DEB887'), ('cadetblue','#5F9EA0'), ('chartreuse','#7FFF00'), ('chocolate','#D2691E'), ('coral','#FF7F50'), + ('cornflowerblue','#6495ED'), ('cornsilk','#FFF8DC'), ('crimson','#DC143C'), ('cyan','#00FFFF'), ('darkblue','#00008B'), ('darkcyan','#008B8B'), + ('darkgoldenrod','#B8860B'), ('darkgray','#A9A9A9'), ('darkgreen','#006400'), ('darkgrey','#A9A9A9'), ('darkkhaki','#BDB76B'), + ('darkmagenta','#8B008B'), ('darkolivegreen','#556B2F'), ('darkorange','#FF8C00'), ('darkorchid','#9932CC'), ('darkred','#8B0000'), + ('darksalmon','#E9967A'), ('darkseagreen','#8FBC8F'), ('darkslateblue','#483D8B'), ('darkslategray','#2F4F4F'), ('darkslategrey','#2F4F4F'), + ('darkturquoise','#00CED1'), ('darkviolet','#9400D3'), ('deeppink','#FF1493'), ('deepskyblue','#00BFFF'), ('dimgray','#696969'), + ('dimgrey','#696969'), ('dodgerblue','#1E90FF'), ('firebrick','#B22222'), ('floralwhite','#FFFAF0'), ('forestgreen','#228B22'), + ('fuchsia','#FF00FF'), ('gainsboro','#DCDCDC'), ('ghostwhite','#F8F8FF'), ('gold','#FFD700'), ('goldenrod','#DAA520'), ('gray','#808080'), + ('green','#008000'), ('greenyellow','#ADFF2F'), ('grey','#808080'), ('honeydew','#F0FFF0'), ('hotpink','#FF69B4'), ('indianred','#CD5C5C'), + ('indigo','#4B0082'), ('ivory','#FFFFF0'), ('khaki','#F0E68C'), ('lavender','#E6E6FA'), ('lavenderblush','#FFF0F5'), ('lawngreen','#7CFC00'), + ('lemonchiffon','#FFFACD'), ('lightblue','#ADD8E6'), ('lightcoral','#F08080'), ('lightcyan','#E0FFFF'), ('lightgoldenrodyellow','#FAFAD2'), + ('lightgray','#D3D3D3'), ('lightgreen','#90EE90'), ('lightgrey','#D3D3D3'), ('lightpink','#FFB6C1'), ('lightsalmon','#FFA07A'), + ('lightseagreen','#20B2AA'), ('lightskyblue','#87CEFA'), ('lightslategray','#778899'), ('lightslategrey','#778899'), + ('lightsteelblue','#B0C4DE'), ('lightyellow','#FFFFE0'), ('lime','#00FF00'), ('limegreen','#32CD32'), ('linen','#FAF0E6'), + ('magenta','#FF00FF'), ('maroon','#800000'), ('mediumaquamarine','#66CDAA'), ('mediumblue','#0000CD'), ('mediumorchid','#BA55D3'), + ('mediumpurple','#9370DB'), ('mediumseagreen','#3CB371'), ('mediumslateblue','#7B68EE'), ('mediumspringgreen','#00FA9A'), + ('mediumturquoise','#48D1CC'), ('mediumvioletred','#C71585'), ('midnightblue','#191970'), ('mintcream','#F5FFFA'), ('mistyrose','#FFE4E1'), + ('moccasin','#FFE4B5'), ('navajowhite','#FFDEAD'), ('navy','#000080'), ('oldlace','#FDF5E6'), ('olive','#808000'), + ('olivedrab','#6B8E23'), ('orange','#FFA500'), ('orangered','#FF4500'), ('orchid','#DA70D6'), ('palegoldenrod','#EEE8AA'), + ('palegreen','#98FB98'), ('paleturquoise','#AFEEEE'), ('palevioletred','#DB7093'), ('papayawhip','#FFEFD5'), ('peachpuff','#FFDAB9'), + ('peru','#CD853F'), ('pink','#FFC0CB'), ('plum','#DDA0DD'), ('powderblue','#B0E0E6'), ('purple','#800080'), ('red','#FF0000'), + ('rosybrown','#BC8F8F'), ('royalblue','#4169E1'), ('saddlebrown','#8B4513'), ('salmon','#FA8072'), ('sandybrown','#F4A460'), + ('seagreen','#2E8B57'), ('seashell','#FFF5EE'), ('sienna','#A0522D'), ('silver','#C0C0C0'), ('skyblue','#87CEEB'), ('slateblue','#6A5ACD'), + ('slategray','#708090'), ('slategrey','#708090'), ('snow','#FFFAFA'), ('springgreen','#00FF7F'), ('steelblue','#4682B4'), + ('tan','#D2B48C'), ('teal','#008080'), ('thistle','#D8BFD8'), ('tomato','#FF6347'), ('turquoise','#40E0D0'), ('violet','#EE82EE'), + ('wheat','#F5DEB3'), ('white','#FFFFFF'), ('whitesmoke','#F5F5F5'), ('yellow','#FFFF00'), ('yellowgreen','#9ACD32')] + + UPGRADE_REMINDER = "Sorry but Happy Hare requires you to re-run this to complete the update:\ncd ~/Happy-Hare\n./install.sh\nMore details: https://github.com/moggieuk/Happy-Hare/wiki/Upgrade-Notice" + + def __init__(self, config): + self.config = config + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.gcode = self.printer.lookup_object('gcode') + self.gcode_move = self.printer.load_object(config, 'gcode_move') + + self.managers = [] # List of mmu manager classes to encapsulate functionality. Managers add themselves + self.mmu_machine = self.printer.lookup_object("mmu_machine") + self.num_gates = self.mmu_machine.num_gates + self.calibration_status = 0b0 + self.w3c_colors = dict(self.W3C_COLORS) + self.filament_remaining = 0. + self._last_tool = self._next_tool = self.TOOL_GATE_UNKNOWN + self._next_gate = None + self.toolchange_retract = 0. # Set from mmu_macro_vars + self._can_write_variables = False + self.toolchange_purge_volume = 0. + self.mmu_logger = None # Setup on connect + self._standalone_sync = False # Used to indicate synced extruder intention whilst out of print + self._suppress_release_grip = False # Used to suppress the relaxing of grip on recursive calls to prevent servo flutter + self.bowden_start_pos = None # If set then we can measure bowden progress + self.has_blobifier = False # Post load blobbling macro (like BLOBIFIER) + self.has_mmu_cutter = False # Post unload cutting macro (like EREC) + self.has_toolhead_cutter = False # Form tip cutting macro (like _MMU_CUT_TIP) + self._is_running_test = False # True while running QA or soak tests + self.slicer_tool_map = None + + # Event handlers + self.printer.register_event_handler('klippy:connect', self.handle_connect) + self.printer.register_event_handler("klippy:disconnect", self.handle_disconnect) + self.printer.register_event_handler("klippy:ready", self.handle_ready) + + # Instruct users to re-run ./install.sh if version number changes + self.config_version = config.getfloat('happy_hare_version', 2.2) # v2.2 was the last release before versioning + if self.config_version is not None and self.config_version < self.VERSION: + raise self.config.error("Looks like you upgraded (v%s -> v%s)?\n%s" % (self.config_version, self.VERSION, self.UPGRADE_REMINDER)) + + # Detect Kalico (Danger Klipper) installation + self.kalico = bool(self.printer.lookup_object('danger_options', False)) + + # Setup remaining hardware like MMU toolhead -------------------------------------------------------- + # We setup MMU hardware during configuration since some hardware like endstop requires + # configuration during the MCU config phase, which happens before klipper connection + # This assumes that the hardware definition appears before the '[mmu]' section. + # The default recommended install will guarantee this order + self._setup_mmu_hardware(config) + + # Read user configuration --------------------------------------------------------------------------- + # + # Printer interaction config + self.extruder_name = config.get('extruder', 'extruder') + self.timeout_pause = config.getint('timeout_pause', 72000, minval=120) + self.default_idle_timeout = config.getint('default_idle_timeout', -1, minval=120) + self.pending_spool_id_timeout = config.getint('pending_spool_id_timeout', default=20, minval=-1) # Not currently exposed + self.disable_heater = config.getint('disable_heater', 600, minval=60) + self.default_extruder_temp = config.getfloat('default_extruder_temp', 200., minval=0.) + self.extruder_temp_variance = config.getfloat('extruder_temp_variance', 2., minval=1.) + self.gcode_load_sequence = config.getint('gcode_load_sequence', 0) + self.gcode_unload_sequence = config.getint('gcode_unload_sequence', 0) + self.slicer_tip_park_pos = config.getfloat('slicer_tip_park_pos', 0., minval=0.) + self.force_form_tip_standalone = config.getint('force_form_tip_standalone', 0, minval=0, maxval=1) + self.force_purge_standalone = config.getint('force_purge_standalone', 0, minval=0, maxval=1) + self.strict_filament_recovery = config.getint('strict_filament_recovery', 0, minval=0, maxval=1) + self.filament_recovery_on_pause = config.getint('filament_recovery_on_pause', 1, minval=0, maxval=1) + self.retry_tool_change_on_error = config.getint('retry_tool_change_on_error', 0, minval=0, maxval=1) + self.print_start_detection = config.getint('print_start_detection', 1, minval=0, maxval=1) + self.startup_home_if_unloaded = config.getint('startup_home_if_unloaded', 0, minval=0, maxval=1) + self.startup_reset_ttg_map = config.getint('startup_reset_ttg_map', 0, minval=0, maxval=1) + self.show_error_dialog = config.getint('show_error_dialog', 1, minval=0, maxval=1) + + # Automatic calibration / tuning options + self.autocal_selector = config.getint('autocal_selector', 0, minval=0, maxval=1) # Not exposed TODO placeholder for implementation + self.skip_cal_rotation_distance = config.getint('skip_cal_rotation_distance', 0, minval=0, maxval=1) + self.autotune_rotation_distance = config.getint('autotune_rotation_distance', 0, minval=0, maxval=1) + self.autocal_bowden_length = config.getint('autocal_bowden_length', 1, minval=0, maxval=1) + self.autotune_bowden_length = config.getint('autotune_bowden_length', 0, minval=0, maxval=1) + self.skip_cal_encoder = config.getint('skip_cal_encoder', 0, minval=0, maxval=1) + self.autotune_encoder = config.getint('autotune_encoder', 0, minval=0, maxval=1) # Not exposed TODO placeholder for implementation + + # Internal macro overrides + self.pause_macro = config.get('pause_macro', 'PAUSE') + self.action_changed_macro = config.get('action_changed_macro', '_MMU_ACTION_CHANGED') + self.print_state_changed_macro = config.get('print_state_changed_macro', '_MMU_PRINT_STATE_CHANGED') + self.mmu_event_macro = config.get('mmu_event_macro', '_MMU_EVENT') + self.form_tip_macro = config.get('form_tip_macro', '_MMU_FORM_TIP').replace("'", "") + self.purge_macro = config.get('purge_macro', '').replace("'", "") + self.pre_unload_macro = config.get('pre_unload_macro', '_MMU_PRE_UNLOAD').replace("'", "") + self.post_form_tip_macro = config.get('post_form_tip_macro', '_MMU_POST_FORM_TIP').replace("'", "") + self.post_unload_macro = config.get('post_unload_macro', '_MMU_POST_UNLOAD').replace("'", "") + self.pre_load_macro = config.get('pre_load_macro', '_MMU_PRE_LOAD').replace("'", "") + self.post_load_macro = config.get('post_load_macro', '_MMU_POST_LOAD_MACRO').replace("'", "") + self.unload_sequence_macro = config.get('unload_sequence_macro', '_MMU_UNLOAD_SEQUENCE').replace("'", "") + self.load_sequence_macro = config.get('load_sequence_macro', '_MMU_LOAD_SEQUENCE').replace("'", "") + + # These macros are not currently exposed but provide future flexability + self.error_dialog_macro = config.get('error_dialog_macro', '_MMU_ERROR_DIALOG') # Not exposed + self.error_macro = config.get('error_macro', '_MMU_ERROR') # Not exposed + self.toolhead_homing_macro = config.get('toolhead_homing_macro', '_MMU_AUTO_HOME') # Not exposed + self.park_macro = config.get('park_macro', '_MMU_PARK') # Not exposed + self.save_position_macro = config.get('save_position_macro', '_MMU_SAVE_POSITION') # Not exposed + self.restore_position_macro = config.get('restore_position_macro', '_MMU_RESTORE_POSITION') # Not exposed + self.clear_position_macro = config.get('clear_position_macro', '_MMU_CLEAR_POSITION') # Not exposed + + # User default (reset state) gate map and TTG map + self.default_ttg_map = list(config.getintlist('tool_to_gate_map', [])) + self.default_gate_status = list(config.getintlist('gate_status', [])) + self.default_gate_filament_name = list(config.getlist('gate_filament_name', [])) + self.default_gate_material = list(config.getlist('gate_material', [])) + self.default_gate_color = list(config.getlist('gate_color', [])) + self.default_gate_temperature = list(config.getintlist('gate_temperature', [])) + self.default_gate_spool_id = list(config.getintlist('gate_spool_id', [])) + self.default_gate_speed_override = list(config.getintlist('gate_speed_override', [])) + + # Configuration for gate loading and unloading + self.gate_homing_endstop = config.getchoice('gate_homing_endstop', {o: o for o in self.GATE_ENDSTOPS}, self.SENSOR_ENCODER) + self.gate_endstop_to_encoder = config.getfloat('gate_endstop_to_encoder', 0., minval=0.) + self.gate_unload_buffer = config.getfloat('gate_unload_buffer', 30., minval=0.) # How far to short bowden move to avoid overshooting the gate + self.gate_homing_max = config.getfloat('gate_homing_max', 2 * self.gate_unload_buffer, minval=self.gate_unload_buffer) + self.gate_preload_homing_max = config.getfloat('gate_preload_homing_max', self.gate_homing_max) + self.gate_parking_distance = config.getfloat('gate_parking_distance', 23.) # Can be +ve or -ve + self.gate_preload_parking_distance = config.getfloat('gate_preload_parking_distance', -10.) # Can be +ve or -ve + self.gate_load_retries = config.getint('gate_load_retries', 1, minval=1, maxval=5) + self.gate_autoload = config.getint('gate_autoload', 1, minval=0, maxval=1) + self.gate_final_eject_distance = config.getfloat('gate_final_eject_distance', 0) + self.bypass_autoload = config.getint('bypass_autoload', 1, minval=0, maxval=1) + self.encoder_dwell = config.getfloat('encoder_dwell', 0.1, minval=0., maxval=2.) # Not exposed + self.encoder_move_step_size = config.getfloat('encoder_move_step_size', 15., minval=5., maxval=25.) # Not exposed + + # Configuration for (fast) bowden move + self.bowden_homing_max = config.getfloat('bowden_homing_max', 2000., minval=100.) + self.bowden_apply_correction = config.getint('bowden_apply_correction', 0, minval=0, maxval=1) + self.bowden_allowable_load_delta = config.getfloat('bowden_allowable_load_delta', 10., minval=1.) + self.bowden_allowable_unload_delta = config.getfloat('bowden_allowable_unload_delta', self.bowden_allowable_load_delta, minval=1.) + self.bowden_move_error_tolerance = config.getfloat('bowden_move_error_tolerance', 60, minval=0, maxval=100) # Percentage of delta of move that results in error + self.bowden_pre_unload_test = config.getint('bowden_pre_unload_test', 0, minval=0, maxval=1) # Check for bowden movement before full pull + self.bowden_pre_unload_error_tolerance = config.getfloat('bowden_pre_unload_error_tolerance', 100, minval=0, maxval=100) # Allowable delta movement % before error + + # Configuration for extruder and toolhead homing + self.extruder_force_homing = config.getint('extruder_force_homing', 0, minval=0, maxval=1) + self.extruder_homing_endstop = config.getchoice('extruder_homing_endstop', {o: o for o in self.EXTRUDER_ENDSTOPS}, self.SENSOR_EXTRUDER_NONE) + self.extruder_homing_max = config.getfloat('extruder_homing_max', 50., above=10.) # Extruder homing max + self.extruder_homing_buffer = config.getfloat('extruder_homing_buffer', 30., minval=0.) # How far to short bowden load move to avoid overshooting + self.extruder_collision_homing_step = config.getint('extruder_collision_homing_step', 3, minval=2, maxval=5) + self.toolhead_homing_max = config.getfloat('toolhead_homing_max', 20., minval=0.) # Toolhead sensor homing max + self.toolhead_extruder_to_nozzle = config.getfloat('toolhead_extruder_to_nozzle', 0., minval=5.) # For "sensorless" + self.toolhead_sensor_to_nozzle = config.getfloat('toolhead_sensor_to_nozzle', 0., minval=1.) # For toolhead sensor + self.toolhead_entry_to_extruder = config.getfloat('toolhead_entry_to_extruder', 0., minval=0.) # For extruder (entry) sensor + self.toolhead_residual_filament = config.getfloat('toolhead_residual_filament', 0., minval=0., maxval=50.) # +ve value = reduction of load length + self.toolhead_ooze_reduction = config.getfloat('toolhead_ooze_reduction', 0., minval=-5., maxval=20.) # +ve value = reduction of load length + self.toolhead_unload_safety_margin = config.getfloat('toolhead_unload_safety_margin', 10., minval=0.) # Extra unload distance + self.toolhead_move_error_tolerance = config.getfloat('toolhead_move_error_tolerance', 60, minval=0, maxval=100) # Allowable delta movement % before error + self.toolhead_entry_tension_test = config.getint('toolhead_entry_tension_test', 1, minval=0, maxval=1) # Use filament compression to test for successful extruder entry (requires compression sensor) + self.toolhead_post_load_tighten = config.getint('toolhead_post_load_tighten', 60, minval=0, maxval=100) # Whether to apply filament tightening move after load (if not synced) + self.toolhead_post_load_tension_adjust = config.getint('toolhead_post_load_tension_adjust', 1, minval=0, maxval=1) # Whether to use sync-feedback sensor to adjust tension (synced) + + # Synchronous motor control + self.sync_to_extruder = config.getint('sync_to_extruder', 0, minval=0, maxval=1) + self.sync_form_tip = config.getint('sync_form_tip', 0, minval=0, maxval=1) + self.sync_purge = config.getint('sync_purge', 0, minval=0, maxval=1) + if self.mmu_machine.filament_always_gripped: + self.sync_to_extruder = self.sync_form_tip = self.sync_purge = 1 + + # TMC current control + self.extruder_collision_homing_current = config.getint('extruder_collision_homing_current', 50, minval=10, maxval=100) + self.extruder_form_tip_current = config.getint('extruder_form_tip_current', 100, minval=100, maxval=150) + self.extruder_purge_current = config.getint('extruder_purge_current', 100, minval=100, maxval=150) + self.sync_gear_current = config.getint('sync_gear_current', 50, minval=10, maxval=100) + + # Filament move speeds and accelaration + self.gear_from_buffer_speed = config.getfloat('gear_from_buffer_speed', 150., minval=10.) + self.gear_from_buffer_accel = config.getfloat('gear_from_buffer_accel', 400, minval=10.) + self.gear_from_spool_speed = config.getfloat('gear_from_spool_speed', 60, minval=10.) + self.gear_from_spool_accel = config.getfloat('gear_from_spool_accel', 100, minval=10.) + self.gear_unload_speed = config.getfloat('gear_unload_speed', self.gear_from_spool_speed, minval=10.) + self.gear_unload_accel = config.getfloat('gear_unload_accel', self.gear_from_spool_accel, minval=10.) + self.gear_short_move_speed = config.getfloat('gear_short_move_speed', 60., minval=1.) + self.gear_short_move_accel = config.getfloat('gear_short_move_accel', 400, minval=10.) + self.gear_short_move_threshold = config.getfloat('gear_short_move_threshold', self.gate_homing_max, minval=1.) + self.gear_homing_speed = config.getfloat('gear_homing_speed', 150, minval=1.) + + self.extruder_load_speed = config.getfloat('extruder_load_speed', 15, minval=1.) + self.extruder_unload_speed = config.getfloat('extruder_unload_speed', 15, minval=1.) + self.extruder_sync_load_speed = config.getfloat('extruder_sync_load_speed', 15., minval=1.) + self.extruder_sync_unload_speed = config.getfloat('extruder_sync_unload_speed', 15., minval=1.) + self.extruder_accel = config.getfloat('extruder_accel', 400, above=10.) + self.extruder_homing_speed = config.getfloat('extruder_homing_speed', 15, minval=1.) + + self.gear_buzz_accel = config.getfloat('gear_buzz_accel', 1000, minval=10.) # Not exposed + + self.macro_toolhead_max_accel = config.getfloat('macro_toolhead_max_accel', 0, minval=0) + self.macro_toolhead_min_cruise_ratio = config.getfloat('macro_toolhead_min_cruise_ratio', minval=0., below=1.) + if self.macro_toolhead_max_accel == 0: + self.macro_toolhead_max_accel = config.getsection('printer').getsection('toolhead').getint('max_accel', 5000) + + # eSpooler + self.espooler_min_distance = config.getfloat('espooler_min_distance', 50., above=0) + self.espooler_max_stepper_speed = config.getfloat('espooler_max_stepper_speed', 300., above=0) + self.espooler_min_stepper_speed = config.getfloat('espooler_min_stepper_speed', 0., minval=0., below=self.espooler_max_stepper_speed) + self.espooler_speed_exponent = config.getfloat('espooler_speed_exponent', 0.5, above=0) + self.espooler_assist_reduced_speed = config.getint('espooler_assist_reduced_speed', 50, minval=0, maxval=100) + self.espooler_printing_power = config.getint('espooler_printing_power', 0, minval=0, maxval=100) + self.espooler_assist_extruder_move_length = config.getfloat("espooler_assist_extruder_move_length", 100, above=10.) + self.espooler_assist_burst_power = config.getint("espooler_assist_burst_power", 50, minval=0, maxval=100) + self.espooler_assist_burst_duration = config.getfloat("espooler_assist_burst_duration", .4, above=0., maxval=10.) + self.espooler_assist_burst_trigger = config.getint("espooler_assist_burst_trigger", 0, minval=0, maxval=1) + self.espooler_assist_burst_trigger_max = config.getint("espooler_assist_burst_trigger_max", 3, minval=1) + self.espooler_rewind_burst_power = config.getint("espooler_rewind_burst_power", 50, minval=0, maxval=100) + self.espooler_rewind_burst_duration = config.getfloat("espooler_rewind_burst_duration", .4, above=0., maxval=10.) + self.espooler_operations = list(config.getlist('espooler_operations', self.ESPOOLER_OPERATIONS)) + + # Optional features + self.has_filament_buffer = bool(config.getint('has_filament_buffer', 1, minval=0, maxval=1)) + self.preload_attempts = config.getint('preload_attempts', 1, minval=1, maxval=20) # How many times to try to grab the filament + self.encoder_move_validation = config.getint('encoder_move_validation', 1, minval=0, maxval=1) # Use encoder to check load/unload movement + self.spoolman_support = config.getchoice('spoolman_support', {o: o for o in self.SPOOLMAN_OPTIONS}, self.SPOOLMAN_OFF) + self.t_macro_color = config.getchoice('t_macro_color', {o: o for o in self.T_MACRO_COLOR_OPTIONS}, self.T_MACRO_COLOR_SLICER) + self.default_endless_spool_enabled = config.getint('endless_spool_enabled', 0, minval=0, maxval=1) + self.endless_spool_on_load = config.getint('endless_spool_on_load', 0, minval=0, maxval=1) + self.endless_spool_eject_gate = config.getint('endless_spool_eject_gate', -1, minval=-1, maxval=self.num_gates - 1) + self.default_endless_spool_groups = list(config.getintlist('endless_spool_groups', [])) + self.tool_extrusion_multipliers = [] + self.tool_speed_multipliers = [] + self.select_tool_macro = config.get('select_tool_macro', default=None) + self.select_tool_num_switches = config.getint('select_tool_num_switches', default=0, minval=0) + + # Logging + self.log_level = config.getint('log_level', 1, minval=0, maxval=4) + self.log_file_level = config.getint('log_file_level', 2, minval=-1, maxval=4) + self.log_statistics = config.getint('log_statistics', 0, minval=0, maxval=1) + self.log_visual = config.getint('log_visual', 1, minval=0, maxval=1) + self.log_startup_status = config.getint('log_startup_status', 1, minval=0, maxval=2) + self.log_m117_messages = config.getint('log_m117_messages', 1, minval=0, maxval=1) + + # Cosmetic console stuff + self.console_stat_columns = list(config.getlist('console_stat_columns', ['unload', 'load', 'total'])) + self.console_stat_rows = list(config.getlist('console_stat_rows', ['total', 'job', 'job_average'])) + self.console_gate_stat = config.getchoice('console_gate_stat', {o: o for o in self.GATE_STATS_TYPES}, self.GATE_STATS_STRING) + self.console_always_output_full = config.getint('console_always_output_full', 1, minval=0, maxval=1) + + # Turn off splash bling for boring people + self.serious = config.getint('serious', 0, minval=0, maxval=1) + # Suppress the Kalico warning for dangerous people + self.suppress_kalico_warning = config.getint('suppress_kalico_warning', 0, minval=0, maxval=1) + + # Currently hidden and testing options + self.test_random_failures = config.getint('test_random_failures', 0, minval=0, maxval=1) + self.test_disable_encoder = config.getint('test_disable_encoder', 0, minval=0, maxval=1) + self.test_force_in_print = config.getint('test_force_in_print', 0, minval=0, maxval=1) + + # Klipper tuning (aka hacks) + # Timer too close is a catch all error, however it has been found to occur on some systems during homing and probing + # operations especially so with CANbus connected mcus. Happy Hare using many homing moves for reliable extruder loading + # and unloading and enabling this option affords klipper more tolerance and avoids this dreaded error. + self.update_trsync = config.getint('update_trsync', 0, minval=0, maxval=1) + + # Some CANbus boards are prone to this but it have been seen on regular USB boards where a comms + # timeout will kill the print. Since it seems to occur only on homing moves perhaps because of too + # high a microstep setting or speed. They can be safely retried to workaround. + # This has been working well in practice. + self.canbus_comms_retries = config.getint('canbus_comms_retries', 3, minval=1, maxval=10) + + # Older neopixels have very finiky timing and can generate lots of "Unable to obtain 'neopixel_result' response" + # errors in klippy.log. This has been linked to subsequent Timer too close errors. An often cited workaround is + # to increase BIT_MAX_TIME in neopixel.py. This option does that automatically for you to save dirtying klipper. + self.update_bit_max_time = config.getint('update_bit_max_time', 0, minval=0, maxval=1) + + # This is required for the BTT AHT10 used on the ViViD MMU + self.update_aht10_commands = config.getint('update_aht10_commands', 0, minval=0, maxval=1) + + # Initialize manager helpers + # These encapsulate specific functionality to reduce the complexity of main class + self.sync_feedback_manager = MmuSyncFeedbackManager(self) + self.environment_manager = MmuEnvironmentManager(self) + + # Establish defaults for "reset" operation ---------------------------------------------------------- + # These lists are the defaults (used when reset) and will be overriden by values in mmu_vars.cfg... + + # Endless spool groups + self.endless_spool_enabled = self.default_endless_spool_enabled + if len(self.default_endless_spool_groups) > 0: + if self.endless_spool_enabled == 1 and len(self.default_endless_spool_groups) != self.num_gates: + raise self.config.error("endless_spool_groups has a different number of values than the number of gates") + else: + self.default_endless_spool_groups = list(range(self.num_gates)) + self.endless_spool_groups = list(self.default_endless_spool_groups) + + # Components of the gate map (status, material, color, spool_id, filament name, temperature, and speed override) + self.gate_map_vars = [ (self.VARS_MMU_GATE_STATUS, 'gate_status', self.GATE_UNKNOWN), + (self.VARS_MMU_GATE_FILAMENT_NAME, 'gate_filament_name', ""), + (self.VARS_MMU_GATE_MATERIAL, 'gate_material', ""), + (self.VARS_MMU_GATE_COLOR, 'gate_color', ""), + (self.VARS_MMU_GATE_TEMPERATURE, 'gate_temperature', int(self.default_extruder_temp)), + (self.VARS_MMU_GATE_SPOOL_ID, 'gate_spool_id', -1), + (self.VARS_MMU_GATE_SPEED_OVERRIDE, 'gate_speed_override', 100) ] + + for _, attr, default in self.gate_map_vars: + default_attr_name = "default_" + attr + default_attr = getattr(self, default_attr_name) + if len(default_attr) > 0: + if len(default_attr) != self.num_gates: + raise self.config.error("%s has different number of entries than the number of gates" % attr) + else: + default_attr.extend([default] * self.num_gates) + setattr(self, attr, list(default_attr)) + self._update_gate_color_rgb() + + # Tool to gate mapping + if len(self.default_ttg_map) > 0: + if not len(self.default_ttg_map) == self.num_gates: + raise self.config.error("tool_to_gate_map has different number of values than the number of gates") + else: + self.default_ttg_map = list(range(self.num_gates)) + self.ttg_map = list(self.default_ttg_map) + + # Tool speed and extrusion multipliers + self.tool_extrusion_multipliers.extend([1.] * self.num_gates) + self.tool_speed_multipliers.extend([1.] * self.num_gates) + + # Register GCODE commands --------------------------------------------------------------------------- + + # Logging and Stats + self.gcode.register_command('MMU_RESET', self.cmd_MMU_RESET, desc = self.cmd_MMU_RESET_help) + self.gcode.register_command('MMU_STATS', self.cmd_MMU_STATS, desc = self.cmd_MMU_STATS_help) + self.gcode.register_command('MMU_STATUS', self.cmd_MMU_STATUS, desc = self.cmd_MMU_STATUS_help) + self.gcode.register_command('MMU_SENSORS', self.cmd_MMU_SENSORS, desc = self.cmd_MMU_SENSORS_help) + + # Calibration + self.gcode.register_command('MMU_CALIBRATE_GEAR', self.cmd_MMU_CALIBRATE_GEAR, desc=self.cmd_MMU_CALIBRATE_GEAR_help) + self.gcode.register_command('MMU_CALIBRATE_ENCODER', self.cmd_MMU_CALIBRATE_ENCODER, desc=self.cmd_MMU_CALIBRATE_ENCODER_help) + self.gcode.register_command('MMU_CALIBRATE_BOWDEN', self.cmd_MMU_CALIBRATE_BOWDEN, desc = self.cmd_MMU_CALIBRATE_BOWDEN_help) + self.gcode.register_command('MMU_CALIBRATE_GATES', self.cmd_MMU_CALIBRATE_GATES, desc = self.cmd_MMU_CALIBRATE_GATES_help) + self.gcode.register_command('MMU_CALIBRATE_TOOLHEAD', self.cmd_MMU_CALIBRATE_TOOLHEAD, desc = self.cmd_MMU_CALIBRATE_TOOLHEAD_help) + self.gcode.register_command('MMU_CALIBRATE_PSENSOR', self.cmd_MMU_CALIBRATE_PSENSOR, desc = self.cmd_MMU_CALIBRATE_PSENSOR_help) + + # Motor control + self.gcode.register_command('MMU_MOTORS_OFF', self.cmd_MMU_MOTORS_OFF, desc = self.cmd_MMU_MOTORS_OFF_help) + self.gcode.register_command('MMU_MOTORS_ON', self.cmd_MMU_MOTORS_ON, desc = self.cmd_MMU_MOTORS_ON_help) + self.gcode.register_command('MMU_SYNC_GEAR_MOTOR', self.cmd_MMU_SYNC_GEAR_MOTOR, desc=self.cmd_MMU_SYNC_GEAR_MOTOR_help) + + # Core MMU functionality + self.gcode.register_command('MMU', self.cmd_MMU, desc = self.cmd_MMU_help) + self.gcode.register_command('MMU_LOG', self.cmd_MMU_LOG, desc = self.cmd_MMU_LOG_help) + self.gcode.register_command('MMU_HELP', self.cmd_MMU_HELP, desc = self.cmd_MMU_HELP_help) + self.gcode.register_command('MMU_ENCODER', self.cmd_MMU_ENCODER, desc = self.cmd_MMU_ENCODER_help) + self.gcode.register_command('MMU_ESPOOLER', self.cmd_MMU_ESPOOLER, desc = self.cmd_MMU_ESPOOLER_help) + self.gcode.register_command('MMU_HOME', self.cmd_MMU_HOME, desc = self.cmd_MMU_HOME_help) + self.gcode.register_command('MMU_SELECT', self.cmd_MMU_SELECT, desc = self.cmd_MMU_SELECT_help) + self.gcode.register_command('MMU_SELECT_BYPASS', self.cmd_MMU_SELECT_BYPASS, desc = self.cmd_MMU_SELECT_BYPASS_help) # Alias for MMU_SELECT BYPASS=1 + self.gcode.register_command('MMU_PRELOAD', self.cmd_MMU_PRELOAD, desc = self.cmd_MMU_PRELOAD_help) + self.gcode.register_command('MMU_CHANGE_TOOL', self.cmd_MMU_CHANGE_TOOL, desc = self.cmd_MMU_CHANGE_TOOL_help) + # TODO Currently cannot not registered directly as Tx commands because cannot attach color/spool_id required by Mailsail + #for tool in range(self.num_gates): + # self.gcode.register_command('T%d' % tool, self.cmd_MMU_CHANGE_TOOL, desc = "Change to tool T%d" % tool) + self.gcode.register_command('MMU_LOAD', self.cmd_MMU_LOAD, desc=self.cmd_MMU_LOAD_help) + self.gcode.register_command('MMU_EJECT', self.cmd_MMU_EJECT, desc = self.cmd_MMU_EJECT_help) + self.gcode.register_command('MMU_UNLOAD', self.cmd_MMU_UNLOAD, desc = self.cmd_MMU_UNLOAD_help) + self.gcode.register_command('MMU_PAUSE', self.cmd_MMU_PAUSE, desc = self.cmd_MMU_PAUSE_help) + self.gcode.register_command('MMU_UNLOCK', self.cmd_MMU_UNLOCK, desc = self.cmd_MMU_UNLOCK_help) + self.gcode.register_command('MMU_RECOVER', self.cmd_MMU_RECOVER, desc = self.cmd_MMU_RECOVER_help) + + # Endstops for print start / stop. Automatically called if printing from virtual SD-card + self.gcode.register_command('MMU_PRINT_START', self.cmd_MMU_PRINT_START, desc = self.cmd_MMU_PRINT_START_help) + self.gcode.register_command('MMU_PRINT_END', self.cmd_MMU_PRINT_END, desc = self.cmd_MMU_PRINT_END_help) + + # User Setup and Testing + self.gcode.register_command('MMU_TEST_BUZZ_MOTOR', self.cmd_MMU_TEST_BUZZ_MOTOR, desc=self.cmd_MMU_TEST_BUZZ_MOTOR_help) + self.gcode.register_command('MMU_TEST_GRIP', self.cmd_MMU_TEST_GRIP, desc = self.cmd_MMU_TEST_GRIP_help) + self.gcode.register_command('MMU_TEST_LOAD', self.cmd_MMU_TEST_LOAD, desc=self.cmd_MMU_TEST_LOAD_help) + self.gcode.register_command('MMU_TEST_MOVE', self.cmd_MMU_TEST_MOVE, desc = self.cmd_MMU_TEST_MOVE_help) + self.gcode.register_command('MMU_TEST_HOMING_MOVE', self.cmd_MMU_TEST_HOMING_MOVE, desc = self.cmd_MMU_TEST_HOMING_MOVE_help) + self.gcode.register_command('MMU_TEST_TRACKING', self.cmd_MMU_TEST_TRACKING, desc=self.cmd_MMU_TEST_TRACKING_help) + self.gcode.register_command('MMU_TEST_CONFIG', self.cmd_MMU_TEST_CONFIG, desc = self.cmd_MMU_TEST_CONFIG_help) + self.gcode.register_command('MMU_TEST_RUNOUT', self.cmd_MMU_TEST_RUNOUT, desc = self.cmd_MMU_TEST_RUNOUT_help) + self.gcode.register_command('MMU_TEST_FORM_TIP', self.cmd_MMU_TEST_FORM_TIP, desc = self.cmd_MMU_TEST_FORM_TIP_help) + self.gcode.register_command('MMU_TEST_PURGE', self.cmd_MMU_TEST_PURGE, desc = self.cmd_MMU_TEST_PURGE_help) + + # Soak Testing + self.gcode.register_command('MMU_SOAKTEST_LOAD_SEQUENCE', self.cmd_MMU_SOAKTEST_LOAD_SEQUENCE, desc = self.cmd_MMU_SOAKTEST_LOAD_SEQUENCE_help) + + # Mapping stuff (TTG, Gate map, Slicer toolmap, Endless spool, Spoolman) + self.gcode.register_command('MMU_TTG_MAP', self.cmd_MMU_TTG_MAP, desc = self.cmd_MMU_TTG_MAP_help) + self.gcode.register_command('MMU_GATE_MAP', self.cmd_MMU_GATE_MAP, desc = self.cmd_MMU_GATE_MAP_help) + self.gcode.register_command('MMU_ENDLESS_SPOOL', self.cmd_MMU_ENDLESS_SPOOL, desc = self.cmd_MMU_ENDLESS_SPOOL_help) + self.gcode.register_command('MMU_CHECK_GATE', self.cmd_MMU_CHECK_GATE, desc = self.cmd_MMU_CHECK_GATE_help) + self.gcode.register_command('MMU_TOOL_OVERRIDES', self.cmd_MMU_TOOL_OVERRIDES, desc = self.cmd_MMU_TOOL_OVERRIDES_help) + self.gcode.register_command('MMU_SLICER_TOOL_MAP', self.cmd_MMU_SLICER_TOOL_MAP, desc = self.cmd_MMU_SLICER_TOOL_MAP_help) + self.gcode.register_command('MMU_CALC_PURGE_VOLUMES', self.cmd_MMU_CALC_PURGE_VOLUMES, desc = self.cmd_MMU_CALC_PURGE_VOLUMES_help) + self.gcode.register_command('MMU_SPOOLMAN', self.cmd_MMU_SPOOLMAN, desc = self.cmd_MMU_SPOOLMAN_help) + + # For use in user controlled load and unload macros + self.gcode.register_command('_MMU_STEP_LOAD_GATE', self.cmd_MMU_STEP_LOAD_GATE, desc = self.cmd_MMU_STEP_LOAD_GATE_help) + self.gcode.register_command('_MMU_STEP_UNLOAD_GATE', self.cmd_MMU_STEP_UNLOAD_GATE, desc = self.cmd_MMU_STEP_UNLOAD_GATE_help) + self.gcode.register_command('_MMU_STEP_LOAD_BOWDEN', self.cmd_MMU_STEP_LOAD_BOWDEN, desc = self.cmd_MMU_STEP_LOAD_BOWDEN_help) + self.gcode.register_command('_MMU_STEP_UNLOAD_BOWDEN', self.cmd_MMU_STEP_UNLOAD_BOWDEN, desc = self.cmd_MMU_STEP_UNLOAD_BOWDEN_help) + self.gcode.register_command('_MMU_STEP_HOME_EXTRUDER', self.cmd_MMU_STEP_HOME_EXTRUDER, desc = self.cmd_MMU_STEP_HOME_EXTRUDER_help) + self.gcode.register_command('_MMU_STEP_LOAD_TOOLHEAD', self.cmd_MMU_STEP_LOAD_TOOLHEAD, desc = self.cmd_MMU_STEP_LOAD_TOOLHEAD_help) + self.gcode.register_command('_MMU_STEP_UNLOAD_TOOLHEAD', self.cmd_MMU_STEP_UNLOAD_TOOLHEAD, desc = self.cmd_MMU_STEP_UNLOAD_TOOLHEAD_help) + self.gcode.register_command('_MMU_STEP_HOMING_MOVE', self.cmd_MMU_STEP_HOMING_MOVE, desc = self.cmd_MMU_STEP_HOMING_MOVE_help) + self.gcode.register_command('_MMU_STEP_MOVE', self.cmd_MMU_STEP_MOVE, desc = self.cmd_MMU_STEP_MOVE_help) + self.gcode.register_command('_MMU_STEP_SET_FILAMENT', self.cmd_MMU_STEP_SET_FILAMENT, desc = self.cmd_MMU_STEP_SET_FILAMENT_help) + self.gcode.register_command('_MMU_STEP_SET_ACTION', self.cmd_MMU_STEP_SET_ACTION, desc = self.cmd_MMU_STEP_SET_ACTION_help) + self.gcode.register_command('_MMU_M400', self.cmd_MMU_M400, desc = self.cmd_MMU_M400_help) # Wait on both movequeues + + # Internal handlers for Runout & Insertion for all sensor options + self.gcode.register_command('__MMU_ENCODER_RUNOUT', self.cmd_MMU_ENCODER_RUNOUT, desc = self.cmd_MMU_ENCODER_RUNOUT_help) + self.gcode.register_command('__MMU_ENCODER_INSERT', self.cmd_MMU_ENCODER_INSERT, desc = self.cmd_MMU_ENCODER_INSERT_help) + self.gcode.register_command('__MMU_SENSOR_RUNOUT', self.cmd_MMU_SENSOR_RUNOUT, desc = self.cmd_MMU_SENSOR_RUNOUT_help) + self.gcode.register_command('__MMU_SENSOR_REMOVE', self.cmd_MMU_SENSOR_REMOVE, desc = self.cmd_MMU_SENSOR_REMOVE_help) + self.gcode.register_command('__MMU_SENSOR_INSERT', self.cmd_MMU_SENSOR_INSERT, desc = self.cmd_MMU_SENSOR_INSERT_help) + self.gcode.register_command('__MMU_SENSOR_CLOG', self.cmd_MMU_SENSOR_CLOG, desc = self.cmd_MMU_SENSOR_CLOG_help) + self.gcode.register_command('__MMU_SENSOR_TANGLE', self.cmd_MMU_SENSOR_TANGLE, desc = self.cmd_MMU_SENSOR_TANGLE_help) + + # Initializer tasks + self.gcode.register_command('__MMU_BOOTUP', self.cmd_MMU_BOOTUP, desc = self.cmd_MMU_BOOTUP_help) # Bootup tasks + + # Load development test commands + _ = MmuTest(self) + + # Apply Klipper hacks ------------------------------------------------------------------------------- + if self.update_trsync: # Timer too close mitigation + try: + import mcu + mcu.TRSYNC_TIMEOUT = max(mcu.TRSYNC_TIMEOUT, 0.05) + except Exception as e: + self.log_error("Unable to update TRSYNC_TIMEOUT: %s" % str(e)) + + if self.update_bit_max_time: # Neopixel update error mitigation + try: + from extras import neopixel + neopixel.BIT_MAX_TIME = max(neopixel.BIT_MAX_TIME, 0.000030) + except Exception as e: + self.log_error("Unable to update BIT_MAX_TIME: %s" % str(e)) + + if self.update_aht10_commands: # Command set of AHT10 (on ViViD) + try: + from extras import aht10 + aht10.AHT10_COMMANDS = { + 'INIT' :[0xBE, 0x08, 0x00], + 'MEASURE' :[0xAC, 0x33, 0x00], + 'RESET' :[0xBE, 0x08, 0x00] + } + except Exception as e: + self.log_error("Unable to update AHT10_COMMANDS: %s" % str(e)) + + # Initialize state and statistics variables + self.reinit() + self._reset_statistics() + self.counters = {} + + # Initialize MMU hardare. Note that logging not set up yet so use main klippy logger + def _setup_mmu_hardware(self, config): + logging.info("MMU: Hardware Initialization -------------------------------") + self.homing_extruder = self.mmu_machine.homing_extruder + + # Dynamically instantiate the selector class + self.selector = globals()[self.mmu_machine.selector_type](self) + if not isinstance(self.selector, BaseSelector): + raise self.config.error("Invalid Selector class for MMU") + + # Now we can instantiate the MMU toolhead + self.mmu_toolhead = MmuToolHead(config, self) + rails = self.mmu_toolhead.get_kinematics().rails + self.gear_rail = rails[1] + self.mmu_extruder_stepper = self.mmu_toolhead.mmu_extruder_stepper # Will be a MmuExtruderStepper if 'self.homing_extruder' is True + + # Setup filament sensors that are also used for homing (endstops). Must be done during initialization + self.sensor_manager = MmuSensorManager(self) + self.led_manager = MmuLedManager(self) + + # Get optional encoder setup. TODO Multi-encoder: rework to default name to None and then use lookup to determine if present + self.encoder_name = config.get('encoder_name', 'mmu_encoder') + self.encoder_sensor = self.printer.lookup_object('mmu_encoder %s' % self.encoder_name, None) + if not self.encoder_sensor: + logging.warning("MMU: No [mmu_encoder] definition found in mmu_hardware.cfg. Assuming encoder is not available") + + # Load espooler if it exists + self.espooler = self.printer.lookup_object('mmu_espooler mmu_espooler', None) + + def _setup_logging(self): + # Setup background file based logging before logging any messages + if self.mmu_logger is None and self.log_file_level >= 0: + logfile_path = self.printer.start_args['log_file'] + dirname = os.path.dirname(logfile_path) + if dirname is None: + mmu_log = '/tmp/mmu.log' + else: + mmu_log = dirname + '/mmu.log' + logging.info("MMU: Log: %s" % mmu_log) + self.mmu_logger = MmuLogger(mmu_log) + self.mmu_logger.log("\n\n\nMMU Startup -----------------------------------------------\n") + + def handle_connect(self): + self._setup_logging() + + self.toolhead = self.printer.lookup_object('toolhead') + self.sensor_manager.reset_active_unit(self.unit_selected) + + # Sanity check extruder name + extruder = self.printer.lookup_object(self.extruder_name, None) + if not extruder: + raise self.config.error("Extruder named '%s' not found on printer" % self.extruder_name) + + # See if we have a TMC controller capable of current control for filament collision detection and syncing + # on gear_stepper and tip forming on extruder + self.gear_tmc = self.extruder_tmc = None + for chip in mmu_machine.TMC_CHIPS: + if self.gear_tmc is None: + self.gear_tmc = self.printer.lookup_object('%s %s' % (chip, mmu_machine.GEAR_STEPPER_CONFIG), None) + if self.gear_tmc is not None: + self.log_debug("Found %s on gear_stepper. Current control enabled. Stallguard 'touch' homing possible." % chip) + if self.extruder_tmc is None: + self.extruder_tmc = self.printer.lookup_object("%s %s" % (chip, self.extruder_name), None) + if self.extruder_tmc is not None: + self.log_debug("Found %s on extruder. Current control enabled. %s" % (chip, "Stallguard 'touch' homing possible." if self.homing_extruder else "")) + if self.gear_tmc is None: + self.log_debug("TMC driver not found for gear_stepper, cannot use current reduction for collision detection or while synchronized printing") + if self.extruder_tmc is None: + self.log_debug("TMC driver not found for extruder, cannot use current increase for tip forming move") + + # Establish gear_stepper initial gear_stepper and extruder currents and current percentage + self.gear_default_run_current = self.gear_tmc.get_status(0)['run_current'] if self.gear_tmc else None + self.extruder_default_run_current = self.extruder_tmc.get_status(0)['run_current'] if self.extruder_tmc else None + self.gear_percentage_run_current = self.extruder_percentage_run_current = 100 # Current run percentages + self._gear_current_locked = False # True if gear current is currently locked by wrap_gear_current() + + # Sanity check that required klipper options are enabled + self.print_stats = self.printer.lookup_object("print_stats", None) + if self.print_stats is None: + self.log_debug("[virtual_sdcard] is not found in config, advanced state control is not possible") + self.pause_resume = self.printer.lookup_object('pause_resume', None) + if self.pause_resume is None: + raise self.config.error("MMU requires [pause_resume] to work, please add it to your config!") + + # Remember user setting of idle_timeout so it can be restored (if not overridden) + if self.default_idle_timeout < 0: + self.default_idle_timeout = self.printer.lookup_object("idle_timeout").idle_timeout + + # Sanity check to see that mmu_vars.cfg is included. This will verify path because default deliberately has 'mmu_revision' entry + self.save_variables = self.printer.lookup_object('save_variables', None) + if self.save_variables: + rd_var = self.save_variables.allVariables.get(self.VARS_MMU_GEAR_ROTATION_DISTANCE, None) + revision_var = self.save_variables.allVariables.get(self.VARS_MMU_REVISION, None) + if revision_var is None: + self.save_variables.allVariables[self.VARS_MMU_REVISION] = 0 + else: + rd_var = None + revision_var = None + if not self.save_variables or (rd_var is None and revision_var is None): + raise self.config.error("Calibration settings file (mmu_vars.cfg) not found. Check [save_variables] section in mmu_macro_vars.cfg\nAlso ensure you only have a single [save_variables] section defined in your printer config and it contains the line: mmu__revision = 0. If not, add this line and restart") + + # Create autotune manager to oversee calibration updates based on available telemetry + self.calibration_manager = MmuCalibrationManager(self) + + # Upgrade legacy or scalar variables to lists ------------------------------------------------------- + bowden_length = self.save_variables.allVariables.get(self.VARS_MMU_CALIB_BOWDEN_LENGTH, None) + if bowden_length: + self.log_debug("Upgrading %s variable" % (self.VARS_MMU_CALIB_BOWDEN_LENGTH)) + bowden_lengths = self._ensure_list_size([round(bowden_length, 1)], self.num_gates) + self.save_variables.allVariables.pop(self.VARS_MMU_CALIB_BOWDEN_LENGTH, None) + # Can't write file now so we let this occur naturally on next write + self.save_variables.allVariables[self.VARS_MMU_CALIB_BOWDEN_LENGTHS] = bowden_lengths + self.save_variables.allVariables[self.VARS_MMU_CALIB_BOWDEN_HOME] = self.gate_homing_endstop + + rotation_distance = self.save_variables.allVariables.get(self.VARS_MMU_GEAR_ROTATION_DISTANCE, None) + if rotation_distance: + self.log_debug("Upgrading %s and %s variables" % (self.VARS_MMU_GEAR_ROTATION_DISTANCE, self.VARS_MMU_CALIB_PREFIX)) + rotation_distances = [] + for i in range(self.num_gates): + ratio = self.save_variables.allVariables.get("%s%d" % (self.VARS_MMU_CALIB_PREFIX, i), 0) + rotation_distances.append(round(rotation_distance * ratio, 4)) + self.save_variables.allVariables.pop("%s%d" % (self.VARS_MMU_CALIB_PREFIX, i), None) + self.save_variables.allVariables.pop(self.VARS_MMU_GEAR_ROTATION_DISTANCE, None) + # Can't write file now so we let this occur naturally on next write + self.save_variables.allVariables[self.VARS_MMU_GEAR_ROTATION_DISTANCES] = rotation_distances + else: + self.save_variables.allVariables.pop("%s0" % self.VARS_MMU_CALIB_PREFIX, None) + + # Load bowden length configuration (calibration set with MMU_CALIBRATE_BOWDEN) ---------------------- + self.bowden_lengths = self.save_variables.allVariables.get(self.VARS_MMU_CALIB_BOWDEN_LENGTHS, None) + bowden_home = self.save_variables.allVariables.get(self.VARS_MMU_CALIB_BOWDEN_HOME, self.gate_homing_endstop) + if self.mmu_machine.require_bowden_move: + if self.bowden_lengths and bowden_home in self.GATE_ENDSTOPS: + self.bowden_lengths = [-1 if x < 0 else x for x in self.bowden_lengths] # Ensure -1 value for uncalibrated + # Ensure list size + if len(self.bowden_lengths) == self.num_gates: + self.log_debug("Loaded saved bowden lengths: %s" % self.bowden_lengths) + else: + self.log_error("Incorrect number of gates specified in %s. Adjusted length" % self.VARS_MMU_CALIB_BOWDEN_LENGTHS) + self.bowden_lengths = self._ensure_list_size(self.bowden_lengths, self.num_gates) + + # Ensure they are identical (just for optics) if variable_bowden_lengths is False + if not self.mmu_machine.variable_bowden_lengths: + self.bowden_lengths = [self.bowden_lengths[0]] * self.num_gates + + self.calibration_manager.adjust_bowden_lengths_on_homing_change() + if not any(x == -1 for x in self.bowden_lengths): + self.calibration_status |= self.CALIBRATED_BOWDENS + else: + self.log_warning("Warning: Bowden lengths not found in mmu_vars.cfg. Probably not calibrated yet") + self.bowden_lengths = [-1] * self.num_gates + else: + self.bowden_lengths = [0] * self.num_gates + self.calibration_status |= self.CALIBRATED_BOWDENS + self.save_variables.allVariables[self.VARS_MMU_CALIB_BOWDEN_LENGTHS] = self.bowden_lengths + self.save_variables.allVariables[self.VARS_MMU_CALIB_BOWDEN_HOME] = bowden_home + + # Load gear rotation distance configuration (calibration set with MMU_CALIBRATE_GEAR) --------------- + self.default_rotation_distance = self.gear_rail.steppers[0].get_rotation_distance()[0] # TODO Should probably be per gear in case they are disimilar? + self.rotation_distances = self.save_variables.allVariables.get(self.VARS_MMU_GEAR_ROTATION_DISTANCES, None) + if self.rotation_distances: + self.rotation_distances = [-1 if x == 0 else x for x in self.rotation_distances] # Ensure -1 value for uncalibrated + # Ensure list size + if len(self.rotation_distances) == self.num_gates: + self.log_debug("Loaded saved gear rotation distances: %s" % self.rotation_distances) + else: + self.log_error("Incorrect number of gates specified in %s. Adjusted length" % self.VARS_MMU_GEAR_ROTATION_DISTANCES) + self.rotation_distances = self._ensure_list_size(self.rotation_distances, self.num_gates) + + # Ensure they are identical (just for optics) if variable_rotation_distances is False + if not self.mmu_machine.variable_rotation_distances: + self.rotation_distances = [self.rotation_distances[0]] * self.num_gates + + if self.rotation_distances[0] != -1: + self.calibration_status |= self.CALIBRATED_GEAR_0 + if not any(x == -1 for x in self.rotation_distances): + self.calibration_status |= self.CALIBRATED_GEAR_RDS + else: + self.log_warning("Warning: Gear rotation distances not found in mmu_vars.cfg. Probably not calibrated yet") + self.rotation_distances = [-1] * self.num_gates + self.save_variables.allVariables[self.VARS_MMU_GEAR_ROTATION_DISTANCES] = self.rotation_distances + + # Load encoder configuration (calibration set with MMU_CALIBRATE_ENCODER) --------------------------- + self.encoder_resolution = 1.0 + if self.has_encoder(): + self.encoder_sensor.set_logger(self.log_debug) # Combine with MMU log + self.encoder_sensor.set_extruder(self.extruder_name) # Ensure it has extruder name + + # Setup FlowGuard mode and detection length + self.sync_feedback_manager.set_encoder_mode() + + # Setup resolution + self.encoder_resolution = self.encoder_sensor.get_resolution() # H/W config contains default + cal_res = self.save_variables.allVariables.get(self.VARS_MMU_ENCODER_RESOLUTION, None) + if cal_res: + self.encoder_resolution = cal_res + self.encoder_sensor.set_resolution(cal_res) + self.log_debug("Loaded saved encoder resolution: %.4f" % cal_res) + self.calibration_status |= self.CALIBRATED_ENCODER + else: + self.log_warning("Warning: Encoder resolution not found in mmu_vars.cfg. Probably not calibrated") + else: + self.calibration_status |= self.CALIBRATED_ENCODER # Pretend we are calibrated to avoid warnings + + # The threshold (mm) that determines real encoder movement (set to 1.5 pulses of encoder. i.e. to allow one rougue pulse) + self.encoder_min = 1.5 * self.encoder_resolution + + # Establish existence of Blobifier and filament cutter options + # TODO: A little bit hacky until a more universal approach is implemented + sequence_vars_macro = self.printer.lookup_object("gcode_macro _MMU_SEQUENCE_VARS", None) + if sequence_vars_macro: + self.has_blobifier = 'blob' in sequence_vars_macro.variables.get('user_post_load_extension', '').lower() # E.g. "BLOBIFIER" (old method of adding) + self.has_mmu_cutter = 'cut' in sequence_vars_macro.variables.get('user_post_unload_extension', '').lower() # E.g. "EREC_CUTTER_ACTION" + self.has_toolhead_cutter = 'cut' in self.form_tip_macro.lower() # E.g. "_MMU_CUT_TIP" + + # Sub components + for m in self.managers: + if hasattr(m, 'handle_connect'): + m.handle_connect() + + def _ensure_list_size(self, lst, size, default_value=-1): + lst = lst[:size] + lst.extend([default_value] * (size - len(lst))) + return lst + + def handle_disconnect(self): + self.log_debug('Klipper disconnected!') + + # Sub components + for m in self.managers: + if hasattr(m, 'handle_disconnect'): + m.handle_disconnect() + + def handle_ready(self): + self._can_write_variables = True + + # Pull retraction length from macro config + sequence_vars_macro = self.printer.lookup_object("gcode_macro _MMU_SEQUENCE_VARS", None) + if sequence_vars_macro: + park_toolchange = sequence_vars_macro.variables.get('park_toolchange',(0)) + self.toolchange_retract = park_toolchange[-1] + + # Reference correct extruder stepper which will definitely be available now + self.mmu_extruder_stepper = self.mmu_toolhead.mmu_extruder_stepper + if not self.homing_extruder: + self.log_debug("Warning: Using original klipper extruder stepper. Extruder homing not possible") + + # Restore state (only if fully calibrated) + self._load_persisted_state() + + # Setup events for managing internal print state machine + self.printer.register_event_handler("idle_timeout:printing", self._handle_idle_timeout_printing) + self.printer.register_event_handler("idle_timeout:ready", self._handle_idle_timeout_ready) + self.printer.register_event_handler("idle_timeout:idle", self._handle_idle_timeout_idle) + + self._setup_hotend_off_timer() + self._setup_pending_spool_id_timer() + self._clear_saved_toolhead_position() + + # This is a bit naughty to register commands here but I need to make sure we are the outermost wrapper + try: + prev_pause = self.gcode.register_command('PAUSE', None) + if prev_pause is not None: + self.gcode.register_command('__PAUSE', prev_pause) + self.gcode.register_command('PAUSE', self.cmd_PAUSE, desc = self.cmd_PAUSE_help) + else: + self.log_error('No existing PAUSE macro found!') + + prev_resume = self.gcode.register_command('RESUME', None) + if prev_resume is not None: + self.gcode.register_command('__RESUME', prev_resume) + self.gcode.register_command('RESUME', self.cmd_MMU_RESUME, desc = self.cmd_MMU_RESUME_help) + else: + self.log_error('No existing RESUME macro found!') + + prev_clear_pause = self.gcode.register_command('CLEAR_PAUSE', None) + if prev_clear_pause is not None: + self.gcode.register_command('__CLEAR_PAUSE', prev_clear_pause) + self.gcode.register_command('CLEAR_PAUSE', self.cmd_CLEAR_PAUSE, desc = self.cmd_CLEAR_PAUSE_help) + else: + self.log_error('No existing CLEAR_PAUSE macro found!') + + prev_cancel = self.gcode.register_command('CANCEL_PRINT', None) + if prev_cancel is not None: + self.gcode.register_command('__CANCEL_PRINT', prev_cancel) + self.gcode.register_command('CANCEL_PRINT', self.cmd_MMU_CANCEL_PRINT, desc = self.cmd_MMU_CANCEL_PRINT_help) + else: + self.log_error('No existing CANCEL_PRINT macro found!') + except Exception as e: + self.log_error('Error trying to wrap PAUSE/RESUME/CLEAR_PAUSE/CANCEL_PRINT macros: %s' % str(e)) + + # Sub components + for m in self.managers: + if hasattr(m, 'handle_ready'): + m.handle_ready() + + # Schedule bootup tasks to run after klipper and hopefully spoolman have settled + self._schedule_mmu_bootup_tasks(self.BOOT_DELAY) + + def reinit(self): + self.is_enabled = self.runout_enabled = True + self.runout_last_enable_time = self.reactor.monotonic() + self.is_handling_runout = self.calibrating = False + self.last_print_stats = self.paused_extruder_temp = self.reason_for_pause = None + self.tool_selected = self._next_tool = self.gate_selected = self.TOOL_GATE_UNKNOWN + self.unit_selected = 0 # Which MMU unit is active if more than one + self._last_toolchange = "Unknown" + self.active_filament = {} + self.filament_pos = self.FILAMENT_POS_UNKNOWN + self.filament_direction = self.DIRECTION_UNKNOWN + self.action = self.ACTION_IDLE + self._old_action = None + self._clear_saved_toolhead_position() + self._reset_job_statistics() + self.print_state = self.resume_to_state = "ready" + self.form_tip_vars = None # Current defaults of gcode variables for tip forming macro + self._clear_slicer_tool_map() + self.pending_spool_id = -1 # For automatic assignment of spool_id if set perhaps by rfid reader + self.saved_toolhead_max_accel = None + self.num_toolchanges = 0 + + # Sub components + for m in self.managers: + if hasattr(m, 'reinit'): + m.reinit() + + def _clear_slicer_tool_map(self): + skip = self.slicer_tool_map.get('skip_automap', False) if self.slicer_tool_map else False + self.slicer_tool_map = {'tools': {}, 'referenced_tools': [], 'initial_tool': None, 'purge_volumes': [], 'total_toolchanges': None} + self._restore_automap_option(skip) + self.slicer_color_rgb = [(0.,0.,0.)] * self.num_gates + self._update_t_macros() # Clear 'color' on Tx macros if displaying slicer colors + + def _restore_automap_option(self, skip=False): + self.slicer_tool_map['skip_automap'] = skip + + # Helper to infer type for setting gcode macro variables + def _fix_type(self, s): + try: + return float(s) + except ValueError: + try: + return int(s) + except ValueError: + return s + + # Helper to ensure int when strings may be passed from UI + def safe_int(self, i, default=0): + try: + return int(i) + except ValueError: + return default + + # Compare unicode strings with optional case insensitivity + def _compare_unicode(self, a, b, case_insensitive=True): + a = unicodedata.normalize('NFKC', a) + b = unicodedata.normalize('NFKC', b) + if case_insensitive: + a = a.lower() + b = b.lower() + return a == b + + # Format color string for display + def _format_color(self, color): + x = re.search(r"^([a-f\d]{6})(ff)?$", color, re.IGNORECASE) + if x is not None: + return '#' + x.group(1).upper() + + x = re.search(r"^([a-f\d]{6}([a-f\d]{2})?)$", color, re.IGNORECASE) + if x is not None: + return '#' + x.group().upper() + + return color + + # This retuns the hex color format without leading '#' E.g. ff00e080 + # Support alpha channel (Nice for Mainsail/Fluidd UI) + def _color_to_rgb_hex(self, color): + if color in self.w3c_colors: + color = self.w3c_colors.get(color) + elif color == '': + color = "000000" + rgb_hex = color.lstrip('#').lower() + return rgb_hex[0:8] + + # This retuns a convenient RGB fraction tuple for controlling LEDs E.g. (0.32, 0.56, 1.00) + # or integer version (82, 143, 255). Alpha channel is cut + def _color_to_rgb_tuple(self, color, fraction=True): + rgb_hex = self._color_to_rgb_hex(color)[:6] + length = len(rgb_hex) + if fraction: + if length % 3 == 0: + return tuple(round(float(int(rgb_hex[i:i + length // 3], 16)) / 255, 3) for i in range(0, length, length // 3)) + return (0.,0.,0.) + else: + if length % 3 == 0: + return tuple(int(rgb_hex[i:i+2], 16) for i in (0, 2, 4)) + return (0,0,0) + + # Helper to return validated color string or None if invalid + def _validate_color(self, color): + color = color.lower() + if color == "": + return "" + + # Try w3c named color + if color in self.w3c_colors: + return color + + # Try RGB color + color = color.lstrip('#').lower() + x = re.search(r"^([a-f\d]{6}([a-f\d]{2})?)$", color, re.IGNORECASE) + if x is not None and x.group() == color: + return color + + return None # Not valid + + # Helper for finding the closest color + # Example: + # color_list = ['123456', 'abcdef', '789abc', '4a7d9f', '010203'] + # _find_closest_color('4b7d8e', color_list) returns '4a7d9f' + def _find_closest_color(self, ref_color, color_list): + weighted_euclidean_distance = lambda color1, color2, weights=(0.3, 0.59, 0.11): ( + sum(weights[i] * (a - b) ** 2 for i, (a, b) in enumerate(zip(color1, color2))) + ) + ref_rgb = self._color_to_rgb_tuple(ref_color) + min_distance = float('inf') + closest_color = None + for color in color_list: + color_rgb = self._color_to_rgb_tuple(color) + distance = weighted_euclidean_distance(ref_rgb, color_rgb) + if distance < min_distance: + min_distance = distance + closest_color = color + return closest_color, min_distance + + # Helper to keep parallel RGB color map updated when color changes + def _update_gate_color_rgb(self): + # Recalculate RGB map for easy LED support + self.gate_color_rgb = [self._color_to_rgb_tuple(i) for i in self.gate_color] + + # Helper to keep parallel RGB color map updated when slicer color or TTG changes + # Will also update the t_macro colors + def _update_slicer_color_rgb(self): + self.slicer_color_rgb = [(0.,0.,0.)] * self.num_gates + for tool_key, tool_value in self.slicer_tool_map['tools'].items(): + tool = int(tool_key) + gate = self.ttg_map[tool] + self.slicer_color_rgb[gate] = self._color_to_rgb_tuple(tool_value['color']) + self._update_t_macros() + self.led_manager.gate_map_changed(None) # Force LED update + + # Helper to determine purge volume for toolchange + def _calc_purge_volume(self, from_tool, to_tool): + fil_diameter = 1.75 + volume = 0. + + if to_tool >= 0: + slicer_purge_volumes = self.slicer_tool_map['purge_volumes'] + if slicer_purge_volumes: + if from_tool >= 0: + volume = slicer_purge_volumes[from_tool][to_tool] + else: + # Assume worse case because we don't know from_tool + volume = max(row[to_tool] for row in slicer_purge_volumes) + + # Always add volume of residual filament (cut fragment and bit always left in the hotend) + volume += math.pi * ((fil_diameter / 2) ** 2) * (self.filament_remaining + self.toolhead_residual_filament) + return volume + + # Generate purge matrix based on filament colors + def _generate_purge_matrix(self, tool_colors, purge_min, purge_max, multiplier): + purge_vol_calc = PurgeVolCalculator(purge_min, purge_max, multiplier) + + # Build purge volume map (x=to_tool, y=from_tool) + should_calc = lambda x,y: x < len(tool_colors) and y < len(tool_colors) and x != y + purge_volumes = [ + [ + purge_vol_calc.calc_purge_vol_by_hex(tool_colors[y], tool_colors[x]) if should_calc(x,y) else 0 + for x in range(self.num_gates) + ] + for y in range(self.num_gates) + ] + return purge_volumes + + def _load_persisted_state(self): + self.log_debug("Loading persisted MMU state") + errors = [] + + # Always load length of filament remaining in extruder (after cut) and last tool loaded + self.filament_remaining = self.save_variables.allVariables.get(self.VARS_MMU_FILAMENT_REMAINING, self.filament_remaining) + self._last_tool = self.save_variables.allVariables.get(self.VARS_MMU_LAST_TOOL, self._last_tool) + + # Load EndlessSpool config + self.endless_spool_enabled = self.save_variables.allVariables.get(self.VARS_MMU_ENABLE_ENDLESS_SPOOL, self.endless_spool_enabled) + endless_spool_groups = self.save_variables.allVariables.get(self.VARS_MMU_ENDLESS_SPOOL_GROUPS, self.endless_spool_groups) + if len(endless_spool_groups) == self.num_gates: + self.endless_spool_groups = endless_spool_groups + else: + errors.append("Incorrect number of gates specified in %s" % self.VARS_MMU_ENDLESS_SPOOL_GROUPS) + + # Load TTG map + tool_to_gate_map = self.save_variables.allVariables.get(self.VARS_MMU_TOOL_TO_GATE_MAP, self.ttg_map) + if len(tool_to_gate_map) == self.num_gates: + self.ttg_map = tool_to_gate_map + else: + errors.append("Incorrect number of gates specified in %s" % self.VARS_MMU_TOOL_TO_GATE_MAP) + + # Load gate map + for var, attr, _ in self.gate_map_vars: + value = self.save_variables.allVariables.get(var, getattr(self, attr)) + if len(value) == self.num_gates: + setattr(self, attr, value) + else: + errors.append("Incorrect number of gates specified with %s" % var) + self._update_gate_color_rgb() + + # Load selected tool and gate + tool_selected = self.save_variables.allVariables.get(self.VARS_MMU_TOOL_SELECTED, self.tool_selected) + gate_selected = self.save_variables.allVariables.get(self.VARS_MMU_GATE_SELECTED, self.gate_selected) + if ( + not (self.TOOL_GATE_BYPASS <= gate_selected <= self.num_gates) or + gate_selected == self.TOOL_GATE_UNKNOWN + ): + errors.append("Invalid gate specified with %s or %s" % (self.VARS_MMU_TOOL_SELECTED, self.VARS_MMU_GATE_SELECTED)) + tool_selected = gate_selected = self.TOOL_GATE_UNKNOWN + + # Don't allow unknown gate on type-B MMU's (could also be first time bootup) + if self.mmu_machine.multigear and gate_selected == self.TOOL_GATE_UNKNOWN: + gate_selected = 0 + + self.selector.restore_gate(gate_selected) + self._set_gate_selected(gate_selected) + self._set_tool_selected(tool_selected) + self._ensure_ttg_match() # Ensure tool/gate consistency + + # Previous filament position + self.filament_pos = self.save_variables.allVariables.get(self.VARS_MMU_FILAMENT_POS, self.filament_pos) + + if len(errors) > 0: + self.log_warning("Warning: Some persisted state was ignored because it contained errors:\n%s" % '\n'.join(errors)) + + swap_stats = self.save_variables.allVariables.get(self.VARS_MMU_SWAP_STATISTICS, {}) + counters = self.save_variables.allVariables.get(self.VARS_MMU_COUNTERS, {}) + self.counters.update(counters) + + # Auto upgrade old names + key_map = {"time_spent_loading": "load", "time_spent_unloading": "unload", "time_spent_paused": "pause"} + swap_stats = {key_map.get(key, key): swap_stats[key] for key in swap_stats} + swap_stats.pop('servo_retries', None) # DEPRECATED + + self.statistics.update(swap_stats) + for gate in range(self.num_gates): + self.gate_statistics[gate] = dict(self.EMPTY_GATE_STATS_ENTRY) + gstats = self.save_variables.allVariables.get("%s%d" % (self.VARS_MMU_GATE_STATISTICS_PREFIX, gate), None) + if gstats: + self.gate_statistics[gate].update(gstats) + + def _schedule_mmu_bootup_tasks(self, delay=0.): + waketime = self.reactor.monotonic() + delay + self.reactor.register_callback(lambda pt: self._print_event("__MMU_BOOTUP"), waketime) + + def _fversion(self, v): + return "v{major}.{minor}.{patch}".format( + major=int(v), + minor=str(v).split('.')[1][0] if '.' in str(v) and len(str(v).split('.')[1]) > 0 else '0', + patch=str(v).split('.')[1][1:] if '.' in str(v) and len(str(v).split('.')[1]) > 1 else '0' + ) + + cmd_MMU_BOOTUP_help = "Internal commands to complete bootup of MMU" + def cmd_MMU_BOOTUP(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + self.selector.bootup() + + try: + # Splash... + msg = '{1}(\_/){0}\n{1}( {0}*,*{1}){0}\n{1}(")_("){0} {5}{2}H{0}{3}a{0}{4}p{0}{2}p{0}{3}y{0} {4}H{0}{2}a{0}{3}r{0}{4}e{0} {1}%s{0} {2}R{0}{3}e{0}{4}a{0}{2}d{0}{3}y{0}{1}...{0}{6}' % self._fversion(self.config_version) + self.log_always(msg, color=True) + if self.kalico: + msg = "Warning: You are running on Kalico (Danger-Klipper). Support is not guaranteed!" + if self.suppress_kalico_warning: + self.log_trace(msg + " Message was suppressed.") + else: + self.log_warning(msg) + + # Look for filament_switch_sensors already configured to warn for possible conflicts + for section in self.config.get_prefix_sections('filament_switch_sensor'): + # Determine if this is created by HH or user + fsensor = self.printer.lookup_object(section.get_name()) + if not isinstance(fsensor.runout_helper, MmuRunoutHelper): + fsensor_name = section.get_name().split()[1] + pause_on_runout = section.getboolean('pause_on_runout', False) + pause_on_runout_msg = " and/or pause during prints unintentionally" if pause_on_runout else "" + self.log_warning("Warning: filament_switch_sensor '%s' found in printer configuration. This may interfere with MMU functionality%s." % (fsensor_name, pause_on_runout_msg)) + + self._set_print_state("initialized") + + # Use per gate sensors to adjust gate map + self.gate_status = self._validate_gate_status(self.gate_status) + + # Can we verify gate selected? If so fix now + gate_selected = self._validate_gate_selected() + if gate_selected is not None and gate_selected != self.gate_selected: + self.selector.restore_gate(gate_selected) + self._set_gate_selected(gate_selected) + self._ensure_ttg_match() # Ensure tool/gate consistency + + # Sanity check filament pos based only on non-intrusive tests and recover if necessary + if self.sensor_manager.check_all_sensors_after( + self.FILAMENT_POS_END_BOWDEN, self.gate_selected + ): + self._set_filament_pos_state(self.FILAMENT_POS_LOADED, silent=True) + + elif ( + (self.filament_pos == self.FILAMENT_POS_LOADED and + not self.sensor_manager.check_any_sensors_after(self.FILAMENT_POS_END_BOWDEN, self.gate_selected)) or + + (self.filament_pos == self.FILAMENT_POS_UNLOADED and + self.sensor_manager.check_any_sensors_in_path()) or + + self.filament_pos not in [self.FILAMENT_POS_LOADED, self.FILAMENT_POS_UNLOADED] + ): + self.recover_filament_pos(can_heat=False, message=True, silent=True) + + # Apply startup options + if self.startup_reset_ttg_map: + self._reset_ttg_map() + + if self.startup_home_if_unloaded and not self.check_if_not_calibrated(self.CALIBRATED_SELECTOR) and self.filament_pos == self.FILAMENT_POS_UNLOADED: + self.home(0) + + if self.log_startup_status: + self.log_always(self._mmu_visual_to_string()) + self._display_visual_state() + self.report_necessary_recovery() + + # Ensure espooler print assist is correct + self._adjust_espooler_assist() + + # Initially disable clog/runout detection + self._disable_filament_monitoring() + + self.reset_sync_gear_to_extruder(False) # Intention is not to sync unless we have to + self.mmu_toolhead.quiesce() + + # Sync with spoolman. Delay as long as possible to maximize the chance it is contactable after startup/reboot + self._spoolman_sync() + + # Sync lane data to Moonraker for slicer integration and cleanup old lanes + self._moonraker_sync_lane_data() + + except Exception as e: + logging.error(traceback.format_exc()) + self.log_error('Error booting up MMU: %s' % str(e)) + + self.mmu_macro_event(self.MACRO_EVENT_RESTART) + + # Wrap execution of gcode command to allow for control over: + # - error handling + # - passing of additional variables + # - waiting on completion + def wrap_gcode_command(self, command, exception=False, variables=None, wait=False): + try: + command = command.replace("''", "") + macro = command.split()[0] + if not macro: return + + if variables: + gcode_macro = self.printer.lookup_object("gcode_macro %s" % macro, None) + if gcode_macro: + gcode_macro.variables.update(variables) + + self.log_trace("Running macro: %s%s" % (command, " (with override variables)" if variables is not None else "")) + + self.gcode.run_script_from_command(command) + if wait: + self.mmu_toolhead.quiesce() + + except Exception as e: + if exception is not None: + if exception: + raise MmuError("Error running %s: %s" % (macro, str(e))) + else: + self.log_error("Error running %s: %s" % (macro, str(e))) + else: + raise + + def mmu_macro_event(self, event_name, params=""): + if self.printer.lookup_object("gcode_macro %s" % self.mmu_event_macro, None) is not None: + self.wrap_gcode_command("%s EVENT=%s %s" % (self.mmu_event_macro, event_name, params)) + + # Wait on desired move queues + # TODO: perhaps better now to remove this method and + # TODO: always just call self.mmu_toolhead.quiesce() + def movequeues_wait(self, toolhead=True, mmu_toolhead=True): + #self.log_trace("movequeues_wait(toolhead=%s, mmu_toolhead=%s)" % (toolhead, mmu_toolhead)) + if toolhead: + self.toolhead.wait_moves() + if mmu_toolhead: + self.mmu_toolhead.wait_moves() + + # Dwell on desired move queues + def movequeues_dwell(self, dwell, toolhead=True, mmu_toolhead=True): + if dwell > 0.: + if toolhead: + self.toolhead.dwell(dwell) + if mmu_toolhead: + self.mmu_toolhead.dwell(dwell) + + +#################################### +# LOGGING AND STATISTICS FUNCTIONS # +#################################### + + def _get_action_string(self, action=None): + if action is None: + action = self.action + + return ("Idle" if action == self.ACTION_IDLE else + "Loading" if action == self.ACTION_LOADING else + "Unloading" if action == self.ACTION_UNLOADING else + "Loading Ext" if action == self.ACTION_LOADING_EXTRUDER else + "Exiting Ext" if action == self.ACTION_UNLOADING_EXTRUDER else + "Forming Tip" if action == self.ACTION_FORMING_TIP else + "Cutting Tip" if action == self.ACTION_CUTTING_TIP else + "Heating" if action == self.ACTION_HEATING else + "Checking" if action == self.ACTION_CHECKING else + "Homing" if action == self.ACTION_HOMING else + "Selecting" if action == self.ACTION_SELECTING else + "Cutting Filament" if action == self.ACTION_CUTTING_FILAMENT else + "Purging" if action == self.ACTION_PURGING else + "Unknown") # Error case - should not happen + + def _get_bowden_progress(self): + if self.bowden_start_pos is not None: + bowden_length = self.calibration_manager.get_bowden_length() + if bowden_length > 0: + current = self.get_encoder_distance(dwell=None) if self.has_encoder() else self._get_live_filament_position() + progress = abs(current - self.bowden_start_pos) / bowden_length + if self.filament_direction == self.DIRECTION_UNLOAD: + progress = 1 - progress + return round(max(0, min(100, progress * 100))) + return -1 + + # Returning new list() is so that clients like KlipperScreen sees the change + def get_status(self, eventtime): + status = { + 'enabled': self.is_enabled, + 'num_gates': self.num_gates, + 'is_homed': self.selector.is_homed, + 'is_locked': self.is_mmu_paused(), # DEPRECATED (alias for is_paused) + 'is_paused': self.is_mmu_paused(), # DEPRECATED (use print_state) + 'is_in_print': self.is_in_print(), # DEPRECATED (use print_state) + 'print_state': self.print_state, + 'unit': self.unit_selected, + 'tool': self.tool_selected, + 'gate': self._next_gate if self._next_gate is not None else self.gate_selected, + 'active_filament': self.active_filament, + 'num_toolchanges': self.num_toolchanges, + 'last_tool': self._last_tool, + 'next_tool': self._next_tool, + 'toolchange_purge_volume': self.toolchange_purge_volume, + 'last_toolchange': self._last_toolchange, + 'runout': self.is_handling_runout, # DEPRECATED (use operation) + 'operation': self.saved_toolhead_operation, + 'filament': "Loaded" if self.filament_pos == self.FILAMENT_POS_LOADED else + "Unloaded" if self.filament_pos == self.FILAMENT_POS_UNLOADED else + "Unknown", + 'filament_position': self.mmu_toolhead.get_position()[1], + 'filament_pos': self.filament_pos, # State machine position + 'filament_direction': self.filament_direction, + 'pending_spool_id': self.pending_spool_id, + 'ttg_map': self.ttg_map, + 'endless_spool_groups': self.endless_spool_groups, + 'gate_status': self.gate_status, + 'gate_filament_name': self.gate_filament_name, + 'gate_material': self.gate_material, + 'gate_color': self.gate_color, + 'gate_temperature': self.gate_temperature, + 'gate_spool_id': self.gate_spool_id, + 'gate_speed_override': self.gate_speed_override, + 'gate_color_rgb': self.gate_color_rgb, + 'slicer_color_rgb': self.slicer_color_rgb, + 'tool_extrusion_multipliers': self.tool_extrusion_multipliers, + 'tool_speed_multipliers': self.tool_speed_multipliers, + 'slicer_tool_map': self.slicer_tool_map, + 'action': self._get_action_string(), + 'has_bypass': self.selector.has_bypass(), # TODO deprecate because this is a per unit selector bypass + 'sync_drive': self.mmu_toolhead.is_synced(), + 'print_start_detection': self.print_start_detection, # For Klippain. Not really sure it is necessary + 'reason_for_pause': self.reason_for_pause if self.is_mmu_paused() else "", + 'extruder_filament_remaining': self.filament_remaining + self.toolhead_residual_filament, + 'spoolman_support': self.spoolman_support, + 'bowden_progress': self._get_bowden_progress(), # Simple 0-100%. -1 if not performing bowden move + 'espooler_active': self.espooler.get_operation(self.gate_selected)[0] if self.has_espooler() else '', + 'clog_detection': self.sync_feedback_manager.flowguard_encoder_mode, # DEPRECATED + 'clog_detection_enabled': self.sync_feedback_manager.flowguard_encoder_mode, # DEPRECATED + 'endless_spool': self.endless_spool_enabled, # DEPRECATED + 'endless_spool_enabled': self.endless_spool_enabled, # DEPRECATED + } + + # Sub components + for m in self.managers: + if hasattr(m, 'get_status'): + status.update(m.get_status(eventtime)) + + # Not yet refactored as manager class + if self.has_espooler(): + status.update(self.espooler.get_status(eventtime)) + + status['sensors'] = self.sensor_manager.get_status(eventtime) + if self.has_encoder(): + status['encoder'] = self.encoder_sensor.get_status(eventtime) + return status + + def _reset_statistics(self): + self.statistics = {} + self.last_statistics = {} + self.track = {} + self.gate_statistics = [] + for _ in range(self.num_gates): + self.gate_statistics.append(dict(self.EMPTY_GATE_STATS_ENTRY)) + self._reset_job_statistics() + + def _reset_job_statistics(self): + self.job_statistics = {} + + def _track_time_start(self, name): + self.track[name] = self.toolhead.get_last_move_time() + + def _track_time_end(self, name): + if name not in self.track: + return # Timer not initialized + self.statistics.setdefault(name, 0) + self.job_statistics.setdefault(name, 0) + elapsed = self.toolhead.get_last_move_time() - self.track[name] + self.statistics[name] += elapsed + self.job_statistics[name] += elapsed + self.last_statistics[name] = elapsed + + @contextlib.contextmanager + def _wrap_track_time(self, name): + self._track_time_start(name) + try: + yield self + finally: + self._track_time_end(name) + + def _track_swap_completed(self): + self.statistics.setdefault('total_swaps', 0) + self.job_statistics.setdefault('total_swaps', 0) + self.statistics.setdefault('swaps_since_pause', 0) + self.statistics.setdefault('swaps_since_pause_record', 0) + + self.statistics['swaps_since_pause'] += 1 + self.statistics['swaps_since_pause_record'] = max(self.statistics['swaps_since_pause_record'], self.statistics['swaps_since_pause']) + self.statistics['total_swaps'] += 1 + self.job_statistics['total_swaps'] += 1 + + def _track_pause_start(self): + self.statistics.setdefault('total_pauses', 0) + self.job_statistics.setdefault('total_pauses', 0) + + self.statistics['total_pauses'] += 1 + self.job_statistics['total_pauses'] += 1 + self.statistics['swaps_since_pause'] = 0 + + self._track_time_start('pause') + self._track_gate_statistics('pauses', self.gate_selected) + + def _track_pause_end(self): + self._track_time_end('pause') + + # Per gate tracking + def _track_gate_statistics(self, key, gate, count=1): + try: + if gate >= 0: + if isinstance(count, float): + self.gate_statistics[gate][key] = round(self.gate_statistics[gate][key] + count, 3) + else: + self.gate_statistics[gate][key] += count + except Exception as e: + self.log_debug("Exception whilst tracking gate stats: %s" % str(e)) + + def _seconds_to_short_string(self, seconds): + if isinstance(seconds, (float, int)) or seconds.isnumeric(): + s = int(seconds) + h = s // 3600 + m = (s // 60) % 60 + ms = int(round((seconds * 1000) % 1000, 0)) + s = s % 60 + + if h > 0: + return "{hour}:{min:0>2}:{sec:0>2}".format(hour=h, min=m, sec=s) + if m > 0: + return "{min}:{sec:0>2}".format(min=m, sec=s) + if s >= 10: + return "{sec}.{tenths}".format(sec=s, tenths=int(round(ms / 100, 0))) + return "{sec}.{hundreds:0>2}".format(sec=s, hundreds=int(round(ms / 10, 0))) + return seconds + + def _seconds_to_string(self, seconds): + result = "" + hours = int(math.floor(seconds / 3600.)) + if hours >= 1: + result += "%d hours " % hours + minutes = int(math.floor(seconds / 60.) % 60) + if hours >= 1 or minutes >= 1: + result += "%d minutes " % minutes + result += "%d seconds" % int((math.floor(seconds) % 60)) + return result + + def _swap_statistics_to_string(self, total=True, detail=False): + # + # +-----------+---------------------+----------------------+----------+ + # | 114(46) | unloading | loading | complete | + # | swaps | pre | - | post | pre | - | post | swap | + # +-----------+------+-------+------+------+-------+-------+----------+ + # | total | 0:07 | 47:19 | 0:00 | 0:01 | 37:11 | 33:39 | 2:00:38 | + # | - avg | 0:00 | 0:24 | 0:00 | 0:00 | 0:19 | 0:17 | 1:03 | + # | this job | 0:00 | 10:27 | 0:00 | 0:00 | 8:29 | 8:30 | 28:02 | + # | - avg | 0:00 | 0:13 | 0:00 | 0:00 | 0:11 | 0:11 | 0:36 | + # | last | 0:00 | 0:12 | 0:00 | 0:00 | 0:10 | 0:14 | 0:39 | + # +-----------+------+-------+------+------+-------+-------+----------+ + # Time spent paused: ... + # + msg = "MMU Statistics:\n" + lifetime = self.statistics + job = self.job_statistics + last = self.last_statistics + total = self.console_always_output_full or total or not self.is_in_print() + + table_column_order = ['pre_unload', 'form_tip', 'unload', 'post_unload', 'pre_load', 'load', 'purge', 'post_load', 'total'] + table_include_columns = self._list_intersection(table_column_order, self.console_stat_columns if not detail else table_column_order) # To maintain the correct order and filter incorrect ones + + table_row_options = ['total', 'total_average', 'job', 'job_average', 'last'] + table_include_rows = self._list_intersection(self.console_stat_rows, table_row_options) # Keep the user provided order + + # Remove totals from table if not in print and not forcing total + if not self.console_always_output_full and not total: + if 'total' in table_include_rows: table_include_rows.remove('total') + if 'total_average' in table_include_rows: table_include_rows.remove('total_average') + if not self.is_in_print(): + if 'job' in table_include_rows: table_include_rows.remove('job') + if 'job_average' in table_include_rows: table_include_rows.remove('job_average') + + if len(table_include_rows) > 0: + # Map the row names (as described in macro_vars) to the proper values. stats is mandatory + table_rows_map = { + 'total': {'stats': lifetime, 'name': 'total '}, + 'total_average': {'stats': lifetime, 'name': UI_CASCADE + ' avg', 'devide': lifetime.get('total_swaps', 1)}, + 'job': {'stats': job, 'name': 'this job '}, + 'job_average': {'stats': job, 'name': UI_CASCADE + ' avg', 'devide': job.get('total_swaps', 1)}, + 'last': {'stats': last, 'name': 'last'} + } + # Map the saved timing values to proper column titles + table_headers_map = { + 'pre_unload': 'pre', + 'form_tip': 'tip', + 'unload': '-', + 'post_unload': 'post', + 'pre_load': 'pre', + 'load': '-', + 'purge': 'purge', + 'post_load': 'post', + 'total': 'swap' + } + # Group the top headers map. Omit the first column, because that'll be filled with the nr. of swaps + table_extra_headers_map = { + 'unloading': ['pre_unload', 'form_tip', 'unload', 'post_unload'], + 'loading': ['pre_load', 'load', 'purge', 'post_load'], + 'complete': ['total'] + } + # Extract the table headers that will be used + table_headers = [table_headers_map[key] for key in table_include_columns] + # Insert the first column. This is normally empty but will sit below the number of swaps + table_headers.insert(0, 'swaps') + + # Filter out the top (group) headers ( If none of the unload columns are present, unloading can be removed) + table_extra_headers = [key for key, values in table_extra_headers_map.items() if self._list_intersection(values, table_include_columns)] + + # Dictionary keys have no predefined order, so re-order them (Lucky the columns are alphabetical) + table_extra_headers.sort(reverse=True) + # Include the number of swaps in the top-left corner of the table + if self.is_in_print(): + if total: + table_extra_headers.insert(0, '%d(%d)' % (lifetime.get('total_swaps', 0), job.get('total_swaps', 0))) + else: + table_extra_headers.insert(0, '%d' % (job.get('total_swaps', 0))) + else: + table_extra_headers.insert(0, '%d' % (lifetime.get('total_swaps', 0))) + + # Build the table and populate with times + table = [] + for row in table_include_rows: + name = table_rows_map[row].get('name', row) + stats = table_rows_map[row]['stats'] + devide = max(1, table_rows_map[row].get('devide', 1)) + table.append([name]) + table[-1].extend(["-" if key not in stats else self._seconds_to_short_string(stats.get(key, 0) / devide) for key in table_include_columns]) + + # Calculate the needed column widths (The +2 is for a margin on both ends) + column_extra_header_widths = [len(table_extra_header) + 2 for table_extra_header in table_extra_headers] + column_widths = [max(len(table_headers[c]), max(len(row[c]) for row in table)) + 2 for c in range(len(table_include_columns) + 1) ] + + # If an 'extra_header' is wider then the sum of the columns beneath it, widen up those columns + for i, w in enumerate(column_extra_header_widths): + start = sum(max(1, len(self._list_intersection(table_extra_headers_map.get(table_extra_header, ['']), table_include_columns))) + for table_extra_header in table_extra_headers[0:i]) + end = start + max(1, len(self._list_intersection(table_extra_headers_map.get(table_extra_headers[i], ['']), table_include_columns))) + while (sum(column_widths[start:end]) + (end - start - 1)) < w: + for c in range(start, end): + column_widths[c] += 1 + column_extra_header_widths[i] = sum(column_widths[start:end]) + (end - start - 1) + + # Build the table header + msg += UI_BOX_TL + UI_BOX_T.join([UI_BOX_H * width for width in column_extra_header_widths]) + UI_BOX_TR + "\n" + msg += UI_BOX_V + UI_BOX_V.join([table_extra_headers[i].center(column_extra_header_widths[i], UI_SEPARATOR) + for i in range(len(column_extra_header_widths))]) + UI_BOX_V + "\n" + msg += UI_BOX_V + UI_BOX_V.join([table_headers[i].center(column_widths[i], UI_SEPARATOR) + for i in range(len(column_widths))]) + UI_BOX_V + "\n" + msg += UI_BOX_L + UI_BOX_M.join([UI_BOX_H * (width) for width in column_widths]) + UI_BOX_R + "\n" + + # Build the table body + for row in table: + msg += UI_BOX_V + UI_BOX_V.join([row[i].rjust(column_widths[i] - 1, UI_SEPARATOR) + UI_SEPARATOR + for i in range(len(column_widths))]) + UI_BOX_V + "\n" + + # Table footer + msg += UI_BOX_BL + UI_BOX_B.join([UI_BOX_H * width for width in column_widths]) + UI_BOX_BR + "\n" + + # Pause data + if total: + msg += "\n%s spent paused over %d pauses (All time)" % (self._seconds_to_short_string(lifetime.get('pause', 0)), lifetime.get('total_pauses', 0)) + if self.is_in_print(): + msg += "\n%s spent paused over %d pauses (This job)" % (self._seconds_to_short_string(job.get('pause', 0)), job.get('total_pauses', 0)) + if self.slicer_tool_map['total_toolchanges'] is not None: + msg += "\n%d / %d toolchanges" % (self.num_toolchanges, self.slicer_tool_map['total_toolchanges']) + else: + msg += "\n%d toolchanges" % self.num_toolchanges + msg += "\nNumber of swaps since last incident: %d (Record: %d)" % (lifetime.get('swaps_since_pause', 0), lifetime.get('swaps_since_pause_record', 0)) + + return msg + + def _list_intersection(self, list1, list2): + result = [] + for item in list1: + if item in list2: + result.append(item) + return result + + def _dump_statistics(self, force_log=False, total=False, job=False, gate=False, detail=False, showcounts=False): + msg = "" + if self.log_statistics or force_log: + if job or total: + msg += self._swap_statistics_to_string(total=total, detail=detail) + if self._can_use_encoder() and gate: + m,d = self._gate_statistics_to_string() + msg += "\n\n" if msg != "" else "" + msg += m + if detail: + msg += "\n" if msg != "" else "" + msg += d + + if showcounts and self.counters: + if msg: + msg += "\n\n" + msg += "Consumption counters:\n" + for counter, metric in self.counters.items(): + if metric['limit'] >= 0 and metric['count'] > metric['limit']: + msg += "Count %s: %d (above limit %d), Warning: %s" % (counter, metric['count'], metric['limit'], metric.get('warning', "")) + elif metric['limit'] >= 0: + msg += "Count %s: %d (limit %d%s)\n" % (counter, metric['count'], metric['limit'], ", will pause" if metric.get('pause', False) else "") + else: + msg += "Count %s: %d\n" % (counter, metric['count']) + + if msg: + self.log_always(msg) + + def _gate_statistics_to_string(self): + msg = "Gate Statistics:\n" + dbg = "" + t = self.console_gate_stat + for gate in range(self.num_gates): + #rounded = {k:round(v,1) if isinstance(v,float) else v for k,v in self.gate_statistics[gate].items()} + rounded = self.gate_statistics[gate] + load_slip_percent = (rounded['load_delta'] / rounded['load_distance']) * 100 if rounded['load_distance'] != 0. else 0. + unload_slip_percent = (rounded['unload_delta'] / rounded['unload_distance']) * 100 if rounded['unload_distance'] != 0. else 0. + quality = rounded['quality'] + # Give the gate a reliability grading based on "quality" which is based on slippage + if t == 'percentage': + status = '%s%%' % min(100, round(quality * 100, 1)) if quality >= 0 else "n/a" + elif quality < 0: + status = UI_EMOTICONS[0] if t == 'emoticon' else "n/a" + elif quality >= 0.985: + status = UI_EMOTICONS[1] if t == 'emoticon' else "Perfect" + elif quality >= 0.965: + status = UI_EMOTICONS[2] if t == 'emoticon' else "Great" + elif quality >= 0.95: + status = UI_EMOTICONS[3] if t == 'emoticon' else "Good" + elif quality >= 0.925: + status = UI_EMOTICONS[4] if t == 'emoticon' else "Marginal" + elif quality >= 0.90: + status = UI_EMOTICONS[5] if t == 'emoticon' else "Degraded" + elif quality >= 0.85: + status = UI_EMOTICONS[6] if t == 'emoticon' else "Poor" + else: + status = UI_EMOTICONS[7] if t == 'emoticon' else "Terrible" + msg += "%d:%s" % (gate, status) + msg += ", " if gate < (self.num_gates - 1) else "" + dbg += "\nGate %d: " % gate + dbg += "Load: (monitored: %.1fmm slippage: %.1f%%)" % (rounded['load_distance'], load_slip_percent) + dbg += "; Unload: (monitored: %.1fmm slippage: %.1f%%)" % (rounded['unload_distance'], unload_slip_percent) + dbg += "; Failures: (load: %d unload: %d pauses: %d)" % (rounded['load_failures'], rounded['unload_failures'], rounded['pauses']) + dbg += "; Quality: %.1f%%" % ((rounded['quality'] * 100.) if rounded['quality'] >= 0. else 0.) + return msg, dbg + + def _persist_gate_statistics(self): + for gate in range(self.num_gates): + self.save_variable("%s%d" % (self.VARS_MMU_GATE_STATISTICS_PREFIX, gate), self.gate_statistics[gate]) + + # Also a good place to update the persisted calibrated clog length (for auto mode) + if self.has_encoder(): + mode = self.sync_feedback_manager.flowguard_encoder_mode + if mode == self.encoder_sensor.RUNOUT_AUTOMATIC: + cdl = self.encoder_sensor.get_clog_detection_length() + self.calibration_manager.update_clog_detection_length(round(cdl, 1)) + + self.write_variables() + + def _persist_swap_statistics(self): + self.statistics = {key: round(value, 2) if isinstance(value, float) else value for key, value in self.statistics.items()} + self.save_variable(self.VARS_MMU_SWAP_STATISTICS, self.statistics, write=True) + + def _persist_counters(self): + self.save_variable(self.VARS_MMU_COUNTERS, self.counters, write=True) + + + def format_help(self, msg, supplement=None): + """ + Format a help message and optional supplement into a nicely aligned block. + + The input `msg` is expected to be multi-line with the first line containing + either "command: description" or just a single heading line. Subsequent + lines may contain parameter definitions in the form "name = value". + + This function: + - Keeps the heading (and highlights the command using UI markers "{5}" / "{6}"). + - Aligns parameter names into a column (minimum width 10). + - Prefixes parameter lines with a cascade/UI marker using `UI_CASCADE`. + - Uses `UI_SPACE` as the fill character when padding parameter names. + - Optionally appends a supplement block (if provided) wrapped with UI markers. + + Args: + msg: The main help message (multi-line). + supplement: Optional supplemental text (multi-line) appended after the main block. + + Returns: + The formatted help string. + """ + if not msg: + return "" + + lines = msg.splitlines() + if not lines: + return msg + + # Format the heading (first line). If the heading contains ":", split into + # command and description and wrap the command in UI markers. + first_line = lines[0].rstrip() + if ":" in first_line: + cmd, helpstr = first_line.split(":", 1) + formatted_help = "{5}" + cmd.strip() + "{6} : " + helpstr.strip() + else: + formatted_help = first_line + + # Compute parameter name column width: minimum 10, else longest name+1. + param_lines = [ln for ln in lines[1:] if "=" in ln] + def param_name_length(ln): + name = ln.split("=", 1)[0].strip() + return len(name) + 1 + + param_width = max(10, max((param_name_length(ln) for ln in param_lines), default=0)) + + # Build formatted parameter lines + formatted_params: list[str] = [] + for ln in lines[1:]: + if "=" in ln: + key, value = ln.split("=", 1) + key_str = key.strip() + value_str = value.strip() + padded_key = key_str.ljust(param_width, UI_SPACE) + padded = f"{padded_key}= {value_str}" + formatted_line = f"{{4}}{UI_CASCADE} {padded}{{0}}" + else: + formatted_line = f"{{4}}{UI_CASCADE} {ln.rstrip()}{{0}}" + formatted_params.append(formatted_line) + + # Handle supplement if provided + formatted_supplement = "" + if supplement is not None: + supp_lines = supplement.splitlines() + if supp_lines: + first = supp_lines[0].strip() + formatted_supplement = "{3}{5}" + first + "{6}" + if len(supp_lines) > 1: + formatted_supplement += "\n" + "\n".join(line.rstrip() for line in supp_lines[1:]) + formatted_supplement += "{0}" + + main_block = "\n".join([formatted_help] + formatted_params) if formatted_params else formatted_help + return main_block + (("\n" + formatted_supplement) if formatted_supplement else "") + +# def format_help(self, msg, supplement=None): +# lines = msg.splitlines() +# if not lines: +# return msg +# +# first_line = lines[0] +# if ":" in first_line: +# cmd, helpstr = first_line.split(":", 1) +# formatted_help = "{5}%s{6}:%s" % (cmd.strip(), helpstr) +# else: +# formatted_help = first_line +# +# param_width = max(10, max((len(line.split("=", 1)[0].strip()) + 1 for line in lines[1:] if "=" in line), default=0)) +# formatted_params = [] +# for line in lines[1:]: +# if "=" in line: +# key, value = line.split("=", 1) +# padded = key.strip().ljust(param_width, UI_SPACE) + "= " + value.strip() +# formatted_line = "{4}%s %s{0}" % (UI_CASCADE, padded) +# else: +# formatted_line = "{4}%s %s{0}" % (UI_CASCADE, line) +# formatted_params.append(formatted_line) +# +# formatted_supplement = "" +# if supplement is not None: +# lines = supplement.splitlines() +# formatted_supplement = "{3}{5}%s{6}" % lines[0] +# formatted_supplement += lines[1:] +# formatted_supplement += "{0}" +# +# return "\n".join([formatted_help] + formatted_params) + formatted_supplement + + def _color_message(self, msg): + try: + html_msg = msg.format( + '', # {0} COLOR OFF + '', # {1} COLOR GREY + '', # {2} COLOR RED + '', # {3} COLOR GREEN + '', # {4} COLOR CYAN + '', # {5} BOLD ON + '' # {6} BOLD OFF + ) + except (IndexError, KeyError, ValueError) as e: + html_msg = msg + + msg = re.sub(r'\{\d\}', '', msg) # Remove numbered placeholders for plain msg + if self.serious: + html_msg = msg + return html_msg, msg + + def log_to_file(self, msg, prefix='> '): + msg = "%s%s" % (prefix, msg) + if self.mmu_logger: + self.mmu_logger.log(msg) + + def log_error(self, msg, color=False): + html_msg, msg = self._color_message(msg) if color else (msg, msg) + if self.mmu_logger: + self.mmu_logger.log(msg) + self.gcode.respond_raw("!! %s" % html_msg) + + def log_warning(self, msg): + self.log_always("{2}%s{0}" % msg, color=True) + + def log_always(self, msg, color=False): + html_msg, msg = self._color_message(msg) if color else (msg, msg) + if self.mmu_logger: + self.mmu_logger.log(msg) + self.gcode.respond_info(html_msg) + + def log_info(self, msg, color=False): + html_msg, msg = self._color_message(msg) if color else (msg, msg) + if self.mmu_logger and self.log_file_level > 0: + self.mmu_logger.log(msg) + if self.log_level > 0: + self.gcode.respond_info(html_msg) + + def log_debug(self, msg): + msg = "%s DEBUG: %s" % (UI_SEPARATOR, msg) + if self.mmu_logger and self.log_file_level > 1: + self.mmu_logger.log(msg) + if self.log_level > 1: + self.gcode.respond_info(msg) + + def log_trace(self, msg): + msg = "%s %s TRACE: %s" % (UI_SEPARATOR, UI_SEPARATOR, msg) + if self.mmu_logger and self.log_file_level > 2: + self.mmu_logger.log(msg) + if self.log_level > 2: + self.gcode.respond_info(msg) + + def log_stepper(self, msg): + msg = "%s %s %s STEPPER: %s" % (UI_SEPARATOR, UI_SEPARATOR, UI_SEPARATOR, msg) + if self.mmu_logger and self.log_file_level > 3: + self.mmu_logger.log(msg) + if self.log_level > 3: + self.gcode.respond_info(msg) + + def log_enabled(self, level): + return (self.mmu_logger and self.log_file_level >= level) or self.log_level >= level + + # Fun visual display of MMU state + def _display_visual_state(self, silent=False): + if not silent and self.log_visual and not self.calibrating: + visual_str = self._state_to_string() + self.log_always(visual_str, color=True) + + def _state_to_string(self, direction=None): + arrow = "<" if self.filament_direction == self.DIRECTION_UNLOAD else ">" + space = "." + home = "|" + gs = "(g)" # SENSOR_GATE or SENSOR_GEAR_PREFIX + es = "(e)" # SENSOR_EXTRUDER + ts = "(t)" # SENSOR_TOOLHEAD + past = lambda pos: arrow if self.filament_pos >= pos else space + homed = lambda pos, sensor: (' ',arrow,sensor) if self.filament_pos > pos else (home,space,sensor) if self.filament_pos == pos else (' ',space,sensor) + trig = lambda name, sensor: re.sub(r'[a-zA-Z]', '*', name) if self.sensor_manager.check_sensor(sensor) else name + + t_str = ("[T%s] " % str(self.tool_selected)) if self.tool_selected >= 0 else "BYPASS " if self.tool_selected == self.TOOL_GATE_BYPASS else "[T?] " + g_str = "{}".format(past(self.FILAMENT_POS_UNLOADED)) + lg_str = "{0}{0}".format(past(self.FILAMENT_POS_HOMED_GATE)) if not self.mmu_machine.require_bowden_move else "" + gs_str = "{0}{2} {1}{1}".format(*homed(self.FILAMENT_POS_HOMED_GATE, trig(gs, self.gate_homing_endstop))) if self.gate_homing_endstop in [self.SENSOR_GATE, self.SENSOR_GEAR_PREFIX, self.SENSOR_EXTRUDER_ENTRY] else "" + en_str = " En {0}".format(past(self.FILAMENT_POS_IN_BOWDEN if self.gate_homing_endstop in [self.SENSOR_GATE, self.SENSOR_GEAR_PREFIX, self.SENSOR_EXTRUDER_ENTRY] else self.FILAMENT_POS_START_BOWDEN)) if self.has_encoder() else "" + bowden1 = "{0}{0}{0}{0}".format(past(self.FILAMENT_POS_IN_BOWDEN)) if self.mmu_machine.require_bowden_move else "" + bowden2 = "{0}{0}{0}{0}".format(past(self.FILAMENT_POS_END_BOWDEN)) if self.mmu_machine.require_bowden_move else "" + es_str = "{0}{2} {1}{1}".format(*homed(self.FILAMENT_POS_HOMED_ENTRY, trig(es, self.SENSOR_EXTRUDER_ENTRY))) if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY) and self.mmu_machine.require_bowden_move else "" + ex_str = "{0}[{2} {1}{1}".format(*homed(self.FILAMENT_POS_HOMED_EXTRUDER, "Ex")) + ts_str = "{0}{2} {1}".format(*homed(self.FILAMENT_POS_HOMED_TS, trig(ts, self.SENSOR_TOOLHEAD))) if self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD) else "" + nz_str = "{} Nz]".format(past(self.FILAMENT_POS_LOADED)) + summary = " {5}{4}LOADED{0}{6}" if self.filament_pos == self.FILAMENT_POS_LOADED else " {5}{4}UNLOADED{0}{6}" if self.filament_pos == self.FILAMENT_POS_UNLOADED else " {5}{2}UNKNOWN{0}{6}" if self.filament_pos == self.FILAMENT_POS_UNKNOWN else "" + counter = " {5}%.1fmm{6}%s" % (self._get_filament_position(), " {1}(e:%.1fmm){0}" % self.get_encoder_distance(dwell=None) if self.has_encoder() and self.encoder_move_validation else "") + visual = "".join((t_str, g_str, lg_str, gs_str, en_str, bowden1, bowden2, es_str, ex_str, ts_str, nz_str, summary, counter)) + return visual + + +### LOGGING AND STATISTICS FUNCTIONS GCODE FUNCTIONS ############################# + + cmd_MMU_STATS_help = "Dump and optionally reset the MMU statistics" + def cmd_MMU_STATS(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + counter = gcmd.get('COUNTER', None) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + total = bool(gcmd.get_int('TOTAL', 0, minval=0, maxval=1)) + detail = bool(gcmd.get_int('DETAIL', 0, minval=0, maxval=1)) + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + showcounts = bool(gcmd.get_int('SHOWCOUNTS', 0, minval=0, maxval=1)) + + if counter: + counter = counter.strip() + delete = bool(gcmd.get_int('DELETE', 0, minval=0, maxval=1)) + limit = gcmd.get_int('LIMIT', 0, minval=-1) + incr = gcmd.get_int('INCR', 0, minval=1) + quiet = True + if delete: + _ = self.counters.pop(counter, None) + elif reset: + if counter in self.counters: + self.counters[counter]['count'] = 0 + elif not limit == 0: + if counter not in self.counters: + self.counters[counter] = {'count': 0} + warning = gcmd.get('WARNING', self.counters[counter].get('warning', "")) + pause = bool(gcmd.get_int('PAUSE', self.counters[counter].get('pause', 0), minval=0, maxval=1)) + self.counters[counter].update({'limit': limit, 'warning': warning, 'pause': pause}) + elif incr: + if counter in self.counters: + metric = self.counters[counter] + metric['count'] += incr + if metric['limit'] >= 0 and metric['count'] > metric['limit']: + warn = "Warning: %s" % metric.get('warning', "") + msg = "Count %s (%d) above limit %d" % (counter, metric['count'], metric['limit']) + msg += "\nUse 'MMU_STATS COUNTER=%s RESET=1' to reset" % counter + if metric.get('pause', False): + self.handle_mmu_error("%s\n%s" % (warn, msg)) + else: + self.log_error(warn) + self.log_always(msg) + else: + self.counters[counter] = {'count': 0, 'limit': -1, 'warning': ""} + self._persist_counters() + elif reset: + self._reset_statistics() + self._persist_swap_statistics() + self._persist_gate_statistics() + if not quiet: + self._dump_statistics(force_log=True, total=True) + return + + if not quiet: + self._dump_statistics(force_log=True, total=total or detail, job=True, gate=True, detail=detail, showcounts=showcounts) + + cmd_MMU_STATUS_help = "Complete dump of current MMU state and important configuration" + def cmd_MMU_STATUS(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + config = gcmd.get_int('SHOWCONFIG', 0, minval=0, maxval=1) + detail = gcmd.get_int('DETAIL', 0, minval=0, maxval=1) + on_off = lambda x: "ON" if x else "OFF" + + msg = "MMU: Happy Hare %s running %s v%s" % (self._fversion(self.config_version), self.mmu_machine.mmu_vendor, self.mmu_machine.mmu_version_string) + msg += " with %d gates" % self.num_gates + msg += (" over %d units" % self.mmu_machine.num_units) if self.mmu_machine.num_units > 1 else "" + msg += " (%s) " % ("DISABLED" if not self.is_enabled else "PAUSED" if self.is_mmu_paused() else "OPERATIONAL") + msg += self.selector.get_mmu_status_config() + if self.has_encoder(): + msg += ". Encoder reads %.1fmm" % self.get_encoder_distance() + msg += "\nPrint state is %s" % self.print_state.upper() + msg += ". Tool %s selected on gate %s%s" % (self.selected_tool_string(), self.selected_gate_string(), self.selected_unit_string()) + msg += ". Toolhead position saved" if self.saved_toolhead_operation else "" + msg += "\nMMU gear stepper at %d%% current and is %s to extruder" % (self.gear_percentage_run_current, "SYNCED" if self.mmu_toolhead.is_gear_synced_to_extruder() else "not synced") + if self._standalone_sync: + msg += ". Standalone sync mode is ENABLED" + if self.sync_feedback_manager.is_enabled(): + msg += "\nSync feedback indicates filament in bowden is: %s" % self.sync_feedback_manager.get_sync_feedback_string(detail=True).upper() + if not self.sync_feedback_manager.is_active(): + msg += " (not currently active)" + else: + msg += "\nSync feedback is disabled" + + if config: + self.calibrated_bowden_length = self.calibration_manager.get_bowden_length() # Temp scalar pulled from list for _f_calc() + msg += "\n\nLOAD SEQUENCE:" + + # Gate loading + msg += "\n- Filament loads into gate by homing a maximum of %s to %s" % (self._f_calc("gate_homing_max"), self._gate_homing_string()) + + # Bowden loading + if self.mmu_machine.require_bowden_move: + if self._must_buffer_extruder_homing(): + if self.extruder_homing_endstop == self.SENSOR_EXTRUDER_ENTRY: + msg += "\n- Bowden is loaded with a fast%s %s move" % (" CORRECTED" if self.bowden_apply_correction else "", self._f_calc("calibrated_bowden_length - toolhead_entry_to_extruder - extruder_homing_buffer")) + else: + msg += "\n- Bowden is loaded with a fast%s %s move" % (" CORRECTED" if self.bowden_apply_correction else "", self._f_calc("calibrated_bowden_length - extruder_homing_buffer")) + else: + msg += "\n- Bowden is loaded with a full fast%s %s move" % (" CORRECTED" if self.bowden_apply_correction else "", self._f_calc("calibrated_bowden_length")) + else: + msg += "\n- No fast bowden move is required" + + # Extruder homing + if self._must_home_to_extruder(): + if self.extruder_homing_endstop == self.SENSOR_EXTRUDER_COLLISION: + msg += ", then homes a maximum of %s to extruder using COLLISION detection (at %d%% current)" % (self._f_calc("extruder_homing_max"), self.extruder_collision_homing_current) + elif self.extruder_homing_endstop == self.SENSOR_GEAR_TOUCH: + msg += ", then homes a maxium of %s to extruder using 'touch' (stallguard) detection" % self._f_calc("extruder_homing_max") + else: + msg += ", then homes a maximum of %s to %s sensor" % (self._f_calc("extruder_homing_max"), self.extruder_homing_endstop.upper()) + if self.extruder_homing_endstop == self.SENSOR_EXTRUDER_ENTRY: + msg += " and then moves %s to extruder extrance" % self._f_calc("toolhead_entry_to_extruder") + else: + if self.extruder_homing_endstop == self.SENSOR_EXTRUDER_NONE and not self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD): + msg += ". WARNING: no extruder homing is performed - extruder loading cannot be precise" + else: + msg += ", no extruder homing is necessary" + + # Extruder loading + if self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD): + msg += "\n- Extruder (synced) loads by homing a maximum of %s to TOOLHEAD sensor before moving the last %s to the nozzle" % (self._f_calc("toolhead_homing_max"), self._f_calc("toolhead_sensor_to_nozzle - toolhead_residual_filament - toolhead_ooze_reduction - toolchange_retract - filament_remaining")) + else: + msg += "\n- Extruder (synced) loads by moving %s to the nozzle" % self._f_calc("toolhead_extruder_to_nozzle - toolhead_residual_filament - toolhead_ooze_reduction - toolchange_retract - filament_remaining") + + # Purging + if self.force_purge_standalone: + if self.purge_macro: + msg += "\n- Purging is always managed by Happy Hare using '%s' macro with extruder purging current of %d%%" % ( + self.purge_macro, self.extruder_purge_current) + else: + msg += "\n- No purging is performed!" + else: + if self.purge_macro: + msg += "\n- Purging is managed by slicer when printing. Otherwise by Happy Hare using '%s' macro with extruder purging current of %d%% when not printing" % ( + self.purge_macro, self.extruder_purge_current) + else: + msg += "\n- Purging is managed by slicer only when printing" + + # Tightening + has_tension = self.sensor_manager.has_sensor(self.SENSOR_TENSION) + has_compression = self.sensor_manager.has_sensor(self.SENSOR_COMPRESSION) + has_proportional = self.sensor_manager.has_sensor(self.SENSOR_PROPORTIONAL) + if ( + self.toolhead_post_load_tighten + and not self.sync_to_extruder + and self._can_use_encoder() + and self.sync_feedback_manager.flowguard_encoder_mode + ): + msg += "\n- Filament in bowden is tightened by %.1fmm (%d%% of clog detection length) at reduced gear current to prevent false clog detection" % (min(self.encoder_sensor.get_clog_detection_length() * self.toolhead_post_load_tighten / 100, 15), self.toolhead_post_load_tighten) + elif ( + self.toolhead_post_load_tension_adjust + and (self.sync_to_extruder or self.sync_purge) + and (has_tension or has_compression or has_proportional) + and self.sync_feedback_manager.is_enabled() + ): + msg += "\n- Filament in bowden will be adjusted a maximum of %.1fmm to neutralize tension" % (self.sync_feedback_manager.sync_feedback_buffer_range or self.sync_feedback_manager.sync_feedback_buffer_maxrange) + + msg += "\n\nUNLOAD SEQUENCE:" + + # Tip forming + if self.force_form_tip_standalone: + if self.form_tip_macro: + msg += "\n- Tip is always formed by Happy Hare using '%s' macro after initial retract of %s with extruder current of %d%%" % ( + self.form_tip_macro, self._f_calc("toolchange_retract"), self.extruder_form_tip_current) + else: + msg += "\n- No tip forming is performed!" + else: + if self.form_tip_macro: + msg += "\n- Tip is formed by slicer when printing. Otherwise by Happy Hare using '%s' macro after initial retract of %s with extruder current of %d%%" % ( + self.form_tip_macro, self._f_calc("toolchange_retract"), self.extruder_form_tip_current) + else: + msg += "\n- Tip is formed by slicer only when printing" + + # Extruder unloading + if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY): + msg += "\n- Extruder (synced) unloads by reverse homing a maximum of %s to EXTRUDER sensor" % self._f_calc("toolhead_entry_to_extruder + toolhead_extruder_to_nozzle - toolhead_residual_filament - toolhead_ooze_reduction - toolchange_retract + toolhead_unload_safety_margin") + elif self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD): + msg += "\n- Extruder (optionally synced) unloads by reverse homing a maximum %s to TOOLHEAD sensor" % self._f_calc("toolhead_sensor_to_nozzle - toolhead_residual_filament - toolhead_ooze_reduction - toolchange_retract + toolhead_unload_safety_margin") + msg += ", then unloads by moving %s to exit extruder" % self._f_calc("toolhead_extruder_to_nozzle - toolhead_sensor_to_nozzle + toolhead_unload_safety_margin") + else: + msg += "\n- Extruder (optionally synced) unloads by moving %s less tip-cutting reported park position to exit extruder" % self._f_calc("toolhead_extruder_to_nozzle + toolhead_unload_safety_margin") + + # Bowden unloading + if self.mmu_machine.require_bowden_move: + if self.has_encoder() and self.bowden_pre_unload_test and not self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY): + msg += "\n- Bowden is unloaded with a short %s validation move before %s fast move" % (self._f_calc("encoder_move_step_size"), self._f_calc("calibrated_bowden_length - gate_unload_buffer - encoder_move_step_size")) + else: + msg += "\n- Bowden is unloaded with a fast %s move" % self._f_calc("calibrated_bowden_length - gate_unload_buffer") + else: + msg += "\n- No fast bowden move is required" + + # Gate parking + msg += "\n- Filament is stored by homing a maximum of %s to %s and parking %s in the gate\n" % (self._f_calc("gate_homing_max"), self._gate_homing_string(), self._f_calc("gate_parking_distance")) + + if self.sync_form_tip or self.sync_purge or self.sync_to_extruder: + msg += "\nGear and Extruder steppers are synchronized during: " + m = [] + if self.sync_to_extruder: + m.append("Print (at %d%% current %s sync feedback)" % (self.sync_gear_current, "with" if self.sync_feedback_manager.is_enabled() else "without")) + if self.sync_form_tip: + m.append("Tip forming") + if self.sync_purge: + m.append("Purging") + msg += ", ".join(m) + + if hasattr(self.selector, 'use_touch_move'): + msg += "\nSelector touch (stallguard) is %s - blocked gate recovery %s possible" % (("ENABLED", "is") if self.selector.use_touch_move() else ("DISABLED", "is not")) + if self.has_encoder(): + msg += "\nMMU has an encoder. Non essential move validation is %s" % ("ENABLED" if self._can_use_encoder() else "DISABLED") + msg += "\nFlowGuard detection is %s" % ("AUTOMATIC" if self.sync_feedback_manager.flowguard_encoder_mode == self.encoder_sensor.RUNOUT_AUTOMATIC else "ENABLED" if self.sync_feedback_manager.flowguard_encoder_mode == self.encoder_sensor.RUNOUT_STATIC else "DISABLED") + msg += " (%.1fmm runout)" % self.encoder_sensor.get_clog_detection_length() + msg += ", EndlessSpool is %s" % ("ENABLED" if self.endless_spool_enabled else "DISABLED") + else: + msg += "\nMMU does not have an encoder - move validation or clog detection is not possible" + msg += "\nSpoolMan is %s" % ("ENABLED (pulling gate map)" if self.spoolman_support == self.SPOOLMAN_PULL else "ENABLED (push gate map)" if self.spoolman_support == self.SPOOLMAN_PUSH else "ENABLED" if self.spoolman_support == self.SPOOLMAN_READONLY else "DISABLED") + msg += "\nSensors: " + sensors = self.sensor_manager.get_all_sensors(inactive=True) + for name, state in sensors.items(): + msg += "%s (%s), " % (name.upper(), "Disabled" if state is None else ("Detected" if state is True else "Empty")) + msg += "\nLogging: Console %d(%s)" % (self.log_level, self.LOG_LEVELS[self.log_level]) + + msg += ", Logfile %d(%s)" % (self.log_file_level, self.LOG_LEVELS[self.log_file_level]) + msg += ", Visual %d(%s)" % (self.log_visual, on_off(self.log_visual)) + msg += ", Statistics %d(%s)" % (self.log_statistics, on_off(self.log_statistics)) + + if not detail: + msg += "\n\nFor details on TTG and EndlessSpool groups add 'DETAIL=1'" + if not config: + msg += ", for configuration add 'SHOWCONFIG=1'" + + msg += "\n\n%s" % self._mmu_visual_to_string() + msg += "\n%s" % self._state_to_string() + + if detail: + msg += "\n\n%s" % self._ttg_map_to_string() + if self.endless_spool_enabled: + msg += "\n\n%s" % self._es_groups_to_string() + msg += "\n\n%s" % self._gate_map_to_string() + + if self.reason_for_pause: + msg += "\n\n{2}MMU is paused because:\n%s{0}" % self.reason_for_pause + + self.log_always(msg, color=True) + + # Always warn if not fully calibrated or needs recovery + self.report_necessary_recovery(use_autotune=False) + + def _f_calc(self, formula): + format_var = lambda p: p + ':' + "%.1f" % vars(self).get(p.lower()) + terms = re.split('(\+|\-)', formula) + result = eval(formula, {}, vars(self)) + formatted_formula = "%.1fmm (" % result + for term in terms: + term = term.strip() + if term in ('+', '-'): + formatted_formula += " " + term + " " + elif len(terms) > 1: + formatted_formula += format_var(term) + else: + formatted_formula += term + formatted_formula += ")" + return formatted_formula + + cmd_MMU_SENSORS_help = "Query state of sensors fitted to mmu" + def cmd_MMU_SENSORS(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + detail = bool(gcmd.get_int('DETAIL', 0, minval=0, maxval=1)) + + eventtime = self.reactor.monotonic() + msg = self.sensor_manager.get_sensor_summary(detail=detail) + if self.has_encoder(): + msg += self._get_encoder_summary(detail=detail) + self.log_always(msg) + + def _get_encoder_summary(self, detail=False): # TODO move to mmu_sensor_manager? + status = self.encoder_sensor.get_status(0) + msg = "Encoder position: %.1f" % status['encoder_pos'] + if detail: + msg += "\n- FlowGuard/Runout: %s" % ("Active" if status['enabled'] else "Inactive") + clog = "Automatic" if status['detection_mode'] == 2 else "On" if status['detection_mode'] == 1 else "Off" + msg += "\n- FlowGuard mode: %s (Detection length: %.1f)" % (clog, status['detection_length']) + msg += "\n- Remaining headroom before trigger: %.1f (min: %.1f)" % (status['headroom'], status['min_headroom']) + msg += "\n- Flowrate: %d %%" % status['flow_rate'] + return msg + + def motors_onoff(self, on=False, motor="all"): + stepper_enable = self.printer.lookup_object('stepper_enable') + steppers = self.gear_rail.steppers if motor == "gears" else [self.gear_rail.steppers[0]] if self.gear_rail.steppers else [] + if on: + if motor in ["all", "gear", "gears"]: + for stepper in steppers: + se = stepper_enable.lookup_enable(stepper.get_name()) + se.motor_enable(self.mmu_toolhead.get_last_move_time()) + if motor in ["all", "selector"]: + self.selector.enable_motors() + self.selector.restore_gate(self.gate_selected) + self.selector.filament_hold_move() # Aka selector move position + else: + if motor in ["all", "gear", "gears"]: + self.mmu_toolhead.unsync() + for stepper in steppers: + se = stepper_enable.lookup_enable(stepper.get_name()) + se.motor_disable(self.mmu_toolhead.get_last_move_time()) + if motor in ["all", "selector"]: + self.selector.restore_gate(self.TOOL_GATE_UNKNOWN) + self.selector.disable_motors() + + +### SERVO AND MOTOR GCODE FUNCTIONS ############################################## + + # This command will loose sync state + cmd_MMU_MOTORS_OFF_help = "Turn off all MMU motors and servos" + def cmd_MMU_MOTORS_OFF(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + self.sync_gear_to_extruder(False, force_grip=True) + self.motors_onoff(on=False) + + cmd_MMU_MOTORS_ON_help = "Turn on all MMU motors and servos" + def cmd_MMU_MOTORS_ON(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + self.motors_onoff(on=True) + self.reset_sync_gear_to_extruder(False) + + cmd_MMU_TEST_BUZZ_MOTOR_help = "Simple buzz the selected motor (default gear) for setup testing" + def cmd_MMU_TEST_BUZZ_MOTOR(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_bypass(): return + motor = gcmd.get('MOTOR', "gear") + + with self.wrap_sync_gear_to_extruder(): + if motor == "gear": + found = self.buzz_gear_motor() + if found is not None: + self.log_info("Filament %s by gear motor buzz" % ("detected" if found else "not detected")) + elif motor == "gears": + try: + for gate in range(self.num_gates): + self.mmu_toolhead.select_gear_stepper(gate) + found = self.buzz_gear_motor() + if found is not None: + self.log_info("Filament %s in gate %d by gear motor buzz" % ("detected" if found else "not detected", gate)) + finally: + self.mmu_toolhead.select_gear_stepper(self.gate_selected) + elif not self.selector.buzz_motor(motor): + raise gcmd.error("Motor '%s' not known" % motor) + + cmd_MMU_SYNC_GEAR_MOTOR_help = "Sync the MMU gear motor to the extruder stepper" + cmd_MMU_SYNC_GEAR_MOTOR_param_help = ( + "MMU_SYNC_GEAR_MOTOR: %s\n" % cmd_MMU_SYNC_GEAR_MOTOR_help + + "SYNC = [0|1] Specify whether to force extruder/mmu syncing out of a print" + + "(no parameters will default SYNC=1)" + ) + def cmd_MMU_SYNC_GEAR_MOTOR(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + + if gcmd.get_int('HELP', 0, minval=0, maxval=1): + self.log_always(self.format_help(self.cmd_MMU_SYNC_GEAR_MOTOR_param_help), color=True) + return + + if self.check_if_bypass(unloaded_ok=False): return + if self.check_if_not_homed(): return + sync = bool(gcmd.get_int('SYNC', 1, minval=0, maxval=1)) + + if self.check_if_always_gripped(): return + + if not self.is_in_print() and self._standalone_sync != sync: + self._standalone_sync = sync # Make sticky if not in a print + if self._standalone_sync: + self.log_info("MMU gear stepper will be synced with extruder whenever filament is in extruder") + else: + self.log_info("MMU gear stepper is unsynced from extruder") + + if sync and self.filament_pos < self.FILAMENT_POS_EXTRUDER_ENTRY: + self.log_warning("Will temporarily sync, but filament position does not indicate in extruder!\nUse 'MMU_RECOVER' to correct the filament position.") + + self.reset_sync_gear_to_extruder(sync, force_grip=True, skip_extruder_check=True) + + +######################### +# CALIBRATION FUNCTIONS # +######################### + + def _sample_stats(self, values): + mean = stdev = vmin = vmax = 0. + if values: + mean = sum(values) / len(values) + diff2 = [( v - mean )**2 for v in values] + stdev = math.sqrt( sum(diff2) / max((len(values) - 1), 1)) + vmin = min(values) + vmax = max(values) + return {'mean': mean, 'stdev': stdev, 'min': vmin, 'max': vmax, 'range': vmax - vmin} + + # Filament is assumed to be at the extruder and will be at extruder again when complete + def _probe_toolhead(self, cold_temp=70, probe_depth=100, sensor_homing=80): + # Ensure extruder is COLD + self.gcode.run_script_from_command("SET_HEATER_TEMPERATURE HEATER=%s TARGET=0" % self.extruder_name) + current_temp = self.printer.lookup_object(self.extruder_name).get_status(0)['temperature'] + if current_temp > cold_temp: + self.log_always("Waiting for extruder to cool") + self.gcode.run_script_from_command("TEMPERATURE_WAIT SENSOR=%s MINIMUM=0 MAXIMUM=%d" % (self.extruder_name, cold_temp)) + + # Enable the extruder stepper + stepper_enable = self.printer.lookup_object('stepper_enable') + ge = stepper_enable.lookup_enable(self.mmu_extruder_stepper.stepper.get_name()) + ge.motor_enable(self.toolhead.get_last_move_time()) + + # Reliably force filament to the nozzle + self.selector.filament_drive() + actual,fhomed,_,_ = self.trace_filament_move("Homing to toolhead sensor", self.toolhead_homing_max, motor="gear+extruder", homing_move=1, endstop_name=self.SENSOR_TOOLHEAD) + if not fhomed: + raise MmuError("Failed to reach toolhead sensor after moving %.1fmm" % self.toolhead_homing_max) + self.selector.filament_release() + actual,_,_,_ = self.trace_filament_move("Forcing filament to nozzle", probe_depth, motor="extruder") + + # Measure 'toolhead_sensor_to_nozzle' + self.selector.filament_drive() + actual,fhomed,_,_ = self.trace_filament_move("Reverse homing off toolhead sensor", -probe_depth, motor="gear+extruder", homing_move=-1, endstop_name=self.SENSOR_TOOLHEAD) + if fhomed: + toolhead_sensor_to_nozzle = -actual + self.log_always("Measured toolhead_sensor_to_nozzle: %.1f" % toolhead_sensor_to_nozzle) + else: + raise MmuError("Failed to reverse home to toolhead sensor") + + # Move to extruder extrance again + self.selector.filament_release() + actual,_,_,_ = self.trace_filament_move("Moving to extruder entrance", -(probe_depth - toolhead_sensor_to_nozzle), motor="extruder") + + # Measure 'toolhead_extruder_to_nozzle' + self.selector.filament_drive() + actual,fhomed,_,_ = self.trace_filament_move("Homing to toolhead sensor", self.toolhead_homing_max, motor="gear+extruder", homing_move=1, endstop_name=self.SENSOR_TOOLHEAD) + if fhomed: + toolhead_extruder_to_nozzle = actual + toolhead_sensor_to_nozzle + self.log_always("Measured toolhead_extruder_to_nozzle: %.1f" % toolhead_extruder_to_nozzle) + else: + raise MmuError("Failed to home to toolhead sensor") + + toolhead_entry_to_extruder = 0. + if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY): + # Retract clear of extruder sensor and then home in "extrude" direction + actual,fhomed,_,_ = self.trace_filament_move("Reverse homing off extruder entry sensor", -(sensor_homing + toolhead_extruder_to_nozzle - toolhead_sensor_to_nozzle), motor="gear+extruder", homing_move=-1, endstop_name=self.SENSOR_EXTRUDER_ENTRY) + actual,_,_,_ = self.trace_filament_move("Moving before extruder entry sensor", -20, motor="gear+extruder") + actual,fhomed,_,_ = self.trace_filament_move("Homing to extruder entry sensor", 40, motor="gear+extruder", homing_move=1, endstop_name=self.SENSOR_EXTRUDER_ENTRY) + + # Measure to toolhead sensor and thus derive 'toolhead_entry_to_extruder' + if fhomed: + actual,fhomed,_,_ = self.trace_filament_move("Homing to toolhead sensor", sensor_homing, motor="gear+extruder", homing_move=1, endstop_name=self.SENSOR_TOOLHEAD) + if fhomed: + toolhead_entry_to_extruder = actual - (toolhead_extruder_to_nozzle - toolhead_sensor_to_nozzle) + self.log_always("Measured toolhead_entry_to_extruder: %.1f" % toolhead_entry_to_extruder) + else: + raise MmuError("Failed to reverse home to toolhead sensor") + + # Unload and re-park filament + self.selector.filament_release() + actual,_,_,_ = self.trace_filament_move("Moving to extruder entrance", -sensor_homing, motor="extruder") + + return toolhead_extruder_to_nozzle, toolhead_sensor_to_nozzle, toolhead_entry_to_extruder + + +### CALIBRATION GCODE COMMANDS ################################################### + + cmd_MMU_CALIBRATE_GEAR_help = "Calibration routine for gear stepper rotational distance" + def cmd_MMU_CALIBRATE_GEAR(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_bypass(): return + if self.check_if_gate_not_valid(): return + length = gcmd.get_float('LENGTH', 100., above=50.) + measured = gcmd.get_float('MEASURED', -1, above=0.) + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + reset = gcmd.get_int('RESET', 0, minval=0, maxval=1) + gate = self.gate_selected if self.gate_selected >= 0 else 0 + + with self.wrap_sync_gear_to_extruder(): + if reset: + self.set_gear_rotation_distance(self.default_rotation_distance) + self.calibration_manager.update_gear_rd(-1) + return + + if measured > 0: + current_rd = self.gear_rail.steppers[0].get_rotation_distance()[0] + new_rd = round(current_rd * measured / length, 4) + self.log_always("MMU gear stepper 'rotation_distance' calculated to be %.4f (currently: %.4f)" % (new_rd, current_rd)) + if save: + self.set_gear_rotation_distance(new_rd) + self.calibration_manager.update_gear_rd(new_rd, console_msg=True) + return + + raise gcmd.error("Must specify 'MEASURED=' and optionally 'LENGTH='") + + # Start: Assumes filament is loaded through encoder + # End: Does not eject filament at end (filament same as start) + cmd_MMU_CALIBRATE_ENCODER_help = "Calibration routine for the MMU encoder" + def cmd_MMU_CALIBRATE_ENCODER(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self._check_has_encoder(): return + if self.check_if_bypass(): return + if self.check_if_not_calibrated(self.CALIBRATED_GEAR_0, check_gates=[self.gate_selected]): return + + length = gcmd.get_float('LENGTH', 400., above=0.) + repeats = gcmd.get_int('REPEATS', 3, minval=1, maxval=10) + speed = gcmd.get_float('SPEED', self.gear_from_buffer_speed, minval=10.) + accel = gcmd.get_float('ACCEL', self.gear_from_buffer_accel, minval=10.) + min_speed = gcmd.get_float('MINSPEED', speed, above=0.) + max_speed = gcmd.get_float('MAXSPEED', speed, above=0.) + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + advance = 60. # Ensure filament is in encoder even if not loaded by user + + try: + with self.wrap_sync_gear_to_extruder(): + with self._require_encoder(): + self.selector.filament_drive() + self.calibrating = True + _,_,measured,_ = self.trace_filament_move("Checking for filament", advance) + if measured < self.encoder_min: + raise MmuError("Filament not detected in encoder. Ensure filament is available and try again") + self._unload_tool() + self.calibration_manager.calibrate_encoder(length, repeats, speed, min_speed, max_speed, accel, save) + _,_,_,_ = self.trace_filament_move("Parking filament", -advance) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + finally: + self.calibrating = False + + # Calibrated bowden length is always from chosen gate homing point to the entruder gears + # Start: With desired gate selected + # End: Filament will be unloaded + cmd_MMU_CALIBRATE_BOWDEN_help = "Calibration of reference bowden length for selected gate" + def cmd_MMU_CALIBRATE_BOWDEN(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_no_bowden_move(): return + if self.check_if_not_homed(): return + if self.check_if_bypass(): return + if self.check_if_loaded(): return + if self.check_if_gate_not_valid(): return + + repeats = gcmd.get_int('REPEATS', 3, minval=1, maxval=10) + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + manual = bool(gcmd.get_int('MANUAL', 0, minval=0, maxval=1)) + collision = bool(gcmd.get_int('COLLISION', 0, minval=0, maxval=1)) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + + if reset: + self.calibration_manager.update_bowden_length(-1, console_msg=True) + return + + if manual: + if self.check_if_not_calibrated(self.CALIBRATED_GEAR_0|self.CALIBRATED_SELECTOR, check_gates=[self.gate_selected]): return + else: + if self.check_if_not_calibrated(self.CALIBRATED_GEAR_0|self.CALIBRATED_ENCODER|self.CALIBRATED_SELECTOR, check_gates=[self.gate_selected]): return + + can_use_sensor = ( + self.extruder_homing_endstop in [ + self.SENSOR_EXTRUDER_ENTRY, + self.SENSOR_COMPRESSION, + self.SENSOR_GEAR_TOUCH + ] and ( + self.sensor_manager.has_sensor(self.extruder_homing_endstop) or + self.gear_rail.is_endstop_virtual(self.extruder_homing_endstop) + ) + ) + can_auto_calibrate = self.has_encoder() or can_use_sensor + + if not can_auto_calibrate and not manual: + self.log_always("No encoder or extruder entry sensor available. Use manual calibration method:\nWith gate selected, manually load filament all the way to the extruder gear\nThen run 'MMU_CALIBRATE_BOWDEN MANUAL=1 BOWDEN_LENGTH=xxx'\nWhere BOWDEN_LENGTH is greater than your real length") + return + + extruder_homing_max = gcmd.get_float('HOMING_MAX', 150, above=0.) + approx_bowden_length = gcmd.get_float('BOWDEN_LENGTH', self.bowden_homing_max if (manual or can_use_sensor) else None, above=0.) + if not approx_bowden_length: + raise gcmd.error("Must specify 'BOWDEN_LENGTH=x' where x is slightly LESS than your estimated bowden length to give room for homing") + + try: + with self.wrap_sync_gear_to_extruder(): + with self._wrap_suspend_filament_monitoring(): + self.calibrating = True + if manual: + # Method 1: Manual (reverse homing to gate) method + length = self.calibration_manager.calibrate_bowden_length_manual(approx_bowden_length) + + elif can_use_sensor and not collision: + # Method 2: Automatic one-shot method with homing sensor (BEST) + self._unload_tool() + length = self.calibration_manager.calibrate_bowden_length_sensor(approx_bowden_length) + + elif self.has_encoder(): + # Method 3: Automatic averaging method with encoder and extruder collision. Uses repeats for accuracy + self._unload_tool() + length = self.calibration_manager.calibrate_bowden_length_collision(approx_bowden_length, extruder_homing_max, repeats) + + else: + raise gcmd.error("Invalid configuration or options provided. Perhaps you tried COLLISION=1 without encoder or don't have extruder_homing_endstop set?") + + cdl = None + msg = "Calibrated bowden length is %.1fmm" % length + if self.has_encoder(): + cdl = self.calibration_manager.calc_clog_detection_length(length) + msg += ". Recommended flowguard_encoder_max_motion (clog detection length): %.1fmm" % cdl + self.log_always(msg) + + if save: + self.calibration_manager.update_bowden_length(length, console_msg=True) + if cdl is not None: + self.calibration_manager.update_clog_detection_length(length, force=True) + + except MmuError as ee: + self.handle_mmu_error(str(ee)) + finally: + self.calibrating = False + + # Start: Will home selector, select gate 0 or required gate + # End: Filament will unload + cmd_MMU_CALIBRATE_GATES_help = "Optional calibration of individual MMU gate" + def cmd_MMU_CALIBRATE_GATES(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_not_homed(): return + if self.check_if_bypass(): return + length = gcmd.get_float('LENGTH', 400., above=0.) + repeats = gcmd.get_int('REPEATS', 3, minval=1, maxval=10) + all_gates = gcmd.get_int('ALL', 0, minval=0, maxval=1) + gate = gcmd.get_int('GATE', -1, minval=0, maxval=self.num_gates - 1) + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + + if gate == -1 and not all_gates: + raise gcmd.error("Must specify 'GATE=' or 'ALL=1' for all gates") + + if reset: + if all_gates: + self.set_gear_rotation_distance(self.default_rotation_distance) + for gate in range(self.num_gates - 1): + self.calibration_manager.update_gear_rd(-1, gate + 1) + else: + self.calibration_manager.update_gear_rd(-1, gate) + return + + if self.check_if_not_calibrated( + self.CALIBRATED_GEAR_0 | self.CALIBRATED_ENCODER | self.CALIBRATED_SELECTOR, + check_gates=[gate] if gate != -1 else None + ): return + + try: + with self.wrap_sync_gear_to_extruder(): + self._unload_tool() + self.calibrating = True + with self._require_encoder(): + if all_gates: + self.log_always("Start the complete calibration of ancillary gates...") + for gate in range(self.num_gates - 1): + self.calibration_manager.calibrate_gate(gate + 1, length, repeats, save=save) + self.log_always("Phew! End of auto gate calibration") + else: + self.calibration_manager.calibrate_gate(gate, length, repeats, save=(save and gate != 0)) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + finally: + self.calibrating = False + + # Start: Test gate should already be selected + # End: Filament will unload + cmd_MMU_CALIBRATE_TOOLHEAD_help = "Automated measurement of key toolhead parameters" + def cmd_MMU_CALIBRATE_TOOLHEAD(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_not_homed(): return + if self.check_if_bypass(): return + if self.check_if_loaded(): return + if self.check_if_not_calibrated(self.CALIBRATED_GEAR_0|self.CALIBRATED_ENCODER|self.CALIBRATED_SELECTOR|self.CALIBRATED_BOWDENS, check_gates=[self.gate_selected]): return + if not self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD): + raise gcmd.error("Sorry this feature requires a toolhead sensor") + clean = gcmd.get_int('CLEAN', 0, minval=0, maxval=1) + dirty = gcmd.get_int('DIRTY', 0, minval=0, maxval=1) + cut = gcmd.get_int('CUT', 0, minval=0, maxval=1) + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + line = "-----------------------------------------------\n" + + if not (clean or cut or dirty): + msg = "Reminder - run with this sequence of options:\n" + msg += "1) 'CLEAN=1' with clean extruder for: toolhead_extruder_to_nozzle, toolhead_sensor_to_nozzle (and toolhead_entry_to_extruder)\n" + msg += "2) 'DIRTY=1' with dirty extruder (no not cut tip fragment) for: toolhead_residual_filament (and toolhead_entry_to_extruder)\n" + msg += "3) 'CUT=1' holding blade in for: variable_blade_pos\n" + msg += "Desired gate should be selected but the filament unloaded\n" + msg += "('SAVE=0' to run without persisting results)\n" + msg += "Note: On Type-B MMU's you might experience noise/grinding as movement limits are explored (select bypass or reduce gear stepper current if a problem)\n" + self.log_always(msg) + return + + if cut: + gcode_macro = self.printer.lookup_object("gcode_macro %s" % self.form_tip_macro, None) + if gcode_macro is None: + raise gcmd.error("Filament tip forming macro '%s' not found" % self.form_tip_macro) + gcode_vars = self.printer.lookup_object("gcode_macro %s_VARS" % self.form_tip_macro, gcode_macro) + if not ('blade_pos' in gcode_vars.variables and 'retract_length' in gcode_vars.variables): + raise gcmd.error("Filament tip forming macro '%s' does not look like a cutting macro!" % self.form_tip_macro) + + try: + with self.wrap_sync_gear_to_extruder(): + self.calibrating = True + self._initialize_filament_position(dwell=True) + overshoot = self._load_gate(allow_retry=False) + _,_ = self._load_bowden(start_pos=overshoot) + _,_ = self._home_to_extruder(self.extruder_homing_max) + + if cut: + self.log_always("Measuring blade cutter postion (with filament fragment)...") + tetn, tstn, tete = self._probe_toolhead() + # Blade position is the difference between empty and extruder with full cut measurements for sensor to nozzle + vbp = self.toolhead_sensor_to_nozzle - tstn + msg = line + if abs(vbp - self.toolhead_residual_filament) < 5: + self.log_error("Measurements did not make sense. Looks like probing went past the blade pos!\nAre you holding the blade closed or have cut filament in the extruder?") + else: + msg += "Calibration Results (cut tip):\n" + msg += "> variable_blade_pos: %.1f (currently: %.1f)\n" % (vbp, gcode_vars.variables['blade_pos']) + msg += "> variable_retract_length: %.1f-%.1f, recommend: %.1f (currently: %.1f)\n" % (self.toolhead_residual_filament + self.toolchange_retract, vbp, vbp - 5., gcode_vars.variables['retract_length']) + msg += line + self.log_always(msg) + if save: + self.log_always("New calibrated blade_pos and retract_length active until restart. Update mmu_macro_vars.cfg to persist") + gcode_vars.variables['blade_pos'] = vbp + gcode_vars.variables['retract_length'] = vbp - 5. + + elif clean: + self.log_always("Measuring clean toolhead dimensions after cold pull...") + tetn, tstn, tete = self._probe_toolhead() + msg = line + msg += "Calibration Results (clean nozzle):\n" + msg += "> toolhead_extruder_to_nozzle: %.1f (currently: %.1f)\n" % (tetn, self.toolhead_extruder_to_nozzle) + msg += "> toolhead_sensor_to_nozzle: %.1f (currently: %.1f)\n" % (tstn, self.toolhead_sensor_to_nozzle) + if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY): + msg += "> toolhead_entry_to_extruder: %.1f (currently: %.1f)\n" % (tete, self.toolhead_entry_to_extruder) + msg += line + self.log_always(msg) + if save: + self.log_always("New toolhead calibration active until restart. Update mmu_parameters.cfg to persist settings") + self.toolhead_extruder_to_nozzle = round(tetn, 1) + self.toolhead_sensor_to_nozzle = round(tstn, 1) + self.toolhead_entry_to_extruder = round(tete, 1) + + elif dirty: + self.log_always("Measuring dirty toolhead dimensions (with filament residue)...") + tetn, tstn, tete = self._probe_toolhead() + # Ooze reduction is the difference between empty and dirty measurements for sensor to nozzle + tor = self.toolhead_sensor_to_nozzle - tstn + msg = line + msg += "Calibration Results (dirty nozzle):\n" + msg += "> toolhead_residual_filament: %.1f (currently: %.1f)\n" % (tor, self.toolhead_residual_filament) + if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY): + msg += "> toolhead_entry_to_extruder: %.1f (currently: %.1f)\n" % (tete, self.toolhead_entry_to_extruder) + msg += line + self.log_always(msg) + if save: + self.toolhead_residual_filament = round(tor, 1) + self.toolhead_entry_to_extruder = round(tete, 1) + + # Unload and park filament + _ = self._unload_bowden() + _,_ = self._unload_gate() + except MmuError as ee: + self.handle_mmu_error(str(ee)) + finally: + self.calibrating = False + + + # Start: Filament must be loaded in extruder + cmd_MMU_CALIBRATE_PSENSOR_help = "Calibrate analog proprotional sync-feedback sensor" + def cmd_MMU_CALIBRATE_PSENSOR(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + + if not self.sensor_manager.has_sensor(self.SENSOR_PROPORTIONAL): + raise gcmd.error("Proportional (analog sync-feedback) sensor not found\n" + usage) + + if self.check_if_disabled(): return + if self.check_if_bypass(): return + if self.check_if_not_loaded(): return + + SD_THRESHOLD = 0.02 + MAX_MOVE_MULTIPLIER = 1.8 + STEP_SIZE = 2.0 + MOVE_SPEED = 8.0 + + move = gcmd.get_float('MOVE', self.sync_feedback_manager.sync_feedback_buffer_maxrange, minval=1, maxval=100) + steps = math.ceil(move * MAX_MOVE_MULTIPLIER / STEP_SIZE) + + usage = ( + "Ensure your sensor is configured by setting sync_feedback_analog_pin in [mmu_sensors].\n" + "The other settings (sync_feedback_analog_max_compression, sync_feedback_analog_max_tension " + "and sync_feedback_analog_neutral_point) will be determined by this calibration." + ) + + if not self.sensor_manager.has_sensor(self.SENSOR_PROPORTIONAL): + raise gcmd.error("Proportional (analog sync-feedback) sensor not found\n" + usage) + + def _avg_raw(n=10, dwell_s=0.1): + """ + Sample sensor.get_status(0)['value_raw'] n times with dwell between reads + and return moving average + """ + sensor = self.sensor_manager.all_sensors.get(self.SENSOR_PROPORTIONAL) + + k = 0.1 # 1st order,low pass filter coefficient, 0.1 for 10 samples + avg = sensor.get_status(0).get('value_raw', None) + + for _ in range(int(max(1, n-1))): + self.movequeues_dwell(dwell_s) + raw = sensor.get_status(0).get('value_raw', None) + if raw is None or not isinstance(raw, float): + return None + avg += k * (raw - avg) # 1st order low pass filter + return (avg) + + def _seek_limit(msg, steps, step_size, prev_val, ramp, log_label): + self.log_always(msg) + for i in range(steps): + _ = self.trace_filament_move(msg, step_size, motor="gear", speed=MOVE_SPEED, wait=True) + val = _avg_raw() + + delta = val - prev_val + + if ramp is None: + if delta == 0: + self.log_always("No sensor change. Retrying") + continue + ramp = (delta > 0) + + if (ramp and val >= prev_val) or (not ramp and val <= prev_val): + prev_val = val + self.log_always("Seeking ... ADC %s limit: %.4f" % (log_label, val)) + else: + # Limit found + return prev_val, ramp, True + + # Ran out of steps without detecting a clear limit + return prev_val, ramp, False + + try: + with self.wrap_sync_gear_to_extruder(): + with self.wrap_gear_current(percent=self.sync_gear_current, reason="while calibrating sync_feedback psensor"): + self.selector.filament_drive() + self.calibrating = True + + raw0 = _avg_raw() + if raw0 is None: + raise gcmd.error("Sensor malfunction. Could not read valid ADC output\nAre you sure you configured in [mmu_sensors]?") + + msg = "Finding compression limit stepping up to %.2fmm\n" % (steps * STEP_SIZE) + c_prev = raw0 + ramp = None + c_prev, ramp, found_c_limit = _seek_limit(msg, steps, STEP_SIZE, c_prev, ramp, "compressed") + + # Back off compressed extreme + msg = "Backing off compressed limit" + self.log_always(msg) + _ = self.trace_filament_move(msg, -(steps * STEP_SIZE / 2.0), motor="gear", speed=MOVE_SPEED, wait=True) + + msg = "Finding tension limit stepping up to %.2fmm\n" % (steps * STEP_SIZE) + t_prev = _avg_raw() + ramp = (not ramp) if found_c_limit else None # If compression succeeded, inverse ramp; otherwise re-detect + t_prev, ramp, found_t_limit = _seek_limit(msg, steps, -STEP_SIZE, t_prev, ramp, "tension") + + # Back off tension extreme + msg = "Backing off tension limit" + self.log_always(msg) + _ = self.trace_filament_move(msg, (steps * STEP_SIZE / 2.0), motor="gear", speed=MOVE_SPEED, wait=True) + + if (found_c_limit and found_t_limit): + msg = "Calibration Results:\n" + msg += "As wired, recommended settings (in mmu_hardware.cfg) are:\n" + msg += "[mmu_sensors]\n" + msg += "sync_feedback_analog_max_compression: %.4f\n" % c_prev + msg += "sync_feedback_analog_max_tension: %.4f\n" % t_prev + msg += "sync_feedback_analog_neutral_point: %.4f\n" % ((c_prev + t_prev) / 2.0) + msg += "After updating, don't forget to restart klipper!" + self.log_always(msg) + else: + msg = "Warning: calibration did not find both compression and tension " + msg += "limits (compression=%s, tension=%s)\n" % (found_c_limit, found_t_limit) + msg += "Perhaps sync_feedback_buffer_maxrange parameter is incorrect?\n" + msg += "Alternatively with bigger movement range by running with MOVE=" + self.log_warning(msg) + + except MmuError as ee: + self.handle_mmu_error(str(ee)) + finally: + self.calibrating = False + + +####################### +# MMU STATE FUNCTIONS # +####################### + + def _setup_hotend_off_timer(self): + self.hotend_off_timer = self.reactor.register_timer(self._hotend_off_handler, self.reactor.NEVER) + + def _hotend_off_handler(self, eventtime): + if not self.is_printing(): + self.log_info("Disabled extruder heater") + self.gcode.run_script_from_command("M104 S0") + return self.reactor.NEVER + + def _setup_pending_spool_id_timer(self): + self.pending_spool_id_timer = self.reactor.register_timer(self._pending_spool_id_handler, self.reactor.NEVER) + + def _pending_spool_id_handler(self, eventtime): + self.pending_spool_id = -1 + return self.reactor.NEVER + + def _check_pending_spool_id(self, gate): + if self.pending_spool_id > 0 and self.spoolman_support != self.SPOOLMAN_PULL: + self.log_info("Spool ID: %s automatically assigned to gate %d" % (self.pending_spool_id, gate)) + mod_gate_ids = self.assign_spool_id(gate, self.pending_spool_id) + + # Request sync and update of filament attributes from Spoolman + if self.spoolman_support == self.SPOOLMAN_PUSH: + self._spoolman_push_gate_map(mod_gate_ids) + elif self.spoolman_support == self.SPOOLMAN_READONLY: + self._spoolman_update_filaments(mod_gate_ids) + + self._moonraker_push_lane_data(mod_gate_ids) + + # Disable timer to prevent reuse + self.pending_spool_id = -1 + self.reactor.update_timer(self.pending_spool_id_timer, self.reactor.NEVER) + + # Assign spool id to gate and clear from other gates returning list of changes + def assign_spool_id(self, gate, spool_id): + self.gate_spool_id[gate] = spool_id + mod_gate_ids = [(gate, spool_id)] + for i, sid in enumerate(self.gate_spool_id): + if sid == spool_id and i != gate: + self.gate_spool_id[i] = -1 + mod_gate_ids.append((i, -1)) + return mod_gate_ids + + def _handle_idle_timeout_printing(self, eventtime): + self._handle_idle_timeout_event(eventtime, "printing") + + def _handle_idle_timeout_ready(self, eventtime): + self._handle_idle_timeout_event(eventtime, "ready") + + def _handle_idle_timeout_idle(self, eventtime): + self._handle_idle_timeout_event(eventtime, "idle") + + def is_printing(self, force_in_print=False): # Actively printing and not paused + return self.print_state in ["started", "printing"] or force_in_print or self.test_force_in_print + + def is_in_print(self, force_in_print=False): # Printing or paused + return bool(self.print_state in ["printing", "pause_locked", "paused"] or force_in_print or self.test_force_in_print) + + def is_mmu_paused(self): # The MMU is paused + return self.print_state in ["pause_locked", "paused"] + + def is_mmu_paused_and_locked(self): # The MMU is paused (and locked) + return self.print_state in ["pause_locked"] + + def is_in_endstate(self): + return self.print_state in ["complete", "cancelled", "error", "ready", "standby", "initialized"] + + def is_in_standby(self): + return self.print_state in ["standby"] + + def is_printer_printing(self): + return bool(self.print_stats and self.print_stats.state == "printing") + + def is_printer_paused(self): + return self.pause_resume.is_paused + + def is_paused(self): + return self.is_printer_paused() or self.is_mmu_paused() + + def _wakeup(self): + if self.is_in_standby(): + self._set_print_state("idle") + + def _check_not_printing(self): + if self.is_printing(): + self.log_error("Operation not possible because currently printing") + return True + return False + + # Track print events simply to ease internal print state transitions. Specificly we want to detect + # the start and end of a print and falling back into 'standby' state on idle + # + # Klipper reference sources for state: + # print_stats: {'filename': '', 'total_duration': 0.0, 'print_duration': 0.0, + # 'filament_used': 0.0, 'state': standby|printing|paused|complete|cancelled|error, + # 'message': '', 'info': {'total_layer': None, 'current_layer': None}} + # idle_status: {'state': Idle|Ready|Printing, 'printing_time': 0.0} + # pause_resume: {'is_paused': True|False} + # + def _handle_idle_timeout_event(self, eventtime, event_type): + if not self.is_enabled: return + self.log_trace("Processing idle_timeout '%s' event" % event_type) + + if self.print_stats and self.print_start_detection: + new_ps = self.print_stats.get_status(eventtime) + if self.last_print_stats is None: + self.last_print_stats = dict(new_ps) + self.last_print_stats['state'] = 'initialized' + prev_ps = self.last_print_stats + old_state = prev_ps['state'] + new_state = new_ps['state'] + if new_state is not old_state: + if new_state == "printing" and event_type == "printing": + # Figure out the difference between initial job start and resume + if prev_ps['state'] == "paused" and prev_ps['filename'] == new_ps['filename'] and prev_ps['total_duration'] < new_ps['total_duration']: + # This is a 'resumed' state so ignore + self.log_trace("Automaticaly detected RESUME (ignored), print_stats=%s, current mmu print_state=%s" % (new_state, self.print_state)) + else: + # This is a 'started' state + self.log_trace("Automaticaly detected JOB START, print_status:print_stats=%s, current mmu print_state=%s" % (new_state, self.print_state)) + if self.print_state not in ["started", "printing"]: + self._on_print_start(pre_start_only=True) + self.reactor.register_callback(lambda pt: self._print_event("MMU_PRINT_START AUTOMATIC=1")) + elif new_state in ["complete", "error"] and event_type == "ready": + self.log_trace("Automatically detected JOB %s, print_stats=%s, current mmu print_state=%s" % (new_state.upper(), new_state, self.print_state)) + if new_state == "error": + self.reactor.register_callback(lambda pt: self._print_event("MMU_PRINT_END STATE=error AUTOMATIC=1")) + else: + self.reactor.register_callback(lambda pt: self._print_event("MMU_PRINT_END STATE=complete AUTOMATIC=1")) + self.last_print_stats = dict(new_ps) + + # Capture transition to standby + if event_type == "idle" and self.print_state != "standby": + self.reactor.register_callback(lambda pt: self._print_event("MMU_PRINT_END STATE=standby IDLE_TIMEOUT=1")) + + def _print_event(self, command): + try: + self.gcode.run_script(command) + except Exception: + logging.exception("MMU: Error running job state initializer/finalizer") + + # MMU job state machine: initialized|ready|started|printing|complete|cancelled|error|pause_locked|paused|standby + def _set_print_state(self, print_state, call_macro=True): + if print_state != self.print_state: + idle_timeout = self.printer.lookup_object("idle_timeout").idle_timeout + self.log_debug("Job State: %s -> %s (MMU State: Encoder: %s, Synced: %s, Paused temp: %s, Resume to state: %s, Position saved for: %s, pause_resume: %s, Idle timeout: %.2fs)" + % (self.print_state.upper(), print_state.upper(), self._get_encoder_state(), self.mmu_toolhead.is_gear_synced_to_extruder(), self.paused_extruder_temp, + self.resume_to_state, self.saved_toolhead_operation, self.is_printer_paused(), idle_timeout)) + if call_macro: + self.led_manager.print_state_changed(print_state, self.print_state) + if self.printer.lookup_object("gcode_macro %s" % self.print_state_changed_macro, None) is not None: + self.wrap_gcode_command("%s STATE='%s' OLD_STATE='%s'" % (self.print_state_changed_macro, print_state, self.print_state)) + self.print_state = print_state + + # If this is called automatically when printing starts. The pre_start_only operations are performed on an idle_timeout + # event so cannot block. The remainder of moves will be called from the queue but they will be called early so + # don't do anything that requires operating toolhead kinematics (we might not even be homed yet) + def _on_print_start(self, pre_start_only=False): + if self.print_state not in ["started", "printing"]: + self.log_trace("_on_print_start(->started)") + self._clear_saved_toolhead_position() + self.num_toolchanges = 0 + self.paused_extruder_temp = None + self._reset_job_statistics() # Reset job stats but leave persisted totals alone + self.reactor.update_timer(self.hotend_off_timer, self.reactor.NEVER) # Don't automatically turn off extruder heaters + self.is_handling_runout = False + self._clear_slicer_tool_map() + self._enable_filament_monitoring() # Enable filament monitoring while printing + self._initialize_encoder(dwell=None) # Encoder 0000 + self._set_print_state("started", call_macro=False) + + if not pre_start_only and self.print_state not in ["printing"]: + self.sync_feedback_manager.wipe_telemetry_logs() + self.log_trace("_on_print_start(->printing)") + self.wrap_gcode_command("SET_GCODE_VARIABLE MACRO=%s VARIABLE=min_lifted_z VALUE=0" % self.park_macro) # Sequential printing movement "floor" + self.wrap_gcode_command("SET_GCODE_VARIABLE MACRO=%s VARIABLE=next_pos VALUE=False" % self.park_macro) + msg = "Happy Hare initialized ready for print" + if self.filament_pos == self.FILAMENT_POS_LOADED: + msg += " (initial tool %s loaded)" % self.selected_tool_string() + else: + msg += " (no filament preloaded)" + if self.ttg_map != self.default_ttg_map: + msg += "\nWarning: Non default TTG map in effect" + self.log_info(msg) + self._set_print_state("printing") + + # Establish syncing state and grip (servo) position + # (must call after print_state is set so we know we are printing) + self.reset_sync_gear_to_extruder(self.sync_to_extruder) + + # Ensure espooler wasn't reset + self._adjust_espooler_assist() + + # Hack: Force state transistion to printing for any early moves if MMU_PRINT_START not yet run + def _fix_started_state(self): + if self.is_printer_printing() and not self.is_in_print(): + self.wrap_gcode_command("MMU_PRINT_START FIX_STATE=1") + + # If this is called automatically it will occur after the user's print ends. + # Therefore don't do anything that requires operating kinematics + def _on_print_end(self, state="complete"): + if not self.is_in_endstate(): + self.log_trace("_on_print_end(%s)" % state) + self.movequeues_wait() + self._clear_saved_toolhead_position() + self.resume_to_state = "ready" + self.paused_extruder_temp = None + self.reactor.update_timer(self.hotend_off_timer, self.reactor.NEVER) # Don't automatically turn off extruder heaters + self._restore_automap_option() + self._disable_filament_monitoring() # Disable filament monitoring + + if self.printer.lookup_object("idle_timeout").idle_timeout != self.default_idle_timeout: + self.gcode.run_script_from_command("SET_IDLE_TIMEOUT TIMEOUT=%d" % self.default_idle_timeout) # Restore original idle_timeout + + self._standalone_sync = False # Safer to clear this on print end or idle_timeout to standby to avoid user confusion + self._set_print_state(state) + + # Establish syncing state and grip (servo) position + # (must call after print_state is set) + self.reset_sync_gear_to_extruder(False) # Intention is not to sync unless we have to + + if state == "standby" and not self.is_in_standby(): + self._set_print_state(state) + self._clear_macro_state(reset=True) + + def handle_mmu_error(self, reason, force_in_print=False): + self._fix_started_state() # Get out of 'started' state before transistion to mmu pause + + run_pause_macro = run_error_macro = recover_pos = send_event = False + if self.is_in_print(force_in_print): + if not self.is_mmu_paused(): + self._disable_filament_monitoring() # Disable filament monitoring while in paused state + self._track_pause_start() + self.resume_to_state = 'printing' if self.is_in_print() else 'ready' + self.reason_for_pause = reason # Only store reason on first error + self._display_mmu_error() + self.paused_extruder_temp = self.printer.lookup_object(self.extruder_name).heater.target_temp + self.log_trace("Saved desired extruder temperature: %.1f%sC" % (self.paused_extruder_temp, UI_DEGREE)) + self.reactor.update_timer(self.hotend_off_timer, self.reactor.monotonic() + self.disable_heater) # Set extruder off timer + self.gcode.run_script_from_command("SET_IDLE_TIMEOUT TIMEOUT=%d" % self.timeout_pause) # Set alternative pause idle_timeout + self.log_trace("Extruder heater will be disabled in %s" % (self._seconds_to_string(self.disable_heater))) + self.log_trace("Idle timeout in %s" % self._seconds_to_string(self.timeout_pause)) + self._save_toolhead_position_and_park('pause') # if already paused this is a no-op + run_error_macro = True + run_pause_macro = not self.is_printer_paused() + send_event = True + recover_pos = self.filament_recovery_on_pause + self._set_print_state("pause_locked") + else: + self.log_error("MMU issue detected whilst printer is paused\nReason: %s" % reason) + recover_pos = self.filament_recovery_on_pause + + else: # Not in a print (standalone operation) + self.log_error("MMU issue: %s" % reason) + # Restore original position if parked because there will be no resume + if self.saved_toolhead_operation: + self._restore_toolhead_position(self.saved_toolhead_operation) + + # Be deliberate about order of these tasks + if run_error_macro: + self.wrap_gcode_command(self.error_macro) + + if run_pause_macro: + # Report errors and ensure we always pause + self.wrap_gcode_command(self.pause_macro, exception=False) + self.pause_resume.send_pause_command() + + if recover_pos: + self.recover_filament_pos(message=True) + + # Intention is not to sync unless we have to but will be restored on resume/continue_printing + self.reset_sync_gear_to_extruder(False) + + if send_event: + self.printer.send_event("mmu:mmu_paused") # Notify MMU paused event + + # Displays MMU error/pause as pop-up dialog and/or via console + def _display_mmu_error(self): + msg= "Print%s paused" % (" was already" if self.is_printer_paused() else " will be") + dialog_macro = self.printer.lookup_object('gcode_macro %s' % self.error_dialog_macro, None) + if self.show_error_dialog and dialog_macro is not None: + # Klipper doesn't handle string quoting so strip problematic characters + reason = self.reason_for_pause.replace("\n", ". ") + for c in "#;'": + reason = reason.replace(c, "") + self.wrap_gcode_command('%s MSG="%s" REASON="%s"' % (self.error_dialog_macro, msg, reason)) + self.log_error("MMU issue detected. %s\nReason: %s" % (msg, self.reason_for_pause)) + self.log_always("After fixing, call RESUME to continue printing (MMU_UNLOCK to restore temperature)") + + def _clear_mmu_error_dialog(self): + dialog_macro = self.printer.lookup_object('gcode_macro %s' % self.error_dialog_macro, None) + if self.show_error_dialog and dialog_macro is not None: + self.wrap_gcode_command('RESPOND TYPE=command MSG="action:prompt_end"') + + def _mmu_unlock(self): + if self.is_mmu_paused(): + self.gcode.run_script_from_command("SET_IDLE_TIMEOUT TIMEOUT=%d" % self.default_idle_timeout) + self.reactor.update_timer(self.hotend_off_timer, self.reactor.NEVER) + + # Important to wait for stable temperature to resume exactly how we paused + if self.paused_extruder_temp: + self.log_info("Enabled extruder heater") + self._ensure_safe_extruder_temperature("pause", wait=True) + self._set_print_state("paused") + + # Continue after load/unload/change_tool/runout operation or pause/error + def _continue_after(self, operation, force_in_print=False, restore=True): + self.log_debug("Continuing from %s state after %s" % (self.print_state, operation)) + if self.is_mmu_paused() and operation == 'resume': + self.reason_for_pause = None + self._ensure_safe_extruder_temperature("pause", wait=True) + self.paused_extruder_temp = None + self._track_pause_end() + if self.is_in_print(force_in_print): + self._enable_filament_monitoring() # Enable filament monitoring while printing + self._set_print_state(self.resume_to_state) + self.resume_to_state = "ready" + self.printer.send_event("mmu:mmu_resumed") + elif self.is_mmu_paused(): + # If paused we can only continue on resume + return + + if self.is_printing(force_in_print): + self.sensor_manager.confirm_loaded() # Can throw MmuError + self.is_handling_runout = False + self._initialize_encoder(dwell=None) # Encoder 0000 + + # Restablish desired syncing state and grip (servo) position + self.reset_sync_gear_to_extruder(self.sync_to_extruder, force_in_print=force_in_print) + + # Good place to reset the _next_tool marker because after any user fix on toolchange error/pause + self._next_tool = self.TOOL_GATE_UNKNOWN + + # Restore print position as final step so no delay + self._restore_toolhead_position(operation, restore=restore) + + # Ensure espooler wasn't reset + self._adjust_espooler_assist() + + # Ready to continue printing... + + def _clear_macro_state(self, reset=False): + if self.printer.lookup_object('gcode_macro %s' % self.clear_position_macro, None) is not None: + self.wrap_gcode_command("%s%s" % (self.clear_position_macro, " RESET=1" if reset else "")) + + def _save_toolhead_position_and_park(self, operation, next_pos=None): + if operation not in ['complete', 'cancel'] and 'xyz' not in self.toolhead.get_status(self.reactor.monotonic())['homed_axes']: + self.gcode.run_script_from_command(self.toolhead_homing_macro) + self.movequeues_wait() + + eventtime = self.reactor.monotonic() + homed = self.toolhead.get_status(eventtime)['homed_axes'] + if 'xyz' in homed: + if not self.saved_toolhead_operation: + # Save toolhead position + + # This is paranoia so I can be absolutely sure that Happy Hare leaves toolhead the same way when we are done + gcode_pos = self.gcode_move.get_status(eventtime)['gcode_position'] + toolhead_gcode_pos = " ".join(["%s:%.1f" % (a, v) for a, v in zip("XYZE", gcode_pos)]) + self.log_debug("Saving toolhead gcode state and position (%s) for %s" % (toolhead_gcode_pos, operation)) + self.gcode.run_script_from_command("SAVE_GCODE_STATE NAME=%s" % self.TOOLHEAD_POSITION_STATE) + self.saved_toolhead_operation = operation + + # Save toolhead velocity limits and set user defined for macros + self.saved_toolhead_max_accel = self.toolhead.max_accel + self.saved_toolhead_min_cruise_ratio = self.toolhead.get_status(eventtime).get('minimum_cruise_ratio', None) + cmd = "SET_VELOCITY_LIMIT ACCEL=%.4f" % self.macro_toolhead_max_accel + if self.saved_toolhead_min_cruise_ratio is not None: + cmd += " MINIMUM_CRUISE_RATIO=%.4f" % self.macro_toolhead_min_cruise_ratio + self.gcode.run_script_from_command(cmd) + + # Record the intended X,Y resume position (this is also passed to the pause/resume restore position in pause is later called) + if next_pos: + self.gcode_move.saved_states[self.TOOLHEAD_POSITION_STATE]['last_position'][:2] = next_pos + + # Make sure we record the current speed/extruder overrides + if self.tool_selected >= 0: + mmu_state = self.gcode_move.saved_states[self.TOOLHEAD_POSITION_STATE] + self.tool_speed_multipliers[self.tool_selected] = mmu_state['speed_factor'] * 60. + self.tool_extrusion_multipliers[self.tool_selected] = mmu_state['extrude_factor'] + + # This will save the print position in the macro and apply park + self.wrap_gcode_command(self.save_position_macro) + self.wrap_gcode_command(self.park_macro) + else: + # Re-apply parking for new operation (this will not change the saved position in macro) + + self.saved_toolhead_operation = operation # Update operation in progress + # Force re-park now because user may not be using HH client_macros. This can result + # in duplicate calls to parking macro but it is itempotent and will ignore + self.wrap_gcode_command(self.park_macro) + else: + self.log_debug("Cannot save toolhead position or z-hop for %s because not homed" % operation) + + def _restore_toolhead_position(self, operation, restore=True): + eventtime = self.reactor.monotonic() + if self.saved_toolhead_operation: + # Inject speed/extruder overrides into gcode state restore data + if self.tool_selected >= 0: + mmu_state = self.gcode_move.saved_states[self.TOOLHEAD_POSITION_STATE] + mmu_state['speed_factor'] = self.tool_speed_multipliers[self.tool_selected] / 60. + mmu_state['extrude_factor'] = self.tool_extrusion_multipliers[self.tool_selected] + + # If this is the final "restore toolhead position" call then allow macro to restore position, then sanity check + # Note: if user calls BASE_RESUME, print will restart but from incorrect position that could be restored later! + if not self.is_paused() or operation == "resume": + # Controlled by the RESTORE=0 flag to MMU_LOAD, MMU_EJECT, MMU_CHANGE_TOOL (only real use case is final unload and perhaps initial load) + restore_macro = "%s RESTORE=%d" % (self.restore_position_macro, int(restore)) + # Restore macro position and clear saved + self.wrap_gcode_command(restore_macro) # Restore macro position and clear saved + + if restore: + # Paranoia: no matter what macros do ensure position and state is good. Either last, next or none (current x,y) + sequence_vars_macro = self.printer.lookup_object("gcode_macro _MMU_SEQUENCE_VARS", None) + travel_speed = 200 + if sequence_vars_macro: + if sequence_vars_macro.variables.get('restore_xy_pos', 'last') == 'none' and self.saved_toolhead_operation in ['toolchange']: + # Don't change x,y position on toolchange + current_pos = self.gcode_move.get_status(eventtime)['gcode_position'] + self.gcode_move.saved_states[self.TOOLHEAD_POSITION_STATE]['last_position'][:2] = current_pos[:2] + travel_speed = sequence_vars_macro.variables.get('park_travel_speed', travel_speed) + gcode_pos = self.gcode_move.saved_states[self.TOOLHEAD_POSITION_STATE]['last_position'] + display_gcode_pos = " ".join(["%s:%.1f" % (a, v) for a, v in zip("XYZE", gcode_pos)]) + self.gcode.run_script_from_command("RESTORE_GCODE_STATE NAME=%s MOVE=1 MOVE_SPEED=%.1f" % (self.TOOLHEAD_POSITION_STATE, travel_speed)) + self.log_debug("Ensuring correct gcode state and position (%s) after %s" % (display_gcode_pos, operation)) + + self._clear_saved_toolhead_position() + + # Always restore toolhead velocity limits + if self.saved_toolhead_max_accel: + cmd = "SET_VELOCITY_LIMIT ACCEL=%.4f" % self.saved_toolhead_max_accel + if self.saved_toolhead_min_cruise_ratio is not None: + cmd += " MINIMUM_CRUISE_RATIO=%.4f" % self.saved_toolhead_min_cruise_ratio + self.gcode.run_script_from_command(cmd) + self.saved_toolhead_max_accel = None + else: + pass # Resume will call here again shortly so we can ignore for now + else: + # Ensure all saved state is cleared + self._clear_macro_state() + self._clear_saved_toolhead_position() + + def _clear_saved_toolhead_position(self): + self.saved_toolhead_operation = '' + + def _disable_filament_monitoring(self): + eventtime = self.reactor.monotonic() + enabled = self.runout_enabled + self.runout_enabled = False + self.log_trace("Disabled FlowGuard and runout detection") + if self.has_encoder() and self.encoder_sensor.is_enabled(): + self.encoder_sensor.disable() + self.sensor_manager.disable_runout(self.gate_selected) + self.sync_feedback_manager.deactivate_flowguard(eventtime) + return enabled + + def _enable_filament_monitoring(self): + eventtime = self.reactor.monotonic() + self.runout_enabled = True + self.log_trace("Enabled FlowGuard and runout detection") + if self.has_encoder() and not self.encoder_sensor.is_enabled(): + self.encoder_sensor.enable() + self.sensor_manager.enable_runout(self.gate_selected) + self.sync_feedback_manager.activate_flowguard(eventtime) + self.runout_last_enable_time = eventtime + + @contextlib.contextmanager + def _wrap_suspend_filament_monitoring(self): + enabled = self._disable_filament_monitoring() + try: + yield self + finally: + if enabled: + self._enable_filament_monitoring() + + # To suppress visual filament position + @contextlib.contextmanager + def wrap_suppress_visual_log(self): + log_visual = self.log_visual + self.log_visual = 0 + try: + yield self + finally: + self.log_visual = log_visual + + def has_espooler(self): + return self.espooler is not None + + def _check_has_espooler(self): + if not self.has_espooler(): + self.log_error("No espooler fitted to this MMU unit") + return True + return False + + def has_encoder(self): + return self.encoder_sensor is not None and not self.test_disable_encoder + + def _can_use_encoder(self): + return self.has_encoder() and self.encoder_move_validation + + def _check_has_encoder(self): + if not self.has_encoder(): + self.log_error("No encoder fitted to this MMU unit") + return True + return False + + def _get_encoder_state(self): + if self.has_encoder(): + return "%s" % "Enabled" if self.encoder_sensor.is_enabled() else "Disabled" + else: + return "n/a" + + # For all encoder methods, 'dwell' means: + # True - gives klipper a little extra time to deliver all encoder pulses when absolute accuracy is required + # False - wait for moves to complete and then read encoder + # None - just read encoder without delay (assumes prior movements have completed) + # Return 'False' if no encoder fitted + def _encoder_dwell(self, dwell): + if self.has_encoder(): + if dwell: + self.movequeues_dwell(self.encoder_dwell) + self.movequeues_wait() + return True + elif dwell is False and self._can_use_encoder(): + self.movequeues_wait() + return True + elif dwell is None and self._can_use_encoder(): + return True + return False + + # Forces encoder to validate despite user desire (override 'encoder_move_validation' setting) + @contextlib.contextmanager + def _require_encoder(self): + if not self.has_encoder(): + raise MmuError("Assertion failure: Encoder required for chosen operation but not present on MMU") + validate = self.encoder_move_validation + self.encoder_move_validation = True + try: + yield self + finally: + self.encoder_move_validation = validate + + def get_encoder_distance(self, dwell=False): + if self._encoder_dwell(dwell): + return self.encoder_sensor.get_distance() + else: + return 0. + + def _get_encoder_counts(self, dwell=False): + if self._encoder_dwell(dwell): + return self.encoder_sensor.get_counts() + else: + return 0 + + def set_encoder_distance(self, distance, dwell=False): + if self._encoder_dwell(dwell): + self.encoder_sensor.set_distance(distance) + + def _initialize_encoder(self, dwell=False): + if self._encoder_dwell(dwell): + self.encoder_sensor.reset_counts() + + def _get_encoder_dead_space(self): + if self.has_encoder() and self.gate_homing_endstop in [self.SENSOR_GATE, self.SENSOR_GEAR_PREFIX]: + return self.gate_endstop_to_encoder + else: + return 0. + + def _initialize_filament_position(self, dwell=False): + self._initialize_encoder(dwell=dwell) + self._set_filament_position() + + def _get_filament_position(self): + return self.mmu_toolhead.get_position()[1] + + def _get_live_filament_position(self): + """ + Return the approximate live filament position + """ + gear_stepper = self.gear_rail.steppers[0] + mcu_pos = gear_stepper.get_mcu_position() + return mcu_pos * gear_stepper.get_step_dist() + + def _set_filament_position(self, position = 0.): + pos = self.mmu_toolhead.get_position() + pos[1] = position + self.mmu_toolhead.set_position(pos) + return position + + def _set_filament_remaining(self, length, color=''): + self.filament_remaining = length + self.save_variable(self.VARS_MMU_FILAMENT_REMAINING, max(0, round(length, 1))) + self.save_variable(self.VARS_MMU_FILAMENT_REMAINING_COLOR, color, write=True) + + def _set_last_tool(self, tool): + self._last_tool = tool + self.save_variable(self.VARS_MMU_LAST_TOOL, tool, write=True) + + def _set_filament_pos_state(self, state, silent=False): + if self.filament_pos != state: + self.filament_pos = state + if self.gate_selected != self.TOOL_GATE_BYPASS or state == self.FILAMENT_POS_UNLOADED or state == self.FILAMENT_POS_LOADED: + self._display_visual_state(silent=silent) + + # Minimal save_variable writes + if state in [self.FILAMENT_POS_LOADED, self.FILAMENT_POS_UNLOADED]: + self.save_variable(self.VARS_MMU_FILAMENT_POS, state, write=True) + elif self.save_variables.allVariables.get(self.VARS_MMU_FILAMENT_POS, 0) != self.FILAMENT_POS_UNKNOWN: + self.save_variable(self.VARS_MMU_FILAMENT_POS, self.FILAMENT_POS_UNKNOWN, write=True) + + self._adjust_espooler_assist() + + def _adjust_espooler_assist(self): + """ + Ensure espooler print assist is in correct state based on whether the filament is in the extruder or not + """ + if self.has_espooler(): + if self.filament_pos == self.FILAMENT_POS_LOADED: + if self.ESPOOLER_PRINT in self.espooler_operations and self.espooler_printing_power == 0: + # Enable in-print assist because filament is in the extruder + self.espooler.set_print_assist_mode(self.gate_selected) + else: + # Ensure in-print assist mode is removed + # (it could have been enabled manually with MMU_ESPOOLER) + self.espooler.reset_print_assist_mode() + + def _set_filament_direction(self, direction): + self.filament_direction = direction + + def _must_home_to_extruder(self): + return self.extruder_homing_endstop != self.SENSOR_EXTRUDER_NONE and (self.extruder_force_homing or not self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD)) + + def _must_buffer_extruder_homing(self): + return self._must_home_to_extruder() and self.extruder_homing_endstop != self.SENSOR_EXTRUDER_COLLISION + + def check_if_disabled(self): + if not self.is_enabled: + self.log_error("Operation not possible. MMU is disabled. Please use MMU ENABLE=1 to use") + return True + self._wakeup() + return False + + def check_if_printing(self): + if self.is_printing(): + self.log_error("Operation not possible. Printer is actively printing") + return True + return False + + def check_if_bypass(self, unloaded_ok=True): + if self.tool_selected == self.TOOL_GATE_BYPASS and (not unloaded_ok or self.filament_pos not in [self.FILAMENT_POS_UNLOADED]): + self.log_error("Operation not possible. MMU is currently using bypass. Unload or select a different gate first") + return True + return False + + def check_if_not_homed(self): + if not self.selector.is_homed: + self.log_error("Operation not possible. MMU selector is not homed") + return True + return False + + def check_if_loaded(self): + if self.filament_pos not in [self.FILAMENT_POS_UNLOADED, self.FILAMENT_POS_UNKNOWN]: + self.log_error("Operation not possible. Filament is loaded") + return True + return False + + def check_if_not_loaded(self): + if self.filament_pos != self.FILAMENT_POS_LOADED: + self.log_error("Operation not possible. Filament is not loaded") + return True + return False + + def check_if_gate_not_valid(self): + if self.gate_selected < 0: + self.log_error("Operation not possible. No MMU gate selected") + return True + return False + + def check_if_always_gripped(self): + if self.mmu_machine.filament_always_gripped: + self.log_error("Operation not possible. MMU design doesn't allow for manual override of syncing state.\nSyncing will be enabled if filament is inside the extruder.\nUse `MMU_RECOVER` to correct filament position if necessary.") + return True + return False + + def check_if_no_bowden_move(self): + if not self.mmu_machine.require_bowden_move: + self.log_error("Operation not possible. MMU design does not require bowden move/calibration") + return True + return False + + # Check if everything calibrated + # Returns True is required calibration is not complete. Defaults to all gates or a specific gate can + # be specified. The purpose of this is to highlight to the user what is not fully calibrated on their + # MMU. It will default to not reporting calibration steps that are optional based on "autotune" options + # Params: required - bitmap of required calibration checks + # silent - report errors (None = report but don't log as error) + # check_gates - list of gates to consider (None = all) + # use_autotune - True = don't warn if handled by autocal/autotune options, False = warn + def check_if_not_calibrated(self, required, silent=False, check_gates=None, use_autotune=True): + if not self.calibration_status & required == required: + if check_gates is None: + check_gates = list(range(self.num_gates)) + + rmsg = omsg = "" + if ( + (not use_autotune or not self.autocal_selector) and + (required & self.CALIBRATED_SELECTOR) and + not (self.calibration_status & self.CALIBRATED_SELECTOR) + ): + uncalibrated = self.selector.get_uncalibrated_gates(check_gates) + if uncalibrated: + info = "\n- Use MMU_CALIBRATE_SELECTOR to calibrate selector for gates: %s" % ",".join(map(str, uncalibrated)) + if self.autocal_selector: + omsg += info + else: + rmsg += info + + if ( + (not use_autotune or not self.skip_cal_rotation_distance) and + (required & self.CALIBRATED_GEAR_0) and + not (self.calibration_status & self.CALIBRATED_GEAR_0) + ): + uncalibrated = self.rotation_distances[0] == -1 + if uncalibrated: + info = "\n- Use MMU_CALIBRATE_GEAR (with gate 0 selected)" + info += " to calibrate gear rotation_distance on gate: 0" + if self.skip_cal_rotation_distance: + omsg += info + else: + rmsg += info + + if ( + (not use_autotune or not self.skip_cal_encoder) and + (required & self.CALIBRATED_ENCODER and + not (self.calibration_status & self.CALIBRATED_ENCODER)) + ): + info = "\n- Use MMU_CALIBRATE_ENCODER (with gate 0 selected)" + if self.skip_cal_encoder: + omsg += info + else: + rmsg += info + + if ( + self.mmu_machine.variable_rotation_distances and + (not use_autotune or not (self.skip_cal_rotation_distance or self.autotune_rotation_distance)) and + (required & self.CALIBRATED_GEAR_RDS) and + not (self.calibration_status & self.CALIBRATED_GEAR_RDS) + ): + uncalibrated = [gate for gate, value in enumerate(self.rotation_distances) if gate != 0 and value == -1 and gate in check_gates] + if uncalibrated: + if self.has_encoder(): + info = "\n- Use MMU_CALIBRATE_GEAR (with appropriate gate selected) or MMU_CALIBRATE_GATES GATE=xx" + info += " to calibrate gear rotation_distance on gates: %s" % ",".join(map(str, uncalibrated)) + else: + info = "\n- Use MMU_CALIBRATE_GEAR (with appropriate gate selected)" + info += " to calibrate gear rotation_distance on gates: %s" % ",".join(map(str, uncalibrated)) + if (self.skip_cal_rotation_distance or self.autotune_rotation_distance): + omsg += info + else: + rmsg += info + + if ( + (not use_autotune or not self.autocal_bowden_length) and + (required & self.CALIBRATED_BOWDENS) and + not (self.calibration_status & self.CALIBRATED_BOWDENS) + ): + if self.mmu_machine.variable_bowden_lengths: + uncalibrated = [gate for gate, value in enumerate(self.bowden_lengths) if value == -1 and gate in check_gates] + if uncalibrated: + info = "\n- Use MMU_CALIBRATE_BOWDEN (with gate selected)" + info += " to calibrate bowden length gates: %s" % ",".join(map(str, uncalibrated)) + if self.autocal_bowden_length: + omsg += info + else: + rmsg += info + else: + uncalibrated = self.bowden_lengths[0] == -1 + if uncalibrated: + info = "\n- Use MMU_CALIBRATE_BOWDEN (with gate 0 selected) to calibrate bowden length" + if self.autocal_bowden_length: + omsg += info + else: + rmsg += info + + if rmsg or omsg: + msg = "Warning: Calibration steps are not complete:" + if rmsg: + msg += "\nRequired:%s" % rmsg + if omsg: + msg += "\nOptional (handled by autocal/autotune):%s" % omsg + if not silent: + if silent is None: # Bootup/status use case to avoid looking like error + self.log_always("{2}%s{0}" % msg, color=True) + else: + self.log_error(msg) + return True + return False + + def check_if_spoolman_enabled(self): + if self.spoolman_support == self.SPOOLMAN_OFF: + self.log_error("Spoolman support is currently disabled") + return True + return False + + def _gate_homing_string(self): + return "ENCODER" if self.gate_homing_endstop == self.SENSOR_ENCODER else "%s sensor" % self.gate_homing_endstop + + def _ensure_safe_extruder_temperature(self, source="auto", wait=False): + extruder = self.printer.lookup_object(self.extruder_name) + current_temp = extruder.get_status(0)['temperature'] + current_target_temp = extruder.heater.target_temp + klipper_minimum_temp = extruder.get_heater().min_extrude_temp + gate_temp = self.gate_temperature[self.gate_selected] if self.gate_selected >= 0 and self.gate_temperature[self.gate_selected] > 0 else self.default_extruder_temp + self.log_trace("_ensure_safe_extruder_temperature: current_temp=%s, paused_extruder_temp=%s, current_target_temp=%s, klipper_minimum_temp=%s, gate_temp=%s, default_extruder_temp=%s, source=%s" % (current_temp, self.paused_extruder_temp, current_target_temp, klipper_minimum_temp, gate_temp, self.default_extruder_temp, source)) + + if source == "pause": + new_target_temp = self.paused_extruder_temp if self.paused_extruder_temp is not None else current_temp # Pause temp should not be None + if self.paused_extruder_temp < klipper_minimum_temp: + # Don't wait if just messing with cold printer + wait = False + + elif source == "auto": # Normal case + if self.is_mmu_paused(): + # In a pause we always want to restore the temp we paused at + if self.paused_extruder_temp is not None: + new_target_temp = self.paused_extruder_temp + source = "pause" + else: # Pause temp should not be None + new_target_temp = current_temp + source = "current" + + elif self.is_printing(): + if current_target_temp < klipper_minimum_temp: + # Almost certainly means the initial tool change before slicer has set + if self.gate_selected >= 0: + new_target_temp = gate_temp + source = "gatemap" + else: + new_target_temp = self.default_extruder_temp + source = "mmu default" + else: + # While actively printing, we want to defer to the slicer for temperature + new_target_temp = current_target_temp + source = "slicer" + + else: + # Standalone "just messing" case + if current_target_temp > klipper_minimum_temp: + new_target_temp = current_target_temp + source = "current" + else: + if self.gate_selected >= 0: + new_target_temp = gate_temp + source = "gatemap" + else: + new_target_temp = self.default_extruder_temp + source = "mmu default" + + # Final safety check + if new_target_temp <= klipper_minimum_temp: + new_target_temp = self.default_extruder_temp + source = "mmu default" + + if new_target_temp > current_target_temp: + if source in ["mmu default", "gatemap"]: + # We use error log channel to avoid heating surprise. This will also cause popup in Klipperscreen + self.log_error("Alert: Automatically heating extruder to %s temp (%.1f%sC)" % (source, new_target_temp, UI_DEGREE)) + else: + self.log_info("Heating extruder to %s temp (%.1f%sC)" % (source, new_target_temp, UI_DEGREE)) + wait = True # Always wait to warm up + + if new_target_temp > 0: + self.gcode.run_script_from_command("M104 S%.1f" % new_target_temp) + + # Optionally wait until temperature is stable or at minimum safe temp so extruder can move + if wait and new_target_temp >= klipper_minimum_temp and abs(new_target_temp - current_temp) > self.extruder_temp_variance: + with self.wrap_action(self.ACTION_HEATING): + self.log_info("Waiting for extruder to reach target (%s) temperature: %.1f%sC" % (source, new_target_temp, UI_DEGREE)) + self.gcode.run_script_from_command("TEMPERATURE_WAIT SENSOR=%s MINIMUM=%.1f MAXIMUM=%.1f" % (self.extruder_name, new_target_temp - self.extruder_temp_variance, new_target_temp + self.extruder_temp_variance)) + + def selected_tool_string(self, tool=None): + if tool is None: + tool = self.tool_selected + if tool == self.TOOL_GATE_BYPASS: + return "Bypass" + elif tool == self.TOOL_GATE_UNKNOWN: + return "Unknown" + else: + return "T%d" % tool + + def selected_gate_string(self, gate=None): + if gate is None: + gate = self.gate_selected + if gate == self.TOOL_GATE_BYPASS: + return "bypass" + elif gate == self.TOOL_GATE_UNKNOWN: + return "unknown" + else: + return "#%d" % gate + + def selected_unit_string(self, unit=None): + return " (unit #%d)" % self.unit_selected if self.mmu_machine.num_units > 1 else "" + + def _set_action(self, action): + if action == self.action: return action + old_action = self.action + self.action = action + self.led_manager.action_changed(action, old_action) + if self.printer.lookup_object("gcode_macro %s" % self.action_changed_macro, None) is not None: + self.wrap_gcode_command("%s ACTION='%s' OLD_ACTION='%s'" % (self.action_changed_macro, self._get_action_string(), self._get_action_string(old_action))) + return old_action + + @contextlib.contextmanager + def wrap_action(self, new_action): + old_action = self._set_action(new_action) + try: + yield (old_action, new_action) + finally: + self._set_action(old_action) + + def _enable_mmu(self): + if self.is_enabled: return + self.reinit() + self._load_persisted_state() + self.is_enabled = True + self.printer.send_event("mmu:enabled") + self.log_always("MMU enabled") + self._schedule_mmu_bootup_tasks() + + def _disable_mmu(self): + if not self.is_enabled: return + self.reinit() + self._disable_filament_monitoring() + self.reactor.update_timer(self.hotend_off_timer, self.reactor.NEVER) + self.gcode.run_script_from_command("SET_IDLE_TIMEOUT TIMEOUT=%d" % self.default_idle_timeout) + self.motors_onoff(on=False) # Will also unsync gear + self.is_enabled = False + self.printer.send_event("mmu:disabled") + self._set_print_state("standby") + self.log_always("MMU disabled") + + # Wrapper so we can minimize actual disk writes and batch updates + def save_variable(self, variable, value, write=False): + self.save_variables.allVariables[variable] = value + if write: + self.write_variables() + + def delete_variable(self, variable, write=False): + _ = self.save_variables.allVariables.pop(variable, None) + if write: + self.write_variables() + + def write_variables(self): + if self._can_write_variables: + mmu_vars_revision = self.save_variables.allVariables.get(self.VARS_MMU_REVISION, 0) + 1 + self.gcode.run_script_from_command("SAVE_VARIABLE VARIABLE=%s VALUE=%d" % (self.VARS_MMU_REVISION, mmu_vars_revision)) + + @contextlib.contextmanager + def _wrap_suspendwrite_variables(self): + self._can_write_variables = False + try: + yield self + finally: + self._can_write_variables = True + self.write_variables() + + def _random_failure(self): + if self.test_random_failures and random.randint(0, 10) == 0: + raise MmuError("Randomized testing failure") + + +### STATE GCODE COMMANDS ######################################################### + + cmd_MMU_help = "Enable/Disable functionality and reset state" + def cmd_MMU(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + enable = gcmd.get_int('ENABLE', minval=0, maxval=1) + if enable == 1: + self._enable_mmu() + else: + self._disable_mmu() + + cmd_MMU_HELP_help = "Display the complete set of MMU commands and function" + def cmd_MMU_HELP(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + testing = gcmd.get_int('TESTING', 0, minval=0, maxval=1) + slicer = gcmd.get_int('SLICER', 0, minval=0, maxval=1) + callbacks = gcmd.get_int('CALLBACKS', 0, minval=0, maxval=1) + steps = gcmd.get_int('STEPS', 0, minval=0, maxval=1) + msg = "Happy Hare MMU commands: (use MMU_HELP SLICER=1 CALLBACKS=1 TESTING=1 STEPS=1 for full command set)\n" + tesing_msg = "\nCalibration and testing commands:\n" + slicer_msg = "\nPrint start/end or slicer macros (defined in mmu_software.cfg\n" + callback_msg = "\nCallbacks (defined in mmu_sequence.cfg, mmu_state.cfg)\n" + seq_msg = "\nAdvanced load/unload sequence and steps:\n" + cmds = list(self.gcode.ready_gcode_handlers.keys()) + cmds.sort() + + # Logic to partition commands: + for c in cmds: + d = self.gcode.gcode_help.get(c, "n/a") + + if (c.startswith("MMU_START") or c.startswith("MMU_END") or c in ["MMU_UPDATE_HEIGHT"]) and c not in ["MMU_ENDLESS_SPOOL"]: + slicer_msg += "%s : %s\n" % (c.upper(), d) # Print start/end macros + + elif c.startswith("MMU") and not c.startswith("MMU__"): + if any(substring in c for substring in ["_CALIBRATE", "_TEST", "_SOAKTEST", "MMU_COLD_PULL"]): + tesing_msg += "%s : %s\n" % (c.upper(), d) # Testing and calibration commands + else: + if c not in ["MMU_CHANGE_TOOL_STANDALONE", "MMU_CHECK_GATES", "MMU_REMAP_TTG", "MMU_FORM_TIP"]: # Remove aliases + msg += "%s : %s\n" % (c.upper(), d) # Base command + + elif c.startswith("_MMU"): + if c.startswith("_MMU_STEP") or c in ["_MMU_M400", "_MMU_LOAD_SEQUENCE", "_MMU_UNLOAD_SEQUENCE"]: + seq_msg += "%s : %s\n" % (c.upper(), d) # Invidual sequence step commands + elif c.startswith("_MMU_PRE_") or c.startswith("_MMU_POST_") or c in ["_MMU_ACTION_CHANGED", "_MMU_EVENT", "_MMU_PRINT_STATE_CHANGED"]: + callback_msg += "%s : %s\n" % (c.upper(), d) # Callbacks + + msg += slicer_msg if slicer else "" + msg += callback_msg if callbacks else "" + msg += tesing_msg if testing else "" + msg += seq_msg if steps else "" + self.log_always(msg) + + + cmd_MMU_ENCODER_help = "Display encoder position and stats or enable/disable runout detection logic in encoder" + def cmd_MMU_ENCODER(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self._check_has_encoder(): return + if self.check_if_disabled(): return + value = gcmd.get_float('VALUE', -1, minval=0.) + enable = gcmd.get_int('ENABLE', -1, minval=0, maxval=1) + if enable == 1: + self.sync_feedback_manager.set_encoder_mode() + elif enable == 0: + self.sync_feedback_manager.set_encoder_mode(self.encoder_sensor.RUNOUT_DISABLED) + elif value >= 0.: + self.set_encoder_distance(value) + return + self.log_info(self._get_encoder_summary(detail=True)) + + + cmd_MMU_ESPOOLER_help = "Direct control of espooler or display of current status" + cmd_MMU_ESPOOLER_param_help = ( + "MMU_ESPOOLER: %s\n" % cmd_MMU_ESPOOLER_help + + "ALLOFF = [0|1] Quick way to turn all espoolers off\n" + + "TRIGGER = [0|1] Fire in-print trigger for testing\n" + + "BURST = [0|1] Jog in direction of OPERATION (assist|rewind) using configured burst duration and power\n" + + "DURATION = [0-10] Override duration of PWM signal (seconds) for burst operations\n" + + "GATE = g Specify gate to operate on (defaults to current gate)\n" + + "LOOSEN = [0|1] Quick way to loosen filament on spool\n" + + "OPERATION = [assist|off|print|rewind] Set espooler operation mode\n" + + "POWER = [0-100] Override default % power to apply to espooler motor\n" + + "QUIET = [0|1] Used to suppress console/log output\n" + + "RESET = [0|1] Turn of in-print assist\n" + + "TIGHTEN = [0|1] Quick way to tighten filament on spool\n" + + "(no parameters for status report)" + ) + def cmd_MMU_ESPOOLER(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + + if gcmd.get_int('HELP', 0, minval=0, maxval=1): + self.log_always(self.format_help(self.cmd_MMU_ESPOOLER_param_help), color=True) + return + + if self._check_has_espooler(): return + + operation = gcmd.get('OPERATION', None) + burst = gcmd.get_int('BURST', 0, minval=0, maxval=1) + tighten = gcmd.get_int('TIGHTEN', 0, minval=0, maxval=1) + loosen = gcmd.get_int('LOOSEN', 0, minval=0, maxval=1) + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + alloff = bool(gcmd.get_int('ALLOFF', 0, minval=0, maxval=1)) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + trigger = bool(gcmd.get_int('TRIGGER', 0, minval=0, maxval=1)) + gate = gcmd.get_int('GATE', None, minval=0, maxval=self.num_gates - 1) + + if reset: + # Turn off in-print assist mode + self.espooler.reset_print_assist_mode() + + if trigger: + # Mimick in-print assist trigger + # No gate specified = similar to extruder movement + # With gate specified = similar to filament tension trigger + self.espooler.advance(gate) + + if alloff: + for gate in range(self.num_gates): + self.espooler.set_operation(gate, 0, self.ESPOOLER_OFF) + + elif tighten or loosen: + if gate is None: + gate = self.gate_selected + if gate < 0: + raise gcmd.error("Invalid gate") + + power = self.espooler_assist_burst_power if loosen else self.espooler_rewind_burst_power + duration = self.espooler_assist_burst_duration if loosen else self.espooler_rewind_burst_duration + operation = self.ESPOOLER_ASSIST if loosen else self.ESPOOLER_REWIND + self.printer.send_event("mmu:espooler_burst", gate, power / 100., duration, operation) + + elif operation is not None: + operation = operation.lower() + + if gate is None: + gate = self.gate_selected + if gate < 0: + raise gcmd.error("Invalid gate") + + # Determine power + if burst: + default_power = self.espooler_assist_burst_power if operation == self.ESPOOLER_ASSIST else self.espooler_rewind_burst_power + else: + default_power = self.espooler_printing_power if operation == self.ESPOOLER_PRINT else 50 + power = gcmd.get_int('POWER', default_power, minval=0, maxval=100) if operation != self.ESPOOLER_OFF else 0 + + if burst: + default_duration = self.espooler_assist_burst_duration if operation == self.ESPOOLER_ASSIST else self.espooler_rewind_burst_duration + duration = gcmd.get_float('DURATION', default_duration, above=0., maxval=10.) + + if operation in [self.ESPOOLER_ASSIST, self.ESPOOLER_REWIND]: + self.log_info("Espooler burst on gate %d for %.1fs at %d%% power in %s direction" % (gate, duration, power, operation)) + self.printer.send_event("mmu:espooler_burst", gate, power / 100., duration, operation) + else: + self.log_error("Must specify 'assist' or 'rewind' operation for burst") + + elif operation not in self.ESPOOLER_OPERATIONS: + raise gcmd.error("Invalid operation. Options are: %s" % ", ".join(self.ESPOOLER_OPERATIONS)) + + elif operation == self.ESPOOLER_PRINT: + if self.is_printing(): + self.log_warning("Cannot set in-print assist mode for non selected gate while printing") + else: + if gate != self.gate_selected: + self.log_warning("In-print assist mode set for non selected gate - for testing only") + self.espooler.set_operation(gate, power / 100, self.ESPOOLER_PRINT) + + elif operation != self.ESPOOLER_OFF: + self.espooler.set_operation(gate, power / 100, operation) + else: + self.espooler.set_operation(gate, 0, self.ESPOOLER_OFF) + + if not quiet: + msg = "" + for gate in range(self.num_gates): + if msg: + msg += "\n" + msg += "{}".format(gate).ljust(2, UI_SPACE) + ": " + if self.has_espooler(): + operation, value = self.espooler.get_operation(gate) + burst = "" + if operation == self.ESPOOLER_PRINT and value == 0: + burst = " [assist for %.1fs at %d%% power " % (self.espooler_assist_burst_duration, self.espooler_assist_burst_power) + if self.espooler_assist_burst_trigger: + burst += "on trigger, max %d bursts]" % self.espooler_assist_burst_trigger_max + else: + burst += "every %.1fmm of extruder movement]" % self.espooler_assist_extruder_move_length + msg += "{}".format(operation).ljust(7, UI_SPACE) + " (%d%%)%s" % (round(value * 100), burst) + else: + msg += "not fitted" + self.log_always(msg) + + + cmd_MMU_RESET_help = "Forget persisted state and re-initialize defaults" + def cmd_MMU_RESET(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + confirm = gcmd.get_int('CONFIRM', 0, minval=0, maxval=1) + if confirm != 1: + self.log_always("You must re-run and add 'CONFIRM=1' to reset all state back to default") + return + self.reinit() + self._reset_statistics() + self._reset_endless_spool() + self._reset_ttg_map() + self._reset_gate_map() + self.save_variable(self.VARS_MMU_GATE_SELECTED, self.gate_selected) + self.save_variable(self.VARS_MMU_TOOL_SELECTED, self.tool_selected) + self.save_variable(self.VARS_MMU_FILAMENT_POS, self.filament_pos) + self.write_variables() + self.log_always("MMU state reset") + self._schedule_mmu_bootup_tasks() + + +######################################################### +# STEP FILAMENT LOAD/UNLOAD MACROS FOR USER COMPOSITION # +######################################################### + + cmd_MMU_TEST_FORM_TIP_help = "Convenience macro for calling the standalone tip forming functionality (or cutter logic)" + def cmd_MMU_TEST_FORM_TIP(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + show = bool(gcmd.get_int('SHOW', 0, minval=0, maxval=1)) + run = bool(gcmd.get_int('RUN', 1, minval=0, maxval=1)) + force_in_print = bool(gcmd.get_int('FORCE_IN_PRINT', 0, minval=0, maxval=1)) # Mimick in-print syncing and current + + gcode_macro = self.printer.lookup_object("gcode_macro %s" % self.form_tip_macro, None) + if gcode_macro is None: + raise gcmd.error("Filament tip forming macro '%s' not found" % self.form_tip_macro) + gcode_vars = self.printer.lookup_object("gcode_macro %s_VARS" % self.form_tip_macro, gcode_macro) + + if reset: + if self.form_tip_vars is not None: + gcode_vars.variables = dict(self.form_tip_vars) + self.form_tip_vars = None + self.log_always("Reset '%s' macro variables to defaults" % self.form_tip_macro) + show = True + + if show: + msg = "Variable settings for macro '%s':" % self.form_tip_macro + for k, v in gcode_vars.variables.items(): + msg += "\nvariable_%s: %s" % (k, v) + self.log_always(msg) + return + + # Save restore point on first call + if self.form_tip_vars is None: + self.form_tip_vars = dict(gcode_vars.variables) + + for param in gcmd.get_command_parameters(): + value = gcmd.get(param) + param = param.lower() + if param.startswith("variable_"): + self.log_always("Removing 'variable_' prefix from '%s' - not necessary" % param) + param = param[9:] + if param in gcode_vars.variables: + gcode_vars.variables[param] = self._fix_type(value) + elif param not in ["reset", "show", "run", "force_in_print"]: + self.log_error("Variable '%s' is not defined for '%s' macro" % (param, self.form_tip_macro)) + + # Run the macro in test mode (final_eject is set) + msg = "Running macro '%s' with the following variable settings:" % self.form_tip_macro + for k, v in gcode_vars.variables.items(): + msg += "\nvariable_%s: %s" % (k, v) + self.log_always(msg) + + try: + with self.wrap_sync_gear_to_extruder(): + if run: + self._ensure_safe_extruder_temperature(wait=True) + + # Ensure sync state and mimick in print if requested + self.reset_sync_gear_to_extruder(self.sync_form_tip, force_in_print=force_in_print) + + _,_,_ = self._do_form_tip(test=not self.is_in_print(force_in_print)) + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + + cmd_MMU_TEST_PURGE_help = "Convenience macro for calling the standalone purging macro" + def cmd_MMU_TEST_PURGE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + last_tool = gcmd.get_int('LAST_TOOL', self._last_tool, minval=0, maxval=self.num_gates - 1) + next_tool = gcmd.get_int('NEXT_TOOL', self.tool_selected, minval=0, maxval=self.num_gates - 1) + if next_tool < 0: next_tool = 0 + + if not self.purge_macro: + self.log_warning("Purge not possible because `purge_macro` is not defined") + return + + try: + # Determine purge volume for test (mimick regular call to purge macro) + self.toolchange_purge_volume = self._calc_purge_volume(last_tool, next_tool) + + _last_tool, _next_tool = self._last_tool, self._next_tool + self._last_tool, self._next_tool = last_tool, next_tool # Valid only during this test + + msg = "Note that the suggested purge volume is based on the current MMU_SLICER_TOOL_MAP" + msg += "\nIf this is not set you might find it useful to run 'MMU_CALC_PURGE_VOLUMES MULTIPLIER=..'" + msg += "\nto create a purge volume map from current filament colors. You can also specify" + msg += "'LAST_TOOL=.. NEXT_TOOL=..' to this command to override currently loaded tool" + self.log_info(msg) + + self.log_info("Calling purge macro '%s'" % self.purge_macro) + with self.wrap_action(self.ACTION_PURGING): + self.purge_standalone() + + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + finally: + self.toolchange_purge_volume = 0. + self._last_tool, self._next_tool = _last_tool, _next_tool # Restore real values + + + cmd_MMU_STEP_LOAD_GATE_help = "User composable loading step: Move filament from gate to start of bowden" + def cmd_MMU_STEP_LOAD_GATE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + try: + with self.wrap_sync_gear_to_extruder(): + self._load_gate() + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_LOAD_GATE: %s" % str(ee)) + + cmd_MMU_STEP_UNLOAD_GATE_help = "User composable unloading step: Move filament from start of bowden and park in the gate" + def cmd_MMU_STEP_UNLOAD_GATE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + full = gcmd.get_int('FULL', 0) + try: + with self.wrap_sync_gear_to_extruder(): + _,_ = self._unload_gate(homing_max=self.calibration_manager.get_bowden_length() if full else None) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_UNLOAD_GATE: %s" % str(ee)) + + cmd_MMU_STEP_LOAD_BOWDEN_help = "User composable loading step: Smart loading of bowden" + def cmd_MMU_STEP_LOAD_BOWDEN(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + length = gcmd.get_float('LENGTH', None, minval=0.) + start_pos = gcmd.get_float('START_POS', 0.) + try: + with self.wrap_sync_gear_to_extruder(): + _,_ = self._load_bowden(length, start_pos=start_pos) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_LOAD_BOWDEN: %s" % str(ee)) + + cmd_MMU_STEP_UNLOAD_BOWDEN_help = "User composable unloading step: Smart unloading of bowden" + def cmd_MMU_STEP_UNLOAD_BOWDEN(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + length = gcmd.get_float('LENGTH', self.calibration_manager.get_bowden_length()) + try: + with self.wrap_sync_gear_to_extruder(): + _ = self._unload_bowden(length) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_UNLOAD_BOWDEN: %s" % str(ee)) + + cmd_MMU_STEP_HOME_EXTRUDER_help = "User composable loading step: Home to extruder sensor or entrance through collision detection" + def cmd_MMU_STEP_HOME_EXTRUDER(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + try: + with self.wrap_sync_gear_to_extruder(): + _,_ = self._home_to_extruder(self.extruder_homing_max) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_HOME_EXTRUDER: %s" % str(ee)) + + cmd_MMU_STEP_LOAD_TOOLHEAD_help = "User composable loading step: Toolhead loading" + def cmd_MMU_STEP_LOAD_TOOLHEAD(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + extruder_only = gcmd.get_int('EXTRUDER_ONLY', 0) + try: + with self.wrap_sync_gear_to_extruder(): + _ = self._load_extruder(extruder_only) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_LOAD_TOOLHEAD: %s" % str(ee)) + + cmd_MMU_STEP_UNLOAD_TOOLHEAD_help = "User composable unloading step: Toolhead unloading" + def cmd_MMU_STEP_UNLOAD_TOOLHEAD(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + extruder_only = bool(gcmd.get_int('EXTRUDER_ONLY', 0)) + park_pos = gcmd.get_float('PARK_POS', -self._get_filament_position()) # +ve value + try: + with self.wrap_sync_gear_to_extruder(): + # Precautionary validation of filament position + park_pos = min(self.toolhead_extruder_to_nozzle, max(0, park_pos)) + self._set_filament_position(-park_pos) + self._unload_extruder(extruder_only = extruder_only) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_UNLOAD_TOOLHEAD: %s" % str(ee)) + + cmd_MMU_STEP_HOMING_MOVE_help = "User composable loading step: Generic homing move" + def cmd_MMU_STEP_HOMING_MOVE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + allow_bypass = bool(gcmd.get_int('ALLOW_BYPASS', 0, minval=0, maxval=1)) + try: + with self.wrap_sync_gear_to_extruder(): + self._homing_move_cmd(gcmd, "User defined step homing move", allow_bypass=allow_bypass) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_HOMING_MOVE: %s" % str(ee)) + + cmd_MMU_STEP_MOVE_help = "User composable loading step: Generic move" + def cmd_MMU_STEP_MOVE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + allow_bypass = bool(gcmd.get_int('ALLOW_BYPASS', 0, minval=0, maxval=1)) + try: + with self.wrap_sync_gear_to_extruder(): + self._move_cmd(gcmd, "User defined step move", allow_bypass=allow_bypass) + except MmuError as ee: + self.handle_mmu_error("_MMU_STEP_MOVE: %s" % str(ee)) + + cmd_MMU_STEP_SET_FILAMENT_help = "User composable loading step: Set filament position state" + def cmd_MMU_STEP_SET_FILAMENT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + state = gcmd.get_int('STATE', minval=self.FILAMENT_POS_UNKNOWN, maxval=self.FILAMENT_POS_LOADED) + silent = gcmd.get_int('SILENT', 0) + self._set_filament_pos_state(state, silent) + + cmd_MMU_STEP_SET_ACTION_help = "User composable loading step: Set action state" + def cmd_MMU_STEP_SET_ACTION(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if gcmd.get_int('RESTORE', 0): + if self._old_action is not None: + self._set_action(self._old_action) + self._old_action = None + else: + state = gcmd.get_int('STATE', minval=self.ACTION_IDLE, maxval=self.ACTION_PURGING) + if self._old_action is None: + self._old_action = self._set_action(state) + else: + self._set_action(state) + + +############################################## +# MODULAR FILAMENT LOAD AND UNLOAD FUNCTIONS # +############################################## + + # Preload selected gate as little as possible. If a full gate load is the only option + # this will then park correctly after pre-load + def _preload_gate(self): + gate_sensor = self.sensor_manager.check_gate_sensor(self.SENSOR_GEAR_PREFIX, self.gate_selected) + if gate_sensor is not None: + if gate_sensor: + self.log_always("Filament already preloaded") + self._set_gate_status(self.gate_selected, self.GATE_AVAILABLE) + return + else: + # Minimal load to gear sensor if fitted + endstop_name = self.sensor_manager.get_gate_sensor_name(self.SENSOR_GEAR_PREFIX, self.gate_selected) + self.log_always("Preloading...") + msg = "Homing to %s sensor" % endstop_name + with self._wrap_suspend_filament_monitoring(): + actual,homed,measured,_ = self.trace_filament_move(msg, self.gate_preload_homing_max, motor="gear", homing_move=1, endstop_name=endstop_name) + if homed: + self.trace_filament_move("Final parking", -self.gate_preload_parking_distance) + self._set_gate_status(self.gate_selected, self.GATE_AVAILABLE) + self._check_pending_spool_id(self.gate_selected) # Have spool_id ready? + self.log_always("Filament detected and loaded in gate %d" % self.gate_selected) + return + else: + # Full gate load if no gear sensor + for _ in range(self.preload_attempts): + self.log_always("Loading...") + try: + self._load_gate(allow_retry=False) + self._check_pending_spool_id(self.gate_selected) # Have spool_id ready? + self.log_always("Parking...") + _,_ = self._unload_gate() + self.log_always("Filament detected and parked in gate %d" % self.gate_selected) + return + except MmuError as ee: + # Exception just means filament is not loaded yet, so continue + self.log_trace("Exception on preload: %s" % str(ee)) + + if self.sensor_manager.check_gate_sensor(self.SENSOR_PRE_GATE_PREFIX, self.gate_selected): + self._set_gate_status(self.gate_selected, self.GATE_UNKNOWN) + self.log_warning("Filament detected by pre-gate %d sensor but did not complete preload" % self.gate_selected) + else: + self._set_gate_status(self.gate_selected, self.GATE_EMPTY) + raise MmuError("Filament not detected") + + # Eject final clear of gate. Important for MMU's where filament is always gripped (e.g. most type-B) + def _eject_from_gate(self, gate=None): + # If gate not specified assume current gate + if gate is None: + gate = self.gate_selected + else: + self.select_gate(gate) + self.selector.filament_drive() + + self.log_always("Ejecting...") + if self.sensor_manager.has_gate_sensor(self.SENSOR_GEAR_PREFIX, gate): + endstop_name = self.sensor_manager.get_gate_sensor_name(self.SENSOR_GEAR_PREFIX, gate) + msg = "Reverse homing off %s sensor" % endstop_name + actual,homed,measured,_ = self.trace_filament_move(msg, -self.gate_homing_max, motor="gear", homing_move=-1, endstop_name=endstop_name) + if homed: + self.log_debug("Endstop %s reached after %.1fmm (measured %.1fmm)" % (endstop_name, actual, measured)) + else: + raise MmuError("Filament did not exit gate homing sensor: %s" % endstop_name) + + if self.gate_final_eject_distance > 0: + msg = "Ejecting filament out of gate" + if self.sensor_manager.check_gate_sensor(self.SENSOR_PRE_GATE_PREFIX, gate) is not None: + # Use homing move so we don't "over eject" + self.trace_filament_move(msg, -self.gate_final_eject_distance, motor="gear", homing_move=-1, endstop_name=self.SENSOR_PRE_GATE_PREFIX, wait=True) + else: + self.trace_filament_move(msg, -self.gate_final_eject_distance, wait=True) + + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED, silent=True) # Should already be in this position + self._set_gate_status(gate, self.GATE_EMPTY) + self.log_always("The filament in gate %d can be removed" % gate) + + # Load filament into gate. This is considered the starting position for the rest of the filament loading + # process. Note that this may overshoot the home position for the "encoder" technique but subsequent + # bowden move will accommodate. Also for systems with gate sensor and encoder with gate sensor first, + # there will be a gap in encoder readings that must be taken into consideration. + # Return the overshoot past homing point + def _load_gate(self, allow_retry=True): + self._validate_gate_config("load") + self._set_filament_direction(self.DIRECTION_LOAD) + self.selector.filament_drive() + retries = self.gate_load_retries if allow_retry else 1 + + if self.gate_homing_endstop == self.SENSOR_ENCODER: + with self._require_encoder(): + measured = 0. + for i in range(retries): + msg = "Initial load into encoder" if i == 0 else ("Retry load into encoder (reetry #%d)" % i) + _,_,m,_ = self.trace_filament_move(msg, self.gate_homing_max) + measured += m + if m > 6.0: + self._set_gate_status(self.gate_selected, max(self.gate_status[self.gate_selected], self.GATE_AVAILABLE)) # Don't reset if filament is buffered + self._set_filament_pos_state(self.FILAMENT_POS_START_BOWDEN) + return measured + else: + self.log_debug("Error loading filament - filament motion was not detected by the encoder. %s" % ("Retrying..." if i < retries - 1 else "")) + if i < retries - 1: + self.selector.filament_release() + self.selector.filament_drive() + + else: # Gate sensor... SENSOR_GATE is shared, but SENSOR_GEAR_PREFIX is specific + for i in range(retries): + endstop_name = self.sensor_manager.get_mapped_endstop_name(self.gate_homing_endstop) + msg = ("Initial homing to %s sensor" % endstop_name) if i == 0 else ("Retry homing to gate sensor (retry #%d)" % i) + h_dir = -1 if self.gate_parking_distance < 0 and self.sensor_manager.check_sensor(endstop_name) else 1 # Reverse home? + actual,homed,measured,_ = self.trace_filament_move(msg, h_dir * self.gate_homing_max, motor="gear", homing_move=h_dir, endstop_name=endstop_name) + if homed: + self.log_debug("Endstop %s reached after %.1fmm (measured %.1fmm)" % (endstop_name, actual, measured)) + self._set_gate_status(self.gate_selected, max(self.gate_status[self.gate_selected], self.GATE_AVAILABLE)) # Don't reset if filament is buffered + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_GATE) + return 0. + else: + self.log_debug("Error loading filament - filament did not reach gate homing sensor. %s" % ("Retrying..." if i < retries - 1 else "")) + if i < retries - 1: + self.selector.filament_release() + self.selector.filament_drive() + + self._set_gate_status(self.gate_selected, self.GATE_EMPTY) + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + msg = "Couldn't pick up filament at gate" + if self.gate_homing_endstop == self.SENSOR_ENCODER: + msg += " (encoder didn't report enough movement)" + else: + msg += " (gate endstop didn't trigger)" + msg += "\nGate marked as empty. Use 'MMU_GATE_MAP GATE=%d AVAILABLE=1' to reset" % self.gate_selected + raise MmuError(msg) + + # Unload filament through gate to final MMU park position. + # Strategies include use of encoder or homing to gate/gear endstop and then parking + # Allows the overriding of homing_max for slow unloads when we are unsure of filament position + # Returns the amount of homing performed to aid calibration + def _unload_gate(self, homing_max=None): + self._validate_gate_config("unload") + self._set_filament_direction(self.DIRECTION_UNLOAD) + self.selector.filament_drive() + full = homing_max == self.calibration_manager.get_bowden_length() + homing_max = homing_max or self.gate_homing_max + + if full: # Means recovery operation + # Safety step because this method is used as a defensive way to unload the entire bowden from unknown position + # It handles the cases of filament still in extruder with no toolhead sensor or, if toolhead sensor is available, + # the small window where filament is between extruder entrance and toolhead sensor + homing_max += self.gate_homing_max # Full bowden may not be quite enough + length = self.toolhead_extruder_to_nozzle + if self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD): + length -= self.toolhead_sensor_to_nozzle # Can safely reduce the base move distance because starting point in toolhead sensor + length += self.toolhead_unload_safety_margin # Add safety margin + + self.log_debug("Performing synced pre-unload bowden move of %.1fmm to ensure filament is not trapped in extruder" % length) + if self.gate_homing_endstop == self.SENSOR_ENCODER: + _,_,_,_ = self.trace_filament_move("Bowden safety pre-unload move", -length, motor="gear+extruder") + else: + endstop_name = self.sensor_manager.get_mapped_endstop_name(self.gate_homing_endstop) + actual,homed,_,_ = self.trace_filament_move("Bowden safety pre-unload move", -length, motor="gear+extruder", homing_move=-1, endstop_name=endstop_name) + # In case we ended up homing during the safety pre-unload, lets just do our parking and be done + # This can easily happen when your parking distance is configured to park the filament past the + # gate sensor instead of behind the gate sensor and the filament position is determined to be + # "somewhere in the bowden tube" + if homed: + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_GATE) + self.trace_filament_move("Final parking", -self.gate_parking_distance) + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + return actual, self.gate_unload_buffer + + if self.gate_homing_endstop == self.SENSOR_ENCODER: + with self._require_encoder(): + if full: + self.log_info("Slowly unloading bowden because unsure of filament position...") + else: + self.log_trace("Unloading gate using the encoder") + success = self._reverse_home_to_encoder(homing_max) + if success: + actual,park,_ = success + _,_,measured,_ = self.trace_filament_move("Final parking", -park) + # We don't expect any movement of the encoder unless it is free-spinning + if measured > self.encoder_min: # We expect 0, but relax the test a little (allow one pulse) + self.log_warning("Warning: Possible encoder malfunction (free-spinning) during final filament parking") + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + return actual, self.gate_unload_buffer + msg = "did not clear the encoder after moving %.1fmm" % homing_max + + else: # Using mmu_gate or mmu_gear_N sensor + endstop_name = self.sensor_manager.get_mapped_endstop_name(self.gate_homing_endstop) + actual,homed,_,_ = self.trace_filament_move("Reverse homing off %s sensor" % endstop_name, -homing_max, motor="gear", homing_move=-1, endstop_name=endstop_name) + if homed: + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_GATE) + self.trace_filament_move("Final parking", -self.gate_parking_distance) + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + return actual, self.gate_unload_buffer + msg = "did not home to sensor '%s' after moving %1.fmm" % (self.gate_homing_endstop, homing_max) + + raise MmuError("Failed to unload gate because %s" % msg) + + # Shared with manual bowden calibration routine + def _reverse_home_to_encoder(self, homing_max): + max_steps = int(math.ceil(homing_max / self.encoder_move_step_size)) + delta = 0. + actual = 0. + for i in range(max_steps): + msg = "Unloading step #%d from encoder" % (i+1) + sactual,_,_,sdelta = self.trace_filament_move(msg, -self.encoder_move_step_size) + delta += sdelta + actual -= sactual + # Large enough delta here means we are out of the encoder + if sdelta >= self.encoder_move_step_size * 0.2: # 20 % + actual -= sdelta + park = self.gate_parking_distance - sdelta # will be between 8 and 20mm (for 23mm gate_parking_distance, 15mm step) + return actual, park, delta + self.log_debug("Filament did not clear encoder even after moving %.1fmm" % (self.encoder_move_step_size * max_steps)) + return None + + # Shared gate functions to deduplicate logic + def _validate_gate_config(self, direction): + if self.gate_homing_endstop == self.SENSOR_ENCODER: + if not self.has_encoder(): + raise MmuError("Attempting to %s encoder but encoder is not configured on MMU!" % direction) + elif self.gate_homing_endstop in self.GATE_ENDSTOPS: + sensor = self.gate_homing_endstop + if self.gate_homing_endstop == self.SENSOR_GEAR_PREFIX: + sensor += "_%d" % self.gate_selected + if not self.sensor_manager.has_sensor(sensor): + raise MmuError("Attempting to %s gate but sensor '%s' is not configured on MMU!" % (direction, sensor)) + else: + raise MmuError("Unsupported gate endstop %s" % self.gate_homing_endstop) + + # Fast load of filament in bowden, usually the full length but if 'full' is False a specific length can be specified + # Note that filament position will be measured from the gate "parking position" and so will be the gate_parking_distance + # plus any overshoot. The start of the bowden move is from the parking homing point. + # Returns ratio of measured movement to real movement IF it is "clean" and could be used for auto-calibration else 0 + def _load_bowden(self, length=None, start_pos=0.): + bowden_length = self.calibration_manager.get_bowden_length() + if length is None: + length = bowden_length + if bowden_length > 0 and not self.calibrating: + length = min(length, bowden_length) # Cannot exceed calibrated distance + full = length == bowden_length + + # Compensate for distance already moved for gate homing endstop (e.g. overshoot after encoder based gate homing) + length -= start_pos + + try: + # Do we need to reduce by buffer amount to ensure we don't overshoot homing sensor + deficit = 0. + if full: + if self._must_buffer_extruder_homing(): + deficit = self.extruder_homing_buffer + # Further reduce to compensate for distance from extruder sensor to extruder entry gear + deficit -= self.toolhead_entry_to_extruder if self.extruder_homing_endstop == self.SENSOR_EXTRUDER_ENTRY else 0 + length -= deficit # Reduce fast move distance + + if length > 0: + self.log_debug("Loading bowden tube") + self._set_filament_direction(self.DIRECTION_LOAD) + self.selector.filament_drive() + self._set_filament_pos_state(self.FILAMENT_POS_START_BOWDEN) + + # Record starting position for bowden progress tracking. Prefer encoder if available + self.bowden_start_pos = (self.get_encoder_distance(dwell=None) if self.has_encoder() else self._get_live_filament_position()) - start_pos + + if self.gate_selected > 0 and self.rotation_distances[self.gate_selected] <= 0: + self.log_warning("Warning: gate %d not calibrated! Using default rotation distance" % self.gate_selected) + + # "Fast" load + _,_,_,delta = self.trace_filament_move("Fast loading move through bowden", length, track=True, encoder_dwell=bool(self.autotune_rotation_distance)) + delta -= self._get_encoder_dead_space() + ratio = (length - delta) / length + + # Encoder based validation test + if self._can_use_encoder() and delta >= length * (self.bowden_move_error_tolerance / 100.) and not self.calibrating: + raise MmuError("Failed to load bowden. Perhaps filament is stuck in gate. Gear moved %.1fmm, Encoder measured %.1fmm" % (length, length - delta)) + + # Encoder based validation test + if self._can_use_encoder() and delta >= self.bowden_allowable_load_delta and not self.calibrating: + ratio = 0. # Not considered valid for auto-calibration + # Correction attempts to load the filament according to encoder reporting + if self.bowden_apply_correction: + for i in range(2): + if delta >= self.bowden_allowable_load_delta: + msg = "Correction load move #%d into bowden" % (i+1) + _,_,_,d = self.trace_filament_move(msg, delta, track=True) + delta = d + self.log_debug("Correction load move was necessary, encoder now measures %.1fmm" % self.get_encoder_distance()) + else: + self.log_debug("Correction load complete, delta %.1fmm is less than 'bowden_allowable_unload_delta' (%.1fmm)" % (delta, self.bowden_allowable_load_delta)) + break + self._set_filament_pos_state(self.FILAMENT_POS_IN_BOWDEN) + if delta >= self.bowden_allowable_load_delta: + self.log_warning("Warning: Excess slippage was detected in bowden tube load afer correction moves. Gear moved %.1fmm, Encoder measured %.1fmm. See mmu.log for more details"% (length, length - delta)) + else: + self.log_warning("Warning: Excess slippage was detected in bowden tube load but 'bowden_apply_correction' is disabled. Gear moved %.1fmm, Encoder measured %.1fmm. See mmu.log for more details" % (length, length - delta)) + + if delta >= self.bowden_allowable_load_delta: + self.log_debug("Possible causes of slippage:\nCalibration ref length too long (hitting extruder gear before homing)\nCalibration ratio for gate is not accurate\nMMU gears are not properly gripping filament\nEncoder reading is inaccurate\nFaulty servo") + + self._random_failure() # Testing + self.movequeues_wait() + else: + # No bowden movement required + ratio = 1. + + if full: + self._set_filament_pos_state(self.FILAMENT_POS_END_BOWDEN) + elif self.filament_pos != self.FILAMENT_POS_IN_BOWDEN: + self._set_filament_pos_state(self.FILAMENT_POS_IN_BOWDEN) + ratio = 0. + return ratio, deficit # For auto-calibration + finally: + self.bowden_start_pos = None + + # Fast unload of filament from exit of extruder gear (end of bowden) to position close to MMU (gate_unload_buffer away) + def _unload_bowden(self, length=None): + bowden_length = self.calibration_manager.get_bowden_length() + if length is None: + length = bowden_length + if bowden_length > 0 and not self.calibrating: + length = min(length, bowden_length) # Cannot exceed calibrated distance + full = length == bowden_length + + # Shorten move by gate buffer used to ensure we don't overshoot homing point + length -= self.gate_unload_buffer + + try: + if length > 0: + self.log_debug("Unloading bowden tube") + self._set_filament_direction(self.DIRECTION_UNLOAD) + self.selector.filament_drive() + + # Optional pre-unload safety step + if (full and self.has_encoder() and self.bowden_pre_unload_test and + self.sensor_manager.check_sensor(self.SENSOR_EXTRUDER_ENTRY) is not False and + self.sensor_manager.check_all_sensors_before(self.FILAMENT_POS_START_BOWDEN, self.gate_selected, loading=False) is not False + ): + with self._require_encoder(): + self.log_debug("Performing bowden pre-unload test") + _,_,_,delta = self.trace_filament_move("Bowden pre-unload test", -self.encoder_move_step_size) + if delta > self.encoder_move_step_size * (self.bowden_pre_unload_error_tolerance / 100.): + self._set_filament_pos_state(self.FILAMENT_POS_EXTRUDER_ENTRY) + raise MmuError("Bowden pre-unload test failed. Filament seems to be stuck in the extruder or filament not loaded\nOptionally use MMU_RECOVER to recover filament position") + length -= self.encoder_move_step_size + + self._set_filament_pos_state(self.FILAMENT_POS_IN_BOWDEN) + + # Record starting position for bowden progress tracking. Prefer encoder if available + self.bowden_start_pos = self.get_encoder_distance(dwell=None) if self.has_encoder() else self._get_live_filament_position() + + # Sensor validation + if self.sensor_manager.check_all_sensors_before(self.FILAMENT_POS_START_BOWDEN, self.gate_selected, loading=False) is False: + sensors = self.sensor_manager.get_all_sensors() + sensor_msg = '' + sname = [] + for name, state in sensors.items(): + sensor_msg += "%s (%s), " % (name.upper(), "Disabled" if state is None else ("Detected" if state is True else "Empty")) + if state is False: + sname.append(name) + self.log_warning("Warning: Possible sensor malfunction - %s sensor indicated no filament present prior to unloading bowden\nWill ignore and attempt to continue..." % ", ".join(sname)) + self.log_debug("Sensor state: %s" % sensor_msg) + + # "Fast" unload + _,_,_,delta = self.trace_filament_move("Fast unloading move through bowden", -length, track=True, encoder_dwell=bool(self.autotune_rotation_distance)) + delta -= self._get_encoder_dead_space() + ratio = (length - delta) / length + + # Encoder based validation test + if self._can_use_encoder() and delta >= self.bowden_allowable_unload_delta and not self.calibrating: + ratio = 0. + # Only a warning because _unload_gate() will deal with it + self.log_warning("Warning: Excess slippage was detected in bowden tube unload. Gear moved %.1fmm, Encoder measured %.1fmm" % (length, length - delta)) + + self._random_failure() # Testing + self.movequeues_wait() + else: + # No bowden movement required + ratio = 1. + + if full: + self._set_filament_pos_state(self.FILAMENT_POS_START_BOWDEN) + elif self.filament_pos != self.FILAMENT_POS_IN_BOWDEN: + self._set_filament_pos_state(self.FILAMENT_POS_IN_BOWDEN) + ratio = 0. + return ratio # For auto-calibration + + finally: + self.bowden_start_pos = None + + # Optionally home filament to designated homing location at the extruder + # Returns any homing distance and extra movement for automatic calibration logic + # or None if not applicable + def _home_to_extruder(self, max_length): + self._set_filament_direction(self.DIRECTION_LOAD) + self.selector.filament_drive() + measured = extra = 0. + homing_movement = None + + if self.extruder_homing_endstop == self.SENSOR_EXTRUDER_NONE: + homed = True + + elif self.extruder_homing_endstop == self.SENSOR_EXTRUDER_COLLISION: + if self.has_encoder(): + actual,homed,measured,_ = self._home_to_extruder_collision_detection(max_length) + homing_movement = actual + else: + raise MmuError("Cannot home to extruder using 'collision' method because encoder is not configured or disabled!") + + else: + self.log_debug("Homing to extruder '%s' endstop, up to %.1fmm" % (self.extruder_homing_endstop, max_length)) + actual,homed,measured,_ = self.trace_filament_move("Homing filament to extruder endstop", max_length, motor="gear", homing_move=1, endstop_name=self.extruder_homing_endstop) + if homed: + self.log_debug("Extruder endstop '%s' reached after %.1fmm (measured %.1fmm)" % (self.extruder_homing_endstop, actual, measured)) + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_ENTRY) + + # Make adjustment based on sensor: extruder - move a little move, compression - back off a little + if self.extruder_homing_endstop == self.SENSOR_EXTRUDER_ENTRY: + extra = self.toolhead_entry_to_extruder + _,_,measured,_ = self.trace_filament_move("Aligning filament to extruder gear", extra, motor="gear") + elif self.extruder_homing_endstop == self.SENSOR_COMPRESSION: + # We don't actually back off because the buffer absorbs the overrun but we still report for calibration + extra = -(self.sync_feedback_manager.sync_feedback_buffer_range / 2.) + + homing_movement = actual + + if not homed: + self._set_filament_pos_state(self.FILAMENT_POS_END_BOWDEN) + raise MmuError("Failed to reach extruder '%s' endstop after moving %.1fmm" % (self.extruder_homing_endstop, max_length)) + + if measured > (max_length * 0.8): + self.log_warning("Warning: 80%% of 'extruder_homing_max' was used homing. You may want to increase 'extruder_homing_max'") + + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_EXTRUDER) + return homing_movement, extra + + # Special extruder homing option for detecting the collision base on lack of encoder movement + def _home_to_extruder_collision_detection(self, max_length): + # Lock the extruder stepper + stepper_enable = self.printer.lookup_object('stepper_enable') + ge = stepper_enable.lookup_enable(self.mmu_extruder_stepper.stepper.get_name()) + ge.motor_enable(self.toolhead.get_last_move_time()) + + step = self.extruder_collision_homing_step * math.ceil(self.encoder_resolution * 10) / 10 + self.log_debug("Homing to extruder gear, up to %.1fmm in %.1fmm steps" % (max_length, step)) + + with self.wrap_gear_current(self.extruder_collision_homing_current, "for collision detection"): + homed = False + measured = delta = 0. + i = 0 + for i in range(int(max_length / step)): + msg = "Homing step #%d" % (i+1) + _,_,smeasured,sdelta = self.trace_filament_move(msg, step, speed=self.gear_homing_speed) + measured += smeasured + delta += sdelta + if sdelta >= self.encoder_min or abs(delta) > step: # Not enough or strange measured movement means we've hit the extruder + homed = True + measured -= step # Subtract the last step to improve accuracy + break + self.log_debug("Extruder entrance%s found after %.1fmm move (%d steps), encoder measured %.1fmm (delta %.1fmm)" + % (" not" if not homed else "", step*(i+1), i+1, measured, delta)) + + if delta > 5.0: + self.log_warning("Warning: A lot of slippage was detected whilst homing to extruder, you may want to reduce 'extruder_collision_homing_current' and/or ensure a good grip on filament by gear drive") + + self._set_filament_position(self._get_filament_position() - step) # Ignore last step movement + return step*i, homed, measured, delta + + # Move filament from the extruder gears (entrance) to the nozzle + # Returns any homing distance for automatic calibration logic + def _load_extruder(self, extruder_only=False): + with self.wrap_action(self.ACTION_LOADING_EXTRUDER): + self.log_debug("Loading filament into extruder") + self._set_filament_direction(self.DIRECTION_LOAD) + + # Important to wait for filaments with wildy different print temps. In practice, the time taken + # to perform a swap should be adequate to reach the target temp but better safe than sorry + self._ensure_safe_extruder_temperature(wait=True) + homing_movement = None + + has_tension = self.sensor_manager.has_sensor(self.SENSOR_TENSION) + has_compression = self.sensor_manager.has_sensor(self.SENSOR_COMPRESSION) + has_proportional = self.sensor_manager.has_sensor(self.SENSOR_PROPORTIONAL) + has_toolhead = self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD) + + synced = not extruder_only + if synced: + self.selector.filament_drive() + speed = self.extruder_sync_load_speed + motor = "gear+extruder" + else: + self.selector.filament_release() + speed = self.extruder_load_speed + motor = "extruder" + + fhomed = False + if has_toolhead: + # With toolhead sensor for accuracy we always first home to toolhead sensor past the extruder entrance + # The remaining load distance is relative to the toolhead sensor + if self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD): + raise MmuError("Possible toolhead sensor malfunction - filament detected before it entered extruder") + self.log_debug("Homing up to %.1fmm to toolhead sensor%s" % (self.toolhead_homing_max, (" (synced)" if synced else ""))) + actual,fhomed,measured,_ = self.trace_filament_move("Homing to toolhead sensor", self.toolhead_homing_max, motor=motor, homing_move=1, endstop_name=self.SENSOR_TOOLHEAD) + if fhomed: + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_TS) + homing_movement = max(actual - (self.toolhead_extruder_to_nozzle - self.toolhead_sensor_to_nozzle), 0) + else: + if self.gate_selected != self.TOOL_GATE_BYPASS: + self._set_filament_pos_state(self.FILAMENT_POS_EXTRUDER_ENTRY) # But could also still be POS_IN_BOWDEN! + else: + # For bypass its best to assume we didn't enter the extruder at all + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + raise MmuError("Failed to reach toolhead sensor after moving %.1fmm" % self.toolhead_homing_max) + + # Length may be reduced by previous unload in filament cutting use case. Ensure reduction is used only one time + d = self.toolhead_sensor_to_nozzle if has_toolhead else self.toolhead_extruder_to_nozzle + length = max(d - self.filament_remaining - self.toolhead_residual_filament - self.toolhead_ooze_reduction - self.toolchange_retract, 0) + + # If we have a compression sensor indicating compression we can detect failure in the critical extruder entrance transition + # by performing the initial load with just the extruder motor and checking that the sensor un-triggers before continuing + if ( + self.gate_selected != self.TOOL_GATE_BYPASS + and self.toolhead_entry_tension_test + and synced + and not has_toolhead + and self.sensor_manager.check_sensor(self.SENSOR_COMPRESSION) + ): + max_range = self.sync_feedback_manager.sync_feedback_buffer_maxrange * 2 # Arbitary but buffer_maxrange is not enough to overcome bowden slack + if length > max_range: + self.log_debug("Monitoring extruder entrance transition for up to %.1fmm..." % max_range) + actual,success = self.sync_feedback_manager.adjust_filament_tension(use_gear_motor=False, max_move=max_range) + if success: + length -= actual + else: + self._set_filament_pos_state(self.FILAMENT_POS_EXTRUDER_ENTRY) # But could also still be POS_IN_BOWDEN! + raise MmuError("Failed to load filament passed the extruder entrance (sync-feedback buffer didn't detect neutral tension)") + + self.log_debug("Loading last %.1fmm to the nozzle..." % length) + _,_,measured,delta = self.trace_filament_move("Loading filament to nozzle", length, speed=speed, motor=motor, wait=True) + self._set_filament_remaining(0.) + + # Encoder based validation test to validate the filament was picked up by extruder. This runs if we are + # short of deterministic sensors and test makes sense + if ( + self.gate_selected != self.TOOL_GATE_BYPASS + and self._can_use_encoder() + and not fhomed + and not extruder_only + ): + self.log_debug("Total measured movement: %.1fmm, total delta: %.1fmm" % (measured, delta)) + if measured < self.encoder_min: + raise MmuError("Move to nozzle failed (encoder didn't sense any movement). Extruder may not have picked up filament or filament did not find homing sensor") + elif delta > length * (self.toolhead_move_error_tolerance / 100.): + self._set_filament_pos_state(self.FILAMENT_POS_IN_EXTRUDER) + raise MmuError("Move to nozzle failed (encoder didn't sense sufficient movement). Extruder may not have picked up filament or filament did not find homing sensor") + + # Make post load filament tension adjustments for reliability. If encoder is fitted, the "post_load_tighten" + # will aid reliability in subsequent clog detection (and takes prescedence), else "post_load_tention_adjust" will try + # to neutralize the filament tension. Don't run on bypass gate. + if ( + self.gate_selected != self.TOOL_GATE_BYPASS + and not extruder_only + ): + if ( + self.toolhead_post_load_tighten + and not self.sync_to_extruder + and self._can_use_encoder() + and self.sync_feedback_manager.flowguard_encoder_mode + ): + # Tightening move to prevent erroneous encoder clog detection/runout if gear stepper is not synced with extruder + with self.wrap_gear_current(percent=50, reason="to tighten filament in bowden"): + # Filament will already be gripped so perform fixed MMU only retract + pullback = min(self.encoder_sensor.get_clog_detection_length() * self.toolhead_post_load_tighten / 100, 15) # % of current clog detection length or 15mm min + _,_,measured,delta = self.trace_filament_move("Tighening filament in bowden", -pullback, motor="gear", wait=True) + self.log_info("Filament tightened by %.1fmm to prevent false clog detection" % pullback) + + elif ( + self.toolhead_post_load_tension_adjust + and (self.sync_to_extruder or self.sync_purge) + and (has_tension or has_compression or has_proportional) + and self.sync_feedback_manager.is_enabled() + ): + # Try to put filament in neutral tension by centering between sensors + # Two methods are available based on switch only sensors or proportional feedback + actual,success = self.sync_feedback_manager.adjust_filament_tension() + if success: + self.log_info("Filament tension in bowden successfully relaxed") + else: + self.log_warning("Unsuccessful in relaxing filament tension after adjusting %.1fmm" % actual) + + self._random_failure() # Testing + self.movequeues_wait() + self._set_filament_pos_state(self.FILAMENT_POS_LOADED) + self.log_debug("Filament should be loaded to nozzle") + return homing_movement # Will only have value if we have toolhead sensor + + # Extract filament past extruder gear (to end of bowden). Assume that tip has already been formed + # and we are parked somewhere in the extruder either by slicer or by stand alone tip creation + # But be careful: + # A poor tip forming routine or slicer could have popped the filament out of the extruder already + # Ending point is either the exit of the extruder or at the extruder (entry) endstop if fitted + # Return True if we were synced + def _unload_extruder(self, extruder_only=False, validate=True): + with self.wrap_action(self.ACTION_UNLOADING_EXTRUDER): + self.log_debug("Extracting filament from extruder") + self._set_filament_direction(self.DIRECTION_UNLOAD) + + self._ensure_safe_extruder_temperature(wait=False) + + synced = self.selector.get_filament_grip_state() == self.FILAMENT_DRIVE_STATE and not extruder_only + if synced: + self.selector.filament_drive() + speed = self.extruder_sync_unload_speed + motor = "gear+extruder" + else: + self.selector.filament_release() + speed = self.extruder_unload_speed + motor = "extruder" + + fhomed = False + if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY) and not extruder_only: + # BEST Strategy: Extruder exit movement leveraging extruder entry sensor. Must be synced + synced = True + self.selector.filament_drive() + speed = self.extruder_sync_unload_speed + motor = "gear+extruder" + + if not self.sensor_manager.check_sensor(self.SENSOR_EXTRUDER_ENTRY): + if self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD): + raise MmuError("Toolhead or extruder sensor failure. Extruder sensor reports no filament but toolhead sensor is still triggered") + else: + self.log_warning("Warning: Filament was not detected by extruder (entry) sensor at start of extruder unload\nWill attempt to continue...") + fhomed = True # Assumption + else: + hlength = self.toolhead_extruder_to_nozzle + self.toolhead_entry_to_extruder + self.toolhead_unload_safety_margin - self.toolhead_residual_filament - self.toolhead_ooze_reduction - self.toolchange_retract + self.log_debug("Reverse homing up to %.1fmm off extruder sensor (synced) to exit extruder" % hlength) + _,fhomed,_,_ = self.trace_filament_move("Reverse homing off extruder sensor", -hlength, motor=motor, homing_move=-1, endstop_name=self.SENSOR_EXTRUDER_ENTRY) + + if not fhomed: + raise MmuError("Failed to reach extruder entry sensor after moving %.1fmm" % hlength) + else: + validate = False + # We know exactly where end of filament is so true up + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_ENTRY) + self._set_filament_position(-(self.toolhead_extruder_to_nozzle + self.toolhead_entry_to_extruder)) + + # TODO There have been reports of this failing, perhaps because of klipper's late update of sensor state? Maybe query_endstop instead + # So former MmuError() has been changed to error message + if self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD): + self.log_warning("Warning: Toolhead sensor still reports filament is present in toolhead! Possible sensor malfunction\nWill attempt to continue...") + + else: + if self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD): + # NEXT BEST: With toolhead sensor we first home to toolhead sensor. Optionally synced + if not self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD): + self.log_warning("Warning: Filament was not detected in extruder by toolhead sensor at start of extruder unload\nWill attempt to continue...") + fhomed = True # Assumption + else: + hlength = self.toolhead_sensor_to_nozzle + self.toolhead_unload_safety_margin - self.toolhead_residual_filament - self.toolhead_ooze_reduction - self.toolchange_retract + self.log_debug("Reverse homing up to %.1fmm off toolhead sensor%s" % (hlength, (" (synced)" if synced else ""))) + _,fhomed,_,_ = self.trace_filament_move("Reverse homing off toolhead sensor", -hlength, motor=motor, homing_move=-1, endstop_name=self.SENSOR_TOOLHEAD) + if not fhomed: + raise MmuError("Failed to reach toolhead sensor after moving %.1fmm" % hlength) + else: + validate = False + # We know exactly where end of filament is so true up + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_TS) + self._set_filament_position(-self.toolhead_sensor_to_nozzle) + + # Finish up with regular extruder exit movement. Optionally synced + length = max(0, self.toolhead_extruder_to_nozzle + self._get_filament_position()) + self.toolhead_unload_safety_margin + self.log_debug("Unloading last %.1fmm to exit the extruder%s" % (length, " (synced)" if synced else "")) + _,_,measured,delta = self.trace_filament_move("Unloading extruder", -length, speed=speed, motor=motor, wait=True) + + # Best guess of filament position is right at extruder entrance or just beyond if synced + if synced: + self._set_filament_position(-(self.toolhead_extruder_to_nozzle + self.toolhead_unload_safety_margin)) + else: + self._set_filament_position(-self.toolhead_extruder_to_nozzle) + + # Encoder based validation test if it has high chance of being useful + # NOTE: This check which used to raise MmuError() is triping many folks up because they have poor tip forming + # logic so just log error and continue. This disguises the root cause problem but will make folks happier + # Not performed for slicer tip forming (validate=True) because everybody is ejecting the filament! + if validate and self._can_use_encoder() and length > self.encoder_move_step_size and not extruder_only and self.gate_selected != self.TOOL_GATE_BYPASS: + self.log_debug("Total measured movement: %.1fmm, total delta: %.1fmm" % (measured, delta)) + msg = None + if measured < self.encoder_min: + msg = "any" + elif synced and delta > length * (self.toolhead_move_error_tolerance / 100.): + msg = "suffient" + if msg: + self.log_warning("Warning: Encoder not sensing %s movement during final extruder retraction move\nConcluding filament either stuck in the extruder, tip forming erroneously completely ejected filament or filament was not fully loaded\nWill attempt to continue..." % msg) + + self._set_filament_pos_state(self.FILAMENT_POS_END_BOWDEN) + + self._random_failure() # Testing + self.movequeues_wait() + self.log_debug("Filament should be out of extruder") + return synced + + +############################################## +# LOAD / UNLOAD SEQUENCES AND FILAMENT TESTS # +############################################## + + def load_sequence(self, bowden_move=None, skip_extruder=False, purge=None, extruder_only=False): + self.movequeues_wait() + + bowden_length = self.calibration_manager.get_bowden_length() # -1 if not calibrated + if bowden_move is None: + bowden_move = bowden_length + + if bowden_move > bowden_length and bowden_length >= 0: + bowden_move = bowden_length + self.log_warning("Warning: Restricting bowden load length to calibrated value of %.1fmm" % bowden_length) + + full = bowden_move == bowden_length + calibrating = bowden_length < 0 and not extruder_only + macros_and_track = not extruder_only and full + + self._set_filament_direction(self.DIRECTION_LOAD) + self._initialize_filament_position(dwell=None) + + try: + home = False + if not extruder_only: + current_action = self._set_action(self.ACTION_LOADING) + if full: + home = self._must_home_to_extruder() or calibrating + else: + skip_extruder = True + + if macros_and_track: + self._track_time_start('load') + # PRE_LOAD user defined macro + with self._wrap_track_time('pre_load'): + self.wrap_gcode_command(self.pre_load_macro, exception=True, wait=True) + + self.log_info("Loading %s..." % ("extruder" if extruder_only else "filament")) + if not extruder_only: + self._display_visual_state() + + homing_movement = None # Track how much homing is done for calibrated bowden length optimization + deficit = 0. # Amount of homing that would be expected (because bowden load is shortened) + bowden_move_ratio = 0. # Track mismatch in moved vs measured bowden distance + overshoot = 0. + calibrated_bowden_length = None + start_filament_pos = self.filament_pos + + # Note: Conditionals deliberately coded this way to match macro alternative + if self.gcode_load_sequence and not calibrating: + self.log_debug("Calling external user defined loading sequence macro") + self.wrap_gcode_command("%s FILAMENT_POS=%d LENGTH=%.1f FULL=%d HOME_EXTRUDER=%d SKIP_EXTRUDER=%d EXTRUDER_ONLY=%d" % (self.load_sequence_macro, start_filament_pos, bowden_move, int(full), int(home), int(skip_extruder), int(extruder_only)), exception=True) + + elif extruder_only: + if start_filament_pos < self.FILAMENT_POS_EXTRUDER_ENTRY: + _ = self._load_extruder(extruder_only=True) + else: + self.log_debug("Assertion failure: Unexpected state %d in load_sequence(extruder_only=True)" % start_filament_pos) + raise MmuError("Cannot load extruder because already in extruder. Unload first") + + elif start_filament_pos >= self.FILAMENT_POS_EXTRUDER_ENTRY: + self.log_debug("Assertion failure: Unexpected state %d in load_sequence()" % start_filament_pos) + raise MmuError("Cannot load because already in extruder. Unload first") + + else: + if start_filament_pos <= self.FILAMENT_POS_UNLOADED: + overshoot = self._load_gate() + + if calibrating: + if self.extruder_homing_endstop in [self.SENSOR_EXTRUDER_NONE, self.SENSOR_EXTRUDER_COLLISION]: + raise MmuError("Auto calibration is not possible with 'extruder_homing_endstop: %s'" % self.SENSOR_EXTRUDER_NONE) + + self.log_warning("Auto calibrating bowden length on gate %d using %s as gate reference point" % (self.gate_selected, self._gate_homing_string())) + if self.sensor_manager.check_sensor(self.extruder_homing_endstop): + raise MmuError("The %s sensor triggered before homing. Check filament and sensor operation" % self.extruder_homing_endstop) + + hm, extra = self._home_to_extruder(self.bowden_homing_max) + if hm is None: + raise MmuError("Failed to auto calibrate bowden because unable to home to extruder after moving %.1fmm" % self.bowden_homing_max) + + calibrated_bowden_length = overshoot + hm + extra + else: + if start_filament_pos < self.FILAMENT_POS_END_BOWDEN: + bowden_move_ratio, deficit = self._load_bowden(bowden_move, start_pos=overshoot) + + if start_filament_pos < self.FILAMENT_POS_HOMED_EXTRUDER and home: + hm, _ = self._home_to_extruder(self.extruder_homing_max) + if hm is not None: + homing_movement = (homing_movement or 0) + hm + + if not skip_extruder: + hm = self._load_extruder() + if hm is not None: + homing_movement = (homing_movement or 0) + hm + + self.movequeues_wait() + msg = "Load of %.1fmm filament successful" % self._get_filament_position() + if self._can_use_encoder(): + final_encoder_pos = self.get_encoder_distance(dwell=None) + not_seen = self.gate_parking_distance + self._get_encoder_dead_space() + msg += " {1}(adjusted encoder: %.1fmm){0}" % (final_encoder_pos + not_seen) + self.log_info(msg, color=True) + + # Notify manager if calibrating/autotuning + if calibrating: + self.calibration_manager.update_bowden_length(calibrated_bowden_length, console_msg=True) + cdl = self.calibration_manager.calc_clog_detection_length(calibrated_bowden_length) + self.calibration_manager.update_clog_detection_length(cdl, force=True) + + elif full and not extruder_only and not self.gcode_load_sequence: + self.calibration_manager.note_load_telemetry(bowden_move_ratio, homing_movement, deficit) + + # Activate loaded spool in Spoolman + self._spoolman_activate_spool(self.gate_spool_id[self.gate_selected]) + + # Deal with purging + if purge == self.PURGE_SLICER and not skip_extruder: + self.log_debug("Purging expected to be performed by slicer") + + elif purge == self.PURGE_STANDALONE and not skip_extruder: + with self._wrap_track_time('purge'): + + # Restore the expected sync state now before running this macro + self.reset_sync_gear_to_extruder(not extruder_only and self.sync_purge) + + with self.wrap_action(self.ACTION_PURGING): + self.purge_standalone() + + # POST_LOAD user defined macro + if macros_and_track: + with self._wrap_track_time('post_load'): + + # Restore the expected sync state now before running this macro + # (we also must force correction of filament grip for old blobifer/unsynced functionality) + self.reset_sync_gear_to_extruder(not extruder_only and self.sync_purge, force_grip=True) + + if self.has_blobifier: # Legacy blobifer integration. purge_macro now preferred + with self.wrap_action(self.ACTION_PURGING): + self.wrap_gcode_command(self.post_load_macro, exception=True, wait=True) + else: + self.wrap_gcode_command(self.post_load_macro, exception=True, wait=True) + + except MmuError as ee: + self._track_gate_statistics('load_failures', self.gate_selected) + raise MmuError("Load sequence failed because:\n%s" % (str(ee))) + + finally: + self._track_gate_statistics('loads', self.gate_selected) + + if not extruder_only: + self._set_action(current_action) + + if macros_and_track: + self._track_time_end('load') + + def unload_sequence(self, bowden_move=None, check_state=False, form_tip=None, extruder_only=False): + self.movequeues_wait() + + bowden_length = self.calibration_manager.get_bowden_length() # -1 if not calibrated yet + if bowden_length < 0: + bowden_length = self.bowden_homing_max # Special case - if not calibrated then apply the max possible bowden length + + if bowden_move is None: + bowden_move = bowden_length + + if bowden_move > bowden_length and bowden_length >= 0: + bowden_move = bowden_length + self.log_warning("Warning: Restricting bowden unload length to calibrated value of %.1fmm" % bowden_length) + + calibrated = bowden_move >= 0 + full = bowden_move == bowden_length + macros_and_track = not extruder_only and full + runout = self.is_handling_runout + + self._set_filament_direction(self.DIRECTION_UNLOAD) + self._initialize_filament_position(dwell=None) + + if check_state or self.filament_pos == self.FILAMENT_POS_UNKNOWN: + # Let's determine where filament is and reset state before continuing + self.recover_filament_pos(message=True) + + if self.filament_pos == self.FILAMENT_POS_UNLOADED: + self.log_debug("Filament already ejected") + return + + try: + if not extruder_only: + current_action = self._set_action(self.ACTION_UNLOADING) + + # Deactivate spool immediately before tip forming/cutting + # Tip forming/cutting macros use the extruder to execute, hence + # any retraction / de retraction moves are accounted in Spoolman. + # By de-activating early, the retraction performed from the macro + # is deliberately not accounted in spoolman + self._spoolman_activate_spool(0) + + # Run PRE_UNLOAD user defined macro + if macros_and_track: + self._track_time_start('unload') + with self._wrap_track_time('pre_unload'): + self.wrap_gcode_command(self.pre_unload_macro, exception=True, wait=True) + + self.log_info("Unloading %s..." % ("extruder" if extruder_only else "filament")) + if not extruder_only: + self._display_visual_state() + + synced_extruder_unload = False + park_pos = 0. + do_form_tip = form_tip if form_tip is not None else self.FORM_TIP_STANDALONE # Default to standalone + if do_form_tip == self.FORM_TIP_SLICER: + # Slicer was responsible for the tip, but the user must set the slicer_tip_park_pos + park_pos = self.slicer_tip_park_pos + self._set_filament_position(-park_pos) + if park_pos == 0.: + self.log_error("Tip forming performed by slicer but 'slicer_tip_park_pos' not set") + else: + self.log_debug("Tip forming performed by slicer, park_pos set to %.1fmm" % park_pos) + + elif do_form_tip == self.FORM_TIP_STANDALONE and (self.filament_pos >= self.FILAMENT_POS_IN_EXTRUDER or runout): + with self._wrap_track_time('form_tip'): + # Extruder only in runout case to give filament best chance to reach gear + detected = self.form_tip_standalone(extruder_only=(extruder_only or runout)) + park_pos = self._get_filament_position() + + # If handling runout warn if we don't see any filament near the gate + if runout and ( + self.sensor_manager.check_any_sensors_before(self.FILAMENT_POS_HOMED_GATE, self.gate_selected) is False or + (self.has_encoder() and self.get_encoder_distance() == 0) + ): + self.log_warning("Warning: Filament not seen near gate after tip forming move. Unload may not be possible") + + self.wrap_gcode_command(self.post_form_tip_macro, exception=True, wait=True) + + # Note: Conditionals deliberately coded this way to match macro alternative + homing_movement = None # Track how much homing is done for calibrated bowden length optimization + deficit = 0. # Amount of homing that would be expected (because bowden load is shortened) + bowden_move_ratio = 0. # Track mismatch in moved vs measured bowden distance + start_filament_pos = self.filament_pos + unload_to_buffer = (start_filament_pos >= self.FILAMENT_POS_END_BOWDEN and not extruder_only) + + if self.gcode_unload_sequence and calibrated: + self.log_debug("Calling external user defined unloading sequence macro") + self.wrap_gcode_command( + "%s FILAMENT_POS=%d LENGTH=%.1f EXTRUDER_ONLY=%d PARK_POS=%.1f" % ( + self.unload_sequence_macro, + start_filament_pos, + bowden_move, + extruder_only, + park_pos + ), + exception=True + ) + + elif extruder_only: + if start_filament_pos >= self.FILAMENT_POS_EXTRUDER_ENTRY: + synced_extruder_unload = self._unload_extruder(extruder_only=True, validate=do_form_tip == self.FORM_TIP_STANDALONE) + else: + self.log_debug("Assertion failure: Unexpected state %d in unload_sequence(extruder_only=True)" % start_filament_pos) + raise MmuError("Cannot unload extruder because filament not detected in extruder!") + + elif start_filament_pos == self.FILAMENT_POS_UNLOADED: + self.log_debug("Assertion failure: Unexpected state %d in unload_sequence()" % start_filament_pos) + raise MmuError("Cannot unload because already unloaded!") + + else: + if start_filament_pos >= self.FILAMENT_POS_EXTRUDER_ENTRY: + # Exit extruder, fast unload of bowden, then slow unload to gate + synced_extruder_unload = self._unload_extruder(validate=do_form_tip == self.FORM_TIP_STANDALONE) + + if ( + (start_filament_pos >= self.FILAMENT_POS_END_BOWDEN and calibrated) or + (start_filament_pos >= self.FILAMENT_POS_HOMED_GATE and not full) + ): + # Fast unload of bowden, then unload gate + bowden_move_ratio = self._unload_bowden(bowden_move) + homing_movement, deficit = self._unload_gate() + + elif start_filament_pos >= self.FILAMENT_POS_HOMED_GATE: + # We have to do slow unload because we don't know exactly where we are. We use + # full bowden length or max possible length if bowden is uncalibrated + _,_ = self._unload_gate(homing_max=bowden_move if calibrated else self.bowden_homing_max) + + # Set future "from buffer" flag (also used for faster loading speed) + if unload_to_buffer and self.gate_status[self.gate_selected] != self.GATE_EMPTY: + self._set_gate_status(self.gate_selected, self.GATE_AVAILABLE_FROM_BUFFER) + + # If runout then over unload to prevent accidental reload + if runout: + self._eject_from_gate() + +# Currently disabled because it results in servo "flutter" that users don't like +# # Encoder based validation test +# if self._can_use_encoder(): +# movement = self.selector.filament_release(measure=True) +# if movement > self.encoder_min: +# self._set_filament_pos_state(self.FILAMENT_POS_UNKNOWN) +# self.log_trace("Encoder moved %.1fmm when filament was released!" % movement) +# raise MmuError("Encoder sensed movement when the servo was released\nConcluding filament is stuck somewhere") + + self.movequeues_wait() + msg = "Unload of %.1fmm filament successful" % self._get_filament_position() + if self._can_use_encoder(): + final_encoder_pos = self.get_encoder_distance(dwell=None) + not_seen = self.gate_parking_distance + self._get_encoder_dead_space() + (self.toolhead_unload_safety_margin if not synced_extruder_unload else 0.) + msg += " {1}(adjusted encoder: %.1fmm){0}" % -(final_encoder_pos + not_seen) + self.log_info(msg, color=True) + + # Notify autotune manager + if full and not extruder_only and not self.gcode_unload_sequence: + self.calibration_manager.note_unload_telemetry(bowden_move_ratio, homing_movement, deficit) + + # POST_UNLOAD user defined macro + if macros_and_track: + with self._wrap_track_time('post_unload'): + + # Restore the expected sync state now before running this macro + self.reset_sync_gear_to_extruder(not extruder_only and self.sync_to_extruder) + + if self.has_mmu_cutter: + with self.wrap_action(self.ACTION_CUTTING_FILAMENT): + self.wrap_gcode_command(self.post_unload_macro, exception=True, wait=True) + else: + self.wrap_gcode_command(self.post_unload_macro, exception=True, wait=True) + + except MmuError as ee: + self._track_gate_statistics('unload_failures', self.gate_selected) + raise MmuError("Unload sequence failed because:\n%s" % (str(ee))) + + finally: + self._track_gate_statistics('unloads', self.gate_selected) + + if not extruder_only: + self._set_action(current_action) + + if macros_and_track: + self._track_time_end('unload') + + # Form tip prior to extraction from the extruder. This can take the form of shaping the filament or could simply + # activate a filament cutting mechanism. Sets filament position based on park pos + # Returns True if filament is detected + def form_tip_standalone(self, extruder_only=False): + self.movequeues_wait() + + # Pre check to validate the presence of filament in the extruder and case where we don't need to form tip + filament_initially_present = self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD) + if filament_initially_present is False: + self.log_debug("Tip forming skipped because no filament was detected") + + if self.filament_pos == self.FILAMENT_POS_LOADED: + self._set_filament_pos_state(self.FILAMENT_POS_EXTRUDER_ENTRY) + else: + self._set_filament_pos_state(self.FILAMENT_POS_IN_BOWDEN) + + self._set_filament_position(-self.toolhead_extruder_to_nozzle) + return False + + gcode_macro = self.printer.lookup_object("gcode_macro %s" % self.form_tip_macro, None) + if gcode_macro is None: + raise MmuError("Filament tip forming macro '%s' not found" % self.form_tip_macro) + + with self.wrap_action(self.ACTION_CUTTING_TIP if self.has_toolhead_cutter else self.ACTION_FORMING_TIP): + sync = self.reset_sync_gear_to_extruder(not extruder_only and self.sync_form_tip) + self._ensure_safe_extruder_temperature(wait=True) + + # Perform the tip forming move and establish park_pos + initial_encoder_position = self.get_encoder_distance() + park_pos, remaining, reported = self._do_form_tip() + measured = self.get_encoder_distance(dwell=None) - initial_encoder_position + self._set_filament_remaining(remaining, self.gate_color[self.gate_selected] if self.gate_selected != self.TOOL_GATE_UNKNOWN else '') + + # Encoder based validation test + detected = True # Start with assumption that filament was present + if self._can_use_encoder() and not reported: + # Logic to try to validate success and update presence of filament based on movement + if filament_initially_present is True: + # With encoder we might be able to check for clog now + if not measured > self.encoder_min: + raise MmuError("No encoder movement: Concluding filament is stuck in extruder") + else: + # Couldn't determine if we initially had filament at start (lack of sensors) + if not measured > self.encoder_min: + # No movement. We can be confident we are/were empty + detected = False + elif sync: + # A further test is needed to see if the filament is actually in the extruder + detected, moved = self.test_filament_still_in_extruder_by_retracting() + park_pos += moved + + self._set_filament_position(-park_pos) + self.set_encoder_distance(initial_encoder_position + park_pos) + + if detected or extruder_only: + # Definitely in extruder + self._set_filament_pos_state(self.FILAMENT_POS_IN_EXTRUDER) + else: + # No detection. Best to assume we are somewhere in bowden for defensive unload + self._set_filament_pos_state(self.FILAMENT_POS_IN_BOWDEN) + + return detected + + def _do_form_tip(self, test=False): + with self._wrap_extruder_current(self.extruder_form_tip_current, "for tip forming move"): + initial_mcu_pos = self.mmu_extruder_stepper.stepper.get_mcu_position() + initial_encoder_position = self.get_encoder_distance() + + with self._wrap_pressure_advance(0., "for tip forming"): + gcode_macro = self.printer.lookup_object("gcode_macro %s" % self.form_tip_macro, "_MMU_FORM_TIP") + self.log_info("Forming tip...") + self.wrap_gcode_command("%s %s" % (self.form_tip_macro, "FINAL_EJECT=1" if test else ""), exception=True, wait=True) + + final_mcu_pos = self.mmu_extruder_stepper.stepper.get_mcu_position() + stepper_movement = (initial_mcu_pos - final_mcu_pos) * self.mmu_extruder_stepper.stepper.get_step_dist() + measured = self.get_encoder_distance(dwell=None) - initial_encoder_position + park_pos = gcode_macro.variables.get("output_park_pos", -1) + try: + park_pos = float(park_pos) + except ValueError as e: + self.log_error("Reported 'output_park_pos: %s' could not be parsed: %s" % (park_pos, str(e))) + park_pos = -1 + + reported = False + if park_pos < 0: + # Use stepper movement (tip forming) + filament_remaining = 0. + park_pos = stepper_movement + self.toolhead_residual_filament + self.toolchange_retract + msg = "After tip forming, extruder moved: %.1fmm thus park_pos calculated as %.1fmm (encoder measured %.1fmm total movement)" % (stepper_movement, park_pos, measured) + if test: + self.log_always(msg) + else: + self.log_trace(msg) + else: + # Means the macro reported it (filament cutting) + if park_pos == 0: + self.log_warning("Warning: output_park_pos was reported as 0mm and may not be set correctly\nWill attempt to continue...") + reported = True + filament_remaining = park_pos - stepper_movement - self.toolhead_residual_filament - self.toolchange_retract + msg = "After tip cutting, park_pos reported as: %.1fmm with calculated %.1fmm filament remaining in extruder (extruder moved: %.1fmm, encoder measured %.1fmm total movement)" % (park_pos, filament_remaining, stepper_movement, measured) + if test: + self.log_always(msg) + else: + self.log_trace(msg) + + if not test: + # Important sanity checks to spot misconfiguration + if park_pos > self.toolhead_extruder_to_nozzle: + self.log_warning("Warning: park_pos (%.1fmm) cannot be greater than 'toolhead_extruder_to_nozzle' distance of %.1fmm! Assumming fully unloaded from extruder\nWill attempt to continue..." % (park_pos, self.toolhead_extruder_to_nozzle)) + park_pos = self.toolhead_extruder_to_nozzle + filament_remaining = 0. + + if filament_remaining < 0: + self.log_warning("Warning: Calculated filament remaining after cut is negative (%.1fmm)! Suspect misconfiguration of output_park_pos (%.1fmm).\nWill attempt to continue assuming no cut filament remaining..." % (filament_remaining, park_pos)) + park_pos = 0. + filament_remaining = 0. + + return park_pos, filament_remaining, reported + + def purge_standalone(self): + if self.purge_macro: + gcode_macro = self.printer.lookup_object("gcode_macro %s" % self.purge_macro, None) + if gcode_macro: + self.log_info("Purging...") + with self._wrap_extruder_current(self.extruder_purge_current, "for filament purge"): + # The macro to decide on the purge volume, but expect to be based on this. + msg = "Suggested purge volume of %.1fmm%s calculated from:\n" % (self.toolchange_purge_volume, UI_CUBE) + msg += "- toolhead_residual_filament: %.1fmm\n" % self.toolhead_residual_filament + msg += "- filament_remaining (previous cut fragment): %.1fmm\n" % self.filament_remaining + msg += "- slicer purge volume for toolchange %s > %s" % (self.selected_tool_string(self._last_tool), self.selected_tool_string(self._next_tool)) + self.log_debug(msg) + self.wrap_gcode_command(self.purge_macro, exception=True, wait=True) + else: + self.log_warning("Purge macro %s not found" % self.purge_macro) + + +################################# +# FILAMENT MOVEMENT AND CONTROL # +################################# + + # Convenience wrapper around all gear and extruder motor movement that retains sync state, tracks movement and creates trace log + # motor = "gear" - gear motor(s) only on rail + # "gear+extruder" - gear and extruder included on rail + # "extruder" - extruder only on gear rail + # "synced" - gear synced with extruder as in print (homing move not possible) + # + # If homing move then endstop name can be specified. + # "mmu_gate" - at the gate on MMU (when motor includes "gear") + # "mmu_gear_N" - post past the filament drive gear + # "extruder" - just before extruder entrance (motor includes "gear" or "extruder") + # "toolhead" - after extruder entrance (motor includes "gear" or "extruder") + # "mmu_gear_touch" - stallguard on gear (when motor includes "gear", only useful for motor="gear") + # "mmu_ext_touch" - stallguard on nozzle (when motor includes "extruder", only useful for motor="extruder") + # + # All move distances are interpreted as relative + # 'wait' will wait on appropriate move queue(s) after completion of move (forced to True if need encoder reading) + # 'measure' whether we need to wait and measure encoder for movement + # 'encoder_dwell' delay some additional time to ensure we have accurate encoder reading (if encoder fitted and required for measuring) + # + # All moves return: actual (relative), homed, measured, delta; mmu_toolhead.get_position[1] holds absolute position + # + def trace_filament_move(self, trace_str, dist, speed=None, accel=None, motor="gear", homing_move=0, endstop_name="default", track=False, wait=False, encoder_dwell=False, speed_override=True): + encoder_start = self.get_encoder_distance(dwell=encoder_dwell) + pos = self.mmu_toolhead.get_position() + ext_pos = self.toolhead.get_position() + homed = False + actual = dist + delta = 0. + null_rtn = (0., False, 0., 0.) + + if homing_move != 0: + # Check for valid endstop + if endstop_name is None: + endstops = self.gear_rail.get_endstops() + else: + endstop_name = self.sensor_manager.get_mapped_endstop_name(endstop_name) + endstops = self.gear_rail.get_extra_endstop(endstop_name) + if endstops is None: + self.log_error("Endstop '%s' not found" % endstop_name) + return null_rtn + + # Set sensible speeds and accelaration if not supplied + if motor in ["gear"]: + if homing_move != 0: + speed = speed or self.gear_homing_speed + accel = accel or min(self.gear_from_buffer_accel, self.gear_from_spool_accel) + else: + if abs(dist) > self.gear_short_move_threshold: + if dist < 0: + speed = speed or self.gear_unload_speed + accel = accel or self.gear_unload_accel + elif (not self.has_filament_buffer or (self.gate_selected >= 0 and self.gate_status[self.gate_selected] != self.GATE_AVAILABLE_FROM_BUFFER)): + speed = speed or self.gear_from_spool_speed + accel = accel or self.gear_from_spool_accel + else: + speed = speed or self.gear_from_buffer_speed + accel = accel or self.gear_from_buffer_accel + else: + speed = speed or self.gear_short_move_speed + accel = accel or self.gear_short_move_accel + + elif motor in ["gear+extruder", "synced"]: + if homing_move != 0: + speed = speed or min(self.gear_homing_speed, self.extruder_homing_speed) + accel = accel or min(max(self.gear_from_buffer_accel, self.gear_from_spool_accel), self.extruder_accel) + else: + speed = speed or (self.extruder_sync_load_speed if dist > 0 else self.extruder_sync_unload_speed) + accel = accel or min(max(self.gear_from_buffer_accel, self.gear_from_spool_accel), self.extruder_accel) + + elif motor in ["extruder"]: + if homing_move != 0: + speed = speed or self.extruder_homing_speed + accel = accel or self.extruder_accel + else: + speed = speed or (self.extruder_load_speed if dist > 0 else self.extruder_unload_speed) + accel = accel or self.extruder_accel + + else: + self.log_error("Assertion failure: Invalid motor specification '%s'" % motor) + return null_rtn + + # Apply per-gate speed override + if self.gate_selected >= 0 and speed_override: + adjust = self.gate_speed_override[self.gate_selected] / 100. + speed *= adjust + accel *= adjust + + def _set_sync_mode(sync_mode): + self.mmu_toolhead.sync(sync_mode) + if sync_mode == MmuToolHead.GEAR_SYNCED_TO_EXTRUDER: + self._adjust_gear_current(percent=self.sync_gear_current, reason="for extruder synced move") + else: + self._restore_gear_current() # 100% + + with self._wrap_espooler(motor, dist, speed, accel, homing_move): + wait = wait or self._wait_for_espooler # Allow eSpooler wrapper to force wait + + # Gear rail is driving the filament + start_pos = self.mmu_toolhead.get_position()[1] + if motor in ["gear", "gear+extruder", "extruder"]: + _set_sync_mode(MmuToolHead.EXTRUDER_SYNCED_TO_GEAR if motor == "gear+extruder" else MmuToolHead.EXTRUDER_ONLY_ON_GEAR if motor == "extruder" else MmuToolHead.GEAR_ONLY) + if homing_move != 0: + trig_pos = [0., 0., 0., 0.] + hmove = HomingMove(self.printer, endstops, self.mmu_toolhead) + init_ext_mcu_pos = self.mmu_extruder_stepper.stepper.get_mcu_position() # For non-homing extruder or if extruder not on gear rail + init_pos = pos[1] + pos[1] += dist + for _ in range(self.canbus_comms_retries): # HACK: We can repeat because homing move + got_comms_timeout = False # HACK: Logic to try to mask CANbus timeout issues + try: + #initial_mcu_pos = self.mmu_extruder_stepper.stepper.get_mcu_position() + #init_pos = pos[1] + #pos[1] += dist + with self.wrap_accel(accel): + trig_pos = hmove.homing_move(pos, speed, probe_pos=True, triggered=homing_move > 0, check_triggered=True) + homed = True + if self.gear_rail.is_endstop_virtual(endstop_name): + # Stallguard doesn't do well at slow speed. Try to infer move completion + if abs(trig_pos[1] - dist) < 1.0: + homed = False + except self.printer.command_error as e: + # CANbus mcu's often seen to exhibit "Communication timeout" so surface errors to user + if abs(trig_pos[1] - dist) > 0. and "after full movement" not in str(e): + if 'communication timeout' in str(e).lower(): + got_comms_timeout = True + speed *= 0.8 # Reduce speed by 20% + self.log_error("Did not complete homing move: %s" % str(e)) + else: + if self.log_enabled(self.LOG_STEPPER): + self.log_stepper("Did not home: %s" % str(e)) + homed = False + finally: + halt_pos = self.mmu_toolhead.get_position() + ext_actual = (self.mmu_extruder_stepper.stepper.get_mcu_position() - init_ext_mcu_pos) * self.mmu_extruder_stepper.stepper.get_step_dist() + + # Support setup where a non-homing extruder is being used + if motor == "extruder" and not self.homing_extruder: + # This isn't super accurate if extruder isn't (homing) MmuExtruder because doesn't have required endstop, thus this will + # overrun and even move slightly even if already homed. We can only correct the actual gear rail position. + halt_pos[1] += ext_actual + self.mmu_toolhead.set_position(halt_pos) # Correct the gear rail position + + actual = halt_pos[1] - init_pos + if self.log_enabled(self.LOG_STEPPER): + self.log_stepper("%s HOMING MOVE: max dist=%.1f, speed=%.1f, accel=%.1f, endstop_name=%s, wait=%s >> %s" % ( + motor.upper(), dist, speed, accel, endstop_name, wait, + ( + "%s halt_pos=%.1f (rail moved=%.1f, extruder moved=%.1f), " + "start_pos=%.1f, trig_pos=%.1f" + % ( + "HOMED" if homed else "DID NOT HOMED", + halt_pos[1], actual, ext_actual, start_pos, trig_pos[1], + ) + ), + ) + ) + + if not got_comms_timeout: + break + else: + if self.log_enabled(self.LOG_STEPPER): + self.log_stepper("%s MOVE: dist=%.1f, speed=%.1f, accel=%.1f, wait=%s" % (motor.upper(), dist, speed, accel, wait)) + pos[1] += dist + with self.wrap_accel(accel): + self.mmu_toolhead.move(pos, speed) + + # Extruder is driving, gear rail is following + elif motor in ["synced"]: + _set_sync_mode(MmuToolHead.GEAR_SYNCED_TO_EXTRUDER) + if homing_move != 0: + self.log_error("Not possible to perform homing move while synced") + else: + if self.log_enabled(self.LOG_STEPPER): + self.log_stepper("%s MOVE: dist=%.1f, speed=%.1f, accel=%.1f, wait=%s" % (motor.upper(), dist, speed, accel, wait)) + ext_pos[3] += dist + self.toolhead.move(ext_pos, speed) + + self.mmu_toolhead.flush_step_generation() # TTC mitigation (TODO still required?) + self.toolhead.flush_step_generation() # TTC mitigation (TODO still required?) + if wait: + self.movequeues_wait() + + encoder_end = self.get_encoder_distance(dwell=encoder_dwell) + measured = encoder_end - encoder_start + delta = abs(actual) - measured # +ve means measured less than moved, -ve means measured more than moved + if trace_str: + if homing_move != 0: + trace_str += ". Stepper: '%s' %s after moving %.1fmm (of max %.1fmm), encoder measured %.1fmm (delta %.1fmm)" + trace_str = trace_str % (motor, ("homed" if homed else "did not home"), actual, dist, measured, delta) + trace_str += ". Pos: @%.1f, (%.1fmm)" % (self.mmu_toolhead.get_position()[1], encoder_end) + else: + trace_str += ". Stepper: '%s' moved %.1fmm, encoder measured %.1fmm (delta %.1fmm)" + trace_str = trace_str % (motor, dist, measured, delta) + trace_str += ". Pos: @%.1f, (%.1fmm)" % (self.mmu_toolhead.get_position()[1], encoder_end) + self.log_trace(trace_str) + + if self._can_use_encoder() and motor == "gear" and track: + if dist > 0: + self._track_gate_statistics('load_distance', self.gate_selected, dist) + self._track_gate_statistics('load_delta', self.gate_selected, delta) + else: + self._track_gate_statistics('unload_distance', self.gate_selected, -dist) + self._track_gate_statistics('unload_delta', self.gate_selected, delta) + if dist != 0: + quality = abs(1. - delta / dist) + cur_quality = self.gate_statistics[self.gate_selected]['quality'] + if cur_quality < 0: + self.gate_statistics[self.gate_selected]['quality'] = quality + else: + # Average down over 10 swaps + self.gate_statistics[self.gate_selected]['quality'] = (cur_quality * 9 + quality) / 10 + + return actual, homed, measured, delta + + # Used to force accelaration override for homing moves + @contextlib.contextmanager + def wrap_accel(self, accel): + self.mmu_toolhead.get_kinematics().set_accel_limit(accel) + try: + yield self + finally: + self.mmu_toolhead.get_kinematics().set_accel_limit(None) + + # Used to wrap certain unload moves and activate eSpooler. Ensures eSpooler is always stopped + @contextlib.contextmanager + def _wrap_espooler(self, motor, dist, speed, accel, homing_move): + self._wait_for_espooler = False + espooler_operation = self.ESPOOLER_OFF + + if self.has_espooler(): + pwm_value = 0 + if abs(dist) >= self.espooler_min_distance and speed > self.espooler_min_stepper_speed: + if dist > 0 and self.ESPOOLER_ASSIST in self.espooler_operations: + espooler_operation = self.ESPOOLER_ASSIST + elif dist < 0 and self.ESPOOLER_REWIND in self.espooler_operations: + espooler_operation = self.ESPOOLER_REWIND + + if espooler_operation == self.ESPOOLER_OFF: + pwm_value = 0 + elif speed >= self.espooler_max_stepper_speed: + pwm_value = 1 + else: + pwm_value = (speed / self.espooler_max_stepper_speed) ** self.espooler_speed_exponent + + # Reduce assist speed compared to rewind but also apply the "print" minimum + # We want rewind to be faster than assist but never non-functional + if espooler_operation == self.ESPOOLER_ASSIST: + pwm_value = max(pwm_value * (self.espooler_assist_reduced_speed / 100), self.espooler_printing_power / 100) + + if espooler_operation != self.ESPOOLER_OFF: + self._wait_for_espooler = not homing_move + self.espooler.set_operation(self.gate_selected, pwm_value, espooler_operation) + try: + # Note gate_selected doesn't change in this use case, it's just filament movement + yield self + + finally: + self._wait_for_espooler = False + if espooler_operation != self.ESPOOLER_OFF: + self.espooler.set_operation(self.gate_selected, 0, self.ESPOOLER_OFF) + + +############################################## +# GENERAL FILAMENT RECOVERY AND MOVE HELPERS # +############################################## + + # Report on need to recover and necessary calibration + def report_necessary_recovery(self, use_autotune=True): + if not self.check_if_not_calibrated(self.CALIBRATED_ALL, silent=None, use_autotune=use_autotune): + if self.filament_pos != self.FILAMENT_POS_UNLOADED and self.TOOL_GATE_UNKNOWN in [self.gate_selected, self.tool_selected]: + self.log_error("Filament detected but tool/gate is unknown. Plese use MMU_RECOVER GATE=xx to correct state") + elif self.filament_pos not in [self.FILAMENT_POS_LOADED, self.FILAMENT_POS_UNLOADED]: + self.log_error("Filament not detected as either unloaded or fully loaded. Please check and use MMU_RECOVER to correct state or fix before continuing") + + # This is a recovery routine to determine the most conservative location of the filament (for unload purposes) + # Also, ensures that the filament availabilty is updated if filament is found + def recover_filament_pos(self, strict=False, can_heat=True, message=False, silent=False): + if message: + self.log_info("Attempting to recover filament position...") + + ts = self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD) + es = self.sensor_manager.check_sensor(self.SENSOR_EXTRUDER_ENTRY) + gs = self.sensor_manager.check_sensor(self.sensor_manager.get_mapped_endstop_name(self.gate_homing_endstop)) + + filament_detected = self.sensor_manager.check_any_sensors_in_path() + looks_loaded = self.sensor_manager.check_all_sensors_in_path() + if not filament_detected: + filament_detected = self.check_filament_in_mmu() # Include encoder detection method + + # Definitely loaded + if ts: + self._set_filament_pos_state(self.FILAMENT_POS_LOADED, silent=silent) + + # Probably loaded: Unless strict we will continue to assume loaded in the absence of sensors to say otherwise + elif not strict and self.filament_pos == self.FILAMENT_POS_LOADED and looks_loaded: + pass + + # Somewhere in extruder + elif filament_detected and can_heat and self.check_filament_in_extruder(): # Encoder based + self._set_filament_pos_state(self.FILAMENT_POS_IN_EXTRUDER, silent=silent) # Will start from tip forming on unload + elif ts is False and filament_detected and (self.strict_filament_recovery or strict) and can_heat and self.check_filament_in_extruder(): + # This case adds an additional encoder based test to see if filament is still being gripped by extruder + # even though TS doesn't see it. It's a pedantic option so on turned on by strict flag + self._set_filament_pos_state(self.FILAMENT_POS_IN_EXTRUDER, silent=silent) # Will start from tip forming + + # At extruder entry + elif es: + self._set_filament_pos_state(self.FILAMENT_POS_HOMED_ENTRY, silent=silent) # Allows for fast bowden unload move + + # Parked at gate (when parking distance is not a retract i.e. gs sensor expected to be triggered) + elif gs and filament_detected and self.gate_parking_distance <= 0: + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED, silent=silent) + + # Somewhere in bowden + elif gs or filament_detected: + self._set_filament_pos_state(self.FILAMENT_POS_IN_BOWDEN, silent=silent) # Prevents fast bowden unload move + + # Sensor sanity check + if self.sensor_manager.check_all_sensors_before(self.FILAMENT_POS_HOMED_GATE, self.gate_selected, loading=False) is False: + sensors = self.sensor_manager.get_sensors_before(self.FILAMENT_POS_HOMED_GATE, self.gate_selected, loading=False) + malfunction = ", ".join(sorted(k for k, v in sensors.items() if v is False)) + self.log_warning("Filament determined to be somewhere in bowden but the following sensors are unexpectedly not triggered: %s\nCheck for further sensor malfunction with MMU_SENSORS command. Also validate the correct gate is selected.\nRe-run MMU_RECOVER when ready" % malfunction) + + # Unloaded + else: + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED, silent=silent) + + # If filament is detected then ensure gate status is correct + if self.gate_selected != self.TOOL_GATE_UNKNOWN and filament_detected: + gate_status = self.gate_status[self.gate_selected] + if self.filament_pos >= self.FILAMENT_POS_START_BOWDEN and gate_status < self.GATE_AVAILABLE: + self._set_gate_status(self.gate_selected, self.GATE_AVAILABLE) + elif gate_status == self.GATE_EMPTY: + self._set_gate_status(self.gate_selected, self.GATE_UNKNOWN) + + # Check for filament in MMU using available sensors or encoder + def check_filament_in_mmu(self): + self.log_debug("Checking for filament in MMU...") + detected = self.sensor_manager.check_any_sensors_in_path() + if not detected and self.has_encoder(): + self.selector.filament_drive() + detected = self.buzz_gear_motor() + self.log_debug("Filament %s in encoder after buzzing gear motor" % ("detected" if detected else "not detected")) + if detected is None: + self.log_debug("No sensors configured!") + return detected + + # Check for filament at currently selected gate + def check_filament_in_gate(self): + self.log_debug("Checking for filament at gate...") + detected = self.sensor_manager.check_any_sensors_before(self.FILAMENT_POS_HOMED_GATE, self.gate_selected) + if not detected and self.has_encoder(): + self.selector.filament_drive() + detected = self.buzz_gear_motor() + self.log_debug("Filament %s in encoder after buzzing gear motor" % ("detected" if detected else "not detected")) + if detected is None: + self.log_debug("No sensors configured!") + return detected + + # Return True if filament runout detected by sensors + def check_filament_runout(self): + self.log_debug("Checking for runout...") + runout = self.sensor_manager.check_for_runout() + if runout is None and self.has_encoder(): + self.selector.filament_drive() + detected = not self.buzz_gear_motor() + self.log_debug("Filament %s in encoder after buzzing gear motor" % ("detected" if detected else "not detected")) + runout = not detected + if runout is None: + self.log_debug("No sensors configured!") + return runout + + # Return True/False if detected or None if test not possible + def check_filament_in_extruder(self): + # First double check extruder entry sensor if fitted + es = self.sensor_manager.check_sensor(self.SENSOR_EXTRUDER_ENTRY) + if es is not None: + return es + + # Now toolhead if fitted + ts = self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD) + if ts is True: + return True + + # Finally resort to movement test with encoder + detected,_ = self.test_filament_still_in_extruder_by_retracting() + return detected + + # Check for filament in extruder by moving extruder motor. Even with toolhead sensor this can + # happen if the filament is in the short distance from sensor to gears. Requires encoder + # Return True/False if detected or None if test not possible + def test_filament_still_in_extruder_by_retracting(self): + detected = None + measured = 0 + if self.has_encoder() and not self.mmu_machine.filament_always_gripped: + with self._require_encoder(): # Force quality measurement + self.log_info("Checking for possibility of filament still in extruder gears...") + self._ensure_safe_extruder_temperature(wait=False) + self.selector.filament_release() + move = self.encoder_move_step_size + _,_,measured,_ = self.trace_filament_move("Checking extruder", -move, speed=self.extruder_unload_speed, motor="extruder") + detected = measured > self.encoder_min + self.log_debug("Filament %s in extruder" % ("detected" if detected else "not detected")) + return detected, measured + + def buzz_gear_motor(self): + if self.has_encoder(): + with self._require_encoder(): # Force quality measurement + initial_encoder_position = self.get_encoder_distance() + self.trace_filament_move(None, 2.5 * self.encoder_resolution, accel=self.gear_buzz_accel, encoder_dwell=None) + self.trace_filament_move(None, -2.5 * self.encoder_resolution, accel=self.gear_buzz_accel, encoder_dwell=None) + measured = self.get_encoder_distance() - initial_encoder_position + self.log_trace("After buzzing gear motor, encoder measured %.2f" % measured) + self.set_encoder_distance(initial_encoder_position, dwell=None) + return measured > self.encoder_min + else: + self.trace_filament_move(None, 5, accel=self.gear_buzz_accel) + self.trace_filament_move(None, -5, accel=self.gear_buzz_accel) + return None + + + def reset_sync_gear_to_extruder(self, sync_intention, force_grip=False, force_in_print=False, skip_extruder_check=False): + """ + Reset the gear-to-extruder sync state based on MMU type and current state. + + Args: + sync_intention (bool): + Desired sync state during printing, derived from parameters such as + `sync_to_extruder`, `sync_form_tip`, and `sync_purge`. + + force_grip (bool, optional): + If True, forces an immediate filament grip state change + (typically triggers a servo movement). + + force_in_print (bool, optional): + Forces the logic to behave as if the printer is currently in a print. + Primarily used for testing. + + skip_extruder_check (bool, optional): + Normally, syncing only occurs if filament is present in the extruder. + When True, this overrides that check. Used by the + `MMU_SYNC_GEAR_MOTOR` command. + + Returns: + bool: The final sync state that was applied. + """ + bypass_selected = self.gate_selected == self.TOOL_GATE_BYPASS + in_print_context = self.is_in_print(force_in_print) + actively_printing = self.is_printing(force_in_print) + + filament_past_entry = self.filament_pos >= self.FILAMENT_POS_EXTRUDER_ENTRY + extruder_check_ok = filament_past_entry or skip_extruder_check + + always_gripped = self.mmu_machine.filament_always_gripped + standalone_sync_requested = self._standalone_sync + + # In a non-print context we also honor the caller's explicit intention. + wants_sync_out_of_print = always_gripped or standalone_sync_requested or sync_intention + + if bypass_selected: + sync = False + + elif in_print_context: + if actively_printing: + # During active printing, respect the print-time sync setting. + sync = bool(self.sync_to_extruder) + else: + # In print context but not actively printing (e.g., paused/warming), + # sync only if filament is present (or overridden) and sync is needed/requested + sync = extruder_check_ok and (always_gripped or standalone_sync_requested) + + else: + # Not in a print: sync if filament is present (or overridden) and any + # condition requires/requests syncing. + sync = extruder_check_ok and wants_sync_out_of_print + + self.sync_gear_to_extruder(sync, force_grip=force_grip) + return sync + + + def sync_gear_to_extruder(self, sync, gate=None, force_grip=False): + """ + Sync or unsync the gear motor with the extruder, handling filament engagement and current control. + + This method: + - Applies safety guards to prevent syncing when bypass/unknown gates are selected or the + selector is not homed. + - Manages filament grip/release (especially important on type-A designs to avoid "buzz" + and to reduce servo flutter). + - Updates the toolhead sync mode. + - Adjusts gear stepper current while synced, and restores it when unsynced. On multigear + machines, it also ensures the previously-used gear stepper's current is restored when + switching gates. + + Args: + sync (bool): + True to sync the gear motor to the extruder; False to unsync. + + gate (Optional[int]): + Gate to apply current adjustments to. If None, defaults to `self.gate_selected`. + This is optional because on some type-B designs this method may be called before + `self.gate_selected` is finalized. + + force_grip (bool, optional): + If True, forces a filament release action when unsyncing even if release suppression + is enabled higher in the call stack. + + Returns: + None + """ + # Default to current selection; some designs call this before gate selection is finalized. + if gate is None: + gate = self.gate_selected + + bypass_or_unknown_gate = gate < 0 + selector_ready = getattr(self.selector, "is_homed", False) + + # Protect cases where we should not sync (type-B always has a homed selector). + if bypass_or_unknown_gate or not selector_ready: + sync = False + self._standalone_sync = False + + # Filament grip handling (do this before syncing to avoid "buzz" movement on type-A MMUs). + if sync: + self.selector.filament_drive() + else: + # There are situations where we want to be lazy to avoid servo "flutter": + # - `_suppress_release_grip` is True unless we are the outermost caller. + # - `force_grip` can override that suppression. + should_release = force_grip or not self._suppress_release_grip + if should_release: + self.selector.filament_release() + + # Sync / unsync toolhead mode (avoid redundant calls). + desired_sync_mode = MmuToolHead.GEAR_SYNCED_TO_EXTRUDER if sync else None + if desired_sync_mode != self.mmu_toolhead.sync_mode: + self.movequeues_wait() # Safety: likely unnecessary but ensures no queued moves conflict. + self.mmu_toolhead.sync(desired_sync_mode) + + # Current control: + # - While synced, optionally reduce current for the active gear stepper. + # - On multigear systems, restore current on the previously-used gear stepper if gate differs. + # - When unsynced, restore current to 100%. + if sync: + if self.mmu_machine.multigear and gate != self.gate_selected: + self._restore_gear_current() # Restore previous gear stepper to 100% + + self._adjust_gear_current( + gate=gate, + percent=self.sync_gear_current, + reason="for extruder syncing", + ) + else: + self._restore_gear_current() # 100% + + + @contextlib.contextmanager + def wrap_sync_gear_to_extruder(self): + """ + Context manager that protects gear/extruder synchronization, current, + and filament grip state. + + This is intended to be used as the outermost wrapper around "MMU_*" + command handlers that may temporarily alter sync, motor current, or + grip state during a print or standalone operation. + + Behavior: + - Captures the current sync state before entering the context. + - Suppresses filament release in nested calls to prevent servo + "flutter" caused by repeated grip/release transitions. + - Ensures only the outermost wrapper clears suppression. + - Restores the previous sync state on exit (via + `reset_sync_gear_to_extruder`), allowing normal logic to + reconcile grip and current safely. + + Yields: + self: Allows the caller to operate within the protected context. + Example: + with self.wrap_sync_gear_to_extruder(): + self.sync_gear_to_extruder(True) + ... + """ + # Capture current sync state so it can be restored on exit. + previous_sync = ( + self.mmu_toolhead.sync_mode == MmuToolHead.GEAR_SYNCED_TO_EXTRUDER + ) + + # Suppress grip release only at the outermost level. + outermost_wrapper = not self._suppress_release_grip + self._suppress_release_grip = True + + try: + yield self + finally: + # Only the outermost wrapper clears suppression. + if outermost_wrapper: + self._suppress_release_grip = False + + # Restore prior sync state. Logic inside reset_sync_gear_to_extruder + # may consult the global suppression flag when reconciling grip. + self.reset_sync_gear_to_extruder(previous_sync) + + + # ---------- TMC Stepper Current Control ---------- + + @contextlib.contextmanager + def wrap_gear_current(self, percent=100, reason=""): + """ + Run block of logic with gear stepper current set to desired percentage and then + restore to previous setting + """ + prev_percent = self._adjust_gear_current(percent=percent, reason=reason) + self._gear_current_locked = True + try: + yield self + finally: + self._gear_current_locked = False + self._restore_gear_current(percent=prev_percent) + + def _adjust_gear_current(self, gate=None, percent=100, reason="", restore=False): + current_percent = self.gear_percentage_run_current + + if self._gear_current_locked: return current_percent + if gate is None: gate = self.gate_selected + if gate < 0: return current_percent + if not (0 < percent < 200): return current_percent + if not self.gear_tmc: return current_percent + if percent == self.gear_percentage_run_current: return current_percent + + gear_stepper_name = mmu_machine.GEAR_STEPPER_CONFIG + if self.mmu_machine.multigear and gate > 0: + gear_stepper_name = "%s_%d" % (mmu_machine.GEAR_STEPPER_CONFIG, gate) + if restore: + msg = "Restoring MMU %s run current to %d%% ({}A)" % (gear_stepper_name, percent) + else: + msg = "Modifying MMU %s run current to %d%% ({}A) %s" % (gear_stepper_name, percent, reason) + target_current = (self.gear_default_run_current * percent) / 100.0 + self._set_tmc_current(gear_stepper_name, target_current, msg) + self.gear_percentage_run_current = percent # Update global record of current % + return percent + + def _restore_gear_current(self, gate=None, percent=100): + _ = self._adjust_gear_current(gate=gate, percent=percent, restore=True) + + @contextlib.contextmanager + def _wrap_extruder_current(self, percent=100, reason=""): + prev_percent = self._adjust_extruder_current(percent, reason) + try: + yield self + finally: + self._restore_extruder_current(percent=prev_percent) + + def _adjust_extruder_current(self, percent=100, reason="", restore=False): + current_percent = self.extruder_percentage_run_current + + if not self.extruder_tmc: return current_percent + if not (0 < percent < 200): return current_percent + if percent == self.extruder_percentage_run_current: return current_percent + + if restore: + msg = "Restoring extruder stepper run current to %d%% ({}A)" + else: + msg = "Modifying extruder stepper run current to %d%% ({}A) %s" % (percent, reason) + self._set_tmc_current(self.extruder_name, (self.extruder_default_run_current * percent) / 100., msg) + self.extruder_percentage_run_current = percent # Update global record of current % + return percent + + def _restore_extruder_current(self, percent=100): + _ = self._adjust_extruder_current(percent=percent, restore=True) + + def _set_tmc_current(self, stepper, run_current, msg): + self.log_info(msg.format("%.2f" % run_current)) + self.gcode.run_script_from_command("SET_TMC_CURRENT STEPPER=%s CURRENT=%.2f" % (stepper, run_current)) + + + # ---------- Pressure Advance Control ---------- + + @contextlib.contextmanager + def _wrap_pressure_advance(self, pa=0, reason=""): + extruder = self.toolhead.get_extruder() + initial_pa = extruder.get_status(0).get('pressure_advance') + + if initial_pa is None: + yield self + return + + try: + if reason: + self.log_debug("Setting pressure advance %s: %.4f" % (reason, pa)) + self._set_pressure_advance(pa) + + yield self + + finally: + if reason: + self.log_debug("Restoring pressure advance: %.4f" % initial_pa) + self._set_pressure_advance(initial_pa) + + def _set_pressure_advance(self, pa): + self.gcode.run_script_from_command("SET_PRESSURE_ADVANCE ADVANCE=%.4f QUIET=1" % pa) + + + # Logic shared with MMU_TEST_MOVE and _MMU_STEP_MOVE + def _move_cmd(self, gcmd, trace_str, allow_bypass=False): + if self.check_if_disabled(): return (0., False, 0., 0.) + if not allow_bypass and self.check_if_bypass(): return (0., False, 0., 0.) + move = gcmd.get_float('MOVE', 100.) + speed = gcmd.get_float('SPEED', None) + accel = gcmd.get_float('ACCEL', None) + motor = gcmd.get('MOTOR', "gear") + wait = bool(gcmd.get_int('WAIT', 1, minval=0, maxval=1)) # Wait for move to complete (make move synchronous) + if motor not in ["gear", "extruder", "gear+extruder", "synced"]: + raise gcmd.error("Valid motor names are 'gear', 'extruder', 'gear+extruder' or 'synced'") + if motor == "extruder": + self.selector.filament_release() + else: + self.selector.filament_drive() + self.log_debug("Moving '%s' motor %.1fmm..." % (motor, move)) + return self.trace_filament_move(trace_str, move, speed=speed, accel=accel, motor=motor, wait=wait) + + # Logic shared with MMU_TEST_HOMING_MOVE and _MMU_STEP_HOMING_MOVE + def _homing_move_cmd(self, gcmd, trace_str, allow_bypass=False): + if self.check_if_disabled(): return (0., False, 0., 0.) + if not allow_bypass and self.check_if_bypass(): return (0., False, 0., 0.) + endstop = gcmd.get('ENDSTOP', "default") + move = gcmd.get_float('MOVE', 100.) + speed = gcmd.get_float('SPEED', None) + accel = gcmd.get_float('ACCEL', None) # Ignored for extruder led moves + motor = gcmd.get('MOTOR', "gear") + if motor not in ["gear", "extruder", "gear+extruder"]: + raise gcmd.error("Valid motor names are 'gear', 'extruder', 'gear+extruder'") + direction = -1 if move < 0 else 1 + stop_on_endstop = gcmd.get_int('STOP_ON_ENDSTOP', direction, minval=-1, maxval=1) + if abs(stop_on_endstop) != 1: + raise gcmd.error("STOP_ON_ENDSTOP can only be 1 (extrude direction) or -1 (retract direction)") + endstop = self.sensor_manager.get_mapped_endstop_name(endstop) + valid_endstops = list(self.gear_rail.get_extra_endstop_names()) + if endstop not in valid_endstops: + raise gcmd.error("Endstop name '%s' is not valid for motor '%s'. Options are: %s" % (endstop, motor, ', '.join(valid_endstops))) + if self.gear_rail.is_endstop_virtual(endstop) and stop_on_endstop == -1: + raise gcmd.error("Cannot reverse home on virtual (TMC stallguard) endstop '%s'" % endstop) + if motor == "extruder": + self.selector.filament_release() + else: + self.selector.filament_drive() + self.log_debug("Homing '%s' motor to '%s' endstop, up to %.1fmm..." % (motor, endstop, move)) + return self.trace_filament_move(trace_str, move, speed=speed, accel=accel, motor=motor, homing_move=stop_on_endstop, endstop_name=endstop) + + +############################ +# TOOL SELECTION FUNCTIONS # +############################ + + def _record_tool_override(self): + tool = self.tool_selected + if tool >= 0: + current_speed_factor = self.gcode_move.get_status(0)['speed_factor'] + current_extrude_factor = self.gcode_move.get_status(0)['extrude_factor'] + if self.tool_speed_multipliers[tool] != current_speed_factor or self.tool_extrusion_multipliers[tool] != current_extrude_factor: + self.tool_speed_multipliers[tool] = current_speed_factor + self.tool_extrusion_multipliers[tool] = current_extrude_factor + self.log_debug("Saved speed/extrusion multiplier for tool T%d as %d%% and %d%%" % (tool, current_speed_factor * 100, current_extrude_factor * 100)) + + def _restore_tool_override(self, tool): + if tool == self.tool_selected: + current_speed_factor = self.gcode_move.get_status(0)['speed_factor'] + current_extrude_factor = self.gcode_move.get_status(0)['extrude_factor'] + speed_factor = self.tool_speed_multipliers[tool] + extrude_factor = self.tool_extrusion_multipliers[tool] + self.gcode.run_script_from_command("M220 S%d" % (speed_factor * 100)) + self.gcode.run_script_from_command("M221 S%d" % (extrude_factor * 100)) + if current_speed_factor != speed_factor or current_extrude_factor != extrude_factor: + self.log_debug("Restored speed/extrusion multiplier for tool T%d as %d%% and %d%%" % (tool, speed_factor * 100, extrude_factor * 100)) + + def _set_tool_override(self, tool, speed_percent, extrude_percent): + if tool == -1: + for i in range(self.num_gates): + if speed_percent is not None: + self.tool_speed_multipliers[i] = speed_percent / 100 + if extrude_percent is not None: + self.tool_extrusion_multipliers[i] = extrude_percent / 100 + self._restore_tool_override(i) + if speed_percent is not None: + self.log_debug("Set speed multiplier for all tools as %d%%" % speed_percent) + if extrude_percent is not None: + self.log_debug("Set extrusion multiplier for all tools as %d%%" % extrude_percent) + else: + if speed_percent is not None: + self.tool_speed_multipliers[tool] = speed_percent / 100 + self.log_debug("Set speed multiplier for tool T%d as %d%%" % (tool, speed_percent)) + if extrude_percent is not None: + self.tool_extrusion_multipliers[tool] = extrude_percent / 100 + self.log_debug("Set extrusion multiplier for tool T%d as %d%%" % (tool, extrude_percent)) + self._restore_tool_override(tool) + + # Primary method to select and loads tool. Assumes we are unloaded + def _select_and_load_tool(self, tool, purge=None): + + try: + # Determine purge volume for toolchange/load. Valid only during toolchange/load operation + self.toolchange_purge_volume = self._calc_purge_volume(self._last_tool, tool) + + self.log_debug('Loading tool %s...' % self.selected_tool_string(tool)) + self.select_tool(tool) + gate = self.ttg_map[tool] if tool >= 0 else self.gate_selected + if self.gate_status[gate] == self.GATE_EMPTY: + if self.endless_spool_enabled and self.endless_spool_on_load: + next_gate, msg = self._get_next_endless_spool_gate(tool, gate) + if next_gate == -1: + raise MmuError("Gate %d is empty!\nNo alternatives gates available after checking %s" % (gate, msg)) + + self.log_error("Gate %d is empty! Checking for alternative gates %s" % (gate, msg)) + self.log_info("Remapping %s to gate %d" % (self.selected_tool_string(tool), next_gate)) + self._remap_tool(tool, next_gate) + self.select_tool(tool) + else: + raise MmuError("Gate %d is empty (and EndlessSpool on load is disabled)\nLoad gate, remap tool to another gate or correct state with 'MMU_CHECK_GATE GATE=%d' or 'MMU_GATE_MAP GATE=%d AVAILABLE=1'" % (gate, gate, gate)) + + self.load_sequence(purge=purge) + self._restore_tool_override(self.tool_selected) # Restore M220 and M221 overrides + + finally: + self.toolchange_purge_volume = 0. + + # Primary method to unload current tool but retain selection + def _unload_tool(self, form_tip=None, prev_tool=None): + if self.filament_pos == self.FILAMENT_POS_UNLOADED: + self.log_info("Tool already unloaded") + return + + self.log_debug("Unloading tool %s" % self.selected_tool_string()) + # Use the actual tool that was in use *before* this toolchange began + # Falls back to current selection if not provided (backwards compatible) + self._set_last_tool(self.tool_selected if prev_tool is None else prev_tool) + self._record_tool_override() # Remember M220 and M221 overrides + self.unload_sequence(form_tip=form_tip) + + def _auto_home(self, tool=0): + if not self.selector.is_homed or self.tool_selected == self.TOOL_GATE_UNKNOWN: + self.log_info("MMU selector not homed, will home before continuing") + self.home(tool) + elif self.filament_pos == self.FILAMENT_POS_UNKNOWN and self.selector.is_homed: + self.recover_filament_pos(message=True) + + # Important to always inform use of "toolchange" operation is case there is an error and manual recovery is necessary + def _note_toolchange(self, m117_msg): + self._last_toolchange = m117_msg + if self.log_m117_messages: + self.gcode.run_script_from_command("M117 %s" % m117_msg) + + # Tell the sequence macros about where to move to next + def _set_next_position(self, next_pos): + if next_pos: + self.wrap_gcode_command("SET_GCODE_VARIABLE MACRO=%s VARIABLE=next_xy VALUE=%s,%s" % (self.park_macro, next_pos[0], next_pos[1])) + self.wrap_gcode_command("SET_GCODE_VARIABLE MACRO=%s VARIABLE=next_pos VALUE=True" % self.park_macro) + else: + self.wrap_gcode_command("SET_GCODE_VARIABLE MACRO=%s VARIABLE=next_pos VALUE=False" % self.park_macro) + + +### TOOL AND GATE SELECTION ###################################################### + + def home(self, tool, force_unload = None): + if self.check_if_bypass(): return + self._set_tool_selected(self.TOOL_GATE_UNKNOWN) + self.selector.home(force_unload=force_unload) + if tool >= 0: + self.select_tool(tool) + elif tool == self.TOOL_GATE_BYPASS: + self.select_bypass() + + def select_gate(self, gate): + try: + + if gate == self.gate_selected: + self.selector.select_gate(gate) # Always give selector a chance to fix position + else: + self._next_gate = gate # Valid only during the gate selection process + _prev_gate = self.gate_selected + self.selector.select_gate(gate) + self._set_gate_selected(gate) + self.led_manager.gate_map_changed(_prev_gate) + self.led_manager.gate_map_changed(gate) + + except MmuError as ee: + self.unselect_gate() + raise ee + finally: + self._next_gate = None + + def unselect_gate(self): + self.selector.select_gate(self.TOOL_GATE_UNKNOWN) # Required for type-B MMU's to unsync + self._set_gate_selected(self.TOOL_GATE_UNKNOWN) + + def select_tool(self, tool): + if tool < 0 or tool >= self.num_gates: + self.log_always("Tool %s does not exist" % self.selected_tool_string(tool)) + return + + gate = self.ttg_map[tool] + if tool == self.tool_selected and gate == self.gate_selected: + self.select_gate(gate) # Some selectors need to be re-synced + return + + self.log_debug("Selecting tool %s on gate %d..." % (self.selected_tool_string(tool), gate)) + self.select_gate(gate) + self._set_tool_selected(tool) + self.log_info("Tool %s enabled%s" % (self.selected_tool_string(tool), (" on gate %d" % gate) if tool != gate else "")) + + def select_bypass(self): + if self.tool_selected == self.TOOL_GATE_BYPASS and self.gate_selected == self.TOOL_GATE_BYPASS: return + self.log_info("Selecting filament bypass...") + self.select_gate(self.TOOL_GATE_BYPASS) + self._set_tool_selected(self.TOOL_GATE_BYPASS) + self._set_filament_direction(self.DIRECTION_LOAD) + self.log_info("Bypass enabled") + + def _set_tool_selected(self, tool): + if tool != self.tool_selected: + self.tool_selected = tool + self.save_variable(self.VARS_MMU_TOOL_SELECTED, self.tool_selected, write=True) + + def _set_gate_selected(self, gate): + self.gate_selected = gate + + new_unit = self.find_unit_by_gate(gate) + if new_unit != self.unit_selected: + self.unit_selected = new_unit + self.sensor_manager.reset_active_unit(new_unit) + + self.sensor_manager.reset_active_gate(self.gate_selected) # Call after unit_selected is set + self.sync_feedback_manager.set_default_rd() # Will always set rotation_distance + + self.save_variable(self.VARS_MMU_GATE_SELECTED, self.gate_selected, write=True) + self.active_filament = { + 'filament_name': self.gate_filament_name[gate], + 'material': self.gate_material[gate], + 'color': self.gate_color[gate], + 'spool_id': self.gate_spool_id[gate], + 'temperature': self.gate_temperature[gate], + } if gate >= 0 else {} + + # Return unit number for gate + def find_unit_by_gate(self, gate): + unit = self.mmu_machine.get_mmu_unit_by_gate(gate) + if unit: + return unit.unit_index + self.log_debug("Assertion failure: Gate %d has no unit!" % gate) + return 0 + + # Set the active gear stepper rotation distance + def set_gear_rotation_distance(self, rd): + if rd: + self.log_trace("Setting gear motor rotation distance: %.4f" % rd) + if self.gear_rail.steppers: + self.gear_rail.steppers[0].set_rotation_distance(rd) + + def _moonraker_push_lane_data(self, gate_ids = None): + gate_ids = [(i, self.gate_spool_id[i]) for i in range(self.num_gates)] if gate_ids is None else gate_ids + if gate_ids: + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("moonraker_push_lane_data", gate_ids=gate_ids) + except Exception as e: + self.log_debug("Failed to push lane data to Moonraker: %s" % str(e)) + + def _moonraker_sync_lane_data(self): + # Push all current gate data to Moonraker + self._moonraker_push_lane_data() + + # Request cleanup of old lanes that no longer exist + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("moonraker_cleanup_lane_data", num_gates=self.num_gates) + except Exception as e: + self.log_debug("Failed to cleanup old lane data: %s" % str(e)) + +### SPOOLMAN INTEGRATION ######################################################### + + def _spoolman_sync(self, quiet=True): + if self.spoolman_support == self.SPOOLMAN_PULL: # Remote gate map + # This will pull gate assignment and filament attributes from spoolman db thus replacing the local map + self._spoolman_pull_gate_map(quiet=quiet) + elif self.spoolman_support == self.SPOOLMAN_PUSH: # Local gate map + # This will update spoolman with just the gate assignment (for visualization) and will update + # local gate map attributes with data from spoolman thus overwriting the local map + self._spoolman_push_gate_map(quiet=quiet) + elif self.spoolman_support == self.SPOOLMAN_READONLY: # Get filament attributes only + self._spoolman_update_filaments(quiet=quiet) + + def _spoolman_activate_spool(self, spool_id=-1): + if self.spoolman_support == self.SPOOLMAN_OFF: return + try: + webhooks = self.printer.lookup_object('webhooks') + if spool_id < 0: + self.log_debug("Spoolman spool_id not set for current gate") + else: + if spool_id == 0: + self.log_debug("Deactivating spool...") + spool_id = None # Moonraker API changed and id=0 no longer deactivates + else: + self.log_debug("Activating spool %s..." % spool_id) + webhooks.call_remote_method("spoolman_set_active_spool", spool_id=spool_id) + except Exception as e: + self.log_error("Error while setting active spool: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Request to send filament data from spoolman db (via moonraker) + # gate=None means all gates with spool_id, else specific gate + def _spoolman_update_filaments(self, gate_ids=None, quiet=True): + if self.spoolman_support == self.SPOOLMAN_OFF: return + if gate_ids is None: # All gates + pruned_gate_ids = [(g, self.gate_spool_id[g]) for g in range(self.num_gates) if self.gate_spool_id[g] >= 0] + else: + pruned_gate_ids = [(g, sid) for g, sid in gate_ids if sid >= 0] + if len(pruned_gate_ids) > 0: + self.log_debug("Requesting the following gate/spool_id pairs from Spoolman: %s" % pruned_gate_ids) + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_get_filaments", gate_ids=pruned_gate_ids, silent=quiet) + except Exception as e: + self.log_error("Error while fetching filament attributes from spoolman: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Store the current gate to spool_id mapping in spoolman db (via moonraker) + def _spoolman_push_gate_map(self, gate_ids=None, quiet=True): + if self.spoolman_support == self.SPOOLMAN_OFF: return + self.log_debug("Pushing gate mapping to Spoolman") + if gate_ids is None: # All gates + gate_ids = [(i, self.gate_spool_id[i]) for i in range(self.num_gates)] + try: + webhooks = self.printer.lookup_object('webhooks') + self.log_debug("Storing gate map in spoolman db...") + webhooks.call_remote_method("spoolman_push_gate_map", gate_ids=gate_ids, silent=quiet) + except Exception as e: + self.log_error("Error while pushing gate map to spoolman: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Request to update local gate based on the remote data stored in spoolman db + def _spoolman_pull_gate_map(self, quiet=True): + if self.spoolman_support == self.SPOOLMAN_OFF: return + self.log_debug("Requesting the gate map from Spoolman") + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_pull_gate_map", silent=quiet) + except Exception as e: + self.log_error("Error while requesting gate map from spoolman: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Clear the spool to gate association in spoolman db + def _spoolman_clear_gate_map(self, sync=False, quiet=True): + if self.spoolman_support == self.SPOOLMAN_OFF: return + self.log_debug("Requesting to clear the gate map in Spoolman") + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_clear_spools_for_printer", sync=sync, silent=quiet) + except Exception as e: + self.log_error("Error while clearing spoolman gate mapping: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Refresh the spoolman cache to pick up changes from elsewhere + def _spoolman_refresh(self, fix, quiet=True): + if self.spoolman_support == self.SPOOLMAN_OFF: return + self.log_debug("Requesting to refresh the spoolman gate cache") + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_refresh", fix=fix, silent=quiet) + except Exception as e: + self.log_error("Error while refreshing spoolman gate cache: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Force spool to map association in spoolman db + def _spoolman_set_spool_gate(self, spool_id, gate, sync=False, quiet=True): + if self.spoolman_support == self.SPOOLMAN_OFF: return + self.log_debug("Setting spool %d to gate %d directly in spoolman db" % (spool_id, gate)) + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_set_spool_gate", spool_id=spool_id, gate=gate, sync=sync, silent=quiet) + except Exception as e: + self.log_error("Error while setting spoolman gate association: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + def _spoolman_unset_spool_gate(self, spool_id=None, gate=None, sync=False, quiet=True): + if self.spoolman_support == self.SPOOLMAN_OFF: return + self.log_debug("Unsetting spool %s or gate %s in spoolman db" % (spool_id, gate)) + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_unset_spool_gate", spool_id=spool_id, gate=gate, sync=sync, silent=quiet) + except Exception as e: + self.log_error("Error while unsetting spoolman gate association: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Dump spool info to console + def _spoolman_display_spool_info(self, spool_id): + if self.spoolman_support == self.SPOOLMAN_OFF: return + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_get_spool_info", spool_id=spool_id) + except Exception as e: + self.log_error("Error while displaying spool info: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + # Dump spool info to console + def _spoolman_display_spool_location(self, printer=None): + if self.spoolman_support == self.SPOOLMAN_OFF: return + try: + webhooks = self.printer.lookup_object('webhooks') + webhooks.call_remote_method("spoolman_display_spool_location", printer=printer) + except Exception as e: + self.log_error("Error while displaying spool location map: %s\n%s" % (str(e), self.SPOOLMAN_CONFIG_ERROR)) + + +### SPOOLMAN COMMANDS ############################################################ + + cmd_MMU_SPOOLMAN_help = "Manage spoolman integration" + def cmd_MMU_SPOOLMAN(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_spoolman_enabled(): return + + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + sync = bool(gcmd.get_int('SYNC', 0, minval=0, maxval=1)) + clear = bool(gcmd.get_int('CLEAR', 0, minval=0, maxval=1)) + refresh = bool(gcmd.get_int('REFRESH', 0, minval=0, maxval=1)) + fix = bool(gcmd.get_int('FIX', 0, minval=0, maxval=1)) + spool_id = gcmd.get_int('SPOOLID', None, minval=1) + gate = gcmd.get_int('GATE', None, minval=-1, maxval=self.num_gates - 1) + printer = gcmd.get('PRINTER', None) # Option to see other printers (only for gate association table atm) + spoolinfo = gcmd.get_int('SPOOLINFO', None, minval=-1) # -1 or 0 is active spool + run = False + + if refresh: + # Rebuild cache in moonraker and sync local and remote + self._spoolman_refresh(fix, quiet=quiet) + if not sync: + self._spoolman_sync(quiet=quiet) + run = True + + if clear: + # Clear the gate allocation in spoolman db + self._spoolman_clear_gate_map(sync=self.spoolman_support == self.SPOOLMAN_PULL, quiet=quiet) + run = True + + if sync: + # Sync local and remote gate maps + self._spoolman_sync(quiet=quiet) + run = True + + # Rest of the options are mutually exclusive + if spoolinfo is not None: + # Dump spool info for active spool or specifed spool id + self._spoolman_display_spool_info(spoolinfo if spoolinfo > 0 else None) + + elif spool_id is not None or gate is not None: + # Update a record in spoolman db + if spool_id is not None and gate is not None: + self._spoolman_set_spool_gate(spool_id, gate, sync=self.spoolman_support == self.SPOOLMAN_PULL, quiet=quiet) + elif spool_id is None and gate is not None: + self._spoolman_unset_spool_gate(gate=gate, sync=self.spoolman_support == self.SPOOLMAN_PULL, quiet=quiet) + elif spool_id is not None and gate is None: + self._spoolman_unset_spool_gate(spool_id=spool_id, sync=self.spoolman_support == self.SPOOLMAN_PULL, quiet=quiet) + + elif not run: + if self.spoolman_support in [self.SPOOLMAN_PULL, self.SPOOLMAN_PUSH]: + # Display gate association table from spoolman db for specified printer + self._spoolman_display_spool_location(printer=printer) + else: + self.log_error("Spoolman gate map not available. Spoolman mode is: %s" % self.spoolman_support) + + +### CORE GCODE COMMANDS ########################################################## + + cmd_MMU_HOME_help = "Home the MMU selector" + def cmd_MMU_HOME(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + self._fix_started_state() + + if self.check_if_not_calibrated(self.CALIBRATED_SELECTOR): + self.log_always("Not calibrated. Will home to endstop only!") + tool = -1 + force_unload = 0 + else: + tool = gcmd.get_int('TOOL', 0, minval=0, maxval=self.num_gates - 1) + force_unload = gcmd.get_int('FORCE_UNLOAD', None, minval=0, maxval=1) + + try: + with self.wrap_sync_gear_to_extruder(): + self.home(tool, force_unload=force_unload) + if tool == -1: + self.log_always("Homed") + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_SELECT_help = "Select the specified logical tool (following TTG map) or physical gate" + def cmd_MMU_SELECT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_not_homed(): return + if self.check_if_loaded(): return + if self.check_if_not_calibrated(self.CALIBRATED_SELECTOR): return + self._fix_started_state() + + bypass = gcmd.get_int('BYPASS', -1, minval=0, maxval=1) + tool = gcmd.get_int('TOOL', -1, minval=0, maxval=self.num_gates - 1) + gate = gcmd.get_int('GATE', -1, minval=0, maxval=self.num_gates - 1) + if tool == -1 and gate == -1 and bypass == -1: + raise gcmd.error("Error on 'MMU_SELECT': missing TOOL, GATE or BYPASS") + + try: + with self.wrap_sync_gear_to_extruder(): + self._select(bypass, tool, gate) + msg = self._mmu_visual_to_string() + msg += "\n%s" % self._state_to_string() + self.log_info(msg, color=True) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_SELECT_BYPASS_help = "Select the filament bypass" + def cmd_MMU_SELECT_BYPASS(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_not_homed(): return + if self.check_if_loaded(): return + if self.check_if_not_calibrated(self.CALIBRATED_SELECTOR): return + self._fix_started_state() + + try: + with self.wrap_sync_gear_to_extruder(): + self._select(1, -1, -1) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + def _select(self, bypass, tool, gate): + if bypass != -1: + self.select_bypass() + elif tool != -1: + self.select_tool(tool) + else: + self.select_gate(gate) + self._ensure_ttg_match() + + cmd_MMU_CHANGE_TOOL_help = "Perform a tool swap (called from Tx command)" + def cmd_MMU_CHANGE_TOOL(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_bypass(): return + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates=[]): return # TODO Hard to tell what gates to check so don't check for now + self._fix_started_state() + + quiet = gcmd.get_int('QUIET', 0, minval=0, maxval=1) + standalone = bool(gcmd.get_int('STANDALONE', 0, minval=0, maxval=1)) + restore = bool(gcmd.get_int('RESTORE', 1, minval=0, maxval=1)) + skip_tip = bool(gcmd.get_int('SKIP_TIP', 0, minval=0, maxval=1)) + skip_purge = bool(gcmd.get_int('SKIP_PURGE', 0, minval=0, maxval=1)) + + # Handle "next_pos" option for toolhead position restoration + next_pos = None + sequence_vars_macro = self.printer.lookup_object("gcode_macro _MMU_SEQUENCE_VARS", None) + if sequence_vars_macro and sequence_vars_macro.variables.get('restore_xy_pos', 'last') == 'next': + # Convert next position to absolute coordinates + next_pos = gcmd.get('NEXT_POS', None) + if next_pos: + try: + x, y = map(float, next_pos.split(',')) + gcode_status = self.gcode_move.get_status(self.reactor.monotonic()) + if not gcode_status['absolute_coordinates']: + gcode_pos = gcode_status['gcode_position'] + x += gcode_pos[0] + y += gcode_pos[1] + next_pos = [x, y] + except (ValueError, KeyError, TypeError) as ee: + # If something goes wrong it is better to ignore next pos completely + self.log_error("Error parsing NEXT_POS: %s" % str(ee)) + + # To support Tx commands linked directly (currently not used because of Mainsail visibility which requires macros) + cmd = gcmd.get_command().strip() + match = re.match(r'[Tt](\d{1,3})$', cmd) + if match: + tool = int(match.group(1)) + if tool < 0 or tool > self.num_gates - 1: + raise gcmd.error("Invalid tool") + else: + # Special case for UI driven change tool where gate is chosen + tool = None + gate = gcmd.get_int('GATE', None, minval=0, maxval=self.num_gates - 1) + if gate is not None: + if gate == self.gate_selected: + self.log_always("Gate %s is already loaded as %s" % (gate, self.selected_tool_string(tool))) + return + + possible_tools = [tool for tool in range(self.num_gates) if self.ttg_map[tool] == gate] + if not possible_tools: + self.log_error("No tool associated with gate %s. Check tool-to-gate mapping with MMU_TTG_MAP" % gate) + return + + if self.tool_selected in possible_tools: + self._remap_tool(self.tool_selected, gate) + tool = self.tool_selected + else: + tool = possible_tools[0] + + if tool is None: + tool = gcmd.get_int('TOOL', minval=0, maxval=self.num_gates - 1) + + try: + with self.wrap_sync_gear_to_extruder(): + with self._wrap_suspend_filament_monitoring(): # Don't want runout accidently triggering during tool change + with self._wrap_suspendwrite_variables(): # Reduce I/O activity to a minimum + self._auto_home(tool=tool) + if self.has_encoder(): + self.encoder_sensor.note_clog_detection_length() + + do_form_tip = self.FORM_TIP_STANDALONE + if skip_tip: + do_form_tip = self.FORM_TIP_NONE + elif self.is_printing() and not (standalone or self.force_form_tip_standalone): + do_form_tip = self.FORM_TIP_SLICER + + do_purge = self.PURGE_STANDALONE + if skip_purge: + do_purge = self.PURGE_NONE + elif self.is_printing() and not (standalone or self.force_purge_standalone): + do_purge = self.PURGE_SLICER + + tip_msg = ("with slicer tip forming" if do_form_tip == self.FORM_TIP_SLICER else + "with standalone MMU tip forming" if do_form_tip == self.FORM_TIP_STANDALONE else + "without tip forming") + purge_msg = ("slicer purging" if do_purge == self.PURGE_SLICER else + "standalone MMU purging" if do_purge == self.PURGE_STANDALONE else + "without purging") + self.log_debug("Tool change initiated %s and %s" % (tip_msg, purge_msg)) + + current_tool_string = self.selected_tool_string() + new_tool_string = self.selected_tool_string(tool) + + # Check if we are already loaded + if ( + tool == self.tool_selected and + self.ttg_map[tool] == self.gate_selected and + self.filament_pos == self.FILAMENT_POS_LOADED + ): + self.log_always("Tool %s is already loaded" % self.selected_tool_string(tool)) + return + + # Load only case + if self.filament_pos == self.FILAMENT_POS_UNLOADED: + msg = "Tool change requested: %s" % new_tool_string + m117_msg = "> %s" % new_tool_string + elif self.tool_selected == tool: + msg = "Reloading: %s" % new_tool_string + m117_msg = "> %s" % new_tool_string + else: + # Normal toolchange case + msg = "Tool change requested, from %s to %s" % (current_tool_string, new_tool_string) + m117_msg = "%s > %s" % (current_tool_string, new_tool_string) + + self._note_toolchange(m117_msg) + self.log_always(msg) + + # Check if new tool is mapped to current gate + if self.ttg_map[tool] == self.gate_selected and self.filament_pos == self.FILAMENT_POS_LOADED: + self.select_tool(tool) + self._note_toolchange(self.selected_tool_string(tool)) + return + + # Ok, now ready to park and perform the swap + self._next_tool = tool # Valid only during the change process - cleared in _continue_after() + self.last_statistics = {} + self._save_toolhead_position_and_park('toolchange', next_pos=next_pos) + self._set_next_position(next_pos) # This can also clear next_position + self._track_time_start('total') + self.printer.send_event("mmu:toolchange", self._last_tool, self._next_tool) + + # Remember the tool that was actually in use before any load attempts + prev_tool = self.tool_selected + + attempts = 2 if self.retry_tool_change_on_error and (self.is_printing() or standalone) else 1 # TODO Replace with inattention timer + try: + for i in range(attempts): + try: + if self.filament_pos != self.FILAMENT_POS_UNLOADED: + self._unload_tool(form_tip=do_form_tip, prev_tool=prev_tool) + self._select_and_load_tool(tool, purge=do_purge) + break + except MmuError as ee: + if i == attempts - 1: + raise MmuError("%s.\nOccured when changing tool: %s" % (str(ee), self._last_toolchange)) + self.log_error("%s.\nOccured when changing tool: %s. Retrying..." % (str(ee), self._last_toolchange)) + # Try again but recover_filament_pos will ensure conservative treatment of unload + self.recover_filament_pos() + + self._track_swap_completed() + if self.log_m117_messages: + self.gcode.run_script_from_command("M117 T%s" % tool) + finally: + self._track_time_end('total') + + # Updates swap statistics + self.num_toolchanges += 1 + self._dump_statistics(job=not quiet, gate=not quiet) + self._persist_swap_statistics() + self._persist_gate_statistics() + + # Deliberately outside of _wrap_gear_synced_to_extruder() so there is no absolutely no delay after restoring position + self._continue_after('toolchange', restore=restore) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + + cmd_MMU_LOAD_help = "Loads filament on current tool/gate or optionally loads just the extruder for bypass or recovery usage (EXTRUDER_ONLY=1)" + def cmd_MMU_LOAD(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_not_homed(): return + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates=[self.gate_selected]): return + self._fix_started_state() + + in_bypass = self.gate_selected == self.TOOL_GATE_BYPASS + extruder_only = bool(gcmd.get_int('EXTRUDER_ONLY', 0, minval=0, maxval=1) or in_bypass) + skip_purge = bool(gcmd.get_int('SKIP_PURGE', 0, minval=0, maxval=1)) + restore = bool(gcmd.get_int('RESTORE', 1, minval=0, maxval=1)) + do_purge = self.PURGE_STANDALONE if not skip_purge else self.PURGE_NONE + + try: + with self.wrap_sync_gear_to_extruder(): + with self._wrap_suspend_filament_monitoring(): # Don't want runout accidently triggering during filament load + if self.filament_pos != self.FILAMENT_POS_UNLOADED: + self.log_always("Filament already loaded") + return + + self._note_toolchange("> %s" % self.selected_tool_string()) + + if extruder_only: + self.load_sequence(bowden_move=0., extruder_only=True, purge=do_purge) + + else: + self._next_tool = self.tool_selected # Valid only during the load process - cleared in _continue_after() + self.last_statistics = {} + self._save_toolhead_position_and_park('load') + if self.tool_selected == self.TOOL_GATE_UNKNOWN: + self.log_error("Selected gate is not mapped to any tool. Will load filament but be sure to use MMU_TTG_MAP to assign tool") + + self._select_and_load_tool(self.tool_selected, purge=do_purge) + self._persist_gate_statistics() + self._continue_after('load', restore=restore) + + self._persist_swap_statistics() + + except MmuError as ee: + self.handle_mmu_error("%s.\nOccured when loading tool: %s" % (str(ee), self._last_toolchange)) + if self.tool_selected == self.TOOL_GATE_BYPASS: + self._set_filament_pos_state(self.FILAMENT_POS_UNKNOWN) + + + cmd_MMU_UNLOAD_help = "Unloads filament and parks it at the gate or optionally unloads just the extruder (EXTRUDER_ONLY=1)" + def cmd_MMU_UNLOAD(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates=[self.gate_selected]): return + self._fix_started_state() + + if self.filament_pos == self.FILAMENT_POS_UNLOADED: + self.log_always("Filament not loaded") + return + + try: + with self.wrap_sync_gear_to_extruder(): + with self._wrap_suspend_filament_monitoring(): # Don't want runout accidently triggering during filament unload + self._mmu_unload_eject(gcmd) + + self._persist_swap_statistics() + + except MmuError as ee: + self.handle_mmu_error("%s.\nOccured when unloading tool" % str(ee)) + + cmd_MMU_EJECT_help = "Alias for MMU_UNLOAD if filament is loaded but will fully eject filament from MMU (release from gear) if already in unloaded state" + def cmd_MMU_EJECT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + + gate = gcmd.get_int('GATE', self.gate_selected, minval=0, maxval=self.num_gates - 1) + force = bool(gcmd.get_int('FORCE', 0, minval=0, maxval=1)) + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates=[gate]): return + self._fix_started_state() + + can_crossload = (self.mmu_machine.can_crossload or self.mmu_machine.multigear) and self.sensor_manager.has_gate_sensor(self.SENSOR_GEAR_PREFIX, gate) + if not can_crossload and gate != self.gate_selected: + if self.check_if_loaded(): return + + # Determine if full eject_from_gate is necessary + in_bypass = self.gate_selected == self.TOOL_GATE_BYPASS + extruder_only = bool(gcmd.get_int('EXTRUDER_ONLY', 0, minval=0, maxval=1)) + can_eject_from_gate = ( + not extruder_only + and not (in_bypass and self.filament_pos != self.FILAMENT_POS_UNLOADED and gate >= 0) + and ( + (self.mmu_machine.multigear and gate != self.gate_selected) + or self.filament_pos == self.FILAMENT_POS_UNLOADED + or force + ) + ) + + if not can_eject_from_gate and self.filament_pos == self.FILAMENT_POS_UNLOADED: + self.log_always("Filament not loaded") + return + + try: + with self.wrap_sync_gear_to_extruder(): + with self._wrap_suspend_filament_monitoring(): # Don't want runout accidently triggering during filament eject + + current_gate = self.gate_selected + if gate != current_gate: + self.select_gate(gate) + + try: + self._mmu_unload_eject(gcmd) + if can_eject_from_gate: + self.log_always("Ejecting filament out of %s" % ("current gate" if gate == current_gate else "gate %d" % gate)) + self._eject_from_gate() + + finally: + if self.gate_selected != current_gate: + # If necessary or easy restore previous gate + if self.is_in_print() or self.mmu_machine.multigear or self.filament_pos != self.FILAMENT_POS_UNLOADED: + self.select_gate(current_gate) + else: + # Lazy movement means we have side effect of changed tool/gate + self._ensure_ttg_match() + self._initialize_encoder() # Encoder 0000 + + self._persist_swap_statistics() + + except MmuError as ee: + self.handle_mmu_error("Filament eject for gate %d failed: %s" % (gate, str(ee))) + + # Common logic for MMU_UNLOAD and MMU_EJECT + def _mmu_unload_eject(self, gcmd): + in_bypass = self.gate_selected == self.TOOL_GATE_BYPASS + extruder_only = bool(gcmd.get_int('EXTRUDER_ONLY', 0, minval=0, maxval=1)) or in_bypass + skip_tip = bool(gcmd.get_int('SKIP_TIP', 0, minval=0, maxval=1)) + restore = bool(gcmd.get_int('RESTORE', 1, minval=0, maxval=1)) + do_form_tip = self.FORM_TIP_STANDALONE if not skip_tip else self.FORM_TIP_NONE + + self._note_toolchange("< %s" % self.selected_tool_string()) + + if extruder_only: + self._set_filament_pos_state(self.FILAMENT_POS_IN_EXTRUDER, silent=True) # Ensure tool tip is performed + self.unload_sequence(bowden_move=0., form_tip=do_form_tip, extruder_only=True) + if in_bypass: + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + self.log_always("Please pull the filament out from the MMU") + else: + if self.filament_pos != self.FILAMENT_POS_UNLOADED: + self.last_statistics = {} + self._save_toolhead_position_and_park('unload') + self._unload_tool(form_tip=do_form_tip) + self._persist_gate_statistics() + self._continue_after('unload', restore=restore) + + # Bookend for start of MMU based print + cmd_MMU_PRINT_START_help = "Forces initialization of MMU state ready for print (usually automatic)" + def cmd_MMU_PRINT_START(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if not self.is_in_print(): + self._on_print_start() + self._clear_macro_state(reset=True) + + # Bookend for end of MMU based print + cmd_MMU_PRINT_END_help = "Forces clean up of state after after print end" + def cmd_MMU_PRINT_END(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + idle_timeout = gcmd.get_int('IDLE_TIMEOUT', 0, minval=0, maxval=1) + end_state = gcmd.get('STATE', "complete") + if not self.is_in_endstate(): + if end_state in ["complete", "error", "cancelled", "ready", "standby"]: + if not idle_timeout and end_state in ["complete"]: + self._save_toolhead_position_and_park("complete") + self._on_print_end(end_state) + else: + raise gcmd.error("Unknown endstate '%s'" % end_state) + + cmd_MMU_LOG_help = "Logs messages in MMU log" + def cmd_MMU_LOG(self, gcmd): + msg = gcmd.get('MSG', "").replace("\\n", "\n").replace(" ", UI_SPACE) + if gcmd.get_int('ERROR', 0, minval=0, maxval=1): + self.log_error(msg) + elif gcmd.get_int('DEBUG', 0, minval=0, maxval=1): + self.log_debug(msg) + else: + self.log_info(msg) + + cmd_MMU_PAUSE_help = "Pause the current print and lock the MMU operations" + def cmd_MMU_PAUSE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + force_in_print = bool(gcmd.get_int('FORCE_IN_PRINT', 0, minval=0, maxval=1)) # Mimick in-print + msg = gcmd.get('MSG',"MMU_PAUSE macro was directly called") + self.handle_mmu_error(msg, force_in_print) + + cmd_MMU_UNLOCK_help = "Wakeup the MMU prior to resume to restore temperatures and timeouts" + def cmd_MMU_UNLOCK(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + self._clear_mmu_error_dialog() + if self.is_mmu_paused_and_locked(): + self._mmu_unlock() + + # Not a user facing command - used in automatic wrapper + cmd_MMU_RESUME_help = "Wrapper around default user RESUME macro" + def cmd_MMU_RESUME(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if not self.is_enabled: + # User defined or Klipper default behavior + self.wrap_gcode_command(" ".join(("__RESUME", gcmd.get_raw_command_parameters())), None) + return + + self.log_debug("MMU RESUME wrapper called") + if not self.is_paused(): + self.log_always("Print is not paused. Resume ignored.") + return + + force_in_print = bool(gcmd.get_int('FORCE_IN_PRINT', 0, minval=0, maxval=1)) # Mimick in-print + try: + self._clear_mmu_error_dialog() + if self.is_mmu_paused_and_locked(): + self._mmu_unlock() + + # Decide if we are ready to resume and give user opportunity to fix state first + if self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD) is True: + self._set_filament_pos_state(self.FILAMENT_POS_LOADED, silent=True) + self.log_always("Automatically set filament state to LOADED based on toolhead sensor") + if self.filament_pos not in [self.FILAMENT_POS_UNLOADED, self.FILAMENT_POS_LOADED]: + raise MmuError("Cannot resume because filament position not indicated as fully loaded (or unloaded). Ensure filament is loaded/unloaded and run:\n MMU_RECOVER LOADED=1 or MMU_RECOVER LOADED=0 or just MMU_RECOVER\nto reset state, then RESUME again") + + # Prevent BASE_RESUME from moving toolhead + if self.TOOLHEAD_POSITION_STATE in self.gcode_move.saved_states: + gcode_pos = self.gcode_move.get_status(self.reactor.monotonic())['gcode_position'] + try: + self.gcode_move.saved_states['PAUSE_STATE']['last_position'][:3] = gcode_pos[:3] + except KeyError: + self.log_error("PAUSE_STATE not defined!") + + self.wrap_gcode_command(" ".join(("__RESUME", gcmd.get_raw_command_parameters())), exception=None) + self._continue_after("resume", force_in_print=force_in_print) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + # Not a user facing command - used in automatic wrapper + cmd_PAUSE_help = "Wrapper around default PAUSE macro" + def cmd_PAUSE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.is_enabled: + self._fix_started_state() # Get out of 'started' state + self.log_debug("MMU PAUSE wrapper called") + self._save_toolhead_position_and_park("pause") + self.wrap_gcode_command(" ".join(("__PAUSE", gcmd.get_raw_command_parameters())), exception=None) + + # Not a user facing command - used in automatic wrapper + cmd_CLEAR_PAUSE_help = "Wrapper around default CLEAR_PAUSE macro" + def cmd_CLEAR_PAUSE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.is_enabled: + self.log_debug("MMU CLEAR_PAUSE wrapper called") + self._clear_macro_state() + if self.saved_toolhead_operation == 'pause': + self._clear_saved_toolhead_position() + self.wrap_gcode_command("__CLEAR_PAUSE", exception=None) + + # Not a user facing command - used in automatic wrapper + cmd_MMU_CANCEL_PRINT_help = "Wrapper around default CANCEL_PRINT macro" + def cmd_MMU_CANCEL_PRINT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.is_enabled: + self._fix_started_state() # Get out of 'started' state before transistion to cancelled + self.log_debug("MMU_CANCEL_PRINT wrapper called") + self._clear_mmu_error_dialog() + self._save_toolhead_position_and_park("cancel") + self.wrap_gcode_command("__CANCEL_PRINT", exception=None) + self._on_print_end("cancelled") + else: + self.wrap_gcode_command("__CANCEL_PRINT", exception=None) + + cmd_MMU_RECOVER_help = "Recover the filament location or manually fix MMU state after intervention/error" + cmd_MMU_RECOVER_param_help = ( + "MMU_RECOVER: %s\n" % cmd_MMU_RECOVER_help + + "TOOL = t Optionally force the assignment of specified tool number\n" + + "GATE = g Optionally force the assignment of the specified gate number (fixes TTG map)\n" + + "BYPASS = 1 Used to force the assignemnt of the bypass Tool/Gate\n" + + "LOADED = [0|1] Force unloaded or loaded (in the extruder) state\n" + + "STRICT = 1 If auto-recovering state, allows extended tests including extruder heating\n" + + "(no parameters for automatic filament position recovery)" + ) + cmd_MMU_RECOVER_supplement_help = ( + "Examples:\n" + + "MMU_RECOVER ...automatically recover filament position\n" + + "MMU_RECOVER LOADED=1 ...to indicate filament is in the extruder\n" + + "MMU_RECOVER TOOL=2 GATE=3 ...to indicate T2 is currently loaded from gate 3" + ) + def cmd_MMU_RECOVER(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + + if gcmd.get_int('HELP', 0, minval=0, maxval=1): + self.log_always(self.format_help(self.cmd_MMU_RECOVER_param_help, self.cmd_MMU_RECOVER_supplement_help), color=True) + return + + tool = gcmd.get_int('TOOL', self.TOOL_GATE_UNKNOWN, minval=-2, maxval=self.num_gates - 1) + mod_gate = gcmd.get_int('GATE', self.TOOL_GATE_UNKNOWN, minval=-2, maxval=self.num_gates - 1) + if gcmd.get_int('BYPASS', None, minval=0, maxval=1): + mod_gate = self.TOOL_GATE_BYPASS + tool = self.TOOL_GATE_BYPASS + loaded = gcmd.get_int('LOADED', -1, minval=0, maxval=1) + strict = gcmd.get_int('STRICT', 0, minval=0, maxval=1) + + try: + if tool == self.TOOL_GATE_BYPASS: + self.selector.restore_gate(self.TOOL_GATE_BYPASS) + self._set_gate_selected(self.TOOL_GATE_BYPASS) + self._set_tool_selected(self.TOOL_GATE_BYPASS) + self._ensure_ttg_match() + + elif tool >= 0: # If tool is specified then use and optionally override the gate + self._set_tool_selected(tool) + gate = self.ttg_map[tool] + if mod_gate >= 0: + gate = mod_gate + if gate >= 0: + self.selector.restore_gate(gate) + self._set_gate_selected(gate) + self.log_info("Remapping T%d to gate %d" % (tool, gate)) + self._remap_tool(tool, gate, loaded) + + elif mod_gate >= 0: # If only gate specified then just reset and ensure tool is correct + self.selector.restore_gate(mod_gate) + self._set_gate_selected(mod_gate) + self._ensure_ttg_match() + + elif tool == self.TOOL_GATE_UNKNOWN and self.tool_selected == self.TOOL_GATE_BYPASS and loaded == -1: + # This is to be able to get out of "stuck in bypass" state + ts = self.sensor_manager.check_sensor(self.SENSOR_TOOLHEAD) + es = self.sensor_manager.check_sensor(self.SENSOR_EXTRUDER_ENTRY) + if ts or es: # TODO use check_all_sensors() call when sensor_manager is fixed + self._set_filament_pos_state(self.FILAMENT_POS_LOADED, silent=True) + else: + if es is None and ts is None: + self.log_warning("Warning: Making assumption that bypass is unloaded because no toolhead sensors are present") + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED, silent=True) + self._set_filament_direction(self.DIRECTION_UNKNOWN) + return + + if loaded == 1: + self._set_filament_direction(self.DIRECTION_LOAD) + self._set_filament_pos_state(self.FILAMENT_POS_LOADED) + elif loaded == 0: + self._set_filament_direction(self.DIRECTION_UNLOAD) + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED) + else: + # Filament position not specified so auto recover + self.recover_filament_pos(strict=strict, message=True) + + # Reset sync state + self.reset_sync_gear_to_extruder(False) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + +### GCODE COMMANDS INTENDED FOR TESTING ########################################## + + cmd_MMU_SOAKTEST_LOAD_SEQUENCE_help = "Soak test tool load/unload sequence" + def cmd_MMU_SOAKTEST_LOAD_SEQUENCE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_bypass(): return + if self.check_if_not_homed(): return + if self.check_if_loaded(): return + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL): return + loops = gcmd.get_int('LOOP', 2) + rand = gcmd.get_int('RANDOM', 0) + to_nozzle = gcmd.get_int('FULL', 0) + try: + with self.wrap_sync_gear_to_extruder(): + for l in range(loops): + self.log_always("Testing loop %d / %d" % (l, loops)) + for t in range(self.num_gates): + tool = t + if rand == 1: + tool = random.randint(0, self.num_gates - 1) + gate = self.ttg_map[tool] + if self.gate_status[gate] == self.GATE_EMPTY: + self.log_always("Skipping tool %d of %d because gate %d is empty" % (tool, self.num_gates, gate)) + else: + self.log_always("Testing tool %d of %d (gate %d)" % (tool, self.num_gates, gate)) + if not to_nozzle: + self.select_tool(tool) + self.load_sequence(bowden_move=100., skip_extruder=True) + self.unload_sequence(bowden_move=100.) + else: + self._select_and_load_tool(tool, purge=self.PURGE_NONE) + self._unload_tool() + self.select_tool(0) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_TEST_GRIP_help = "Test the MMU grip for a Tool" + def cmd_MMU_TEST_GRIP(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_bypass(): return + self.selector.filament_drive() + self.motors_onoff(on=False, motor="gear") + + cmd_MMU_TEST_TRACKING_help = "Test the tracking of gear feed and encoder sensing" + def cmd_MMU_TEST_TRACKING(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self._check_has_encoder(): return + if self.check_if_disabled(): return + if self.check_if_bypass(): return + if self.check_if_not_homed(): return + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates=[self.gate_selected]): return + direction = gcmd.get_int('DIRECTION', 1, minval=-1, maxval=1) + step = gcmd.get_float('STEP', 1, minval=0.5, maxval=20) + sensitivity = gcmd.get_float('SENSITIVITY', self.encoder_resolution, minval=0.1, maxval=10) + if direction == 0: return + try: + with self.wrap_sync_gear_to_extruder(): + if self.filament_pos not in [self.FILAMENT_POS_START_BOWDEN, self.FILAMENT_POS_IN_BOWDEN]: + # Ready MMU for test if not already setup + self._unload_tool() + self.load_sequence(bowden_move=100. if direction == self.DIRECTION_LOAD else 200., skip_extruder=True) + self.selector.filament_drive() + with self._require_encoder(): + self._initialize_filament_position() + for i in range(1, int(100 / step)): + self.trace_filament_move(None, direction * step, encoder_dwell=None) + measured = self.get_encoder_distance() + moved = i * step + drift = int(round((moved - measured) / sensitivity)) + if drift > 0: + drift_str = "++++++++!!"[0:drift] + elif (moved - measured) < 0: + drift_str = "--------!!"[0:-drift] + else: + drift_str = "" + self.log_info("Gear/Encoder : %05.2f / %05.2f mm %s" % (moved, measured, drift_str)) + self._unload_tool() + except MmuError as ee: + self.handle_mmu_error("Tracking test failed: %s" % str(ee)) + + cmd_MMU_TEST_LOAD_help = "For quick testing filament loading from gate to the extruder" + def cmd_MMU_TEST_LOAD(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_bypass(): return + if self.check_if_loaded(): return + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates=[self.gate_selected]): return + full = gcmd.get_int('FULL', 0, minval=0, maxval=1) + try: + with self.wrap_sync_gear_to_extruder(): + if full: + self.load_sequence(skip_extruder=True) + else: + length = gcmd.get_float('LENGTH', 100., minval=10., maxval=self.calibration_manager.get_bowden_length()) + self.load_sequence(bowden_move=length, skip_extruder=True) + except MmuError as ee: + self.handle_mmu_error("Load test failed: %s" % str(ee)) + + cmd_MMU_TEST_MOVE_help = "Test filament move to help debug setup / options" + def cmd_MMU_TEST_MOVE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + debug = bool(gcmd.get_int('DEBUG', 0, minval=0, maxval=1)) # Hidden option + allow_bypass = bool(gcmd.get_int('ALLOW_BYPASS', 0, minval=0, maxval=1)) + + with self.wrap_sync_gear_to_extruder(): + with DebugStepperMovement(self, debug): + actual,_,measured,_ = self._move_cmd(gcmd, "Test move", allow_bypass=allow_bypass) + self.log_always("Moved %.1fmm%s" % (actual, (" (measured %.1fmm)" % measured) if self._can_use_encoder() else "")) + + cmd_MMU_TEST_HOMING_MOVE_help = "Test filament homing move to help debug setup / options" + def cmd_MMU_TEST_HOMING_MOVE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + allow_bypass = bool(gcmd.get_int('ALLOW_BYPASS', 0, minval=0, maxval=1)) + + with self.wrap_sync_gear_to_extruder(): + debug = bool(gcmd.get_int('DEBUG', 0, minval=0, maxval=1)) # Hidden option + with DebugStepperMovement(self, debug): + actual,homed,measured,_ = self._homing_move_cmd(gcmd, "Test homing move", allow_bypass=allow_bypass) + self.log_always("%s after %.1fmm%s" % (("Homed" if homed else "Did not home"), actual, (" (measured %.1fmm)" % measured) if self._can_use_encoder() else "")) + + cmd_MMU_TEST_CONFIG_help = "Runtime adjustment of MMU configuration for testing or in-print tweaking purposes" + def cmd_MMU_TEST_CONFIG(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + + # Try to catch illegal parameters + illegal_params = [ + p for p in gcmd.get_command_parameters() + if vars(self).get(p.lower()) is None + and self.selector.check_test_config(p.lower()) + and self.sync_feedback_manager.check_test_config(p.lower()) + and self.environment_manager.check_test_config(p.lower()) + and p.lower() not in [ + self.VARS_MMU_CALIB_BOWDEN_LENGTH, + self.VARS_MMU_CALIB_CLOG_LENGTH + ] + and p.upper() not in ['QUIET'] + ] + if illegal_params: + raise gcmd.error("Unknown parameter: %s" % illegal_params) + + # Filament Speeds + self.gear_from_buffer_speed = gcmd.get_float('GEAR_FROM_BUFFER_SPEED', self.gear_from_buffer_speed, minval=10.) + self.gear_from_buffer_accel = gcmd.get_float('GEAR_FROM_BUFFER_ACCEL', self.gear_from_buffer_accel, minval=10.) + self.gear_from_spool_speed = gcmd.get_float('GEAR_FROM_SPOOL_SPEED', self.gear_from_spool_speed, minval=10.) + self.gear_from_spool_accel = gcmd.get_float('GEAR_FROM_SPOOL_ACCEL', self.gear_from_spool_accel, above=10.) + self.gear_unload_speed = gcmd.get_float('GEAR_UNLOAD_SPEED', self.gear_unload_speed, minval=10.) + self.gear_unload_accel = gcmd.get_float('GEAR_UNLOAD_ACCEL', self.gear_unload_accel, above=10.) + self.gear_short_move_speed = gcmd.get_float('GEAR_SHORT_MOVE_SPEED', self.gear_short_move_speed, minval=10.) + self.gear_short_move_accel = gcmd.get_float('GEAR_SHORT_MOVE_ACCEL', self.gear_short_move_accel, minval=10.) + self.gear_short_move_threshold = gcmd.get_float('GEAR_SHORT_MOVE_THRESHOLD', self.gear_short_move_threshold, minval=0.) + self.gear_homing_speed = gcmd.get_float('GEAR_HOMING_SPEED', self.gear_homing_speed, above=1.) + self.extruder_homing_speed = gcmd.get_float('EXTRUDER_HOMING_SPEED', self.extruder_homing_speed, above=1.) + self.extruder_load_speed = gcmd.get_float('EXTRUDER_LOAD_SPEED', self.extruder_load_speed, above=1.) + self.extruder_unload_speed = gcmd.get_float('EXTRUDER_UNLOAD_SPEED', self.extruder_unload_speed, above=1.) + self.extruder_sync_load_speed = gcmd.get_float('EXTRUDER_SYNC_LOAD_SPEED', self.extruder_sync_load_speed, above=1.) + self.extruder_sync_unload_speed = gcmd.get_float('EXTRUDER_SYNC_UNLOAD_SPEED', self.extruder_sync_unload_speed, above=1.) + self.extruder_accel = gcmd.get_float('EXTRUDER_ACCEL', self.extruder_accel, above=10.) + + # Synchronous motor control + self.sync_to_extruder = gcmd.get_int('SYNC_TO_EXTRUDER', self.sync_to_extruder, minval=0, maxval=1) + self.sync_form_tip = gcmd.get_int('SYNC_FORM_TIP', self.sync_form_tip, minval=0, maxval=1) + self.sync_purge = gcmd.get_int('SYNC_PURGE', self.sync_purge, minval=0, maxval=1) + if self.mmu_machine.filament_always_gripped: + self.sync_to_extruder = self.sync_form_tip = self.sync_purge = 1 + + # TMC current control + self.sync_gear_current = gcmd.get_int('SYNC_GEAR_CURRENT', self.sync_gear_current, minval=10, maxval=100) + self.extruder_collision_homing_current = gcmd.get_int('EXTRUDER_COLLISION_HOMING_CURRENT', self.extruder_collision_homing_current, minval=10, maxval=100) + self.extruder_form_tip_current = gcmd.get_int('EXTRUDER_FORM_TIP_CURRENT', self.extruder_form_tip_current, minval=100, maxval=150) + self.extruder_purge_current = gcmd.get_int('EXTRUDER_PURGE_CURRENT', self.extruder_purge_current, minval=100, maxval=150) + + # Homing, loading and unloading controls + gate_homing_endstop = gcmd.get('GATE_HOMING_ENDSTOP', self.gate_homing_endstop) + if gate_homing_endstop not in self.GATE_ENDSTOPS: + raise gcmd.error("gate_homing_endstop is invalid. Options are: %s" % self.GATE_ENDSTOPS) + if gate_homing_endstop != self.gate_homing_endstop: + self.gate_homing_endstop = gate_homing_endstop + self.calibration_manager.adjust_bowden_lengths_on_homing_change() + + # Special bowden calibration (get current length after potential gate_homing_endstop change) + gate_selected = max(self.gate_selected, 0) # Assume gate 0 if not known / bypass + bowden_length = gcmd.get_float('MMU_CALIBRATION_BOWDEN_LENGTH', self.bowden_lengths[gate_selected], minval=0.) + if bowden_length != self.bowden_lengths[gate_selected]: + self.calibration_manager.update_bowden_length(bowden_length, gate_selected) + + self.gate_endstop_to_encoder = gcmd.get_float('GATE_SENSOR_TO_ENCODER', self.gate_endstop_to_encoder) + self.gate_autoload = gcmd.get_int('GATE_AUTOLOAD', self.gate_autoload, minval=0, maxval=1) + self.gate_final_eject_distance = gcmd.get_float('GATE_FINAL_EJECT_DISTANCE', self.gate_final_eject_distance) + self.gate_unload_buffer = gcmd.get_float('GATE_UNLOAD_BUFFER', self.gate_unload_buffer, minval=0.) + self.gate_homing_max = gcmd.get_float('GATE_HOMING_MAX', self.gate_homing_max) + self.gate_parking_distance = gcmd.get_float('GATE_PARKING_DISTANCE', self.gate_parking_distance) + self.gate_preload_homing_max = gcmd.get_float('GATE_PRELOAD_HOMING_MAX', self.gate_preload_homing_max) + self.gate_preload_parking_distance = gcmd.get_float('GATE_PRELOAD_PARKING_DISTANCE', self.gate_preload_parking_distance) + + self.bypass_autoload = gcmd.get_int('BYPASS_AUTOLOAD', self.bypass_autoload, minval=0, maxval=1) + self.bowden_apply_correction = gcmd.get_int('BOWDEN_APPLY_CORRECTION', self.bowden_apply_correction, minval=0, maxval=1) + self.bowden_allowable_unload_delta = self.bowden_allowable_load_delta = gcmd.get_float('BOWDEN_ALLOWABLE_LOAD_DELTA', self.bowden_allowable_load_delta, minval=1., maxval=50.) + self.bowden_pre_unload_test = gcmd.get_int('BOWDEN_PRE_UNLOAD_TEST', self.bowden_pre_unload_test, minval=0, maxval=1) + + extruder_homing_endstop = gcmd.get('EXTRUDER_HOMING_ENDSTOP', self.extruder_homing_endstop) + if extruder_homing_endstop not in self.EXTRUDER_ENDSTOPS: + raise gcmd.error("extruder_homing_endstop is invalid. Options are: %s" % self.EXTRUDER_ENDSTOPS) + self.extruder_homing_endstop = extruder_homing_endstop + + self.extruder_homing_max = gcmd.get_float('EXTRUDER_HOMING_MAX', self.extruder_homing_max, above=10.) + self.extruder_force_homing = gcmd.get_int('EXTRUDER_FORCE_HOMING', self.extruder_force_homing, minval=0, maxval=1) + + self.toolhead_homing_max = gcmd.get_float('TOOLHEAD_HOMING_MAX', self.toolhead_homing_max, minval=0.) + self.toolhead_entry_to_extruder = gcmd.get_float('TOOLHEAD_ENTRY_TO_EXTRUDER', self.toolhead_entry_to_extruder, minval=0.) + self.toolhead_sensor_to_nozzle = gcmd.get_float('TOOLHEAD_SENSOR_TO_NOZZLE', self.toolhead_sensor_to_nozzle, minval=0.) + self.toolhead_extruder_to_nozzle = gcmd.get_float('TOOLHEAD_EXTRUDER_TO_NOZZLE', self.toolhead_extruder_to_nozzle, minval=0.) + self.toolhead_residual_filament = gcmd.get_float('TOOLHEAD_RESIDUAL_FILAMENT', self.toolhead_residual_filament, minval=0.) + self.toolhead_ooze_reduction = gcmd.get_float('TOOLHEAD_OOZE_REDUCTION', self.toolhead_ooze_reduction, minval=-5., maxval=20.) + self.toolhead_unload_safety_margin = gcmd.get_float('TOOLHEAD_UNLOAD_SAFETY_MARGIN', self.toolhead_unload_safety_margin, minval=0.) + self.toolhead_post_load_tighten = gcmd.get_int('TOOLHEAD_POST_LOAD_TIGHTEN', self.toolhead_post_load_tighten, minval=0, maxval=100) + self.toolhead_post_load_tension_adjust = gcmd.get_int('TOOLHEAD_POST_LOAD_TENSION_ADJUST', self.toolhead_post_load_tension_adjust, minval=0, maxval=1) + self.toolhead_entry_tension_test = gcmd.get_int('TOOLHEAD_ENTRY_TENSION_TEST', self.toolhead_entry_tension_test, minval=0, maxval=1) + self.gcode_load_sequence = gcmd.get_int('GCODE_LOAD_SEQUENCE', self.gcode_load_sequence, minval=0, maxval=1) + self.gcode_unload_sequence = gcmd.get_int('GCODE_UNLOAD_SEQUENCE', self.gcode_unload_sequence, minval=0, maxval=1) + + # Software behavior options + self.extruder_temp_variance = gcmd.get_float('EXTRUDER_TEMP_VARIANCE', self.extruder_temp_variance, minval=1.) + self.endless_spool_enabled = gcmd.get_int('ENDLESS_SPOOL_ENABLED', self.endless_spool_enabled, minval=0, maxval=1) + self.endless_spool_on_load = gcmd.get_int('ENDLESS_SPOOL_ON_LOAD', self.endless_spool_on_load, minval=0, maxval=1) + self.endless_spool_eject_gate = gcmd.get_int('ENDLESS_SPOOL_EJECT_GATE', self.endless_spool_eject_gate, minval=-1, maxval=self.num_gates - 1) + + prev_spoolman_support = self.spoolman_support + spoolman_support = gcmd.get('SPOOLMAN_SUPPORT', self.spoolman_support) + if spoolman_support not in self.SPOOLMAN_OPTIONS: + raise gcmd.error("spoolman_support is invalid. Options are: %s" % self.SPOOLMAN_OPTIONS) + if spoolman_support == self.SPOOLMAN_OFF: + self.gate_spool_id[:] = [-1] * self.num_gates + self.spoolman_support = spoolman_support + + prev_t_macro_color = self.t_macro_color + t_macro_color = gcmd.get('T_MACRO_COLOR', self.t_macro_color) + if t_macro_color not in self.T_MACRO_COLOR_OPTIONS: + raise gcmd.error("t_macro_color is invalid. Options are: %s" % self.T_MACRO_COLOR_OPTIONS) + self.t_macro_color = t_macro_color + + self.log_level = gcmd.get_int('LOG_LEVEL', self.log_level, minval=0, maxval=4) + self.log_file_level = gcmd.get_int('LOG_FILE_LEVEL', self.log_file_level, minval=0, maxval=4) + self.log_visual = gcmd.get_int('LOG_VISUAL', self.log_visual, minval=0, maxval=1) + self.log_statistics = gcmd.get_int('LOG_STATISTICS', self.log_statistics, minval=0, maxval=1) + self.log_m117_messages = gcmd.get_int('LOG_M117_MESSAGES', self.log_m117_messages, minval=0, maxval=1) + + console_gate_stat = gcmd.get('CONSOLE_GATE_STAT', self.console_gate_stat) + if console_gate_stat not in self.GATE_STATS_TYPES: + raise gcmd.error("console_gate_stat is invalid. Options are: %s" % self.GATE_STATS_TYPES) + self.console_gate_stat = console_gate_stat + + self.slicer_tip_park_pos = gcmd.get_float('SLICER_TIP_PARK_POS', self.slicer_tip_park_pos, minval=0.) + self.force_form_tip_standalone = gcmd.get_int('FORCE_FORM_TIP_STANDALONE', self.force_form_tip_standalone, minval=0, maxval=1) + self.force_purge_standalone = gcmd.get_int('FORCE_PURGE_STANDALONE', self.force_purge_standalone, minval=0, maxval=1) + self.strict_filament_recovery = gcmd.get_int('STRICT_FILAMENT_RECOVERY', self.strict_filament_recovery, minval=0, maxval=1) + self.filament_recovery_on_pause = gcmd.get_int('FILAMENT_RECOVERY_ON_PAUSE', self.filament_recovery_on_pause, minval=0, maxval=1) + self.preload_attempts = gcmd.get_int('PRELOAD_ATTEMPTS', self.preload_attempts, minval=1, maxval=20) + self.encoder_move_validation = gcmd.get_int('ENCODER_MOVE_VALIDATION', self.encoder_move_validation, minval=0, maxval=1) + self.autotune_rotation_distance = gcmd.get_int('AUTOTUNE_ROTATION_DISTANCE', self.autotune_rotation_distance, minval=0, maxval=1) + self.autotune_bowden_length = gcmd.get_int('AUTOTUNE_BOWDEN_LENGTH', self.autotune_bowden_length, minval=0, maxval=1) + self.retry_tool_change_on_error = gcmd.get_int('RETRY_TOOL_CHANGE_ON_ERROR', self.retry_tool_change_on_error, minval=0, maxval=1) + self.print_start_detection = gcmd.get_int('PRINT_START_DETECTION', self.print_start_detection, minval=0, maxval=1) + self.show_error_dialog = gcmd.get_int('SHOW_ERROR_DIALOG', self.show_error_dialog, minval=0, maxval=1) + form_tip_macro = gcmd.get('FORM_TIP_MACRO', self.form_tip_macro) + if form_tip_macro != self.form_tip_macro: + self.form_tip_vars = None # If macro is changed invalidate defaults + self.form_tip_macro = form_tip_macro + self.purge_macro = gcmd.get('PURGE_MACRO', self.purge_macro) + + # Available only with espooler + if self.has_espooler(): + self.espooler_min_distance = gcmd.get_float('ESPOOLER_MIN_DISTANCE', self.espooler_min_distance, above=0) + self.espooler_max_stepper_speed = gcmd.get_float('ESPOOLER_MAX_STEPPER_SPEED', self.espooler_max_stepper_speed, above=0) + self.espooler_min_stepper_speed = gcmd.get_float('ESPOOLER_MIN_STEPPER_SPEED', self.espooler_min_stepper_speed, minval=0., below=self.espooler_max_stepper_speed) + self.espooler_speed_exponent = gcmd.get_float('ESPOOLER_SPEED_EXPONENT', self.espooler_speed_exponent, above=0) + self.espooler_assist_reduced_speed = gcmd.get_int('ESPOOLER_ASSIST_REDUCED_SPEED', 50, minval=0, maxval=100) + self.espooler_printing_power = gcmd.get_int('ESPOOLER_PRINTING_POWER', self.espooler_printing_power, minval=0, maxval=100) + self.espooler_assist_extruder_move_length = gcmd.get_float("ESPOOLER_ASSIST_EXTRUDER_MOVE_LENGTH", self.espooler_assist_extruder_move_length, above=10.) + self.espooler_assist_burst_power = gcmd.get_int("ESPOOLER_ASSIST_BURST_POWER", self.espooler_assist_burst_power, minval=0, maxval=100) + self.espooler_assist_burst_duration = gcmd.get_float("ESPOOLER_ASSIST_BURST_DURATION", self.espooler_assist_burst_duration, above=0., maxval=10.) + self.espooler_assist_burst_trigger = gcmd.get_int("ESPOOLER_ASSIST_BURST_TRIGGER", self.espooler_assist_burst_trigger, minval=0, maxval=1) + self.espooler_assist_burst_trigger_max = gcmd.get_int("ESPOOLER_ASSIST_BURST_TRIGGER_MAX", self.espooler_assist_burst_trigger_max, minval=1) + self.espooler_rewind_burst_power = gcmd.get_int("ESPOOLER_REWIND_BURST_POWER", self.espooler_rewind_burst_power, minval=0, maxval=100) + self.espooler_rewind_burst_duration = gcmd.get_float("ESPOOLER_REWIND_BURST_DURATION", self.espooler_rewind_burst_duration, above=0., maxval=10.) + + espooler_operations = list(gcmd.get('ESPOOLER_OPERATIONS', ','.join(self.espooler_operations)).split(',')) + for op in espooler_operations: + if op not in self.ESPOOLER_OPERATIONS: + raise gcmd.error("espooler_operations '%s' is invalid. Options are: %s" % (op, self.ESPOOLER_OPERATIONS)) + self.espooler_operations = espooler_operations + + # Available only with encoder + if self.has_encoder(): + cal_clog_length = self.save_variables.allVariables.get(self.VARS_MMU_CALIB_CLOG_LENGTH, None) + clog_length = gcmd.get_float('MMU_CALIBRATION_CLOG_LENGTH', cal_clog_length, minval=1., maxval=100.) + if clog_length != cal_clog_length: + self.calibration_manager.update_clog_detection_length(clog_length, force=True) + + # Currently hidden and testing options + self.test_random_failures = gcmd.get_int('TEST_RANDOM_FAILURES', self.test_random_failures, minval=0, maxval=1) + self.test_disable_encoder = gcmd.get_int('TEST_DISABLE_ENCODER', self.test_disable_encoder, minval=0, maxval=1) + self.test_force_in_print = gcmd.get_int('TEST_FORCE_IN_PRINT', self.test_force_in_print, minval=0, maxval=1) + self.canbus_comms_retries = gcmd.get_int('CANBUS_COMMS_RETRIES', self.canbus_comms_retries, minval=1, maxval=10) + self.serious = gcmd.get_int('SERIOUS', self.serious, minval=0, maxval=1) + + # Sub components + for m in self.managers: + if hasattr(m, 'set_test_config'): + m.set_test_config(gcmd) + + if not quiet: + msg = "FILAMENT MOVEMENT SPEEDS:" + msg += "\ngear_from_spool_speed = %.1f" % self.gear_from_spool_speed + msg += "\ngear_from_spool_accel = %.1f" % self.gear_from_spool_accel + msg += "\ngear_unload_speed = %.1f" % self.gear_unload_speed + msg += "\ngear_unload_accel = %.1f" % self.gear_unload_accel + if self.has_filament_buffer: + msg += "\ngear_from_buffer_speed = %.1f" % self.gear_from_buffer_speed + msg += "\ngear_from_buffer_accel = %.1f" % self.gear_from_buffer_accel + msg += "\ngear_short_move_speed = %.1f" % self.gear_short_move_speed + msg += "\ngear_short_move_accel = %.1f" % self.gear_short_move_accel + msg += "\ngear_short_move_threshold = %.1f" % self.gear_short_move_threshold + msg += "\ngear_homing_speed = %.1f" % self.gear_homing_speed + msg += "\nextruder_homing_speed = %.1f" % self.extruder_homing_speed + msg += "\nextruder_load_speed = %.1f" % self.extruder_load_speed + msg += "\nextruder_unload_speed = %.1f" % self.extruder_unload_speed + msg += "\nextruder_sync_load_speed = %.1f" % self.extruder_sync_load_speed + msg += "\nextruder_sync_unload_speed = %.1f" % self.extruder_sync_unload_speed + msg += "\nextruder_accel = %.1f" % self.extruder_accel + + msg += "\n\nMOTOR SYNC CONTROL:" + msg += "\nsync_to_extruder = %d" % self.sync_to_extruder + msg += "\nsync_form_tip = %d" % self.sync_form_tip + msg += "\nsync_purge = %d" % self.sync_purge + msg += "\nsync_gear_current = %d%%" % self.sync_gear_current + if self.has_encoder(): + msg += "\nextruder_collision_homing_current = %d%%" % self.extruder_collision_homing_current + msg += "\nextruder_form_tip_current = %d%%" % self.extruder_form_tip_current + msg += "\nextruder_purge_current = %d%%" % self.extruder_purge_current + msg += self.sync_feedback_manager.get_test_config() + + msg += "\n\nLOADING/UNLOADING:" + msg += "\ngate_homing_endstop = %s" % self.gate_homing_endstop + if self.gate_homing_endstop in [self.SENSOR_GATE] and self.has_encoder(): + msg += "\ngate_endstop_to_encoder = %s" % self.gate_endstop_to_encoder + msg += "\ngate_unload_buffer = %s" % self.gate_unload_buffer + msg += "\ngate_homing_max = %s" % self.gate_homing_max + msg += "\ngate_parking_distance = %s" % self.gate_parking_distance + msg += "\ngate_preload_homing_max = %s" % self.gate_preload_homing_max + msg += "\ngate_preload_parking_distance = %s" % self.gate_preload_parking_distance + msg += "\ngate_autoload = %s" % self.gate_autoload + msg += "\ngate_final_eject_distance = %s" % self.gate_final_eject_distance + if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY): + msg += "\nbypass_autoload = %s" % self.bypass_autoload + if self.has_encoder(): + msg += "\nbowden_apply_correction = %d" % self.bowden_apply_correction + msg += "\nbowden_allowable_load_delta = %d" % self.bowden_allowable_load_delta + msg += "\nbowden_pre_unload_test = %d" % self.bowden_pre_unload_test + msg += "\nextruder_force_homing = %d" % self.extruder_force_homing + msg += "\nextruder_homing_endstop = %s" % self.extruder_homing_endstop + msg += "\nextruder_homing_max = %.1f" % self.extruder_homing_max + msg += "\ntoolhead_extruder_to_nozzle = %.1f" % self.toolhead_extruder_to_nozzle + if self.sensor_manager.has_sensor(self.SENSOR_TOOLHEAD): + msg += "\ntoolhead_sensor_to_nozzle = %.1f" % self.toolhead_sensor_to_nozzle + msg += "\ntoolhead_homing_max = %.1f" % self.toolhead_homing_max + if self.sensor_manager.has_sensor(self.SENSOR_EXTRUDER_ENTRY): + msg += "\ntoolhead_entry_to_extruder = %.1f" % self.toolhead_entry_to_extruder + msg += "\ntoolhead_residual_filament = %.1f" % self.toolhead_residual_filament + msg += "\ntoolhead_ooze_reduction = %.1f" % self.toolhead_ooze_reduction + msg += "\ntoolhead_unload_safety_margin = %d" % self.toolhead_unload_safety_margin + msg += "\ntoolhead_entry_tension_test = %d" % self.toolhead_entry_tension_test + msg += "\ntoolhead_post_load_tighten = %d" % self.toolhead_post_load_tighten + msg += "\ntoolhead_post_load_tension_adjust = %d" % self.toolhead_post_load_tension_adjust + msg += "\ngcode_load_sequence = %d" % self.gcode_load_sequence + msg += "\ngcode_unload_sequence = %d" % self.gcode_unload_sequence + + msg += "\n\nTIP FORMING/CUTTING:" + msg += "\nform_tip_macro = %s" % self.form_tip_macro + msg += "\nforce_form_tip_standalone = %d" % self.force_form_tip_standalone + if not self.force_form_tip_standalone: + msg += "\nslicer_tip_park_pos = %.1f" % self.slicer_tip_park_pos + + msg += "\n\nPURGING:" + msg += "\npurge_macro = %s" % self.purge_macro + msg += "\nforce_purge_standalone = %d" % self.force_purge_standalone + + if self.has_espooler(): + msg += "\n\nESPOOLER:" + msg += "\nespooler_min_distance = %s" % self.espooler_min_distance + msg += "\nespooler_max_stepper_speed = %s" % self.espooler_max_stepper_speed + msg += "\nespooler_min_stepper_speed = %s" % self.espooler_min_stepper_speed + msg += "\nespooler_speed_exponent = %s" % self.espooler_speed_exponent + msg += "\nespooler_assist_reduced_speed = %s%%" % self.espooler_assist_reduced_speed + msg += "\nespooler_printing_power = %s%%" % self.espooler_printing_power + msg += "\nespooler_assist_extruder_move_length = %s" % self.espooler_assist_extruder_move_length + msg += "\nespooler_assist_burst_power = %d" % self.espooler_assist_burst_power + msg += "\nespooler_assist_burst_duration = %s" % self.espooler_assist_burst_duration + msg += "\nespooler_assist_burst_trigger = %d" % self.espooler_assist_burst_trigger + msg += "\nespooler_assist_burst_trigger_max = %d" % self.espooler_assist_burst_trigger_max + msg += "\nespooler_rewind_burst_power = %d" % self.espooler_rewind_burst_power + msg += "\nespooler_rewind_burst_duration = %s" % self.espooler_rewind_burst_duration + msg += "\nespooler_operations = %s" % self.espooler_operations + + # Heater/Environment + msg += self.environment_manager.get_test_config() + + msg += "\n\nLOGGING:" + msg += "\nlog_level = %d" % self.log_level + msg += "\nlog_visual = %d" % self.log_visual + if self.mmu_logger: + msg += "\nlog_file_level = %d" % self.log_file_level + msg += "\nlog_statistics = %d" % self.log_statistics + msg += "\nlog_m117_messages = %d" % self.log_m117_messages + msg += "\nconsole_gate_stat = %s" % self.console_gate_stat + + msg += "\n\nOTHER:" + msg += "\nextruder_temp_variance = %.1f" % self.extruder_temp_variance + msg += "\nendless_spool_enabled = %d" % self.endless_spool_enabled + msg += "\nendless_spool_on_load = %d" % self.endless_spool_on_load + msg += "\nendless_spool_eject_gate = %d" % self.endless_spool_eject_gate + msg += "\nspoolman_support = %s" % self.spoolman_support + msg += "\nt_macro_color = %s" % self.t_macro_color + msg += "\npreload_attempts = %d" % self.preload_attempts + if self.has_encoder(): + msg += "\nstrict_filament_recovery = %d" % self.strict_filament_recovery + msg += "\nencoder_move_validation = %d" % self.encoder_move_validation + msg += "\nautotune_rotation_distance = %d" % self.autotune_rotation_distance + msg += "\nautotune_bowden_length = %d" % self.autotune_bowden_length + msg += "\nfilament_recovery_on_pause = %d" % self.filament_recovery_on_pause + msg += "\nretry_tool_change_on_error = %d" % self.retry_tool_change_on_error + msg += "\nprint_start_detection = %d" % self.print_start_detection + msg += "\nshow_error_dialog = %d" % self.show_error_dialog + + # Selector and Servo + msg += self.selector.get_test_config() + + # These are in mmu_vars.cfg and are offered here for convenience + msg += "\n\nCALIBRATION (mmu_vars.cfg):" + if self.mmu_machine.variable_bowden_lengths: + msg += "\nmmu_calibration_bowden_lengths = %s" % self.bowden_lengths + else: + msg += "\nmmu_calibration_bowden_length = %.1f" % self.bowden_lengths[0] + if self.has_encoder(): + msg += "\nmmu_calibration_clog_length = %.1f" % self.save_variables.allVariables.get(self.VARS_MMU_CALIB_CLOG_LENGTH, -1) + + self.log_info(msg) + + # Some changes need additional action to be taken + if prev_spoolman_support != self.spoolman_support: + self._spoolman_sync() + if prev_t_macro_color != self.t_macro_color: + self._update_t_macros() + + +########################################### +# RUNOUT, ENDLESS SPOOL and GATE HANDLING # +########################################### + + # Handler for all "runout" type events including "clog" and "tangle". + # If event_type is None then caller isn't sure (runout or clog) + def _runout(self, event_type=None, sensor=None): + with self._wrap_suspend_filament_monitoring(): # Don't want runout accidently triggering during handling + self.is_handling_runout = (event_type == "runout") # Best starting assumption + self._save_toolhead_position_and_park('runout') # includes "clog" and "tangle" + + type_str = event_type or "runout/clog/tangle" + if self.tool_selected < 0: + raise MmuError("Filament %s on an unknown or bypass tool\nManual intervention is required" % type_str) + + if self.filament_pos != self.FILAMENT_POS_LOADED and event_type is None: + raise MmuError("Filament %s occured but filament is marked as not loaded(?)\nManual intervention is required" % type_str) + + self.log_debug("Issue on tool T%d" % self.tool_selected) + + # Check for clog/tangle by looking for filament at the gate (or in the encoder) + if event_type is None: + if not self.check_filament_runout(): + if self.has_encoder(): + self.encoder_sensor.note_clog_detection_length() + # Eliminate runout + event_type = "clog/tangle" + self.is_handling_runout = False + raise MmuError("A clog/tangle has been detected and requires manual intervention") + else: + # We definitely have a filament runout + type_str = event_type = "runout" + self.is_handling_runout = True # Will remain true until complete and continue or resume after error + + if event_type == "runout": + if self.endless_spool_enabled: + self._next_tool = self.tool_selected # Valid only during the reload process - cleared in _continue_after() + self._set_gate_status(self.gate_selected, self.GATE_EMPTY) # Indicate current gate is empty + next_gate, msg = self._get_next_endless_spool_gate(self.tool_selected, self.gate_selected) + if next_gate == -1: + raise MmuError("Runout detected on %s\nNo alternative gates available after checking %s" % (sensor, msg)) + + self.log_error("A runout has been detected. Checking for alternative gates %s" % msg) + self.log_info("Remapping T%d to gate %d" % (self.tool_selected, next_gate)) + + if self.endless_spool_eject_gate > 0: + self.log_info("Ejecting filament remains to designated waste gate %d" % self.endless_spool_eject_gate) + self.select_gate(self.endless_spool_eject_gate) + self._unload_tool(form_tip=self.FORM_TIP_STANDALONE) + self._eject_from_gate() # Push completely out of gate + self.select_gate(next_gate) # Necessary if unloaded to waste gate + self._remap_tool(self.tool_selected, next_gate) + self._select_and_load_tool(self.tool_selected, purge=self.PURGE_STANDALONE) # if user has set up standalone purging, respect option and purge. + + self._continue_after("endless_spool") + self.pause_resume.send_resume_command() # Undo what runout sensor handling did + return + else: + raise MmuError("Runout detected on %s\nEndlessSpool mode is off - manual intervention is required" % sensor) + + raise MmuError("A %s has been detected on %s and requires manual intervention" % (type_str, sensor)) + + def _get_next_endless_spool_gate(self, tool, gate): + group = self.endless_spool_groups[gate] + next_gate = -1 + checked_gates = [] + for i in range(self.num_gates - 1): + check = (gate + i + 1) % self.num_gates + if self.endless_spool_groups[check] == group: + checked_gates.append(check) + if self.gate_status[check] != self.GATE_EMPTY: + next_gate = check + break + alt_gates = "(checked gates: %s)" % ",".join(map(str, checked_gates)) + msg = "for T%d in EndlessSpool Group %s %s" % (tool, chr(ord('A') + group), alt_gates) + return next_gate, msg + + # Use pre-gate (and gear) sensors to "correct" gate status + # Return updated gate_status adjusted by sensor readings + def _validate_gate_status(self, gate_status): + v_gate_status = list(gate_status) # Ensure that webhooks sees get_status() change + for gate, status in enumerate(v_gate_status): + gear_detected = self.sensor_manager.check_gate_sensor(self.SENSOR_GEAR_PREFIX, gate) + if gear_detected is True: + v_gate_status[gate] = self.GATE_AVAILABLE + else: + pre_detected = self.sensor_manager.check_gate_sensor(self.SENSOR_PRE_GATE_PREFIX, gate) + if pre_detected is True and status == self.GATE_EMPTY: + v_gate_status[gate] = self.GATE_UNKNOWN + elif pre_detected is False and status != self.GATE_EMPTY: + v_gate_status[gate] = self.GATE_EMPTY + return v_gate_status + + # Use post-gear sensors to correct the selected gate. + # Returns the unique detected gate index, or None if zero/multiple detected. + def _validate_gate_selected(self): + gate = None + for g in range(self.num_gates): + if self.sensor_manager.check_all_sensors_before(self.FILAMENT_POS_START_BOWDEN, g, loading=True) is True: + if gate is None: + gate = g + else: + return None + return gate + + def _get_filament_char(self, gate, no_space=False, show_source=False): + show_source &= self.has_filament_buffer + gate_status = self.gate_status[gate] + if self.endless_spool_enabled and gate == self.endless_spool_eject_gate: + return "W" + elif gate_status == self.GATE_AVAILABLE_FROM_BUFFER: + return "B" if show_source else "*" + elif gate_status == self.GATE_AVAILABLE: + return "S" if show_source else "*" + elif gate_status == self.GATE_EMPTY: + return (UI_SEPARATOR if no_space else " ") + else: + return "?" + + def _ttg_map_to_string(self, tool=None, show_groups=True): + if show_groups: + msg = "TTG Map & EndlessSpool Groups:\n" + else: + msg = "TTG Map:\n" # String used to filter in KS-HH + num_tools = self.num_gates + tools = range(num_tools) if tool is None else [tool] + for i in tools: + gate = self.ttg_map[i] + filament_char = self._get_filament_char(gate, show_source=False) + msg += "\n" if i and tool is None else "" + msg += "T{:<2}-> Gate{:>2}({})".format(i, gate, filament_char) + + if show_groups and self.endless_spool_enabled: + group = self.endless_spool_groups[gate] + msg += " Group %s:" % chr(ord('A') + group) + gates_in_group = [(j + gate) % num_tools for j in range(num_tools)] + #msg += " >".join("{:>2}({})".format(g, self._get_filament_char(g, show_source=False)) for g in gates_in_group if self.endless_spool_groups[g] == group) + msg += " >".join("{:>2}".format(g) for g in gates_in_group if self.endless_spool_groups[g] == group) + + if i == self.tool_selected: + msg += " [SELECTED]" + return msg + + def _mmu_visual_to_string(self): + divider = UI_SPACE + UI_SEPARATOR + UI_SPACE + c_sum = 0 + msg_units = "Unit : " + msg_gates = "Gate : " + msg_avail = "Avail: " + msg_tools = "Tools: " + msg_selct = "Selct: " + for unit_index, gate_count in enumerate(self.mmu_machine.gate_counts): + gate_indices = range(c_sum, c_sum + gate_count) + c_sum += gate_count + last_gate = gate_indices[-1] == self.num_gates - 1 + sep = ("|" + divider) if not last_gate else "|" + tool_strings = [] + select_strings = [] + for g in gate_indices: + msg_gates += "".join("|{:^3}".format(g) if g < 10 else "| {:2}".format(g)) + msg_avail += "".join("| %s " % self._get_filament_char(g, no_space=True, show_source=True)) + tool_str = "+".join("T%d" % t for t in range(self.num_gates) if self.ttg_map[t] == g) + tool_strings.append(("|%s " % (tool_str if tool_str else " {} ".format(UI_SEPARATOR)))[:4]) + if self.gate_selected == g and self.gate_selected != self.TOOL_GATE_UNKNOWN: + select_strings.append("|\%s/|" % (UI_SEPARATOR if self.filament_pos < self.FILAMENT_POS_START_BOWDEN else "*")) + else: + select_strings.append("----") + unit_str = "{0:-^{width}}".format( " " + str(unit_index) + " ", width=len(gate_indices) * 4 + 1) + msg_units += unit_str + (divider if not last_gate else "") + msg_gates += sep + msg_avail += sep + msg_tools += "".join(tool_strings) + sep + msg_selct += ("".join(select_strings) + "-")[:len(gate_indices) * 4 + 1] + (divider if not last_gate else "") + lines = [msg_units] if len(self.mmu_machine.units) > 1 else [] + lines.extend([msg_gates, msg_tools, msg_avail, msg_selct]) + msg = "\n".join(lines) + if self.selector.is_homed: + msg += " " + self.selected_tool_string() + else: + msg += " NOT HOMED" + return msg + + def _es_groups_to_string(self, title=None): + msg = "%s:\n" % title if title else "EndlessSpool Groups:\n" + groups = {} + for gate in range(self.num_gates): + group = self.endless_spool_groups[gate] + if group not in groups: + groups[group] = [gate] + else: + groups[group].append(gate) + msg += "\n".join( + "Group %s: Gates: %s" % (chr(ord('A') + group), ", ".join(map(str, gates))) + for group, gates in groups.items() + ) + return msg + + def _gate_map_to_string(self, detail=False): + msg = "Gates / Filaments:" # String used to filter in KS-HH + available_status = { + self.GATE_AVAILABLE_FROM_BUFFER: "Buffer", + self.GATE_AVAILABLE: "Spool", + self.GATE_EMPTY: "Empty", + self.GATE_UNKNOWN: "Unknown" + } + + for g in range(self.num_gates): + available = available_status[self.gate_status[g]] + name = self.gate_filament_name[g] or "Unknown" + material = self.gate_material[g] or "Unknown" + color = self._format_color(self.gate_color[g] or "n/a") + temperature = self.gate_temperature[g] or "n/a" + + gate_fstr = "" + if detail: + filament_char = self._get_filament_char(g, show_source=False) + tools = ",".join("T{}".format(t) for t in range(self.num_gates) if self.ttg_map[t] == g) + tools_fstr = (" [{}]".format(tools) if tools else "") + gate_fstr = "{}".format(g).ljust(2, UI_SPACE) + gate_fstr = "{}({}){}:".format(gate_fstr, filament_char, tools_fstr).ljust(15, UI_SPACE) + else: + gate_fstr = "{}:".format(g).ljust(3, UI_SPACE) + + available_fstr = "{};".format(available).ljust(9, UI_SPACE) + fil_fstr = "{} | {}{}C | {} | {}".format(material, temperature, UI_DEGREE, color, name) + + spool_option = (str(self.gate_spool_id[g]) if self.gate_spool_id[g] > 0 else "n/a") + if self.spoolman_support == self.SPOOLMAN_OFF: + spool_fstr = "" + elif self.gate_spool_id[g] <= 0: + spool_fstr = "Id: {};".format(spool_option).ljust(12, UI_SPACE) + else: + spool_fstr = "Id: {}".format(spool_option).ljust(8, UI_SPACE) + "--> " + + speed_fstr = " [Speed:{}%]".format(self.gate_speed_override[g]) if self.gate_speed_override[g] != 100 else "" + extra_fstr = " [Selected]" if detail and g == self.gate_selected else "" + + msg += "\n{}{}{}{}{}{}".format(gate_fstr, available_fstr, spool_fstr, fil_fstr, speed_fstr, extra_fstr) + return msg + + # Remap a tool/gate relationship and gate filament availability + def _remap_tool(self, tool, gate, available=None): + self.ttg_map = list(self.ttg_map) # Ensure that webhook sees get_status() change + self.ttg_map[tool] = gate + self._persist_ttg_map() + self._ensure_ttg_match() + self._update_slicer_color_rgb() # Indexed by gate + if available is not None: + self._set_gate_status(gate, available) + + # Find and set a tool that maps to gate (for recovery) + def _ensure_ttg_match(self): + if self.gate_selected in [self.TOOL_GATE_UNKNOWN, self.TOOL_GATE_BYPASS]: + self._set_tool_selected(self.gate_selected) + else: + possible_tools = [tool for tool in range(self.num_gates) if self.ttg_map[tool] == self.gate_selected] + if possible_tools: + if self.tool_selected not in possible_tools: + self.log_debug("Resetting tool selected to match TTG map for current gate (%d)" % self.gate_selected) + self._set_tool_selected(possible_tools[0]) + else: + self.log_warning("Resetting tool selected to unknown because current gate (%d) isn't associated with tool in TTG map" % self.gate_selected) + self._set_tool_selected(self.TOOL_GATE_UNKNOWN) + + def _persist_ttg_map(self): + self.save_variable(self.VARS_MMU_TOOL_TO_GATE_MAP, self.ttg_map, write=True) + + def _reset_ttg_map(self): + self.log_debug("Resetting TTG map") + self.ttg_map = list(self.default_ttg_map) + self._persist_ttg_map() + self._ensure_ttg_match() + self._update_slicer_color_rgb() # Indexed by gate + + def _persist_endless_spool(self): + self.save_variable(self.VARS_MMU_ENABLE_ENDLESS_SPOOL, self.endless_spool_enabled) + self.save_variable(self.VARS_MMU_ENDLESS_SPOOL_GROUPS, self.endless_spool_groups) + self.write_variables() + + def _reset_endless_spool(self): + self.log_debug("Resetting Endless Spool mapping") + self.endless_spool_enabled = self.default_endless_spool_enabled + self.endless_spool_groups = list(self.default_endless_spool_groups) + self._persist_endless_spool() + + def _set_gate_status(self, gate, state): + if 0 <= gate < self.num_gates: + if state != self.gate_status[gate]: + self.gate_status = list(self.gate_status) # Ensure that webhooks sees get_status() change + self.gate_status[gate] = state + self._persist_gate_status() + self.led_manager.gate_map_changed(gate) + self.mmu_macro_event(self.MACRO_EVENT_GATE_MAP_CHANGED, "GATE=%d" % gate) + + def _persist_gate_status(self): + self.save_variable(self.VARS_MMU_GATE_STATUS, self.gate_status, write=True) + + # Ensure that webhooks sees get_status() change after gate map update. It is important to call this prior to + # updating gate_map so change is always seen. This approach removes need to copy lists on every call to get_status() + def _renew_gate_map(self): + self.gate_status = list(self.gate_status) + self.gate_filament_name = list(self.gate_filament_name) + self.gate_material = list(self.gate_material) + self.gate_color = list(self.gate_color) + self.gate_temperature = list(self.gate_temperature) + self.gate_spool_id = list(self.gate_spool_id) + self.gate_speed_override = list(self.gate_speed_override) + + def _persist_gate_map(self, spoolman_sync=False, gate_ids=None): + self.save_variable(self.VARS_MMU_GATE_STATUS, self.gate_status) + self.save_variable(self.VARS_MMU_GATE_FILAMENT_NAME, self.gate_filament_name) + self.save_variable(self.VARS_MMU_GATE_MATERIAL, self.gate_material) + self.save_variable(self.VARS_MMU_GATE_COLOR, self.gate_color) + self.save_variable(self.VARS_MMU_GATE_TEMPERATURE, self.gate_temperature) + self.save_variable(self.VARS_MMU_GATE_SPOOL_ID, self.gate_spool_id) + self.save_variable(self.VARS_MMU_GATE_SPEED_OVERRIDE, self.gate_speed_override) + self.write_variables() + self._update_t_macros() + + # Also persist to spoolman db if pushing updates for visability + if spoolman_sync: + if self.spoolman_support == self.SPOOLMAN_PUSH: + if gate_ids is None: + gate_ids = list(enumerate(self.gate_spool_id)) + if gate_ids: + self._spoolman_push_gate_map(gate_ids) + elif self.spoolman_support == self.SPOOLMAN_READONLY: + self._spoolman_update_filaments(gate_ids) + + self._moonraker_push_lane_data(gate_ids) + + self.led_manager.gate_map_changed(None) + if self.printer.lookup_object("gcode_macro %s" % self.mmu_event_macro, None) is not None: + self.mmu_macro_event(self.MACRO_EVENT_GATE_MAP_CHANGED, "GATE=-1") + + def _reset_gate_map(self): + self.log_debug("Resetting gate map") + self.gate_status = self._validate_gate_status(self.default_gate_status) + self.gate_filament_name = list(self.default_gate_filament_name) + self.gate_material = list(self.default_gate_material) + self.gate_color = list(self.default_gate_color) + self.gate_temperature = list(self.default_gate_temperature) + if self.spoolman_support in [self.SPOOLMAN_OFF, self.SPOOLMAN_PULL]: + self.gate_spool_id = [-1] * self.num_gates + else: + self.gate_spool_id = list(self.default_gate_spool_id) + self.gate_speed_override = list(self.default_gate_speed_override) + self._update_gate_color_rgb() + self._persist_gate_map(spoolman_sync=True) + + def _automap_gate(self, tool, strategy): + if tool is None: + self.log_error("Automap tool called without a tool argument") + return + tool_to_remap = self.slicer_tool_map['tools'][str(tool)] + # strategy checks + if strategy in ['spool_id']: + self.log_error("'%s' automapping strategy is not yet supported. Support for this feature is on the way, please be patient." % strategy) + return + + # Create printable strategy string + strategy_str = strategy.replace("_", " ").title() + + # Deduct search_in and tool_field based on strategy + # tool fields are like {'color': color, 'material': material, 'temp': temp, 'name': name, 'in_use': used} + if strategy == self.AUTOMAP_FILAMENT_NAME: + search_in = self.gate_filament_name + tool_field = 'name' + elif strategy == self.AUTOMAP_SPOOL_ID: + search_in = self.gate_spool_id + tool_field = 'spool_id' # Placeholders for future support + elif strategy == self.AUTOMAP_MATERIAL: + search_in = self.gate_material + tool_field = 'material' + elif strategy in [self.AUTOMAP_CLOSEST_COLOR, self.AUTOMAP_COLOR]: + search_in = self.gate_color + tool_field = 'color' + else: + self.log_error("Invalid automap strategy '%s'" % strategy) + return + + # Automapping logic + errors = [] + warnings = [] + messages = [] + remaps = [] + + if not tool_to_remap[tool_field]: + errors.append("%s of tool %s must be set. When using automapping all referenced tools must have a %s" % (tool_field, tool, strategy_str)) + + if not errors: + # 'standard' exactly matching fields + if strategy != self.AUTOMAP_CLOSEST_COLOR: + for gn, gate_feature in enumerate(search_in): + # When matching by name normalize possible unicode characters and match case-insensitive + if strategy == self.AUTOMAP_FILAMENT_NAME: + equal = self._compare_unicode(tool_to_remap[tool_field], gate_feature) + elif strategy == self.AUTOMAP_COLOR: + equal = tool_to_remap[tool_field].upper().ljust(8,'F') == gate_feature.upper().ljust(8,'F') + else: + equal = tool_to_remap[tool_field] == gate_feature + if equal: + remaps.append("T%s --> G%s (%s)" % (tool, gn, gate_feature)) + self.wrap_gcode_command("MMU_TTG_MAP TOOL=%d GATE=%d QUIET=1" % (tool, gn)) + if not remaps: + errors.append("No gates found for tool %s with %s %s" % (tool, strategy_str, tool_to_remap[tool_field])) + + # 'colors' search for closest + elif strategy == self.AUTOMAP_CLOSEST_COLOR: + if tool_to_remap['material'] == "unknown": + errors.append("When automapping with closest color, the tool material must be set.") + if tool_to_remap['material'] not in self.gate_material: + errors.append("No gate has a filament matching the desired material (%s). Available are : %s" % (tool_to_remap['material'], self.gate_material)) + if not errors: + color_list = [] + for gn, color in enumerate(search_in): + gm = "".join(self.gate_material[gn].strip()).replace('#', '').lower() + if gm == tool_to_remap['material'].lower(): + color_list.append(color) + if not color_list: + errors.append("Gates with %s are missing color information..." % tool_to_remap['material']) + + if not errors: + closest, distance = self._find_closest_color(tool_to_remap['color'], color_list) + for gn, color in enumerate(search_in): + gm = "".join(self.gate_material[gn].strip()).replace('#', '').lower() + if gm == tool_to_remap['material'].lower(): + if closest == color: + t = self.console_gate_stat + if distance > 0.5: + warnings.append("Color matching is significantly different ! %s" % (UI_EMOTICONS[7] if t == 'emoticon' else '')) + elif distance > 0.2: + warnings.append("Color matching might be noticebly different %s" % (UI_EMOTICONS[5] if t == 'emoticon' else '')) + elif distance > 0.05: + warnings.append("Color matching seems quite good %s" % (UI_EMOTICONS[3] if t == 'emoticon' else '')) + elif distance > 0.02: + warnings.append("Color matching is excellent %s" % (UI_EMOTICONS[2] if t == 'emoticon' else '')) + elif distance < 0.02: + warnings.append("Color matching is perfect %s" % (UI_EMOTICONS[1] if t == 'emoticon' else '')) + remaps.append("T%s --> G%s (%s with closest color: %s)" % (tool, gn, gm, color)) + self.wrap_gcode_command("MMU_TTG_MAP TOOL=%d GATE=%d QUIET=1" % (tool, gn)) + + if not remaps: + errors.append("Unable to find a suitable color for tool %s (color: %s)" % (tool, tool_to_remap['color'])) + if len(remaps) > 1: + warnings.append("Multiple gates found for tool %s with %s '%s'" % (tool, strategy_str, tool_to_remap[tool_field])) + + # Display messages while automapping + if remaps: + remaps.insert(0, "Automatically mapped tool %s based on %s" % (tool, strategy_str)) + for msg in remaps: + self.log_always(msg) + if messages: + for msg in messages: + self.log_always(msg) + # Display warnings while automapping + for msg in warnings: + self.log_info(msg) + # Display errors while automapping + if errors: + reason = ["Error during automapping"] + if self.is_printing(): + self.handle_mmu_error("\n".join(reason+errors)) + else: + self.log_error(reason[0]) + for e in errors: + self.log_error(e) + + # Set 'color' and 'spool_id' variable on the Tx macro for Mainsail/Fluidd to pick up + # We don't use SET_GCODE_VARIABLE because the macro variable may not exist ahead of time + def _update_t_macros(self): + for tool in range(self.num_gates): + gate = self.ttg_map[tool] + t_macro = self.printer.lookup_object("gcode_macro T%d" % tool, None) + + if t_macro: + t_vars = dict(t_macro.variables) # So Mainsail sees the update + + spool_id = self.gate_spool_id[gate] + if (self.t_macro_color != self.T_MACRO_COLOR_OFF and + spool_id >= 0 and + self.spoolman_support != self.SPOOLMAN_OFF and + self.gate_status[gate] != self.GATE_EMPTY): + + t_vars['spool_id'] = self.gate_spool_id[gate] + else: + t_vars.pop('spool_id', None) + + if self.t_macro_color == self.T_MACRO_COLOR_SLICER: + st = self.slicer_tool_map['tools'].get(str(tool), None) + rgb_hex = self._color_to_rgb_hex(st.get('color', None)) if st else None + if rgb_hex: + t_vars['color'] = rgb_hex + else: + t_vars.pop('color', None) + + elif self.t_macro_color in [self.T_MACRO_COLOR_GATEMAP, self.T_MACRO_COLOR_ALLGATES]: + rgb_hex = self._color_to_rgb_hex(self.gate_color[gate]) + if self.gate_status[gate] != self.GATE_EMPTY or self.t_macro_color == self.T_MACRO_COLOR_ALLGATES: + t_vars['color'] = rgb_hex + else: + t_vars.pop('color', None) + + else: # 'off' case + t_vars.pop('color', None) + + t_macro.variables = t_vars + +### GCODE COMMANDS FOR RUNOUT, TTG MAP, GATE MAP and GATE LOGIC ################## + + cmd_MMU_TEST_RUNOUT_help = "Manually invoke the clog/runout detection logic for testing" + def cmd_MMU_TEST_RUNOUT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + + event_type = gcmd.get('TYPE', None) + try: + with self.wrap_sync_gear_to_extruder(): + self._runout(event_type=event_type, sensor="TEST") + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_ENCODER_RUNOUT_help = "Internal encoder filament runout handler" + def cmd_MMU_ENCODER_RUNOUT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if not self.is_enabled: + self.pause_resume.send_resume_command() # Undo what runout sensor handling did + return + self._fix_started_state() + try: + with self.wrap_sync_gear_to_extruder(): + self._runout(sensor="Encoder") + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_ENCODER_INSERT_help = "Internal encoder filament insert detection handler" + def cmd_MMU_ENCODER_INSERT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if not self.is_enabled: return + # TODO Possible future bypass preload feature - make gate act like bypass + + # Callback to handle runout event from an MMU sensors. Note that pause_resume.send_pause_command() + # will have already been issued but no PAUSE command + # Params: + # EVENTTIME will contain reactor time that the sensor triggered and command was queued + # SENSOR will contain sensor name + # GATE will be set if specific pre-gate or gear sensor + cmd_MMU_SENSOR_RUNOUT_help= "Internal MMU filament runout handler" + def cmd_MMU_SENSOR_RUNOUT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if not self.is_enabled: + self.pause_resume.send_resume_command() # Undo what runout sensor handling did + return + self._fix_started_state() + eventtime = gcmd.get_float('EVENTTIME', self.reactor.monotonic()) + gate = gcmd.get_int('GATE', None) + raw_sensor = gcmd.get('SENSOR', "") + sensor = self.sensor_manager.get_unitless_sensor_name(raw_sensor) + process_runout = False + + try: + with self.wrap_sync_gear_to_extruder(): + if eventtime < self.runout_last_enable_time: + self.log_debug("Assertion failure: Late sensor runout event on %s. Ignored" % raw_sensor) + + elif sensor and self.sensor_manager.check_sensor(sensor): + self.log_debug("Assertion failure: Runout handler suspects sensor malfunction on %s. Ignored" % raw_sensor) + + else: + # Always update gate map from pre-gate sensor + if sensor.startswith(self.SENSOR_PRE_GATE_PREFIX) and gate != self.gate_selected: + self._set_gate_status(gate, self.GATE_EMPTY) + + # Real runout to process... + if sensor.startswith(self.SENSOR_PRE_GATE_PREFIX) and gate == self.gate_selected: + if self.endless_spool_enabled and self.endless_spool_eject_gate == gate: + self.log_trace("Ignoring filament runout detected by %s because endless_spool_eject_gate is active on that gate" % raw_sensor) + else: + process_runout = True + + elif sensor == self.SENSOR_GATE and gate is None: + process_runout = True + + elif sensor.startswith(self.SENSOR_GEAR_PREFIX) and gate == self.gate_selected: + process_runout = True + + elif sensor.startswith(self.SENSOR_EXTRUDER_ENTRY): + raise MmuError("Filament runout occured at extruder. Manual intervention is required") + + else: + self.log_debug("Assertion failure: Unexpected/unhandled sensor runout event on %s. Ignored" % raw_sensor) + + if process_runout: + self._runout(event_type="runout", sensor=sensor) # Will send_resume_command() or fail and pause + else: + self.pause_resume.send_resume_command() # Undo what runout sensor handling did + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_SENSOR_CLOG_help= "Internal MMU filament clog handler" + def cmd_MMU_SENSOR_CLOG(self, gcmd): + try: + with self.wrap_sync_gear_to_extruder(): + self._clog_tangle(gcmd, "clog") + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_SENSOR_TANGLE_help= "Internal MMU filament tangle handler" + def cmd_MMU_SENSOR_TANGLE(self, gcmd): + try: + with self.wrap_sync_gear_to_extruder(): + self._clog_tangle(gcmd, "tangle") + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + # Common callback to handle clog/tangle event from an MMU sensors. + # Note that pause_resume.send_pause_command() will have already been issued but no PAUSE command + # Params: + # EVENTTIME will contain reactor time that the sensor triggered and command was queued + # SENSOR will contain sensor name + def _clog_tangle(self, gcmd, event_type): + self.log_to_file(gcmd.get_commandline()) + if not self.is_enabled: + self.pause_resume.send_resume_command() # Undo what runout sensor handling did + return + self._fix_started_state() + eventtime = gcmd.get_float('EVENTTIME', self.reactor.monotonic()) + sensor = gcmd.get('SENSOR', "") + self._runout(event_type=event_type, sensor=sensor) # Will send_resume_command() or fail and pause + + # Callback to handle insert event from an MMU sensor + # Params: + # EVENTTIME will contain reactor time that the sensor triggered and command was queued + # SENSOR will contain sensor name + # GATE will be set if specific pre-gate or gear sensor + cmd_MMU_SENSOR_INSERT_help= "Internal MMU filament insertion handler" + def cmd_MMU_SENSOR_INSERT(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if not self.is_enabled: return + self._fix_started_state() + eventtime = gcmd.get_float('EVENTTIME', self.reactor.monotonic()) + gate = gcmd.get_int('GATE', None) + raw_sensor = gcmd.get('SENSOR', "") + sensor = self.sensor_manager.get_unitless_sensor_name(raw_sensor) + + try: + with self.wrap_sync_gear_to_extruder(): + if sensor.startswith(self.SENSOR_PRE_GATE_PREFIX) and gate is not None: + self._set_gate_status(gate, self.GATE_UNKNOWN) + self._check_pending_spool_id(gate) # Have spool_id ready? + if not self.is_printing() and self.gate_autoload: + self.gcode.run_script_from_command("MMU_PRELOAD GATE=%d" % gate) + + elif sensor == self.SENSOR_EXTRUDER_ENTRY: + if self.gate_selected != self.TOOL_GATE_BYPASS: + msg = "bypass not selected" + elif self.is_printing(): + msg = "actively printing" # Should not get here! + elif self.filament_pos != self.FILAMENT_POS_UNLOADED: + msg = "extruder cannot be verified as unloaded. Try running MMU_RECOVER to fix state" + elif not self.bypass_autoload: + msg = "bypass autoload is disabled" + else: + self.log_debug("Autoloading extruder") + with self._wrap_suspend_filament_monitoring(): + self._note_toolchange("> Bypass") + self.load_sequence(bowden_move=0., extruder_only=True, purge=self.PURGE_NONE) # TODO PURGE_STANDALONE? + return + self.log_debug("Ignoring extruder insertion because %s" % msg) + + else: + self.log_debug("Assertion failure: Unexpected/unhandled sensor insert event on %s. Ignored" % raw_sensor) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + # Callback to handle removal event from an MMU sensor (only mmu_pre_gate for now). A removal + # event can happen both in an out of a print + # Params: + # EVENTTIME will contain reactor time that the sensor triggered and command was queued + # SENSOR will contain sensor name + # GATE will be set if specific pre-gate or gear sensor + cmd_MMU_SENSOR_REMOVE_help= "Internal MMU filament removal handler" + def cmd_MMU_SENSOR_REMOVE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if not self.is_enabled: return + self._fix_started_state() + eventtime = gcmd.get_float('EVENTTIME', self.reactor.monotonic()) + gate = gcmd.get_int('GATE', None) + raw_sensor = gcmd.get('SENSOR', "") + sensor = self.sensor_manager.get_unitless_sensor_name(raw_sensor) + + try: + with self.wrap_sync_gear_to_extruder(): + if sensor.startswith(self.SENSOR_PRE_GATE_PREFIX) and gate is not None: + # Ignore pre-gate runout if endless_spool_eject_gate feature is active and we want filament to be consumed to clear gate + if not(self.endless_spool_enabled and self.endless_spool_eject_gate > 0): + self._set_gate_status(gate, self.GATE_EMPTY) + else: + self.log_trace("Ignoring filament removal detected by %s because endless_spool_eject_gate is active" % raw_sensor) + + else: + self.log_debug("Assertion failure: Unexpected/unhandled sensor remove event on %s. Ignored" % raw_sensor) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_M400_help = "Wait on both move queues" + def cmd_MMU_M400(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + self.mmu_toolhead.quiesce() + + cmd_MMU_TTG_MAP_help = "aka MMU_REMAP_TTG Display or remap a tool to a specific gate and set gate availability" + def cmd_MMU_TTG_MAP(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + detail = bool(gcmd.get_int('DETAIL', 0, minval=0, maxval=1)) + ttg_map = gcmd.get('MAP', "!") + gate = gcmd.get_int('GATE', -1, minval=0, maxval=self.num_gates - 1) + tool = gcmd.get_int('TOOL', -1, minval=0, maxval=self.num_gates - 1) + available = gcmd.get_int('AVAILABLE', self.GATE_UNKNOWN, minval=self.GATE_EMPTY, maxval=self.GATE_AVAILABLE) + + try: + if reset == 1: + self._reset_ttg_map() + elif ttg_map != "!": + ttg_map = gcmd.get('MAP').split(",") + if len(ttg_map) != self.num_gates: + self.log_always("The number of map values (%d) is not the same as number of gates (%d)" % (len(ttg_map), self.num_gates)) + return + self.ttg_map = [] + for gate in ttg_map: + if gate.isdigit(): + self.ttg_map.append(int(gate)) + else: + self.ttg_map.append(0) + self._persist_ttg_map() + elif gate != -1: + status = self.gate_status[gate] + if not available == self.GATE_UNKNOWN or (available == self.GATE_UNKNOWN and status == self.GATE_EMPTY): + status = available + if tool == -1: + self._set_gate_status(gate, status) + else: + self._remap_tool(tool, gate, status) + else: + quiet = False # Display current TTG map + if not quiet: + msg = self._ttg_map_to_string(show_groups=detail) + if not detail and self.endless_spool_enabled: + msg += "\nDETAIL=1 to see EndlessSpool map" + self.log_info(msg) + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_GATE_MAP_help = "Display or define the type and color of filaments on each gate" + def cmd_MMU_GATE_MAP(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + detail = bool(gcmd.get_int('DETAIL', 0, minval=0, maxval=1)) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + gates = gcmd.get('GATES', "!") + gmapstr = gcmd.get('MAP', "{}") # Hidden option for bulk filament update (from moonraker/ui components) + replace = bool(gcmd.get_int('REPLACE', 0, minval=0, maxval=1)) # Hidden option for bulk filament update from spoolman + from_spoolman = bool(gcmd.get_int('FROM_SPOOLMAN', 0, minval=0, maxval=1)) # Hidden option for bulk filament update from spoolman + gate = gcmd.get_int('GATE', -1, minval=0, maxval=self.num_gates - 1) + next_spool_id = gcmd.get_int('NEXT_SPOOLID', None, minval=-1) + + gate_map = None + try: + gate_map = ast.literal_eval(gmapstr) + except Exception as e: + self.log_error("Recieved unparsable gate map update. See log for more details") + self.log_debug("Exception whilst parsing gate map in MMU_GATE_MAP: %s" % str(e)) + return + + if reset: + self._reset_gate_map() + else: + self._renew_gate_map() # Ensure that webhooks sees changes + + if next_spool_id: + if self.spoolman_support != self.SPOOLMAN_PULL: + if next_spool_id > 0: + self.pending_spool_id = next_spool_id + self.reactor.update_timer(self.pending_spool_id_timer, self.reactor.monotonic() + self.pending_spool_id_timeout) + else: + # Disable timer to prevent reuse + self.pending_spool_id = -1 + self.reactor.update_timer(self.pending_spool_id_timer, self.reactor.NEVER) + else: + self.log_error("Cannot use use NEXT_SPOOLID feature with spoolman_support: pull. Use 'push' or 'readonly' modes") + return + + changed_gate_ids = [] + + if gate_map: # --------- BATCH UPDATE from spoolman or UI -------- + try: + self.log_debug("Received gate map update (replace: %s)" % replace) + if replace: + # Replace complete map including spool_id (should only be in spoolman "pull" mode) + if self.spoolman_support != self.SPOOLMAN_PULL: + self.mmu.log_debug("Assertion failure: received gate map replacement update but not in spoolman 'pull' mode") + + # If from spoolman gate_map should be a full gate list with spool_id = -1 for unset gates + for gate, fil in gate_map.items(): + if not (0 <= gate < self.num_gates): + self.log_debug("Warning: Illegal gate number %d supplied in gate map update - ignored" % gate) + continue + + # Update gate attributes if we have valid spool_id + spool_id = self.safe_int(fil.get('spool_id', -1)) + self.gate_spool_id[gate] = spool_id + self.gate_filament_name[gate] = fil.get('name', '') + self.gate_material[gate] = fil.get('material', '') + self.gate_color[gate] = fil.get('color', '') + self.gate_temperature[gate] = max( + self.safe_int(fil.get('temp', self.default_extruder_temp)), + self.default_extruder_temp + ) + # gate_speed_override and gate_status can be set locally + else: + # Update map (ui or from spoolman in "readonly" and "push" modes) + ids_dict = {} + for gate, fil in gate_map.items(): + if not (0 <= gate < self.num_gates): + self.log_debug("Warning: Illegal gate number %d supplied in gate map update - ignored" % gate) + continue + + spool_id = self.safe_int(fil.get('spool_id', -1)) + if (not from_spoolman or spool_id != -1): + # Update attributes but don't allow spoolman to accidently clear + self.gate_filament_name[gate] = fil.get('name', '') + self.gate_material[gate] = fil.get('material', '') + self.gate_color[gate] = fil.get('color', '') + self.gate_temperature[gate] = max( + self.safe_int(fil.get('temp', self.default_extruder_temp)), + self.default_extruder_temp + ) + self.gate_speed_override[gate] = self.safe_int(fil.get('speed_override', self.gate_speed_override[gate])) + self.gate_status[gate] = self.safe_int(fil.get('status', self.gate_status[gate])) # For UI manual fixing of availabilty + + # If spool_id has changed, clean up possible stale use of old one + if spool_id != self.gate_spool_id[gate]: + self.log_debug("Spool_id changed for gate %d in MMU_GATE_MAP" % gate) + mod_gate_ids = self.assign_spool_id(gate, spool_id) + for (gate, sid) in mod_gate_ids: + ids_dict[gate] = sid + + changed_gate_ids = list(ids_dict.items()) + + except Exception as e: + self.log_debug("Invalid MAP parameter: %s\nException: %s" % (gate_map, str(e))) + raise gcmd.error("Invalid MAP parameter. See mmu.log for details") + + elif gates != "!" or gate >= 0: + gatelist = [] + if gates != "!": + # List of gates + try: + for gate in gates.split(','): + gate = int(gate) + if 0 <= gate < self.num_gates: + gatelist.append(gate) + except ValueError: + raise gcmd.error("Invalid GATES parameter: %s" % gates) + else: + # Specifying one gate (filament) + gatelist.append(gate) + + ids_dict = {} + for gate in gatelist: + available = gcmd.get_int('AVAILABLE', self.gate_status[gate], minval=-1, maxval=2) + name = gcmd.get('NAME', None) + material = gcmd.get('MATERIAL', None) + color = gcmd.get('COLOR', None) + spool_id = gcmd.get_int('SPOOLID', None, minval=-1) + temperature = gcmd.get_int('TEMP', int(self.default_extruder_temp)) + speed_override = gcmd.get_int('SPEED', self.gate_speed_override[gate], minval=10, maxval=150) + + if self.spoolman_support != self.SPOOLMAN_PULL: + # Local gate map, can update attributes + spool_id = spool_id or self.gate_spool_id[gate] + name = name if name is not None else self.gate_filament_name[gate] + material = (material if material is not None else self.gate_material[gate]).upper() + color = (color if color is not None else self.gate_color[gate]).lower() + temperature = temperature or self.gate_temperature[gate] + color = self._validate_color(color) + if color is None: + raise gcmd.error("Color specification must be in form 'rrggbb' or 'rrggbbaa' hexadecimal value (no '#') or valid color name or empty string") + self.gate_filament_name[gate] = name + self.gate_material[gate] = material + self.gate_color[gate] = color + self.gate_temperature[gate] = temperature + self.gate_speed_override[gate] = speed_override + self.gate_status[gate] = available + + if spool_id != self.gate_spool_id[gate]: + self.log_debug("Spool_id changed for gate %d in MMU_GATE_MAP" % gate) + mod_gate_ids = self.assign_spool_id(gate, spool_id) + for (gate, sid) in mod_gate_ids: + ids_dict[gate] = sid + + else: + # Remote (spoolman) gate map, don't update local attributes that are set by spoolman + self.gate_status[gate] = available + self.gate_speed_override[gate] = speed_override + if any(x is not None for x in [material, color, spool_id, name]): + self.log_error("Spoolman mode is '%s': Can only set gate status and speed override locally\nUse MMU_SPOOLMAN or update spoolman directly" % self.SPOOLMAN_PULL) + return + + changed_gate_ids = list(ids_dict.items()) + + # Ensure everything is synced + self._update_gate_color_rgb() + + # Caution, make sure that an update from spoolman does end up in infinite loop! + self._persist_gate_map(spoolman_sync=bool(changed_gate_ids) and not from_spoolman, gate_ids=changed_gate_ids) # This will also update LED status + + if not quiet: + self.log_info(self._gate_map_to_string(detail)) + + cmd_MMU_ENDLESS_SPOOL_help = "Diplay or Manage EndlessSpool functionality and groups" + def cmd_MMU_ENDLESS_SPOOL(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + enabled = gcmd.get_int('ENABLE', -1, minval=0, maxval=1) + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + groups = gcmd.get('GROUPS', "!") + + if enabled >= 0: + self.endless_spool_enabled = enabled + self.save_variable(self.VARS_MMU_ENABLE_ENDLESS_SPOOL, self.endless_spool_enabled, write=True) + if enabled and not quiet: + self.log_always("EndlessSpool is enabled") + if not self.endless_spool_enabled: + self.log_always("EndlessSpool is disabled") + return + + if reset: + self._reset_endless_spool() + + elif groups != "!": + groups = gcmd.get('GROUPS', ",".join(map(str, self.endless_spool_groups))).split(",") + if len(groups) != self.num_gates: + self.log_always("The number of group values (%d) is not the same as number of gates (%d)" % (len(groups), self.num_gates)) + return + self.endless_spool_groups = [] + for group in groups: + if group.isdigit(): + self.endless_spool_groups.append(int(group)) + else: + self.endless_spool_groups.append(0) + self._persist_endless_spool() + + else: + quiet = False # Display current map + + if not quiet: + self.log_info(self._es_groups_to_string()) + + cmd_MMU_TOOL_OVERRIDES_help = "Displays, sets or clears tool speed and extrusion factors (M220 & M221)" + def cmd_MMU_TOOL_OVERRIDES(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + tool = gcmd.get_int('TOOL', -1, minval=0, maxval=self.num_gates) + speed = gcmd.get_int('M220', None, minval=0, maxval=200) + extrusion = gcmd.get_int('M221', None, minval=0, maxval=200) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + + if reset: + self._set_tool_override(tool, 100, 100) + elif tool >= 0: + self._set_tool_override(tool, speed, extrusion) + + msg_tool = "Tools: " + msg_sped = "M220 : " + msg_extr = "M221 : " + for i in range(self.num_gates): + range_end = 6 if i > 9 else 5 + tool_speed = int(self.tool_speed_multipliers[i] * 100) + tool_extr = int(self.tool_extrusion_multipliers[i] * 100) + msg_tool += ("| T%d " % i)[:range_end] + msg_sped += ("| %d " % tool_speed)[:range_end] + msg_extr += ("| %d " % tool_extr)[:range_end] + msg = "|\n".join([msg_tool, msg_sped, msg_extr]) + "|\n" + self.log_always(msg) + + cmd_MMU_SLICER_TOOL_MAP_help = "Display or define the tools used in print as specified by slicer" + def cmd_MMU_SLICER_TOOL_MAP(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + self._fix_started_state() + + detail = bool(gcmd.get_int('DETAIL', 0, minval=0, maxval=1)) + purge_map = bool(gcmd.get_int('PURGE_MAP', 0, minval=0, maxval=1)) + sparse_purge_map = bool(gcmd.get_int('SPARSE_PURGE_MAP', 0, minval=0, maxval=1)) + reset = bool(gcmd.get_int('RESET', 0, minval=0, maxval=1)) + initial_tool = gcmd.get_int('INITIAL_TOOL', None, minval=0, maxval=self.num_gates - 1) + total_toolchanges = gcmd.get_int('TOTAL_TOOLCHANGES', None, minval=0) + tool = gcmd.get_int('TOOL', -1, minval=0, maxval=self.num_gates - 1) + material = gcmd.get('MATERIAL', "unknown") + color = gcmd.get('COLOR', "").lower() + name = gcmd.get('NAME', "") # Filament name + temp = gcmd.get_int('TEMP', 0, minval=0) + used = bool(gcmd.get_int('USED', 1, minval=0, maxval=1)) # Is used in print (i.e a referenced tool or not) + purge_volumes = gcmd.get('PURGE_VOLUMES', "") + num_slicer_tools = gcmd.get_int('NUM_SLICER_TOOLS', self.num_gates, minval=1, maxval=self.num_gates) # Allow slicer to have less tools than MMU gates + automap_strategy = gcmd.get('AUTOMAP', None) + skip_automap = gcmd.get_int('SKIP_AUTOMAP', None, minval=0, maxval=1) + + quiet = False + if reset: + self._clear_slicer_tool_map() + quiet = True + else: + self.slicer_tool_map = dict(self.slicer_tool_map) # Ensure that webhook sees get_status() change + + # This is a "one-print" option that supresses automatic automap. If specified, set the skip option + # else leave it be. It will be reset at print end + if skip_automap is not None: + # This is a "one-print" option that supresses automatic automap + self._restore_automap_option(bool(skip_automap)) + + if tool >= 0: + self.slicer_tool_map['tools'][str(tool)] = {'color': color, 'material': material, 'temp': temp, 'name': name, 'in_use': used} + if used: + self.slicer_tool_map['referenced_tools'] = sorted(set(self.slicer_tool_map['referenced_tools'] + [tool])) + if not self.slicer_tool_map['skip_automap'] and automap_strategy and automap_strategy != self.AUTOMAP_NONE: + self._automap_gate(tool, automap_strategy) + if color: + self._update_slicer_color_rgb() + quiet = True + + if initial_tool is not None: + self.slicer_tool_map['initial_tool'] = initial_tool + self.slicer_tool_map['referenced_tools'] = sorted(set(self.slicer_tool_map['referenced_tools'] + [initial_tool])) + quiet = True + + if total_toolchanges is not None: + self.slicer_tool_map['total_toolchanges'] = total_toolchanges + quiet = True + + if purge_volumes != "": + try: + volumes = list(map(float, purge_volumes.split(','))) + n = len(volumes) + num_tools = self.num_gates + if n == 1: + calc = lambda x,y: volumes[0] * 2 # Build a single value matrix + elif n == num_slicer_tools: + calc = lambda x,y: volumes[y] + volumes[x] # Will build symmetrical purge matrix "from" followed by "to" + elif n == num_slicer_tools ** 2: + calc = lambda x,y: volumes[y + x * num_slicer_tools] # Full NxN matrix supplied in rows of "from" for each "to" + elif n == num_slicer_tools * 2: + calc = lambda x,y: volumes[y] + volumes[num_slicer_tools + x] # Build matrix with sum of "from" list then "to" list + else: + raise gcmd.error("Incorrect number of values for PURGE_VOLUMES. Expected 1, %d, %d, or %d, got %d" % (num_tools, num_tools * 2, num_tools ** 2, n)) + # Build purge volume map (x=to_tool, y=from_tool) + should_calc = lambda x,y: x < num_slicer_tools and y < num_slicer_tools and x != y + self.slicer_tool_map['purge_volumes'] = [ + [ + calc(x,y) if should_calc(x,y) else 0 + for y in range(self.num_gates) + ] + for x in range(self.num_gates) + ] + except ValueError as e: + raise gcmd.error("Error parsing PURGE_VOLUMES: %s" % str(e)) + quiet = True + + if not quiet: + colors = sum(1 for tool in self.slicer_tool_map['tools'] if self.slicer_tool_map['tools'][tool]['in_use']) + + have_purge_map = len(self.slicer_tool_map['purge_volumes']) > 0 + msg = "No slicer tool map loaded" + if colors > 0 or self.slicer_tool_map['initial_tool'] is not None: + msg = "--------- Slicer MMU Tool Summary ---------\n" + msg += "Single color print" if colors <= 1 else "%d color print" % colors + msg += " (Purge volume map loaded)\n" if colors > 1 and have_purge_map else "\n" + for t, params in self.slicer_tool_map['tools'].items(): + if params['in_use'] or detail: + msg += "T%d (gate %d, %s, %s, %d%sC)" % (int(t), self.ttg_map[int(t)], params['material'], params['color'], params['temp'], UI_DEGREE) + msg += " Not used\n" if detail and not params['in_use'] else "\n" + if self.slicer_tool_map['initial_tool'] is not None: + msg += "Initial Tool: T%d" % self.slicer_tool_map['initial_tool'] + msg += " (will use bypass)\n" if colors <= 1 and self.tool_selected == self.TOOL_GATE_BYPASS else "\n" + msg += "-------------------------------------------" + if detail or purge_map or sparse_purge_map: + if have_purge_map: + rt = self.slicer_tool_map['referenced_tools'] + volumes = [row[:num_slicer_tools] for row in self.slicer_tool_map['purge_volumes'][:num_slicer_tools]] + msg += "\nPurge Volume Map (mm^3):\n" + msg += "To ->" + UI_SEPARATOR.join("{}T{: <2}".format(UI_SPACE, i) for i in range(num_slicer_tools)) + "\n" + msg += '\n'.join([ + "T{: <2}{}{}".format(y, UI_SEPARATOR, ' '.join( + map(lambda v, x, y=y: str(round(v)).rjust(4, UI_SPACE) + if (not sparse_purge_map or (y in rt and x in rt)) and v > 0 + else "{}{}-{}".format(UI_SPACE, UI_SPACE, UI_SPACE), + row, range(len(row)) + ) + )) + for y, row in enumerate(volumes) + ]) + + elif have_purge_map: + msg += "\nDETAIL=1 to see purge volume map" + self.log_always(msg) + + cmd_MMU_CALC_PURGE_VOLUMES_help = "Calculate purge volume matrix based on filament color overriding slicer tool map import" + def cmd_MMU_CALC_PURGE_VOLUMES(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + self._fix_started_state() + + min_purge = gcmd.get_int('MIN', 0, minval=0) + max_purge = gcmd.get_int('MAX', 800, minval=1) + multiplier = gcmd.get_float('MULTIPLIER', 1., above=0.) + source = gcmd.get('SOURCE', 'gatemap') + if source not in ['gatemap', 'slicer']: + raise gcmd.error("Invalid color source: %s. Options are: gatemap, slicer" % source) + if min_purge >= max_purge: + raise gcmd.error("MAX purge volume must be greater than MIN") + + tool_rgb_colors = [] + if source == 'slicer': + # Pull colors from existing slicer map + for tool in range(self.num_gates): + tool_info = self.slicer_tool_map['tools'].get(str(tool)) + if tool_info: + tool_rgb_colors.append(self._color_to_rgb_hex(tool_info.get('color', ''))) + else: + tool_rgb_colors.append(self._color_to_rgb_hex('')) + else: + # Logic to use tools mapped to gate colors with current ttg map + for tool in range(self.num_gates): + gate = self.ttg_map[tool] + tool_rgb_colors.append(self._color_to_rgb_hex(self.gate_color[gate])) + + try: + self.slicer_tool_map['purge_volumes'] = self._generate_purge_matrix(tool_rgb_colors, min_purge, max_purge, multiplier) + self.log_always("Purge map updated. Use 'MMU_SLICER_TOOL_MAP PURGE_MAP=1' to view") + except Exception as e: + raise MmuError("Error generating purge volues: %s" % str(e)) + + cmd_MMU_CHECK_GATE_help = "Automatically inspects gate(s), parks filament and marks availability" + def cmd_MMU_CHECK_GATE(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_not_homed(): return + if self.check_if_bypass(): return + self._fix_started_state() + + quiet = gcmd.get_int('QUIET', 0, minval=0, maxval=1) + # These three parameters are mutually exclusive so we only process one + tools = gcmd.get('TOOLS', "!") + gates = gcmd.get('GATES', "!") + tool = gcmd.get_int('TOOL', -1, minval=0, maxval=self.num_gates - 1) + gate = gcmd.get_int('GATE', -1, minval=0, maxval=self.num_gates - 1) + all_gates = gcmd.get_int('ALL', 0, minval=0, maxval=1) + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates = None if gate == -1 else [gate]): return # TODO Incomplete/simplified gate selection + + try: + with self.wrap_sync_gear_to_extruder(): + with self._wrap_suspend_filament_monitoring(): # Don't want runout accidently triggering during gate check + with self._wrap_suspendwrite_variables(): # Reduce I/O activity to a minimum + with self.wrap_action(self.ACTION_CHECKING): + tool_selected = self.tool_selected + filament_pos = self.filament_pos + gates_tools = [] + if gate >= 0: + # Individual gate + gates_tools.append([gate, -1]) + elif tool >= 0: + # Individual tool + gate = self.ttg_map[tool] + gates_tools.append([gate, tool]) + elif all_gates: + for gate in range(self.num_gates): + gates_tools.append([gate, -1]) + elif gates != "!": + # List of gates + try: + for gate in gates.split(','): + gate = int(gate) + if 0 <= gate < self.num_gates: + gates_tools.append([gate, -1]) + except ValueError: + raise MmuError("Invalid GATES parameter: %s" % tools) + elif tools != "!": + # Tools used in print (may be empty list) + try: + for tool in tools.split(','): + if not tool == "": + tool = int(tool) + if 0 <= tool < self.num_gates: + gate = self.ttg_map[tool] + gates_tools.append([gate, tool]) + if len(gates_tools) == 0: + self.log_debug("No tools to check, assuming default tool is already loaded") + return + except ValueError: + raise MmuError("Invalid TOOLS parameter: %s" % tools) + elif self.gate_selected >= 0: + # No parameters means current gate + gates_tools.append([self.gate_selected, -1]) + else: + raise MmuError("Current gate is invalid") + + # Force initial eject + if filament_pos != self.FILAMENT_POS_UNLOADED: + self.log_info("Unloading current tool prior to checking gates") + + # Perform full unload sequence including parking + self._note_toolchange("< %s" % self.selected_tool_string()) + self.last_statistics = {} + self._save_toolhead_position_and_park('unload') + self._unload_tool(form_tip=self.FORM_TIP_STANDALONE) + self._persist_gate_statistics() + self._continue_after('unload') + + if len(gates_tools) > 1: + self.log_info("Will check gates: %s" % ', '.join(str(g) for g,t in gates_tools)) + with self.wrap_suppress_visual_log(): + self._set_tool_selected(self.TOOL_GATE_UNKNOWN) + for gate, tool in gates_tools: + try: + self.select_gate(gate) + self.log_info("Checking gate %d..." % gate) + _ = self._load_gate(allow_retry=False) + if tool >= 0: + self.log_info("Tool T%d - Filament detected. Gate %d marked available" % (tool, gate)) + else: + self.log_info("Gate %d - Filament detected. Marked available" % gate) + self._set_gate_status(gate, max(self.gate_status[gate], self.GATE_AVAILABLE)) + try: + _,_ = self._unload_gate() + except MmuError as ee: + raise MmuError("Failure during check gate %d %s:\n%s" % (gate, "(T%d)" % tool if tool >= 0 else "", str(ee))) + except MmuError as ee: + self._set_gate_status(gate, self.GATE_EMPTY) + self._set_filament_pos_state(self.FILAMENT_POS_UNLOADED, silent=True) + if tool >= 0: + msg = "Tool T%d on gate %d marked EMPTY" % (tool, gate) + else: + msg = "Gate %d marked EMPTY" % gate + self.log_debug("Gate marked empty because: %s" % str(ee)) + if self.is_in_print(): + raise MmuError("%s%s" % ("Required " if self.is_printing() else "", msg)) + else: + self.log_always(msg) + finally: + self._initialize_encoder() # Encoder 0000 + + # If not printing select original tool and load filament if necessary + # We don't do this when printing because this is expected to preceed loading initial tool + if not self.is_printing(): + try: + if tool_selected == self.TOOL_GATE_BYPASS: + self.select_bypass() + elif tool_selected != self.TOOL_GATE_UNKNOWN: + if filament_pos == self.FILAMENT_POS_LOADED: + self.log_info("Restoring tool loaded prior to checking gates") + + # Perform full load sequence including parking + self._note_toolchange("> %s" % self.selected_tool_string(tool=tool_selected)) + self.last_statistics = {} + self._save_toolhead_position_and_park('load') + self._select_and_load_tool(tool_selected, purge=self.PURGE_NONE) + self._persist_gate_statistics() + self._continue_after('load') + else: + self.select_tool(tool_selected) + except MmuError as ee: + raise MmuError("Failure re-selecting Tool %d:\n%s" % (tool_selected, str(ee))) + else: + # At least restore the selected tool, but don't re-load filament + self.select_tool(tool_selected) + + if not quiet: + self.log_info(self._mmu_visual_to_string()) + + except MmuError as ee: + self.handle_mmu_error(str(ee)) + + cmd_MMU_PRELOAD_help = "Preloads filament at specified or current gate" + def cmd_MMU_PRELOAD(self, gcmd): + self.log_to_file(gcmd.get_commandline()) + if self.check_if_disabled(): return + if self.check_if_printing(): return + if self.check_if_not_homed(): return + + gate = gcmd.get_int('GATE', self.gate_selected, minval=0, maxval=self.num_gates - 1) + if self.check_if_not_calibrated(self.CALIBRATED_ESSENTIAL, check_gates=[gate]): return + + can_crossload = ( + (self.mmu_machine.can_crossload or self.mmu_machine.multigear) + and self.sensor_manager.has_gate_sensor(self.SENSOR_GEAR_PREFIX, gate) + ) + if not can_crossload: + if self.check_if_bypass(): return + if self.check_if_loaded(): return + + self.log_always("Preloading filament in %s..." % ("current gate" if gate == self.gate_selected else "gate %d" % gate)) + try: + with self.wrap_sync_gear_to_extruder(): + with self.wrap_suppress_visual_log(): + with self.wrap_action(self.ACTION_CHECKING): + + current_gate = self.gate_selected + if gate != current_gate: + self.select_gate(gate) + + try: + self._preload_gate() + + finally: + if self.gate_selected != current_gate: + # If necessary or easy restore previous gate + if self.is_in_print() or self.mmu_machine.multigear or self.filament_pos != self.FILAMENT_POS_UNLOADED: + self.select_gate(current_gate) + else: + # Lazy movement means we have side effect of changed tool/gate + self._ensure_ttg_match() + self._initialize_encoder() # Encoder 0000 + except MmuError as ee: + self.handle_mmu_error("Filament preload for gate %d failed: %s" % (gate, str(ee))) + + +def load_config(config): + return Mmu(config) diff --git a/klippy/extras/mmu/mmu_calibration_manager.py b/klippy/extras/mmu/mmu_calibration_manager.py new file mode 100644 index 000000000000..200f3dc3bdd0 --- /dev/null +++ b/klippy/extras/mmu/mmu_calibration_manager.py @@ -0,0 +1,560 @@ +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Manager class to handle all aspects of MMU calibration and autotuning. In +# paricular manage persistence of bowden lengths and gear rotation distances. +# +# Implements commands: +# MMU_SET_LED +# +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, math + +# Happy Hare imports + +# MMU subcomponent clases +from .mmu_shared import * + + +class MmuCalibrationManager: + + def __init__(self, mmu): + self.mmu = mmu + + + # -------------------- Bowden length manipulation -------------------- + # Notes: + # - The bowden length is the distance between the current choice of endstops. + # If those endstops change the bowden length must be adjusted + # - A calibrated bowden length must also be updated if the rotation_distance for + # that gate is updated + # - Testing has shown that the encoder based clog detection length is generally + # proportional to the bowden length + + # Returns the currently calibrated bowden length or the default for gate 0 if not + def get_bowden_length(self, gate=None): + if gate == None: gate = self.mmu.gate_selected + + ref_gate = gate if gate >= 0 and self.mmu.mmu_machine.variable_bowden_lengths else 0 + return self.mmu.bowden_lengths[ref_gate] + + + # Update bowden calibration for current gate and clog_detection if not yet calibrated + def update_bowden_length(self, length, gate=None, console_msg=False): + if gate == None: gate = self.mmu.gate_selected + if gate < 0: + self.mmu.log_debug("Assertion failure: cannot save bowden length for gate: %s" % self.mmu.selected_gate_string(gate)) + return + + all_gates = not self.mmu.mmu_machine.variable_bowden_lengths + + if length < 0: # Reset + action = "reset" + if all_gates: + self.mmu.bowden_lengths = [-1] * self.mmu.num_gates + else: + self.mmu.bowden_lengths[gate] = -1 + + else: + length = round(length, 1) + action = "saved" + if all_gates: + self.mmu.bowden_lengths = [length] * self.mmu.num_gates + else: + self.mmu.bowden_lengths[gate] = length + + msg = "Calibrated bowden length %.1fmm has been %s %s" % (length, action, ("for all gates" if all_gates else "gate %d" % gate)) + if console_msg: + self.mmu.log_always(msg) + else: + self.mmu.log_debug(msg) + + # Update calibration status + if not any(x == -1 for x in self.mmu.bowden_lengths): + self.mmu.calibration_status |= self.mmu.CALIBRATED_BOWDENS + + # Persist + self.mmu.save_variable(self.mmu.VARS_MMU_CALIB_BOWDEN_LENGTHS, self.mmu.bowden_lengths) + self.mmu.write_variables() + + + # Adjust all bowden lengths if endstops are changed (e.g. from MMU_TEST_CONFIG) + def adjust_bowden_lengths_on_homing_change(self): + current_home = self.mmu.save_variables.allVariables.get(self.mmu.VARS_MMU_CALIB_BOWDEN_HOME, None) + if self.mmu.gate_homing_endstop == current_home: + return + + adjustment = 0 + if current_home == self.mmu.SENSOR_ENCODER: + adjustment = self.mmu.gate_endstop_to_encoder + elif self.mmu.gate_homing_endstop == self.mmu.SENSOR_ENCODER: + adjustment = -self.mmu.gate_endstop_to_encoder + self.mmu.bowden_lengths = [length + adjustment if length != -1 else length for length in self.mmu.bowden_lengths] + self.mmu.log_debug("Adjusted bowden lengths by %.1f: %s because of gate_homing_endstop change" % (adjustment, self.mmu.bowden_lengths)) + + # Persist + self.mmu.save_variable(self.mmu.VARS_MMU_CALIB_BOWDEN_LENGTHS, self.mmu.bowden_lengths) + self.mmu.save_variable(self.mmu.VARS_MMU_CALIB_BOWDEN_HOME, self.mmu.gate_homing_endstop) + self.mmu.write_variables() + + + # -------------------- Encoder based runout/clog/tangle length manipulation -------------------- + + def calc_clog_detection_length(self, bowden_length): + cal_min = round((bowden_length * 2) / 100., 1) # 2% of bowden length seems to be good starting point + return max(cal_min, 8.) # Never less than 8mm + + + def update_clog_detection_length(self, cdl, force=False): + """ + Persist the calibrated encoder clog detection length and notify the encoder of change if in auto mode + If not forced then save if auto but don't update the encoder + """ + if not self.mmu.has_encoder(): return + if not cdl: return + + auto = (self.mmu.sync_feedback_manager.flowguard_encoder_mode == self.mmu.encoder_sensor.RUNOUT_AUTOMATIC) + + if auto or force: + self.mmu.save_variable(self.mmu.VARS_MMU_CALIB_CLOG_LENGTH, cdl, write=bool(force)) + + if auto and not force: + self.mmu.encoder_sensor.set_clog_detection_length(cdl) + + + # -------------------- Gear stepper rotation distance manipulation -------------------- + # Notes: + # - If the rotation distance is changed for gate with calibrated bowden length then adjust bowden length + + # Return current calibrated gear rotation_distance or sensible default + def get_gear_rd(self, gate=None): + if gate == None: gate = self.mmu.gate_selected + + if gate < 0: + rd = self.mmu.default_rotation_distance + else: + rd = self.mmu.rotation_distances[gate if gate >= 0 and self.mmu.mmu_machine.variable_rotation_distances else 0] + + if rd <= 0: + rd = self.mmu.default_rotation_distance + self.mmu.log_debug("Gate %d not calibrated, falling back to default rotation_distance: %.4f" % (gate, rd)) + + return rd + + + # Save rotation_distance for gate (and associated gates) adjusting any calibrated bowden length + def update_gear_rd(self, rd, gate=None, console_msg=False): + if gate == None: gate = self.mmu.gate_selected + if gate < 0: + self.mmu.log_debug("Assertion failure: cannot save gear rotation_distance for gate: %d" % gate) + return + + # Initial calibration on gate 0 also sets all gates as auto calibration starting point + all_gates = ( + not self.mmu.mmu_machine.variable_rotation_distances + or (gate == 0 and self.mmu.rotation_distances[0] == 0.) + ) + + if rd < 0: + if all_gates: + self.mmu.rotation_distances = [-1] * self.mmu.num_gates + else: + self.mmu.rotation_distances[gate] = -1 + + self.mmu.log_always("Gear rotation distance calibration has been reset for %s" % ("all gates" if all_gates else "gate %d" % gate)) + + else: + prev_rd = self.get_gear_rd(gate) + rd = round(rd, 4) + + if all_gates: + self.mmu.rotation_distances = [rd] * self.mmu.num_gates + updated_gates = list(range(self.mmu.num_gates)) + else: + self.mmu.rotation_distances[gate] = rd + updated_gates = [gate] + + # Adjust calibrated bowden lengths + for g in updated_gates if self.mmu.mmu_machine.variable_bowden_lengths else [gate]: + prev_bowden = self.mmu.bowden_lengths[g] # Must get raw value + if prev_bowden > 0: # Is calibrated + new_bl = prev_bowden * (prev_rd / rd) # Adjust for same effective calibrated distance + self.update_bowden_length(new_bl, g) + + msg = "Rotation distance calibration (%.4f) has been saved for %s" % (rd, ("all gates" if all_gates else "gate %d" % gate)) + if console_msg: + self.mmu.log_always(msg) + else: + self.mmu.log_debug(msg) + + # Update calibration status + if self.mmu.rotation_distances[0] != -1: + self.mmu.calibration_status |= self.mmu.CALIBRATED_GEAR_0 + if not any(x == -1 for x in self.mmu.rotation_distances): + self.mmu.calibration_status |= self.mmu.CALIBRATED_GEAR_RDS + + # Persist + self.mmu.save_variable(self.mmu.VARS_MMU_GEAR_ROTATION_DISTANCES, self.mmu.rotation_distances, write=True) + + + # + # Calibration implementations... + # + + # Bowden calibration - Method 1 + # This method of bowden calibration is done in reverse and is a fallback. The user inserts filament to the + # actual extruder and we measure the distance necessary to home to the defined gate homing position + def calibrate_bowden_length_manual(self, approx_bowden_length): + try: + self.mmu.log_always("Calibrating bowden length on gate %d (manual method) using %s as gate reference point" % (self.mmu.gate_selected, self.mmu._gate_homing_string())) + self.mmu._set_filament_direction(self.mmu.DIRECTION_UNLOAD) + self.mmu.selector.filament_drive() + self.mmu.log_always("Finding %s endstop position..." % self.mmu.gate_homing_endstop) + homed = False + + if self.mmu.gate_homing_endstop == self.mmu.SENSOR_ENCODER: + with self.mmu._require_encoder(): + success = self.mmu._reverse_home_to_encoder(approx_bowden_length) + if success: + actual,_,_ = success + homed = True + + else: # Gate sensor... SENSOR_GATE is shared, but SENSOR_GEAR_PREFIX is specific + actual, homed, measured, _ = self.mmu.trace_filament_move( + "Reverse homing off gate sensor", + -approx_bowden_length, + motor="gear", + homing_move=-1, + endstop_name=self.mmu.gate_homing_endstop, + ) + + if not homed: + raise MmuError("Did not home to gate sensor after moving %.1fmm" % approx_bowden_length) + + actual = abs(actual) + self.mmu.log_always("Filament homed back to gate after %.1fmm movement" % actual) + self.mmu._unload_gate() + return actual + + except MmuError as ee: + raise MmuError("Calibration of bowden length on gate %d failed. Aborting because:\n%s" % (self.mmu.gate_selected, str(ee))) + + + # Bowden calibration - Method 2 + # Automatic one-shot homing calibration from gate to endstop + # bowden_length = actual_moved + toolhead_entry_to_extruder + def calibrate_bowden_length_sensor(self, extruder_homing_max): + try: + self.mmu.log_always( + "Calibrating bowden length for gate %d using %s as gate reference point and %s as extruder homing point" % + ( + self.mmu.gate_selected, + self.mmu._gate_homing_string(), + self.mmu.extruder_homing_endstop + ) + ) + self.mmu._initialize_filament_position(dwell=True) + overshoot = self.mmu._load_gate(allow_retry=False) + + if self.mmu.extruder_homing_endstop in [self.mmu.SENSOR_EXTRUDER_ENTRY, self.mmu.SENSOR_COMPRESSION]: + if self.mmu.sensor_manager.check_sensor(self.mmu.extruder_homing_endstop): + raise MmuError("The %s sensor triggered before homing. Check filament and sensor operation" % self.mmu.extruder_homing_endstop) + + actual, extra = self.mmu._home_to_extruder(extruder_homing_max) + measured = self.mmu.get_encoder_distance(dwell=True) + self.mmu._get_encoder_dead_space() + calibrated_length = round(overshoot + actual + extra, 1) + + msg = "Filament homed to extruder after %.1fmm movement" % actual + if self.mmu.has_encoder(): + msg += "\n(encoder measured %.1fmm)" % (measured - self.mmu.gate_parking_distance) + self.mmu.log_always(msg) + + self.mmu._unload_bowden(calibrated_length) # Fast move + self.mmu._unload_gate() + return calibrated_length + + except MmuError as ee: + raise MmuError("Calibration of bowden length on gate %d failed. Aborting because:\n%s" % (self.mmu.gate_selected, str(ee))) + + + # Bowden calibration - Method 3 + # Automatic calibration from gate to extruder entry sensor or collision with extruder gear (requires encoder) + # Allows for repeats to average restult which is essential with encoder collision detection + def calibrate_bowden_length_collision(self, approximate_length, extruder_homing_max, repeats): + orig_endstop = self.mmu.extruder_homing_endstop + try: + # Can't allow "none" endstop during calibration so temporarily change it + self.mmu.extruder_homing_endstop = self.mmu.SENSOR_EXTRUDER_COLLISION + + self.mmu.log_always("Calibrating bowden length on gate %d using %s as gate reference point and encoder collision detection" % (self.mmu.gate_selected, self.mmu._gate_homing_string())) + reference_sum = spring_max = 0. + successes = 0 + + for i in range(repeats): + self.mmu._initialize_filament_position(dwell=True) + overshoot = self.mmu._load_gate(allow_retry=False) + self.mmu._load_bowden(approximate_length, start_pos=overshoot) # Get close to extruder homing point + + self.mmu.log_info("Finding extruder gear position (try #%d of %d)..." % (i+1, repeats)) + _,_ = self.mmu._home_to_extruder(extruder_homing_max) + actual = self.mmu._get_filament_position() - self.mmu.gate_parking_distance + measured = self.mmu.get_encoder_distance(dwell=True) + self.mmu._get_encoder_dead_space() + spring = self.mmu.selector.filament_release(measure=True) if self.mmu.has_encoder() else 0. + reference = actual - spring + + # When homing using collision, we expect the filament to spring back. + if spring != 0: + msg = "Pass #%d: Filament homed to extruder after %.1fmm movement" % (i+1, actual) + if self.mmu.has_encoder(): + msg += "\n(encoder measured %.1fmm, filament sprung back %.1fmm)" % (measured - self.mmu.gate_parking_distance, spring) + self.mmu.log_always(msg) + reference_sum += reference + spring_max = max(spring, spring_max) + successes += 1 + else: + # No spring means we haven't reliably homed + self.mmu.log_always("Failed to detect a reliable home position on this attempt") + + self.mmu._initialize_filament_position(True) + self.mmu._unload_bowden(reference) + self.mmu._unload_gate() + + if successes == 0: + raise MmuError("All %d attempts at homing failed. MMU needs some adjustments!" % repeats) + + return (reference_sum / successes) + + except MmuError as ee: + # Add some more context to the error and re-raise + raise MmuError("Calibration of bowden length on gate %d failed. Aborting because:\n%s" % (self.mmu.gate_selected, str(ee))) + finally: + self.mmu.extruder_homing_endstop = orig_endstop + + + def calibrate_encoder(self, length, repeats, speed, min_speed, max_speed, accel, save=True): + pos_values, neg_values = [], [] + self.mmu.log_always("%s over %.1fmm..." % ("Calibrating" if save else "Validating calibration", length)) + speed_incr = (max_speed - min_speed) / repeats + test_speed = min_speed + mean = 0 + + try: + for x in range(repeats): + if speed_incr > 0.: + self.mmu.log_always("Test run #%d, Speed=%.1f mm/s" % (x, test_speed)) + + # Move forward + self.mmu._initialize_filament_position(dwell=True) + self.mmu.trace_filament_move(None, length, speed=test_speed, accel=accel, wait=True) + counts = self.mmu._get_encoder_counts(dwell=True) + pos_values.append(counts) + self.mmu.log_always("%s+ counts: %d" % (UI_SPACE*2, counts)) + + # Move backward + self.mmu._initialize_filament_position(dwell=True) + self.mmu.trace_filament_move(None, -length, speed=test_speed, accel=accel, wait=True) + counts = self.mmu._get_encoder_counts(dwell=True) + neg_values.append(counts) + self.mmu.log_always("%s- counts: %d" % (UI_SPACE*2, counts)) + + if counts == 0: break + test_speed += speed_incr + + mean_pos = self.mmu._sample_stats(pos_values)['mean'] + mean_neg = self.mmu._sample_stats(neg_values)['mean'] + mean = (float(mean_pos) + float(mean_neg)) / 2 + + if mean == 0: + self.mmu.log_always("No counts measured. Ensure a tool was selected with filament gripped before running calibration and that your encoder is working properly") + return + + resolution = length / mean + old_result = mean * self.mmu.encoder_sensor.get_resolution() + + msg = "Load direction: mean=%(mean).2f stdev=%(stdev).2f min=%(min)d max=%(max)d range=%(range)d" % self.mmu._sample_stats(pos_values) + msg += "\nUnload direction: mean=%(mean).2f stdev=%(stdev).2f min=%(min)d max=%(max)d range=%(range)d" % self.mmu._sample_stats(neg_values) + self.mmu.log_always(msg) + + # Sanity check to ensure all teeth are reflecting / being counted. 20% tolerance + if (abs(resolution - self.mmu.encoder_sensor.get_resolution()) / self.mmu.encoder_sensor.get_resolution()) > 0.2: + self.mmu.log_warning("Warning: Encoder is not detecting the expected number of counts based on CAD parameters which may indicate an issue") + + msg = "Before calibration measured length: %.2fmm" % old_result + msg += "\nCalculated resolution of the encoder: %.4f (currently: %.4f)" % (resolution, self.mmu.encoder_sensor.get_resolution()) + self.mmu.log_always(msg) + + if save: + self.mmu.encoder_sensor.set_resolution(resolution) + self.mmu.save_variable(self.mmu.VARS_MMU_ENCODER_RESOLUTION, round(resolution, 4), write=True) + self.mmu.log_always("Encoder calibration has been saved") + self.mmu.calibration_status |= self.mmu.CALIBRATED_ENCODER + + except MmuError as ee: + # Add some more context to the error and re-raise + raise MmuError("Calibration of encoder failed. Aborting, because:\n%s" % str(ee)) + + finally: + if mean == 0: + self.mmu._set_filament_pos_state(self.mmu.FILAMENT_POS_UNKNOWN) + + + # Automatically calibrate the rotation_distance for gate>0 using encoder measurements and gate 0 as reference + # Gate 0 is always calibrated with MMU_CALILBRATE_GEAR + def calibrate_gate(self, gate, length, repeats, save=True): + try: + pos_values, neg_values = [], [] + self.mmu.select_gate(gate) + self.mmu._load_gate(allow_retry=False) + self.mmu.log_always("%s gate %d over %.1fmm..." % ("Calibrating" if (gate > 0 and save) else "Validating calibration of", gate, length)) + + if gate == 0: + self.mmu.log_always("Gate 0 is calibrated with MMU_CALIBRATE_GEAR and manual measurement, so this will run as a validation that encoder is calibrated correctly") + + for _ in range(repeats): + self.mmu._initialize_filament_position(dwell=True) + _,_,measured,delta = self.mmu.trace_filament_move("Calibration load movement", length, encoder_dwell=True) + pos_values.append(measured) + self.mmu.log_always("%s+ measured: %.1fmm (counts: %d)" % (UI_SPACE*2, (length - delta), self.mmu._get_encoder_counts(dwell=None))) + self.mmu._initialize_filament_position(dwell=True) + _,_,measured,delta = self.mmu.trace_filament_move("Calibration unload movement", -length, encoder_dwell=True) + neg_values.append(measured) + self.mmu.log_always("%s- measured: %.1fmm (counts: %d)" % (UI_SPACE*2, (length - delta), self.mmu._get_encoder_counts(dwell=None))) + + msg = "Load direction: mean=%(mean).2f stdev=%(stdev).2f min=%(min).2f max=%(max).2f range=%(range).2f" % self.mmu._sample_stats(pos_values) + msg += "\nUnload direction: mean=%(mean).2f stdev=%(stdev).2f min=%(min).2f max=%(max).2f range=%(range).2f" % self.mmu._sample_stats(neg_values) + self.mmu.log_always(msg) + + mean_pos = self.mmu._sample_stats(pos_values)['mean'] + mean_neg = self.mmu._sample_stats(neg_values)['mean'] + mean = (float(mean_pos) + float(mean_neg)) / 2 + ratio = mean / length + current_rd = self.mmu.gear_rail.steppers[0].get_rotation_distance()[0] + new_rd = round(ratio * current_rd, 4) + + self.mmu.log_always("Calibration move of %d x %.1fmm, average encoder measurement: %.1fmm - Ratio is %.4f" % (repeats * 2, length, mean, ratio)) + self.mmu.log_always("Calculated gate %d rotation_distance: %.4f (currently: %.4f)" % (gate, new_rd, self.mmu.rotation_distances[gate])) + if gate != 0: # Gate 0 is not calibrated, it is the reference and set with MMU_CALIBRATE_GEAR + gate0_rd = self.mmu.rotation_distances[0] + tolerance_range = (gate0_rd - gate0_rd * 0.2, gate0_rd + gate0_rd * 0.2) # Allow 20% variation from gate 0 + if tolerance_range[0] <= new_rd < tolerance_range[1]: + if save: + self.mmu.set_gear_rotation_distance(new_rd) + self.update_gear_rd(new_rd, console_msg=True) + else: + self.mmu.log_always("Calibration ignored because it is not considered valid (>20% difference from gate 0)") + self.mmu._unload_gate() + self.mmu._set_filament_pos_state(self.mmu.FILAMENT_POS_UNLOADED) + except MmuError as ee: + # Add some more context to the error and re-raise + raise MmuError("Calibration for gate %d failed. Aborting, because:\n%s" % (gate, str(ee))) + + + def note_load_telemetry(self, bowden_move_ratio, homing_movement, deficit): + if homing_movement is not None: + homing_movement -= deficit + self._autotune(self.mmu.DIRECTION_LOAD, bowden_move_ratio, homing_movement) + + + def note_unload_telemetry(self, bowden_move_ratio, homing_movement, deficit): + if homing_movement is not None: + homing_movement -= deficit + self._autotune(self.mmu.DIRECTION_UNLOAD, bowden_move_ratio, homing_movement) + + + # Use data from load or unload operation to auto-calibrate / auto-tune + # + # Data we can use: + # - ratio of large bowden move to that measured by encoder (0 if it can't be relied on) + # - the amount of unexpected homing necessary to reach endstop. We want some homing + # movement but we can use excessive numbers for tuning (None indicates not available) + # - the direction of filament movement + # + # Things we could possibly tune from this infomation: + # - If gate 0, use the bowden move ratio to update encoder calibration ("encoder calibration"). Not reliable so not currently done! + # - If gate 0, use excess homing move to tune the calibrated bowden length ("bowden calibration") + # but only do this if bowden move ratio is reasonable. Can be done in both directions + # - If gate >0, use the bowden move ratio to set/tune the gear rotation_distance ("gate calibration") + # but only do this if homing movement data tells us we haven't overshot. Can be done in both directions + # + # Calibration replaces the previous value. Autotuning applies a moving average + def _autotune(self, direction, bowden_move_ratio, homing_movement): + msg = "Autotune: bowden move ratio: %.4f, Extra homing movement: %s" % (bowden_move_ratio, "n/a" if homing_movement is None else "%.1fmm" % homing_movement) + if homing_movement is not None: + + # If sync-feedback is available it provides a better way to autotune rotation distance. This is retained for legacy cases + has_tension, has_compression, has_proportional = self.mmu.sync_feedback_manager.get_active_sensors() + + # Encoder based automatic calibration of gate's gear rotation_distance + # TODO Currently only works with gate >0. Could work with gate 0 if variable_rotation_distance is True + # TODO and bowden is calibrated and we don't tune bowden below + if ( + False and # TODO Temporarily disabled based on user's feedback until tested further + not any([has_tension, has_compression, has_proportional]) and + self.mmu.autotune_rotation_distance and + self.mmu.mmu_machine.variable_rotation_distances and + self.mmu.gate_selected > 0 and + bowden_move_ratio > 0 and + homing_movement > 0 + ): + if direction in [self.mmu.DIRECTION_LOAD, self.mmu.DIRECTION_UNLOAD]: + current_rd = self.mmu.gear_rail.steppers[0].get_rotation_distance()[0] + new_rd = round(bowden_move_ratio * current_rd, 4) + gate0_rd = self.mmu.rotation_distances[0] + + # Allow max 10% variation from gate 0 for autotune + if math.isclose(new_rd, gate0_rd, rel_tol=0.1): + if not self.mmu.calibrating and self.mmu.rotation_distances[self.mmu.gate_selected] > 0: + # Tuning existing calibration + new_rd = round((self.mmu.rotation_distances[self.mmu.gate_selected] * 5 + new_rd) / 6, 4) # Moving average + msg += ". Autotuned rotation_distance: %.4f for gate %d" % (new_rd, self.mmu.gate_selected) + if not math.isclose(current_rd, new_rd): + _ = self.mmu.update_gear_rd(new_rd, self.mmu.gate_selected) + else: + msg += ". Calculated rotation_distance: %.4f for gate %d failed sanity check and has been ignored" % (new_rd, self.mmu.gate_selected) + + + # Automatic calibration of bowden length based on actual homing movement telemetry + # TODO Currently only works with gate 0. Could work with other gates if variable_bowden_lengths is True and rotation distance is calibrated + if ( + self.mmu.autotune_bowden_length and + self.mmu.mmu_machine.require_bowden_move and + self.mmu.gate_selected == 0 and + ( + 0.9 < bowden_move_ratio < 1.1 or + not self.mmu.has_encoder() + ) + ): + if direction in [self.mmu.DIRECTION_LOAD, self.mmu.DIRECTION_UNLOAD]: + bowden_length = self.get_bowden_length() + # We expect homing_movement to be 0 if perfectly calibrated and perfect movement + # Note that we only change calibrated bowden length if extra homing is >1% of bowden length + error_tolerance = bowden_length * 0.01 # 1% of bowden length + if abs(homing_movement) > error_tolerance: + if homing_movement > 0: + new_bl = bowden_length + error_tolerance + else: + new_bl = bowden_length - error_tolerance + else: + new_bl = bowden_length + new_bl = round((bowden_length * 5 + new_bl) / 6, 1) # Still perform moving average to smooth changes + if not math.isclose(bowden_length, new_bl): + self.update_bowden_length(new_bl) + msg += " Autotuned bowden length: %.1f" % new_bl + + if self.mmu.gate_selected == 0 and homing_movement > 0 and bowden_move_ratio > 0: + # Bowden movement based warning of encoder calibration aka MMU_CALIBRATE_ENCODER + if not 0.95 < bowden_move_ratio < 1.05: + msg += ". Encoder measurement on gate 0 was outside of desired calibration range. You may want to check function or recalibrate" + else: + msg += ". Tuning not possible" + + self.mmu.log_debug(msg) + diff --git a/klippy/extras/mmu/mmu_environment_manager.py b/klippy/extras/mmu/mmu_environment_manager.py new file mode 100644 index 000000000000..66690eea865c --- /dev/null +++ b/klippy/extras/mmu/mmu_environment_manager.py @@ -0,0 +1,1158 @@ +# -*- coding: utf-8 -*- +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Manager class to implement MMU heater control and basic filament drying functionality +# +# Two setups are supported: +# 1. The more normal shared enclosure with single heater and environment sensor. In this case +# 'filament_heater' and 'environment_sensor' properties should be set. Direct heater or +# drying lifecycle control is possible. An optional venting macro will periodically be called +# with no arguments. +# 2. Where each MMU gate has a separate heater/environment sensor (e.g. EMU design). Here it +# is possible to supplied which gates to dry. The list of heaters and environment sensors +# should be set with the 'filament_heaters' and 'environment_sensors' properties. +# Further, in this mode a basic "power management" is implemented which limits the number +# of simulateous heaters to that defined by the 'max_concurrent_heaters' property. +# Individual control of per-gate heaters and lifecycle is possible by specifying gates of +# interest. The periodic venting macro will be called with a GATE parameter listing the +# currently heated gates. +# The manager will support automatic spool rotation if equiped with eSpooler and the dry cycle +# is initiated with this option. IMPORTANT: filament must be removed from the MMU inlet and +# fastened to the spool. Also, the GATES parameter must be supplied. +# +# TODO For HHv4 this needs to operate per unit (gate range) +# +# Implements commands: +# MMU_HEATER +# +# Implements printer variables: +# drying_state [{string} : list indexed by gate with values: +# DRYING_STATE_ACTIVE 'active' actively drying +# DRYING_STATE_QUEUED 'queued' waiting to start +# DRYING_STATE_COMPLETE 'complete' completed drying +# DRYING_STATE_CANCELLED 'cancelled' cycle was canceled prematurely +# DRYING_STATUS_NONE '' not part of the current cycle +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import ast, logging + +# Happy Hare imports +from ..mmu_machine import VENDOR_VVD + +# MMU subcomponent clases +from .mmu_shared import * + + +class MmuEnvironmentManager: + + CHECK_INTERVAL = 30 # How often to check heater and environment sensors (seconds) + + # Environment sensor chips with humidity + ENV_SENSOR_CHIPS = ["bme280", "htu21d", "sht3x", "lm75", "aht10"] + + # Drying states (mostly relevant for per-gate heaters) + DRYING_STATE_NONE = '' + DRYING_STATE_QUEUED = 'queued' + DRYING_STATE_ACTIVE = 'active' + DRYING_STATE_COMPLETE = 'complete' + DRYING_STATE_CANCELLED = 'canceled' + + def __init__(self, mmu): + self.mmu = mmu + self.mmu.managers.append(self) + + # Process config + self.heater_max_temp = self.mmu.config.getfloat('heater_max_temp', 65, above=0.) # Never to exceed temp to avoid melting enclosure + self.heater_default_dry_temp = self.mmu.config.getfloat('heater_default_dry_temp', 45, above=0.) + self.heater_default_dry_time = self.mmu.config.getfloat('heater_default_dry_time', 300, above=0.) + self.heater_default_humidity = self.mmu.config.getfloat('heater_default_humidity', 10, above=0.) + self.heater_vent_macro = self.mmu.config.get( 'heater_vent_macro', '') + self.heater_vent_interval = self.mmu.config.getfloat('heater_vent_interval', 0, minval=0) + self.heater_rotate_interval = self.mmu.config.getfloat('heater_rotate_interval', 5, minval=1) + + # Build tuples of drying temp / drying time indexed by filament type + drying_data_str = self.mmu.config.get('drying_data', {}) + try: + drying_data = ast.literal_eval(drying_data_str) + # Store as upper case keys (If there are duplicate keys differing only by case, the last one wins) + self.drying_data = dict((str(k).upper(), v) for k, v in drying_data.items()) + except Exception as e: + raise self.mmu.config.error("Unparsable 'drying_data' parameter: %s" % str(e)) + + # Listen of important mmu events + self.mmu.printer.register_event_handler("mmu:enabled", self._handle_mmu_enabled) + self.mmu.printer.register_event_handler("mmu:disabled", self._handle_mmu_disabled) + self.mmu.printer.register_event_handler("mmu:espooler_burst_done", self._handle_espooler_burst_done) + + # Register GCODE commands --------------------------------------------------------------------------- + self.mmu.gcode.register_command('MMU_HEATER', self.cmd_MMU_HEATER, desc=self.cmd_MMU_HEATER_help) + + self._periodic_timer = self.mmu.reactor.register_timer(self._check_mmu_environment) + self.reinit() + + + # + # Standard mmu manager hooks... + # + + def reinit(self): + self._drying_temp = None + self._drying_humidity_target = None + self._drying_start_time = self._drying_end_time = None + self._drying_gates = [] + self._drying_vent_interval = None + + # Per-gate drying state (multi-heater mode) + # gate -> dict(state, start_time, end_time, temp, humidity_target, done_reason, last_temp, last_humidity) + self._gate_drying = {} # Contains details required for managing drying for scheduled gates + + # Drying state indexed by gate + self._drying_state = [self.DRYING_STATE_NONE] * self.mmu.num_gates + + self._drying_queue = [] # Queued gates awaiting heater capacity (FIFO) + self._vent_timer = None + + # Optional auto spool rotation (eSpooler) + self._rotate_timer = None + self._rotate_enabled = False + self.spools_to_rotate = [] # Queue of spools that we are rotating (one at a time) + + + # Module has no ready/connect/disconnect lifecycle hooks + + + def set_test_config(self, gcmd): + if self.has_heater(): + self.heater_default_dry_temp = gcmd.get_float('HEATER_DEFAULT_DRY_TEMP', self.heater_default_dry_temp, above=0.) + self.heater_default_dry_time = gcmd.get_float('HEATER_DEFAULT_DRY_TIME', self.heater_default_dry_time, above=0.) + self.heater_default_humidity = gcmd.get_float('HEATER_DEFAULT_HUMIDITY', self.heater_default_humidity, above=0.) + self.heater_vent_macro = gcmd.get( 'HEATER_VENT_MACRO', self.heater_vent_macro) + self.heater_vent_interval = gcmd.get_float('HEATER_VENT_INTERVAL', self.heater_vent_interval, minval=0) + self.heater_rotate_interval = gcmd.get_float('HEATER_ROTATE_INTERVAL', self.heater_rotate_interval, minval=0) + + + def get_test_config(self): + msg = "" + if self.has_heater(): + msg = "\n\nHEATER:" + msg += "\nheater_default_dry_temp = %.1f" % self.heater_default_dry_temp + msg += "\nheater_default_dry_time = %.1f" % self.heater_default_dry_time + msg += "\nheater_default_humidity = %.1f" % self.heater_default_humidity + msg += "\nheater_vent_macro = %s" % self.heater_vent_macro + msg += "\nheater_vent_interval = %.1f" % self.heater_vent_interval + msg += "\nheater_rotate_interval = %.1f" % self.heater_rotate_interval + + return msg + + + def check_test_config(self, param): + return vars(self).get(param) is None + + # + # Mmu Heater manager public access... + # + + def is_drying(self): + """ + Returns whether the MMU heater is currently in drying cycle + """ + for s in self._drying_state: + if s == self.DRYING_STATE_ACTIVE or s == self.DRYING_STATE_QUEUED: + return True + return False + + + def _has_per_gate_heaters(self): + """ + Returns whether this MMU configuration has a separate heater for each gate + (and corresponding environment sensor per gate) + """ + heaters = self.mmu.mmu_machine.filament_heaters + sensors = self.mmu.mmu_machine.environment_sensors + if not heaters or not sensors: + return False + return True + + + def has_heater(self): + if self._has_per_gate_heaters(): + heaters = self.mmu.mmu_machine.filament_heaters + return bool(heaters) # At least one heater configured + return self.mmu.mmu_machine.filament_heater != '' + + + def has_env_sensor(self): + if self._has_per_gate_heaters(): + sensors = self.mmu.mmu_machine.environment_sensors + return bool(sensors) + return self.mmu.mmu_machine.environment_sensor != '' + + + def _get_active_gates(self): + """ + Return list of active gates from per-gate drying states + """ + return [i for i, s in enumerate(self._drying_state) if s == self.DRYING_STATE_ACTIVE] + + + # + # GCODE Commands ----------------------------------------------------------- + # + + cmd_MMU_HEATER_help = "Control MMU heater(s) and filament drying cycle" + cmd_MMU_HEATER_param_help = ( + "MMU_HEATER: %s\n" % cmd_MMU_HEATER_help + + "STOP = [0|1] Turn off heater and drying cycle\n" + + "DRYING_DATA = [0|1] Dump configured drying data for filament types\n" + + "DRY = [0|1] Disable/enable filament heater for filament drying cycle\n" + + "TIMER = #(mins) Force drying time\n" + + "TEMP = #(degrees) Force temperature\n" + + "HUMIDITY = % Terminate drying when humidty goal is reached\n" + + "GATES = g1,g2 Gates to control ONLY IF MMU has per-gate heaters/dryers\n" + + "ROTATE = [0|1] Rotate spool (requires eSpooler and explicit GATES)\n" + + "ROTATE_INTERVAL = #(mins) How often to rotate spools when drying (requires eSpooler)\n" + + "VENT_INTERVAL = #(mins) How often to call 'vent' macro in drying cycle\n" + + "(no parameters for status report)" + ) + def cmd_MMU_HEATER(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + + if gcmd.get_int('HELP', 0, minval=0, maxval=1): + self.mmu.log_always(self.mmu.format_help(self.cmd_MMU_HEATER_param_help), color=True) + return + + if not self.has_heater(): + raise gcmd.error("No MMU heater configured") + + drying_data = gcmd.get_int('DRYING_DATA', 0, minval=0, maxval=1) + stop = gcmd.get_int('STOP', None, minval=0, maxval=1) + dry = gcmd.get_int('DRY', None, minval=0, maxval=1) + timer = gcmd.get_float('TIMER', None, minval=0.) + temp = gcmd.get_float('TEMP', None, minval=0., maxval=self.heater_max_temp) + humidity = gcmd.get_float('HUMIDITY', self.heater_default_humidity, minval=0.) + vent_interval = gcmd.get_float('VENT_INTERVAL', self.heater_vent_interval, minval=0.) + rotate = gcmd.get_int('ROTATE', 0, minval=0, maxval=1) + rotate_interval = gcmd.get_float('ROTATE_INTERVAL', self.heater_rotate_interval, minval=1.) + + # GATE is a common user mistake so interpret as GATES of one element + gate = gcmd.get_int('GATE', None, minval=0, maxval=self.mmu.num_gates - 1) + if gate is not None: + gates_str = str(gate) + else: + gates_str = gcmd.get('GATES', "!") + + gates = [] + if gates_str != "!": + # Supplied list of gates + gates_param = True + try: + for gate in gates_str.split(','): + gate = int(gate) + if 0 <= gate < self.mmu.num_gates: + gates.append(gate) + except ValueError: + raise gcmd.error("Invalid GATES parameter: %s" % gates_str) + else: + gates_param = False + all_gates = list(range(self.mmu.num_gates)) + empty_gates = [ + i for i, status in enumerate(self.mmu.gate_status) + if status == self.mmu.GATE_EMPTY + ] + full_gates = [ + i for i, status in enumerate(self.mmu.gate_status) + if status != self.mmu.GATE_EMPTY + ] + + def _format_minutes(minutes): + hours, mins = divmod(int(minutes), 60) + parts = [] + if hours: + parts.append("%d hour%s" % (hours, "" if hours == 1 else "s")) + if mins: + parts.append("%d minute%s" % (mins, "" if mins == 1 else "s")) + if not (hours or mins): + parts.append("<1 minute") + return " ".join(parts) + + # Display drying data table --------------------------------------------- + if drying_data: + msg = u"Drying data:\n" + for material in sorted(self.drying_data.keys()): + t, minutes = self.drying_data[material] + # Avoid format() on unicode with alignment in Py2 edge-cases; keep it simple + msg += u"%s %s°C for %s\n" % (material + ":", int(t), _format_minutes(minutes)) + self.mmu.log_always(msg) + return + + # Cancel drying cycle / Heater off -------------------------------------- + if stop or temp == 0: + if self._has_per_gate_heaters(): + if not gates_param: + gates = all_gates + + if self.is_drying(): + # STOP=1 with explicit GATES=... cancels only those gates in multi-heater mode + cancelled = self._cancel_gates(gates, reason="cancelled") + if cancelled: + self.mmu.log_info("Cancelled drying for gates: %s" % ",".join(map(str, gates))) + else: + self.mmu.log_info("No matching active/queued gates to cancel") + + # If all gates are now done, stop overall cycle + all_done = True + for g in self._drying_gates: + gd = self._gate_drying.get(g) + if not gd or gd.get('state') not in [self.DRYING_STATE_COMPLETE, self.DRYING_STATE_CANCELLED]: + all_done = False + break + if all_done: + self._stop_drying_cycle("Drying cycle stopped (all selected gates cancelled)", reset_state=True) + + else: + # Always make sure heater is turned off + for gate in gates: + self._heater_off(gate=gate) + + else: + # Otherwise stop whole cycle / single heater off + if self.is_drying(): + self.mmu.log_info("Cancelled drying cycle") + self._stop_drying_cycle(reset_state=True) + + else: + # Always make sure heater is turned off + self._heater_off() + + return + + # Raw heater control ---------------------------------------------------- + if not dry and temp is not None: + if not gates_param: + gates = full_gates # Default to all non empty gates + + # In per-gate mode, apply TEMP to the selected gate heaters + if self._has_per_gate_heaters(): + if not gates: + self.mmu.log_always("No gates selected for raw heater control") + return + + if len(gates) > self.mmu.mmu_machine.max_concurrent_heaters: + self.mmu.log_error("Exceeded max concurrent heaters") + return + + # Best-effort: set each selected gate heater to TEMP + # - If gate is queued in a drying cycle: only update _gate_drying target (do not turn on yet) + # - If gate is active: update _gate_drying and apply immediately + # - If not in drying cycle OR gate not in current drying gates: apply immediately + for gate in gates: + gd = self._gate_drying.get(gate) + + if self.is_drying() and gd is not None: + state = gd.get('state') + + # Update per-gate target in all cases when part of cycle + gd['temp'] = temp + + if state == self.DRYING_STATE_QUEUED: + # Don't power on yet; it will be applied when the gate becomes active + continue + + # Active (or any unexpected state): apply immediately + self._heater_on(temp, gate=gate) + continue + + # Not in drying cycle, or gate not part of current cycle: apply immediately + self._heater_on(temp, gate=gate) + + return + + # Single heater mode + self._heater_on(temp) + if self.is_drying(): + self._drying_temp = temp + return + + # Initiate drying cycle ------------------------------------------------- + if dry: + if not self.has_env_sensor(): + self.mmu.log_warning("MMU environment sensor not found. Check 'environment_sensor' configuration") + return + + if self.is_drying(): + self.mmu.log_always("MMU already in filament drying cycle. Stop current cycle first") + return + + # Optional spool rotation (requires eSpooler and explicit gates) + # (BTT ViViD is allowed if not in print) + if rotate and not (self.mmu.has_espooler() or self.mmu.mmu_machine.mmu_vendor == VENDOR_VVD): + self.mmu.log_warning("Rotation requested but eSpooler not fitted - ignoring") + rotate = 0 + + if rotate and not gates_param: + raise gcmd.error("ROTATE requires explicit GATES parameter") + + if not rotate and not gates_param: + gates = full_gates # Default to all non empty gates + + if rotate: + for gate in gates: + if self.mmu.gate_status[gate] != self.mmu.GATE_EMPTY: + self.mmu.log_warning("Gate %d is not empty so cannot rotate (filament end must be removed from the gate and secured to the spool for rotation)" % gate) + + # Per-gate recommended temps/times, plus overall notes + per_gate_plan = self._get_drying_plan(gates) + # If TIMER specified, override all selected gates to that time (multi-heater mode uses per-gate timers) + if timer is not None and self._has_per_gate_heaters(): + for gate in gates: + per_gate_plan[gate]['timer'] = timer + + # If TEMP specified, override all selected gates to that temp (still warn if above recommendation) + if temp is not None: + for gate in gates: + if temp > per_gate_plan[gate]['temp']: + if per_gate_plan[gate]['material']: + self.mmu.log_warning(u"Warning: Gate %d drying temperature %.1f°C is greater than that recommended for %s (%.1f°C)" + % (gate, temp, per_gate_plan[gate]['material'], per_gate_plan[gate]['temp'])) + else: + self.mmu.log_warning(u"Warning: Gate %d has unknown filament type. Cannot validate temperature %.1f°C" % (gate, temp)) + per_gate_plan[gate]['temp'] = temp + else: + # Default to each filament type recommended temperature and dry time + lowest = self.heater_default_dry_temp + longest = self.heater_default_dry_time + for gate in gates: + t = per_gate_plan[gate]['temp'] + d = per_gate_plan[gate]['timer'] + if t < lowest: lowest = t + if d > longest: longest = d + + # If we only have a single heater apply the lowest temp for longest time + if not self._has_per_gate_heaters(): + temp = lowest + info = "specified" + if timer is None: + timer = longest + info = "longest" + self.mmu.log_info(u"Defaulting to lowest drying temperature of %.1f°C for %s %s given filaments types currently in MMU" + % (temp, info, _format_minutes(timer))) + + # Note that in multi-heater mode, each gate's temp and end_time is tracked independently + self._drying_time = timer or self.heater_default_dry_time + self._drying_temp = temp or self.heater_default_dry_temp + self._drying_humidity_target = humidity + self._drying_start_time = self.mmu.reactor.monotonic() + self._drying_end_time = self._drying_start_time + self._drying_time * 60 + self._drying_gates = gates + self._drying_vent_interval = vent_interval + self._drying_rotate_interval = rotate_interval + + # Optional spool rotation state + self.spools_to_rotate = [] + self._rotate_enabled = bool(rotate) + if self._rotate_enabled: + self._rotate_timer = rotate_interval * 60.0 + else: + self._rotate_timer = None + + # Initiate drying cycle + self._start_drying_cycle(per_gate_plan) + msg = u"MMU filament drying cycle started:" + + elif self.is_drying(): + msg = u"MMU is in filament drying cycle:" + + else: # Not in drying cycle, but let's check heaters + if self._has_per_gate_heaters(): + # Per-gate heaters + heaters_on = [] + heaters_off = [] + # Report all gates (0..num_gates-1) + for gate in range(self.mmu.num_gates): + cur_temp, cur_target = self._get_heater_status(gate) + if cur_target is None: + # Heater missing / not configured; skip silently + continue + + if cur_target != 0: + heaters_on.append((gate, cur_target, cur_temp)) + else: + heaters_off.append(gate) + + if heaters_on: + msg = u"Not in drying cycle but one or more gate heaters are on:" + for gate, target, actual in heaters_on: + msg += u"\nGate %d: Target temperature %.1f°C (current: %.1f°C)" % (gate, target, actual) + if heaters_off: + msg += u"\nGate heaters off: %s" % u",".join([str(g) for g in heaters_off]) + else: + msg = u"Not in drying cycle and all gate heaters are off" + + else: + # Single shared heater + cur_temp, cur_target = self._get_heater_status() + if cur_target is None: + msg = u"Heater if not found / misconfigured" + elif cur_target != 0: + msg = u"Not in drying cycle but heater is on. Target temperature: %.1f°C (current: %.1f°C)" % (cur_target, cur_temp) + else: + msg = u"Not in drying cycle and heater is off" + + # Display status report of drying cycle --------------------------------- + if self.is_drying(): + now = self.mmu.reactor.monotonic() + + if self._drying_gates: + msg += u"\nDrying filaments in gates: %s" % u",".join(str(g) for g in self._drying_gates) + + if not self._has_per_gate_heaters(): + # Single heater status report + remaining_mins = _format_minutes((self._drying_end_time - now) // 60) + cur_temp, cur_humidity = self._get_environment_status() + msg += u"\nCycle time: %s (remaining: %s)" % (_format_minutes(self._drying_time), remaining_mins) + if cur_temp is not None: + msg += u"\nTarget humidity: %.1f%%" % self._drying_humidity_target + if cur_humidity is not None: + msg += u" (current: %.1f%%)" % cur_humidity + else: + msg += u"\nEnvironment sensor not available / misconfigured" + # Use heater's temp instead + cur_temp, _ = self._get_heater_status() + if cur_temp is None: cur_temp = -1 # Saftey, should not be possible to get here + msg += u"\nDrying temp: %.1f°C (current: %.1f°C)" % (self._drying_temp, cur_temp) + + else: + # Per-gate status report + msg += u"\nPer-gate dryer mode (max concurrent heaters: %d). Humidty target %.1f%%" % (self.mmu.mmu_machine.max_concurrent_heaters, self._drying_humidity_target) + for gate in self._drying_gates: + gd = self._gate_drying.get(gate, {}) + state = gd.get('state', self.DRYING_STATE_NONE) + material = gd.get('material', None) + t = gd.get('temp', None) + last_t = gd.get('last_temp', None) + last_h = gd.get('last_humidity', None) + end_t = gd.get('end_time', None) + if end_t is not None and state == self.DRYING_STATE_ACTIVE: + rem = max(0, int((end_t - now) // 60)) + rem_txt = _format_minutes(rem) + else: + rem_txt = None + + line = u"\nGate %d: " % gate + if state == self.DRYING_STATE_ACTIVE: + if last_t is not None: + line += u"Drying %s %.1f°C (target %.1f°C)" % (material, last_t, t) + if last_h is not None: + line += u", humidity %.1f%%" % last_h + if rem_txt is not None: + line += u", %s remaining" % rem_txt + + elif state == self.DRYING_STATE_QUEUED: + line += u"(queued waiting for heater slot, target %.1f°C)" % t + + elif state in [self.DRYING_STATE_COMPLETE, self.DRYING_STATE_CANCELLED]: + reason = gd.get('done_reason', 'complete' if state == self.DRYING_STATE_COMPLETE else 'cancelled') + line += u"(%s" % reason + if last_h is not None: + line += u", final humidity: %.1f%%" % last_h + line += u")" + + msg += line + + # Venting status + if self._vent_timer is not None: + msg += u"\nVenting operational (running macro %s every %s, next in %s)" % ( + self.heater_vent_macro, + _format_minutes(self._drying_vent_interval), + _format_minutes(max(self.CHECK_INTERVAL, self._vent_timer) / 60), + ) + else: + if not self.heater_vent_macro: + vent_reason = "heater_vent_macro not set" + else: + vent_reason = "heater_vent_interval is 0" + msg += u"\nVenting not operational (%s)" % vent_reason + + # Rotation status (eSpooler) + if self._rotate_enabled: + msg += u"\nSpool rotation enabled (running every %s, next in %s)" % ( + _format_minutes(self._drying_rotate_interval), + _format_minutes(max(self.CHECK_INTERVAL, self._rotate_timer) / 60), + ) + elif self.mmu.has_espooler(): + msg += u"\nSpool rotation not enabled" + + # Report status + self.mmu.log_always(msg) + + + def get_status(self, eventtime=None): + """ + Structured status for client consumption. + We don't duplicate temperature or humidity data here but expect the client to read configuration + and look up appropriate heator and environemnt sensor objects directly + """ + status = { + 'drying_state': list(self._drying_state), + } + return status + + + # + # Internal implementation -------------------------------------------------- + # + + def _handle_mmu_disabled(self): + """ + Event indicating that the MMU unit was disabled + """ + self._stop_drying_cycle(reset_state=True) + self._heater_off() + self.spools_to_rotate = [] + + + def _handle_mmu_enabled(self): + """ + Event indicating that the MMU unit was enabled + """ + self.reinit() + + + def _check_mmu_environment(self, eventtime): + """ + Reactor callback to periodically check drying status and to rationalize state + """ + if not self.is_drying(): + return self.mmu.reactor.NEVER + + now = self.mmu.reactor.monotonic() + + # Per-gate drying mode + if self._has_per_gate_heaters(): + # Update active gates: check completion / humidity threshold + completed_any = False + for gate in list(self._get_active_gates()): + gd = self._gate_drying.get(gate) + if not gd or gd.get('state') != self.DRYING_STATE_ACTIVE: + continue + + # Read environment sensor + cur_temp, cur_humidity = self._get_environment_status(gate=gate) + gd['last_temp'] = cur_temp + gd['last_humidity'] = cur_humidity + + # Cycle complete (per gate) + if gd.get('end_time') is not None and (gd['end_time'] - now) <= 0: + self._heater_off(gate=gate) + gd['state'] = self.DRYING_STATE_COMPLETE + gd['done_reason'] = 'timer complete' + completed_any = True + try: + self._drying_state[gate] = self.DRYING_STATE_COMPLETE + except Exception: + pass + continue + + # Humidity goal reached (per gate) + if cur_humidity is not None and cur_humidity <= self._drying_humidity_target: + self._heater_off(gate=gate) + gd['state'] = self.DRYING_STATE_COMPLETE + gd['done_reason'] = 'humidity goal reached' + completed_any = True + try: + self._drying_state[gate] = self.DRYING_STATE_COMPLETE + except Exception: + pass + continue + + # If any heater slots freed, start next queued gates + if completed_any: + self._start_next_queued_gates(now) + + # If all gates done, stop overall drying cycle + all_done = True + for gate in self._drying_gates: + gd = self._gate_drying.get(gate) + if not gd or gd.get('state') not in [self.DRYING_STATE_COMPLETE, self.DRYING_STATE_CANCELLED]: + all_done = False + break + if all_done: + self._stop_drying_cycle("Drying cycle complete (all gates)", reset_state=False) + return self.mmu.reactor.NEVER + + else: # Single heater mode + + # Cycle complete? + if (self._drying_end_time - now) <= 0: + cur_temp, cur_humidity = self._get_environment_status() + for gate in range(self.mmu.num_gates): + try: + if self._drying_state[gate] == self.DRYING_STATE_ACTIVE: + self._drying_state[gate] = self.DRYING_STATE_COMPLETE + except Exception: + pass + self._stop_drying_cycle("Drying cycle complete. Final humidity: %.1f%%" % cur_humidity, reset_state=False) + return self.mmu.reactor.NEVER + + # Humidity goal reached? + cur_temp, cur_humidity = self._get_environment_status() + if cur_humidity is not None and cur_humidity <= self._drying_humidity_target: + for gate in range(self.mmu.num_gates): + try: + if self._drying_state[gate] == self.DRYING_STATE_ACTIVE: + self._drying_state[gate] = self.DRYING_STATE_COMPLETE + except Exception: + pass + self._stop_drying_cycle("Drying cycle terminated because humidity goal %.1f%% reached" % self._drying_humidity_target, reset_state=False) + return self.mmu.reactor.NEVER + + # Run periodic venting (macro) + if self._vent_timer is not None: + self._vent_timer -= self.CHECK_INTERVAL + + if self._vent_timer < 0 and self.heater_vent_macro: + cmd = self.heater_vent_macro + if self._has_per_gate_heaters(): + cmd += " GATES=%s" % ",".join(map(str, self._get_active_gates())) + self.mmu.log_debug("MmuEnvironmentManager: Running heater vent macro '%s'" % cmd) + self.mmu.wrap_gcode_command(cmd, exception=False) # Will report errors without exception + + # Reset countdown regardless (prevents hammering if undefined or failing) + self._vent_timer = self._drying_vent_interval * 60.0 if self._drying_vent_interval else None + + # Run periodic spool rotation (eSpooler) + if self._rotate_timer is not None and self._rotate_enabled: + self._rotate_timer -= self.CHECK_INTERVAL + + if self._rotate_timer < 0: + # Re-check EMPTY status at time of rotation (supports dynamic state changes) + if self._has_per_gate_heaters(): + candidates = list(self._get_active_gates()) + else: + candidates = list(range(self.mmu.num_gates)) + + gates_to_rotate = [] + for gate in candidates: + try: + if self.mmu.gate_status[gate] == self.mmu.GATE_EMPTY: + gates_to_rotate.append(gate) + except Exception: + pass + + if gates_to_rotate: + self._rotate_spools_in_gates(gates_to_rotate) + + self._rotate_timer = self._drying_rotate_interval * 60.0 # To seconds + + # Reschedule + return eventtime + self.CHECK_INTERVAL + + + def _start_drying_cycle(self, per_gate_plan=None): + if self.is_drying(): + return + + self.mmu.log_debug("MmuEnvironmentManager: Filament drying started") + + # Reset state at the beginning of a new cycle + self._drying_state = [self.DRYING_STATE_NONE] * self.mmu.num_gates + + # Vent timer countdown (seconds). 0/None disables venting. + if self._drying_vent_interval: + self._vent_timer = self._drying_vent_interval * 60.0 # To seconds + else: + self._vent_timer = None + + # Turn on heater or heaters depending on mode + if not self._has_per_gate_heaters(): + # Single heater mode + for gate in range(self.mmu.num_gates): + try: + self._drying_state[gate] = self.DRYING_STATE_ACTIVE + except Exception: + pass + self._heater_on(self._drying_temp) + + else: + # Multi heater mode: Initialize per-gate state and start as many as possible + self._gate_drying = {} + self._drying_queue = [] + self._drying_state = [self.DRYING_STATE_NONE] * self.mmu.num_gates + + if per_gate_plan is None: + per_gate_plan = self._get_drying_plan(self._drying_gates) + + # Queue all selected gates; we'll start up to max_concurrent_heaters + for gate in self._drying_gates: + plan = per_gate_plan.get(gate, {}) + gtemp = plan.get('temp', self.heater_default_dry_temp) + gtime = plan.get('timer', self.heater_default_dry_time) + material = plan.get('material', "unknown") + + self._gate_drying[gate] = { + 'state': self.DRYING_STATE_QUEUED, + 'start_time': None, + 'end_time': None, + 'material': material, + 'temp': gtemp, + 'timer': gtime, + 'humidity_target': self._drying_humidity_target, + 'done_reason': None, + 'last_temp': None, + 'last_humidity': None, + } + self._drying_queue.append(gate) + try: + self._drying_state[gate] = self.DRYING_STATE_QUEUED + except Exception: + pass + + # Turn heater on if possible else queue + self._start_next_queued_gates(self.mmu.reactor.monotonic()) + + # Enable + self.mmu.reactor.update_timer(self._periodic_timer, self.mmu.reactor.NOW) + + + def _start_next_queued_gates(self, now): + """ + Start queued gates up to max concurrent heaters. + In this setup ensure the maximum drying time is applied per gate + meaning the total drying time for all gates might be longer. + """ + max_h = self.mmu.mmu_machine.max_concurrent_heaters + if max_h <= 0: + max_h = 1 + + while len(self._get_active_gates()) < max_h and self._drying_queue: + gate = self._drying_queue.pop(0) + gd = self._gate_drying.get(gate) + if not gd or gd.get('state') != self.DRYING_STATE_QUEUED: + continue + + # Use per-gate time (from material) else user forced timer or default time + per_gate_minutes = gd.get('timer', None) + if per_gate_minutes is None: + per_gate_minutes = int(self._drying_time) + + gd['start_time'] = now + gd['end_time'] = now + (int(per_gate_minutes) * 60) + gd['state'] = self.DRYING_STATE_ACTIVE + gd['done_reason'] = None + + try: + self._drying_state[gate] = self.DRYING_STATE_ACTIVE + except Exception: + pass + + # Read environment sensor + cur_temp, cur_humidity = self._get_environment_status(gate=gate) + gd['last_temp'] = cur_temp + gd['last_humidity'] = cur_humidity + + self._heater_on(gd.get('temp'), gate=gate) + + + def _stop_drying_cycle(self, msg="Filament drying stopped", reset_state=True): + if self.is_drying() or self._drying_end_time is not None: + self.mmu.log_info(msg) + self.mmu.reactor.update_timer(self._periodic_timer, self.mmu.reactor.NEVER) + + # Turn off all heaters in either mode + if self._has_per_gate_heaters(): + for gate in list(self._get_active_gates()): + self._heater_off(gate=gate) + # Best effort: also turn off any configured heaters for selected gates + for gate in self._drying_gates: + self._heater_off(gate=gate) + else: + self._heater_off() + + self._drying_queue = [] + self._drying_gates = [] + + if reset_state: + self._drying_state = [self.DRYING_STATE_NONE] * self.mmu.num_gates + + # Stop rotation + self._rotate_timer = None + self._rotate_enabled = False + + + def _cancel_gates(self, gates, reason="cancelled"): + """ + Cancel drying for the given gates in multi-heater mode. + - If gate is active: turn off its heater, mark done, remove from active list. + - If gate is queued: remove from pending queue, mark done. + - If gate is unknown: ignore. + Returns number of gates actually cancelled. + """ + cancelled = 0 + now = self.mmu.reactor.monotonic() + + for gate in list(gates): + gd = self._gate_drying.get(gate) + + # If we don't have state for it, it might not be part of this cycle + if gd is None: + continue + + state = gd.get('state') + + if state == self.DRYING_STATE_ACTIVE: + # Turn off heater and mark done + self._heater_off(gate=gate) + gd['state'] = self.DRYING_STATE_COMPLETE + gd['done_reason'] = reason + gd['end_time'] = now + self._drying_state[gate] = self.DRYING_STATE_CANCELLED + cancelled += 1 + + elif state == self.DRYING_STATE_QUEUED: + # Remove from pending queue and mark done + try: + while gate in self._drying_queue: + self._drying_queue.remove(gate) + except Exception: + pass + gd['state'] = self.DRYING_STATE_COMPLETE + gd['done_reason'] = reason + gd['end_time'] = now + self._drying_state[gate] = self.DRYING_STATE_CANCELLED + cancelled += 1 + + elif state == self.DRYING_STATE_COMPLETE: + # Already done; no-op + pass + + # After cancellations, if we freed heater slots start next queued gates + if self._has_per_gate_heaters(): + self._start_next_queued_gates(now) + + return cancelled + + + def _heater_on(self, temp, gate=None): + """ + Turn MMU heater on. + """ + if not self._has_per_gate_heaters(): + self.mmu.log_debug(u"MmuEnvironmentManager: Heater %s set to target temp of %.1f°C" % (self.mmu.mmu_machine.filament_heater, temp)) + hname = self._heater_name(self.mmu.mmu_machine.filament_heater) + self.mmu.gcode.run_script_from_command("SET_HEATER_TEMPERATURE HEATER=%s TARGET=%.1f" % (hname, temp)) + return + + heaters = self.mmu.mmu_machine.filament_heaters + if gate < 0 or gate >= len(heaters) or not heaters[gate]: + self.mmu.log_warning("MmuEnvironmentManager: No heater configured for gate %d" % gate) + return + + hname = self._heater_name(heaters[gate]) + self.mmu.log_debug(u"MmuEnvironmentManager: Gate %d heater %s set to target temp of %.1f°C" % (gate, hname, temp)) + self.mmu.gcode.run_script_from_command("SET_HEATER_TEMPERATURE HEATER=%s TARGET=%.1f" % (hname, temp)) + + + def _heater_off(self, gate=None): + """ + Turn MMU heater off. If gate=None then turn off all heaters + """ + if not self._has_per_gate_heaters() and self.mmu.mmu_machine.filament_heater: + self.mmu.log_debug("MmuEnvironmentManager: Heater %s turned off" % self.mmu.mmu_machine.filament_heater) + hname = self._heater_name(self.mmu.mmu_machine.filament_heater) + self.mmu.gcode.run_script_from_command("SET_HEATER_TEMPERATURE HEATER=%s TARGET=0" % hname) + return + + if gate is None: + # Turn off all known heaters (best effort) + self.mmu.log_debug("MmuEnvironmentManager: All gate heaters turned off") + heaters = self.mmu.mmu_machine.filament_heaters + for i in range(len(heaters)): + if heaters[i]: + hname = self._heater_name(heaters[i]) + self.mmu.gcode.run_script_from_command("SET_HEATER_TEMPERATURE HEATER=%s TARGET=0" % hname) + return + + heaters = self.mmu.mmu_machine.filament_heaters + if gate < 0 or gate >= len(heaters) or not heaters[gate]: + return + _,target = self._get_heater_status(gate) + if target: + hname = self._heater_name(heaters[gate]) + self.mmu.log_debug("MmuEnvironmentManager: Gate %d heater %s turned off" % (gate, hname)) + self.mmu.gcode.run_script_from_command("SET_HEATER_TEMPERATURE HEATER=%s TARGET=0" % hname) + + + def _heater_name(self, heater_obj_name): + """ + Return just the simple heater name from the heater object name + """ + return heater_obj_name.split(None, 1)[1].strip() + + + def _get_heater_status(self, gate=None): + """ + Return tuple of temperature and target temperature from heater + either the single heater or the per-gate heater + Returns (None, None) if heater is not configured / not found. + """ + if gate is None: + heater_name = self.mmu.mmu_machine.filament_heater + else: + heaters = self.mmu.mmu_machine.filament_heaters + if gate < 0 or gate >= len(heaters) or not heaters[gate]: + return (None, None) + heater_name = heaters[gate] + + obj = self.mmu.printer.lookup_object(heater_name, None) + if obj is None: + return (None, None) + + status = obj.get_status(0) + return (status.get('temperature'), status.get('target')) + + + def _get_environment_status(self, gate=None): + """ + Return tuple of temperature and humidity from environment sensor. + Note that some configured sensors may only offer temperature + """ + if gate is None: + sensor = self.mmu.mmu_machine.environment_sensor + else: + sensors = self.mmu.mmu_machine.environment_sensors + if gate < 0 or gate >= len(sensors) or not sensors[gate]: + return None, None + sensor = sensors[gate] + + obj = self.mmu.printer.lookup_object(sensor, None) + if obj is None: + return None, None + + status = obj.get_status(0) + temperature = status.get('temperature') + + # See if chip supports humidity (we hope so) + humidity = None + p = sensor.split() + s_name = p[1] if len(p) > 1 else None + if s_name: + for chip in self.ENV_SENSOR_CHIPS: + obj = self.mmu.printer.lookup_object("%s %s" % (chip, s_name), None) + if obj: + humidity = obj.get_status(0).get('humidity') + break + + return (temperature, humidity) + + + def _rotate_spools_in_gates(self, gates): + """ + eSpooler-driven spool rotation. + Move the spools in the retract direction a small distance, 90 degrees is perfect + """ + self.mmu.log_info("Rotating spools in gates: %s..." % ",".join(map(str, gates))) + if self.mmu.mmu_machine.mmu_vendor != VENDOR_VVD: + self.spools_to_rotate = list(gates) + # Initiate rotation of first spool -- they are moved in sequence for asetics and to avoid possiblity of overload + self._rotate_spool(self.spools_to_rotate[0]) + return + + # Special case VVD design because of unique spool rotation using shared gear stepper coupled to gate selection + if not self.mmu.is_in_print(): + gate_selected = self.mmu.gate_selected + for gate in gates: + self.mmu.select_gate(gate) + _,_,_,_ = self.mmu.trace_filament_move("Rotating spool for drying", -100, motor="gear", wait=True) + self.mmu.select_gate(gate_selected) + + + def _rotate_spool(self, gate): + """ + Send event to cause a small rewind action to rotate the spool in gate + """ + power = self.mmu.espooler_rewind_burst_power + duration = self.mmu.espooler_rewind_burst_duration + self.mmu.printer.send_event("mmu:espooler_burst", gate, power / 100., duration, self.mmu.ESPOOLER_REWIND) + + + def _handle_espooler_burst_done(self, gate): + """ + Event indicating that a spool rotation completed + """ + if gate in self.spools_to_rotate: + self.spools_to_rotate.remove(gate) + if self.spools_to_rotate: + self._rotate_spool(self.spools_to_rotate[0]) + + + def _get_max_drying_temp_time(self, gates): + """ + For the given gates, look up each gate's material to find drying data (temp/time) + Return (lowest_temp, longest_time) across the set. + + If a material is not found in self.drying_data, use: + - self.heater_default_dry_temp + - self.heater_default_dry_time + """ + default_temp = self.heater_default_dry_temp + default_time = self.heater_default_dry_time + + lowest_temp = None + longest_time = None + + for gate in gates: + material = self.mmu.gate_material[gate] + key = str(material).upper() + + temp, duration = self.drying_data.get(key, (default_temp, default_time)) + + # Track lowest temperature + if lowest_temp is None or temp < lowest_temp: + lowest_temp = temp + + # Track longest time + if longest_time is None or duration > longest_time: + longest_time = duration + + # If no matching materials return defaults + if lowest_temp is None: + lowest_temp = default_temp + if longest_time is None: + longest_time = default_time + + return (lowest_temp, longest_time) + + + def _get_drying_plan(self, gates): + """ + For the given gates, look up each gate's material to find drying data (temp/time). + Returns dict indexed by gate: + plan[gate] = { 'temp': recommended_temp, 'timer': recommended_time, ... } + + If a material is not found in self.drying_data, use defaults. + """ + max_temp = self.heater_max_temp + default_temp = self.heater_default_dry_temp + default_time = self.heater_default_dry_time + + plan = {} + for gate in gates: + material = self.mmu.gate_material[gate] + key = str(material).upper() + temp, duration = self.drying_data.get(key, (default_temp, default_time)) + plan[gate] = { + 'material': material, + 'temp': float(min(max_temp, temp)), + 'timer': int(duration), + } + return plan + diff --git a/klippy/extras/mmu/mmu_extruder_monitor.py b/klippy/extras/mmu/mmu_extruder_monitor.py new file mode 100644 index 000000000000..930bf36a4305 --- /dev/null +++ b/klippy/extras/mmu/mmu_extruder_monitor.py @@ -0,0 +1,153 @@ +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Helper class for callback based extruder monitoring. +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# + +from .mmu_shared import MmuError + +class ExtruderMonitor: + """ + Periodically samples the extruder position and notifies registered callbacks + when their per-callback movement thresholds are crossed (in either direction). + + - Each callback tracks its own signed movement accumulator since it was registered + or last triggered. The callback is invoked with the *signed* distance moved. + - Global enable/disable is controlled by (re)scheduling the reactor timer. + """ + + CHECK_INTERVAL = 0.5 # How often to check extruder movement (seconds) + + def __init__(self, mmu): + self.mmu = mmu + + self.enabled = True + self._last_pos = None # Last absolute extruder position + + # Per-callback state: + # { callback: {"threshold": float, "accum": float} } + self._callbacks = {} + + self._timer = self.mmu.reactor.register_timer(self._check_extruder_movement) + self.enable() # Start now + + + def enable(self): + """ + Globally enable monitoring and start the watchdog immediately. + """ + self.mmu.reactor.update_timer(self._timer, self.mmu.reactor.NOW) + self.enabled = True + + + def disable(self): + """ + Globally disable monitoring and stop the watchdog. + """ + self.mmu.reactor.update_timer(self._timer, self.mmu.reactor.NEVER) + self.enabled = False + + + def register_callback(self, cb, movement_threshold): + """ + Register a callback to be notified when accumulated movement crosses + the threshold (in either direction). + Args: + cb: Callable taking one positional arg: signed_distance_mm (float). + movement_threshold: Positive float, in mm. Must be > 0. + Behavior: + - Resets this callback's accumulator to 0 on registration. + - If already registered, updates the threshold and resets accumulator. + """ + if not callable(cb): + raise TypeError("cb must be callable") + if movement_threshold is None or movement_threshold <= 0: + raise ValueError("movement_threshold must be a positive float") + + self._callbacks[cb] = {"threshold": float(movement_threshold), "accum": 0.0} + + # Ensure the timer is running if globally enabled + if self.enabled: + self.mmu.reactor.update_timer(self._timer, self.mmu.reactor.NOW) + + + def remove_callback(self, cb): + """ + Unregister a previously registered callback. Silently ignores unknown cbs. + """ + self._callbacks.pop(cb, None) + + + def get_and_reset_accumulated(self, cb): + """ + Get the currently accumulated signed distance for a given callback + and reset its accumulator to zero. + Args: + cb: The same callback object that was passed to register_callback(). + Returns: + float: The signed accumulated distance in mm since registration or + the last reset/trigger. + Raises: + KeyError: If the callback is not currently registered. + """ + state = self._callbacks.get(cb) + if state is None: + raise KeyError("Callback is not registered with ExtruderMonitor") + + distance = state["accum"] + state["accum"] = 0.0 + return distance + + + # ---- Internal Implementation ---- + + def _check_extruder_movement(self, eventtime): + """ + Reactor timer entrypoint. Returns the next wakeup time. + """ + if not self.enabled or not self._callbacks: + return eventtime + self.CHECK_INTERVAL + + mcu = self.mmu.printer.lookup_object('mcu') + est_print_time = mcu.estimated_print_time(eventtime) + pos = self.mmu.toolhead.get_extruder().find_past_position(est_print_time) + + # Initialize last position on first successful read + if self._last_pos is None: + self._last_pos = pos + return eventtime + self.CHECK_INTERVAL + + # Compute signed delta since last sample + delta = pos - self._last_pos + self._last_pos = pos + + if delta != 0.0: + # Accumulate per-subscriber and trigger as needed + to_trigger = [] + for cb, state in self._callbacks.items(): + state["accum"] += delta # Signed accumulation + threshold = state["threshold"] + + if abs(state["accum"]) >= threshold: + # Capture the actual signed distance since last trigger and reset + signed_distance = state["accum"] + state["accum"] = 0.0 + to_trigger.append((cb, signed_distance)) + + # Second loop to avoid reentrancy issues if callbacks mutate subscriptions + for cb, signed_distance in to_trigger: + try: + cb(eventtime, signed_distance) + except MmuError as ee: + self.mmu.log_error("Error calling callback: %s" % str(ee)) + + # Reschedule + return eventtime + self.CHECK_INTERVAL diff --git a/klippy/extras/mmu/mmu_led_manager.py b/klippy/extras/mmu/mmu_led_manager.py new file mode 100644 index 000000000000..26449c744f56 --- /dev/null +++ b/klippy/extras/mmu/mmu_led_manager.py @@ -0,0 +1,747 @@ +# -*- coding: utf-8 -*- +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Manager class to centralize mmu_led operations accross all mmu_units +# +# Implements commands: +# MMU_SET_LED +# MMU_LED +# +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging + +# Happy Hare imports +from ..mmu_leds import MmuLeds + +# MMU subcomponent clases +from .mmu_shared import * + +class MmuLedManager: + def __init__(self, mmu): + self.mmu = mmu + self.mmu_machine = mmu.mmu_machine + self.inside_timer = False + self.pending_update = [False] * self.mmu_machine.num_units + self.effect_state = {} # Current state used to minimise updates {unit: {segment: effect}} + + # Event handlers + self.mmu.printer.register_event_handler("klippy:ready", self.handle_ready) + + # Register commands + self.mmu.gcode.register_command('MMU_SET_LED', self.cmd_MMU_SET_LED, desc = self.cmd_MMU_SET_LED_help) + self.mmu.gcode.register_command('MMU_LED', self.cmd_MMU_LED, desc = self.cmd_MMU_LED_help) + + def handle_ready(self): + self.setup_led_timer() + + def setup_led_timer(self): + self.led_timer = self.mmu.reactor.register_timer(self.led_timer_handler, self.mmu.reactor.NEVER) + + def led_timer_handler(self, eventtime): + self.inside_timer = True + try: + for unit in range(self.mmu_machine.num_units): + self.pending_update[unit] = False + self._set_led(unit, None, exit_effect='default', entry_effect='default', status_effect='default', logo_effect='default') + finally: + self.inside_timer = False + return self.mmu.reactor.NEVER + + def schedule_led_command(self, duration, unit): + if not self.inside_timer: + self.pending_update[unit] = True + self.mmu.reactor.update_timer(self.led_timer, self.mmu.reactor.monotonic() + duration) + + cmd_MMU_SET_LED_help = "Directly control MMU leds" + cmd_MMU_SET_LED_param_help = ( + "MMU_SET_LED: %s\n" % cmd_MMU_SET_LED_help + + "UNIT = #(int)\n" + + "GATE = #(int)\n" + + "EXIT_EFFECT = [off|gate_status|filament_color|slicer_color|r,g,b|_effect_]\n" + + "ENTRY_EFFECT = [off|gate_status|filament_color|slicer_color|r,g,b|_effect_]\n" + + "STATUS_EFFECT = [off|on|filament_color|slicer_color|r,g,b|_effect_]\n" + + "LOGO_EFFECT = [off|r,g,b|_effect_]\n" + + "DURATION = #.#(float) seconds\n" + + "FADETIME = #.#(float) seconds" + ) + def cmd_MMU_SET_LED(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + + help = bool(gcmd.get_int('HELP', 0, minval=0, maxval=1)) + unit = gcmd.get_int('UNIT', 0, minval=0, maxval=self.mmu_machine.num_units - 1) + gate = gcmd.get_int('GATE', None, minval=0, maxval=self.mmu_machine.num_gates - 1) + exit_effect = gcmd.get('EXIT_EFFECT', None) + entry_effect = gcmd.get('ENTRY_EFFECT', None) + status_effect = gcmd.get('STATUS_EFFECT', None) + logo_effect = gcmd.get('LOGO_EFFECT', None) + duration = gcmd.get_float('DURATION', None, minval=0) + fadetime = gcmd.get_float('FADETIME', 1, minval=0) + + if self.mmu_machine.get_mmu_unit_by_index(unit).leds is None: + self.mmu.log_error("No LEDs configured on MMU unit %d" % unit) + return + + if help: + self.mmu.log_always(self.mmu.format_help(self.cmd_MMU_SET_LED_param_help), color=True) + return + + self._set_led( + unit, gate, + entry_effect=entry_effect, + exit_effect=exit_effect, + status_effect=status_effect, + logo_effect=logo_effect, + fadetime=fadetime, + duration=duration + ) + + cmd_MMU_LED_help = "Manage mode of operation of optional MMU LED's" + cmd_MMU_LED_param_help = ( + "MMU_LED: %s\n" % cmd_MMU_LED_help + + "UNIT = #(int) default all units\n" + + "ENABLE = [0|1]\n" + + "ANIMATION = [0|1]\n" + + "EXIT_EFFECT = [off|gate_status|filament_color|slicer_color|r,g,b|_effect_]\n" + + "ENTRY_EFFECT = [off|gate_status|filament_color|slicer_color|r,g,b|_effect_]\n" + + "STATUS_EFFECT = [off|on|filament_color|slicer_color|r,g,b|_effect_]\n" + + "LOGO_EFFECT = [off|r,g,b|_effect_]\n" + + "REFRESH = [0|1]\n" + + "QUIET = [0|1]" + ) + def cmd_MMU_LED(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + + quiet = bool(gcmd.get_int('QUIET', 0, minval=0, maxval=1)) + help = bool(gcmd.get_int('HELP', 0, minval=0, maxval=1)) + refresh = bool(gcmd.get_int('REFRESH', 0, minval=0, maxval=1)) + unit = gcmd.get_int('UNIT', None, minval=0, maxval=self.mmu_machine.num_units - 1) + + mmu_units = [self.mmu_machine.get_mmu_unit_by_index(unit)] if unit is not None else self.mmu_machine.units + if all(mmu_unit.leds is None for mmu_unit in mmu_units): + self.mmu.log_error("No LEDs configured on MMU") + return + + if help: + self.mmu.log_always(self.mmu.format_help(self.cmd_MMU_LED_param_help), color=True) + return + + msg = "" + for mmu_unit in mmu_units: + leds = mmu_unit.leds + unit = mmu_unit.unit_index + + if leds: + exit_effect = gcmd.get('EXIT_EFFECT', leds.exit_effect) + entry_effect = gcmd.get('ENTRY_EFFECT', leds.entry_effect) + status_effect = gcmd.get('STATUS_EFFECT', leds.status_effect) + logo_effect = gcmd.get('LOGO_EFFECT', leds.logo_effect) + enabled = bool(gcmd.get_int('ENABLE', leds.enabled, minval=0, maxval=1)) + animation = bool(gcmd.get_int('ANIMATION', leds.animation, minval=0, maxval=1)) + + if leds.enabled and not enabled or refresh: + # Enabled to disabled or refresh + self._set_led( + unit, None, + exit_effect='off', + entry_effect='off', + status_effect='off', + logo_effect='off' + ) + else: + if leds.animation and not animation: + # Turning animation off so clear existing effects + self._set_led( + unit, None, + exit_effect='off', + entry_effect='off', + status_effect='off', + logo_effect='off', + fadetime=0 + ) + + if (leds.exit_effect != exit_effect or + leds.entry_effect != entry_effect or + leds.status_effect != status_effect or + leds.logo_effect != logo_effect or + leds.enabled != enabled or + leds.animation != animation or + refresh): + + leds.exit_effect = exit_effect + leds.entry_effect = entry_effect + leds.status_effect = status_effect + leds.logo_effect = logo_effect + leds.enabled = enabled + leds.animation = animation + + if enabled: + self._set_led( + unit, None, + exit_effect='default', + entry_effect='default', + status_effect='default', + logo_effect='default' + ) + + if not quiet: + available = lambda effect, enabled : ("'%s'" % str(effect)) if enabled else "unavailable" + msg += "\nUnit %s LEDs (%s)\n" % (unit, ("enabled" if enabled else "disabled")) + msg += " Animation: %s\n" % ("enabled" if animation else "disabled") + msg += " Default exit effect: %s\n" % available(exit_effect, leds.get_status()['exit']) + msg += " Default entry effect: %s\n" % available(entry_effect, leds.get_status()['entry']) + msg += " Default status effect: %s\n" % available(status_effect, leds.get_status()['status']) + msg += " Default logo effect: %s\n" % available(logo_effect, leds.get_status()['logo']) + else: + msg += "No LEDs configured on MMU unit %d" % unit + self.mmu.log_always(msg) + + # Called when an action has changed to update LEDs + # (this could be changed to klipper event) + def action_changed(self, action, old_action): + gate = self.mmu.gate_selected + + # Check for unit specific actions + if action in [self.mmu.ACTION_HOMING, self.mmu.ACTION_SELECTING]: + units_to_update = [self.mmu.unit_selected] + else: + units_to_update = range(self.mmu_machine.num_units) + + for unit in units_to_update: + + # Load sequence... + # idle -> loading -> load_ext -> [heat -> load_ext] -> loading* -> purging* -> loading* -> idle (*=excluded) + + if action == self.mmu.ACTION_LOADING: + if old_action not in [self.mmu.ACTION_LOADING_EXTRUDER, self.mmu.ACTION_PURGING]: + self._set_led( + unit, gate, + exit_effect=self.effect_name(unit, 'loading'), + status_effect=self.effect_name(unit, 'loading'), + fadetime=0.5 + ) + + elif action == self.mmu.ACTION_LOADING_EXTRUDER: + self._set_led( + unit, gate, + exit_effect=self.effect_name(unit, 'loading_extruder'), + status_effect=self.effect_name(unit, 'loading_extruder'), + fadetime=0.5 + ) + + elif action == self.mmu.ACTION_PURGING: + pass + + # Unload sequence... + # idle -> unloading -> form_tip/cut -> [heat -> form_tip/cut] -> unloading* -> unload_ext -> unloading + # [cutting* -> unloading*] -> idle (*=excluded) + elif action == self.mmu.ACTION_UNLOADING: + if old_action == self.mmu.ACTION_IDLE: + self._set_led( + unit, gate, + exit_effect=self.effect_name(unit, 'unloading_extruder'), + status_effect=self.effect_name(unit, 'unloading_extruder'), + fadetime=0.5 + ) + + elif old_action not in [ + self.mmu.ACTION_FORMING_TIP, + self.mmu.ACTION_CUTTING_FILAMENT + ]: + self._set_led( + unit, gate, + exit_effect=self.effect_name(unit, 'unloading'), + status_effect=self.effect_name(unit, 'unloading'), + fadetime=0.5 + ) + + elif action == self.mmu.ACTION_UNLOADING_EXTRUDER: + self._set_led( + unit, gate, + exit_effect=self.effect_name(unit, 'unloading_extruder'), + status_effect=self.effect_name(unit, 'unloading_extruder'), + fadetime=0.5 + ) + + elif action in [self.mmu.ACTION_FORMING_TIP, self.mmu.ACTION_FORMING_TIP]: + self._set_led( + unit, gate, + exit_effect=self.effect_name(unit, 'unloading_extruder'), + status_effect=self.effect_name(unit, 'unloading_extruder'), + fadetime=0.5 + ) + + elif action == self.mmu.ACTION_CUTTING_FILAMENT: + pass + + # Other actions... + + elif action == self.mmu.ACTION_HEATING: + self._set_led( + unit, gate, + exit_effect=self.effect_name(unit, 'heating'), + status_effect=self.effect_name(unit, 'heating') + ) + + elif action == self.mmu.ACTION_IDLE: + self._set_led( + unit, None, + exit_effect='default', + status_effect='default' + ) + + # Type-A MMU actions involving selector (unit specific)... + + # idle -> home -> select -> home* -> idle (*=excluded) + elif action == self.mmu.ACTION_HOMING: + if old_action == self.mmu.ACTION_IDLE: + self._set_led( + unit, None, + exit_effect=self.effect_name(unit, 'selecting'), + status_effect=self.effect_name(unit, 'selecting'), + fadetime=0 + ) + + # idle -> select -> idle + elif action == self.mmu.ACTION_SELECTING: + if old_action not in [self.mmu.ACTION_CHECKING]: + self._set_led( + unit, None, + exit_effect='default', + status_effect=self.effect_name(unit, 'selecting'), + fadetime=0 + ) + + # idle -> check -> select* -> check* -> select* -> check* -> idle + elif action == self.mmu.ACTION_CHECKING: + if old_action == self.mmu.ACTION_IDLE: + self._set_led( + unit, None, + exit_effect='default', + status_effect=self.effect_name(unit, 'checking') + ) + + # Called when print state changes to update LEDs + # (this could be changed to klipper event) + def print_state_changed(self, state, old_state): + gate = self.mmu.gate_selected + if state in ['initialized', 'printing', 'ready', 'cancelled', 'standby']: + units_to_update = range(self.mmu_machine.num_units) + else: + units_to_update = [self.mmu.unit_selected] + + for unit in units_to_update: + if state == "initialized": + self._set_led( + unit, None, + exit_effect=self.effect_name(unit, 'initialized'), + entry_effect=self.effect_name(unit, 'initialized'), + status_effect=self.effect_name(unit, 'initialized'), + duration=8 + ) + + elif state == "printing": + self._set_led( + unit, None, + exit_effect='default', + entry_effect='default', + status_effect='default' + ) + + elif state == "pause_locked": + self._set_led( + unit, None, + exit_effect=self.effect_name(unit, 'error'), + status_effect=self.effect_name(unit, 'error') + ) + + elif state == "paused": + self._set_led( + unit, gate, # Focus to specific gate + exit_effect=self.effect_name(unit, 'error'), + status_effect=self.effect_name(unit, 'error') + ) + + elif state == "ready": + self._set_led( + unit, None, + exit_effect='default', + entry_effect='default', + status_effect='default' + ) + + elif state == "complete": + self._set_led( + unit, None, + exit_effect=self.effect_name(unit, 'complete'), + status_effect='default', + duration=10 + ) + + elif state == "error": + self._set_led( + unit, None, + exit_effect=self.effect_name(unit, 'error'), + status_effect='default', + duration=10 + ) + + elif state == "cancelled": + self._set_led( + unit, None, + exit_effect='default', + entry_effect='default', + status_effect='default' + ) + + elif state == "standby": + self._set_led( + unit, None, + exit_effect='off', + entry_effect='off', + status_effect='off', + logo_effect='off' + ) + + # Called when gate map is updated to update LEDs + # (this could be changed to klipper event) + def gate_map_changed(self, gate): + if gate is not None and gate < 0: + gate = None + gate_effects = {'gate_status', 'filament_color', 'slicer_color'} + units = [self.mmu_machine.get_mmu_unit_by_gate(gate)] if gate is not None else self.mmu_machine.units + for mmu_unit in units: + leds = mmu_unit.leds + if not leds: + continue + + entry_effect = leds.entry_effect if leds.entry_effect in gate_effects else None + exit_effect = leds.exit_effect if leds.exit_effect in gate_effects else None + status_effect = leds.status_effect if leds.status_effect in gate_effects - {'gate_status'} else None + + if exit_effect or entry_effect or status_effect: + self._set_led( + mmu_unit.unit_index, + gate, + exit_effect=exit_effect, + entry_effect=entry_effect, + status_effect=status_effect + ) + + def effect_name(self, unit, operation): + leds = self.mmu_machine.get_mmu_unit_by_index(unit).leds + if leds: + return leds.get_effect(operation) + return '' + + # Make the necessary configuration changes to LED accross all mmu_units + # + # Effects for LED segments when not providing "action status feedback" can be: + # any effect name, "r,g,b" color, or built-in functional effects: + # "off" - LED's off + # "on" - LED's white + # "gate_status" - indicate gate availability + # "filament_color" - indicate filament color + # "slicer_color" - display slicer defined color for each gate + def _set_led(self, unit, gate, duration=None, fadetime=1, exit_effect=None, entry_effect=None, status_effect=None, logo_effect=None): + effects = { + 'entry': entry_effect, + 'exit': exit_effect, + 'status': status_effect, + 'logo': logo_effect, + } + + # Helper functions to make core logic simplier... + + # Iteration wrapper to easily detect the last loop + def with_last(iterable): + it = iter(iterable) + try: + prev = next(it) + except StopIteration: + return # Empty iterable + for item in it: + yield prev, False + prev = item + yield prev, True + + # List of led indexes (1-based on led_chain_spec) for iteration + def led_indexes(unit, segment, gate): + mmu_unit = self.mmu_machine.get_mmu_unit_by_index(unit) + num_leds = mmu_unit.leds.get_status()[segment] + if gate is None or gate < 0: + return list(range(1, num_leds + 1)) + leds_per_gate = num_leds // mmu_unit.num_gates + index0 = (gate - mmu_unit.first_gate) * leds_per_gate + 1 + return list(range(index0, index0 + leds_per_gate)) + + # Get raw "LEDS=" spec to stop an effect on virtual chain for given segment + def led_chain_spec(unit, segment): + return 'unit%d_mmu_%s_leds' % (unit, segment) + + # Get specific LEDS=" spec to stop an effect on whole segment or gate part of segment + def effect_leds_spec(unit, segment, gate): + if gate is not None and gate >= 0: + led_index_str = ','.join(map(str, led_indexes(unit, segment, gate))) + return "%s (%s)" % (led_chain_spec(unit, segment), led_index_str) # All leds for gate + return led_chain_spec(unit, segment) # All leds in segment + + # Get "EFFECT=" spec Used for applying effects + def effect_spec(unit, gate, effect): + if gate is not None and gate >= 0: + return "%s_%d" % (effect, gate) + return "unit%d_%s" % (unit, effect) + + # Translate desired effect into specific one based on context + def get_effective_effect(mmu_unit, segment, suggested): + if not mmu_unit.leds or not mmu_unit.leds.enabled or mmu_unit.leds.get_status()[segment] == 0: + return '' # Not available + elif suggested == 'default': + return mmu_unit.leds.get_status()['%s_effect' % segment] + return suggested + + # Stop the current effect on the gate led(s) + def stop_gate_effect(unit, segment, gate, fadetime=None): + if self.mmu_machine.get_mmu_unit_by_index(unit).leds.animation: + self.mmu.gcode.run_script_from_command( + "_MMU_STOP_LED_EFFECTS LEDS='%s' %s" % ( + effect_leds_spec(unit, segment, gate), + ('FADETIME=%d' % fadetime) if fadetime is not None else '' + ) + ) + + # Sets or replaces effect on the gate led(s) + def set_gate_effect(base_effect, unit, segment, gate, fadetime=None): + leds = self.mmu_machine.get_mmu_unit_by_index(unit).leds + if leds.animation: + self.mmu.gcode.run_script_from_command( + "_MMU_SET_LED_EFFECT EFFECT='%s' REPLACE=1 %s" % ( + effect_spec(unit, gate, "%s_%s" % (base_effect, segment)), + ('FADETIME=%d' % fadetime) if fadetime is not None else '' + ) + ) + else: + # Set all leds for effect to static rbg + rgb = leds.get_rgb_for_effect(base_effect) + set_gate_rgb(rgb, unit, segment, gate) + + # Sets rgb value of gate led(s) + def set_gate_rgb(rgb, unit, segment, gate, transmit=True): + # Normally there is only a single led per gate but some designs have many + for index, is_last in with_last(led_indexes(unit, segment, gate)): + self.mmu.gcode.run_script_from_command( + "SET_LED LED=%s INDEX=%d RED=%s GREEN=%s BLUE=%s TRANSMIT=%d" % ( + led_chain_spec(unit, segment), index, rgb[0], rgb[1], rgb[2], 1 if transmit and is_last else 0 + ) + ) + + # Stop any previous effect before setting rgb else it won't have an effect + def stop_effect_and_set_gate_rgb(rgb, unit, segment, gate, fadetime=None): + if fadetime: + set_gate_rgb(rgb, unit, segment, gate) + stop_gate_effect(unit, segment, gate, fadetime=fadetime) + else: + stop_gate_effect(unit, segment, gate) + set_gate_rgb(rgb, unit, segment, gate) + + + # + # Process LED update... + # + try: + mmu_unit = self.mmu_machine.get_mmu_unit_by_index(unit) + if ( + not mmu_unit.leds or + not mmu_unit.leds.enabled or + (gate is not None and not mmu_unit.manages_gate(gate)) + ): + # Ignore if unit doesn't have leds, is disabled for doesn't manage the specific gate + # (saves callers from checking) + return + + if gate is not None and gate < 0: + return + + # Don't allow changes to shortcut animations - important changes will be seen when update timer fires + if self.pending_update[unit]: + return + + # Schedule a return to defaults after duration + if duration is not None: + self.schedule_led_command(duration, unit) + + # + # Entry and Exit + # + for segment in ['exit', 'entry']: + effect = get_effective_effect(mmu_unit, segment, effects[segment]) + + # effect will be None if leds not configured for no led chain for that segment + #if not effect or self.effect_state.get(unit, {}).get(segment) == effect: + if not effect: + continue + + elif effect == "off": + + stop_effect_and_set_gate_rgb((0,0,0), unit, segment, gate, fadetime=fadetime) + + elif effect == "gate_status": # Filament availability (gate_map) + + def _effect_for_gate(g): + # Selected gate, with filament past extruder entry: force 'gate_selected' + if g == self.mmu.gate_selected and self.mmu.filament_pos > self.mmu.FILAMENT_POS_EXTRUDER_ENTRY: + return self.effect_name(unit, 'gate_selected') + + suffix = '_sel' if g == self.mmu.gate_selected else '' + status = self.mmu.gate_status[g] + + if status == self.mmu.GATE_UNKNOWN: + key = 'gate_unknown' + elif status > self.mmu.GATE_EMPTY: + key = 'gate_available' + else: + key = 'gate_empty' + + return self.effect_name(unit, '%s%s' % (key, suffix)) + + if gate is not None: + set_gate_effect(_effect_for_gate(gate), unit, segment, gate, fadetime=fadetime) + else: + for g in range(mmu_unit.first_gate, mmu_unit.first_gate + mmu_unit.num_gates): + set_gate_effect(_effect_for_gate(g), unit, segment, g, fadetime=fadetime) + + elif effect == "filament_color": + + def _resolve_filament_rgb(g): + rgb = self.mmu.gate_color_rgb[g] + if self.mmu.gate_status[g] == self.mmu.GATE_EMPTY: + return mmu_unit.leds.empty_light + if self.mmu.gate_color[g] == "": + return mmu_unit.leds.white_light + if rgb == (0, 0, 0): + return mmu_unit.leds.black_light + return rgb + + if gate is not None: + rgb = _resolve_filament_rgb(gate) + stop_effect_and_set_gate_rgb(rgb, unit, segment, gate) + else: + stop_gate_effect(unit, segment, None) + for g, is_last in with_last(range(mmu_unit.first_gate, mmu_unit.first_gate + mmu_unit.num_gates)): + rgb = _resolve_filament_rgb(g) + set_gate_rgb(rgb, unit, segment, g, transmit=is_last) + + elif effect == "slicer_color": + + def _resolve_slicer_rgb(g): + rgb = self.mmu.slicer_color_rgb[g] + if self.mmu.gate_status[g] == self.mmu.GATE_EMPTY: + return mmu_unit.leds.empty_light + if rgb == (0, 0, 0): + return mmu_unit.leds.black_light + return rgb + + if gate is not None: + rgb = _resolve_slicer_rgb(gate) + stop_effect_and_set_gate_rgb(rgb, unit, segment, gate) + else: + stop_gate_effect(unit, segment, None) # Stop all gates + for g, is_last in with_last(range(mmu_unit.first_gate, mmu_unit.first_gate + mmu_unit.num_gates)): + rgb = _resolve_slicer_rgb(g) + set_gate_rgb(rgb, unit, segment, g, transmit=is_last) + + elif isinstance(effect, tuple) or ',' in effect: # RGB color + rgb = MmuLeds.string_to_rgb(effect) + if gate is not None: + stop_effect_and_set_gate_rgb(rgb, unit, segment, gate) + else: + stop_gate_effect(unit, segment, None) # Stop all gates + for g, is_last in with_last(range(mmu_unit.first_gate, mmu_unit.first_gate + mmu_unit.num_gates)): + set_gate_rgb(rgb, unit, segment, g, transmit=is_last) + + elif effect != "": # Named effect + set_gate_effect(effect, unit, segment, gate, fadetime=fadetime) + + self.effect_state.setdefault(unit, {})[segment] = effect + + + # + # Status + # + segment = "status" + effect = get_effective_effect(mmu_unit, segment, effects[segment]) + + #if not effect or self.effect_state.get(unit, {}).get(segment) == effect: + if not effect: + pass + + elif effect == "off": + + stop_effect_and_set_gate_rgb((0,0,0), unit, segment, gate, fadetime=fadetime) + + elif effect in ["filament_color", "on"]: + + stop_gate_effect(unit, segment, None) + rgb = mmu_unit.leds.white_light + if self.mmu.gate_selected >= 0 and self.mmu.filament_pos > self.mmu.FILAMENT_POS_UNLOADED: + if effects[segment] != "on" and self.mmu.gate_color[self.mmu.gate_selected] != "": + rgb = self.mmu.gate_color_rgb[self.mmu.gate_selected] + if rgb == (0,0,0): + rgb = mmu_unit.leds.black_light + else: + rgb = mmu_unit.leds.black_light + set_gate_rgb(rgb, unit, segment, None) + + elif effect == "slicer_color": + + stop_gate_effect(unit, segment, None) + rgb = (0,0,0) + if self.mmu.gate_selected >= 0 and self.mmu.filament_pos > self.mmu.FILAMENT_POS_UNLOADED: + rgb = self.mmu.slicer_color_rgb[self.mmu.gate_selected] + set_gate_rgb(rgb, unit, segment, None) + + elif isinstance(effect, tuple) or ',' in effect: # RGB color + rgb = MmuLeds.string_to_rgb(effect) + stop_effect_and_set_gate_rgb(rgb, unit, segment, None) + + elif effect != "": # Named effect + set_gate_effect(effect, unit, segment, None, fadetime=fadetime) + + self.effect_state.setdefault(unit, {})[segment] = effect + + # + # Logo + # + segment = "logo" + effect = get_effective_effect(mmu_unit, segment, effects[segment]) + + #if not effect or self.effect_state.get(unit, {}).get(segment) == effect: + if not effect: + pass + + elif effect == "off": + + stop_effect_and_set_gate_rgb((0,0,0), unit, segment, None, fadetime=fadetime) + + elif isinstance(effect, tuple) or ',' in effect: # RGB color + rgb = MmuLeds.string_to_rgb(effect) + stop_effect_and_set_gate_rgb(rgb, unit, segment, None) + + elif effect != "": # Named effect + + set_gate_effect(effect, unit, segment, None, fadetime=fadetime) + + self.effect_state.setdefault(unit, {})[segment] = effect + + except Exception as e: + # Don't let a misconfiguration ruin a print! + self.mmu.log_error("Error updating leds: %s" % str(e)) diff --git a/klippy/extras/mmu/mmu_logger.py b/klippy/extras/mmu/mmu_logger.py new file mode 100644 index 000000000000..78834cb43f3d --- /dev/null +++ b/klippy/extras/mmu/mmu_logger.py @@ -0,0 +1,80 @@ +# Happy Hare MMU Software +# Logging helpers +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, logging.handlers, threading, os, queue, atexit + +# MMU subcomponent clases +from .mmu_shared import * + +class MmuLogger: + def __init__(self, logfile_path): + name = os.path.splitext(os.path.basename(logfile_path))[0] + self.logger = logging.getLogger(name) + + self.queue_listener = None + if not any(isinstance(h, QueueHandler) for h in self.logger.handlers): + handler = logging.handlers.TimedRotatingFileHandler(logfile_path, when='midnight', backupCount=3) + handler.setFormatter(MultiLineFormatter('%(asctime)s %(message)s', datefmt='%H:%M:%S')) + self.queue_listener = QueueListener(handler) + self.logger.addHandler(QueueHandler(self.queue_listener.bg_queue)) + + self.logger.setLevel(logging.INFO) + self.logger.propagate = False + atexit.register(self.shutdown) + + def log(self, message): + self.logger.info(message.replace(UI_SPACE, ' ').replace(UI_SEPARATOR, ' ')) + + def shutdown(self): + if self.queue_listener is not None: + self.queue_listener.stop() + +# Poll log queue on background thread and log each message to logfile +class QueueHandler(logging.Handler): + def __init__(self, log_queue): + super(QueueHandler, self).__init__() + self.queue = log_queue + + def emit(self, record): + try: + self.queue.put_nowait(record) + except Exception: + self.handleError(record) + +class QueueListener: + def __init__(self, handler): + self.bg_queue = queue.Queue() + self.handler = handler + self.bg_thread = threading.Thread(target=self._bg_thread) + self.bg_thread.daemon = True + self.bg_thread.start() + + def _bg_thread(self): + while True: + record = self.bg_queue.get(True) + if record is None: + break + self.handler.handle(record) + + def stop(self): + self.bg_queue.put_nowait(None) + self.bg_thread.join() + +# Class to improve formatting of multi-line messages +class MultiLineFormatter(logging.Formatter): + def format(self, record): + indent = ' ' * 9 + formatted_message = super(MultiLineFormatter, self).format(record) + if record.exc_text: + # Don't modify exception stack traces + return formatted_message + return formatted_message.replace('\n', '\n' + indent) diff --git a/klippy/extras/mmu/mmu_selector.py b/klippy/extras/mmu/mmu_selector.py new file mode 100644 index 000000000000..da53f4f0b264 --- /dev/null +++ b/klippy/extras/mmu/mmu_selector.py @@ -0,0 +1,2204 @@ +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Implementation of various selector variations: +# +# VirtualSelector: +# Implements selector for type-B MMU's with gear driver per gate +# - Uses gear driver stepper per-gate +# - For type-B designs like BoxTurtle, KMS, QuattroBox +# +# LinearSelector: +# Implements Linear Selector for type-A MMU's without servo +# - Stepper controlled linear movement with endstop +# - Supports type-A with combined selection and filament gripping line ERCFv3 +# +# LinearServoSelector: +# Implements Linear Selector for type-A MMU's with servo +# - Stepper controlled linear movement with endstop +# - Servo controlled filament gripping +# - Supports type-A classic MMU's like ERCFv1.1, ERCFv2.0 and Tradrack +# +# LinearMultiGearSelector: +# Implements Linear Selector for type-C MMU's with multiple gear steppers: +# - Uses gear driver stepper per-gate +# - Uses selector stepper for gate selection with endstop +# - Supports type-A classic MMU's like ERCF and Tradrack +# +# Rotary Selector +# - Rotary Selector for 3D Chamelon using stepper selection +# without servo +# +# Macro Selector +# - Universal selector control via macros +# - Great for experimention +# +# Servo Selector +# - Servo based Selector for PicoMMU and clones +# +# Indexed Selector +# - Stepper based Selector for ViViD with per-gate index sensors +# +# +# Implements commands (selector dependent): +# MMU_CALIBRATE_SELECTOR +# MMU_SOAKTEST_SELECTOR +# MMU_SERVO +# MMU_GRIP +# MMU_RELEASE +# +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import random, logging, math, re + +# Klipper imports +from ..homing import Homing, HomingMove + +# Happy Hare imports +from .. import mmu_machine +from ..mmu_machine import MmuToolHead + +# MMU subcomponent clases +from .mmu_shared import MmuError + + +################################################################################ +# Base Selector Class +################################################################################ + +class BaseSelector: + + def __init__(self, mmu): + self.mmu = mmu + self.mmu.managers.append(self) + self.is_homed = False + self.mmu_unit = 0 + + def reinit(self): + pass + + def handle_connect(self): + pass + + def handle_ready(self): + pass + + def handle_disconnect(self): + pass + + def bootup(self): + pass + + def home(self, force_unload = None): + pass + + def select_gate(self, gate): + pass + + def restore_gate(self, gate): + pass + + def filament_drive(self): + pass + + def filament_release(self, measure=False): + return 0. # Fake encoder movement + + def filament_hold_move(self): + pass + + def get_filament_grip_state(self): + return self.mmu.FILAMENT_DRIVE_STATE + + def disable_motors(self): + pass + + def enable_motors(self): + pass + + def buzz_motor(self, motor): + return False + + def has_bypass(self): + return self.mmu.mmu_machine.has_bypass + + def get_status(self, eventtime): + return { + 'has_bypass': self.has_bypass() + } + + def get_mmu_status_config(self): + return "\nSelector Type: %s" % self.__class__.__name__ + + def set_test_config(self, gcmd): + pass + + def get_test_config(self): + return "" + + def check_test_config(self, param): + return True + + def get_uncalibrated_gates(self, check_gates): + return [] + + + +################################################################################ +# VirtualSelector: +# Implements selector for type-B MMU's with gear driver per gate +# - Uses gear driver stepper per-gate +# - For type-B designs like BoxTurtle, KMS, QuattroBox +################################################################################ + +class VirtualSelector(BaseSelector, object): + + def __init__(self, mmu): + super(VirtualSelector, self).__init__(mmu) + self.is_homed = True + + # Read all controller parameters related to selector or servo to stop klipper complaining. This + # is done to allow for uniform and shared mmu_parameters.cfg file regardless of configuration. + for option in ['selector_', 'servo_', 'cad_']: + for key in mmu.config.get_prefix_options(option): + _ = mmu.config.get(key) + + # Selector "Interface" methods --------------------------------------------- + + def handle_connect(self): + self.mmu_toolhead = self.mmu.mmu_toolhead + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR # No calibration necessary + + def select_gate(self, gate): + if gate == self.mmu.gate_selected: return + self.mmu_toolhead.select_gear_stepper(gate) # Select correct drive stepper or none if bypass + + def restore_gate(self, gate): + self.mmu.mmu_toolhead.select_gear_stepper(gate) # Select correct drive stepper or none if bypass + + def get_mmu_status_config(self): + msg = "\nVirtual selector" + return msg + + + +################################################################################ +# LinearSelector: +# Implements Linear Selector for type-A MMU's with servo +# - Stepper controlled linear movement with endstop +# - Optional servo controlled filament gripping +# - Supports type-A classic MMU's like ERCF and Tradrack +################################################################################ + +class LinearSelector(BaseSelector, object): + + # mmu_vars.cfg variables + VARS_MMU_SELECTOR_OFFSETS = "mmu_selector_offsets" + VARS_MMU_SELECTOR_BYPASS = "mmu_selector_bypass" + + def __init__(self, mmu): + super(LinearSelector, self).__init__(mmu) + self.bypass_offset = -1 + + # Process config + self.selector_move_speed = mmu.config.getfloat('selector_move_speed', 200, minval=1.) + self.selector_homing_speed = mmu.config.getfloat('selector_homing_speed', 100, minval=1.) + self.selector_touch_speed = mmu.config.getfloat('selector_touch_speed', 60, minval=1.) + self.selector_touch_enabled = mmu.config.getint('selector_touch_enabled', 1, minval=0, maxval=1) + + # To simplfy config CAD related parameters are set based on vendor and version setting + # + # These are default for ERCFv1.1 - the first MMU supported by Happy Hare + # cad_gate0_pos - approximate distance from endstop to first gate + # cad_gate_width - width of each gate + # cad_bypass_offset - distance from end of travel to the bypass + # cad_last_gate_offset - distance from end of travel to last gate + # cad_block_width - width of bearing block (ERCF v1.1) + # cad_bypass_block_width - width of bypass block (ERCF v1.1) + # cad_bypass_block_delta - distance from previous gate to bypass (ERCF v1.1) + # + self.cad_gate0_pos = 4.2 + self.cad_gate_width = 21. + self.cad_bypass_offset = 0 + self.cad_last_gate_offset = 2. + self.cad_block_width = 5. + self.cad_bypass_block_width = 6. + self.cad_bypass_block_delta = 9. + self.cad_selector_tolerance = 15. + + # Specific vendor build parameters / tuning. + if self.mmu.mmu_machine.mmu_vendor.lower() == mmu_machine.VENDOR_ERCF.lower(): + if self.mmu.mmu_machine.mmu_version >= 2.0: # V2 community edition + self.cad_gate0_pos = 4.0 + self.cad_gate_width = 23. + self.cad_bypass_offset = 0.72 + self.cad_last_gate_offset = 14.4 + + else: # V1.1 original + # Modifications: + # t = TripleDecky filament blocks + # s = Springy sprung servo selector + # b = Binky encoder upgrade + if "t" in self.mmu.mmu_machine.mmu_version_string: + self.cad_gate_width = 23. # Triple Decky is wider filament block + self.cad_block_width = 0. # Bearing blocks are not used + + if "s" in self.mmu.mmu_machine.mmu_version_string: + self.cad_last_gate_offset = 1.2 # Springy has additional bump stops + + elif self.mmu.mmu_machine.mmu_vendor.lower() == mmu_machine.VENDOR_TRADRACK.lower(): + self.cad_gate0_pos = 2.5 + self.cad_gate_width = 17. + self.cad_bypass_offset = 0 # Doesn't have bypass + self.cad_last_gate_offset = 0. # Doesn't have reliable hard stop at limit of travel + + # But still allow all CAD parameters to be customized + self.cad_gate0_pos = mmu.config.getfloat('cad_gate0_pos', self.cad_gate0_pos, minval=0.) + self.cad_gate_width = mmu.config.getfloat('cad_gate_width', self.cad_gate_width, above=0.) + self.cad_bypass_offset = mmu.config.getfloat('cad_bypass_offset', self.cad_bypass_offset, minval=0.) + self.cad_last_gate_offset = mmu.config.getfloat('cad_last_gate_offset', self.cad_last_gate_offset, above=0.) + self.cad_block_width = mmu.config.getfloat('cad_block_width', self.cad_block_width, above=0.) # ERCF v1.1 only + self.cad_bypass_block_width = mmu.config.getfloat('cad_bypass_block_width', self.cad_bypass_block_width, above=0.) # ERCF v1.1 only + self.cad_bypass_block_delta = mmu.config.getfloat('cad_bypass_block_delta', self.cad_bypass_block_delta, above=0.) # ERCF v1.1 only + self.cad_selector_tolerance = mmu.config.getfloat('cad_selector_tolerance', self.cad_selector_tolerance, above=0.) # Extra movement allowed by selector + + # Sub components (optional servo) + if isinstance(self, LinearServoSelector): + self.servo = LinearSelectorServo(mmu) if isinstance(self, LinearServoSelector) else None + else: + self.servo = None + # Read all controller parameters related to to stop klipper complaining. This is done to allow + # for uniform and shared mmu_parameters.cfg file regardless of configuration. + for option in ['servo_']: + for key in mmu.config.get_prefix_options(option): + _ = mmu.config.get(key) + + # Register GCODE commands specific to this module + gcode = mmu.printer.lookup_object('gcode') + gcode.register_command('MMU_CALIBRATE_SELECTOR', self.cmd_MMU_CALIBRATE_SELECTOR, desc = self.cmd_MMU_CALIBRATE_SELECTOR_help) + gcode.register_command('MMU_SOAKTEST_SELECTOR', self.cmd_MMU_SOAKTEST_SELECTOR, desc = self.cmd_MMU_SOAKTEST_SELECTOR_help) + + # Selector stepper setup before MMU toolhead is instantiated + section = mmu_machine.SELECTOR_STEPPER_CONFIG + if mmu.config.has_section(section): + # Inject options into selector stepper config regardless or what user sets + mmu.config.fileconfig.set(section, 'position_min', -1.) + mmu.config.fileconfig.set(section, 'position_max', self._get_max_selector_movement()) + mmu.config.fileconfig.set(section, 'homing_speed', self.selector_homing_speed) + + # Selector "Interface" methods --------------------------------------------- + + def reinit(self): + # Sub components + if self.servo: + self.servo.reinit() + + def handle_connect(self): + self.mmu_toolhead = self.mmu.mmu_toolhead + self.selector_rail = self.mmu_toolhead.get_kinematics().rails[0] + self.selector_stepper = self.selector_rail.steppers[0] + + # Load selector offsets (calibration set with MMU_CALIBRATE_SELECTOR) ------------------------------- + self.selector_offsets = self.mmu.save_variables.allVariables.get(self.VARS_MMU_SELECTOR_OFFSETS, None) + if self.selector_offsets: + # Ensure list size + if len(self.selector_offsets) == self.mmu.num_gates: + self.mmu.log_debug("Loaded saved selector offsets: %s" % self.selector_offsets) + else: + self.mmu.log_error("Incorrect number of gates specified in %s. Adjusted length" % self.VARS_MMU_SELECTOR_OFFSETS) + self.selector_offsets = self._ensure_list_size(self.selector_offsets, self.mmu.num_gates) + + if not any(x == -1 for x in self.selector_offsets): + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR + else: + self.mmu.log_always("Warning: Selector offsets not found in mmu_vars.cfg. Probably not calibrated") + self.selector_offsets = [-1] * self.mmu.num_gates + self.mmu.save_variables.allVariables[self.VARS_MMU_SELECTOR_OFFSETS] = self.selector_offsets + + self.bypass_offset = self.mmu.save_variables.allVariables.get(self.VARS_MMU_SELECTOR_BYPASS, -1) + if self.bypass_offset > 0: + self.mmu.log_debug("Loaded saved bypass offset: %s" % self.bypass_offset) + else: + self.bypass_offset = -1 # Ensure -1 value for uncalibrated / non-existent + self.mmu.save_variables.allVariables[self.VARS_MMU_SELECTOR_BYPASS] = self.bypass_offset + + # See if we have a TMC controller setup with stallguard + self.selector_tmc = None + for chip in mmu_machine.TMC_CHIPS: + if self.selector_tmc is None: + self.selector_tmc = self.mmu.printer.lookup_object('%s %s' % (chip, mmu_machine.SELECTOR_STEPPER_CONFIG), None) + if self.selector_tmc is not None: + self.mmu.log_debug("Found %s on selector_stepper. Stallguard 'touch' movement and recovery possible." % chip) + if self.selector_tmc is None: + self.mmu.log_debug("TMC driver not found for selector_stepper, cannot use 'touch' movement and recovery") + + # Sub components + if self.servo: + self.servo.handle_connect() + + def _ensure_list_size(self, lst, size, default_value=-1): + lst = lst[:size] + lst.extend([default_value] * (size - len(lst))) + return lst + + def handle_disconnect(self): + # Sub components + if self.servo: + self.servo.handle_disconnect() + + def handle_ready(self): + # Sub components + if self.servo: + self.servo.handle_ready() + + def home(self, force_unload = None): + if self.mmu.check_if_bypass(): return + with self.mmu.wrap_action(self.mmu.ACTION_HOMING): + self.mmu.log_info("Homing MMU...") + if force_unload is not None: + self.mmu.log_debug("(asked to %s)" % ("force unload" if force_unload else "not unload")) + if force_unload is True: + # Forced unload case for recovery + self.mmu.unload_sequence(check_state=True) + elif force_unload is None and self.mmu.filament_pos != self.mmu.FILAMENT_POS_UNLOADED: + # Automatic unload case + self.mmu.unload_sequence() + self._home_selector() + + # Physically move selector to correct gate position + def select_gate(self, gate): + if gate == self.mmu.gate_selected: return + with self.mmu.wrap_action(self.mmu.ACTION_SELECTING): + self.filament_hold_move() + if gate == self.mmu.TOOL_GATE_BYPASS: + self._position(self.bypass_offset) + elif gate >= 0: + self._position(self.selector_offsets[gate]) + + # Correct rail position for selector + def restore_gate(self, gate): + if gate == self.mmu.TOOL_GATE_BYPASS: + self.set_position(self.bypass_offset) + elif gate >= 0: + self.set_position(self.selector_offsets[gate]) + + def filament_drive(self, buzz_gear=True): + if self.servo: + self.servo.servo_down(buzz_gear=buzz_gear) + + def filament_release(self, measure=False): + if self.servo: + return self.servo.servo_up(measure=measure) + + def filament_hold_move(self): # AKA position for holding filament and moving selector + if self.servo: + self.servo.servo_move() + + def get_filament_grip_state(self): + if self.servo: + return self.servo.get_filament_grip_state() + + def disable_motors(self): + stepper_enable = self.mmu.printer.lookup_object('stepper_enable') + se = stepper_enable.lookup_enable(self.selector_stepper.get_name()) + se.motor_disable(self.mmu_toolhead.get_last_move_time()) + self.is_homed = False + if self.servo: + self.servo.disable_motors() + + def enable_motors(self): + stepper_enable = self.mmu.printer.lookup_object('stepper_enable') + se = stepper_enable.lookup_enable(self.selector_stepper.get_name()) + se.motor_enable(self.mmu_toolhead.get_last_move_time()) + + def buzz_motor(self, motor): + if motor == "selector": + pos = self.mmu_toolhead.get_position()[0] + self.move(None, pos + 5, wait=False) + self.move(None, pos - 5, wait=False) + self.move(None, pos, wait=False) + elif motor == "servo" and self.servo: + self.servo.buzz_motor() + else: + return False + return True + + def has_bypass(self): + return self.mmu.mmu_machine.has_bypass and self.bypass_offset >= 0 + + def get_status(self, eventtime): + status = super(LinearSelector, self).get_status(eventtime) + if self.servo: + status.update(self.servo.get_status(eventtime)) + return status + + def get_mmu_status_config(self): + msg = "\nSelector is NOT HOMED" if not self.is_homed else "" + if self.servo: + msg += self.servo.get_mmu_status_config() + return msg + + def set_test_config(self, gcmd): + self.selector_move_speed = gcmd.get_float('SELECTOR_MOVE_SPEED', self.selector_move_speed, minval=1.) + self.selector_homing_speed = gcmd.get_float('SELECTOR_HOMING_SPEED', self.selector_homing_speed, minval=1.) + self.selector_touch_speed = gcmd.get_float('SELECTOR_TOUCH_SPEED', self.selector_touch_speed, minval=1.) + self.selector_touch_enabled = gcmd.get_int('SELECTOR_TOUCH_ENABLED', self.selector_touch_enabled, minval=0, maxval=1) + + # Sub components + if self.servo: + self.servo.set_test_config(gcmd) + + def get_test_config(self): + msg = "\n\nSELECTOR:" + msg += "\nselector_move_speed = %.1f" % self.selector_move_speed + msg += "\nselector_homing_speed = %.1f" % self.selector_homing_speed + msg += "\nselector_touch_speed = %.1f" % self.selector_touch_speed + msg += "\nselector_touch_enabled = %d" % self.selector_touch_enabled + + # Sub components + if self.servo: + msg += self.servo.get_test_config() + + return msg + + def check_test_config(self, param): + return (vars(self).get(param) is None) and (self.servo is None or self.servo.check_test_config(param)) + + def get_uncalibrated_gates(self, check_gates): + return [gate for gate, value in enumerate(self.selector_offsets) if value == -1 and gate in check_gates] + + # Internal Implementation -------------------------------------------------- + + cmd_MMU_CALIBRATE_SELECTOR_help = "Calibration of the selector positions or postion of specified gate" + def cmd_MMU_CALIBRATE_SELECTOR(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + single = gcmd.get_int('SINGLE', 0, minval=0, maxval=1) + gate = gcmd.get_int('GATE', -1, minval=0, maxval=self.mmu.mmu_machine.num_gates - 1) + if gate == -1 and gcmd.get_int('BYPASS', -1, minval=0, maxval=1) == 1: + gate = self.mmu.TOOL_GATE_BYPASS + + try: + with self.mmu.wrap_sync_gear_to_extruder(): + self.mmu.calibrating = True + self.mmu.reinit() + self.filament_hold_move() + successful = False + if gate != -1: + successful = self._calibrate_selector(gate, extrapolate=not single, save=save) + else: + successful = self._calibrate_selector_auto(save=save, v1_bypass_block=gcmd.get_int('BYPASS_BLOCK', -1, minval=1, maxval=3)) + + if not any(x == -1 for x in self.selector_offsets): + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR + + # If not fully calibrated turn off the selector stepper to ease next step, else activate by homing + if successful and self.mmu.calibration_status & self.mmu.CALIBRATED_SELECTOR: + self.mmu.log_always("Selector calibration complete") + self.mmu.select_tool(0) + else: + self.mmu.motors_onoff(on=False, motor="selector") + + except MmuError as ee: + self.mmu.handle_mmu_error(str(ee)) + finally: + self.mmu.calibrating = False + + cmd_MMU_SOAKTEST_SELECTOR_help = "Soak test of selector movement" + def cmd_MMU_SOAKTEST_SELECTOR(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + if self.mmu.check_if_loaded(): return + if self.mmu.check_if_not_calibrated(self.mmu.CALIBRATED_SELECTOR): return + loops = gcmd.get_int('LOOP', 100) + servo = bool(gcmd.get_int('SERVO', 0)) + home = bool(gcmd.get_int('HOME', 0)) + try: + with self.mmu.wrap_sync_gear_to_extruder(): + if home: + self.home() + for l in range(loops): + self.mmu.log_always("Testing loop %d / %d" % (l + 1, loops)) + tool = random.randint(0, self.mmu.num_gates) + if tool == self.mmu.num_gates: + self.mmu.select_bypass() + else: + if random.randint(0, 10) == 0 and home: + self.mmu.home(tool=tool) + else: + self.mmu.select_tool(tool) + if servo: + self.filament_drive() + except MmuError as ee: + self.mmu.handle_mmu_error("Soaktest abandoned because of error: %s" % str(ee)) + + def _get_max_selector_movement(self, gate=-1): + n = gate if gate >= 0 else self.mmu.num_gates - 1 + + if self.mmu.mmu_machine.mmu_vendor == mmu_machine.VENDOR_ERCF: + # ERCF Designs + if self.mmu.mmu_machine.mmu_version >= 2.0 or "t" in self.mmu.mmu_machine.mmu_version_string: + max_movement = self.cad_gate0_pos + (n * self.cad_gate_width) + else: + max_movement = self.cad_gate0_pos + (n * self.cad_gate_width) + (n//3) * self.cad_block_width + else: + # Everything else + max_movement = self.cad_gate0_pos + (n * self.cad_gate_width) + + max_movement += self.cad_last_gate_offset if gate in [self.mmu.TOOL_GATE_UNKNOWN] else 0. + max_movement += self.cad_selector_tolerance + return max_movement + + # Manual selector offset calibration + def _calibrate_selector(self, gate, extrapolate=True, save=True): + gate_str = lambda gate : ("gate %d" % gate) if gate >= 0 else "bypass" + + max_movement = self._get_max_selector_movement(gate) + self.mmu.log_always("Measuring the selector position for %s..." % gate_str(gate)) + traveled, found_home = self.measure_to_home() + + # Test we actually homed + if not found_home: + self.mmu.log_error("Selector didn't find home position") + return False + + # Warn and don't save if the measurement is unexpected + if traveled > max_movement: + self.mmu.log_always("Selector move measured %.1fmm. More than the anticipated maximum of %.1fmm. Save disabled\nIt is likely that your basic MMU dimensions are incorrect in mmu_parameters.cfg. Check vendor/version and optional 'cad_*' parameters" % (traveled, max_movement)) + save = 0 + else: + self.mmu.log_always("Selector move measured %.1fmm" % traveled) + + if save: + if gate >= 0: + self.selector_offsets[gate] = round(traveled, 1) + if ( + extrapolate and gate == self.mmu.num_gates - 1 and self.selector_offsets[0] > 0 or + extrapolate and gate == 0 and self.selector_offsets[-1] > 0 + ): + # Distribute selector spacing + spacing = (self.selector_offsets[-1] - self.selector_offsets[0]) / (self.mmu.num_gates - 1) + self.selector_offsets = [round(self.selector_offsets[0] + i * spacing, 1) for i in range(self.mmu.num_gates)] + else: + extrapolate = False + self.mmu.save_variable(self.VARS_MMU_SELECTOR_OFFSETS, self.selector_offsets, write=True) + else: + self.bypass_offset = round(traveled, 1) + extrapolate = False + self.mmu.save_variable(self.VARS_MMU_SELECTOR_BYPASS, self.bypass_offset, write=True) + + if extrapolate: + self.mmu.log_always("All selector offsets have been extrapolated and saved:\n%s" % self.selector_offsets) + else: + self.mmu.log_always("Selector offset (%.1fmm) for %s has been saved" % (traveled, gate_str(gate))) + if gate == 0: + self.mmu.log_always("Run MMU_CALIBRATE_SELECTOR again with GATE=%d to extrapolate all gate positions. Use SINGLE=1 to force calibration of only one gate" % (self.mmu.num_gates - 1)) + return True + + # Fully automated selector offset calibration + # Strategy is to find the two end gates, infer and set number of gates and distribute selector positions + # Assumption: the user has manually positioned the selector aligned with gate 0 before calling. Doesn't work + # with as well with open ended designs like Tradrack. Use "manual" calibration routine above for that + def _calibrate_selector_auto(self, save=True, v1_bypass_block=-1): + self.mmu.log_always("Auto calibrating the selector. Excuse the whizz, bang, buzz, clicks...") + + # Step 1 - position of gate 0 + self.mmu.log_always("Measuring the selector position for gate 0...") + traveled, found_home = self.measure_to_home() + if not found_home or traveled > self.cad_gate0_pos + self.cad_selector_tolerance: + self.mmu.log_error("Selector didn't find home position or distance moved (%.1fmm) was larger than expected.\nAre you sure you aligned selector with gate 0 and removed filament?" % traveled) + return False + gate0_pos = traveled + + # Step 2 - end of selector + max_movement = self._get_max_selector_movement() + self.mmu.log_always("Searching for end of selector... (up to %.1fmm)" % max_movement) + if self.use_touch_move(): + _,found_home = self.homing_move("Detecting end of selector movement", max_movement, homing_move=1, endstop_name=self.mmu.SENSOR_SELECTOR_TOUCH) + else: + # This might not sound good! + self.move("Ensure we are clear off the physical endstop", self.cad_gate0_pos) + self.move("Forceably detecting end of selector movement", max_movement, speed=self.selector_homing_speed) + found_home = True + if not found_home: + msg = "Didn't detect the end of the selector" + if self.cad_last_gate_offset > 0: + self.mmu.log_error(msg) + return False + else: + self.mmu.log_always(msg) + + # Step 3a - selector length + self.mmu.log_always("Measuring the full selector length...") + traveled, found_home = self.measure_to_home() + if not found_home: + self.mmu.log_error("Selector didn't find home position after full length move") + return False + self.mmu.log_always("Maximum selector movement is %.1fmm" % traveled) + + # Step 3b - bypass and last gate position (measured back from limit of travel) + if self.cad_bypass_offset > 0: + bypass_pos = traveled - self.cad_bypass_offset + else: + bypass_pos = -1 + if self.cad_last_gate_offset > 0: + # This allows the error to be averaged + last_gate_pos = traveled - self.cad_last_gate_offset + else: + # This simply assumes theoretical distance + last_gate_pos = gate0_pos + (self.mmu.num_gates - 1) * self.cad_gate_width + + # Step 4 - the calcs + length = last_gate_pos - gate0_pos + self.mmu.log_debug("Results: gate0_pos=%.1f, last_gate_pos=%.1f, length=%.1f" % (gate0_pos, last_gate_pos, length)) + selector_offsets = [] + + if self.mmu.mmu_machine.mmu_vendor.lower() == mmu_machine.VENDOR_ERCF.lower() and self.mmu.mmu_machine.mmu_version == 1.1: + # ERCF v1.1 special case + num_gates = adj_gate_width = int(round(length / (self.cad_gate_width + self.cad_block_width / 3))) + 1 + num_blocks = (num_gates - 1) // 3 + bypass_offset = -1 + if num_gates > 1: + if v1_bypass_block >= 0: + adj_gate_width = (length - (num_blocks - 1) * self.cad_block_width - self.cad_bypass_block_width) / (num_gates - 1) + else: + adj_gate_width = (length - num_blocks * self.cad_block_width) / (num_gates - 1) + self.mmu.log_debug("Adjusted gate width: %.1f" % adj_gate_width) + for i in range(num_gates): + bypass_adj = (self.cad_bypass_block_width - self.cad_block_width) if (i // 3) >= v1_bypass_block else 0. + selector_offsets.append(round(gate0_pos + (i * adj_gate_width) + (i // 3) * self.cad_block_width + bypass_adj, 1)) + if ((i + 1) / 3) == v1_bypass_block: + bypass_offset = selector_offsets[i] + self.cad_bypass_block_delta + + else: + # Generic Type-A MMU case + num_gates = int(round(length / self.cad_gate_width)) + 1 + adj_gate_width = length / (num_gates - 1) if num_gates > 1 else length + self.mmu.log_debug("Adjusted gate width: %.1f" % adj_gate_width) + for i in range(num_gates): + selector_offsets.append(round(gate0_pos + (i * adj_gate_width), 1)) + bypass_offset = bypass_pos + + if num_gates != self.mmu.num_gates: + self.mmu.log_error("You configued your MMU for %d gates but I counted %d! Please update 'num_gates'" % (self.mmu.num_gates, num_gates)) + return False + + self.mmu.log_always("Offsets: %s%s" % (selector_offsets, (" (bypass: %.1f)" % bypass_offset) if bypass_offset > 0 else " (no bypass fitted)")) + if save: + self.selector_offsets = selector_offsets + self.bypass_offset = bypass_offset + self.mmu.save_variable(self.VARS_MMU_SELECTOR_OFFSETS, self.selector_offsets) + self.mmu.save_variable(self.VARS_MMU_SELECTOR_BYPASS, self.bypass_offset) + self.mmu.write_variables() + self.mmu.log_always("Selector calibration has been saved") + return True + + def _home_selector(self): + self.mmu.unselect_gate() + self.filament_hold_move() + self.mmu.movequeues_wait() + try: + homing_state = mmu_machine.MmuHoming(self.mmu.printer, self.mmu_toolhead) + homing_state.set_axes([0]) + self.mmu.mmu_toolhead.get_kinematics().home(homing_state) + self.is_homed = True + except Exception as e: # Homing failed + raise MmuError("Homing selector failed because of blockage or malfunction. Klipper reports: %s" % str(e)) + + def _position(self, target): + if not self.use_touch_move(): + self.move("Positioning selector", target) + else: + init_pos = self.mmu_toolhead.get_position()[0] + halt_pos,homed = self.homing_move("Positioning selector with 'touch' move", target, homing_move=1, endstop_name=self.mmu.SENSOR_SELECTOR_TOUCH) + if homed: # Positioning move was not successful + with self.mmu.wrap_suppress_visual_log(): + travel = abs(init_pos - halt_pos) + if travel < 4.0: # Filament stuck in the current gate (based on ERCF design) + self.mmu.log_info("Selector is blocked by filament inside gate, will try to recover...") + self.move("Realigning selector by a distance of: %.1fmm" % -travel, init_pos) + self.mmu_toolhead.flush_step_generation() # TTC mitigation when homing move + regular + get_last_move_time() in close succession + + # See if we can detect filament in gate area + found = self.mmu.check_filament_in_gate() + if not found: + # Push filament into view of the gate endstop + self.filament_drive() + _,_,measured,_ = self.mmu.trace_filament_move("Locating filament", self.mmu.gate_parking_distance + self.mmu.gate_endstop_to_encoder + 10.) + if self.mmu.has_encoder() and measured < self.mmu.encoder_min: + raise MmuError("Unblocking selector failed bacause unable to move filament to clear") + + # Try a full unload sequence + try: + self.mmu.unload_sequence(check_state=True) + except MmuError as ee: + raise MmuError("Unblocking selector failed because: %s" % (str(ee))) + + # Check if selector can now reach proper target + self._home_selector() + halt_pos,homed = self.homing_move("Positioning selector with 'touch' move", target, homing_move=1, endstop_name=self.mmu.SENSOR_SELECTOR_TOUCH) + if homed: # Positioning move was not successful + self.is_homed = False + raise MmuError("Unblocking selector recovery failed. Path is probably internally blocked") + + else: # Selector path is blocked, probably externally + self.is_homed = False + raise MmuError("Selector is externally blocked perhaps by filament in another gate") + + def move(self, trace_str, new_pos, speed=None, accel=None, wait=False): + return self._trace_selector_move(trace_str, new_pos, speed=speed, accel=accel, wait=wait)[0] + + def homing_move(self, trace_str, new_pos, speed=None, accel=None, homing_move=0, endstop_name=None): + return self._trace_selector_move(trace_str, new_pos, speed=speed, accel=accel, homing_move=homing_move, endstop_name=endstop_name) + + # Internal raw wrapper around all selector moves except rail homing + # Returns position after move, if homed (homing moves) + def _trace_selector_move(self, trace_str, new_pos, speed=None, accel=None, homing_move=0, endstop_name=None, wait=False): + if trace_str: + self.mmu.log_trace(trace_str) + + self.mmu_toolhead.quiesce() + + # Set appropriate speeds and accel if not supplied + if homing_move != 0: + speed = speed or (self.selector_touch_speed if self.selector_touch_enabled or endstop_name == self.mmu.SENSOR_SELECTOR_TOUCH else self.selector_homing_speed) + else: + speed = speed or self.selector_move_speed + accel = accel or self.mmu_toolhead.get_selector_limits()[1] + + pos = self.mmu_toolhead.get_position() + homed = False + if homing_move != 0: + # Check for valid endstop + endstops = self.selector_rail.get_endstops() if endstop_name is None else self.selector_rail.get_extra_endstop(endstop_name) + if endstops is None: + self.mmu.log_error("Endstop '%s' not found" % endstop_name) + return pos[0], homed + + hmove = HomingMove(self.mmu.printer, endstops, self.mmu_toolhead) + try: + trig_pos = [0., 0., 0., 0.] + with self.mmu.wrap_accel(accel): + pos[0] = new_pos + trig_pos = hmove.homing_move(pos, speed, probe_pos=True, triggered=homing_move > 0, check_triggered=True) + if hmove.check_no_movement(): + self.mmu.log_stepper("No movement detected") + if self.selector_rail.is_endstop_virtual(endstop_name): + # Try to infer move completion is using Stallguard. Note that too slow speed or accelaration + delta = abs(new_pos - trig_pos[0]) + if delta < 1.0: + homed = False + self.mmu.log_trace("Truing selector %.4fmm to %.2fmm" % (delta, new_pos)) + self.mmu_toolhead.move(pos, speed) + else: + homed = True + else: + homed = True + except self.mmu.printer.command_error: + homed = False + finally: + self.mmu_toolhead.flush_step_generation() # TTC mitigation when homing move + regular + get_last_move_time() in close succession + pos = self.mmu_toolhead.get_position() + if self.mmu.log_enabled(self.mmu.LOG_STEPPER): + self.mmu.log_stepper("SELECTOR HOMING MOVE: requested position=%.1f, speed=%.1f, accel=%.1f, endstop_name=%s >> %s" % (new_pos, speed, accel, endstop_name, "%s actual pos=%.2f, trig_pos=%.2f" % ("HOMED" if homed else "DID NOT HOMED", pos[0], trig_pos[0]))) + else: + pos = self.mmu_toolhead.get_position() + with self.mmu.wrap_accel(accel): + pos[0] = new_pos + self.mmu_toolhead.move(pos, speed) + if self.mmu.log_enabled(self.mmu.LOG_STEPPER): + self.mmu.log_stepper("SELECTOR MOVE: position=%.1f, speed=%.1f, accel=%.1f" % (new_pos, speed, accel)) + if wait: + self.mmu.movequeues_wait(toolhead=False, mmu_toolhead=True) + + return pos[0], homed + + def set_position(self, position): + pos = self.mmu_toolhead.get_position() + pos[0] = position + self.mmu_toolhead.set_position(pos, homing_axes=(0,)) + self.enable_motors() + self.is_homed = True + return position + + def measure_to_home(self): + self.mmu.movequeues_wait() + init_mcu_pos = self.selector_stepper.get_mcu_position() + homed = False + try: + homing_state = mmu_machine.MmuHoming(self.mmu.printer, self.mmu_toolhead) + homing_state.set_axes([0]) + self.mmu_toolhead.get_kinematics().home(homing_state) + homed = True + except Exception: + pass # Home not found + mcu_position = self.selector_stepper.get_mcu_position() + traveled = abs(mcu_position - init_mcu_pos) * self.selector_stepper.get_step_dist() + return traveled, homed + + def use_touch_move(self): + return self.selector_tmc and self.mmu.SENSOR_SELECTOR_TOUCH in self.selector_rail.get_extra_endstop_names() and self.selector_touch_enabled + + + +################################################################################ +# LinearSelectorServo +# Implements servo control for typical linear selector +################################################################################ + +class LinearSelectorServo: + + # mmu_vars.cfg variables + VARS_MMU_SERVO_ANGLES = "mmu_servo_angles" + + def __init__(self, mmu): + self.mmu = mmu + + # Servo states + self.SERVO_MOVE_STATE = mmu.FILAMENT_HOLD_STATE + self.SERVO_DOWN_STATE = mmu.FILAMENT_DRIVE_STATE + self.SERVO_UP_STATE = mmu.FILAMENT_RELEASE_STATE + self.SERVO_UNKNOWN_STATE = mmu.FILAMENT_UNKNOWN_STATE + + # Process config + self.servo_angles = {} + self.servo_angles['down'] = mmu.config.getint('servo_down_angle', 90) + self.servo_angles['up'] = mmu.config.getint('servo_up_angle', 90) + self.servo_angles['move'] = mmu.config.getint('servo_move_angle', self.servo_angles['up']) + self.servo_duration = mmu.config.getfloat('servo_duration', 0.2, minval=0.1) + self.servo_always_active = mmu.config.getint('servo_always_active', 0, minval=0, maxval=1) + self.servo_active_down = mmu.config.getint('servo_active_down', 0, minval=0, maxval=1) + self.servo_dwell = mmu.config.getfloat('servo_dwell', 0.4, minval=0.1) + self.servo_buzz_gear_on_down = mmu.config.getint('servo_buzz_gear_on_down', 3, minval=0, maxval=10) + + self.servo = mmu.printer.lookup_object('mmu_servo selector_servo', None) + if not self.servo: + raise mmu.config.error("No [mmu_servo selector_servo] definition found in mmu_hardware.cfg") + + # Register GCODE commands specific to this module + gcode = self.mmu.printer.lookup_object('gcode') + gcode.register_command('MMU_SERVO', self.cmd_MMU_SERVO, desc = self.cmd_MMU_SERVO_help) + + self.reinit() + + def reinit(self): + self.servo_state = self.SERVO_UNKNOWN_STATE + self.servo_angle = self.SERVO_UNKNOWN_STATE + + def handle_connect(self): + self.mmu_toolhead = self.mmu.mmu_toolhead + + # Override with saved/calibrated servo positions (set with MMU_SERVO) + try: + servo_angles = self.mmu.save_variables.allVariables.get(self.VARS_MMU_SERVO_ANGLES, {}) + self.servo_angles.update(servo_angles) + except Exception as e: + raise self.mmu.config.error("Exception whilst parsing servo angles from 'mmu_vars.cfg': %s" % str(e)) + + def handle_disconnect(self): + pass + + def handle_ready(self): + pass + + cmd_MMU_SERVO_help = "Move MMU servo to position specified position or angle" + def cmd_MMU_SERVO(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + reset = gcmd.get_int('RESET', 0) + save = gcmd.get_int('SAVE', 0) + pos = gcmd.get('POS', "").lower() + if reset: + self.mmu.delete_variable(self.VARS_MMU_SERVO_ANGLES, write=True) + self.mmu.log_info("Calibrated servo angles have be reset to configured defaults") + elif pos == "off": + self.servo_off() # For 'servo_always_active' case + elif pos == "up": + if save: + self._servo_save_pos(pos) + else: + self.servo_up() + elif pos == "move": + if save: + self._servo_save_pos(pos) + else: + self.servo_move() + elif pos == "down": + if self.mmu.check_if_bypass(): return + if save: + self._servo_save_pos(pos) + else: + self.servo_down() + elif save: + self.mmu.log_error("Servo position not specified for save") + elif pos == "": + if self.mmu.check_if_bypass(): return + angle = gcmd.get_int('ANGLE', None) + if angle is not None: + self.mmu.log_debug("Setting servo to angle: %d" % angle) + self._set_servo_angle(angle) + else: + self.mmu.log_always("Current servo angle: %d, Positions: %s" % (self.servo_angle, self.servo_angles)) + self.mmu.log_info("Use POS= or ANGLE= to move position") + else: + self.mmu.log_error("Unknown servo position '%s'" % pos) + + def _set_servo_angle(self, angle): + self.servo.set_position(angle=angle, duration=None if self.servo_always_active else self.servo_duration) + self.servo_angle = angle + self.servo_state = self.SERVO_UNKNOWN_STATE + + def _servo_save_pos(self, pos): + if self.servo_angle != self.SERVO_UNKNOWN_STATE: + self.servo_angles[pos] = self.servo_angle + self.mmu.save_variable(self.VARS_MMU_SERVO_ANGLES, self.servo_angles, write=True) + self.mmu.log_info("Servo angle '%d' for position '%s' has been saved" % (self.servo_angle, pos)) + else: + self.mmu.log_info("Servo angle unknown") + + def servo_down(self, buzz_gear=True): + if self.mmu._is_running_test: return # Save servo while testing + if self.mmu.gate_selected == self.mmu.TOOL_GATE_BYPASS: return + if self.servo_state == self.SERVO_DOWN_STATE: return + self.mmu.log_trace("Setting servo to down (filament drive) position at angle: %d" % self.servo_angles['down']) + + if buzz_gear and self.servo_buzz_gear_on_down > 0: + self.mmu_toolhead.sync(MmuToolHead.GEAR_ONLY) # Must be in correct sync mode before buzz to avoid delay + + self.mmu.movequeues_wait() # Probably not necessary + initial_encoder_position = self.mmu.get_encoder_distance(dwell=None) + self.servo.set_position(angle=self.servo_angles['down'], duration=None if self.servo_active_down or self.servo_always_active else self.servo_duration) + + if self.servo_angle != self.servo_angles['down'] and buzz_gear and self.servo_buzz_gear_on_down > 0: + for _ in range(self.servo_buzz_gear_on_down): + self.mmu.trace_filament_move(None, 0.8, speed=25, accel=self.mmu.gear_buzz_accel, encoder_dwell=None, speed_override=False) + self.mmu.trace_filament_move(None, -0.8, speed=25, accel=self.mmu.gear_buzz_accel, encoder_dwell=None, speed_override=False) + self.mmu.movequeues_dwell(max(self.servo_dwell, self.servo_duration, 0)) + + self.servo_angle = self.servo_angles['down'] + self.servo_state = self.SERVO_DOWN_STATE + self.mmu.set_encoder_distance(initial_encoder_position) + self.mmu.mmu_macro_event(self.mmu.MACRO_EVENT_FILAMENT_GRIPPED) + + def servo_move(self): # Position servo for selector movement + if self.mmu._is_running_test: return # Save servo while testing + if self.servo_state == self.SERVO_MOVE_STATE: return + self.mmu.log_trace("Setting servo to move (filament hold) position at angle: %d" % self.servo_angles['move']) + if self.servo_angle != self.servo_angles['move']: + self.mmu.movequeues_wait() + self.servo.set_position(angle=self.servo_angles['move'], duration=None if self.servo_always_active else self.servo_duration) + self.mmu.movequeues_dwell(max(self.servo_dwell, self.servo_duration, 0)) + self.servo_angle = self.servo_angles['move'] + self.servo_state = self.SERVO_MOVE_STATE + + def servo_up(self, measure=False): + if self.mmu._is_running_test: return 0. # Save servo while testing + if self.servo_state == self.SERVO_UP_STATE: return 0. + self.mmu.log_trace("Setting servo to up (filament released) position at angle: %d" % self.servo_angles['up']) + delta = 0. + if self.servo_angle != self.servo_angles['up']: + self.mmu.movequeues_wait() + if measure: + initial_encoder_position = self.mmu.get_encoder_distance(dwell=None) + self.servo.set_position(angle=self.servo_angles['up'], duration=None if self.servo_always_active else self.servo_duration) + self.mmu.movequeues_dwell(max(self.servo_dwell, self.servo_duration, 0)) + if measure: + # Report on spring back in filament then revert counter + delta = self.mmu.get_encoder_distance() - initial_encoder_position + if delta > 0.: + self.mmu.log_debug("Spring in filament measured %.1fmm - adjusting encoder" % delta) + self.mmu.set_encoder_distance(initial_encoder_position, dwell=None) + self.servo_angle = self.servo_angles['up'] + self.servo_state = self.SERVO_UP_STATE + return delta + + def _servo_auto(self): + if self.mmu.is_printing() and self.mmu_toolhead.is_gear_synced_to_extruder(): + self.servo_down() + elif not self.mmu.selector.is_homed or self.mmu.tool_selected < 0 or self.mmu.gate_selected < 0: + self.servo_move() + else: + self.servo_up() + + # De-energize servo if 'servo_always_active' or 'servo_active_down' are being used + def servo_off(self): + self.servo.set_position(width=0, duration=None) + + def get_filament_grip_state(self): + return self.servo_state + + def disable_motors(self): + self.servo_move() + self.servo_off() + self.reinit() # Reset state + + def enable_motors(self): + self.servo_move() + + def buzz_motor(self): + self.mmu.movequeues_wait() + old_state = self.servo_state + low=min(self.servo_angles['down'], self.servo_angles['up']) + high=max(self.servo_angles['down'], self.servo_angles['up']) + mid = (low + high) / 2 + move = (high - low) / 4 + duration=None if self.servo_always_active else self.servo_duration + self.servo.set_position(angle=mid, duration=duration) + self.mmu.movequeues_dwell(max(self.servo_duration, 0.5), mmu_toolhead=False) + self.servo.set_position(angle=(mid - move), duration=duration) + self.mmu.movequeues_dwell(max(self.servo_duration, 0.5), mmu_toolhead=False) + self.servo.set_position(angle=(mid + move), duration=duration) + self.mmu.movequeues_dwell(max(self.servo_duration, 0.5), mmu_toolhead=False) + self.mmu.movequeues_wait() + if old_state == self.SERVO_DOWN_STATE: + self.servo_down(buzz_gear=False) + elif old_state == self.SERVO_MOVE_STATE: + self.servo_move() + else: + self.servo_up() + + def set_test_config(self, gcmd): + self.servo_duration = gcmd.get_float('SERVO_DURATION', self.servo_duration, minval=0.1) + self.servo_always_active = gcmd.get_int('SERVO_ALWAYS_ACTIVE', self.servo_always_active, minval=0, maxval=1) + self.servo_active_down = gcmd.get_int('SERVO_ACTIVE_DOWN', self.servo_active_down, minval=0, maxval=1) + self.servo_dwell = gcmd.get_float('SERVO_DWELL', self.servo_active_down, minval=0.1) + self.servo_buzz_gear_on_down = gcmd.get_int('SERVO_BUZZ_GEAR_ON_DOWN', self.servo_buzz_gear_on_down, minval=0, maxval=10) + + def get_test_config(self): + msg = "\n\nSERVO:" + msg += "\nservo_duration = %.1f" % self.servo_duration + msg += "\nservo_always_active = %d" % self.servo_always_active + msg += "\nservo_active_down = %d" % self.servo_active_down + msg += "\nservo_dwell = %.1f" % self.servo_dwell + msg += "\nservo_buzz_gear_on_down = %d" % self.servo_buzz_gear_on_down + + return msg + + def check_test_config(self, param): + return vars(self).get(param) is None + + def get_mmu_status_config(self): + msg = ". Servo in %s position" % ("UP" if self.servo_state == self.SERVO_UP_STATE else \ + "DOWN" if self.servo_state == self.SERVO_DOWN_STATE else "MOVE" if self.servo_state == self.SERVO_MOVE_STATE else "unknown") + return msg + + def get_status(self, eventtime): + return { + 'servo': "Up" if self.servo_state == self.SERVO_UP_STATE else + "Down" if self.servo_state == self.SERVO_DOWN_STATE else + "Move" if self.servo_state == self.SERVO_MOVE_STATE else + "Unknown", + } + + + +################################################################################ +# LinearServoSelector: +# Implements Linear Selector for type-A MMU's with servo +# - Stepper controlled linear movement with endstop +# - Servo controlled filament gripping +# - Supports type-A with combined selection and filament gripping line ERCFv3 +# +class LinearServoSelector(LinearSelector, object): + + def __init__(self, mmu): + super(LinearServoSelector, self).__init__(mmu) + + + +################################################################################ +# LinearMultiGearSelector: +# Implements Linear Selector for type-C MMU's that: +# - Uses gear driver stepper gate +# - Uses selector stepper for gate selection with endstop +# - Supports type-A classic MMU's like ERCF and Tradrack +# +################################################################################ + +class LinearMultiGearSelector(LinearSelector, object): + + def __init__(self, mmu): + super(LinearMultiGearSelector, self).__init__(mmu) + + # Selector "Interface" methods --------------------------------------------- + + def select_gate(self, gate): + self.mmu_toolhead.select_gear_stepper(gate) # Select correct gear drive stepper or none if bypass + super(LinearMultiGearSelector, self).select_gate(gate) + + def restore_gate(self, gate): + self.mmu.mmu_toolhead.select_gear_stepper(gate) # Select correct gear drive stepper or none if bypass + super(LinearMultiGearSelector, self).restore_gate(gate) + + + +################################################################################ +# Rotary Selector +# Implements Rotary Selector for type-A MMU's that uses stepper controlled +# rail[0] on mmu toolhead (3D Chameleon) +# +# 'filament_always_gripped' alters operation: +# 0 (default) - Lazy gate selection, occurs when asked to grip filament +# 1 - Gripped immediately on selection and will not release +# +# Implements commands: +# MMU_CALIBRATE_SELECTOR +# MMU_SOAKTEST_SELECTOR +# MMU_GRIP - realign with selected gate +# MMU_RELEASE - move between gates to release filament +################################################################################ + +class RotarySelector(BaseSelector, object): + + # mmu_vars.cfg variables + VARS_MMU_SELECTOR_OFFSETS = "mmu_selector_offsets" + VARS_MMU_SELECTOR_GATE_POS = "mmu_selector_gate_pos" + + def __init__(self, mmu): + super(RotarySelector, self).__init__(mmu) + + # Process config + self.selector_move_speed = mmu.config.getfloat('selector_move_speed', 200, minval=1.) + self.selector_homing_speed = mmu.config.getfloat('selector_homing_speed', 100, minval=1.) + self.selector_touch_speed = mmu.config.getfloat('selector_touch_speed', 60, minval=1.) # Not used with 3DChameleon but allows for param in config + self.selector_touch_enabled = mmu.config.getint('selector_touch_enabled', 1, minval=0, maxval=1) # Not used with 3DChameleon but allows for param in config + + # To simplfy config CAD related parameters are set based on vendor and version setting + # + # cad_gate0_pos - approximate distance from endstop to first gate + # cad_gate_width - width of each gate + # cad_last_gate_offset - distance from end of travel to last gate + # + # Chameleon defaults + self.cad_gate0_pos = 4.0 + self.cad_gate_width = 25. + self.cad_last_gate_offset = 2. + self.cad_bypass_offset = 0 # Doesn't have bypass + self.cad_selector_tolerance = 15. + + self.cad_gate_directions = [1, 1, 0, 0] + self.cad_release_gates = [2, 3, 0, 1] + + # But still allow all CAD parameters to be customized + self.cad_gate0_pos = mmu.config.getfloat('cad_gate0_pos', self.cad_gate0_pos, minval=0.) + self.cad_gate_width = mmu.config.getfloat('cad_gate_width', self.cad_gate_width, above=0.) + self.cad_last_gate_offset = mmu.config.getfloat('cad_last_gate_offset', self.cad_last_gate_offset, above=0.) + self.cad_selector_tolerance = mmu.config.getfloat('cad_selector_tolerance', self.cad_selector_tolerance, above=0.) # Extra movement allowed by selector + + self.cad_gate_directions = list(mmu.config.getintlist('cad_gate_directions',self.cad_gate_directions)) + self.cad_release_gates = list(mmu.config.getintlist('cad_release_gates', self.cad_release_gates)) + + # Register GCODE commands specific to this module + gcode = mmu.printer.lookup_object('gcode') + gcode.register_command('MMU_CALIBRATE_SELECTOR', self.cmd_MMU_CALIBRATE_SELECTOR, desc=self.cmd_MMU_CALIBRATE_SELECTOR_help) + gcode.register_command('MMU_SOAKTEST_SELECTOR', self.cmd_MMU_SOAKTEST_SELECTOR, desc=self.cmd_MMU_SOAKTEST_SELECTOR_help) + gcode.register_command('MMU_GRIP', self.cmd_MMU_GRIP, desc=self.cmd_MMU_GRIP_help) + gcode.register_command('MMU_RELEASE', self.cmd_MMU_RELEASE, desc=self.cmd_MMU_RELEASE_help) + + # Selector stepper setup before MMU toolhead is instantiated + section = mmu_machine.SELECTOR_STEPPER_CONFIG + if mmu.config.has_section(section): + # Inject options into selector stepper config regardless or what user sets + mmu.config.fileconfig.set(section, 'position_min', -1.) + mmu.config.fileconfig.set(section, 'position_max', self._get_max_selector_movement()) + mmu.config.fileconfig.set(section, 'homing_speed', self.selector_homing_speed) + + # Selector "Interface" methods --------------------------------------------- + + def reinit(self): + self.grip_state = self.mmu.FILAMENT_DRIVE_STATE + + def handle_connect(self): + self.mmu_toolhead = self.mmu.mmu_toolhead + self.selector_rail = self.mmu_toolhead.get_kinematics().rails[0] + self.selector_stepper = self.selector_rail.steppers[0] + + # Have an endstop (most likely stallguard)? + endstops = self.selector_rail.get_endstops() + self.has_endstop = bool(endstops) and endstops[0][0].__class__.__name__ != "MockEndstop" + + # Load selector offsets (calibration set with MMU_CALIBRATE_SELECTOR) ------------------------------- + self.selector_offsets = self.mmu.save_variables.allVariables.get(self.VARS_MMU_SELECTOR_OFFSETS, None) + if self.selector_offsets: + # Ensure list size + if len(self.selector_offsets) == self.mmu.num_gates: + self.mmu.log_debug("Loaded saved selector offsets: %s" % self.selector_offsets) + else: + self.mmu.log_error("Incorrect number of gates specified in %s. Adjusted length" % self.VARS_MMU_SELECTOR_OFFSETS) + self.selector_offsets = self._ensure_list_size(self.selector_offsets, self.mmu.num_gates) + + if not any(x == -1 for x in self.selector_offsets): + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR + else: + self.mmu.log_always("Warning: Selector offsets not found in mmu_vars.cfg. Probably not calibrated") + self.selector_offsets = [-1] * self.mmu.num_gates + self.mmu.save_variables.allVariables[self.VARS_MMU_SELECTOR_OFFSETS] = self.selector_offsets + + def _ensure_list_size(self, lst, size, default_value=-1): + lst = lst[:size] + lst.extend([default_value] * (size - len(lst))) + return lst + + def home(self, force_unload = None): + if self.mmu.check_if_bypass(): return + with self.mmu.wrap_action(self.mmu.ACTION_HOMING): + self.mmu.log_info("Homing MMU...") + if force_unload is not None: + self.mmu.log_debug("(asked to %s)" % ("force unload" if force_unload else "not unload")) + if force_unload is True: + # Forced unload case for recovery + self.mmu.unload_sequence(check_state=True) + elif force_unload is False and self.mmu.filament_pos != self.mmu.FILAMENT_POS_UNLOADED: + # Automatic unload case + self.mmu.unload_sequence() + self._home_selector() + + # Actual gate selection can be delayed (if not forcing grip) until the + # filament_drive/release to reduce selector movement + def select_gate(self, gate): + if gate != self.mmu.gate_selected: + with self.mmu.wrap_action(self.mmu.ACTION_SELECTING): + if self.mmu.mmu_machine.filament_always_gripped: + self._grip(gate) + + def restore_gate(self, gate): + gate_pos = self.mmu.save_variables.allVariables.get(self.VARS_MMU_SELECTOR_GATE_POS, None) + if gate_pos is not None: + self.set_position(self.selector_offsets[gate_pos]) + if gate == gate_pos: + self.grip_state = self.mmu.FILAMENT_DRIVE_STATE + else: + self.grip_state = self.mmu.FILAMENT_RELEASE_STATE + else: + self.grip_state = self.mmu.FILAMENT_UNKNOWN_STATE + + def filament_drive(self): + self._grip(self.mmu.gate_selected) + + def filament_release(self, measure=False): + if not self.mmu.mmu_machine.filament_always_gripped: + self._grip(self.mmu.gate_selected, release=True) + return 0. # Fake encoder movement + + # Note there is no separation of gate selection and grip/release with this type of selector + def _grip(self, gate, release=False): + if gate >= 0: + if release: + release_pos = self.selector_offsets[self.cad_release_gates[gate]] + self.mmu.log_trace("Setting selector to filament release position at position: %.1f" % release_pos) + self._position(release_pos) + self.grip_state = self.mmu.FILAMENT_RELEASE_STATE + + # Precaution to ensure correct postion/gate restoration on restart + self.mmu.save_variable(self.VARS_MMU_SELECTOR_GATE_POS, self.cad_release_gates[gate], write=True) + else: + grip_pos = self.selector_offsets[gate] + self.mmu.log_trace("Setting selector to filament grip position at position: %.1f" % grip_pos) + self._position(grip_pos) + self.grip_state = self.mmu.FILAMENT_DRIVE_STATE + + # Precaution to ensure correct postion/gate restoration on restart + self.mmu.save_variable(self.VARS_MMU_SELECTOR_GATE_POS, gate, write=True) + + # Ensure gate filament drive is in the correct direction + self.mmu_toolhead.get_kinematics().rails[1].set_direction(self.cad_gate_directions[gate]) + self.mmu.movequeues_wait() + else: + self.grip_state = self.mmu.FILAMENT_UNKNOWN_STATE + + def get_filament_grip_state(self): + return self.grip_state + + def disable_motors(self): + stepper_enable = self.mmu.printer.lookup_object('stepper_enable') + se = stepper_enable.lookup_enable(self.selector_stepper.get_name()) + se.motor_disable(self.mmu_toolhead.get_last_move_time()) + self.is_homed = False + + def enable_motors(self): + stepper_enable = self.mmu.printer.lookup_object('stepper_enable') + se = stepper_enable.lookup_enable(self.selector_stepper.get_name()) + se.motor_enable(self.mmu_toolhead.get_last_move_time()) + + def buzz_motor(self, motor): + if motor == "selector": + pos = self.mmu_toolhead.get_position()[0] + self.move(None, pos + 5, wait=False) + self.move(None, pos - 5, wait=False) + self.move(None, pos, wait=False) + else: + return False + return True + + def get_status(self, eventtime): + status = super(RotarySelector, self).get_status(eventtime) + status.update({ + 'grip': "Gripped" if self.grip_state == self.mmu.FILAMENT_DRIVE_STATE else "Released", + }) + return status + + def get_mmu_status_config(self): + msg = "\nSelector is NOT HOMED. " if not self.is_homed else "" + msg += "Filament is %s" % ("GRIPPED" if self.grip_state == self.mmu.FILAMENT_DRIVE_STATE else "RELEASED") + return msg + + def set_test_config(self, gcmd): + self.selector_move_speed = gcmd.get_float('SELECTOR_MOVE_SPEED', self.selector_move_speed, minval=1.) + self.selector_homing_speed = gcmd.get_float('SELECTOR_HOMING_SPEED', self.selector_homing_speed, minval=1.) + + def get_test_config(self): + msg = "\n\nSELECTOR:" + msg += "\nselector_move_speed = %.1f" % self.selector_move_speed + msg += "\nselector_homing_speed = %.1f" % self.selector_homing_speed + return msg + + def get_uncalibrated_gates(self, check_gates): + return [gate for gate, value in enumerate(self.selector_offsets) if value == -1 and gate in check_gates] + + # Internal Implementation -------------------------------------------------- + + cmd_MMU_GRIP_help = "Grip filament in current gate" + def cmd_MMU_GRIP(self, gcmd): + if self.mmu.gate_selected >= 0: + self.filament_drive() + + cmd_MMU_RELEASE_help = "Ungrip filament in current gate" + def cmd_MMU_RELEASE(self, gcmd): + if self.mmu.gate_selected >= 0: + if not self.mmu.mmu_machine.filament_always_gripped: + self.filament_release() + else: + self.mmu.log_error("Selector configured to not allow filament release") + + cmd_MMU_CALIBRATE_SELECTOR_help = "Calibration of the selector positions or postion of specified gate" + def cmd_MMU_CALIBRATE_SELECTOR(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + single = gcmd.get_int('SINGLE', 0, minval=0, maxval=1) + quick = gcmd.get_int('QUICK', 0, minval=0, maxval=1) + gate = gcmd.get_int('GATE', 0, minval=0, maxval=self.mmu.mmu_machine.num_gates - 1) + + try: + self.mmu.calibrating = True + self.mmu.reinit() + successful = False + + if self.has_endstop and not quick: + successful = self._calibrate_selector(gate, extrapolate=not single, save=save) + else: + self.mmu.log_always("%s - will calculate gate offsets from cad_gate0_offset and cad_gate_width" % ("Quick method" if quick else "No endstop configured")) + self.selector_offsets = [round(self.cad_gate0_pos + i * self.cad_gate_width, 1) for i in range(self.mmu.num_gates)] + self.mmu.save_variable(self.VARS_MMU_SELECTOR_OFFSETS, self.selector_offsets, write=True) + successful = True + + if not any(x == -1 for x in self.selector_offsets): + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR + + # If not fully calibrated turn off the selector stepper to ease next step, else activate by homing + if successful and self.mmu.calibration_status & self.mmu.CALIBRATED_SELECTOR: + self.mmu.log_always("Selector calibration complete") + self.mmu.select_tool(0) + else: + self.mmu.motors_onoff(on=False, motor="selector") + + except MmuError as ee: + self.mmu.handle_mmu_error(str(ee)) + finally: + self.mmu.calibrating = False + + cmd_MMU_SOAKTEST_SELECTOR_help = "Soak test of selector movement" + def cmd_MMU_SOAKTEST_SELECTOR(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + if self.mmu.check_if_loaded(): return + if self.mmu.check_if_not_calibrated(self.mmu.CALIBRATED_SELECTOR): return + loops = gcmd.get_int('LOOP', 100) + home = bool(gcmd.get_int('HOME', 0)) + try: + with self.mmu.wrap_sync_gear_to_extruder(): + if home: + self.home() + for l in range(loops): + self.mmu.log_always("Testing loop %d / %d" % (l + 1, loops)) + tool = random.randint(0, self.mmu.num_gates - 1) + if random.randint(0, 10) == 0 and home: + self.mmu.home(tool=tool) + else: + if random.randint(0, 10) == 0 and home: + self.mmu.home(tool=tool) + else: + self.mmu.select_tool(tool) + if not self.mmu.mmu_machine.filament_always_gripped: + self.filament_drive() + except MmuError as ee: + self.mmu.handle_mmu_error("Soaktest abandoned because of error: %s" % str(ee)) + + def _get_max_selector_movement(self, gate=-1): + n = gate if gate >= 0 else self.mmu.num_gates - 1 + + max_movement = self.cad_gate0_pos + (n * self.cad_gate_width) + max_movement += self.cad_last_gate_offset if gate in [self.mmu.TOOL_GATE_UNKNOWN] else 0. + max_movement += self.cad_selector_tolerance + return max_movement + + # Manual selector offset calibration + def _calibrate_selector(self, gate, extrapolate=True, save=True): + max_movement = self._get_max_selector_movement(gate) + self.mmu.log_always("Measuring the selector position for gate %d..." % gate) + traveled, found_home = self.measure_to_home() + + # Test we actually homed + if not found_home: + self.mmu.log_error("Selector didn't find home position") + return False + + # Warn and don't save if the measurement is unexpected + if traveled > max_movement: + self.mmu.log_always("Selector move measured %.1fmm. More than the anticipated maximum of %.1fmm. Save disabled\nIt is likely that your basic MMU dimensions are incorrect in mmu_parameters.cfg. Check vendor/version and optional 'cad_*' parameters" % (traveled, max_movement)) + save = 0 + else: + self.mmu.log_always("Selector move measured %.1fmm" % traveled) + + if save: + self.selector_offsets[gate] = round(traveled, 1) + if extrapolate and gate == self.mmu.num_gates - 1 and self.selector_offsets[0] > 0: + # Distribute selector spacing based on measurements of first and last gate + spacing = (self.selector_offsets[-1] - self.selector_offsets[0]) / (self.mmu.num_gates - 1) + self.selector_offsets = [round(self.selector_offsets[0] + i * spacing, 1) for i in range(self.mmu.num_gates)] + elif extrapolate: + # Distribute using cad spacing + self.selector_offsets = [round(self.selector_offsets[0] + i * self.cad_gate_width, 1) for i in range(self.mmu.num_gates)] + else: + extrapolate = False + self.mmu.save_variable(self.VARS_MMU_SELECTOR_OFFSETS, self.selector_offsets, write=True) + + if extrapolate: + self.mmu.log_always("All selector offsets have been extrapolated and saved:\n%s" % self.selector_offsets) + else: + self.mmu.log_always("Selector offset (%.1fmm) for gate %d has been saved" % (traveled, gate)) + if gate == 0: + self.mmu.log_always("Run MMU_CALIBRATE_SELECTOR again with GATE=%d to extrapolate all gate positions. Use SINGLE=1 to force calibration of only one gate" % (self.mmu.num_gates - 1)) + return True + + def _home_selector(self): + self.mmu.unselect_gate() + self.mmu.movequeues_wait() + try: + if self.has_endstop: + homing_state = mmu_machine.MmuHoming(self.mmu.printer, self.mmu_toolhead) + homing_state.set_axes([0]) + self.mmu.mmu_toolhead.get_kinematics().home(homing_state) + else: + self._home_hard_endstop() + self.is_homed = True + except Exception as e: # Homing failed + raise MmuError("Homing selector failed because of blockage or malfunction. Klipper reports: %s" % str(e)) + + def _home_hard_endstop(self): + self.mmu.log_always("Forcing selector homing to hard endstop. Excuse the noise!\n(Configure stallguard endstop on selector stepper to avoid)") + self.set_position(self._get_max_selector_movement()) # Worst case position to allow full movement + self.move("Forceably homing to hard endstop", new_pos=0, speed=self.selector_homing_speed) + self.set_position(0) # Reset pos + + def _position(self, target): + self.move("Positioning selector", target) + + def move(self, trace_str, new_pos, speed=None, accel=None, wait=False): + return self._trace_selector_move(trace_str, new_pos, speed=speed, accel=accel, wait=wait) + + # Internal raw wrapper around all selector moves except rail homing + # Returns position after move, if homed (homing moves) + def _trace_selector_move(self, trace_str, new_pos, speed=None, accel=None, wait=False): + if trace_str: + self.mmu.log_trace(trace_str) + + self.mmu_toolhead.quiesce() + + # Set appropriate speeds and accel if not supplied + speed = speed or self.selector_move_speed + accel = accel or self.mmu_toolhead.get_selector_limits()[1] + + pos = self.mmu_toolhead.get_position() + with self.mmu.wrap_accel(accel): + pos[0] = new_pos + self.mmu_toolhead.move(pos, speed) + if self.mmu.log_enabled(self.mmu.LOG_STEPPER): + self.mmu.log_stepper("SELECTOR MOVE: position=%.1f, speed=%.1f, accel=%.1f" % (new_pos, speed, accel)) + if wait: + self.mmu.movequeues_wait(toolhead=False, mmu_toolhead=True) + return pos[0] + + def set_position(self, position): + pos = self.mmu_toolhead.get_position() + pos[0] = position + self.mmu_toolhead.set_position(pos, homing_axes=(0,)) + self.enable_motors() + self.is_homed = True + return position + + def measure_to_home(self): + self.mmu.movequeues_wait() + init_mcu_pos = self.selector_stepper.get_mcu_position() + homed = False + try: + homing_state = mmu_machine.MmuHoming(self.mmu.printer, self.mmu_toolhead) + homing_state.set_axes([0]) + self.mmu_toolhead.get_kinematics().home(homing_state) + homed = True + except Exception: + pass # Home not found + mcu_position = self.selector_stepper.get_mcu_position() + traveled = abs(mcu_position - init_mcu_pos) * self.selector_stepper.get_step_dist() + return traveled, homed + + + +################################################################################ +# Macro Selector +# Implements macro-based selector for MMU's +# +# Example demultiplexer-style SELECT_TOOL macro: +# [gcode_macro SELECT_TOOL] +# gcode: +# SET_PIN PIN=d0 VALUE={params.S0} +# SET_PIN PIN=d1 VALUE={params.S1} +# SET_PIN PIN=d2 VALUE={params.S2} +# +# Example optocoupler-style SELECT_TOOL macro: +# [gcode_macro SELECT_TOOL] +# gcode: +# SET_PIN PIN=o{printer.mmu.gate} VALUE=0 +# SET_PIN PIN=o{params.GATE} VALUE=1 +################################################################################ + +class MacroSelector(BaseSelector, object): + + def __init__(self, mmu): + super(MacroSelector, self).__init__(mmu) + self.is_homed = True + + self.printer = mmu.printer + self.gcode = self.printer.lookup_object('gcode') + + self.select_tool_macro = mmu.config.get('select_tool_macro') + self.select_tool_num_switches = mmu.config.getint('select_tool_num_switches', default=0, minval=1) + + # Check if using a demultiplexer-style setup + if self.select_tool_num_switches > 0: + self.binary_mode = True + max_num_tools = 2**self.select_tool_num_switches + # Verify that there aren't too many tools for the demultiplexer + if mmu.num_gates > max_num_tools: + raise mmu.config.error('Maximum number of allowed tools is %d, but %d are present.' % (max_num_tools, mmu.num_gates)) + else: + self.binary_mode = False + + # Read all controller parameters related to selector or servo to stop klipper complaining. This + # is done to allow for uniform and shared mmu_parameters.cfg file regardless of configuration. + for option in ['selector_', 'servo_', 'cad_']: + for key in mmu.config.get_prefix_options(option): + _ = mmu.config.get(key) + + # Selector "Interface" methods --------------------------------------------- + + def handle_connect(self): + self.mmu_toolhead = self.mmu.mmu_toolhead + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR # No calibration necessary + + def handle_ready(self): + logging.info("Happy Hare MacroSelector: Gate %d" % self.mmu.gate_selected) + self.select_gate(self.mmu.gate_selected) + + def select_gate(self, gate): + # Store parameters as list + params = ['GATE=' + str(gate)] + if self.binary_mode: # If demultiplexer, pass binary parameters to the macro in the form of S0=, S1=, S2=, etc. + binary = list(reversed('{0:b}'.format(gate).zfill(self.select_tool_num_switches))) + for i in range(self.select_tool_num_switches): + char = binary[i] + params.append('S' + str(i) + '=' + str(char)) + params = ' '.join(params) + + # Call selector macro + self.mmu.wrap_gcode_command('%s %s' % (self.select_tool_macro, params)) + + def restore_gate(self, gate): + pass + + + +################################################################################ +# Servo Selector +# Implements Servo based Selector for type-A MMU's like PicoMMU. Filament is +# always gripped when gate selected but a release position is assumed between +# each gate position (or specified release position, often 0 degrees) +# +# 'filament_always_gripped' alters operation: +# 0 (default) - Lazy gate selection, occurs when asked to grip filament +# 1 - Gripped immediately on selection and will not release +# +# Implements commands: +# MMU_CALIBRATE_SELECTOR +# MMU_SOAKTEST_SELECTOR +# MMU_GRIP - realign with selected gate +# MMU_RELEASE - move between gates to release filament +################################################################################ + +class ServoSelector(BaseSelector, object): + + # mmu_vars.cfg variables + VARS_MMU_SELECTOR_ANGLES = "mmu_selector_angles" + VARS_MMU_SELECTOR_BYPASS_ANGLE = "mmu_selector_bypass_angle" + + def __init__(self, mmu): + + super(ServoSelector, self).__init__(mmu) + self.is_homed = True + self.servo_state = self.mmu.FILAMENT_UNKNOWN_STATE + self.selector_bypass_angle = -1 + + # Get hardware + servo_name = mmu.config.get('selector_servo_name', "selector_servo") + self.servo = mmu.printer.lookup_object("mmu_servo %s" % servo_name, None) + if not self.servo: + raise self.mmu.config.error("Selector servo not found. Perhaps missing '[mmu_servo %s]' definition" % servo_name) + + # Process config + self.servo_duration = mmu.config.getfloat('servo_duration', 0.5, minval=0.1) + self.servo_dwell = mmu.config.getfloat('servo_dwell', 0.6, minval=0.1) + self.servo_always_active = mmu.config.getint('servo_always_active', 0, minval=0, maxval=1) + self.servo_min_angle = mmu.config.getfloat('servo_min_angle', 0, above=0) # Not exposed + self.servo_max_angle = mmu.config.getfloat('servo_max_angle', self.servo.max_angle, above=0) # Not exposed + self.servo_angle = self.servo_min_angle + (self.servo_max_angle - self.servo_min_angle) / 2 + self.selector_release_angle = mmu.config.getfloat('selector_release_angle', -1, minval=-1, maxval=self.servo_max_angle) + self.selector_bypass_angle = mmu.config.getfloat('selector_bypass_angle', -1, minval=-1, maxval=self.servo_max_angle) + self.selector_angles = list(mmu.config.getintlist('selector_gate_angles', [])) + + # Register GCODE commands specific to this module + gcode = mmu.printer.lookup_object('gcode') + gcode.register_command('MMU_CALIBRATE_SELECTOR', self.cmd_MMU_CALIBRATE_SELECTOR, desc = self.cmd_MMU_CALIBRATE_SELECTOR_help) + gcode.register_command('MMU_SOAKTEST_SELECTOR', self.cmd_MMU_SOAKTEST_SELECTOR, desc=self.cmd_MMU_SOAKTEST_SELECTOR_help) + gcode.register_command('MMU_GRIP', self.cmd_MMU_GRIP, desc=self.cmd_MMU_GRIP_help) + gcode.register_command('MMU_RELEASE', self.cmd_MMU_RELEASE, desc=self.cmd_MMU_RELEASE_help) + + # Read all controller parameters related to selector or servo to stop klipper complaining. This + # is done to allow for uniform and shared mmu_parameters.cfg file regardless of configuration. + for option in ['selector_', 'servo_', 'cad_']: + for key in mmu.config.get_prefix_options(option): + _ = mmu.config.get(key) + + # Selector "Interface" methods --------------------------------------------- + + def reinit(self): + self.servo_state = self.mmu.FILAMENT_UNKNOWN_STATE + + def handle_connect(self): + # Load and merge calibrated selector angles (calibration set with MMU_CALIBRATE_SELECTOR) ----------- + self.selector_angles = self._ensure_list_size(self.selector_angles, self.mmu.num_gates) + + cal_selector_angles = self.mmu.save_variables.allVariables.get(self.VARS_MMU_SELECTOR_ANGLES, []) + if cal_selector_angles: + self.mmu.log_debug("Loaded saved selector angles: %s" % cal_selector_angles) + else: + self.mmu.log_always("Warning: Selector angles not found in mmu_vars.cfg. Using configured defaults") + + # Merge calibrated angles with conf angles + for gate, angle in enumerate(zip(self.selector_angles, cal_selector_angles)): + if angle[1] >= 0: + self.selector_angles[gate] = angle[1] + + if not any(x == -1 for x in self.selector_angles): + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR + + selector_bypass_angle = self.mmu.save_variables.allVariables.get(self.VARS_MMU_SELECTOR_BYPASS_ANGLE, -1) + if selector_bypass_angle >= 0: + self.selector_bypass_angle = selector_bypass_angle + self.mmu.log_debug("Loaded saved bypass angle: %s" % self.selector_bypass_angle) + + def _ensure_list_size(self, lst, size, default_value=-1): + lst = lst[:size] + lst.extend([default_value] * (size - len(lst))) + return lst + + # Actual gate selection (servo movement) can be delayed until the filament_drive/release instruction + # to prevent unecessary flutter. Conrolled by `filament_always_gripped` setting + def select_gate(self, gate): + if gate != self.mmu.gate_selected: + with self.mmu.wrap_action(self.mmu.ACTION_SELECTING): + if self.mmu.mmu_machine.filament_always_gripped: + self._grip(gate) + + def restore_gate(self, gate): + if gate == self.mmu.TOOL_GATE_BYPASS: + self.servo_state = self.mmu.FILAMENT_RELEASE_STATE + self.mmu.log_trace("Setting servo to bypass angle: %.1f" % self.selector_bypass_angle) + self._set_servo_angle(self.selector_bypass_angle) + else: + if self.mmu.mmu_machine.filament_always_gripped: + self._grip(gate) + else: + # Defer movement until filament_drive/release/hold call + self.servo_state = self.mmu.FILAMENT_UNKNOWN_STATE + + def filament_drive(self): + self._grip(self.mmu.gate_selected) + + def filament_release(self, measure=False): + if not self.mmu.mmu_machine.filament_always_gripped: + self._grip(self.mmu.gate_selected, release=True) + return 0. # Fake encoder movement + + # Common logic for servo manipulation + def _grip(self, gate, release=False): + if gate == self.mmu.TOOL_GATE_BYPASS: + self.mmu.log_trace("Setting servo to bypass angle: %.1f" % self.selector_bypass_angle) + self._set_servo_angle(self.selector_bypass_angle) + self.servo_state = self.mmu.FILAMENT_UNKNOWN_STATE + elif gate >= 0: + if release: + release_angle = self._get_closest_released_angle() + self.mmu.log_trace("Setting servo to filament released position at angle: %.1f" % release_angle) + self._set_servo_angle(release_angle) + self.servo_state = self.mmu.FILAMENT_RELEASE_STATE + else: + angle = self.selector_angles[gate] + self.mmu.log_trace("Setting servo to filament grip position at angle: %.1f" % angle) + self._set_servo_angle(angle) + self.servo_state = self.mmu.FILAMENT_DRIVE_STATE + else: + self.servo_state = self.mmu.FILAMENT_UNKNOWN_STATE + + def get_filament_grip_state(self): + return self.servo_state + + def buzz_motor(self, motor): + if motor == "selector": + prev_servo_angle = self.servo_angle + low = max(min(self.selector_angles), self.servo_min_angle) + high = min(max(self.selector_angles), self.servo_max_angle) + mid = (low + high) / 2 + move = (high - low) / 4 + self._set_servo_angle(angle=mid) + self._set_servo_angle(angle=mid - move) + self._set_servo_angle(angle=mid + move) + self._set_servo_angle(angle=prev_servo_angle) + else: + return False + return True + + def has_bypass(self): + return self.mmu.mmu_machine.has_bypass and self.selector_bypass_angle >= 0 + + def get_status(self, eventtime): + status = super(ServoSelector, self).get_status(eventtime) + status.update({ + 'grip': "Gripped" if self.servo_state == self.mmu.FILAMENT_DRIVE_STATE else "Released", + }) + return status + + def get_mmu_status_config(self): + msg = super(ServoSelector, self).get_mmu_status_config() + msg += ". Servo in %s position" % ("GRIP" if self.servo_state == self.mmu.FILAMENT_DRIVE_STATE else \ + "RELEASE" if self.servo_state == self.mmu.FILAMENT_RELEASE_STATE else "unknown") + return msg + + def get_uncalibrated_gates(self, check_gates): + return [gate for gate, value in enumerate(self.selector_angles) if value == -1 and gate in check_gates] + + # Internal Implementation -------------------------------------------------- + + cmd_MMU_GRIP_help = "Grip filament in current gate" + def cmd_MMU_GRIP(self, gcmd): + if self.mmu.gate_selected >= 0: + self.filament_drive() + + cmd_MMU_RELEASE_help = "Ungrip filament in current gate" + def cmd_MMU_RELEASE(self, gcmd): + if self.mmu.gate_selected >= 0: + if not self.mmu.mmu_machine.filament_always_gripped: + self.filament_release() + else: + self.mmu.log_error("Selector configured to not allow filament release") + + cmd_MMU_CALIBRATE_SELECTOR_help = "Calibration of the selector servo angle for specifed gate(s)" + def cmd_MMU_CALIBRATE_SELECTOR(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + + usage = "\nUsage: MMU_CALIBRATE_SELECTOR [GATE=x] [BYPASS=0|1] [SPACING=x] [ANGLE=x] [SAVE=0|1] [SINGLE=0|1] [SHOW=0|1]" + show = gcmd.get_int('SHOW', 0) + angle = gcmd.get_int('ANGLE', None) + save = gcmd.get_int('SAVE', 1, minval=0, maxval=1) + single = gcmd.get_int('SINGLE', 0, minval=0, maxval=1) + spacing = gcmd.get_float('SPACING', 25., above=0, below=180) # TiPicoMMU is 25 degrees between gates + gate = gcmd.get_int('GATE', -1, minval=0, maxval=self.mmu.mmu_machine.num_gates - 1) + if gate == -1 and gcmd.get_int('BYPASS', -1, minval=0, maxval=1) == 1: + gate = self.mmu.TOOL_GATE_BYPASS + + if show: + msg = "" + if not self.mmu.calibration_status & self.mmu.CALIBRATED_SELECTOR: + msg += "Calibration not complete\n" + msg += "Current selector gate angle positions are: %s degrees" % self.selector_angles + if self.selector_release_angle >= 0: + msg += "\nRelease angle is fixed at: %s degrees" % self.selector_release_angle + else: + msg += "\nRelease angles configured to be between each gate angle" + if self.has_bypass(): + msg += "\nBypass angle: %s" % self.selector_bypass_angle + else: + msg += "\nBypass angle not configured" + self.mmu.log_info(msg) + + elif angle is not None: + self.mmu.log_debug("Setting selector servo to angle: %d" % angle) + self._set_servo_angle(angle) + self.servo_state = self.mmu.FILAMENT_UNKNOWN_STATE + + elif save: + if gate == self.mmu.TOOL_GATE_BYPASS: + self.selector_bypass_angle = self.servo_angle + self.mmu.save_variable(self.VARS_MMU_SELECTOR_BYPASS_ANGLE, self.selector_bypass_angle, write=True) + self.mmu.log_info("Servo angle '%d' for bypass position has been saved" % self.servo_angle) + elif gate >= 0: + if single: + self.selector_angles[gate] = self.servo_angle + self.mmu.save_variable(self.VARS_MMU_SELECTOR_ANGLES, self.selector_angles, write=True) + self.mmu.log_info("Servo angle '%d' for gate %d has been saved" % (self.servo_angle, gate)) + else: + # If possible evenly distribute based on spacing + angles = self._generate_gate_angles(self.servo_angle, gate, spacing) + if angles: + self.selector_angles = angles + self.mmu.save_variable(self.VARS_MMU_SELECTOR_ANGLES, self.selector_angles, write=True) + self.mmu.log_info("Selector gate angle positions %s has been saved" % self.selector_angles) + else: + self.mmu.log_error("Not possible to distribute angles with separation of %.1f degrees with gate %d at %.1f%s" % (spacing, gate, self.servo_angle, usage)) + else: + self.mmu.log_error("No gate specified%s" % usage) + else: + self.mmu.log_always("Current selector servo angle: %d, Selector gate angle positions: %s" % (self.servo_angle, self.selector_angles)) + + if not any(x == -1 for x in self.selector_angles): + self.mmu.calibration_status |= self.mmu.CALIBRATED_SELECTOR + + cmd_MMU_SOAKTEST_SELECTOR_help = "Soak test of selector movement" + def cmd_MMU_SOAKTEST_SELECTOR(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + if self.mmu.check_if_loaded(): return + if self.mmu.check_if_not_calibrated(self.mmu.CALIBRATED_SELECTOR): return + loops = gcmd.get_int('LOOP', 10) + try: + with self.mmu.wrap_sync_gear_to_extruder(): + for l in range(loops): + self.mmu.log_always("Testing loop %d / %d" % (l + 1, loops)) + tool = random.randint(0, self.mmu.num_gates - 1) + self.mmu.select_tool(tool) + if not self.mmu.mmu_machine.filament_always_gripped: + self.filament_drive() + except MmuError as ee: + self.mmu.handle_mmu_error("Soaktest abandoned because of error: %s" % str(ee)) + + def _set_servo_angle(self, angle): + if angle >= 0 and angle != self.servo_angle: + self.mmu.movequeues_wait() + self.servo.set_position(angle=angle, duration=None if self.servo_always_active else self.servo_duration) + self.servo_angle = angle + self.mmu.movequeues_dwell(max(self.servo_dwell, self.servo_duration, 0)) + + def _get_closest_released_angle(self): + if self.selector_release_angle >= 0: + return self.selector_release_angle + neutral_angles = [(self.selector_angles[i] + self.selector_angles[i + 1]) / 2 for i in range(len(self.selector_angles) - 1)] + closest_angle = 0 + min_difference = float('inf') + for angle in neutral_angles: + difference = abs(angle - self.servo_angle) + if difference < min_difference: + min_difference = difference + closest_angle = max(0, angle) + return closest_angle + + def _generate_gate_angles(self, known_angle, known_gate, spacing): + angles = [] + start_angle = known_angle - known_gate * spacing + for i in range(self.mmu.num_gates): + a = start_angle + i * spacing + if not (self.servo_min_angle <= a <= self.servo_max_angle): + return None # Not possible + angles.append(round(a)) + return angles + + + +################################################################################ +# Indexed Selector +# Implements simple Indexed Selector for type-A MMU's that uses a stepper for +# gate selection but has an indexing sensor for each gate. +# E.g. As fitted to BTT ViViD +# +# Implements commands: +# MMU_SOAKTEST_SELECTOR +################################################################################ + +class IndexedSelector(BaseSelector, object): + + def __init__(self, mmu): + super(IndexedSelector, self).__init__(mmu) + self.is_homed = True + + # Process config + self.selector_move_speed = mmu.config.getfloat('selector_move_speed', 100, minval=1.) + self.selector_homing_speed = mmu.config.getfloat('selector_homing_speed', self.selector_move_speed, minval=1.) + self.selector_touch_speed = mmu.config.getfloat('selector_touch_speed', 60, minval=1.) # Not used with ViViD but allows for param in config + self.selector_touch_enabled = mmu.config.getint('selector_touch_enabled', 1, minval=0, maxval=1) # Not used with ViViD but allows for param in config + self.selector_index_distance = mmu.config.getfloat('selector_index_distance', 5, minval=0.) + + # To simplfy config CAD related parameters are set based on vendor and version setting + self.cad_gate_width = 90. # Rotation distance set to make this equivalent to degrees + self.cad_max_rotations = 2 + + # But still allow all CAD parameters to be customized + self.cad_gate_width = mmu.config.getfloat('cad_gate_width', self.cad_gate_width, above=0.) + self.cad_max_rotations = mmu.config.getfloat('cad_max_rotations', self.cad_max_rotations, above=0.) + + # Register GCODE commands + gcode = mmu.printer.lookup_object('gcode') + gcode.register_command('MMU_SOAKTEST_SELECTOR', self.cmd_MMU_SOAKTEST_SELECTOR, desc=self.cmd_MMU_SOAKTEST_SELECTOR_help) + + # Selector stepper setup before MMU toolhead is instantiated + section = mmu_machine.SELECTOR_STEPPER_CONFIG + if mmu.config.has_section(section): + # Inject options into selector stepper config regardless or what user sets + mmu.config.fileconfig.set(section, 'homing_speed', self.selector_homing_speed) + + # Selector "Interface" methods --------------------------------------------- + + def handle_connect(self): + self.mmu_toolhead = self.mmu.mmu_toolhead + self.selector_rail = self.mmu_toolhead.get_kinematics().rails[0] + self.selector_stepper = self.selector_rail.steppers[0] + self._set_position(0) # Reset pos + + cmd_MMU_SOAKTEST_SELECTOR_help = "Soak test of selector movement" + def cmd_MMU_SOAKTEST_SELECTOR(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + if self.mmu.check_if_loaded(): return + if self.mmu.check_if_not_calibrated(self.mmu.CALIBRATED_SELECTOR): return + loops = gcmd.get_int('LOOP', 100) + home = bool(gcmd.get_int('HOME', 0)) + try: + with self.mmu.wrap_sync_gear_to_extruder(): + if home: + self.home() + for l in range(loops): + self.mmu.log_always("Testing loop %d / %d" % (l + 1, loops)) + tool = random.randint(0, self.mmu.num_gates) + if tool == self.mmu.num_gates: + self.mmu.select_bypass() + else: + self.mmu.select_tool(tool) + if not self.mmu.mmu_machine.filament_always_gripped: + self.filament_drive() + except MmuError as ee: + self.mmu.handle_mmu_error("Soaktest abandoned because of error: %s" % str(ee)) + + def bootup(self): + self.select_gate(self.mmu.gate_selected) + + def home(self, force_unload = None): + if self.mmu.check_if_bypass(): return + with self.mmu.wrap_action(self.mmu.ACTION_HOMING): + self.mmu.log_info("Homing MMU...") + if force_unload is not None: + self.mmu.log_debug("(asked to %s)" % ("force unload" if force_unload else "not unload")) + if force_unload is True: + # Forced unload case for recovery + self.mmu.unload_sequence(check_state=True) + elif force_unload is None and self.mmu.filament_pos != self.mmu.FILAMENT_POS_UNLOADED: + # Automatic unload case + self.mmu.unload_sequence() + self._home_selector() + + def select_gate(self, gate): + if gate >= 0: + endstop = self.selector_rail.get_extra_endstop(self._get_gate_endstop(gate)) + if not endstop: + raise MmuError("Extra endstop %s not defined on the selector stepper" % self._get_gate_endstop(gate)) + mcu_endstop = endstop[0][0] + if not mcu_endstop.query_endstop(self.mmu_toolhead.get_last_move_time()): + with self.mmu.wrap_action(self.mmu.ACTION_SELECTING): + self._find_gate(gate) + + def disable_motors(self): + stepper_enable = self.mmu.printer.lookup_object('stepper_enable') + se = stepper_enable.lookup_enable(self.selector_stepper.get_name()) + se.motor_disable(self.mmu_toolhead.get_last_move_time()) + self.is_homed = False + + def enable_motors(self): + stepper_enable = self.mmu.printer.lookup_object('stepper_enable') + se = stepper_enable.lookup_enable(self.selector_stepper.get_name()) + se.motor_enable(self.mmu_toolhead.get_last_move_time()) + + def buzz_motor(self, motor): + if motor == "selector": + pos = self.mmu_toolhead.get_position()[0] + self.move(None, pos + 5, wait=False) + self.move(None, pos - 5, wait=False) + self.move(None, pos, wait=False) + else: + return False + return True + + def get_mmu_status_config(self): + msg = "\nSelector is NOT HOMED" if not self.is_homed else "" + return msg + + def set_test_config(self, gcmd): + self.selector_move_speed = gcmd.get_float('SELECTOR_MOVE_SPEED', self.selector_move_speed, minval=1.) + self.selector_homing_speed = gcmd.get_float('SELECTOR_HOMING_SPEED', self.selector_homing_speed, minval=1.) + + def get_test_config(self): + msg = "\n\nSELECTOR:" + msg += "\nselector_move_speed = %.1f" % self.selector_move_speed + msg += "\nselector_homing_speed = %.1f" % self.selector_homing_speed + return msg + + # Internal Implementation -------------------------------------------------- + + def _get_max_selector_movement(self): + max_movement = self.mmu.num_gates * self.cad_gate_width * self.cad_max_rotations + return max_movement + + def _home_selector(self): + self.mmu.unselect_gate() + self.mmu.movequeues_wait() + try: + self._find_gate(0) + self.is_homed = True + except Exception as e: # Homing failed + logging.error(traceback.format_exc()) + raise MmuError("Homing selector failed because of blockage or malfunction. Klipper reports: %s" % str(e)) + + def _get_gate_endstop(self, gate): + return "unit0_gate%d" % gate + + def _find_gate(self, gate): + rotation_dir = self._best_rotation_direction(self.mmu.gate_selected, gate) + max_move = self._get_max_selector_movement() * rotation_dir + self.mmu.movequeues_wait() + actual,homed = self._trace_selector_move("Indexing selector", max_move, speed=self.selector_move_speed, homing_move=1, endstop_name=self._get_gate_endstop(gate)) + if abs(actual) > 0 and homed: + # If we actually moved to home make sure we are centered on index endstop + center_move = (self.selector_index_distance / 2) * rotation_dir + self._trace_selector_move("Centering selector", center_move, speed=self.selector_move_speed) + + # TODO automate the setup of the sequence through homing move on startup + def _best_rotation_direction(self, start_gate, end_gate): + if start_gate < 0: + return 1 # Forward direction + + sequence = [0, 2, 1, 3] # Forward order of gates + n = len(sequence) + forward_distance = reverse_distance = 0 + + # Find distance in forward direction + start_idx = sequence.index(start_gate) + for i in range(1, n): + if sequence[(start_idx + i) % n] == end_gate: + forward_distance = i + break + + # Find distance in reverse direction + rev_seq = sequence[::-1] + start_idx = rev_seq.index(start_gate) + for i in range(1, n): + if rev_seq[(start_idx + i) % n] == end_gate: + reverse_distance = i + break + + return 1 if forward_distance <= reverse_distance else -1 + + # Internal raw wrapper around all selector moves + # Returns position after move, and if homed (homing moves) + def _trace_selector_move(self, trace_str, dist, speed=None, accel=None, homing_move=0, endstop_name="default", wait=False): + null_rtn = (0., False) + homed = False + actual = dist + + self.mmu_toolhead.quiesce() + + if homing_move != 0: + # Check for valid endstop + endstops = self.selector_rail.get_endstops() if endstop_name is None else self.selector_rail.get_extra_endstop(endstop_name) + if endstops is None: + self.mmu.log_error("Endstop '%s' not found" % endstop_name) + return null_rtn + + # Set appropriate speeds and accel if not supplied + speed = speed or self.selector_homing_speed if homing_move != 0 else self.selector_move_speed + accel = accel or self.mmu_toolhead.get_selector_limits()[1] + + pos = self.mmu_toolhead.get_position() + if homing_move != 0: + try: + with self.mmu.wrap_accel(accel): + init_pos = pos[0] + pos[0] += dist + trig_pos = [0., 0., 0., 0.] + hmove = HomingMove(self.mmu.printer, endstops, self.mmu_toolhead) + trig_pos = hmove.homing_move(pos, speed, probe_pos=True, triggered=homing_move > 0, check_triggered=True) + homed = True + except self.mmu.printer.command_error as e: + homed = False + + halt_pos = self.mmu_toolhead.get_position() + actual = halt_pos[0] - init_pos + if self.mmu.log_enabled(self.mmu.LOG_STEPPER): + self.mmu.log_stepper("SELECTOR HOMING MOVE: max dist=%.1f, speed=%.1f, accel=%.1f, endstop_name=%s, wait=%s >> %s" % (dist, speed, accel, endstop_name, wait, "%s halt_pos=%.1f (rail moved=%.1f), trig_pos=%.1f" % ("HOMED" if homed else "DID NOT HOMED", halt_pos[0], actual, trig_pos[0]))) + + else: + with self.mmu.wrap_accel(accel): + pos[0] += dist + self.mmu_toolhead.move(pos, speed) + if self.mmu.log_enabled(self.mmu.LOG_STEPPER): + self.mmu.log_stepper("SELECTOR MOVE: position=%.1f, speed=%.1f, accel=%.1f" % (dist, speed, accel)) + + self.mmu_toolhead.flush_step_generation() # TTC mitigation (TODO: still required?) + self.mmu.toolhead.flush_step_generation() # TTC mitigation (TODO: still required?) + if wait: + self.mmu.movequeues_wait(toolhead=False, mmu_toolhead=True) + + if trace_str: + if homing_move != 0: + trace_str += ". Stepper: selector %s after moving %.1fmm (of max %.1fmm)" + trace_str = trace_str % (("homed" if homed else "did not home"), actual, dist) + trace_str += ". Pos: @%.1f" % self.mmu_toolhead.get_position()[0] + else: + trace_str += ". Stepper: selector moved %.1fmm" % dist + trace_str += ". Pos: @%.1f" % self.mmu_toolhead.get_position()[0] + self.mmu.log_trace(trace_str) + + return actual, homed + + def _set_position(self, position): + pos = self.mmu_toolhead.get_position() + pos[0] = position + self.mmu_toolhead.set_position(pos) + self.enable_motors() + self.is_homed = True + return position diff --git a/klippy/extras/mmu/mmu_sensor_manager.py b/klippy/extras/mmu/mmu_sensor_manager.py new file mode 100644 index 000000000000..cfa601af9050 --- /dev/null +++ b/klippy/extras/mmu/mmu_sensor_manager.py @@ -0,0 +1,322 @@ +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Manager to centralize mmu_sensor operations +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import random, logging, math, re + +# Happy Hare imports +from ..mmu_sensors import MmuRunoutHelper + +# MMU subcomponent clases +from .mmu_shared import MmuError + +class MmuSensorManager: + def __init__(self, mmu): + self.mmu = mmu + self.all_sensors = {} # All sensors on mmu unit optionally with unit prefix and gate suffix + self.sensors = {} # All (presence detection) sensors on active unit stripped of unit prefix + self.viewable_sensors = {} # Sensors of all types for current gate/unit renamed with simple names + + # Assemble all possible switch sensors in desired display order + sensor_names = [] + sensor_names.extend([self.get_gate_sensor_name(self.mmu.SENSOR_PRE_GATE_PREFIX, i) for i in range(self.mmu.num_gates)]) + sensor_names.extend([self.get_gate_sensor_name(self.mmu.SENSOR_GEAR_PREFIX, i) for i in range(self.mmu.num_gates)]) + sensor_names.extend([ + self.mmu.SENSOR_GATE, + self.mmu.SENSOR_TENSION, + self.mmu.SENSOR_COMPRESSION, + self.mmu.SENSOR_PROPORTIONAL + ]) + if self.mmu.mmu_machine.num_units > 1: + for i in range(self.mmu.mmu_machine.num_units): + sensor_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_GATE, i)) + sensor_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_TENSION, i)) + sensor_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_COMPRESSION, i)) + sensor_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_PROPORTIONAL, i)) + sensor_names.extend([ + self.mmu.SENSOR_EXTRUDER_ENTRY, + self.mmu.SENSOR_TOOLHEAD + ]) + mmu_sensors = self.mmu.printer.lookup_object("mmu_sensors") + self.all_sensors = mmu_sensors.sensors + + # Special case for "no bowden" (one unit) designs where mmu_gate is an alias for extruder sensor + if not self.mmu.mmu_machine.require_bowden_move and self.all_sensors.get(self.mmu.SENSOR_EXTRUDER_ENTRY, None) and self.mmu.SENSOR_GATE not in self.all_sensors: + self.all_sensors[self.mmu.SENSOR_GATE] = self.all_sensors[self.mmu.SENSOR_EXTRUDER_ENTRY] + + # Setup subset of filament sensors that are also used for homing (endstops) + self.endstop_names = [] + self.endstop_names.extend([self.get_gate_sensor_name(self.mmu.SENSOR_PRE_GATE_PREFIX, i) for i in range(self.mmu.num_gates)]) + self.endstop_names.extend([self.get_gate_sensor_name(self.mmu.SENSOR_GEAR_PREFIX, i) for i in range(self.mmu.num_gates)]) + self.endstop_names.extend([ + self.mmu.SENSOR_GATE, + self.mmu.SENSOR_TENSION, + self.mmu.SENSOR_COMPRESSION + ]) + if self.mmu.mmu_machine.num_units > 1: + for i in range(self.mmu.mmu_machine.num_units): + self.endstop_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_GATE, i)) + self.endstop_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_COMPRESSION, i)) + self.endstop_names.append(self.get_unit_sensor_name(self.mmu.SENSOR_TENSION, i)) + self.endstop_names.extend([ + self.mmu.SENSOR_EXTRUDER_ENTRY, + self.mmu.SENSOR_TOOLHEAD + ]) + # TODO Assumes one stepper but in theory could be on all + self.endstop_names.extend([ + self.mmu.SENSOR_GEAR_TOUCH + ]) + for name in self.endstop_names: + sensor = self.all_sensors.get(name, None) + if sensor is not None: + if sensor.__class__.__name__ in ["MmuAdcSwitchSensor", "MmuHallEndstop"]: + sensor_pin = sensor.runout_helper.switch_pin + mcu_endstop = self.mmu.gear_rail.add_extra_endstop(sensor_pin, name, mcu_endstop=sensor) + else: + # Add sensor pin as an extra endstop for gear rail + sensor_pin = sensor.runout_helper.switch_pin + ppins = self.mmu.printer.lookup_object('pins') + pin_params = ppins.parse_pin(sensor_pin, True, True) + share_name = "%s:%s" % (pin_params['chip_name'], pin_params['pin']) + ppins.allow_multi_use_pin(share_name) + mcu_endstop = self.mmu.gear_rail.add_extra_endstop(sensor_pin, name) + + # This ensures rapid stopping of extruder stepper when endstop is hit on synced homing + # otherwise the extruder can continue to move a small (speed dependent) distance + if self.mmu.homing_extruder and name in [self.mmu.SENSOR_TOOLHEAD, self.mmu.SENSOR_COMPRESSION, self.mmu.SENSOR_TENSION]: + mcu_endstop.add_stepper(self.mmu.mmu_extruder_stepper.stepper) + else: + logging.warning("MMU: Filament sensor %s is not defined in [mmu_sensors]" % name) + + # Reset the "viewable" sensors used in UI (unit must be updated first) + def reset_active_gate(self, gate): + sensor_name_map = { + self.mmu.SENSOR_PRE_GATE_PREFIX: self.get_gate_sensor_name(self.mmu.SENSOR_PRE_GATE_PREFIX, gate), + self.mmu.SENSOR_GEAR_PREFIX: self.get_gate_sensor_name(self.mmu.SENSOR_GEAR_PREFIX, gate), + self.mmu.SENSOR_GATE: self.get_mapped_endstop_name(self.mmu.SENSOR_GATE), + self.mmu.SENSOR_COMPRESSION: self.get_mapped_endstop_name(self.mmu.SENSOR_COMPRESSION), + self.mmu.SENSOR_TENSION: self.get_mapped_endstop_name(self.mmu.SENSOR_TENSION), + self.mmu.SENSOR_PROPORTIONAL: self.get_mapped_endstop_name(self.mmu.SENSOR_PROPORTIONAL), + self.mmu.SENSOR_EXTRUDER_ENTRY: self.mmu.SENSOR_EXTRUDER_ENTRY, + self.mmu.SENSOR_TOOLHEAD: self.mmu.SENSOR_TOOLHEAD + } + self.viewable_sensors = { + name: self.all_sensors.get(mapped_name) + for name, mapped_name in sensor_name_map.items() + if self.all_sensors.get(mapped_name) is not None + } + + # Activate only sensors for current unit and rename for access + def reset_active_unit(self, unit): + self.sensors = {} + for name, sensor in self.all_sensors.items(): + if name.startswith("unit_"): + if unit is not None and name.startswith("unit_" + str(unit)): + self.sensors[re.sub(r'unit_\d+_', '', name)] = sensor + sensor.runout_helper.enable_button_feedback(True) + else: + # Ensure any excluded sensor is completely deactivated + sensor.runout_helper.enable_runout(False) + sensor.runout_helper.enable_button_feedback(False) + else: + self.sensors[name] = sensor + + # Return dict of all sensor states (or None if sensor disabled) + def get_all_sensors(self, inactive=False): + names = {} + for name, sensor in self.sensors.items() if not inactive else self.all_sensors.items(): + names[name] = bool(sensor.runout_helper.filament_present) if sensor.runout_helper.sensor_enabled else None + return names + + def has_sensor(self, name): + return self.sensors[name].runout_helper.sensor_enabled if name in self.sensors else False + + def has_gate_sensor(self, name, gate): + return self.sensors[self.get_gate_sensor_name(name, gate)].runout_helper.sensor_enabled if self.get_gate_sensor_name(name, gate) in self.sensors else False + + def get_gate_sensor_name(self, name, gate): + return "%s_%d" % (name, gate) # Must match mmu_sensors + + def get_unit_sensor_name(self, name, unit): + return "unit_%d_%s" % (unit, name) # Must match mmu_sensors + + def get_unitless_sensor_name(self, name): + return re.sub(r'unit_\d+_', '', name) + + # Get unit or gate specific endstop if it exists + # Take generic name and look for "_genericName" and "genericName_" + def get_mapped_endstop_name(self, endstop_name): + mapped_name = self.get_unit_sensor_name(endstop_name, self.mmu.unit_selected) + if mapped_name in self.endstop_names: + return mapped_name + + mapped_name = self.get_gate_sensor_name(endstop_name, self.mmu.gate_selected) + if mapped_name in self.endstop_names: + return mapped_name + + return endstop_name + + # Return sensor state or None if not installed + def check_sensor(self, name): + sensor = self.sensors.get(name, None) + if sensor is not None and sensor.runout_helper.sensor_enabled: + detected = bool(sensor.runout_helper.filament_present) + return detected + else: + return None + + # Return per-gate sensor state or None if not installed + def check_gate_sensor(self, name, gate): + sensor_name = self.get_gate_sensor_name(name, gate) + sensor = self.sensors.get(sensor_name, None) + if sensor is not None and sensor.runout_helper.sensor_enabled: + detected = bool(sensor.runout_helper.filament_present) + return detected + else: + return None + + # Returns True if ALL sensors before position detect filament + # None if NO sensors available (disambiguate from non-triggered sensor) + # Can be used as a "filament continuity test" + def check_all_sensors_before(self, pos, gate, loading=True): + sensors = self.get_sensors_before(pos, gate, loading) + if all(state is None for state in sensors.values()): + return None + return all(state is not False for state in sensors.values()) + + # Returns True if ANY sensor before position detects filament + # None if NO sensors available (disambiguate from non-triggered sensor) + # Can be used as a filament visibility test over a portion of the travel + def check_any_sensors_before(self, pos, gate, loading=True): + sensors = self.get_sensors_before(pos, gate, loading) + if all(state is None for state in sensors.values()): + return None + return any(state is True for state in sensors.values()) + + # Returns True if ALL sensors after position detect filament + # None if NO sensors available (disambiguate from non-triggered sensor) + # Can be used as a "filament continuity test" + def check_all_sensors_after(self, pos, gate, loading=True): + sensors = self.get_sensors_after(pos, gate, loading) + if all(state is None for state in sensors.values()): + return None + return all(state is not False for state in sensors.values()) + + # Returns True if ANY sensor after position detects filament + # None if no sensors available (disambiguate from non-triggered sensor) + # Can be used to validate position + def check_any_sensors_after(self, pos, gate, loading=True): + sensors = self.get_sensors_after(pos, gate, loading) + if all(state is None for state in sensors.values()): + return None + return any(state is True for state in sensors.values()) + + # Returns True if all sensors in current filament path are triggered + # None if no sensors available (disambiguate from non-triggered sensor) + def check_all_sensors_in_path(self): + sensors = self.get_sensors_before(self.mmu.FILAMENT_POS_LOADED, self.mmu.gate_selected) + if all(state is None for state in sensors.values()): + return None + return all(state is not False for state in sensors.values()) + + # Returns True if any sensors in current filament path are triggered (EXCLUDES pre-gate) + # None if no sensors available (disambiguate from non-triggered sensor) + def check_any_sensors_in_path(self): + sensors = self.get_all_sensors_for_gate(self.mmu.gate_selected) + if all(state is None for state in sensors.values()): + return None + return any(state is True for state in sensors.values()) + + # Returns True is any sensors in filament path are not triggered + # None if no sensors available (disambiguate from non-triggered sensor) + # Can be used to spot failure in "continuity" i.e. runout + def check_for_runout(self): + sensors = self.get_sensors_before(self.mmu.FILAMENT_POS_LOADED, self.mmu.gate_selected) + if all(state is None for state in sensors.values()): + return None + return any(state is False for state in sensors.values()) + + # Error with explanation if any filament sensors don't detect filament + def confirm_loaded(self): + sensors = self.get_sensors_before(self.mmu.FILAMENT_POS_LOADED, self.mmu.gate_selected) + if any(state is False for state in sensors.values()): + MmuError("Loaded check failed:\nFilament not detected by sensors: %s" % ', '.join([name for name, state in sensors.items() if state is False])) + + # Return formatted summary of all sensors under management (include all mmu units) + def get_sensor_summary(self, detail=False): + summary = "" + for name, state in self.get_all_sensors(inactive=True).items(): + if state is not None or detail: + sensor = self.all_sensors.get(name) + if name in [self.mmu.SENSOR_PROPORTIONAL]: + # Special case analog sensor + value = sensor.get_status(0).get('value', 0.) + value_raw = sensor.get_status(0).get('value_raw', 0.) + summary += "%s: %.2f" % (name, ("(%.2f, currently disabled)" % value) if state is None else value) + if detail: + summary += " (raw: %.2f)" % (value_raw) + else: + trig = "%s" % 'TRIGGERED' if sensor.runout_helper.filament_present else 'Open' + summary += "%s: %s" % (name, ("(%s, currently disabled)" % trig) if state is None else trig) + if detail and sensor.runout_helper.runout_suspended is not None and state is not None: + summary += "%s" % (", Runout enabled" if not sensor.runout_helper.runout_suspended else "") + summary += "\n" + return summary + + def enable_runout(self, gate): + self._set_sensor_runout(True, gate) + + def disable_runout(self, gate): + self._set_sensor_runout(False, gate) + + def _set_sensor_runout(self, enable, gate): + for name, sensor in self.sensors.items(): + if isinstance(sensor.runout_helper, MmuRunoutHelper): + per_gate = re.search(r'_(\d+)$', name) # Must match mmu_sensors + if per_gate: + sensor.runout_helper.enable_runout(enable and (int(per_gate.group(1)) == gate)) + else: + sensor.runout_helper.enable_runout(enable and (gate != self.mmu.TOOL_GATE_UNKNOWN)) + + # Defines sensors and relationship to filament_pos state for easy filament tracing + def _get_sensors(self, pos, gate, position_condition): + result = {} + if gate >= 0: + # Note: For gear sensor the position of POS_HOMED_GATE is only valid if is not usually triggered (i.e. parking retract) + sensor_selection = [ + (self.get_gate_sensor_name(self.mmu.SENSOR_PRE_GATE_PREFIX, gate), None), + (self.get_gate_sensor_name(self.mmu.SENSOR_GEAR_PREFIX, gate), self.mmu.FILAMENT_POS_HOMED_GATE if self.mmu.gate_homing_endstop == self.mmu.SENSOR_GEAR_PREFIX and self.mmu.gate_parking_distance <= 0 else None), + (self.mmu.SENSOR_GATE, self.mmu.FILAMENT_POS_HOMED_GATE), + (self.mmu.SENSOR_EXTRUDER_ENTRY, self.mmu.FILAMENT_POS_HOMED_ENTRY), + (self.mmu.SENSOR_TOOLHEAD, self.mmu.FILAMENT_POS_HOMED_TS), + ] + for name, position_check in sensor_selection: + sensor = self.sensors.get(name, None) + if sensor and position_condition(pos, position_check): + result[name] = bool(sensor.runout_helper.filament_present) if sensor.runout_helper.sensor_enabled else None + return result # TODO handle bypass and return only EXTRUDER_ENTRY and TOOLHEAD sensors + + def get_sensors_before(self, pos, gate, loading=True): + return self._get_sensors(pos, gate, lambda p, pc: pc is None or (loading and p >= pc) or (not loading and p > pc)) + + def get_sensors_after(self, pos, gate, loading=True): + return self._get_sensors(pos, gate, lambda p, pc: pc is not None and ((loading and p < pc) or (not loading and p <= pc))) + + def get_all_sensors_for_gate(self, gate): + return self._get_sensors(-1, gate, lambda p, pc: pc is not None) + + def get_status(self, eventtime=None): + result = { + name: bool(sensor.runout_helper.filament_present) if sensor.runout_helper.sensor_enabled else None + for name, sensor in self.viewable_sensors.items() + } + return result diff --git a/klippy/extras/mmu/mmu_shared.py b/klippy/extras/mmu/mmu_shared.py new file mode 100644 index 000000000000..af310185543b --- /dev/null +++ b/klippy/extras/mmu/mmu_shared.py @@ -0,0 +1,38 @@ +# Happy Hare MMU Software +# Shared classes +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import sys + +# Default to not using unicode on Python2. Not worth the hassle until klipper drops py2 support! +UI_SPACE, UI_SEPARATOR, UI_DASH, UI_DEGREE, UI_BLOCK, UI_CASCADE = ' ', '.', '-', '^', '*', '-' +UI_BOX_TL, UI_BOX_BL, UI_BOX_TR, UI_BOX_BR = '+', '+', '+', '+' +UI_BOX_L, UI_BOX_R, UI_BOX_T, UI_BOX_B = '+', '+', '+', '+' +UI_BOX_M, UI_BOX_H, UI_BOX_V = '+', '-', '|' +UI_EMOTICONS = ['?', 'A+', 'A', 'B', 'C', 'C-', 'D', 'F'] +UI_SQUARE, UI_CUBE = '^2', '^3' + + +if sys.version_info[0] >= 3: + # Use (common) unicode for improved formatting and klipper layout + UI_SPACE, UI_SEPARATOR, UI_DASH, UI_DEGREE, UI_BLOCK, UI_CASCADE = '\u00A0', '\u00A0', '\u2014', '\u00B0', '\u2588', '\u2514' + # Not all character sets include these so best to use defaults above + # UI_BOX_TL, UI_BOX_BL, UI_BOX_TR, UI_BOX_BR = '\u250C', '\u2514', '\u2510', '\u2518' + # UI_BOX_L, UI_BOX_R, UI_BOX_T, UI_BOX_B = '\u251C', '\u2524', '\u252C', '\u2534' + # UI_BOX_M, UI_BOX_H, UI_BOX_V = '\u253C', '\u2500', '\u2502' + UI_EMOTICONS = [UI_DASH, '\U0001F60E', '\U0001F603', '\U0001F60A', '\U0001F610', '\U0001F61F', '\U0001F622', '\U0001F631'] + UI_SQUARE, UI_CUBE = '\u00B2', '\u00B3' + + +# Mmu exception error class +class MmuError(Exception): + pass diff --git a/klippy/extras/mmu/mmu_sync_controller.py b/klippy/extras/mmu/mmu_sync_controller.py new file mode 100644 index 000000000000..6fcfba9b9928 --- /dev/null +++ b/klippy/extras/mmu/mmu_sync_controller.py @@ -0,0 +1,1692 @@ +# -*- coding: utf-8 -*- +# +# Happy Hare MMU Software +# Sync Feedback Controller +# +# This helper module implements a motion-triggered filament tension controller — that adapts gear +# stepper rotation distance (RD) dynamically based on sensor feedback. It offers modes of operation: +# +# 1) Simple dual level RD selection that works with CO (Compression only switch), +# TO (Tension only switch), and optionally with D (Dual switch) or P (Proportional) sensors. +# +# 2) Combined proportional-derivative (PD) controller with Extended Kalman Filter +# (EKF) for optimal results with P (Proportional) sensor. +# +# Flowguard: It also implements protection for all modes/sensor types that will trigger +# on clog (at extruder) or tangle (at MMU) conditions. +# +# Autotune: An autotuning option can be enabled for dynamic tuning (and persistence) of +# calibrated MMU gear rotation_distance. +# +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# + +import math +import io, json # for debug log + +from collections import deque + +# --- dataclass shim (Py2.7-safe). If real dataclasses are available (Py3.7+), we use them. --- +try: + from dataclasses import dataclass # noqa: F401 +except Exception: + def dataclass(_cls=None, **_kwargs): + """ + Minimal no-op @dataclass shim for Py2.7: + - Leaves attributes as-is (default values taken from class dict). + - If class has no __init__, provide a simple kwargs-based initializer that sets attributes. + """ + def wrap(cls): + if not hasattr(cls, "__init__"): + def __init__(self, **kw): + for k, v in kw.items(): + setattr(self, k, v) + cls.__init__ = __init__ + return cls + return wrap if _cls is None else wrap(_cls) + +# --- math.isclose replacement for Py2.7 --- +def _isclose(a, b, rel_tol=1e-09, abs_tol=0.0): + return abs(a - b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol) + + +# Sync-Feedback sensor type: +# SensorType = Literal["P", "D", "CO", "TO"] +# (drop typing constructs for Py2.7 compatibility) + +# ----------------------------------------------------------------------------- +# SyncControllerConfig reference +# ----------------------------------------------------------------------------- +# +# Mechanics +# - buffer_range_mm (mm) Usable sensor travel that maps linearly to x ∈ [-1,+1]. +# All control logic is normalized by this. Increase if your +# sensor saturates too easily; decrease for a “tighter” x scale. +# - buffer_max_range_mm (mm) Physical clamp of the spring/buffer travel (|x| clipping). +# Must be ≥ buffer_range_mm. Used by the simulator and for +# visualization/safety margins. +# - sensor_type "P" => proportional z ∈ [-1, +1]; uses EKF + PD + KD +# "D" => discrete dual-switch z ∈ {-1,0,+1}; twolevel only +# "CO" => compression-only switch z ∈ {0,+1}; twolevel only +# "TO" => tension_only switch z ∈ {-1,0}; twolevel only +# +# Core lag tuning (readiness r) +# - sensor_lag_mm (mm) Motion required before treating sensor changes as “fresh info”. +# r ramps from 0→1 across this distance (gates smoothing/rates). +# 0 disables gating (r=1 always). +# - info_delta_a For Type-P only: minimum |Δz| to count as “new info”. +# Helps suppress tiny noise from constantly resetting the lag meter. +# +# Gains (PD on x with deadband) +# - kp Proportional gain on x (after deadband). Larger => stronger pull +# toward neutral; too high can oscillate near zero. +# - kd Derivative on x (Type-P only; requires dt>0). Dampens fast x +# changes. Set 0 to disable if your signal is noisy. +# - ctrl_deadband No-action band around x=0. Prevents over-correcting tiny errors. +# +# EKF noises +# - q_x Process noise on x. Larger trusts the model less => faster tracking, +# but noisier estimates. +# - q_c Process noise on c (calibration). Larger lets c drift/learn faster. +# - r_type Measurement noise for Type-P. Larger trusts the sensor less. +# +# Calibration bounds +# - c_min, c_max Hard clamps for c (effective compliance/throughput factor). +# Keep wide enough to cover materials but not so wide that c runs away. +# +# FlowGuard (distance-based) +# - flowguard_extreme_threshold Threshold in x or z treated as “pegged” (≈ jam/tangle). +# Used for detection, readiness floor, and relief logic. +# - flowguard_relief_mm (mm) Required accumulated “relief” motion to prove we tried to +# correct an extreme. If None, defaults to buffer_max_range_mm. USER EXPOSED +# +# Rotation distance +# - rd_start (mm) Default/persisted baseline RD. Used as mirror reference for mapping +# - rd_min_max_speed_multiplier Allowed RD bounds based on % speed +# - rd_twolevel_speed_multiplier Min/Max RD based on % speed for twolevel operation USER EXPOSED +# - rd_twolevel_boost_multiplier Extra boost speed USER EXPOSED +# +# Distance-based smoothing & slew +# - rd_filter_len_mm (mm) Exponential smoothing length vs extruder motion. +# Alpha = 1 - exp(-|Δmm| / L). Larger L = slower RD changes. +# - rd_rate_per_mm Hard rate limit on |ΔRD| per mm of motion (scaled by readiness r). +# None disables. Works together with rd_filter_len_mm; the tighter one wins. +# +# Extreme behavior +# - readiness_extreme_floor Minimum readiness r when the sensor/estimate is pegged. Ensures +# RD can change quickly enough under clear faults. +# - rate_extreme_multiplier Multiplier on rd_rate_per_mm when pegged (speed up corrections). +# - snap_at_extremes If True, apply relief-biased snap when pegged +# per update) to move away from the peg. +# - extreme_relief_frac Fraction of |d_ext| used to compute a relief RD step each update +# when snap_at_extremes is active. Typical 0.15–0.35. +# +# Autotune +# EKF logic: +# - autotune_stable_x_thresh Consider “near neutral” if |x| ≤ this. +# Determines when we accumulate samples for autotune. +# - autotune_stable_time_s Minimum time spent near neutral before we consider autotuning. +# - autotune_basis "time" | "motion" | "either" | "both" — which tests must pass. +# - autotune_motion_mm Motion near neutral required if basis uses motion. +# considered too small to avoid recommending trivial changes. +# - autotune_var_rel_frac Max allowed std(speed) near neutral required for autotune to propose an update +# - autotune_var_len_mm Distance over which to estimate RD mean/variance during the near-neutral “stable” window. +# Twolevel logic: +# - autotune_significance_z Z-score tests for twolevel estimator (0 disables, 2≈95% confidence). +# Shared logic: +# - autotune_cooldown_s/mm Minimum time/motion since the last autotune before another suggestion. +# +# Tuning tips: +# - If RD reacts too sluggishly in normal operation, decrease rd_filter_len_mm and/or increase +# rd_rate_per_mm (watch stability near neutral). +# - If you see chatter near x=0, reduce kp and/or kd, or increase r_type. +# - If FlowGuard trips too early, raise flowguard_relief_mm. +# - If autotune fires too often, increase the cooldowns; if it +# never fires, reduce autotune_stable_time_s and/or autotune_motion_mm. + +# -------------------------- Config dataclass (annotation-free) ------------------------- +@dataclass +class SyncControllerConfig(object): + # Logging + log_sync = False # whether to create log of every tick for debugging purposes + log_file = "/tmp/sync.jsonl" # debugging/plotting json log + + # Mechanics + buffer_range_mm = 8.0 # sensor usable travel (maps to normalized [-1,+1]) + buffer_max_range_mm = 14.0 # physical max travel (spring clamp) ≥ buffer_range_mm + sensor_type = "D" # "P" | "D" | "CO" | "TO" + + # Core lag tuning (readiness r) + sensor_lag_mm = 0.0 # expected motion to see new info; 0 => no lag gating (r=1) + info_delta_a = 0.08 # Type-P: min sensor delta to count as "new info" + + # Gains (PD on x with deadband) + kp = 0.5 + kd = 0.4 # derivative term (used for Type-P) + ctrl_deadband = 0.1 # neutral deadband for PD around x=0 + + # EKF noises + q_x = 1e-3 + q_c = 5e-5 + r_type = 2.5e-2 + + # Calibration bounds + c_min = 0.25 + c_max = 4.0 + + # FlowGuard (distance-based) + flowguard_extreme_threshold = 0.9 + flowguard_relief_mm = None + + # Rotation distance + rd_start = 20.0 # initial baseline (previous calibrated value) + rd_min_max_speed_multiplier = 0.25 # ±25% speed + rd_twolevel_speed_multiplier = 0.05 # ±5% speed + rd_twolevel_boost_multiplier = 0.05 # ±5% extra boost speed + + # Distance-based smoothing & slew + rd_filter_len_mm = 25.0 # exp smoothing length (mm of extruder motion for ~63% step @ r=1) + rd_rate_per_mm = 0.10 # per-mm hard rate limit on ΔRD (scaled by readiness) + + # Extreme behavior control + readiness_extreme_floor = 0.7 # when pegged, raise r to at least this + rate_extreme_multiplier = 2.0 # multiply rate cap when pegged + snap_at_extremes = True # enable relief-biased snap when pegged + extreme_relief_frac = 0.25 # fraction of |d_ext| of guaranteed relief per update + + # EKF autotune logic tests + autotune_stable_x_thresh = 0.12 + autotune_stable_time_s = 4.0 + autotune_basis = "both" + autotune_motion_mm = None + autotune_var_rel_frac = 0.004 # allow ≈0.4% relative speed std + autotune_var_len_mm = None + + # Twolevel logic tests + autotune_significance_z = 1.0 # z-score (twolevel confidence) threshold to accept new RD (0 disables, 1≈68%, 2≈96%) + + # Shared tests + autotune_cooldown_s = 10.0 + autotune_cooldown_mm = 100.0 + autotune_min_save_frac = 0.001 # Only consider saving if > ≈0.1% speed change from last persisted value + + # Certainty tracking of rd recommendations + autotune_cert_window = 8 # fifo length of rd certainty scores + autotune_cert_tau_rel = 0.01 # target relative SE (e.g. 1%) + autotune_cert_n0 = 3.0 # prior sample penalty + autotune_cert_hysteresis = 0.001 # min score improvement to accept + + os_min_flip_mm = 0.0 # minimum motion between flips (anti-chatter) + + # Optional two-level for P type sensors + use_twolevel_for_type_p = None # True/False to force option for type-P sensors + p_twolevel_threshold = 0.80 # P extreme if z>=+thr or z<=-thr + p_twolevel_hysteresis = 0.2 # shrink threshold by this when exiting a twolevel extreme + + # dataclass shim doesn’t call __post_init__ automatically on Py2; do the work in __init__ + def __init__(self, **kw): + # apply defaults from class dict + for k, v in self.__class__.__dict__.items(): + if not k.startswith("_") and not callable(v): + setattr(self, k, v) + # apply user overrides + for k, v in kw.items(): + setattr(self, k, v) + + # --- begin original __post_init__ logic --- + if self.buffer_range_mm <= 0: + raise ValueError("buffer_range_mm must be > 0") + if self.buffer_max_range_mm <= 0: + raise ValueError("buffer_max_range_mm must be > 0") + if self.buffer_max_range_mm < self.buffer_range_mm: + raise ValueError("buffer_max_range_mm must be ≥ buffer_range_mm") + + # Autotune window defaults + if self.autotune_motion_mm is None: + self.autotune_motion_mm = 3.0 * self.rd_filter_len_mm + if self.autotune_var_len_mm is None: + self.autotune_var_len_mm = 1.8 * self.rd_filter_len_mm + + # FlowGuard relief threshold (how much "counter-effort" must be proven) + if self.flowguard_relief_mm is None: + mult = 0.3 if self.sensor_type in ['P'] else 0.7 + self.flowguard_relief_mm = max(mult * self.buffer_range_mm, self.buffer_max_range_mm) + + +# ------------------------------- EKF State ------------------------------ +@dataclass +class EKFState(object): + """ + EKF state for [x, c] with covariance. + """ + x = 0.0 + c = 1.0 + P11 = 0.5 + P12 = 0.0 + P22 = 0.2 + x_prev = 0.0 + + def __init__(self): + # Attributes already have defaults + pass + + +# ------------------------------ Autotune Engine ------------------------- +class _AutotuneEngine(object): + """ + Helper object that owns *all* autotune bookkeeping and decisions + """ + + def __init__(self, ctrl): + self.ctrl = ctrl + + # Core counters and state + self._total_motion_mm = 0.0 + self._total_time_s = 0.0 + self._paused = False + + # PD window stats + self._stable_time = 0.0 + self._stable_motion_mm = 0.0 + self._rd_ema_mean = None + self._rd_ema_var = 0.0 + + # Autotune anchors & cooldown trackers + self._autotune_last_time_s = -1e12 + self._autotune_last_motion_mm = -1e12 + self._autotune_baseline = self.ctrl.rd_ref # Persisted rd setting + self._autotune_current = self.ctrl.rd_ref # Current recommendation + self._autotune_min_cert_score = 0.5 # Don't recommend persist if less than this score + + # Suggestion tracking + self._rd_cert_fifo = deque(maxlen=int(max(1, ctrl.cfg.autotune_cert_window))) + self._rd_cert_last_score = -1.0 + + # Two-level estimator buckets & evidence + self._tl_flips = 0 + self._tl_updates_since_flip = 0 + + # Segment/cycle tracking for two-level duty estimator + self._tl_seg_level = None # "low" / "high" + self._tl_seg_mm = 0.0 # +ve (prevailing extrude) or -ve (prevailing retract) + self._tl_seg_mm_extreme = 0.0 # +ve (prevailing extrude) or -ve (prevailing retract) + self._tl_samples_low = [] # FIFO of extruder distances traveled while in "low" state + self._tl_samples_high = [] # FIFO of extruder distances traveled while in "high" state + self._tl_last_unpaired_low = None + self._tl_last_unpaired_high = None + self._tl_cycles = [] # List of (low_mm, high_mm) + self._tl_seg_window = 6 # Moving window per level + self._tl_cycle_window = 4 # Moving window of paired cycles + if ctrl.cfg.sensor_type in ['CO', 'TO']: + self._tl_min_cycles = 4 # Required minimum number of samples + else: + self._tl_min_cycles = 2 # Less because using full "buffer_range" + + # -------------------------------- API ----------------------------------- + + def restart(self, rd_init, reset_totals=True, reset_cooldown=True, reset_confidence=True): + """ + Rebase all autotune anchors/windows on a fresh baseline for new starting rd value + - Cooldown timers are either reset-to-now (default) or to a large negative origin + - Total counts are optionally reset + """ + self._autotune_current = rd_init + self._paused = False + + # Reset or preserve cooldown origins + if reset_cooldown: + self._autotune_last_time_s = self._total_time_s + self._autotune_last_motion_mm = self._total_motion_mm + else: + self._autotune_last_time_s = -1e12 + self._autotune_last_motion_mm = -1e12 + + # Suggestion tracking + if reset_confidence: + self._rd_cert_fifo = deque(maxlen=int(max(1, self.ctrl.cfg.autotune_cert_window))) + self._rd_cert_last_score = -1.0 + + # Reset or preserve core counters + if reset_totals: + self._total_motion_mm = 0.0 + self._total_time_s = 0.0 + + # PD window stats + self._stable_time = 0.0 + self._stable_motion_mm = 0.0 + + # Two-level estimator buckets & evidence + self._tl_flips = 0 + self._tl_updates_since_flip = 0 + + # Clear segment/cycle tracking for two-level duty estimator + self._tl_seg_level = None + self._tl_seg_mm = 0.0 + self._tl_seg_mm_extreme = 0.0 + self._tl_samples_low = [] + self._tl_samples_high = [] + self._tl_last_unpaired_low = None + self._tl_last_unpaired_high = None + self._tl_cycles = [] + + + def pause(self): + """ + Called to pause autotune generally because we received a large retract or we know + we are going to do an extended retract (tuning only work reliably in a extruder direction) + or we are performing movement that is known to cause underextrusion (like blobifer purge). + """ + if not self._paused: + self._paused = True + + + def resume(self): + """ + Resume autotune monitoring. We have to perform a soft reset. + """ + if self._paused: + self.restart(self._autotune_current, reset_totals=False, reset_cooldown=False, reset_confidence=False) + + + def note_twolevel_tick(self, os_level, flipped, d_ext, is_extreme=False): + """ + Called once per update_autotune() in two-level branch to keep buckets/evidence up-to-date. + """ + if self._paused: return + + cfg = self.ctrl.cfg + + # Flip handling + if flipped: + self._tl_updates_since_flip = 0 + self._tl_flips += 1 + else: + self._tl_updates_since_flip += 1 + + # Only accumulate segments after the first flip to remove startup conditions + if self._tl_flips < 1: + return + + # Accumulate current segment distance + self._tl_seg_mm += d_ext + if is_extreme: + self._tl_seg_mm_extreme += d_ext + + # On flip: close previous segment (if started) and store sample + if flipped: + seg_level = self._tl_seg_level + seg_mm = abs(self._tl_seg_mm) + + if seg_level == "low": + self._tl_samples_low.append(seg_mm) + if len(self._tl_samples_low) > self._tl_seg_window: + self._tl_samples_low.pop(0) + # Pair with existing high if available + if self._tl_last_unpaired_high is not None: + self._tl_cycles.append((seg_mm, self._tl_last_unpaired_high)) + if len(self._tl_cycles) > self._tl_cycle_window: + self._tl_cycles.pop(0) + self._tl_last_unpaired_high = None + else: + self._tl_last_unpaired_low = seg_mm + + elif seg_level == "high": + self._tl_samples_high.append(seg_mm) + if len(self._tl_samples_high) > self._tl_seg_window: + self._tl_samples_high.pop(0) + # Pair with existing low if available + if self._tl_last_unpaired_low is not None: + self._tl_cycles.append((self._tl_last_unpaired_low, seg_mm)) + if len(self._tl_cycles) > self._tl_cycle_window: + self._tl_cycles.pop(0) + self._tl_last_unpaired_low = None + else: + self._tl_last_unpaired_high = seg_mm + + # Start new segment for the new level + self._tl_seg_level = os_level + self._tl_seg_mm = 0.0 + self._tl_seg_mm_extreme = 0.0 + + + def update_autotune(self, d_ext, dt_s, report_trivial=False): + """ + On sensor update, recommend rd update based on mode: + - If two-level mode is active (CO/TO/D, or P with use_twolevel_for_type_p=True), + only query the two-level estimator. + - Otherwise, only query the PD near-neutral window. + If rd is recommended, run through shared statistical tests + """ + status = {"rd": None, "note": "", "save": False} + + if self._paused: + status["note"] = "Autotune: Paused" + return status + + cfg = self.ctrl.cfg + + # Track time/movement + self._total_time_s += max(0.0, float(dt_s)) + self._total_motion_mm += abs(float(d_ext)) + travel = "@{:.0f}s/{:.0f}mm".format(self._total_time_s, self._total_motion_mm) + + # Cooldown - sufficient motion/time since last save + since_mm = self._total_motion_mm - self._autotune_last_motion_mm + since_s = self._total_time_s - self._autotune_last_time_s + req_mm = cfg.autotune_cooldown_mm + req_s = cfg.autotune_cooldown_s + if since_mm < req_mm or since_s < req_s: + return status + + if self.ctrl.twolevel_active: + rec_rd, note = self._recommend_rd_from_twolevel() + else: + rec_rd, note = self._recommend_rd_from_ekf_path(d_ext, dt_s) + + # No recommendation but optional reject note + if rec_rd is None: + status["note"] = "Autotune: {} {}".format(travel, note) if note else "" + return status + + # Perform final shared checks on recommendation... + + if not (self.ctrl.rd_low <= rec_rd <= self.ctrl.rd_high): + status["note"] = "Autotune: {} Rejected rd {:.4f} because out of bounds!".format(travel, rec_rd) + return status + + # This makes is progressively harder to accept autotune + rec_rd, _note = self._autotune_confident(rec_rd) + if rec_rd is None: + status["note"] = "Autotune: {} {}".format(travel, _note) if _note else "" + return status + + # Do nothing on truly trivial changes + if not report_trivial and _isclose(rec_rd, self._autotune_current, abs_tol=1e-3): + status["note"] = "Autotune: {} Rejected rd {:.4f} because too trivial a delta".format(travel, rec_rd) + return status + + # We have new tuned rd value... + self._autotune_current = rec_rd + status["rd"] = rec_rd + status["note"] = "Autotune: {} {} and {}".format(travel, note, _note) + + # Should we recommend saving as new default reference? + if self._rd_cert_last_score >= self._autotune_min_cert_score: + frac = self._frac_speed_delta(rec_rd, self._autotune_baseline) + min_frac = cfg.autotune_min_save_frac + if frac >= min_frac: + self._autotune_baseline = rec_rd + status["save"] = True + + self.restart(rec_rd, reset_totals=False, reset_cooldown=False, reset_confidence=False) + return status + + + def twolevel_phase(self, exclude_extreme=False): + """ + Return (level, phase) for current two-level segment where + phase is measured between extremes (extreme motion removed). + Return None if we don't have enough evidence yet. + phase : progress in [0,1] within current segment (distance-based) + level : "low" | "high" (segment we're currently in) + extruding : true if extruding + """ + level = self._tl_seg_level + if level is None: + return None + + samples = self._tl_samples_high if level == "high" else self._tl_samples_low + if not samples: + return None + + mean_len = sum(samples) / float(len(samples)) + travel = abs(self._tl_seg_mm) + if exclude_extreme: + travel -= abs(self._tl_seg_mm_extreme) + mean_len -= abs(self._tl_seg_mm_extreme) + phase = max(0.0, min(1.0, travel / max(1e-6, mean_len))) + + return phase, level, self._tl_seg_mm > 0 + + + def get_rec_rd(self): + """ + Return the current recommended RD + """ + return self._autotune_current + + def get_tuned_rd(self): + """ + Return the last tuned RD. Initially this is the starting value + """ + return self._autotune_baseline + + + # ---------------------------- Internal Impl ----------------------------- + + + def _recommend_rd_from_ekf_path(self, d_ext, dt_s): + """ + Autotune baseline RD using EKF path statistics gathered near neutral. + Returns: Tuple (rec_rd|None, note|None) + """ + cfg = self.ctrl.cfg + + # Stability tests near neutral + stability_test = abs(self.ctrl.state.x) < cfg.autotune_stable_x_thresh + + # Accrue stable time/motion + move = abs(d_ext) + if stability_test: + self._stable_time += dt_s + self._stable_motion_mm += move + + # if move == 0.0: leave EMA unchanged this tick + if move > 0.0: + L = max(1e-9, cfg.autotune_var_len_mm) + alpha = 1.0 - math.exp(-move / L) + + # --- EMA in SPEED space: v = 1 / rd_current --- + rd_curr = max(1e-9, float(self.ctrl.rd_current)) + v = 1.0 / rd_curr + + if self._rd_ema_mean is None: + # Seed on first accepted sample (now in speed space) + self._rd_ema_mean = v + self._rd_ema_var = 0.0 + else: + # EWMA mean + West's EW variance, in speed space + m_prev = self._rd_ema_mean + d = v - m_prev + m_new = m_prev + alpha * d + v_new = (1.0 - alpha) * (self._rd_ema_var + alpha * d * d) + + self._rd_ema_mean = m_new + self._rd_ema_var = max(0.0, v_new) + + else: + # Leaving stable test -> drop stats so we don't carry junk + self._stable_time = 0.0 + self._stable_motion_mm = 0.0 + self._rd_ema_mean = None + self._rd_ema_var = 0.0 + + if self._rd_ema_mean is None: + return None, None + + time_ok = (self._stable_time >= cfg.autotune_stable_time_s) + motion_ok = (self._stable_motion_mm >= (cfg.autotune_motion_mm or 0.0)) + if cfg.autotune_basis == "time": + ready = time_ok + elif cfg.autotune_basis == "motion": + ready = motion_ok + elif cfg.autotune_basis == "either": + ready = time_ok or motion_ok + else: + ready = time_ok and motion_ok + if not ready: + return None, None + + # --- Interpret EMA as SPEED stats, then map back to RD --- + mean_v = max(self._rd_ema_mean, 1e-12) # mean speed + var_v = max(0.0, self._rd_ema_var) # variance of speed + + # Speed-relative variance test: std(speed)/mean(speed) ≤ f + f = cfg.autotune_var_rel_frac + std_v = math.sqrt(var_v) + rel_std_v = std_v / mean_v if mean_v > 0 else float("inf") + if rel_std_v > f: + # Convert mean_v back to rd for reporting + mean_rd_for_note = 1.0 / mean_v + note = "Rejected rd {:.4f} due to speed-relative variance {:.4f} > {:.4f}".format(mean_rd_for_note, rel_std_v, f) + return None, note + + # Potential new candidate: + # Convert mean speed back to an rd estimate + mean_rd = 1.0 / mean_v + note = u"EKF logic suggests rd≈{:.4f} after {:.1f}s/{:.1f}mm near neutral".format(mean_rd, self._stable_time, self._stable_motion_mm) + return mean_rd, note + + + def _recommend_rd_from_twolevel(self): + """ + Minimal statistical baseline update for two-level mode for CO/TO sensor types or + optionally P/D types if configured in twolevel mode. + Returns: Tuple (rec_rd|None, note|None) + """ + cfg = self.ctrl.cfg + + # Only evaluate *on flips* (state changes) + if self._tl_updates_since_flip != 0: + return None, None + + # Require min segment samples per state and min total cycles + n_low = len(self._tl_samples_low) + n_high = len(self._tl_samples_high) + n_cycles = len(self._tl_cycles) + if n_low < self._tl_min_cycles or n_high < self._tl_min_cycles or n_cycles < self._tl_min_cycles: + return None, None + + # Compute per-cycle fractions for variance (fh_list) and ratio-of-sums for mean duty + fh_list = [] + dl_sum = 0.0 + dh_sum = 0.0 + for (dl, dh) in self._tl_cycles[-self._tl_cycle_window:]: + tot = max(1e-12, dl + dh) + fh_list.append(dh / tot) # per-cycle fraction (for variance) + dl_sum += dl + dh_sum += dh + + if not fh_list: + return None, None + + # Duty (mean) via ratio-of-sums + tot_sum = max(1e-12, dl_sum + dh_sum) + fh_mean = dh_sum / tot_sum + + # Duty-weighted *speed* estimate, then map back to RD to remove RD-space bias + v_low = 1.0 / max(1e-9, self.ctrl.rd_low) + v_high = 1.0 / max(1e-9, self.ctrl.rd_high) + v_est = (1.0 - fh_mean) * v_low + fh_mean * v_high + rd_est = 1.0 / max(1e-9, v_est) + + # rd_est significance test via z-score from variability of fh across cycles to see if it + # statistically distinguishable from the baseline given the observed variability + z = None + if cfg.autotune_significance_z > 0.0 and len(fh_list) >= 2: + # Sample std of fh across cycles + mu = sum(fh_list) / float(len(fh_list)) + var_f = sum((f - mu) ** 2 for f in fh_list) / float(max(1, len(fh_list) - 1)) + std_f = math.sqrt(var_f if var_f > 0.0 else 0.0) + se_f = (std_f / math.sqrt(len(fh_list))) if len(fh_list) > 0 else float("inf") + + # Propagate fh uncertainty through rd = 1 / ((1-f)/rd_low + f/rd_high) + # drd/df = rd^2 * (1/rd_low - 1/rd_high) = rd^2 * (v_low - v_high) + sensitivity = (rd_est ** 2) * abs(v_low - v_high) + se_rd = sensitivity * se_f # Std error in rd + + if se_rd >= 1e-9: + z = abs(rd_est - self._autotune_current) / se_rd + if z < float(cfg.autotune_significance_z): + note = ("Rejected rd {:.4f} because z-score {:.2f} not significant (<{:.2f})").format(rd_est, z, cfg.autotune_significance_z) + return None, note + # else: se_rd ~ 0 => treat as pass (perfect/no-variance case) + + # Potential new candidate + score = ("%.2f" % z) if z is not None else "perfect" + note = (u"Two-level logic suggests rd≈{:.4f} (duty {:.2f} over {} cycles, z-score={})").format(rd_est, fh_mean, len(fh_list), score) + return rd_est, note + + + def _certainty_score(self, samples, tau_rel=0.01, n0=3.0, eps=1e-12): + """ + Certainty in [0,1]. Higher = more certain. + - tau_rel: target relative SE; smaller => stricter (e.g., 0.01 = 1%) + - n0: prior sample penalty; larger => more skepticism with small n + Returns: (score, mean, se, n) + """ + vals = [float(v) for v in samples if v is not None] + n = len(vals) + if n == 0: + return 0.0, None, None, 0 + + m = sum(vals) / float(n) + + if n >= 2: + mean_sq = sum(v*v for v in vals) / float(n) + var = max(0.0, mean_sq - m*m) * n / float(max(1, n - 1)) # unbiased + s = math.sqrt(var) + else: + s = 0.0 + + se = s / math.sqrt(n) if n > 0 else float('inf') + rel_se = se / max(abs(m), eps) if m != 0.0 else float('inf') + + # Precision shrinks as relative SE grows; bounded (0,1] + prec = 1.0 / (1.0 + (rel_se / max(tau_rel, eps))) + + # Sample-size prior; bounded [0,1) + size = n / (n + float(n0)) + + # Enforce "n<2 -> effectively no certainty" + if n < 2: + prec = 0.0 + + score = prec * size + return score, m, se, n + + def _frac_speed_delta(self, rd_new, rd_ref): + # |v(new)-v(ref)| / v(ref) with v = 1/rd => |rd_ref/rd_new - 1| + return abs((rd_ref / max(1e-9, rd_new)) - 1.0) + + def _autotune_confident(self, rec_rd): + """ + All speed-relative: + - min fractional speed delta vs last saved value + Returns: tuple(True|False, reason) + """ + cfg = self.ctrl.cfg + + # Require ever increasing certainty score (std error + n) + tau_rel = cfg.autotune_cert_tau_rel + n0 = cfg.autotune_cert_n0 + hyster = cfg.autotune_cert_hysteresis + + # Push proposal and score the window + self._rd_cert_fifo.append(rec_rd) + score, mean, se, n = self._certainty_score(self._rd_cert_fifo, tau_rel=tau_rel, n0=n0) + prev = self._rd_cert_last_score + threshold = 0 if prev == 0 else max(prev + hyster, 0) + improved = score > threshold + + if not improved: + if prev < 0: + self._rd_cert_last_score = 0. + note = "Rejected new rd {:.4f} due to certainty score of zero (n={})".format(rec_rd, n) + else: + note = "Rejected new rd {:.4f} due to certainty score {:.3f} ≤ prev {:.3f} (n={})".format(rec_rd, score, prev, n) + return None, note + + self._rd_cert_last_score = score + note = "with certainty score of {:.3f} (prev {:.3f}), n={}, mean {:.4f}, SE {:.4f}".format( + score, prev, n, mean if mean is not None else float('nan'), se if se is not None else float('nan')) + return mean, note + +# ----------------------------- Flowguard Engine ------------------------- + +class _FlowguardEngine(object): + """ + Encapsulates FlowGuard state and logic. Determines based on total filament movement + and amount of rd correction applied if a clog or tangle is likely to have occurred. + A reason string explains the reason for the trigger. + A single update_flowguard() entry point to be called on each tick. + """ + + def __init__(self, ctrl): + self.ctrl = ctrl + self.reset() + + # -------------------------------- API ----------------------------------- + + def reset(self): + # Accumulators + self._comp_motion_mm = 0.0 + self._tens_motion_mm = 0.0 + self._relief_comp_mm = 0.0 + self._relief_tens_mm = 0.0 + + # Condition monitoring + self._trigger = "" + self._reason = "" + self._level = 0.0 + self._max_clog = 0.0 + self._max_tangle = 0.0 + self._relief_headroom = 0.0 # Debugging + + # FlowGuard arming test + self._armed = False # Disarmed until a state change while moving or verified near neutral + self._arm_motion_mm = 0.0 # Motion since last (or initial) state sample + self._arm_last_state = None + + def update_flowguard(self, d_ext, sensor_reading): + """ + Distance-based FlowGuard with symmetric handling for one-sided switches. + + - For P/D sensors: + Uses controller._extreme_flags() on the sensor reading. + - For CO/TO sensors: + Uses the sensor directly for the *seen* side, and infer the unseen side + CO (compression-only): unseen = TENSION; relief effort is COMPRESSION (delta_rel > 0) + TO (tension-only) : unseen = COMPRESSION; relief effort is TENSION (delta_rel < 0) + Returns: Status Dict {"trigger", "reason", ...} + """ + cfg = self.ctrl.cfg + effort = self._relief_effort(d_ext) # +ve => compression effort, -ve => tension effort + + # Get the current sensor state for FlowGuard purposes (CO/TO are always at extreme) + state_now = self._flowguard_polarity(sensor_reading) + comp_ext, tens_ext = (state_now == 1, state_now == -1) + + # Arming logic to prevent false triggers on startup if thresholds are tight + self._arm_motion_mm += d_ext + state_now = self.ctrl._extreme_polarity(sensor_reading) + if self._arm_last_state is None: + self._arm_last_state = state_now + + self._relief_headroom = cfg.flowguard_relief_mm + + if not self._armed: + # Arm when we've moved and observed any change in coarse state or know we are near neutral + changed_state = (state_now != self._arm_last_state) + moved = abs(self._arm_motion_mm) > 0.0 + near_neutral = cfg.sensor_type in ("P") and abs(sensor_reading) < cfg.autotune_stable_x_thresh + if moved and (changed_state or near_neutral): + self._armed = True + else: + return self.status() + self._arm_last_state = state_now + + if comp_ext: # Extreme Compression + self._comp_motion_mm += d_ext + + # Relief for compression is *tension* effort (delta_rel < 0) + if effort < 0: + self._relief_comp_mm += (-effort) + + comp_relief_trig = (abs(self._relief_comp_mm) >= cfg.flowguard_relief_mm) + self._relief_headroom -= self._relief_comp_mm + + if comp_relief_trig and not self._trigger: + self._trigger = "clog" + self._reason = "Compression stuck after %.2f mm motion and %.2f mm relief (triggering parameter: flowguard_max_relief)" % ( + self._comp_motion_mm, self._relief_comp_mm + ) + + # Maintain normalized [0..1] clog headroom marker + mcr = abs(self._relief_comp_mm / cfg.flowguard_relief_mm) + c_level = min(1.0, mcr) + self._level = c_level + if c_level > self._max_clog: + self._max_clog = c_level + + # Reset the tension side when compression extreme is active + self._tens_motion_mm = 0.0 + self._relief_tens_mm = 0.0 + + elif tens_ext: # Extreme Tension + self._tens_motion_mm += d_ext + + # Relief for tension is *compression* effort (delta_rel > 0) + if effort > 0: + self._relief_tens_mm += effort + + tens_relief_trig = (abs(self._relief_tens_mm) >= cfg.flowguard_relief_mm) + self._relief_headroom -= self._relief_tens_mm + + if tens_relief_trig and not self._trigger: + self._trigger = "tangle" + self._reason = "Tension stuck after %.2f mm motion and %.2f mm relief (triggering parameter: flowguard_max_relief)" % ( + self._tens_motion_mm, self._relief_tens_mm + ) + + # Maintain normalized [0..-1] tangle headroom marker + mtr = -abs(self._relief_tens_mm / cfg.flowguard_relief_mm) + t_level = max(-1.0, mtr) + self._level = t_level + if self._level < self._max_tangle: + self._max_tangle = t_level + + # Reset the compression side when tension extreme is active + self._comp_motion_mm = 0.0 + self._relief_comp_mm = 0.0 + + else: # No extreme: reset both sides + self._comp_motion_mm = 0.0 + self._relief_comp_mm = 0.0 + self._tens_motion_mm = 0.0 + self._relief_tens_mm = 0.0 + + return self.status() + + def status(self): + s = { + "active": self._armed, + "level": self._level, + "max_clog": self._max_clog, + "max_tangle": self._max_tangle, + "trigger": self._trigger, + "reason": self._reason, + } + + # When debug logging + if self.ctrl.cfg.log_sync: + s.update({ + "relief_headroom": self._relief_headroom, + }) + + return s + + def _relief_effort(self, d_ext): + """ + Signed relief 'effort' this tick (mm-equivalent). + Positive => compression effort, negative => tension effort. + + Baseline is autotune._autotune_current, i.e. the *tuned* RD: + - In twolevel mode, this matches the rd_ref we recenter around. + - In EKF mode, this is the learned "true" RD, even if rd_ref remains the + originally persisted value. + """ + rd_ref = self.ctrl.autotune.get_rec_rd() # Starts at self.ctrl.rd_ref + rd_cur = self.ctrl.rd_current + if abs(rd_cur) < 1e-9: + return 0.0 + return d_ext * ((rd_ref / rd_cur) - 1.0) + + def _flowguard_polarity(self, sensor_reading): + """ + FlowGuard-only coarse polarity. + + For CO/TO, treat OPEN as an extreme on the unseen side so FlowGuard tracks immediately: + CO: z==1 -> +1 (compression), z==0 -> -1 (tension-as-open) + TO: z==-1 -> -1 (tension), z==0 -> +1 (compression-as-open) + + For P/D, defer to controller polarity. + """ + cfg = self.ctrl.cfg + if cfg.sensor_type == "CO": + return 1 if int(sensor_reading) == 1 else -1 + if cfg.sensor_type == "TO": + return -1 if int(sensor_reading) == -1 else 1 + return self.ctrl._extreme_polarity(sensor_reading) + + +# -------------------------- Controller Core ---------------------------- + +class SyncController(object): + """ + Movement-triggered filament tension controller. + + update(eventtime, extruder_delta_mm, sensor_reading): + - Propagates EKF with motion & measurement + - Computes desired effective gear motion to pull x→0 (PD with derivative if Type-P) + - Converts to RD target via configurable gear mapping (symmetric/asymmetric), then distance-smoothed + - Relief-biased snap when sensor pegged + - Neutral trim near zero + - FlowGuard detection + - Autotune of baseline RD (time/motion near neutral, or two-level duty estimator) + """ + + def __init__(self, cfg, c0= 1.0, x0=None): + self.cfg = cfg + self._set_twolevel_active() + + self._tick = 0 + self._last_time_s = None + self._log_ready = False + + self.K = 2.0 / cfg.buffer_range_mm # mm => normalized delta in x + self.state = EKFState() + self.state.c = max(cfg.c_min, min(cfg.c_max, c0)) + if x0 is not None: + self.state.x = max(-1.0, min(1.0, x0)) + + rd_init = float(cfg.rd_start) + self.rd_current = rd_init # Current rd in effect + self.rd_ref = rd_init # Last "tuned" rd + + # Allows initial wider range of rd until first autotune candidate + self._twolevel_boost_active = True + + self._twolevel_hys_state = 0 # -1, 0, +1 (last hysteretic extreme for type-P) + + # Set absolute limits for rd range + self._set_min_max_rd(rd_init) + + # Readiness (lag-aware) + self._mm_since_info = 0.0 + self._last_info_z = None + + # UI visualization + self._vis_est = 0.0 + + # Two-level flip-flop state (reused for CO/TO and optional P/D) + self._os_target_level = "low" # "low" or "high" + self._os_since_flip_mm = 0.0 + + # FlowGuard engine (encapsulates non-shared FlowGuard state/logic) + self.flowguard = _FlowguardEngine(self) + + # Autotune helper (encapsulates all autotune state/logic) + self.autotune = _AutotuneEngine(self) + + + # ------------------------------------ PUBLIC API ------------------------------------ + + def reset(self, eventtime, rd_init, sensor_reading, log_file=None, hard_reset=True, simulation=False): + """ + Full controller reset for a gear motor swap or new cold start. + Seeds internal time to `t_s` and zeroes elapsed time. + """ + cfg = self.cfg + self._set_twolevel_active() + + self._log_ready = False + self._current_log_file = log_file or cfg.log_file + + # Rotation distance & baseline (always rebase) + self.rd_current = rd_init + self.rd_ref = rd_init + self._twolevel_boost_active = True + self._set_min_max_rd(rd_init) + + # Seed x_hat from sensor reading + if cfg.sensor_type == "P": + z = float(sensor_reading) + x0 = max(-1.0, min(1.0, z)) + self._twolevel_hys_state = int(math.copysign(1, x0)) if abs(x0) >= 1e-6 else 0 + + else: + z = int(sensor_reading) + z = 1 if z > 0 else (-1 if z < 0 else 0) + x0 = float(z) + + # EKF state & covariance + if cfg.sensor_type == "P" and not self.twolevel_active: + self.state.x = float(x0) + self.state.x_prev = self.state.x + self.state.c = 1.0 + self.state.P11 = 0.5 + self.state.P12 = 0.0 + self.state.P22 = 0.2 + + # Readiness (lag-aware) + self._mm_since_info = 0.0 + self._last_info_z = float(x0) if cfg.sensor_type == "P" else int(round(x0)) + + # Time (for update()) + self._tick = 0 + self._last_time_s = eventtime + + # For UI + self._vis_est = float(sensor_reading) + + # Two-level init for CO/TO + if self.cfg.sensor_type in ("CO", "TO"): + in_contact0 = self._onesided_contact(sensor_reading) + if self.cfg.sensor_type == "CO": + self._os_target_level = "high" if in_contact0 else "low" + else: + self._os_target_level = "low" if in_contact0 else "high" + self._os_since_flip_mm = 0.0 + + # Two-level init for P/D (optional) + if self.cfg.sensor_type in ("P", "D") and self.twolevel_active: + pol0 = self._extreme_polarity(sensor_reading) + if pol0 > 0: + self._os_target_level = "high" + elif pol0 < 0: + self._os_target_level = "low" + else: + self._os_target_level = "low" # neutral start; will flip on first extreme + self._os_since_flip_mm = 0.0 + + if hard_reset: + # Rebase autotune helper on the new start + self.autotune.restart(rd_init) + + # Reset FlowGuard engine state on controller reset + self.flowguard.reset() + + # Setup special json debug log + if self.cfg.log_sync: + self._init_log() + + return self.update(eventtime, 0.0, sensor_reading, simulation=simulation) + + + def update(self, eventtime, extruder_delta_mm, sensor_reading, simulation=False): + """ + Required absolute timestamp `t_s` (seconds). Internally computes dt from the + last call (or reset). The timestamp must be monotonic non-decreasing. + """ + cfg = self.cfg + + if self._last_time_s is None: + self._last_time_s = eventtime + + if extruder_delta_mm < 0: + self.autotune.pause() + else: + self.autotune.resume() + + # Compute dt and advance time cursors + dt_s = max(0.0, float(eventtime - self._last_time_s)) # Protect against non-monotonic clock + self._last_time_s = eventtime + d_ext = float(extruder_delta_mm) + + rd_prev = self.rd_current + rd_note = None + d_gear = self._gear_mm_from_rd(d_ext, rd_prev) + + if self.twolevel_active: + # ------------------- TWO-LEVEL BRANCH ------------------ + + # FlowGuard update + flowguard_out = self.flowguard.update_flowguard(d_ext, int(sensor_reading) if cfg.sensor_type in ("CO", "TO", "D") else sensor_reading) + + # Determine immediate RD target from two-level rules + prev_level = self._os_target_level # Capture before helper changes (detect flips) + rd_target = self._twolevel_rd_target(rd_prev, d_ext, sensor_reading) + flipped_this_tick = (self._os_target_level != prev_level) + + # Delegate two-level evidence collection to autotune helper + if cfg.sensor_type in ("CO", "TO"): + extreme_active = self._onesided_contact(sensor_reading) + else: + extreme_active = (self._extreme_polarity(sensor_reading) != 0) + self.autotune.note_twolevel_tick(self._os_target_level, flipped_this_tick, d_ext, extreme_active) + + else: + # -------------------- KALMAN BRANCH -------------------- + + self._ekf_predict(extruder_mm=d_ext, gear_mm=d_gear) + self._ekf_update(float(sensor_reading)) + + # FlowGuard update + flowguard_out = self.flowguard.update_flowguard(d_ext, sensor_reading) + + # Compute immediate RD target + desired_eff = self._desired_effective_gear_mm(d_ext, dt_s) # = c_hat * u_des + c_hat = max(cfg.c_min, min(cfg.c_max, self.state.c)) + u_des = desired_eff / c_hat + rd_target = self._rd_from_desired_gear_mm(d_ext, u_des) + if rd_target is None: + rd_target = rd_prev # no extruder motion; hold RD + + # Relief-biased snap at extremes (guaranteed relief per update) + comp_ext, tens_ext = self._extreme_flags(sensor_reading) + if cfg.snap_at_extremes and d_ext != 0.0 and (comp_ext or tens_ext): + zsign = 1 if comp_ext else -1 # +1 compression, -1 tension + relief_frac = max(0.05, min(0.60, float(cfg.extreme_relief_frac))) + rd_ref = self.rd_ref + + # Derived from: delta_rel = d_ext * (c_hat * rd_ref / rd - 1) + sgn = 1.0 if d_ext > 0 else -1.0 + denom = 1.0 - (sgn * zsign) * relief_frac + denom = max(0.05, denom) + rd_target = (c_hat * rd_ref) / denom + rd_note = "Relief-biased snap at extreme" + + # Smooth target + rd_clamped = self._clamp_to_envelope(rd_target) + rd_target = self._smooth_rd_by_distance(rd_prev, rd_clamped, d_ext, sensor_reading=sensor_reading) + + # ------------- SHARED -------------- + + # Now clamp and apply the newly decided RD for future motion + rd_applied = self._clamp_to_envelope(rd_target) + if not _isclose(self.rd_current, rd_applied, abs_tol=1e-12): + self.rd_current = rd_applied + + # Update UI helper + sensor_expected = self._expected_sensor_reading(sensor_reading) + + # Autotune decision + autotune_out = self.autotune.update_autotune(d_ext, dt_s, report_trivial=self._twolevel_boost_active) + auto_rd = autotune_out.get('rd') + if auto_rd is not None: + if self.twolevel_active: + # Only adjust RD reference point if in twolevel mode to "center switching" + self.rd_ref = auto_rd + + # Reset boost (twolevel rd high/low) after first autotune candidate + self._twolevel_boost_active = False + self._set_low_high_rd(auto_rd) + + if flowguard_out.get('trigger'): + self.autotune.restart(self.rd_ref) + + # Essential output + out = { + "output": { + "rd_prev": rd_prev, + "rd_current": self.rd_current, + "rd_tuned": self.autotune.get_tuned_rd(), # What autotune believes to be accurate + "sensor_ui": sensor_expected, + "flowguard": flowguard_out, # Keys: "trigger", "reason", "level", "max_clog", "max_tangle", "active" + "autotune": autotune_out, # Keys: "rd", "note", "save" + } + } + + # Additional debug/logging info + if cfg.log_sync or simulation: + out["output"].update({ + "rd_target": rd_target, # Unclamped target on which rd_current is based + "rd_ref": self.rd_ref, # What EKF or twolevel logic is using as baseline + "rd_note": rd_note, + "x_est": self.state.x, + "c_est": self.state.c + }) + out["input"] = { + "tick": self._tick, + "t_s": eventtime, + "dt_s": dt_s, + "d_mm": extruder_delta_mm, + "sensor": sensor_reading + } + + if cfg.log_sync: + self._append_log_entry(out) + + self.state.x_prev = self.state.x + self._tick += 1 + return out + + + def polarity(self, sensor_reading): + return self._extreme_polarity(sensor_reading) + + + def get_type_mode(self): + sensor_type = self.cfg.sensor_type + if sensor_type == 'P': + sensor_type += " (TwoLevel mode)" if self.twolevel_active else " (EKF mode)" + return sensor_type + + + def get_current_rd(self): + """ + Return the current RD in use + """ + return self.rd_current + + + # --------------------------------- Internal Impl ------------------------------------ + + def _set_twolevel_active(self): + """ + Twolevel mode is updated on each reset to allow responsive behavior to sensor disable + """ + self.twolevel_active = ( + self.cfg.sensor_type in ("CO", "TO", "D") + or (self.cfg.sensor_type == "P" and self.cfg.use_twolevel_for_type_p is True) + ) + + + def _set_min_max_rd(self, rd): + """ + Set absolute immutable min/max rd "speeds" + """ + f_minmax = max(0.0, min(0.99, self.cfg.rd_min_max_speed_multiplier)) + + self.rd_min = rd / (1.0 + f_minmax) + self.rd_max = rd / (1.0 - f_minmax) + self._set_low_high_rd(rd) # Also set current low/high in effect + + + def _set_low_high_rd(self, rd): + """ + Set high/low rd "speeds" + """ + f_minmax = max(0.0, min(0.99, self.cfg.rd_min_max_speed_multiplier)) + if self.twolevel_active: + f_norm = self.cfg.rd_twolevel_speed_multiplier + f_boost = self.cfg.rd_twolevel_boost_multiplier if self._twolevel_boost_active else 0.0 + f = max(0.0, min(f_minmax, (f_norm + f_boost))) + else: + f = f_minmax + + self.rd_low = rd / (1.0 + f) + self.rd_high = rd / (1.0 - f) + + + def _clamp_to_envelope(self, rd): + """ + Never allow rd outside of limits + """ + return max(self.rd_min, min(self.rd_max, rd)) + + # -------------- Mapping helpers ----------------- + + def _gear_mm_from_rd(self, d_ext, rd): + """ + Map RD -> effective gear motion for this update. + Asymmetric mapping: + forward (d_ext > 0): u = d_ext * (rd_ref / rd) + retract (d_ext < 0): u = d_ext * (rd / rd_ref) + """ + rd_ref = self.rd_ref + d_ext = float(d_ext) + if abs(d_ext) < 1e-12: + return 0.0 + + if d_ext > 0.0: + scale = rd_ref / max(1e-9, rd) + else: + scale = max(1e-9, rd) / rd_ref + + return d_ext * scale + + def _rd_from_desired_gear_mm(self, d_ext, u_des): + """ + Invert the asymmetric mapping to get the RD target from desired effective gear motion. + Enforces no in-step reversal: u_des * d_ext must be > 0. + """ + rd_ref = self.rd_ref + d_ext = float(d_ext) + if abs(d_ext) < 1e-12: + return None + + # Prevent reversal within an update + if u_des * d_ext <= 0.0: + return self.rd_high if d_ext > 0 else self.rd_low + + if d_ext > 0.0: + # u = d_ext * (rd_ref / rd) => rd = rd_ref * d_ext / u + denom = u_des if abs(u_des) > 1e-12 else (1e-12) + rd = rd_ref * d_ext / denom + else: + # u = d_ext * (rd / rd_ref) => rd = (u * rd_ref) / d_ext + # (note: d_ext < 0 so division preserves sign correctly) + rd = (u_des * rd_ref) / d_ext + + return rd + + # --------------------- EKF ---------------------- + + def _ekf_predict(self, extruder_mm, gear_mm): + """ + Predict with the RD actually used last update (rd_prev): + """ + s, cfg = self.state, self.cfg + x_pred = s.x + self.K * (s.c * gear_mm - extruder_mm) + c_pred = s.c + + F11 = 1.0 + F12 = self.K * gear_mm + F21 = 0.0 + F22 = 1.0 + + P11, P12, P22 = s.P11, s.P12, s.P22 + FP11 = F11*P11 + F12*P12 + FP12 = F11*P12 + F12*P22 + FP21 = F21*P11 + F22*P12 + FP22 = F21*P12 + F22*P22 + + s.P11 = FP11*F11 + FP12*F12 + cfg.q_x + s.P12 = FP11*F21 + FP12*F22 + s.P22 = FP21*F21 + FP22*F22 + cfg.q_c + + s.x = max(-1.25, min(1.25, x_pred)) # Soft clamp in estimate space + s.c = max(cfg.c_min, min(cfg.c_max, c_pred)) + + + def _ekf_update(self, z): + s, cfg = self.state, self.cfg + z = max(-1.0, min(1.0, float(z))) + R = cfg.r_type + y = z - s.x + S = s.P11 + R + if S <= 0: + return + Kx = s.P11 / S + Kc = s.P12 / S + s.x += Kx * y + s.c += Kc * y + s.c = max(cfg.c_min, min(cfg.c_max, s.c)) + s.P22 -= (s.P12 * Kc) + s.P12 *= (1 - Kx) + s.P11 *= (1 - Kx) + + + # ----------- Sensor reading helpers ---------- + + def _onesided_contact(self, sensor_reading): + """ + True if the one-sided sensor is in-contact (triggered) + """ + cfg = self.cfg + + if cfg.sensor_type == "CO": + return int(sensor_reading) == 1 + + if cfg.sensor_type == "TO": + return int(sensor_reading) == -1 + + return False + + + def _extreme_polarity(self, sensor_reading): + """ + Reduce sensor to a coarse (extreme) states + P : +1 if ≥ threshold, -1 if ≤ -threshold, else 0 + D : {-1,0,+1} as-is + CO: {0, +1} as-is + TO: {-1, 0} as-is + Normally this is the flowguard threshold but the P/D twolevel is + usually a slightly lesser test + """ + cfg = self.cfg + if cfg.sensor_type != "P": + return int(sensor_reading) + + z = float(sensor_reading) + if not self.twolevel_active: + thr = cfg.flowguard_extreme_threshold + return 1 if z >= thr else -1 if z <= -thr else 0 + + # Add hysteresis on twolevel extreme for type-P sensor + hi = abs(float(cfg.p_twolevel_threshold)) + lo = max(0.0, hi - cfg.p_twolevel_hysteresis) + s = self._twolevel_hys_state + + if s != 0: + if s * z <= lo: + s = 0 + else: + s = (z >= hi) - (z <= -hi) + + self._twolevel_hys_state = s + return s + + + def _is_extreme(self, sensor_reading): + """ + True if current reading is pegged per sensor type + """ + return self._extreme_polarity(sensor_reading) != 0 + + + def _extreme_flags(self, sensor_reading): + """ + Return (compression_extreme, tension_extreme) + """ + p = self._extreme_polarity(sensor_reading) + return (p == 1, p == -1) + + # -------------- Twolevel helpers ------------- + + def _twolevel_rd_target(self, rd_prev, d_ext, sensor_reading): + """ + CO/TO: Pure two-level control. + - CO: open -> rd_low (seek compression), contact -> rd_high (relieve) + - TO: open -> rd_high (seek tension), contact -> rd_low (relieve) + Uses a small hysteresis on motion (os_min_flip_mm) so we don't chatter. + Returns the desired RD target for this update (before smoothing/rate limiting). + + P/D: Two-level mode (optional via config). + - Flip only at extremes (neutral band does not change RD). + - Compression extreme -> rd_high; Tension extreme -> rd_low. + """ + cfg = self.cfg + self._os_since_flip_mm += d_ext + + if cfg.sensor_type in ("CO", "TO"): + # Desired level from current contact state + in_contact = self._onesided_contact(sensor_reading) + if cfg.sensor_type == "CO": + desired_level = "high" if in_contact else "low" + else: # "TO" + desired_level = "low" if in_contact else "high" + + else: + # Desired level from polarity + pol = self._extreme_polarity(sensor_reading) # {+1, -1, 0} + if pol == 0: + desired_level = self._os_target_level + else: + desired_level = "high" if (pol > 0) else "low" + + # Flip only if we've moved enough since the last flip + if desired_level != self._os_target_level and abs(self._os_since_flip_mm) >= cfg.os_min_flip_mm: + self._os_target_level = desired_level + self._os_since_flip_mm = 0.0 + + # Map level to RD + rd_target = self.rd_low if self._os_target_level == "low" else self.rd_high + + return rd_target + + # ----------------- EKF helpers --------------- + + def _desired_effective_gear_mm(self, d_ext, dt_s): + s, cfg = self.state, self.cfg + dead = max(0.0, cfg.ctrl_deadband) + x = s.x + x_ctrl = 0.0 if abs(x) < dead else (x - math.copysign(dead, x)) + + kd_eff = cfg.kd if dt_s > 0 else 0.0 + dx = (s.x - s.x_prev) / max(1e-9, dt_s) if kd_eff != 0.0 else 0.0 + + return d_ext - cfg.kp * x_ctrl - kd_eff * dx + + + def _smooth_rd_by_distance(self, rd_prev, rd_target, d_ext, sensor_reading=None): + """ + Glide the current RD towards target using a fixed rd_filter_len_mm motion length + respecting readiness factor and extreme limits. + """ + move = abs(float(d_ext)) + + # Exponential smoothing for soft glide from rd_prev toward rd_target. + # Bigger move or higher r => bigger step. + L = max(1e-9, self.cfg.rd_filter_len_mm) + alpha_base = 1.0 - math.exp(-move / L) + r = self._update_readiness_and_get_r(sensor_reading, move) if sensor_reading is not None else 1.0 + alpha = r * alpha_base + + rd_filtered = rd_prev + alpha * (rd_target - rd_prev) + + # Rate limit (with extreme multiplier) + is_extreme = self._is_extreme(sensor_reading) if sensor_reading is not None else False + if self.cfg.rd_rate_per_mm is not None and move > 0: + rate_mult = (self.cfg.rate_extreme_multiplier if is_extreme else 1.0) + max_step = abs(self.cfg.rd_rate_per_mm) * move * r * rate_mult + rd_delta = rd_filtered - rd_prev + if rd_delta > max_step: + rd_filtered = rd_prev + max_step + elif rd_delta < -max_step: + rd_filtered = rd_prev - max_step + + return rd_filtered + + + def _update_readiness_and_get_r(self, sensor_reading, move_abs_mm): + """ + Returns (lag-aware) readiness value for 0=not ready, to 1=ready now + The purpose is so that we don’t react fully until we’ve seen enough + motion or a meaningful sensor change. + P-EKF only + """ + cfg = self.cfg + if cfg.sensor_lag_mm <= 0: + r = 1.0 + else: + self._mm_since_info += move_abs_mm + z = float(sensor_reading) + if self._last_info_z is None or abs(z - self._last_info_z) >= cfg.info_delta_a: + self._last_info_z = z + self._mm_since_info = 0.0 + L = max(1e-6, cfg.sensor_lag_mm) + r = max(0.0, min(1.0, self._mm_since_info / L)) + + if self._is_extreme(sensor_reading): + r = max(r, cfg.readiness_extreme_floor) + return r + + + def _expected_sensor_reading(self, sensor_reading): + """ + UI helper for prediction of idealized sensor reading. + Returns a float in [-1, 1] depending on sensor type. + """ + cfg = self.cfg + + # Type P: always passthrough true sensor reading + if cfg.sensor_type == "P": + self._vis_est = sensor_reading + return self._vis_est + + # Snap to extremes for D/CO/TO when pegged: + if self._is_extreme(sensor_reading): + self._vis_est = float(self._extreme_polarity(sensor_reading)) + return self._vis_est + + # Get phase info + ph = self.autotune.twolevel_phase(exclude_extreme=cfg.sensor_type == "D") + + # Snap to extreme if no phase info available + if ph is None: + self._vis_est = float(self._extreme_polarity(sensor_reading)) + return self._vis_est + + phase, level, extruding = ph + + # Type CO/TO: Split phase to encompass rebound + if cfg.sensor_type in ['CO', 'TO']: + + def triangle_half(p, lo=0.3, hi=0.8): + base = (1.0 - 2.0*p) if p <= 0.5 else (2.0*p - 1.0) # in [0,1] + return lo + (hi - lo) * base + + if cfg.sensor_type == "CO": + self._vis_est = triangle_half(phase) + else: + self._vis_est = -triangle_half(phase) + + return self._vis_est + + # Type D: Adjust phase to exclude extreme portion + def triangle_full(p, lo=-0.9, hi=0.9): + return lo + (hi - lo) * p + + t = triangle_full(phase) + if level == "low": + x_pred = t if extruding else -t # Ramping up if extruding + else: + x_pred = -t if extruding else t # Ramping down if extruding + self._vis_est = x_pred + return self._vis_est + + + # -------------- Logging helpers -------------- + + def _init_log(self, log_file=None): + """ + (Re)create the log file and write a single header entry. + Clears any existing file. + """ + header = { + "header": { + "rd_start": self.cfg.rd_start, + "sensor_type": self.cfg.sensor_type, + "twolevel_active": self.twolevel_active, + "buffer_range_mm": self.cfg.buffer_range_mm, + "buffer_max_range_mm": self.cfg.buffer_max_range_mm, + } + } + + with io.open(self._current_log_file, "a", encoding="utf-8") as f: + json.dump(header, f, ensure_ascii=False) + f.write("\n") + self._log_ready = True + + + def _append_log_entry(self, record): + """ + Append a single JSON object to the log as one line. + """ + if not self._log_ready: + return + + with io.open(self._current_log_file, "a", encoding="utf-8") as f: + json.dump(record, f, ensure_ascii=False) + f.write("\n") diff --git a/klippy/extras/mmu/mmu_sync_controller.py3 b/klippy/extras/mmu/mmu_sync_controller.py3 new file mode 100644 index 000000000000..65451e573650 --- /dev/null +++ b/klippy/extras/mmu/mmu_sync_controller.py3 @@ -0,0 +1,1659 @@ +# -*- coding: utf-8 -*- +# +# Happy Hare MMU Software +# Sync Feedback Controller +# +# This helper module implements a motion-triggered filament tension controller — that adapts gear +# stepper rotation distance (RD) dynamically based on sensor feedback. It offers modes of operation: +# +# 1) Simple dual level RD selection that works with CO (Compression only switch), +# TO (Tension only switch), and optionally with D (Dual switch) or P (Proportional) sensors. +# +# 2) Combined proportional-derivative (PD) controller with Extended Kalman Filter +# (EKF) for optimal results with P (Proportional) sensor. +# +# Flowguard: It also implements protection for all modes/sensor types that will trigger +# on clog (at extruder) or tangle (at MMU) conditions. +# +# Autotune: An autotuning option can be enabled for dynamic tuning (and persistence) of +# calibrated MMU gear rotation_distance. +# +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# + +from __future__ import annotations +from dataclasses import dataclass +from typing import Optional, Literal, Dict, Any +from collections import deque + +import math +import io, json # for debug log + + +# Sync-Feedback sensor type: +SensorType = Literal["P", "D", "CO", "TO"] + +# ----------------------------------------------------------------------------- +# SyncControllerConfig reference +# ----------------------------------------------------------------------------- +# +# Mechanics +# - buffer_range_mm (mm) Usable sensor travel that maps linearly to x ∈ [-1,+1]. +# All control logic is normalized by this. Increase if your +# sensor saturates too easily; decrease for a “tighter” x scale. +# - buffer_max_range_mm (mm) Physical clamp of the spring/buffer travel (|x| clipping). +# Must be ≥ buffer_range_mm. Used by the simulator and for +# visualization/safety margins. +# - sensor_type "P" => proportional z ∈ [-1, +1]; uses EKF + PD + KD +# "D" => discrete dual-switch z ∈ {-1,0,+1}; twolevel only +# "CO" => compression-only switch z ∈ {0,+1}; twolevel only +# "TO" => tension_only switch z ∈ {-1,0}; twolevel only +# +# Core lag tuning (readiness r) +# - sensor_lag_mm (mm) Motion required before treating sensor changes as “fresh info”. +# r ramps from 0→1 across this distance (gates smoothing/rates). +# 0 disables gating (r=1 always). +# - info_delta_a For Type-P only: minimum |Δz| to count as “new info”. +# Helps suppress tiny noise from constantly resetting the lag meter. +# +# Gains (PD on x with deadband) +# - kp Proportional gain on x (after deadband). Larger => stronger pull +# toward neutral; too high can oscillate near zero. +# - kd Derivative on x (Type-P only; requires dt>0). Dampens fast x +# changes. Set 0 to disable if your signal is noisy. +# - ctrl_deadband No-action band around x=0. Prevents over-correcting tiny errors. +# +# EKF noises +# - q_x Process noise on x. Larger trusts the model less => faster tracking, +# but noisier estimates. +# - q_c Process noise on c (calibration). Larger lets c drift/learn faster. +# - r_type Measurement noise for Type-P. Larger trusts the sensor less. +# +# Calibration bounds +# - c_min, c_max Hard clamps for c (effective compliance/throughput factor). +# Keep wide enough to cover materials but not so wide that c runs away. +# +# FlowGuard (distance-based) +# - flowguard_extreme_threshold Threshold in x or z treated as “pegged” (≈ jam/tangle). +# Used for detection, readiness floor, and relief logic. +# - flowguard_relief_mm (mm) Required accumulated “relief” motion to prove we tried to +# correct an extreme. If None, defaults to buffer_max_range_mm. USER EXPOSED +# +# Rotation distance +# - rd_start (mm) Default/persisted baseline RD. Used as mirror reference for mapping +# - rd_min_max_speed_multiplier Allowed RD bounds based on % speed +# - rd_twolevel_speed_multiplier Min/Max RD based on % speed for twolevel operation USER EXPOSED +# - rd_twolevel_boost_multiplier Extra boost speed USER EXPOSED +# +# Distance-based smoothing & slew +# - rd_filter_len_mm (mm) Exponential smoothing length vs extruder motion. +# Alpha = 1 - exp(-|Δmm| / L). Larger L = slower RD changes. +# - rd_rate_per_mm Hard rate limit on |ΔRD| per mm of motion (scaled by readiness r). +# None disables. Works together with rd_filter_len_mm; the tighter one wins. +# +# Extreme behavior +# - readiness_extreme_floor Minimum readiness r when the sensor/estimate is pegged. Ensures +# RD can change quickly enough under clear faults. +# - rate_extreme_multiplier Multiplier on rd_rate_per_mm when pegged (speed up corrections). +# - snap_at_extremes If True, apply relief-biased snap when pegged +# per update) to move away from the peg. +# - extreme_relief_frac Fraction of |d_ext| used to compute a relief RD step each update +# when snap_at_extremes is active. Typical 0.15–0.35. +# +# Autotune +# EKF logic: +# - autotune_stable_x_thresh Consider “near neutral” if |x| ≤ this. +# Determines when we accumulate samples for autotune. +# - autotune_stable_time_s Minimum time spent near neutral before we consider autotuning. +# - autotune_basis "time" | "motion" | "either" | "both" — which tests must pass. +# - autotune_motion_mm Motion near neutral required if basis uses motion. +# considered too small to avoid recommending trivial changes. +# - autotune_var_rel_frac Max allowed std(speed) near neutral required for autotune to propose an update +# - autotune_var_len_mm Distance over which to estimate RD mean/variance during the near-neutral “stable” window. +# Twolevel logic: +# - autotune_significance_z Z-score tests for twolevel estimator (0 disables, 2≈95% confidence). +# Shared logic: +# - autotune_cooldown_s/mm Minimum time/motion since the last autotune before another suggestion. +# +# Tuning tips: +# - If RD reacts too sluggishly in normal operation, decrease rd_filter_len_mm and/or increase +# rd_rate_per_mm (watch stability near neutral). +# - If you see chatter near x=0, reduce kp and/or kd, or increase r_type. +# - If FlowGuard trips too early, raise flowguard_relief_mm. +# - If autotune fires too often, increase the cooldowns; if it +# never fires, reduce autotune_stable_time_s and/or autotune_motion_mm. + +@dataclass +class SyncControllerConfig: + # Logging + log_sync: bool = False # whether to create log of every tick for debugging purposes + log_file: str = "/tmp/sync.jsonl" # debugging/plotting json log + + # Mechanics + buffer_range_mm: float = 8.0 # sensor usable travel (maps to normalized [-1,+1]) + buffer_max_range_mm: float = 14.0 # physical max travel (spring clamp) ≥ buffer_range_mm + sensor_type: SensorType = "D" + + # Core lag tuning (readiness r) + sensor_lag_mm: float = 0.0 # expected motion to see new info; 0 => no lag gating (r=1) + info_delta_a: float = 0.08 # Type-P: min sensor delta to count as "new info" + + # Gains (PD on x with deadband) + kp: float = 0.5 + kd: float = 0.4 # derivative term (used for Type-P) + ctrl_deadband: float = 0.1 # neutral deadband for PD around x=0 + + # EKF noises + q_x: float = 1e-3 + q_c: float = 5e-5 + r_type: float = 2.5e-2 + + # Calibration bounds + c_min: float = 0.25 + c_max: float = 4.0 + + # FlowGuard (distance-based) + flowguard_extreme_threshold: float = 0.9 + flowguard_relief_mm: Optional[float] = None + + # Rotation distance + rd_start: float = 20.0 # initial baseline (previous calibrated value) + rd_min_max_speed_multiplier: float = 0.25 # ±25% speed + rd_twolevel_speed_multiplier: float = 0.05 # ±5% speed + rd_twolevel_boost_multiplier: float = 0.05 # ±5% extra boost speed + + # Distance-based smoothing & slew + rd_filter_len_mm: float = 25.0 # exp smoothing length (mm of extruder motion for ~63% step @ r=1) + rd_rate_per_mm: Optional[float] = 0.10 # per-mm hard rate limit on ΔRD (scaled by readiness) + + # Extreme behavior control + readiness_extreme_floor: float = 0.7 # when pegged, raise r to at least this + rate_extreme_multiplier: float = 2.0 # multiply rate cap when pegged + snap_at_extremes: bool = True # enable relief-biased snap when pegged + extreme_relief_frac: float = 0.25 # fraction of |d_ext| of guaranteed relief per update + + # EKF autotune logic tests + autotune_stable_x_thresh: float = 0.12 + autotune_stable_time_s: float = 4.0 + autotune_basis: str = "both" + autotune_motion_mm: Optional[float] = None + autotune_var_rel_frac: float = 0.004 # allow ≈0.4% relative speed std + autotune_var_len_mm:float = None + + # Twolevel logic tests + autotune_significance_z: float = 1.0 # z-score (twolevel confidence) threshold to accept new RD (0 disables, 1≈68%, 2≈96%) + + # Shared tests + autotune_cooldown_s: float = 10.0 + autotune_cooldown_mm: float = 100.0 + autotune_min_save_frac: float = 0.001 # Only consider saving if > ≈0.1% speed change from last persisted value + + # Certainty tracking of rd recommendations + autotune_cert_window: int = 8 # fifo length of rd certainty scores + autotune_cert_tau_rel: float = 0.01 # target relative SE (e.g. 1%) + autotune_cert_n0: float = 3.0 # prior sample penalty + autotune_cert_hysteresis: float = 0.001 # min score improvement to accept + + os_min_flip_mm: float = 0.0 # minimum motion between flips (anti-chatter) + + # Optional two-level for P type sensors + use_twolevel_for_type_p: Optional[bool] = None # True/False to force option for type-P sensors + p_twolevel_threshold: float = 0.80 # P extreme if z>=+thr or z<=-thr + p_twolevel_hysteresis: float = 0.2 # shrink threshold by this when exiting a twolevel extreme + + def __post_init__(self): + if self.buffer_range_mm <= 0: + raise ValueError("buffer_range_mm must be > 0") + if self.buffer_max_range_mm <= 0: + raise ValueError("buffer_max_range_mm must be > 0") + if self.buffer_max_range_mm < self.buffer_range_mm: + raise ValueError("buffer_max_range_mm must be ≥ buffer_range_mm") + + # Autotune window defaults + if self.autotune_motion_mm is None: + self.autotune_motion_mm = 3.0 * self.rd_filter_len_mm + if self.autotune_var_len_mm is None: + self.autotune_var_len_mm = 1.8 * self.rd_filter_len_mm + + # FlowGuard relief threshold (how much "counter-effort" must be proven) + if self.flowguard_relief_mm is None: + mult = 0.3 if self.sensor_type in ['P'] else 0.7 + self.flowguard_relief_mm = max(mult * self.buffer_range_mm, self.buffer_max_range_mm) + + +# ------------------------------- EKF State ------------------------------ + +@dataclass +class EKFState: + """ + EKF state for [x, c] with covariance. + """ + x: float = 0.0 + c: float = 1.0 + P11: float = 0.5 + P12: float = 0.0 + P22: float = 0.2 + x_prev: float = 0.0 + + +# ------------------------------ Autotune Engine ------------------------- + +class _AutotuneEngine: + """ + Helper object that owns *all* autotune bookkeeping and decisions + """ + + def __init__(self, ctrl): + self.ctrl = ctrl + + # Core counters and state + self._total_motion_mm = 0.0 + self._total_time_s = 0.0 + self._paused = False + + # PD window stats + self._stable_time = 0.0 + self._stable_motion_mm = 0.0 + self._rd_ema_mean = None + self._rd_ema_var = 0.0 + + # Autotune anchors & cooldown trackers + self._autotune_last_time_s = -1e12 + self._autotune_last_motion_mm = -1e12 + self._autotune_baseline = self.ctrl.rd_ref # Persisted rd setting + self._autotune_current = self.ctrl.rd_ref # Current recommendation + self._autotune_min_cert_score = 0.5 # Don't recommend persist if less than this score + + # Suggestion tracking + self._rd_cert_fifo = deque(maxlen=int(max(1, ctrl.cfg.autotune_cert_window))) + self._rd_cert_last_score = -1.0 + + # Two-level estimator buckets & evidence + self._tl_flips = 0 + self._tl_updates_since_flip = 0 + + # Segment/cycle tracking for two-level duty estimator + self._tl_seg_level = None # "low" / "high" + self._tl_seg_mm = 0.0 # +ve (prevailing extrude) or -ve (prevailing retract) + self._tl_seg_mm_extreme = 0.0 # +ve (prevailing extrude) or -ve (prevailing retract) + self._tl_samples_low = [] # FIFO of extruder distances traveled while in "low" state + self._tl_samples_high = [] # FIFO of extruder distances traveled while in "high" state + self._tl_last_unpaired_low = None + self._tl_last_unpaired_high = None + self._tl_cycles = [] # List of (low_mm, high_mm) + self._tl_seg_window = 6 # Moving window per level + self._tl_cycle_window = 4 # Moving window of paired cycles + if ctrl.cfg.sensor_type in ['CO', 'TO']: + self._tl_min_cycles = 4 # Required minimum number of samples + else: + self._tl_min_cycles = 2 # Less because using full "buffer_range" + + # -------------------------------- API ----------------------------------- + + def restart(self, rd_init, reset_totals=True, reset_cooldown=True, reset_confidence=True): + """ + Rebase all autotune anchors/windows on a fresh baseline for new starting rd value + - Cooldown timers are either reset-to-now (default) or to a large negative origin + - Total counts are optionally reset + """ + self._autotune_current = rd_init + self._paused = False + + # Reset or preserve cooldown origins + if reset_cooldown: + self._autotune_last_time_s = self._total_time_s + self._autotune_last_motion_mm = self._total_motion_mm + else: + self._autotune_last_time_s = -1e12 + self._autotune_last_motion_mm = -1e12 + + # Suggestion tracking + if reset_confidence: + self._rd_cert_fifo = deque(maxlen=int(max(1, self.ctrl.cfg.autotune_cert_window))) + self._rd_cert_last_score = -1.0 + + # Reset or preserve core counters + if reset_totals: + self._total_motion_mm = 0.0 + self._total_time_s = 0.0 + + # PD window stats + self._stable_time = 0.0 + self._stable_motion_mm = 0.0 + + # Two-level estimator buckets & evidence + self._tl_flips = 0 + self._tl_updates_since_flip = 0 + + # Clear segment/cycle tracking for two-level duty estimator + self._tl_seg_level = None + self._tl_seg_mm = 0.0 + self._tl_seg_mm_extreme = 0.0 + self._tl_samples_low = [] + self._tl_samples_high = [] + self._tl_last_unpaired_low = None + self._tl_last_unpaired_high = None + self._tl_cycles = [] + + + def pause(self): + """ + Called to pause autotune generally because we received a large retract or we know + we are going to do an extended retract (tuning only work reliably in a extruder direction) + or we are performing movement that is known to cause underextrusion (like blobifer purge). + """ + if not self._paused: + self._paused = True + + + def resume(self): + """ + Resume autotune monitoring. We have to perform a soft reset. + """ + if self._paused: + self.restart(self._autotune_current, reset_totals=False, reset_cooldown=False, reset_confidence=False) + + + def note_twolevel_tick(self, os_level, flipped, d_ext, is_extreme=False): + """ + Called once per update_autotune() in two-level branch to keep buckets/evidence up-to-date. + """ + if self._paused: return + + cfg = self.ctrl.cfg + + # Flip handling + if flipped: + self._tl_updates_since_flip = 0 + self._tl_flips += 1 + else: + self._tl_updates_since_flip += 1 + + # Only accumulate segments after the first flip to remove startup conditions + if self._tl_flips < 1: + return + + # Accumulate current segment distance + self._tl_seg_mm += d_ext + if is_extreme: + self._tl_seg_mm_extreme += d_ext + + # On flip: close previous segment (if started) and store sample + if flipped: + seg_level = self._tl_seg_level + seg_mm = abs(self._tl_seg_mm) + + if seg_level == "low": + self._tl_samples_low.append(seg_mm) + if len(self._tl_samples_low) > self._tl_seg_window: + self._tl_samples_low.pop(0) + # Pair with existing high if available + if self._tl_last_unpaired_high is not None: + self._tl_cycles.append((seg_mm, self._tl_last_unpaired_high)) + if len(self._tl_cycles) > self._tl_cycle_window: + self._tl_cycles.pop(0) + self._tl_last_unpaired_high = None + else: + self._tl_last_unpaired_low = seg_mm + + elif seg_level == "high": + self._tl_samples_high.append(seg_mm) + if len(self._tl_samples_high) > self._tl_seg_window: + self._tl_samples_high.pop(0) + # Pair with existing low if available + if self._tl_last_unpaired_low is not None: + self._tl_cycles.append((self._tl_last_unpaired_low, seg_mm)) + if len(self._tl_cycles) > self._tl_cycle_window: + self._tl_cycles.pop(0) + self._tl_last_unpaired_low = None + else: + self._tl_last_unpaired_high = seg_mm + + # Start new segment for the new level + self._tl_seg_level = os_level + self._tl_seg_mm = 0.0 + self._tl_seg_mm_extreme = 0.0 + + + def update_autotune(self, d_ext, dt_s, report_trivial=False): + """ + On sensor update, recommend rd update based on mode: + - If two-level mode is active (CO/TO/D, or P with use_twolevel_for_type_p=True), + only query the two-level estimator. + - Otherwise, only query the PD near-neutral window. + If rd is recommended, run through shared statistical tests + """ + status = {"rd": None, "note": "", "save": False} + + if self._paused: + status["note"] = "Autotune: Paused" + return status + + cfg = self.ctrl.cfg + + # Track time/movement + self._total_time_s += max(0.0, float(dt_s)) + self._total_motion_mm += abs(float(d_ext)) + travel = "@{:.0f}s/{:.0f}mm".format(self._total_time_s, self._total_motion_mm) + + # Cooldown - sufficient motion/time since last save + since_mm = self._total_motion_mm - self._autotune_last_motion_mm + since_s = self._total_time_s - self._autotune_last_time_s + req_mm = cfg.autotune_cooldown_mm + req_s = cfg.autotune_cooldown_s + if since_mm < req_mm or since_s < req_s: + return status + + if self.ctrl.twolevel_active: + rec_rd, note = self._recommend_rd_from_twolevel() + else: + rec_rd, note = self._recommend_rd_from_ekf_path(d_ext, dt_s) + + + # No recommendation but optional reject note + if rec_rd is None: + status["note"] = "Autotune: {} {}".format(travel, note) if note else "" + return status + + # Perform final shared checks on recommendation... + + if not (self.ctrl.rd_low <= rec_rd <= self.ctrl.rd_high): + status["note"] = "Autotune: {} Rejected rd {:.4f} because out of bounds!".format(travel, rec_rd) + return status + + # This makes is progressively harder to accept autotune + rec_rd, _note = self._autotune_confident(rec_rd) + if rec_rd is None: + status["note"] = "Autotune: {} {}".format(travel, _note) if _note else "" + return status + + # Do nothing on truly trivial changes + if not report_trivial and math.isclose(rec_rd, self._autotune_current, abs_tol=1e-3): + status["note"] = "Autotune: {} Rejected rd {:.4f} because too trivial a delta".format(travel, rec_rd) + return status + + # We have new tuned rd value... + self._autotune_current = rec_rd + status["rd"] = rec_rd + status["note"] = "Autotune: {} {} and {}".format(travel, note, _note) + + # Should we recommend saving as new default reference? + if self._rd_cert_last_score >= self._autotune_min_cert_score: + frac = self._frac_speed_delta(rec_rd, self._autotune_baseline) + min_frac = cfg.autotune_min_save_frac + if frac >= min_frac: + self._autotune_baseline = rec_rd + status["save"] = True + + self.restart(rec_rd, reset_totals=False, reset_cooldown=False, reset_confidence=False) + return status + + + def twolevel_phase(self, exclude_extreme=False): + """ + Return (level, phase) for current two-level segment where + phase is measured between extremes (extreme motion removed). + Return None if we don't have enough evidence yet. + phase : progress in [0,1] within current segment (distance-based) + level : "low" | "high" (segment we're currently in) + extruding : true if extruding + """ + level = self._tl_seg_level + if level is None: + return None + + samples = self._tl_samples_high if level == "high" else self._tl_samples_low + if not samples: + return None + + mean_len = sum(samples) / float(len(samples)) + travel = abs(self._tl_seg_mm) + if exclude_extreme: + travel -= abs(self._tl_seg_mm_extreme) + mean_len -= abs(self._tl_seg_mm_extreme) + phase = max(0.0, min(1.0, travel / max(1e-6, mean_len))) + + return phase, level, self._tl_seg_mm > 0 + + + def get_rec_rd(self): + """ + Return the current recommended RD + """ + return self._autotune_current + + def get_tuned_rd(self): + """ + Return the last tuned RD. Initially this is the starting value + """ + return self._autotune_baseline + + + # ---------------------------- Internal Impl ----------------------------- + + + def _recommend_rd_from_ekf_path(self, d_ext, dt_s): + """ + Autotune baseline RD using EKF path statistics gathered near neutral. + Returns: Tuple (rec_rd|None, note|None) + """ + cfg = self.ctrl.cfg + + # Stability tests near neutral + stability_test = abs(self.ctrl.state.x) < cfg.autotune_stable_x_thresh + + # Accrue stable time/motion + move = abs(d_ext) + if stability_test: + self._stable_time += dt_s + self._stable_motion_mm += move + + # if move == 0.0: leave EMA unchanged this tick + if move > 0.0: + L = max(1e-9, cfg.autotune_var_len_mm) + alpha = 1.0 - math.exp(-move / L) + + # --- EMA in SPEED space: v = 1 / rd_current --- + rd_curr = max(1e-9, float(self.ctrl.rd_current)) + v = 1.0 / rd_curr + + if self._rd_ema_mean is None: + # Seed on first accepted sample (now in speed space) + self._rd_ema_mean = v + self._rd_ema_var = 0.0 + else: + # EWMA mean + West's EW variance, in speed space + m_prev = self._rd_ema_mean + d = v - m_prev + m_new = m_prev + alpha * d + v_new = (1.0 - alpha) * (self._rd_ema_var + alpha * d * d) + + self._rd_ema_mean = m_new + self._rd_ema_var = max(0.0, v_new) + + else: + # Leaving stable test -> drop stats so we don't carry junk + self._stable_time = 0.0 + self._stable_motion_mm = 0.0 + self._rd_ema_mean = None + self._rd_ema_var = 0.0 + + if self._rd_ema_mean is None: + return None, None + + time_ok = (self._stable_time >= cfg.autotune_stable_time_s) + motion_ok = (self._stable_motion_mm >= (cfg.autotune_motion_mm or 0.0)) + if cfg.autotune_basis == "time": + ready = time_ok + elif cfg.autotune_basis == "motion": + ready = motion_ok + elif cfg.autotune_basis == "either": + ready = time_ok or motion_ok + else: + ready = time_ok and motion_ok + if not ready: + return None, None + + # --- Interpret EMA as SPEED stats, then map back to RD --- + mean_v = max(self._rd_ema_mean, 1e-12) # mean speed + var_v = max(0.0, self._rd_ema_var) # variance of speed + + # Speed-relative variance test: std(speed)/mean(speed) ≤ f + f = cfg.autotune_var_rel_frac + std_v = math.sqrt(var_v) + rel_std_v = std_v / mean_v if mean_v > 0 else float("inf") + if rel_std_v > f: + # Convert mean_v back to rd for reporting + mean_rd_for_note = 1.0 / mean_v + note = f"Rejected rd {mean_rd_for_note:.4f} due to speed-relative variance {rel_std_v:.4f} > {f:.4f}" + return None, note + + # Potential new candidate: + # Convert mean speed back to an rd estimate + mean_rd = 1.0 / mean_v + note = f"EKF logic suggests rd≈{mean_rd:.4f} after {self._stable_time:.1f}s/{self._stable_motion_mm:.1f}mm near neutral" + return mean_rd, note + + + def _recommend_rd_from_twolevel(self): + """ + Minimal statistical baseline update for two-level mode for CO/TO sensor types or + optionally P/D types if configured in twolevel mode. + Returns: Tuple (rec_rd|None, note|None) + """ + cfg = self.ctrl.cfg + + # Only evaluate *on flips* (state changes) + if self._tl_updates_since_flip != 0: + return None, None + + # Require min segment samples per state and min total cycles + n_low = len(self._tl_samples_low) + n_high = len(self._tl_samples_high) + n_cycles = len(self._tl_cycles) + if n_low < self._tl_min_cycles or n_high < self._tl_min_cycles or n_cycles < self._tl_min_cycles: + return None, None + + # Compute per-cycle fractions for variance (fh_list) and ratio-of-sums for mean duty + fh_list = [] + dl_sum = 0.0 + dh_sum = 0.0 + for (dl, dh) in self._tl_cycles[-self._tl_cycle_window:]: + tot = max(1e-12, dl + dh) + fh_list.append(dh / tot) # per-cycle fraction (for variance) + dl_sum += dl + dh_sum += dh + + if not fh_list: + return None, None + + # Duty (mean) via ratio-of-sums + tot_sum = max(1e-12, dl_sum + dh_sum) + fh_mean = dh_sum / tot_sum + + # Duty-weighted *speed* estimate, then map back to RD to remove RD-space bias + v_low = 1.0 / max(1e-9, self.ctrl.rd_low) + v_high = 1.0 / max(1e-9, self.ctrl.rd_high) + v_est = (1.0 - fh_mean) * v_low + fh_mean * v_high + rd_est = 1.0 / max(1e-9, v_est) + + # rd_est significance test via z-score from variability of fh across cycles to see if it + # statistically distinguishable from the baseline given the observed variability + z = None + if cfg.autotune_significance_z > 0.0 and len(fh_list) >= 2: + # Sample std of fh across cycles + mu = sum(fh_list) / float(len(fh_list)) + var_f = sum((f - mu) ** 2 for f in fh_list) / float(max(1, len(fh_list) - 1)) + std_f = math.sqrt(var_f if var_f > 0.0 else 0.0) + se_f = (std_f / math.sqrt(len(fh_list))) if len(fh_list) > 0 else float("inf") + + # Propagate fh uncertainty through rd = 1 / ((1-f)/rd_low + f/rd_high) + # drd/df = rd^2 * (1/rd_low - 1/rd_high) = rd^2 * (v_low - v_high) + sensitivity = (rd_est ** 2) * abs(v_low - v_high) + se_rd = sensitivity * se_f # Std error in rd + + if se_rd >= 1e-9: + z = abs(rd_est - self._autotune_current) / se_rd + if z < float(cfg.autotune_significance_z): + note = ("Rejected rd {:.4f} because z-score {:.2f} not significant (<{:.2f})").format(rd_est, z, cfg.autotune_significance_z) + return None, note + # else: se_rd ~ 0 => treat as pass (perfect/no-variance case) + + # Potential new candidate + score = ("%.2f" % z) if z is not None else "perfect" + note = ("Two-level logic suggests rd≈{:.4f} (duty {:.2f} over {} cycles, z-score={})").format(rd_est, fh_mean, len(fh_list), score) + return rd_est, note + + + def _certainty_score(self, samples, tau_rel=0.01, n0=3.0, eps=1e-12): + """ + Certainty in [0,1]. Higher = more certain. + - tau_rel: target relative SE; smaller => stricter (e.g., 0.01 = 1%) + - n0: prior sample penalty; larger => more skepticism with small n + Returns: (score, mean, se, n) + """ + vals = [float(v) for v in samples if v is not None] + n = len(vals) + if n == 0: + return 0.0, None, None, 0 + + m = sum(vals) / float(n) + + if n >= 2: + mean_sq = sum(v*v for v in vals) / float(n) + var = max(0.0, mean_sq - m*m) * n / float(max(1, n - 1)) # unbiased + s = math.sqrt(var) + else: + s = 0.0 + + se = s / math.sqrt(n) if n > 0 else float('inf') + rel_se = se / max(abs(m), eps) if m != 0.0 else float('inf') + + # Precision shrinks as relative SE grows; bounded (0,1] + prec = 1.0 / (1.0 + (rel_se / max(tau_rel, eps))) + + # Sample-size prior; bounded [0,1) + size = n / (n + float(n0)) + + # Enforce "n<2 -> effectively no certainty" + if n < 2: + prec = 0.0 + + score = prec * size + return score, m, se, n + + def _frac_speed_delta(self, rd_new, rd_ref): + # |v(new)-v(ref)| / v(ref) with v = 1/rd => |rd_ref/rd_new - 1| + return abs((rd_ref / max(1e-9, rd_new)) - 1.0) + + def _autotune_confident(self, rec_rd): + """ + All speed-relative: + - min fractional speed delta vs last saved value + Returns: tuple(True|False, reason) + """ + cfg = self.ctrl.cfg + + # Require ever increasing certainty score (std error + n) + tau_rel = cfg.autotune_cert_tau_rel + n0 = cfg.autotune_cert_n0 + hyster = cfg.autotune_cert_hysteresis + + # Push proposal and score the window + self._rd_cert_fifo.append(rec_rd) + score, mean, se, n = self._certainty_score(self._rd_cert_fifo, tau_rel=tau_rel, n0=n0) + prev = self._rd_cert_last_score + threshold = 0 if prev == 0 else max(prev + hyster, 0) + improved = score > threshold + + if not improved: + if prev < 0: + self._rd_cert_last_score = 0. + note = "Rejected new rd {:.4f} due to certainty score of zero (n={})".format(rec_rd, n) + else: + note = "Rejected new rd {:.4f} due to certainty score {:.3f} ≤ prev {:.3f} (n={})".format(rec_rd, score, prev, n) + return None, note + + self._rd_cert_last_score = score + note = "with certainty score of {:.3f} (prev {:.3f}), n={}, mean {:.4f}, SE {:.4f}".format( + score, prev, n, mean if mean is not None else float('nan'), se if se is not None else float('nan')) + return mean, note + +# ----------------------------- Flowguard Engine ------------------------- + +class _FlowguardEngine: + """ + Encapsulates FlowGuard state and logic. Determines based on total filament movement + and amount of rd correction applied if a clog or tangle is likely to have occurred. + A reason string explains the reason for the trigger. + A single update_flowguard() entry point to be called on each tick. + """ + + def __init__(self, ctrl): + self.ctrl = ctrl + self.reset() + + # -------------------------------- API ----------------------------------- + + def reset(self): + # Accumulators + self._comp_motion_mm = 0.0 + self._tens_motion_mm = 0.0 + self._relief_comp_mm = 0.0 + self._relief_tens_mm = 0.0 + + # Condition monitoring + self._trigger = "" + self._reason = "" + self._level = 0.0 + self._max_clog = 0.0 + self._max_tangle = 0.0 + self._motion_headroom = 0.0 # Debugging + self._relief_headroom = 0.0 # Debugging + + # FlowGuard arming test + self._armed = False # Disarmed until a state change while moving + self._arm_motion_mm = 0.0 # Motion since last (or initial) state sample + self._arm_last_state = None + + def update_flowguard(self, d_ext, sensor_reading): + """ + Distance-based FlowGuard with symmetric handling for one-sided switches. + + - For P/D sensors: + Uses controller._extreme_flags() on the sensor reading. + - For CO/TO sensors: + Uses the sensor directly for the *seen* side, and infer the unseen side + CO (compression-only): unseen = TENSION; relief effort is COMPRESSION (delta_rel > 0) + TO (tension-only) : unseen = COMPRESSION; relief effort is TENSION (delta_rel < 0) + Returns: Status Dict {"trigger", "reason", ...} + """ + cfg = self.ctrl.cfg + effort = self._relief_effort(d_ext) # +ve => compression effort, -ve => tension effort + + # Get the current sensor state for FlowGuard purposes (CO/TO are always at extreme) + state_now = self._flowguard_polarity(sensor_reading) + comp_ext, tens_ext = (state_now == 1, state_now == -1) + + # Arming logic to prevent false triggers on startup if thresholds are tight + self._arm_motion_mm += d_ext + state_now = self.ctrl._extreme_polarity(sensor_reading) + if self._arm_last_state is None: + self._arm_last_state = state_now + + self._relief_headroom = cfg.flowguard_relief_mm + + if not self._armed: + # Arm when we've moved and observed any change in coarse state + changed = (state_now != self._arm_last_state) + if abs(self._arm_motion_mm) > 0.0 and changed: + self._armed = True + else: + return self.status() + self._arm_last_state = state_now + + if comp_ext: # Extreme Compression + self._comp_motion_mm += d_ext + + # Relief for compression is *tension* effort (delta_rel < 0) + if effort < 0: + self._relief_comp_mm += (-effort) + + comp_relief_trig = (abs(self._relief_comp_mm) >= cfg.flowguard_relief_mm) + self._relief_headroom -= self._relief_comp_mm + + if comp_relief_trig and not self._trigger: + self._trigger = "clog" + self._reason = "Compression stuck after %.2f mm motion and %.2f mm relief (triggering parameter: flowguard_max_relief)" % ( + self._comp_motion_mm, self._relief_comp_mm + ) + + # Maintain normalized [0..1] clog headroom marker + mcr = abs(self._relief_comp_mm / cfg.flowguard_relief_mm) + c_level = min(1.0, mcr) + self._level = c_level + if c_level > self._max_clog: + self._max_clog = c_level + + # Reset the tension side when compression extreme is active + self._tens_motion_mm = 0.0 + self._relief_tens_mm = 0.0 + + elif tens_ext: # Extreme Tension + self._tens_motion_mm += d_ext + + # Relief for tension is *compression* effort (delta_rel > 0) + if effort > 0: + self._relief_tens_mm += effort + + tens_relief_trig = (abs(self._relief_tens_mm) >= cfg.flowguard_relief_mm) + self._relief_headroom -= self._relief_tens_mm + + if tens_relief_trig and not self._trigger: + self._trigger = "tangle" + self._reason = "Tension stuck after %.2f mm motion and %.2f mm relief (triggering parameter: flowguard_max_relief)" % ( + self._tens_motion_mm, self._relief_tens_mm + ) + + # Maintain normalized [0..-1] tangle headroom marker + mtr = -abs(self._relief_tens_mm / cfg.flowguard_relief_mm) + t_level = max(-1.0, mtr) + self._level = t_level + if self._level < self._max_tangle: + self._max_tangle = t_level + + # Reset the compression side when tension extreme is active + self._comp_motion_mm = 0.0 + self._relief_comp_mm = 0.0 + + else: # No extreme: reset both sides + self._comp_motion_mm = 0.0 + self._relief_comp_mm = 0.0 + self._tens_motion_mm = 0.0 + self._relief_tens_mm = 0.0 + + return self.status() + + def status(self): + s = { + "active": self._armed, + "level": self._level, + "max_clog": self._max_clog, + "max_tangle": self._max_tangle, + "trigger": self._trigger, + "reason": self._reason, + } + + # When debug logging + if self.ctrl.cfg.log_sync: + s.update({ + "headroom": self._motion_headroom, + "relief_headroom": self._relief_headroom, + }) + + return s + + def _relief_effort(self, d_ext): + """ + Signed relief 'effort' this tick (mm-equivalent). + Positive => compression effort, negative => tension effort. + + Baseline is autotune._autotune_current, i.e. the *tuned* RD: + - In twolevel mode, this matches the rd_ref we recenter around. + - In EKF mode, this is the learned "true" RD, even if rd_ref remains the + originally persisted value. + """ + rd_ref = self.ctrl.autotune.get_rec_rd() # Starts at self.ctrl.rd_ref + rd_cur = self.ctrl.rd_current + if abs(rd_cur) < 1e-9: + return 0.0 + return d_ext * ((rd_ref / rd_cur) - 1.0) + + def _flowguard_polarity(self, sensor_reading): + """ + FlowGuard-only coarse polarity. + + For CO/TO, treat OPEN as an extreme on the unseen side so FlowGuard tracks immediately: + CO: z==1 -> +1 (compression), z==0 -> -1 (tension-as-open) + TO: z==-1 -> -1 (tension), z==0 -> +1 (compression-as-open) + + For P/D, defer to controller polarity. + """ + cfg = self.ctrl.cfg + if cfg.sensor_type == "CO": + return 1 if int(sensor_reading) == 1 else -1 + if cfg.sensor_type == "TO": + return -1 if int(sensor_reading) == -1 else 1 + return self.ctrl._extreme_polarity(sensor_reading) + + +# -------------------------- Controller Core ---------------------------- + +class SyncController: + """ + Movement-triggered filament tension controller. + + update(eventtime, extruder_delta_mm, sensor_reading): + - Propagates EKF with motion & measurement + - Computes desired effective gear motion to pull x→0 (PD with derivative if Type-P) + - Converts to RD target via configurable gear mapping (symmetric/asymmetric), then distance-smoothed + - Relief-biased snap when sensor pegged + - Neutral trim near zero + - FlowGuard detection + - Autotune of baseline RD (time/motion near neutral, or two-level duty estimator) + """ + + def __init__(self, cfg: SyncControllerConfig, c0=1.0, x0=None): + self.cfg = cfg + self._set_twolevel_active() + + self._tick = 0 + self._last_time_s = None + self._log_ready = False + + self.K = 2.0 / cfg.buffer_range_mm # mm => normalized delta in x + self.state = EKFState() + self.state.c = max(cfg.c_min, min(cfg.c_max, c0)) + if x0 is not None: + self.state.x = max(-1.0, min(1.0, x0)) + + rd_init = float(cfg.rd_start) + self.rd_current = rd_init # Current rd in effect + self.rd_ref = rd_init # Last "tuned" rd + + # Allows initial wider range of rd until first autotune candidate + self._twolevel_boost_active = True + + self._twolevel_hys_state = 0 # -1, 0, +1 (last hysteretic extreme for type-P) + + # Set absolute limits for rd range + self._set_min_max_rd(rd_init) + + # Readiness (lag-aware) + self._mm_since_info = 0.0 + self._last_info_z = None + + # UI visualization + self._vis_est = 0.0 + + # Two-level flip-flop state (reused for CO/TO and optional P/D) + self._os_target_level = "low" # "low" or "high" + self._os_since_flip_mm = 0.0 + + # FlowGuard engine (encapsulates non-shared FlowGuard state/logic) + self.flowguard = _FlowguardEngine(self) + + # Autotune helper (encapsulates all autotune state/logic) + self.autotune = _AutotuneEngine(self) + + + # ------------------------------------ PUBLIC API ------------------------------------ + + def reset(self, eventtime, rd_init, sensor_reading, log_file=None, hard_reset=True, simulation=False): + """ + Full controller reset for a gear motor swap or new cold start. + Seeds internal time to `t_s` and zeroes elapsed time. + """ + cfg = self.cfg + self._set_twolevel_active() + + self._log_ready = False + self._current_log_file = log_file or cfg.log_file + + # Rotation distance & baseline (always rebase) + self.rd_current = rd_init + self.rd_ref = rd_init + self._twolevel_boost_active = True + self._set_min_max_rd(rd_init) + + # Seed x_hat from sensor reading + if cfg.sensor_type == "P": + z = float(sensor_reading) + x0 = max(-1.0, min(1.0, z)) + self._twolevel_hys_state = int(math.copysign(1, x0)) if abs(x0) >= 1e-6 else 0 + + else: + z = int(sensor_reading) + z = 1 if z > 0 else (-1 if z < 0 else 0) + x0 = float(z) + + # EKF state & covariance + if cfg.sensor_type == "P" and not self.twolevel_active: + self.state.x = float(x0) + self.state.x_prev = self.state.x + self.state.c = 1.0 + self.state.P11 = 0.5 + self.state.P12 = 0.0 + self.state.P22 = 0.2 + + # Readiness (lag-aware) + self._mm_since_info = 0.0 + self._last_info_z = float(x0) if cfg.sensor_type == "P" else int(round(x0)) + + # Time (for update()) + self._tick = 0 + self._last_time_s = eventtime + + # For UI + self._vis_est = float(sensor_reading) + + # Two-level init for CO/TO + if self.cfg.sensor_type in ("CO", "TO"): + in_contact0 = self._onesided_contact(sensor_reading) + if self.cfg.sensor_type == "CO": + self._os_target_level = "high" if in_contact0 else "low" + else: + self._os_target_level = "low" if in_contact0 else "high" + self._os_since_flip_mm = 0.0 + + # Two-level init for P/D (optional) + if self.cfg.sensor_type in ("P", "D") and self.twolevel_active: + pol0 = self._extreme_polarity(sensor_reading) + if pol0 > 0: + self._os_target_level = "high" + elif pol0 < 0: + self._os_target_level = "low" + else: + self._os_target_level = "low" # neutral start; will flip on first extreme + self._os_since_flip_mm = 0.0 + + if hard_reset: + # Rebase autotune helper on the new start + self.autotune.restart(rd_init) + + # Reset FlowGuard engine state on controller reset + self.flowguard.reset() + + # Setup special json debug log + if self.cfg.log_sync: + self._init_log() + + return self.update(eventtime, 0.0, sensor_reading, simulation=simulation) + + + def update(self, eventtime, extruder_delta_mm, sensor_reading, simulation=False): + """ + Required absolute timestamp `t_s` (seconds). Internally computes dt from the + last call (or reset). The timestamp must be monotonic non-decreasing. + """ + cfg = self.cfg + + if self._last_time_s is None: + self._last_time_s = eventtime + + if extruder_delta_mm < 0: + self.autotune.pause() + else: + self.autotune.resume() + + # Compute dt and advance time cursors + dt_s = max(0.0, float(eventtime - self._last_time_s)) # Protect against non-monotonic clock + self._last_time_s = eventtime + d_ext = float(extruder_delta_mm) + + rd_prev = self.rd_current + rd_note = None + d_gear = self._gear_mm_from_rd(d_ext, rd_prev) + + if self.twolevel_active: + # ------------------- TWO-LEVEL BRANCH ------------------ + + # FlowGuard update + flowguard_out = self.flowguard.update_flowguard(d_ext, int(sensor_reading) if cfg.sensor_type in ("CO", "TO", "D") else sensor_reading) + + # Determine immediate RD target from two-level rules + prev_level = self._os_target_level # Capture before helper changes (detect flips) + rd_target = self._twolevel_rd_target(rd_prev, d_ext, sensor_reading) + flipped_this_tick = (self._os_target_level != prev_level) + + # Delegate two-level evidence collection to autotune helper + if cfg.sensor_type in ("CO", "TO"): + extreme_active = self._onesided_contact(sensor_reading) + else: + extreme_active = (self._extreme_polarity(sensor_reading) != 0) + self.autotune.note_twolevel_tick(self._os_target_level, flipped_this_tick, d_ext, extreme_active) + + else: + # -------------------- KALMAN BRANCH -------------------- + + self._ekf_predict(extruder_mm=d_ext, gear_mm=d_gear) + self._ekf_update(float(sensor_reading)) + + # FlowGuard update + flowguard_out = self.flowguard.update_flowguard(d_ext, sensor_reading) + + # Compute immediate RD target + desired_eff = self._desired_effective_gear_mm(d_ext, dt_s) # = c_hat * u_des + c_hat = max(cfg.c_min, min(cfg.c_max, self.state.c)) + u_des = desired_eff / c_hat + rd_target = self._rd_from_desired_gear_mm(d_ext, u_des) + if rd_target is None: + rd_target = rd_prev # no extruder motion; hold RD + + # Relief-biased snap at extremes (guaranteed relief per update) + comp_ext, tens_ext = self._extreme_flags(sensor_reading) + if cfg.snap_at_extremes and d_ext != 0.0 and (comp_ext or tens_ext): + zsign = 1 if comp_ext else -1 # +1 compression, -1 tension + relief_frac = max(0.05, min(0.60, float(cfg.extreme_relief_frac))) + rd_ref = self.rd_ref + + # Derived from: delta_rel = d_ext * (c_hat * rd_ref / rd - 1) + sgn = 1.0 if d_ext > 0 else -1.0 + denom = 1.0 - (sgn * zsign) * relief_frac + denom = max(0.05, denom) + rd_target = (c_hat * rd_ref) / denom + rd_note = "Relief-biased snap at extreme" + + # Smooth target + rd_clamped = self._clamp_to_envelope(rd_target) + rd_target = self._smooth_rd_by_distance(rd_prev, rd_clamped, d_ext, sensor_reading=sensor_reading) + + # ------------- SHARED -------------- + + # Now clamp and apply the newly decided RD for future motion + rd_applied = self._clamp_to_envelope(rd_target) + if not math.isclose(self.rd_current, rd_applied): + self.rd_current = rd_applied + + # Update UI helper + sensor_expected = self._expected_sensor_reading(sensor_reading) + + # Autotune decision + autotune_out = self.autotune.update_autotune(d_ext, dt_s, report_trivial=self._twolevel_boost_active) + auto_rd = autotune_out.get('rd') + if auto_rd is not None: + if self.twolevel_active: + # Only adjust RD reference point if in twolevel mode to "center switching" + self.rd_ref = auto_rd + + # Reset boost (twolevel rd high/low) after first autotune candidate + self._twolevel_boost_active = False + self._set_low_high_rd(auto_rd) + + if flowguard_out.get('trigger'): + self.autotune.restart(self.rd_ref) + + # Essential output + out = { + "output": { + "rd_prev": rd_prev, + "rd_current": self.rd_current, + "rd_tuned": self.autotune.get_tuned_rd(), # What autotune believes to be accurate + "sensor_ui": sensor_expected, + "flowguard": flowguard_out, # Keys: "trigger", "reason", "level", "max_clog", "max_tangle", "active" + "autotune": autotune_out, # Keys: "rd", "note", "save" + } + } + + # Additional debug/logging info + if cfg.log_sync or simulation: + out["output"].update({ + "rd_target": rd_target, # Unclamped target on which rd_current is based + "rd_ref": self.rd_ref, # What EKF or twolevel logic is using as baseline + "rd_note": rd_note, + "x_est": self.state.x, + "c_est": self.state.c + }) + out["input"] = { + "tick": self._tick, + "t_s": eventtime, + "dt_s": dt_s, + "d_mm": extruder_delta_mm, + "sensor": sensor_reading + } + + if cfg.log_sync: + self._append_log_entry(out) + + self.state.x_prev = self.state.x + self._tick += 1 + return out + + + def polarity(self, sensor_reading): + return self._extreme_polarity(sensor_reading) + + + def get_type_mode(self): + sensor_type = self.cfg.sensor_type + if sensor_type == 'P': + sensor_type += " (TwoLevel mode)" if self.twolevel_active else " (EKF mode)" + return sensor_type + + + def get_current_rd(self): + """ + Return the current RD in use + """ + return self.rd_current + + + # --------------------------------- Internal Impl ------------------------------------ + + def _set_twolevel_active(self): + """ + Twolevel mode is updated on each reset to allow responsive behavior to sensor disable + """ + self.twolevel_active = ( + self.cfg.sensor_type in ("CO", "TO", "D") + or (self.cfg.sensor_type == "P" and self.cfg.use_twolevel_for_type_p is True) + ) + + + def _set_min_max_rd(self, rd): + """ + Set absolute immutable min/max rd "speeds" + """ + f_minmax = max(0.0, min(0.99, self.cfg.rd_min_max_speed_multiplier)) + + self.rd_min = rd / (1.0 + f_minmax) + self.rd_max = rd / (1.0 - f_minmax) + self._set_low_high_rd(rd) # Also set current low/high in effect + + + def _set_low_high_rd(self, rd): + """ + Set high/low rd "speeds" + """ + f_minmax = max(0.0, min(0.99, self.cfg.rd_min_max_speed_multiplier)) + if self.twolevel_active: + f_norm = self.cfg.rd_twolevel_speed_multiplier + f_boost = self.cfg.rd_twolevel_boost_multiplier if self._twolevel_boost_active else 0.0 + f = max(0.0, min(f_minmax, (f_norm + f_boost))) + else: + f = f_minmax + + self.rd_low = rd / (1.0 + f) + self.rd_high = rd / (1.0 - f) + + + def _clamp_to_envelope(self, rd): + """ + Never allow rd outside of limits + """ + return max(self.rd_min, min(self.rd_max, rd)) + + # -------------- Mapping helpers ----------------- + + def _gear_mm_from_rd(self, d_ext, rd): + """ + Map RD -> effective gear motion for this update. + Asymmetric mapping: + forward (d_ext > 0): u = d_ext * (rd_ref / rd) + retract (d_ext < 0): u = d_ext * (rd / rd_ref) + """ + rd_ref = self.rd_ref + d_ext = float(d_ext) + if abs(d_ext) < 1e-12: + return 0.0 + + if d_ext > 0.0: + scale = rd_ref / max(1e-9, rd) + else: + scale = max(1e-9, rd) / rd_ref + + return d_ext * scale + + def _rd_from_desired_gear_mm(self, d_ext, u_des): + """ + Invert the asymmetric mapping to get the RD target from desired effective gear motion. + Enforces no in-step reversal: u_des * d_ext must be > 0. + """ + rd_ref = self.rd_ref + d_ext = float(d_ext) + if abs(d_ext) < 1e-12: + return None + + # Prevent reversal within an update + if u_des * d_ext <= 0.0: + return self.rd_high if d_ext > 0 else self.rd_low + + if d_ext > 0.0: + # u = d_ext * (rd_ref / rd) => rd = rd_ref * d_ext / u + denom = u_des if abs(u_des) > 1e-12 else (1e-12) + rd = rd_ref * d_ext / denom + else: + # u = d_ext * (rd / rd_ref) => rd = (u * rd_ref) / d_ext + # (note: d_ext < 0 so division preserves sign correctly) + rd = (u_des * rd_ref) / d_ext + + return rd + + # --------------------- EKF ---------------------- + + def _ekf_predict(self, extruder_mm, gear_mm): + """ + Predict with the RD actually used last update (rd_prev): + """ + s, cfg = self.state, self.cfg + x_pred = s.x + self.K * (s.c * gear_mm - extruder_mm) + c_pred = s.c + + F11 = 1.0 + F12 = self.K * gear_mm + F21 = 0.0 + F22 = 1.0 + + P11, P12, P22 = s.P11, s.P12, s.P22 + FP11 = F11*P11 + F12*P12 + FP12 = F11*P12 + F12*P22 + FP21 = F21*P11 + F22*P12 + FP22 = F21*P12 + F22*P22 + + s.P11 = FP11*F11 + FP12*F12 + cfg.q_x + s.P12 = FP11*F21 + FP12*F22 + s.P22 = FP21*F21 + FP22*F22 + cfg.q_c + + s.x = max(-1.25, min(1.25, x_pred)) # Soft clamp in estimate space + s.c = max(cfg.c_min, min(cfg.c_max, c_pred)) + + + def _ekf_update(self, z): + s, cfg = self.state, self.cfg + z = max(-1.0, min(1.0, float(z))) + R = cfg.r_type + y = z - s.x + S = s.P11 + R + if S <= 0: + return + Kx = s.P11 / S + Kc = s.P12 / S + s.x += Kx * y + s.c += Kc * y + s.c = max(cfg.c_min, min(cfg.c_max, s.c)) + s.P22 -= (s.P12 * Kc) + s.P12 *= (1 - Kx) + s.P11 *= (1 - Kx) + + + # ----------- Sensor reading helpers ---------- + + def _onesided_contact(self, sensor_reading): + """ + True if the one-sided sensor is in-contact (triggered) + """ + cfg = self.cfg + + if cfg.sensor_type == "CO": + return int(sensor_reading) == 1 + + if cfg.sensor_type == "TO": + return int(sensor_reading) == -1 + + return False + + + def _extreme_polarity(self, sensor_reading): + """ + Reduce sensor to a coarse (extreme) states + P : +1 if ≥ threshold, -1 if ≤ -threshold, else 0 + D : {-1,0,+1} as-is + CO: {0, +1} as-is + TO: {-1, 0} as-is + Normally this is the flowguard threshold but the P/D twolevel is + usually a slightly lesser test + """ + cfg = self.cfg + if cfg.sensor_type != "P": + return int(sensor_reading) + + z = float(sensor_reading) + if not self.twolevel_active: + thr = cfg.flowguard_extreme_threshold + return 1 if z >= thr else -1 if z <= -thr else 0 + + # Add hysteresis on twolevel extreme for type-P sensor + hi = abs(float(cfg.p_twolevel_threshold)) + lo = max(0.0, hi - cfg.p_twolevel_hysteresis) + s = self._twolevel_hys_state + + if s != 0: + if s * z <= lo: + s = 0 + else: + s = (z >= hi) - (z <= -hi) + + self._twolevel_hys_state = s + return s + + + def _is_extreme(self, sensor_reading): + """ + True if current reading is pegged per sensor type + """ + return self._extreme_polarity(sensor_reading) != 0 + + + def _extreme_flags(self, sensor_reading): + """ + Return (compression_extreme, tension_extreme) + """ + p = self._extreme_polarity(sensor_reading) + return (p == 1, p == -1) + + # -------------- Twolevel helpers ------------- + + def _twolevel_rd_target(self, rd_prev, d_ext, sensor_reading): + """ + CO/TO: Pure two-level control. + - CO: open -> rd_low (seek compression), contact -> rd_high (relieve) + - TO: open -> rd_high (seek tension), contact -> rd_low (relieve) + Uses a small hysteresis on motion (os_min_flip_mm) so we don't chatter. + Returns the desired RD target for this update (before smoothing/rate limiting). + + P/D: Two-level mode (optional via config). + - Flip only at extremes (neutral band does not change RD). + - Compression extreme -> rd_high; Tension extreme -> rd_low. + """ + cfg = self.cfg + self._os_since_flip_mm += d_ext + + if cfg.sensor_type in ("CO", "TO"): + # Desired level from current contact state + in_contact = self._onesided_contact(sensor_reading) + if cfg.sensor_type == "CO": + desired_level = "high" if in_contact else "low" + else: # "TO" + desired_level = "low" if in_contact else "high" + + else: + # Desired level from polarity + pol = self._extreme_polarity(sensor_reading) # {+1, -1, 0} + if pol == 0: + desired_level = self._os_target_level + else: + desired_level = "high" if (pol > 0) else "low" + + # Flip only if we've moved enough since the last flip + if desired_level != self._os_target_level and abs(self._os_since_flip_mm) >= cfg.os_min_flip_mm: + self._os_target_level = desired_level + self._os_since_flip_mm = 0.0 + + # Map level to RD + rd_target = self.rd_low if self._os_target_level == "low" else self.rd_high + + return rd_target + + # ----------------- EKF helpers --------------- + + def _desired_effective_gear_mm(self, d_ext, dt_s): + s, cfg = self.state, self.cfg + dead = max(0.0, cfg.ctrl_deadband) + x = s.x + x_ctrl = 0.0 if abs(x) < dead else (x - math.copysign(dead, x)) + + kd_eff = cfg.kd if dt_s > 0 else 0.0 + dx = (s.x - s.x_prev) / max(1e-9, dt_s) if kd_eff != 0.0 else 0.0 + + return d_ext - cfg.kp * x_ctrl - kd_eff * dx + + + def _smooth_rd_by_distance(self, rd_prev, rd_target, d_ext, sensor_reading=None): + """ + Glide the current RD towards target using a fixed rd_filter_len_mm motion length + respecting readiness factor and extreme limits. + """ + move = abs(float(d_ext)) + + # Exponential smoothing for soft glide from rd_prev toward rd_target. + # Bigger move or higher r => bigger step. + L = max(1e-9, self.cfg.rd_filter_len_mm) + alpha_base = 1.0 - math.exp(-move / L) + r = self._update_readiness_and_get_r(sensor_reading, move) if sensor_reading is not None else 1.0 + alpha = r * alpha_base + + rd_filtered = rd_prev + alpha * (rd_target - rd_prev) + + # Rate limit (with extreme multiplier) + is_extreme = self._is_extreme(sensor_reading) if sensor_reading is not None else False + if self.cfg.rd_rate_per_mm is not None and move > 0: + rate_mult = (self.cfg.rate_extreme_multiplier if is_extreme else 1.0) + max_step = abs(self.cfg.rd_rate_per_mm) * move * r * rate_mult + rd_delta = rd_filtered - rd_prev + if rd_delta > max_step: + rd_filtered = rd_prev + max_step + elif rd_delta < -max_step: + rd_filtered = rd_prev - max_step + + return rd_filtered + + + def _update_readiness_and_get_r(self, sensor_reading, move_abs_mm): + """ + Returns (lag-aware) readiness value for 0=not ready, to 1=ready now + The purpose is so that we don’t react fully until we’ve seen enough + motion or a meaningful sensor change. + P-EKF only + """ + cfg = self.cfg + if cfg.sensor_lag_mm <= 0: + r = 1.0 + else: + self._mm_since_info += move_abs_mm + z = float(sensor_reading) + if self._last_info_z is None or abs(z - self._last_info_z) >= cfg.info_delta_a: + self._last_info_z = z + self._mm_since_info = 0.0 + L = max(1e-6, cfg.sensor_lag_mm) + r = max(0.0, min(1.0, self._mm_since_info / L)) + + if self._is_extreme(sensor_reading): + r = max(r, cfg.readiness_extreme_floor) + return r + + + def _expected_sensor_reading(self, sensor_reading): + """ + UI helper for prediction of idealized sensor reading. + Returns a float in [-1, 1] depending on sensor type. + """ + cfg = self.cfg + + # Type P: always passthrough true sensor reading + if cfg.sensor_type == "P": + self._vis_est = sensor_reading + return self._vis_est + + # Snap to extremes for D/CO/TO when pegged: + if self._is_extreme(sensor_reading): + self._vis_est = float(self._extreme_polarity(sensor_reading)) + return self._vis_est + + # Get phase info + ph = self.autotune.twolevel_phase(exclude_extreme=cfg.sensor_type == "D") + + # Snap to extreme if no phase info available + if ph is None: + self._vis_est = float(self._extreme_polarity(sensor_reading)) + return self._vis_est + + phase, level, extruding = ph + + # Type CO/TO: Split phase to encompass rebound + if cfg.sensor_type in ['CO', 'TO']: + + def triangle_half(p, lo=0.3, hi=0.8): + base = (1.0 - 2.0*p) if p <= 0.5 else (2.0*p - 1.0) # in [0,1] + return lo + (hi - lo) * base + + if cfg.sensor_type == "CO": + self._vis_est = triangle_half(phase) + else: + self._vis_est = -triangle_half(phase) + + return self._vis_est + + # Type D: Adjust phase to exclude extreme portion + def triangle_full(p, lo=-0.9, hi=0.9): + return lo + (hi - lo) * p + + t = triangle_full(phase) + if level == "low": + x_pred = t if extruding else -t # Ramping up if extruding + else: + x_pred = -t if extruding else t # Ramping down if extruding + self._vis_est = x_pred + return self._vis_est + + + # -------------- Logging helpers -------------- + + def _init_log(self, log_file=None): + """ + (Re)create the log file and write a single header entry. + Clears any existing file. + """ + header = { + "header": { + "rd_start": self.cfg.rd_start, + "sensor_type": self.cfg.sensor_type, + "twolevel_active": self.twolevel_active, + "buffer_range_mm": self.cfg.buffer_range_mm, + "buffer_max_range_mm": self.cfg.buffer_max_range_mm, + } + } + + with io.open(self._current_log_file, "a", encoding="utf-8") as f: + json.dump(header, f, ensure_ascii=False) + f.write("\n") + self._log_ready = True + + + def _append_log_entry(self, record): + """ + Append a single JSON object to the log as one line. + """ + if not self._log_ready: + return + + with io.open(self._current_log_file, "a", encoding="utf-8") as f: + json.dump(record, f, ensure_ascii=False) + f.write("\n") diff --git a/klippy/extras/mmu/mmu_sync_feedback_manager.py b/klippy/extras/mmu/mmu_sync_feedback_manager.py new file mode 100644 index 000000000000..c3238c8c124c --- /dev/null +++ b/klippy/extras/mmu/mmu_sync_feedback_manager.py @@ -0,0 +1,896 @@ +# -*- coding: utf-8 -*- +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Manager class to handle sync-feedback and adjustment of gear stepper rotation distance +# to keep MMU in sync with extruder as well as some filament tension routines. +# +# FlowGuard: It also implements protection for all modes/sensor types that will trigger +# on clog (at extruder) or tangle (at MMU) conditions. +# +# Autotune: An autotuning option can be enabled for dynamic tuning (and persistence) of +# calibrated MMU gear rotation_distance. +# +# Implements commands: +# MMU_SYNC_FEEDBACK +# MMU_FLOWGUARD +# +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, math, time, os + +# Happy Hare imports + +# MMU subcomponent clases +from .mmu_sync_controller import SyncControllerConfig, SyncController +from .mmu_extruder_monitor import ExtruderMonitor +from .mmu_shared import MmuError + +class MmuSyncFeedbackManager: + + SF_STATE_NEUTRAL = 0 + SF_STATE_COMPRESSION = 1 + SF_STATE_TENSION = -1 + + def __init__(self, mmu): + self.mmu = mmu + self.mmu.managers.append(self) + + self.estimated_state = float(self.SF_STATE_NEUTRAL) + self.active = False # Sync-feedback actively operating? + self.flowguard_active = False # FlowGuard armed? + self.ctrl = None + self.flow_rate = 100. # Estimated % flowrate (calc only for proportional sensors) + + # Process config + self.sync_feedback_enabled = self.mmu.config.getint('sync_feedback_enabled', 0, minval=0, maxval=1) + self.sync_feedback_buffer_range = self.mmu.config.getfloat('sync_feedback_buffer_range', 10., minval=0.) + self.sync_feedback_buffer_maxrange = self.mmu.config.getfloat('sync_feedback_buffer_maxrange', 10., minval=0.) + self.sync_feedback_speed_multiplier = self.mmu.config.getfloat('sync_feedback_speed_multiplier', 5, minval=1, maxval=50) + self.sync_feedback_boost_multiplier = self.mmu.config.getfloat('sync_feedback_boost_multiplier', 5, minval=1, maxval=50) + self.sync_feedback_extrude_threshold = self.mmu.config.getfloat('sync_feedback_extrude_threshold', 5, above=1.) + self.sync_feedback_debug_log = self.mmu.config.getint('sync_feedback_debug_log', 0) + self.sync_feedback_force_twolevel = self.mmu.config.getint('sync_feedback_force_twolevel', 0) # Not exposed + + # FlowGuard + self.flowguard_enabled = self.mmu.config.getint('flowguard_enabled', 1, minval=0, maxval=1) + self.flowguard_max_relief = self.mmu.config.getfloat('flowguard_max_relief', 8, above=1.) + self.flowguard_encoder_mode = self.mmu.config.getint('flowguard_encoder_mode', 2, minval=0, maxval=2) + self.flowguard_encoder_max_motion = self.mmu.config.getfloat('flowguard_encoder_max_motion', 20, above=0.) + + # Setup events for managing motor synchronization + self.mmu.printer.register_event_handler("mmu:synced", self._handle_mmu_synced) + self.mmu.printer.register_event_handler("mmu:unsynced", self._handle_mmu_unsynced) + self.mmu.printer.register_event_handler("mmu:sync_feedback", self._handle_sync_feedback) + + # Initial flowguard status + self.flowguard_status = {'trigger': '', 'reason': '', 'level': 0.0, 'max_clog': 0.0, 'max_tangle': 0.0, 'active': False, 'enabled': bool(self.flowguard_enabled)} + + # Register GCODE commands --------------------------------------------------------------------------- + + self.mmu.gcode.register_command('MMU_SYNC_FEEDBACK', self.cmd_MMU_SYNC_FEEDBACK, desc=self.cmd_MMU_SYNC_FEEDBACK_help) + self.mmu.gcode.register_command('MMU_FLOWGUARD', self.cmd_MMU_FLOWGUARD, desc=self.cmd_MMU_FLOWGUARD_help) + + self.extruder_monitor = ExtruderMonitor(mmu) + + + # + # Standard mmu manager hooks... + # + + def reinit(self): + self.extruder_monitor.enable() + + + def handle_connect(self): + self._init_controller() + + + def handle_disconnect(self): + pass + + + def set_test_config(self, gcmd): + if self.has_sync_feedback(): + self.sync_feedback_enabled = gcmd.get_int('SYNC_FEEDBACK_ENABLED', self.sync_feedback_enabled, minval=0, maxval=1) + self.sync_feedback_buffer_range = gcmd.get_float('SYNC_FEEDBACK_BUFFER_RANGE', self.sync_feedback_buffer_range, minval=0.) + self.sync_feedback_buffer_maxrange = gcmd.get_float('SYNC_FEEDBACK_BUFFER_MAXRANGE', self.sync_feedback_buffer_maxrange, minval=0.) + self.sync_feedback_speed_multiplier = gcmd.get_float('SYNC_FEEDBACK_SPEED_MULTIPLIER', self.sync_feedback_speed_multiplier, minval=1., maxval=50) + self.sync_feedback_boost_multiplier = gcmd.get_float('SYNC_FEEDBACK_BOOST_MULTIPLIER', self.sync_feedback_boost_multiplier, minval=1., maxval=50) + self.sync_feedback_extrude_threshold = gcmd.get_float('SYNC_FEEDBACK_EXTRUDE_THRESHOLD', self.sync_feedback_extrude_threshold, above=1.) + self.sync_feedback_debug_log = gcmd.get_int('SYNC_FEEDBACK_DEBUG_LOG', self.sync_feedback_debug_log, minval=0, maxval=1) + + flowguard_enabled = gcmd.get_int('FLOWGUARD_ENABLED', self.flowguard_enabled, minval=0, maxval=1) + if flowguard_enabled != self.flowguard_enabled: + self._config_flowguard_feature(flowguard_enabled) + self.flowguard_max_relief = gcmd.get_float('FLOWGUARD_MAX_RELIEF', self.flowguard_max_relief, above=1.) + + if self.mmu.has_encoder(): + mode = gcmd.get_int('FLOWGUARD_ENCODER_MODE', self.flowguard_encoder_mode, minval=0, maxval=2) + if mode != self.flowguard_encoder_mode: + self.flowguard_encoder_mode = mode + self.set_encoder_mode() + self.flowguard_encoder_max_motion = gcmd.get_float('FLOWGUARD_ENCODER_MAX_MOTION', self.flowguard_encoder_max_motion, above=0.) + + + def get_test_config(self): + msg = "" + if self.has_sync_feedback(): + msg += "\nsync_feedback_enabled = %d" % self.sync_feedback_enabled + msg += "\nsync_feedback_buffer_range = %.1f" % self.sync_feedback_buffer_range + msg += "\nsync_feedback_buffer_maxrange = %.1f" % self.sync_feedback_buffer_maxrange + msg += "\nsync_feedback_speed_multiplier = %.1f" % self.sync_feedback_speed_multiplier + msg += "\nsync_feedback_boost_multiplier = %.1f" % self.sync_feedback_boost_multiplier + msg += "\nsync_feedback_extrude_threshold = %.1f" % self.sync_feedback_extrude_threshold + msg += "\nsync_feedback_debug_log = %d" % self.sync_feedback_debug_log + + msg += "\n\nFLOWGUARD:" + msg += "\nflowguard_enabled = %d" % self.flowguard_enabled + msg += "\nflowguard_max_relief = %.1f" % self.flowguard_max_relief + + if self.mmu.has_encoder(): + msg += "\nflowguard_encoder_mode = %d" % self.flowguard_encoder_mode + msg += "\nflowguard_encoder_max_motion = %.1f" % self.flowguard_encoder_max_motion + return msg + + + def check_test_config(self, param): + return vars(self).get(param) is None + + # + # Sync feedback manager public access... + # + + def set_default_rd(self): + """ + Ensure correct starting rotation distance + """ + gate = self.mmu.gate_selected + if gate < 0: return + + rd = self.mmu.calibration_manager.get_gear_rd(gate) + self.mmu.log_debug("MmuSyncFeedbackManager: Setting default rotation distance for gate %d to %.4f" % (gate, rd)) + self.mmu.set_gear_rotation_distance(rd) + + + def is_enabled(self): + """ + This is whether the user has enabled the sync-feedback feature (the "big" switch) + """ + return self.sync_feedback_enabled + + + def is_active(self): + """ + Returns whether the sync-feedback is currently active (when synced) + """ + return self.active + + + def get_sync_feedback_string(self, state=None, detail=False): + if state is None: + state = self._get_sensor_state() + if (self.mmu.is_enabled and self.sync_feedback_enabled and self.active) or detail: + # Polarity varies slightly between modes on proportional sensor so ask controller + polarity = self.ctrl.polarity(state) + return 'compressed' if polarity > 0 else 'tension' if polarity < 0 else 'neutral' + elif self.mmu.is_enabled and self.sync_feedback_enabled: + return "inactive" + return "disabled" + + + def activate_flowguard(self, eventtime): + if self.flowguard_enabled and not self.flowguard_active: + self.flowguard_active = True + # This resets controller with last good autotuned RD, resets flowguard and resumes autotune + self._reset_controller(eventtime, hard_reset=False) + self.ctrl.autotune.resume() + self.mmu.log_info("FlowGuard monitoring activated and autotune resumed") + + + def deactivate_flowguard(self, eventtime): + if self.flowguard_enabled and self.flowguard_active: + self.flowguard_active = False + self.ctrl.autotune.pause() # Very likley this is a period that we want to exclude from autotuning + self.mmu.log_info("FlowGuard monitoring deactivated and autotune paused") + + + # This is "FlowGuard" on the encoder so manage it here + def set_encoder_mode(self, mode=None): + """ + Changing detection mode so ensure correct clog detection length + """ + if not self.mmu.has_encoder(): return + + # Notify sensor of mode + if mode is None: mode = self.flowguard_encoder_mode + self.mmu.encoder_sensor.set_mode(mode) + + # Figure out the correct detection length based on mode + cdl = self.flowguard_encoder_max_motion + if mode == self.mmu.encoder_sensor.RUNOUT_AUTOMATIC: + cdl = self.mmu.save_variables.allVariables.get(self.mmu.VARS_MMU_CALIB_CLOG_LENGTH, cdl) + + # Notify sensor of detection length + self.mmu.encoder_sensor.set_clog_detection_length(cdl) + + + def adjust_filament_tension(self, use_gear_motor=True, max_move=None): + """ + Relax the filament tension, preferring proportional control if available else sync-feedback sensor switches. + By default uses gear stepper to achive the result but optionally can use just extruder stepper for + extruder entry check using compression sensor 'max_move' is advisory maximum travel distance + Returns distance of the correction move and whether operation was successful (or None if not performed) + """ + has_tension, has_compression, has_proportional = self.get_active_sensors() + max_move = max_move or self.sync_feedback_buffer_maxrange + + if has_proportional: + return self._adjust_filament_tension_proportional() # Doesn't yet support extruder stepper or max_move parameter + + if has_tension or has_compression: + return self._adjust_filament_tension_switch(use_gear_motor=use_gear_motor, max_move=max_move) + + # All sensors must be disabled... + return 0.0, None + + + def wipe_telemetry_logs(self): + """ + Called to wipe any sync debug files on print start + """ + for gate in range(self.mmu.num_gates): + log_path = self._telemetry_log_path(gate) + + # Can't wipe if already synced and active + if gate != self.mmu.gate_selected or not self.active: + if os.path.exists(log_path): + try: + os.remove(log_path) + self.mmu.log_error("REMOVED log_path=%s" % log_path) + except OSError as e: + self.mmu.log_debug("Unable to wipe sync feedback debug log: %s" % log_path) + + + def get_active_sensors(self): + """ + Returns tuple of active sync-feedback sensors + """ + sm = self.mmu.sensor_manager + has_tension = sm.has_sensor(self.mmu.SENSOR_TENSION) + has_compression = sm.has_sensor(self.mmu.SENSOR_COMPRESSION) + has_proportional = sm.has_sensor(self.mmu.SENSOR_PROPORTIONAL) + return has_tension, has_compression, has_proportional + + + def has_sync_feedback(self): + return all(s is not None for s in self.get_active_sensors()) + + + # + # GCODE Commands ----------------------------------------------------------- + # + + cmd_MMU_FLOWGUARD_help = "Enable/disable FlowGuard (clog-tangle detection)" + cmd_MMU_FLOWGUARD_param_help = ( + "MMU_FLOWGUARD: %s\n" % cmd_MMU_FLOWGUARD_help + + "ENABLE = [1|0] enable/disable FlowGuard clog/tangle detection\n" + + "(no parameters for status report)" + ) + def cmd_MMU_FLOWGUARD(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + + if not self.sync_feedback_enabled: + self.mmu.log_warning("Sync feedback is disabled or not configured. FlowGuard is unavailable") + return + + if gcmd.get_int('HELP', 0, minval=0, maxval=1): + self.mmu.log_always(self.mmu.format_help(self.cmd_MMU_FLOWGUARD_param_help), color=True) + return + + enable = gcmd.get_int('ENABLE', None, minval=0, maxval=1) + + if enable is not None: + self._config_flowguard_feature(enable) + return + + # Just report status + if self.flowguard_enabled: + active = " and currently active" if self.flowguard_active else " (not currently active)" + self.mmu.log_always("FlowGuard monitoring feature is enabled%s" % active) + else: + self.mmu.log_always("FlowGuard monitoring feature is disabled") + + + cmd_MMU_SYNC_FEEDBACK_help = "Controls sync feedback and applies filament tension adjustments" + cmd_MMU_SYNC_FEEDBACK_param_help = ( + "MMU_SYNC_FEEDBACK: %s\n" % cmd_MMU_SYNC_FEEDBACK_help + + "ENABLE = [1|0] enable/disable sync feedback control\n" + + "RESET = [1|0] reset sync controller and return RD to last known good value\n" + + "ADJUST_TENSION = [1|0] apply correction to neutralize filament tension\n" + + "AUTOTUNE = [1|0] allow saving of autotuned rotation distance\n" + + "(no parameters for status report)" + ) + def cmd_MMU_SYNC_FEEDBACK(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + if self.mmu.check_if_bypass(): return + + if gcmd.get_int('HELP', 0, minval=0, maxval=1): + self.mmu.log_always(self.mmu.format_help(self.cmd_MMU_SYNC_FEEDBACK_param_help), color=True) + return + + if not self.has_sync_feedback(): + self.mmu.log_warning("No sync-feedback sensors!") + return + + has_tension, has_compression, has_proportional = self.get_active_sensors() + + if not (has_proportional or has_tension or has_compression): + self.mmu.log_warning("No sync-feedback sensors are enabled!") + return + + enable = gcmd.get_int('ENABLE', None, minval=0, maxval=1) + reset = gcmd.get_int('RESET', None, minval=0, maxval=1) + autotune = gcmd.get_int('AUTOTUNE', None, minval=0, maxval=1) + adjust_tension = gcmd.get_int('ADJUST_TENSION', 0, minval=0, maxval=1) + + if enable is not None: + self.sync_feedback_enabled = enable + self.mmu.log_always("Sync feedback feature is %s" % ("enabled" if enable else "disabled")) + + if reset is not None and self.sync_feedback_enabled: + self.mmu.log_always("Sync feedback reset") + eventtime = self.mmu.reactor.monotonic() + self._reset_controller(eventtime) + + if autotune is not None: + self.mmu.autotune_rotation_distance = autotune + self.mmu.log_always("Save Autotuned rotation distance feature is %s" % ("enabled" if autotune else "disabled")) + + if adjust_tension: + try: + with self.mmu.wrap_sync_gear_to_extruder(): # Cannot adjust sync feedback sensor if gears are not synced + with self.mmu._wrap_suspend_filament_monitoring(): # Avoid spurious runout during tiny corrective moves (unlikely) + actual,success = self.adjust_filament_tension() + if success: + self.mmu.log_info("Neutralized tension after moving %.2fmm" % actual) + elif success is False: + self.mmu.log_warning("Moved %.2fmm without neutralizing tension" % actual) + else: + self.mmu.log_warning("Operation not possible. Perhaps sensors are disabled?") + + except MmuError as ee: + self.mmu.log_error("Error in MMU_SYNC_FEEDBACK: %s" % str(ee)) + + if enable is None and autotune is None and not adjust_tension: + # Just report status + if self.sync_feedback_enabled: + mode = self.ctrl.get_type_mode() + active = " and currently active" if self.active else " (not currently active)" + msg = "Sync feedback feature with type-%s sensor is enabled%s\n" % (mode, active) + + rd_start = self.mmu.calibration_manager.get_gear_rd() + rd_current = self.ctrl.get_current_rd() + rd_rec = self.ctrl.autotune.get_rec_rd() + msg += "- Current RD: %.2f, Autotune recommended: %.2f, Default: %.2f\n" % (rd_current, rd_rec, rd_start) + + has_tension, has_compression, has_proportional = self.get_active_sensors() + msg += "- State: %s\n" % self.get_sync_feedback_string(detail=True) + msg += "- FlowGuard: %s" % ("Active" if self.flowguard_active else "Inactive") + if has_proportional: + msg += " (Flowrate: %.1f%%)" % self.flow_rate + + self.mmu.log_always(msg) + + else: + self.mmu.log_always("Sync feedback feature is disabled") + + + def get_status(self, eventtime=None): + self.flowguard_status['encoder_mode'] = self.flowguard_encoder_mode # Ok to mutate status + return { + 'sync_feedback_state': self.get_sync_feedback_string(), + 'sync_feedback_enabled': self.is_enabled(), + 'sync_feedback_bias_raw': self._get_sync_bias_raw(), + 'sync_feedback_bias_modelled': self._get_sync_bias_modelled(), + 'sync_feedback_flow_rate': self.flow_rate, + 'flowguard': self.flowguard_status, + } + + + # + # Internal implementation -------------------------------------------------- + # + + def _telemetry_log_path(self, gate=None): + if gate is None: gate = self.mmu.gate_selected + + logfile_path = self.mmu.printer.start_args['log_file'] + dirname = os.path.dirname(logfile_path) + + if not dirname: + dirname = "/tmp" + + return os.path.join(dirname, 'sync_%d.jsonl' % gate) + + + def _handle_mmu_synced(self, eventtime=None): + """ + Event indicating that gear stepper is now synced with extruder + """ + if not self.mmu.is_enabled: return + if eventtime is None: eventtime = self.mmu.reactor.monotonic() + + msg = "MmuSyncFeedbackManager: Synced MMU to extruder%s" % (" (sync feedback activated)" if self.sync_feedback_enabled else "") + if self.mmu.mmu_machine.filament_always_gripped: + self.mmu.log_debug(msg) + else: + self.mmu.log_info(msg) + + if self.active: return + + # Enable sync feedback + self.active = True + self.new_autotuned_rd = None + + # Throw away current autotune info and reset rd + self._reset_controller(eventtime) + + # Turn on extruder movement events + self.extruder_monitor.register_callback(self._handle_extruder_movement, self.sync_feedback_extrude_threshold) + + + def _handle_mmu_unsynced(self, eventtime=None): + """ + Event indicating that gear stepper has been unsynced from extruder + """ + if not (self.mmu.is_enabled and self.sync_feedback_enabled and self.active): return + if eventtime is None: eventtime = self.mmu.reactor.monotonic() + + msg = "MmuSyncFeedbackManager: Unsynced MMU from extruder%s" % (" (sync feedback deactivated)" if self.sync_feedback_enabled else "") + if self.mmu.mmu_machine.filament_always_gripped: + self.mmu.log_debug(msg) + else: + self.mmu.log_info(msg) + + if not self.active: return + + # Deactivate sync feedback + self.active = False + + if self.new_autotuned_rd is not None: + self.mmu.log_info("MmuSyncFeedbackManager: New Autotuned rotation distance (%.4f) for gate %d\n" % (self.new_autotuned_rd, self.mmu.gate_selected)) + self.mmu.calibration_manager.update_gear_rd(self.new_autotuned_rd) + + # Restore default (last tuned) rotation distance + self.set_default_rd() + + # Optional but let's turn off extruder movement events + self.extruder_monitor.remove_callback(self._handle_extruder_movement) + + + def _handle_extruder_movement(self, eventtime, move): + """ + Event call when extruder has moved more than threshold. This also allows for + periodic rotation_distance adjustment, autotune and flowguard checking + """ + if not (self.mmu.is_enabled and self.sync_feedback_enabled and self.active): return + if eventtime is None: eventtime = self.mmu.reactor.monotonic() + + self.mmu.log_trace("MmuSyncFeedbackManager: Extruder movement event, move=%.1f" % move) + + state = self._get_sensor_state() + status = self.ctrl.update(eventtime, move, state) + self._process_status(eventtime, status) + + + def _handle_sync_feedback(self, eventtime, state): + """ + Event call when sync-feedback discrete state changes. + 'state' should be -1 (tension), 0 (neutral), 1 (compressed) + or can be a proportional float value between -1.0 and 1.0 + """ + if not (self.mmu.is_enabled and self.sync_feedback_enabled and self.active): return + if eventtime is None: eventtime = self.mmu.reactor.monotonic() + + msg = "MmuSyncFeedbackManager: Sync state changed to %s" % (self.get_sync_feedback_string(state)) + if self.mmu.mmu_machine.filament_always_gripped: + self.mmu.log_debug(msg) + else: + self.mmu.log_info(msg) + + move = self.extruder_monitor.get_and_reset_accumulated(self._handle_extruder_movement) + status = self.ctrl.update(eventtime, move, state) + self._process_status(eventtime, status) + + + def _process_status(self, eventtime, status): + """ + Common logic to process the rotation distance recommendations of the sync controller + """ + output = status['output'] + + # Handle estimated sensor position + self.estimated_state = output['sensor_ui'] + + # Handle flowguard trip + self.flowguard_status = dict(output['flowguard']) + self.flowguard_status['enabled'] = bool(self.flowguard_enabled) + f_trigger = self.flowguard_status.get('trigger', None) + f_reason = self.flowguard_status.get('reason', "") + if f_trigger: + if self.flowguard_enabled and self.flowguard_active: + self.mmu.log_error("FlowGuard detected a %s.\nReason for trip: %s" % (f_trigger, f_reason)) + + # Pick most appropriate sensor to assign event to (primariliy for optics) + has_tension, has_compression, has_proportional = self.get_active_sensors() + + if has_proportional: + sensor_key = self.mmu.SENSOR_PROPORTIONAL + elif has_compression and not has_tension: + sensor_key = self.mmu.SENSOR_COMPRESSION + elif has_tension and not has_compression: + sensor_key = self.mmu.SENSOR_TENSION + elif f_trigger == "clog": + sensor_key = self.mmu.SENSOR_COMPRESSION + else: # "tangle" + sensor_key = self.mmu.SENSOR_TENSION + sm = self.mmu.sensor_manager + sensor = sm.sensors.get(sensor_key) + + sensor.runout_helper.note_clog_tangle(f_trigger) + self.deactivate_flowguard(eventtime) + else: + self.mmu.log_debug("FlowGuard detected a %s, but handling is disabled.\nReason for trip: %s" % (f_trigger, f_reason)) + self.ctrl.flowguard.reset() # Prevent repetitive messages + + # Handle new autotune suggestions + autotune = output['autotune'] + rd = autotune.get('rd', None) + note = autotune.get('note', None) + save = autotune.get('save', None) + if rd is not None: + msg = "MmuSyncFeedbackManager: Autotune suggested new operational reference rd: %.4f\n%s" % (rd, note) + if save and self.mmu.autotune_rotation_distance: + self.new_autotuned_rd = rd + self.mmu.log_debug(msg) + + # Always update instaneous gear stepper rotation_distance + rd_current, rd_prev, rd_tuned = output['rd_current'], output['rd_prev'], output['rd_tuned'] + if rd_current != rd_prev: + self.mmu.log_debug("MmuSyncFeedbackManager: Altered rotation distance for gate %d from %.4f to %.4f" % (self.mmu.gate_selected, rd_prev, rd_current)) + self.mmu.set_gear_rotation_distance(rd_current) + + # Proportional sensor (with autotune) allows for estimation of flow rate! + if self.mmu.sensor_manager.has_sensor(self.mmu.SENSOR_PROPORTIONAL): + # if rd_current > rd_true then flowrate must be reduced + self.flow_rate = round(min(1.0, (rd_tuned / rd_current)) * 100., 2) + + + def _reset_controller(self, eventtime, hard_reset=True): + """ + hard_reset: Completely reset sync-feedback: throw away autotune info, reset rd to + last calibrated value. Typically called when handling sync but also can + be explicitly called but MMU_SYNC_FEEDBACK command + soft_reset: Rebase sync-feedback to last autotuned value. Typically called when + resuming flowguard (after some activity we want to exclude from tuning) + """ + # Allow dynamic changing of effective "sensor type" based on currently enabled sensors + self.ctrl.cfg.sensor_type = self._get_sensor_type() + + # Reset controller with initial rd and sensor reading (will also reset flowguard and autotune on hard_reset) + starting_state = self._get_sensor_state() + self.estimated_state = starting_state + if hard_reset: + rd_start = self.mmu.calibration_manager.get_gear_rd() + else: + rd_start = self.ctrl.autotune.get_rec_rd() + status = self.ctrl.reset(eventtime, rd_start, starting_state, log_file=self._telemetry_log_path(), hard_reset=hard_reset) + self._process_status(eventtime, status) # May adjust rotation_distance + + + def _init_controller(self): + """ + The controller logic is in a completely standalone module for simulation + and debugging purposes so instantiate it here with current config + Returns: the SyncController object + """ + rd_start = self.mmu.calibration_manager.get_gear_rd() + cfg = SyncControllerConfig( + log_sync = bool(self.sync_feedback_debug_log), + buffer_range_mm = self.sync_feedback_buffer_range, + buffer_max_range_mm = self.sync_feedback_buffer_maxrange, + sensor_type = self._get_sensor_type(), + use_twolevel_for_type_p = self.sync_feedback_force_twolevel, + rd_start = rd_start, + flowguard_relief_mm = self.flowguard_max_relief, + ) + self.ctrl = SyncController(cfg) + return self.ctrl + + + def _config_flowguard_feature(self, enable): + if enable: + self.mmu.log_info("FlowGuard monitoring feature %senabled" % ("already " if self.flowguard_enabled else "")) + if not self.flowguard_enabled: + self.flowguard_enabled = True + if self.ctrl: + self.ctrl.flowguard.reset() + else: + self.mmu.log_info("FlowGuard monitoring feature %sdisabled" % ("already " if not self.flowguard_enabled else "")) + self.flowguard_enabled = False + + + def _get_sync_bias_raw(self): + return float(self._get_sensor_state()) + + + def _get_sync_bias_modelled(self): + if self.mmu.is_enabled and self.sync_feedback_enabled and self.active and self.mmu.is_printing(): + # This is a better representation for UI when the controller is active + return self.estimated_state + else: + # Otherwise return the real state + return float(self._get_sensor_state()) + + + def _get_sensor_state(self): + """ + Get current tension state based on current sensor feedback. + Returns float in range [-1.0 .. 1.0] for proportional, {-1, 0, 1) for switch + """ + sm = self.mmu.sensor_manager + has_proportional = sm.has_sensor(self.mmu.SENSOR_PROPORTIONAL) + if has_proportional: + sensor = sm.sensors.get(self.mmu.SENSOR_PROPORTIONAL) + return sensor.get_status(0).get('value', 0.) + + tension_active = sm.check_sensor(self.mmu.SENSOR_TENSION) + compression_active = sm.check_sensor(self.mmu.SENSOR_COMPRESSION) + + if tension_active == compression_active: + ss = self.SF_STATE_NEUTRAL + elif compression_active: + ss = self.SF_STATE_COMPRESSION + elif tension_active: + ss = self.SF_STATE_TENSION + else: + ss = self.SF_STATE_NEUTRAL + return ss + + + def _get_sensor_type(self): + """ + Return symbolic sensor type based on current active sensors + "P" => proportional z ∈ [-1, +1]; enables EKF + "D" => discrete dual-switch z ∈ {-1,0,+1}; Optional EKF + "CO" => compression-only switch z ∈ {0,+1} + "TO" => tension_only switch z ∈ {-1,0} + """ + has_tension, has_compression, has_proportional = self.get_active_sensors() + return ( + "P" if has_proportional + else "D" if has_compression and has_tension + else "CO" if has_compression + else "TO" if has_tension + else "Unknown" + ) + + + def _adjust_filament_tension_switch(self, use_gear_motor=True, max_move=None): + """ + Helper to relax filament tension using the sync-feedback buffer. This can be performed either with the + gear motor (default) or extruder motor (which is also good as an extruder loading check) + Returns distance moved and whether operation was successful and neutral was found (or None if not performed) + """ + fhomed = None + actual = 0. + + state = self._get_sensor_state() + if state == self.SF_STATE_NEUTRAL: + return actual, True + + has_tension, has_compression, _ = self.get_active_sensors() + if not (has_tension or has_compression): + self.mmu.log_debug("No active sync feedback sensors; cannot adjust filament tension") + return actual, fhomed + + max_move = max_move or self.sync_feedback_buffer_maxrange + self.mmu.log_debug("Monitoring extruder entrance transition for up to %.1fmm..." % max_move) + + motor = "gear" if use_gear_motor else "extruder" + speed = min(self.mmu.gear_homing_speed, self.mmu.extruder_homing_speed) # Keep this tension adjustment slow + + # Determine direction based on state and motor type + # Note that if sync_feedback_buffer_range is 0, it implies + # special case where neutral point overlaps both sensors + if state == self.SF_STATE_COMPRESSION: + self.mmu.log_debug("Relaxing filament compression") + direction = -1 if use_gear_motor else 1 + + if self.sync_feedback_buffer_range == 0: + msg = "Homing to tension sensor" + sensor = self.mmu.SENSOR_TENSION + homing_dir = 1 + elif has_compression: + msg = "Reverse homing off compression sensor" + sensor = self.mmu.SENSOR_COMPRESSION + homing_dir = -1 + else: + msg = "Homing to tension sensor" + sensor = self.mmu.SENSOR_TENSION + homing_dir = 1 + + else: + # Tension state + self.mmu.log_debug("Relaxing filament tension") + direction = 1 if use_gear_motor else -1 + + if self.sync_feedback_buffer_range == 0: + msg = "Homing to compression sensor" + sensor = self.mmu.SENSOR_COMPRESSION + homing_dir = 1 + elif has_tension: + msg = "Reverse homing off tension sensor" + sensor = self.mmu.SENSOR_TENSION + homing_dir = -1 + else: + msg = "Homing to compression sensor" + sensor = self.mmu.SENSOR_COMPRESSION + homing_dir = 1 + + actual,fhomed,_,_ = self.mmu.trace_filament_move( + msg, + max_move * direction, + speed=speed, + motor=motor, + homing_move=homing_dir, + endstop_name=sensor, + ) + + if fhomed and self.sync_feedback_buffer_range != 0: + if use_gear_motor: + # Move just a little more to find perfect neutral spot between sensors + _,_,_,_ = self.mmu.trace_filament_move("Centering sync feedback buffer", (self.sync_feedback_buffer_range * direction) / 2.) + else: + self.mmu.log_debug("Failed to reach neutral filament tension after moving %.1fmm" % max_move) + + return actual, fhomed + + + def _adjust_filament_tension_proportional(self): + """ + Helper to relax filament tension using the proportional sync-feedback buffer. + Returns: actual distance moved (mm), success bool + """ + + # nudge_mm: per-move adjustment distance in mm (small feed or retract) + # neutral_band: absolute value of proportional sensor reading considered "neutral". + # This can be loosely interpreted as a % over the max range of detection of the sensor. + # For example for a sensor with 14mm range, a 0.15 tolerance is approx 1.4mm either side of centre. + # settle_time: delay between moves to allow sensor feedback to update + # timeout_s: hard stop to avoid hanging if the sensor never clears + neutral_band = 0.1 + settle_time = 0.1 + timeout_s = 10.0 + + # Wait for move queues to clear + self.mmu.mmu_toolhead.quiesce() + + # sanity-check parameters before doing anything + # neutral band needs to have a non zero and non trivial value. Enforce 5% (0.05) + # as the lower limit of acceptable neutral band tolerance. + if neutral_band < 0.05: + neutral_band = 0.05 + + # maxrange is full end-to-end sensor span; use half as the per-side budget from neutral to either end + maxrange_span_mm = float(self.sync_feedback_buffer_maxrange) + if maxrange_span_mm <= 0.0: + self.mmu.log_debug("Proportional adjust skipped: buffer maxrange <= 0") + return 0., False + per_side_budget_mm = 0.5 * maxrange_span_mm + nudge_mm = per_side_budget_mm * neutral_band + + # Cap total nudge iterations to stay within the overall sensor range + max_steps = int(math.ceil(maxrange_span_mm / nudge_mm)) + + moved_total_mm = 0.0 # total net distance moved during this adjustment + moved_nudges_mm = 0.0 # sum of all nudge moves + moved_initial_mm = 0.0 # size of the initial proportional move (if any) + steps = 0 # total moves performed + t_start = self.mmu.reactor.monotonic() + + # --- Initial proportional correction --- + # Negative sensor state = tension -> feed filament. positive sensor state = compression -> retract filament + prop_state = self._get_sensor_state() # [-1..+1], 0 ≈ neutral + if abs(prop_state) > neutral_band: + # Initial move distance as a proportion to how off centre we are based on the sensor readings. + # this will get the sensor close but likely will need a few fine adjustments (nudges) to get it + # within the centre range depending on how large the bowden tube slack is. + initial_move_mm = -prop_state * per_side_budget_mm + if abs(initial_move_mm) >= nudge_mm: + self.mmu.trace_filament_move( + "Proportional initial adjust - extruder load", + initial_move_mm, motor="gear", wait=True + ) + moved_total_mm += initial_move_mm + moved_initial_mm = initial_move_mm + steps += 1 + try: + self.mmu.reactor.pause(settle_time) + except Exception: + time.sleep(settle_time) + + # --- Check proportional sensor state after initial move and return if within neutral deadband --- + prop_state = self._get_sensor_state() + if abs(prop_state) <= neutral_band: + self.mmu.log_info( + "Proportional adjust: neutral after initial " + "(nudge=%.2fmm, initial=%.2fmm, nudges=%.2fmm, total=%.2fmm, steps=%d, final_state=%.3f, success=yes)" % + (nudge_mm, moved_initial_mm, moved_nudges_mm, moved_total_mm, steps, prop_state) + ) + return moved_total_mm, True + + # --- Fine adjustment loop (nudges) --- + while abs(moved_total_mm) < maxrange_span_mm and steps < max_steps: + prop_state = self._get_sensor_state() + # timeout safety: avoid hanging if the sensor never clears + if (self.mmu.reactor.monotonic() - t_start) > timeout_s: + self.mmu.log_info( + "Proportional adjust: timed out " + "(nudge=%.2fmm, initial=%.2fmm, nudges=%.2fmm, total=%.2fmm, steps=%d, final_state=%.3f)" % + (nudge_mm, moved_initial_mm, moved_nudges_mm, moved_total_mm, steps, prop_state) + ) + return moved_total_mm, False + + if abs(prop_state) <= neutral_band: + # confirm neutral after a short wait + try: + self.mmu.reactor.pause(settle_time) + except Exception: + time.sleep(settle_time) + prop_state = self._get_sensor_state() + if abs(prop_state) <= neutral_band: + break + + # Direction: tension -> feed forward; compression -> retract + nudge_move_mm = nudge_mm if prop_state < 0.0 else -nudge_mm + # don't exceed the end to end sensor span (maxrange_span_mm). Serves as "ultimate" failsafe. + if abs(moved_total_mm + nudge_move_mm) >= maxrange_span_mm: + self.mmu.log_info( + "Proportional adjust: aborted (exceeded buffer) " + "(nudge=%.2fmm, initial=%.2fmm, nudges=%.2fmm, total=%.2fmm, steps=%d, final_state=%.3f)" % + (nudge_mm, moved_initial_mm, moved_nudges_mm, moved_total_mm, steps, prop_state) + ) + return moved_total_mm, False + + self.mmu.trace_filament_move( + "Proportional adjust - extruder load", + nudge_move_mm, motor="gear", wait=True + ) + moved_total_mm += nudge_move_mm + moved_nudges_mm += nudge_move_mm + steps += 1 + try: + self.mmu.reactor.pause(settle_time) + except Exception: + time.sleep(settle_time) + + # Final check + final_state = self._get_sensor_state() + success = abs(final_state) <= neutral_band + self.mmu.log_info( + "Proportional adjust: complete " + "(nudge=%.2fmm, initial=%.2fmm, nudges=%.2fmm, total=%.2fmm, steps=%d, final_state=%.3f, success=%s)" % + (nudge_mm, moved_initial_mm, moved_nudges_mm, moved_total_mm, steps, final_state, "yes" if success else "no") + ) + return moved_total_mm, success diff --git a/klippy/extras/mmu/mmu_test.py b/klippy/extras/mmu/mmu_test.py new file mode 100644 index 000000000000..c372adc83519 --- /dev/null +++ b/klippy/extras/mmu/mmu_test.py @@ -0,0 +1,864 @@ +# Happy Hare MMU Software +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Goal: Define internal test operations to aid development. Note these tests are "raw" +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import random, logging, math + +from ..mmu_sensors import MmuSensors + +# Happy Hare imports +from .. import mmu_machine +from ..mmu_machine import MmuToolHead + +# MMU subcomponent clases +from .mmu_shared import * +from .mmu_utils import PurgeVolCalculator, DebugStepperMovement + +class SyncStateTest(object): + ''' + This class describes what a sync_state test case is and offers methods to manipulate them. + ''' + def __init__(self, compression_sensor, tension_sensor, compression_state, tension_state, toggle_compression, toggle_tension, triggered_sensor, id): + self.compression_sensor = compression_sensor.runout_helper.sensor_enabled + self.tension_sensor = tension_sensor.runout_helper.sensor_enabled + self.compression_state = compression_state + self.tension_state = tension_state + self.toggle_compression = toggle_compression + self.toggle_tension = toggle_tension + self.expected = None + self.triggered_sensor = triggered_sensor + self.id = id + self.set_expected() + + def set_expected(self): + ''' + Return the expected float state with given inputs and current sensor states. + ''' + if self.compression_sensor and self.tension_sensor: + if self.compression_state == self.tension_state: + self.expected = 0. + elif self.compression_state and not self.tension_state: + self.expected = 1. + elif not self.compression_state and self.tension_state: + self.expected = -1. + elif self.compression_sensor: + if self.compression_state: + self.expected = 1. + else: + self.expected = -1. + elif self.tension_sensor: + if self.tension_state: + self.expected = -1. + else: + self.expected = 1. + else : + self.expected = 0. + + def __str__(self): + return "compression_sensor=%s, tension_sensor=%s, compression_state=%s, tension_state=%s, toggle_compression=%s, toggle_tension=%s, triggered_sensor=%s" % (self.compression_sensor, self.tension_sensor, self.compression_state, self.tension_state, self.toggle_compression, self.toggle_tension, self.triggered_sensor) + + def __repr__(self): + return self.__str__() + +class MmuTest: + + def __init__(self, mmu): + self.mmu = mmu + mmu.gcode.register_command('_MMU_TEST', self.cmd_MMU_TEST, desc = self.cmd_MMU_TEST_help) # Internal for testing + + cmd_MMU_TEST_help = "Internal Happy Hare developer tests" + def cmd_MMU_TEST(self, gcmd): + self.mmu.log_to_file(gcmd.get_commandline()) + if self.mmu.check_if_disabled(): return + + if gcmd.get_int('HELP', 0, minval=0, maxval=1): + self.mmu.log_info("SYNC_STATE=['compression'|'tension'|'both'|neutral] : Set the sync state") + self.mmu.log_info("SYNC_EVENT=[-1.0 ... 1.0] : Generate sync feedback event") + self.mmu.log_info("DUMP_UNICODE=1 : Display special characters used in display") + self.mmu.log_info("RUN_SEQUENCE=1 : Run through the set of sequence macros tracking time") + self.mmu.log_info("GET_POS=1 : Fetch the current filament position state") + self.mmu.log_info("SET_POS= : Set the current filament position state") + self.mmu.log_info("SET_RD= [GATE=]: Update the specified gate's rotation distance") + self.mmu.log_info("GET_POSITION=1 : Fetch the current filament position") + self.mmu.log_info("SET_POSITION= : Fetch the current filament position") + self.mmu.log_info("SYNC_LOAD_TEST=1 : Hammer stepper syncing and movement. Params: LOOP|HOME|WAIT") + self.mmu.log_info("REALISTIC_SYNC_TEST=1 : Load test normal stepper syncing and movement. Params: LOOP|SELECT|ENDSTOP") + self.mmu.log_info("QUIESCE_TEST=1 : Quick test of problematic sync changes") + self.mmu.log_info("SEL_MOVE=1 : Selector homing move. Params: MOVE|SPEED|ACCEL|WAIT|LOOP") + self.mmu.log_info("SEL_HOMING_MOVE=1 : Selector homing move. Params: MOVE|SPEED|ACCEL|WAIT|LOOP|ENDSTOP") + self.mmu.log_info("SEL_LOAD_TEST=1 : Load test selector movements. Params: LOOP|ENDSTOP") + self.mmu.log_info("TTC_TEST=1 : Provoke known TTC condition. Params: LOOP|MIX|DEBUG|WAIT") + self.mmu.log_info("TTC_TEST2=1 : Provoke known TTC condition. Params: LOOP|MIX|DEBUG|WAIT") + self.mmu.log_info("TTC_TEST3=1 : Provoke known TTC condition. Params: LOOP|MIX|DEBUG|WAIT") + self.mmu.log_info("STEPCOMPRESS_TEST=1 : Provoke stepcompress error. Parms: LOOP|MIX|DEBUG|WAIT") + self.mmu.log_info("AUTO_CALIBRATE=1 [GATE=] [DIRECTION=] [RATIO=] [HOMING=] : Call auto-calibrate function directly") + self.mmu.log_info("GATE_MOTOR=n : Select the specified gear motor on type-B designs") + self.mmu.log_info("SYNC_G2E=1 : Sync gear to extruder (user mode)") + self.mmu.log_info("SYNC_E2G=1 [EXTRUDER_ONLY=] : Sync extruder to gear optionally just the extruder on rail") + self.mmu.log_info("UNSYNC=1 [GEAR_ONLY=]: Unsync (user mode) or unsynced with assuption of no extruder movement") + return + + def log(msg): + self.mmu.log_info(msg) + logging.info("MMU: %s" % msg) + + try: + self.mmu._is_running_test = True + have_run_test = False + + sync_state = gcmd.get('SYNC_STATE', None) + if sync_state is not None: + have_run_test = True + # Create phony sensors for testing purposes (will be removed after the test) + mmu_sensors = self.mmu.printer.lookup_object("mmu_sensors") + config = self.mmu.config.getsection('mmu_sensors') + sensors_to_remove = [] + compression_sensor_filament_present = tension_sensor_filament_present = False + + # Use the temporary sensors for the test if the real ones are not present or disabled + compression_test_sensor = mmu_sensors.sensors.get(self.mmu.SENSOR_COMPRESSION, None) + if compression_test_sensor is None or not compression_test_sensor.runout_helper.sensor_enabled: + mmu_sensors._create_mmu_sensor(config, self.mmu.SENSOR_COMPRESSION, None, 'test_'+self.mmu.SENSOR_COMPRESSION+'_pin', 0, button_handler=mmu_sensors._sync_compression_callback) + compression_test_sensor = mmu_sensors.sensors.get(self.mmu.SENSOR_COMPRESSION, None) + sensors_to_remove.append(self.mmu.SENSOR_COMPRESSION) + tension_test_sensor = mmu_sensors.sensors.get(self.mmu.SENSOR_TENSION, None) + if tension_test_sensor is None or not tension_test_sensor.runout_helper.sensor_enabled: + mmu_sensors._create_mmu_sensor(config, self.mmu.SENSOR_TENSION, None, 'test_'+self.mmu.SENSOR_TENSION+'_pin', 0, button_handler=mmu_sensors._sync_tension_callback) + tension_test_sensor = mmu_sensors.sensors.get(self.mmu.SENSOR_TENSION, None) + sensors_to_remove.append(self.mmu.SENSOR_TENSION) + + if sync_state == 'loop': + nb_iterations = gcmd.get_int('LOOP', 1000, minval=1, maxval=10000000) + gathered_states = [] + tests = [] + global finished + finished = False + # save the sensor state + saved_compr = compression_test_sensor.runout_helper.sensor_enabled + saved_tens = tension_test_sensor.runout_helper.sensor_enabled + + def gather_state(state): + self.mmu.log_trace(" -- Gathered state: %s" % len(gathered_states)) + gathered_states.append(state) + def wait_for_results(): + while len(gathered_states) < len(tests): + pass + self.mmu.log_trace(" -- All states were gatherred : %s" % len(gathered_states)) + display_results() + global finished + finished = True + def wait_run(): + global finished + while not finished: + pass + finished = False + def display_results(): + nb_tests_by_expected = {} + # For each configuration print the number of times it was run + self.mmu.log_debug("Configuration repartition") + nb_hits = {} + for comp in [None, True, False]: + for tens in [None, True, False]: + for t in tests: + if (comp, tens) not in nb_hits: + nb_hits.update({(comp, tens): 0}) + if (t.compression_state, t.tension_state) == (comp, tens): + nb_hits[(comp, tens)] += 1 + # Print the hits from most to least frequent + for key, value in sorted(nb_hits.items(), key=lambda item: item[1], reverse=True): + if value : self.mmu.log_debug(" compression : %s - tension : %s -> %s" % (key[0], key[1], value)) + + # Group by expected result and print how many tests should result in that state + self.mmu.log_debug("Expected state repartition") + for expected in [1.,0.,-1.]: + count = len([1 for t in tests if t.expected == expected]) + self.mmu.log_debug(" Expected state %s -> %s" % (expected, count)) + nb_tests_by_expected.update({expected: count}) + + mismatches = {} + for i, test in enumerate(tests): + if str(test) not in mismatches: + mismatches[str(test)] = {'test' : test, 'count' : 0} + if test.expected != gathered_states[i]: + self.mmu.log_debug("MISMATCH on test id:%s (expected : %s) -> %s" % (str(test.id), test.expected, gathered_states[i])) + self.mmu.log_debug(" Test : %s" % (str(test))) + mismatches[str(test)]['count'] += 1 + # Display mismatches + nb_mismatches = sum([v['count'] for v in mismatches.values()]) + self.mmu.log_info("Total Mismatches: "+str(nb_mismatches) + '/' + str(len(tests)) + ' (' + str(round(nb_mismatches / len(tests) * 100, 2)) +' %)') + + if mismatches: + # Sort by most mismatches.values (highest first) + mismatches = dict(sorted(mismatches.items(), key=lambda item: item[1]['count'], reverse=True)) + for test_str, info in mismatches.items(): + if info['count'] : self.mmu.log_debug("MISMATCH: %s (expected : %s) -> %s" % (test_str, info['test'].expected, info['count'])) + # Summary displaying which expected state has which percentage of total errors + for expected in [1,0,-1]: + if nb_tests_by_expected[expected]: + nb_err_count = sum([info['count'] for __, info in mismatches.items() if info['test'].expected == expected]) + self.mmu.log_debug(">>>>>> Expected state " + str(expected) + " -> " + str(nb_err_count) + '/' + str(nb_tests_by_expected[expected]) + ' (' + str(round(nb_err_count / nb_tests_by_expected[expected] * 100, 2)) + ' %)') + self.mmu.log_debug(" Edge detection error repartition") + # Group by compression rising edge + count = sum([info['count'] for __, info in mismatches.items() if info['test'].expected == expected and info['test'].toggle_compression == 'rising edge']) + if count : self.mmu.log_debug(" " + str(count) + '/' + str(nb_err_count) + ' (' + str(round(((count / nb_err_count) if nb_err_count else 0) * 100, 2)) + ' %) ' + 'compression rising edge') + # Group by compression falling edge + count = sum([info['count'] for __, info in mismatches.items() if info['test'].expected == expected and info['test'].toggle_compression == 'falling edge']) + if count : self.mmu.log_debug(" " + str(count) + '/' + str(nb_err_count) + ' (' + str(round(((count / nb_err_count) if nb_err_count else 0) * 100, 2)) + ' %) ' + 'compression falling edge') + # Group by tension rising edge + count = sum([info['count'] for __, info in mismatches.items() if info['test'].expected == expected and info['test'].toggle_tension == 'rising edge']) + if count : self.mmu.log_debug(" " + str(count) + '/' + str(nb_err_count) + ' (' + str(round(((count / nb_err_count) if nb_err_count else 0) * 100, 2)) + ' %) ' + 'tension rising edge') + # Group by tension falling edge + count = sum([info['count'] for __, info in mismatches.items() if info['test'].expected == expected and info['test'].toggle_tension == 'falling edge']) + if count : self.mmu.log_debug(" " + str(count) + '/' + str(nb_err_count) + ' (' + str(round(((count / nb_err_count) if nb_err_count else 0) * 100, 2)) + ' %) ' + 'tension falling edge') + + else: + self.mmu.log_info("No mismatches") + + self.mmu.printer.register_event_handler("mmu:sync_feedback_finished", gather_state) + self.mmu.printer.register_event_handler("mmu:test_gen_finished", wait_for_results) + # randomly remove tension or compression sensor + for sensor_scenario in ['compression_only', 'tension_only', 'both', 'none']: + if sensor_scenario == 'compression_only': + compression_test_sensor.runout_helper.sensor_enabled = True + tension_test_sensor.runout_helper.sensor_enabled = False + elif sensor_scenario == 'tension_only': + compression_test_sensor.runout_helper.sensor_enabled = False + tension_test_sensor.runout_helper.sensor_enabled = True + elif sensor_scenario == 'both': + compression_test_sensor.runout_helper.sensor_enabled = True + tension_test_sensor.runout_helper.sensor_enabled = True + else: + compression_test_sensor.runout_helper.sensor_enabled = False + tension_test_sensor.runout_helper.sensor_enabled = False + + self.mmu.log_info(">>>>>> Testing sensor configuration '%s'" % sensor_scenario) + + while len(tests) < nb_iterations: + compression_sensor_filament_present = compression_test_sensor.runout_helper.filament_present + tension_sensor_filament_present = tension_test_sensor.runout_helper.filament_present + toggle_compression = "no change" + toggle_tension = "no change" + # generate a random sync state + if random.choice([True, False]): # we consider only one sensor can change state at a time + compression_sensor_filament_present = random.choice([True, False]) + if compression_sensor_filament_present != compression_test_sensor.runout_helper.filament_present: + compression_test_sensor.runout_helper.note_filament_present(compression_sensor_filament_present) + triggered_sensor = "compression" + toggle_compression = "rising edge" if compression_sensor_filament_present else "falling edge" + self.mmu.log_trace(" -- Generated test: %s" % len(tests)) + tests.append(SyncStateTest(compression_test_sensor, tension_test_sensor, compression_sensor_filament_present, tension_sensor_filament_present, toggle_compression, toggle_tension, triggered_sensor, len(tests))) + else : + tension_sensor_filament_present = random.choice([True, False]) + if tension_sensor_filament_present != tension_test_sensor.runout_helper.filament_present: + tension_test_sensor.runout_helper.note_filament_present(tension_sensor_filament_present) + triggered_sensor = "tension" + toggle_tension = "rising edge" if tension_sensor_filament_present else "falling edge" + self.mmu.log_trace(" -- Generated test: %s" % len(tests)) + tests.append(SyncStateTest(compression_test_sensor, tension_test_sensor, compression_sensor_filament_present, tension_sensor_filament_present, toggle_compression, toggle_tension, triggered_sensor, len(tests))) + + + self.mmu.log_trace(" -- All %d were generated" % len(tests)) + self.mmu.printer.send_event("mmu:test_gen_finished") + wait_run() + gathered_states = [] + tests = [] + # remove test sensors and associated config + ppins = self.mmu.printer.lookup_object('pins') + for sensor in sensors_to_remove: + self.mmu.printer.objects.pop("filament_switch_sensor %s_sensor" % sensor) + config.fileconfig.pop("filament_switch_sensor %s_sensor" % sensor) + mmu_sensors.sensors.pop(sensor) + share_name = "%s:%s" % (ppins.parse_pin('test_'+sensor+'_pin')['chip_name'], ppins.parse_pin('test_'+sensor+'_pin')['pin']) + ppins.active_pins.pop(share_name) + for cmd, (__, val) in self.mmu.gcode.mux_commands.items() : + if ("%s_sensor" % sensor) in [k for k in [v for v in val.keys() if v]]: + self.mmu.gcode.mux_commands[cmd][1].pop(("%s_sensor" % sensor)) + + # restore the original sensor state + compression_test_sensor.runout_helper.sensor_enabled = saved_compr + tension_test_sensor.runout_helper.sensor_enabled = saved_tens + self.mmu.log_info("See mmu.log for a more details") + + else: + if sync_state == 'compression': + if compression_test_sensor is not None: + self.mmu.log_info("Setting compression sensor to 'detected'") + compression_sensor_filament_present = True + if tension_test_sensor is not None: + self.mmu.log_info("Setting tension sensor to 'not detected'") + tension_sensor_filament_present = False + elif sync_state == 'tension': + if compression_test_sensor is not None: + self.mmu.log_info("Setting compression sensor to 'not detected'") + compression_sensor_filament_present = False + if tension_test_sensor is not None: + self.mmu.log_info("Setting tension sensor to 'detected'") + tension_sensor_filament_present = True + elif sync_state in ['both', 'neutral']: + if compression_test_sensor is not None: + self.mmu.log_info("Setting compression sensor to 'detected'") + compression_sensor_filament_present = True + if tension_test_sensor is not None: + self.mmu.log_info("Setting tension sensor to 'detected'") + tension_sensor_filament_present = True + else: + self.mmu.log_error("Invalid sync state: %s" % sync_state) + # Generate a tension or compression event + self.mmu.log_trace(">>>>>> sync test Testing configuration %s" % (sync_state.upper())) + if compression_test_sensor is not None: + compression_test_sensor.runout_helper.note_filament_present(compression_sensor_filament_present) + if tension_test_sensor is not None: + tension_test_sensor.runout_helper.note_filament_present(tension_sensor_filament_present) + # Remove event handlers + self.mmu.printer.event_handlers.pop("mmu:sync_feedback_finished", None) + self.mmu.printer.event_handlers.pop("mmu:test_gen_finished", None) + return + + + feedback = gcmd.get_float('SYNC_EVENT', None, minval=-1., maxval=1.) + if feedback is not None: + have_run_test = True + self.mmu.log_info("Sending 'mmu:sync_feedback %.2f' event" % feedback) + self.mmu.printer.send_event("mmu:sync_feedback", self.mmu.toolhead.get_last_move_time(), feedback) + + + if gcmd.get_int('DUMP_UNICODE', 0, minval=0, maxval=1): + have_run_test = True + self.mmu.log_info("UI_SPACE=%s, UI_SEPARATOR=%s, UI_DASH=%s, UI_DEGREE=%s, UI_BLOCK=%s, UI_CASCADE=%s" % (UI_SPACE, UI_SEPARATOR, UI_DASH, UI_DEGREE, UI_BLOCK, UI_CASCADE)) + self.mmu.log_info("{}{}{}{}".format(UI_BOX_TL, UI_BOX_T, UI_BOX_H, UI_BOX_TR)) + self.mmu.log_info("{}{}{}{}".format(UI_BOX_L, UI_BOX_M, UI_BOX_H, UI_BOX_R)) + self.mmu.log_info("{}{}{}{}".format(UI_BOX_V, UI_BOX_V, UI_SPACE, UI_BOX_V)) + self.mmu.log_info("{}{}{}{}".format(UI_BOX_BL, UI_BOX_B, UI_BOX_H, UI_BOX_BR)) + self.mmu.log_info("UI_EMOTICONS=%s" % UI_EMOTICONS) + + + if gcmd.get_int('RUN_SEQUENCE', 0, minval=0, maxval=1): + have_run_test = True + error = gcmd.get_int('ERROR', 0, minval=0, maxval=1) + if gcmd.get_int('FORCE_IN_PRINT', 0, minval=0, maxval=1): + self.mmu._set_print_state("printing") + with self.mmu._wrap_track_time('total'): + with self.mmu._wrap_track_time('unload'): + with self.mmu._wrap_track_time('pre_unload'): + self.mmu.wrap_gcode_command(self.mmu.pre_unload_macro, exception=False, wait=True) + self.mmu.wrap_gcode_command(self.mmu.post_form_tip_macro, exception=False, wait=True) + with self.mmu._wrap_track_time('post_unload'): + self.mmu.wrap_gcode_command(self.mmu.post_unload_macro, exception=False, wait=True) + with self.mmu._wrap_track_time('load'): + with self.mmu._wrap_track_time('pre_load'): + self.mmu.wrap_gcode_command(self.mmu.pre_load_macro, exception=False, wait=True) + with self.mmu._wrap_track_time('post_load'): + self.mmu.wrap_gcode_command(self.mmu.post_load_macro, exception=False, wait=False) + if error: + self.mmu.wrap_gcode_command("MMU_PAUSE") + self.mmu.log_info("Statistics:%s" % self.mmu.last_statistics) + self.mmu._set_print_state("idle") + + + if gcmd.get_int('RUN_CHANGE_SEQUENCE', 0, minval=0, maxval=1): + have_run_test = True + pause = gcmd.get_int('PAUSE', 0, minval=0, maxval=1) + next_pos = gcmd.get('NEXT_POS', "last") + goto_pos = None + if next_pos == 'next': + self.mmu.wrap_gcode_command("SET_GCODE_VARIABLE MACRO=_MMU_SEQUENCE_VARS VARIABLE=restore_xy_pos VALUE='\"%s\"'" % next_pos) + goto_pos = [11, 11] + self.mmu._set_next_position(goto_pos) + if gcmd.get_int('FORCE_IN_PRINT', 0, minval=0, maxval=1): + self.mmu._set_print_state("printing") + self.mmu._save_toolhead_position_and_park('toolchange', next_pos=goto_pos) + with self.mmu._wrap_track_time('total'): + try: + with self.mmu._wrap_track_time('unload'): + with self.mmu._wrap_track_time('pre_unload'): + self.mmu.wrap_gcode_command(self.mmu.pre_unload_macro, exception=False, wait=True) + self.mmu.wrap_gcode_command(self.mmu.post_form_tip_macro, exception=False, wait=True) + with self.mmu._wrap_track_time('post_unload'): + self.mmu.wrap_gcode_command(self.mmu.post_unload_macro, exception=False, wait=True) + with self.mmu._wrap_track_time('load'): + with self.mmu._wrap_track_time('pre_load'): + self.mmu.wrap_gcode_command(self.mmu.pre_load_macro, exception=False, wait=True) + if pause: + raise MmuError("TEST ERROR") + else: + with self.mmu._wrap_track_time('post_load'): + self.mmu.wrap_gcode_command(self.mmu.post_load_macro, exception=False, wait=True) + self.mmu._restore_toolhead_position('toolchange') + except MmuError as ee: + self.mmu.handle_mmu_error(str(ee)) + self.mmu.log_info("Statistics:%s" % self.mmu.last_statistics) + self.mmu._set_print_state("idle") + + + if gcmd.get_int('SYNC_G2E', 0, minval=0, maxval=1): + have_run_test = True + self.mmu.mmu_toolhead.sync(MmuToolHead.GEAR_SYNCED_TO_EXTRUDER) + + + if gcmd.get_int('SYNC_E2G', 0, minval=0, maxval=1): + have_run_test = True + extruder_only = bool(gcmd.get_int('EXTRUDER_ONLY', 0, minval=0, maxval=1)) + self.mmu.mmu_toolhead.sync(MmuToolHead.EXTRUDER_ONLY_ON_GEAR if extruder_only else MmuToolHead.EXTRUDER_SYNCED_TO_GEAR) + + + if gcmd.get_int('UNSYNC', 0, minval=0, maxval=1): + have_run_test = True + gear_only = bool(gcmd.get_int('GEAR_ONLY', 0, minval=0, maxval=1)) + self.mmu.mmu_toolhead.sync(MmuToolHead.GEAR_ONLY if gear_only else None) + + + pos = gcmd.get_float('SET_POS', -1, minval=0, maxval=10) + if pos >= 0: + have_run_test = True + self.mmu._set_filament_pos_state(pos) + + + rd = gcmd.get_float('SET_RD', None, above=0) + if rd is not None: + have_run_test = True + gate = gcmd.get_int('GATE', -1, minval=-2, maxval=self.mmu.num_gates) + if gate >= 0: + self.mmu.calibration_manager.update_gear_rd(rd, gate) + + + position = gcmd.get_float('SET_POSITION', -1, minval=0) + if position >= 0: + have_run_test = True + self.mmu._set_filament_position(position) + + + if gcmd.get_int('GET_POSITION', 0, minval=0, maxval=1): + have_run_test = True + self.mmu.log_info("Filament position: %s" % self.mmu._get_filament_position()) + + + action = gcmd.get_float('SET_ACTION', -1, minval=0) + if action >= 0: + have_run_test = True + self.mmu.action = action + + + if gcmd.get_int('QUIESCE_TEST', 0, minval=0, maxval=1): + have_run_test = True + self.mmu.log_warning( + "Setup:\n" + "toolhead sensor should be present or dummy ones created\n" + "Extruder should be hot (able to extrude)" + ) + + # Simple dispatcher so the sequence below stays clean and declarative + def _exec(ops): + for i, (op, arg) in enumerate(ops, start=1): + if op == "g": + self.mmu.log_info("Step %d: %s" % (i, arg)) + self.mmu.gcode.run_script_from_command(arg) + elif op == "sync": + self.mmu.log_info("Step %d: %s" % (i, arg)) + self.mmu.mmu_toolhead.sync(arg) + elif op == "unsync": + self.mmu.log_info("Step %d: unsync()" % i) + self.mmu.mmu_toolhead.unsync() + else: + self.mmu.log_warning("Step %d: unknown op %s" % (i, op)) + + ops = [ + ("unsync", None), + ("g", f"G1 E-24 F6000"), + ("sync", MmuToolHead.GEAR_SYNCED_TO_EXTRUDER), + ("g", f"G1 E8 F6000"), + ("unsync", None), + ("g", f"G1 E2 F6000"), + ("g", "MMU_TEST_HOMING_MOVE MOTOR=gear MOVE=30 ENDSTOP=toolhead STOP_ON_ENDSTOP=1"), + ("g", f"G1 E-24 F6000"), + ("sync", MmuToolHead.EXTRUDER_SYNCED_TO_GEAR), + ("g", f"G1 E-2 F6000"), + ("g", "MMU_TEST_MOVE MOTOR=gear MOVE=30 WAIT=1"), + ("g", "MMU_TEST_HOMING_MOVE MOTOR=gear+extruder MOVE=30 ENDSTOP=toolhead STOP_ON_ENDSTOP=1"), + ("g", "MMU_TEST_MOVE MOTOR=gear+extruder MOVE=30"), + ("g", f"G1 E8 F6000"), + ("unsync", None), + ("g", f"G1 E-10 F6000"), + ("g", f"G1 E8 F6000"), + ("g", "MMU_TEST_MOVE MOTOR=gear MOVE=30 WAIT=0"), + ("g", f"G1 E-10 F6000"), + ("g", f"G1 E8 F6000"), + ("g", "MMU_TEST_MOVE MOTOR=gear MOVE=30 WAIT=0"), + ("g", f"G1 E-10 F6000"), + ("g", f"G1 E8 F6000"), + ("g", "MMU_TEST_MOVE MOTOR=gear MOVE=30 WAIT=0"), + ("g", "MMU_TEST_MOVE MOTOR=gear MOVE=30 WAIT=0"), + ("g", "MMU_TEST_MOVE MOTOR=gear MOVE=30 WAIT=0"), + ("g", "MMU_TEST_MOVE MOTOR=gear MOVE=30 WAIT=0"), + ("g", f"G1 E-10 F6000"), + ] + _exec(ops) + + # Test of all expected MMU and extruder movement (this must be bulletproof) + # e.g. _MMU_TEST REALISTIC_SYNC_TEST=1 ENDSTOP=toolhead LOOP=100 SELECT=1 SERVO=1 + if gcmd.get_int('REALISTIC_SYNC_TEST', 0, minval=0, maxval=1): + have_run_test = True + self.mmu.log_warning("Setup:\ntoolhead sensor should be present or dummy ones created or use ENDSTOP=xxx\nExtruder should be hot (able to extrude)\nSELECT=1 turns on gate selection on type-B designs") + loop = gcmd.get_int('LOOP', 10, minval=1, maxval=1000) + select = gcmd.get_int('SELECT', 0, minval=0, maxval=1) + servo = gcmd.get_int('SERVO', 0, minval=0, maxval=1) + endstop = gcmd.get('ENDSTOP', "toolhead") + + try: + if servo: + self.mmu._is_running_test = False # Else servo won't move + + for i in range(loop): + log("Loop: %d..." % i) + + # Run a few randomized moves on the mmu toolhead to simulate load/unload logic (optional gate switch) + tracked = 0. + initial_pos = self.mmu._get_filament_position() + for j in range(6): + move_type = random.randint(0, 10) # 11 to enable tracking test + move = random.randint(0, 100) - 50 + speed = random.uniform(50, 200) + accel = random.randint(50, 1000) + homing_move = random.randint(-1, 1) + motor = random.choice(["gear", "gear+extruder", "extruder"]) + wait = random.randint(0, 1) + ed = random.randint(0, 4) + encoder_dwell = True if ed == 0 else None if ed == 1 else False + + # Sometimes switch gate. This will test interleaved selector movement / gear rail reconfiguration + if select and random.randint(0, 3) == 0: + gate = random.randint(0, self.mmu.num_gates - 1) + log("Selecting gate: %d" % gate) + self.mmu.select_gate(gate) + + # Sometimes inject servo movement for selectors with servo + if servo and random.randint(0, 4) == 0: + pos = "move" if random.randint(0, 1) else "down" + log("Moving servo to position: %s" % pos) + self.mmu.gcode.run_script_from_command("MMU_SERVO POS=%s" % pos) + + log("> Internal mmu movement %d: move(%s, motor=%s, speed=%.2f, accel=%s, homing_move=%s, endstop_name=%s, encoder_dwell=%s, wait=%s)" % (j, move, motor, speed, accel, homing_move, endstop, encoder_dwell, wait)) + actual,_,_,_ = self.mmu.trace_filament_move("REALISTIC_SYNC_TEST", move, motor=motor, speed=speed, accel=accel, homing_move=homing_move, endstop_name=endstop, encoder_dwell=encoder_dwell, speed_override=False, wait=wait) + tracked += actual + + # Check MMU position is correct + final_pos = self.mmu._get_filament_position() + expected = final_pos - initial_pos + if not math.isclose(expected, tracked): + raise MmuError("TEST ERROR: inital_pos=%.6f, final_pos=%.6f, tracked=%.6f (expected=%.6f)" % (initial_pos, final_pos, tracked, expected)) + + # Run a few randomized moves on the printer toolhead to simulate user movement + # Sync state must either be unsynced or GEAR_SYNCED_TO_EXTRUDER + sync = None if random.randint(0, 1) else MmuToolHead.GEAR_SYNCED_TO_EXTRUDER + self.mmu.mmu_toolhead.sync(sync) + log(">> Extruder movement (%s)..." % ("not synced" if sync is None else "synced to extruder")) + for j in range(5): + move = random.randint(-10, 10) + self.mmu.gcode.run_script_from_command("G1 E%d F6000" % move) + + # Simulate a call back into _MMU_STEP_MOVE or similar call - e.g. user calls from post_load_macro + if random.randint(0, 4) == 0: + log(">>> Calling _MMU_STEP_** move") + if random.randint(0, 1): + self.mmu.gcode.run_script_from_command("_MMU_STEP_MOVE MOVE=10") + else: + self.mmu.gcode.run_script_from_command("_MMU_STEP_HOMING_MOVE MOVE=10 ENDSTOP=%s" % endstop) + + log("TEST COMPLETE") + + except MmuError as ee: + log("TEST TERMINATED WITH MMU EXCEPTION: %s" % str(ee)) + + + if gcmd.get_int('SYNC_LOAD_TEST', 0, minval=0, maxval=1): + have_run_test = True + self.mmu.log_warning("Setup:\ntoolhead sensor should be present or dummy ones created or use ENDSTOP=xxx\nExtruder should be hot (able to extrude)\nSELECT=0 turns off gate selection on type-B designs\nWAIT=[0|1] forces wait on movequeues condition after non-homing moves") + endstop = gcmd.get('ENDSTOP', 'toolhead') + loop = gcmd.get_int('LOOP', 10, minval=1, maxval=1000) + select = gcmd.get_int('SELECT', 1, minval=0, maxval=1) + wait = gcmd.get_int('WAIT', None, minval=0, maxval=1) + self.mmu.gcode.run_script_from_command("SAVE_GCODE_STATE NAME=mmu_test") + self.mmu._initialize_filament_position() + total = 0. + for i in range(loop): + move_type = random.randint(0, 10) # 11 to enable tracking test + move = random.randint(0, 100) - 50 + speed = random.uniform(50, 200) + accel = random.randint(50, 1000) + homing = random.randint(0, 1) + extruder_only = random.randint(0, 1) + motor = random.choice(["gear", "gear+extruder", "extruder"]) + w = random.randint(0, 1) if wait is None else wait + if select and self.mmu.mmu_machine.multigear: + if random.randint(0, 1): + gate = random.randint(0, self.mmu.num_gates - 1) + log("Selecting gate: %d" % gate) + self.mmu.select_gate(gate) + if move_type in (0, 1): + log("Loop: %d - Synced extruder movement with G1 Ex: %.1fmm" % (i, move)) + self.mmu.mmu_toolhead.sync(MmuToolHead.GEAR_SYNCED_TO_EXTRUDER) + self.mmu.gcode.run_script_from_command("G1 E%.2f F%d" % (move, speed * 60)) + elif move_type == 2: + log("Loop: %d - Unsynced extruder movement with G1 Ex: %.1fmm" % (i, move)) + self.mmu.mmu_toolhead.unsync() + self.mmu.gcode.run_script_from_command("G1 E%.2f F%d" % (move, speed * 60)) + elif move_type == 3: + log("Loop: %d - Regular mmu move: %.1fmm, MOTOR=%s" % (i, move, motor)) + self.mmu.gcode.run_script_from_command("MMU_TEST_MOVE MOTOR=%s MOVE=%.2f SPEED=%d WAIT=%d" % (motor, move, speed, w)) + total += move + elif move_type in (4, 5, 6): + log("Loop: %d - HOMING MOVE: %.1fmm, MOTOR=%s" % (i, move, motor)) + self.mmu.gcode.run_script_from_command("MMU_TEST_HOMING_MOVE MOTOR=%s MOVE=%.2f SPEED=%d ENDSTOP=%s STOP_ON_ENDSTOP=1" % (motor, move, speed, endstop)) + total += move + elif move_type == 7: + if random.randint(0, 1): + new_pos = random.uniform(0, 300) + log("Loop: %d - Set filament position" % i) + self.mmu._set_filament_position(new_pos) + total = new_pos + else: + log("Loop: %d - Initialized filament position" % i) + self.mmu._initialize_filament_position() + total = 0. + elif move_type == 8: + log("Loop: %d - Save gcode state" % i) + self.mmu.gcode.run_script_from_command("SAVE_GCODE_STATE NAME=mmu_test") + elif move_type == 9: + log("Loop: %d - Restore gcode state" % i) + self.mmu.gcode.run_script_from_command("RESTORE_GCODE_STATE NAME=mmu_test") + elif move_type == 10: + log("Loop: %d - Synced extruder movement: %.1fmm" % (i, move)) + self.mmu.gcode.run_script_from_command("MMU_TEST_MOVE MOTOR=synced MOVE=%.2f SPEED=%d WAIT=%d" % (move, speed, w)) + else: + sync = "---" if self.mmu.mmu_toolhead.sync_mode is None else "E2G" if self.mmu.mmu_toolhead.sync_mode == MmuToolHead.EXTRUDER_SYNCED_TO_GEAR else "G2E" if self.mmu.mmu_toolhead.sync_mode == MmuToolHead.GEAR_SYNCED_TO_EXTRUDER else "Ext" + self.mmu.movequeues_wait() + tracking = abs(self.mmu._get_filament_position() - total) < 0.1 + self.mmu.log_info(">>>>>> STATUS: sync: %s, pos=%.2f, total=%.2f" % (sync, self.mmu._get_filament_position(), total)) + if not tracking: + self.mmu.log_error(">>>>>> Position tracking error") + break + self.mmu.gcode.run_script_from_command("_MMU_DUMP_TOOLHEAD") + self.mmu.log_info("Aggregate move distance: %.1fmm, Toolhead reports: %.1fmm" % (total, self.mmu._get_filament_position())) + + + if gcmd.get_int('SEL_MOVE', 0, minval=0, maxval=1): + have_run_test = True + move = gcmd.get_float('MOVE', 10.) + speed = gcmd.get_float('SPEED', None) + accel = gcmd.get_float('ACCEL', None) + wait = gcmd.get_int('WAIT', 1, minval=0, maxval=1) + loop = gcmd.get_int('LOOP', 1, minval=1) + for i in range(loop): + pos = self.mmu.mmu_toolhead.get_position()[0] + actual = self.mmu.selector.move("Test move", pos + move, speed=speed, accel=accel, wait=wait) + self.mmu.log_always("%d. Rail starting pos: %s, Selector moved to %.4fmm" % (i, pos, actual)) + if actual != pos + move: + self.mmu.log_always("Off target position by: %.4f" % (actual - (pos + move))) + + + if gcmd.get_int('SEL_HOMING_MOVE', 0, minval=0, maxval=1): + have_run_test = True + move = gcmd.get_float('MOVE', 10.) + speed = gcmd.get_float('SPEED', None) + accel = gcmd.get_float('ACCEL', None) + loop = gcmd.get_int('LOOP', 1, minval=1) + endstop = gcmd.get('ENDSTOP', self.mmu.SENSOR_SELECTOR_TOUCH if self.mmu.selector.use_touch_move() else self.mmu.SENSOR_SELECTOR_HOME) + for i in range(loop): + pos = self.mmu.mmu_toolhead.get_position()[0] + self.mmu.log_always("Rail starting pos: %s" % pos) + actual,homed = self.mmu.selector.homing_move("Test homing move", pos + move, speed=speed, accel=accel, homing_move=1, endstop_name=endstop) + self.mmu.log_always("%d. Rail starting pos: %s, Selector moved to %.4fmm homing to %s (%s)" % (i, pos, actual, endstop, "homed" if homed else "DID NOT HOME")) + if actual != pos + move: + self.mmu.log_always("Off target position by: %.4f" % (actual - (pos + move))) + + + if gcmd.get_int('SEL_LOAD_TEST', 0, minval=0, maxval=1): + have_run_test = True + loop = gcmd.get_int('LOOP', 10, minval=1, maxval=1000) + if gcmd.get_int('HOME', 0, minval=0, maxval=1): + self.mmu.gcode.run_script_from_command("MMU_HOME") + for i in range(loop): + move_type = random.randint(0, 2) + move = random.randint(10, 100) + speed = random.uniform(50, 200) + accel = random.randint(50, 2000) + homing = random.randint(0, 2) + wait = random.randint(0, 1) + pos = self.mmu.mmu_toolhead.get_position()[0] + if move_type in (1, 2): + endstop = "mmu_sel_touch" if move_type == 2 else "mmu_sel_home" + actual,homed = self.mmu.selector.homing_move("Test homing move", move, speed=speed, accel=accel, homing_move=1, endstop_name=endstop) + self.mmu.log_always("%d. Homing move: Rail starting pos: %s, Selector moved to %.4fmm homing to %s (%s)" % (i, pos, actual, endstop, "homed" if homed else "DID NOT HOME")) + else: + actual = self.mmu.selector.move("Test move", move, speed=speed, accel=accel, wait=w) + self.mmu.log_always("%d. Move: Rail starting pos: %s, Selector moved to %.4fmm" % (i, pos, actual)) + + + if gcmd.get_int('TTC_TEST', 0, minval=0, maxval=1): + have_run_test = True + loop = gcmd.get_int('LOOP', 10, minval=1, maxval=1000) + debug = gcmd.get_int('DEBUG', 0, minval=0, maxval=1) + mix = gcmd.get_int('MIX', 0, minval=0, maxval=1) + wait = gcmd.get_int('WAIT', None, minval=0, maxval=1) + for i in range(loop): + self.mmu.log_info("Loop: %d" % i) + w = random.randint(0, 1) if wait is None else wait + if self.mmu.mmu_machine.multigear: + self.mmu.select_gate(random.randint(0, self.mmu.num_gates - 1)) + stop_on_endstop = random.choice([-1, 1]) + motor = "gear+extruder" if random.randint(0, mix) else "extruder" + self.mmu.gcode.run_script_from_command("MMU_TEST_HOMING_MOVE MOTOR=%s MOVE=5 ENDSTOP=toolhead STOP_ON_ENDSTOP=%d DEBUG=%d" % (motor, stop_on_endstop, debug)) + if random.randint(0, 1): + self.mmu.gcode.run_script_from_command("MMU_TEST_MOVE MOTOR=%s MOVE=5 DEBUG=%d WAIT=%d" % (motor, debug, w)) + if random.randint(0, 1): + self.mmu.mmu_toolhead.get_last_move_time() # Try to provoke TTC + + + if gcmd.get_int('TTC_TEST2', 0, minval=0, maxval=1): + have_run_test = True + loop = gcmd.get_int('LOOP', 10, minval=1, maxval=1000) + debug = gcmd.get_int('DEBUG', 0, minval=0, maxval=1) + mix = gcmd.get_int('MIX', 0, minval=0, maxval=1) + wait = gcmd.get_int('WAIT', None, minval=0, maxval=1) + for i in range(loop): + stop_on_endstop = random.choice([-1, 0, 1]) + w = random.randint(0, 1) if wait is None else wait + self.mmu.log_info("Loop: %d" % i) + motor = "gear+extruder" if random.randint(0, mix) else "extruder" + self.mmu.trace_filament_move("test", 5, motor=motor, homing_move=stop_on_endstop, endstop_name="toolhead", wait=w) + if random.randint(0, 1): + self.mmu.gcode.run_script_from_command("M83") + self.mmu.gcode.run_script_from_command("G1 E5 F300") + + + if gcmd.get_int('TTC_TEST3', 0, minval=0, maxval=1): + have_run_test = True + loop = gcmd.get_int('LOOP', 10, minval=1, maxval=1000) + debug = gcmd.get_int('DEBUG', 0, minval=0, maxval=1) + wait = gcmd.get_int('WAIT', None, minval=0, maxval=1) + for i in range(loop): + self.mmu.log_info("Loop: %d" % i) + w = random.randint(0, 1) if wait is None else wait + if self.mmu.mmu_machine.multigear: + self.mmu.select_gate(random.randint(0, self.mmu.num_gates - 1)) + stop_on_endstop = random.choice([-1, 1]) + motor = "gear" + self.mmu.gcode.run_script_from_command("MMU_TEST_HOMING_MOVE MOTOR=%s MOVE=-70 SPEED=300 ACCEL=1000 ENDSTOP=toolhead STOP_ON_ENDSTOP=%d DEBUG=%d" % (motor, stop_on_endstop, debug)) + if random.randint(0, 1): + self.mmu.gcode.run_script_from_command("MMU_TEST_MOVE MOTOR=%s MOVE=5 DEBUG=%d WAIT=%d" % (motor, debug, w)) + if random.randint(0, 1): + self.mmu.mmu_toolhead.get_last_move_time() # Try to provoke TTC + + + if gcmd.get_int('STEPCOMPRESS_TEST', 0, minval=0, maxval=1): + have_run_test = True + loop = gcmd.get_int('LOOP', 1, minval=10, maxval=1000) + debug = gcmd.get_int('DEBUG', 0, minval=0, maxval=1) + motor = gcmd.get('MOTOR', None) + wait = gcmd.get_int('WAIT', None, minval=0, maxval=1) + select = gcmd.get_int('SELECT', 1, minval=0, maxval=1) + stop_on_endstop = gcmd.get_int('STOP_ON_ENDSTOP', None, minval=-1, maxval=1) + for i in range(loop): + self.mmu.log_info("Loop: %d" % i) + w = random.randint(0, 1) if wait is None else wait + if self.mmu.mmu_machine.multigear and select: + self.mmu.select_gate(random.randint(0, self.mmu.num_gates - 1)) + self.mmu.gcode.run_script_from_command("M83") + self.mmu.gcode.run_script_from_command("G1 E1 F300") + if motor is None: + motor = "gear+extruder" if random.randint(0, 1) else "extruder" + if stop_on_endstop is None: + stop_on_endstop = random.choice([-1, 0, 1]) + if wait is None: + w = random.randint(0, 1) + with DebugStepperMovement(self.mmu, debug): + self.mmu.trace_filament_move("test", 1, motor=motor, homing_move=stop_on_endstop, endstop_name="toolhead", wait=w) + + + if gcmd.get_int('AUTO_CALIBRATE', 0, minval=0, maxval=1): + have_run_test = True + gate = gcmd.get_int('GATE', 0, minval=-2, maxval=8) + direction = gcmd.get_int('DIRECTION', 1, minval=-1, maxval=1) + ratio = gcmd.get_float('RATIO', 1., minval=-1, maxval=2) + homing_movement = gcmd.get_float('HOMING', None, minval=0, maxval=100) + self.mmu.gate_selected = gate + self.mmu._auto_calibrate(direction, ratio, homing_movement) + + + select_gate = gcmd.get_int('GATE_MOTOR', -99, minval=self.mmu.TOOL_GATE_BYPASS, maxval=self.mmu.num_gates) + if not select_gate == -99: + have_run_test = True + self.mmu.mmu_toolhead.select_gear_stepper(select_gate) + + + if gcmd.get_int('CALC_PURGE', 0, minval=0, maxval=1): + have_run_test = True + purge_vol_calc = PurgeVolCalculator(0, 800, 1.0) + + purge_vol = purge_vol_calc.calc_purge_vol_by_rgb(192, 192, 192, 247, 35, 35) + self.mmu.log_always("The purge vol from RGB color 192, 192, 192 to 247, 35, 35 is: {}".format(purge_vol)) + + purge_vol = purge_vol_calc.calc_purge_vol_by_hex("#C0C0C0", "#F72323") + self.mmu.log_always("The purge vol from hex color #C0C0C0 to #F72323 is: {}".format(purge_vol)) + + tool_colors = ["FFFF00","80FFFF","FFFFFF","FF8000"] + purge_volumes = self.mmu._generate_purge_matrix(tool_colors, 0, 800, 1.0) + self.mmu.log_always("\ntool_colors={}\npurge_volumes={}".format(tool_colors, purge_volumes)) + purge_volumes = self.mmu._generate_purge_matrix(tool_colors, 0, 800, 0.5) + self.mmu.log_always("\ntool_colors={}\npurge_volumes={}".format(tool_colors, purge_volumes)) + + + runout = gcmd.get_int('RUNOUT', None, minval=0, maxval=1) + if runout is not None: + have_run_test = True + if runout == 1: + self.mmu._enable_runout() + elif runout == 0: + self.mmu._disable_runout() + + + if gcmd.get_int('SENSOR', 0, minval=0, maxval=1): + have_run_test = True + pos = gcmd.get_int('POS', 0, minval=-1) + gate = gcmd.get_int('GATE', 0, minval=-2, maxval=8) + loading = bool(gcmd.get_int('LOADING', 1, minval=0, maxval=1)) + loop = gcmd.get_int('LOOP', 0, minval=0, maxval=1) + + if not loop: + sensors = self.mmu.sensor_manager.get_sensors_before(pos, gate, loading=loading) + self.mmu.log_always("check_all_sensors_before(%s,%s)=%s" % (pos, gate, self.mmu.sensor_manager.check_all_sensors_before(pos, gate, loading=loading))) + self.mmu.log_always("sensors before=%s" % sensors) + + sensors = self.mmu.sensor_manager.get_sensors_after(pos, gate, loading=loading) + self.mmu.log_always("check_all_sensors_after(%s,%s)=%s" % (pos, gate, self.mmu.sensor_manager.check_all_sensors_after(pos, gate, loading=loading))) + self.mmu.log_always("sensors after=%s" % sensors) + else: + for pos in range(-1,11): + self.mmu.log_always("check_all_sensors_before(%s,%s)=%s" % (pos, gate, self.mmu.sensor_manager.check_all_sensors_before(pos, gate, loading=loading))) + self.mmu.log_always("") + for pos in range(-1,11): + self.mmu.log_always("check_all_sensors_after(%s,%s)=%s" % (pos, gate, self.mmu.sensor_manager.check_all_sensors_after(pos, gate, loading=loading))) + self.mmu.log_always("check_any_sensors_in_path()=%s" % self.mmu.sensor_manager.check_any_sensors_in_path()) + self.mmu.log_always("check_for_runout()=%s" % self.mmu.sensor_manager.check_for_runout()) + + + fil_pos = gcmd.get_int('FILAMENT_POS', -2, minval=-1, maxval=10) + if fil_pos != -2: + have_run_test = True + with self.mmu.wrap_sync_gear_to_extruder(): + self.mmu._set_filament_pos_state(fil_pos) + + if not have_run_test: + self.mmu.log_warning("Not a valid test. Use HELP=1 for tests") + + finally: + self.mmu._is_running_test = False # Restore non testing context + diff --git a/klippy/extras/mmu/mmu_utils.py b/klippy/extras/mmu/mmu_utils.py new file mode 100644 index 000000000000..3075f549afe0 --- /dev/null +++ b/klippy/extras/mmu/mmu_utils.py @@ -0,0 +1,150 @@ +# Happy Hare MMU Software +# Utility classes for Happy Hare MMU +# +# DebugStepperMovement +# Goal: Internal testing class for debugging synced movement +# +# PurgeVolCalculator +# Goal: Purge volume calculator based on color change +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import math + + +# Internal testing class for debugging synced movement +# Add this around move logic: +# with DebugStepperMovement(self): +# +class DebugStepperMovement: + def __init__(self, mmu, debug=False): + self.mmu = mmu + self.debug = debug + + def __enter__(self): + if self.debug: + self.g_steps0 = self.mmu.gear_rail.steppers[0].get_mcu_position() + self.g_pos0 = self.mmu.gear_rail.steppers[0].get_commanded_position() + self.e_steps0 = self.mmu.mmu_extruder_stepper.stepper.get_mcu_position() + self.e_pos0 = self.mmu.mmu_extruder_stepper.stepper.get_commanded_position() + self.rail_pos0 = self.mmu.mmu_toolhead.get_position()[1] + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.debug: + self.mmu.log_always("Waiting for movement to complete...") + self.mmu.movequeues_wait() + g_steps1 = self.mmu.gear_rail.steppers[0].get_mcu_position() + g_pos1 = self.mmu.gear_rail.steppers[0].get_commanded_position() + e_steps1 = self.mmu.mmu_extruder_stepper.stepper.get_mcu_position() + e_pos1 = self.mmu.mmu_extruder_stepper.stepper.get_commanded_position() + rail_pos1 = self.mmu.mmu_toolhead.get_position()[1] + self.mmu.log_always("Gear steps: %d = %.4fmm, commanded movement: %.4fmm" % (g_steps1 - self.g_steps0, (g_steps1 - self.g_steps0) * self.mmu.gear_rail.steppers[0].get_step_dist(), g_pos1 - self.g_pos0)) + self.mmu.log_always("Extruder steps: %d = %.4fmm, commanded movement: %.4fmm" % (e_steps1 - self.e_steps0, (e_steps1 - self.e_steps0) * self.mmu.mmu_extruder_stepper.stepper.get_step_dist(), e_pos1 - self.e_pos0)) + self.mmu.log_always("Rail movement: %.4fmm" % (rail_pos1 - self.rail_pos0)) + + +class PurgeVolCalculator: + def __init__(self, min_purge_vol, max_purge_vol, multiplier): + self.min_purge_vol = min_purge_vol + self.max_purge_vol = max_purge_vol + self.multiplier = multiplier + + def calc_purge_vol_by_rgb(self, src_r, src_g, src_b, dst_r, dst_g, dst_b): + src_r_f = float(src_r) / 255.0 + src_g_f = float(src_g) / 255.0 + src_b_f = float(src_b) / 255.0 + dst_r_f = float(dst_r) / 255.0 + dst_g_f = float(dst_g) / 255.0 + dst_b_f = float(dst_b) / 255.0 + + from_hsv_h, from_hsv_s, from_hsv_v = self.RGB2HSV(src_r_f, src_g_f, src_b_f) + to_hsv_h, to_hsv_s, to_hsv_v = self.RGB2HSV(dst_r_f, dst_g_f, dst_b_f) + hs_dist = self.DeltaHS_BBS(from_hsv_h, from_hsv_s, from_hsv_v, to_hsv_h, to_hsv_s, to_hsv_v) + from_lumi = self.get_luminance(src_r_f, src_g_f, src_b_f) + to_lumi = self.get_luminance(dst_r_f, dst_g_f, dst_b_f) + + lumi_purge = 0.0 + if to_lumi >= from_lumi: + lumi_purge = math.pow(to_lumi - from_lumi, 0.7) * 560.0 + else: + lumi_purge = (from_lumi - to_lumi) * 80.0 + + inter_hsv_v = 0.67 * to_hsv_v + 0.33 * from_hsv_v + hs_dist = min(inter_hsv_v, hs_dist) + hs_purge = 230.0 * hs_dist + + purge_volume = self.calc_triangle_3rd_edge(hs_purge, lumi_purge, 120.0) + purge_volume = max(purge_volume, 0.0) + purge_volume += self.min_purge_vol + purge_volume *= self.multiplier + purge_volume = min(int(purge_volume), self.max_purge_vol) + + return purge_volume + + def calc_purge_vol_by_hex(self, src_clr, dst_clr): + src_rgb = self.hex_to_rgb(src_clr) + dst_rgb = self.hex_to_rgb(dst_clr) + return self.calc_purge_vol_by_rgb(*(src_rgb + dst_rgb)) + + @staticmethod + def RGB2HSV(r, g, b): + Cmax = max(r, g, b) + Cmin = min(r, g, b) + delta = Cmax - Cmin + + if abs(delta) < 0.001: + h = 0.0 + elif Cmax == r: + h = 60.0 * math.fmod((g - b) / delta, 6.0) + elif Cmax == g: + h = 60.0 * ((b - r) / delta + 2) + else: + h = 60.0 * ((r - g) / delta + 4) + s = 0.0 if abs(Cmax) < 0.001 else delta / Cmax + v = Cmax + return h, s, v + + @staticmethod + def to_radians(degree): + return degree / 180.0 * math.pi + + @staticmethod + def get_luminance(r, g, b): + return r * 0.3 + g * 0.59 + b * 0.11 + + @staticmethod + def calc_triangle_3rd_edge(edge_a, edge_b, degree_ab): + return math.sqrt(edge_a * edge_a + edge_b * edge_b - 2 * edge_a * edge_b * math.cos(PurgeVolCalculator.to_radians(degree_ab))) + + @staticmethod + def DeltaHS_BBS(h1, s1, v1, h2, s2, v2): + h1_rad = PurgeVolCalculator.to_radians(h1) + h2_rad = PurgeVolCalculator.to_radians(h2) + + dx = math.cos(h1_rad) * s1 * v1 - math.cos(h2_rad) * s2 * v2 + dy = math.sin(h1_rad) * s1 * v1 - math.sin(h2_rad) * s2 * v2 + dxy = math.sqrt(dx * dx + dy * dy) + + return min(1.2, dxy) + + @staticmethod + def hex_to_rgb(hex_color): + hex_color = hex_color.lstrip('#') + if len(hex_color) == 3: + hex_color = ''.join([c * 2 for c in hex_color]) + if len(hex_color) == 8: + hex_color = hex_color[:6] + if len(hex_color) != 6: + raise ValueError("Invalid hex color code, it should be 3, 6 or 8 digits long") + color_value = int(hex_color, 16) + r = (color_value >> 16) & 0xFF + g = (color_value >> 8) & 0xFF + b = color_value & 0xFF + return r, g, b diff --git a/klippy/extras/mmu_encoder.py b/klippy/extras/mmu_encoder.py new file mode 100644 index 000000000000..dded5a13d6a7 --- /dev/null +++ b/klippy/extras/mmu_encoder.py @@ -0,0 +1,310 @@ +# Happy Hare MMU Software +# Driver for encoder that supports movement measurement, runout/clog detection and flow rate calc +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Based on: +# Original Enraged Rabbit Carrot Feeder Project Copyright (C) 2021 Ette +# Generic Filament Sensor Module Copyright (C) 2019 Eric Callahan +# Filament Motion Sensor Module Copyright (C) 2021 Joshua Wherrett +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, time + +# Klipper imports +from . import pulse_counter + +class MmuEncoder: + CHECK_MOVEMENT_TIMEOUT = 0.250 + + RUNOUT_DISABLED = 0 + RUNOUT_STATIC = 1 + RUNOUT_AUTOMATIC = 2 + + def __init__(self, config): + self.name = config.get_name().split()[-1] + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.gcode = self.printer.lookup_object('gcode') + encoder_pin = config.get('encoder_pin') + self._logger = None + + # For counter functionality + self.sample_time = config.getfloat('sample_time', 0.1, above=0.) + self.poll_time = config.getfloat('poll_time', 0.001, above=0.) + self.set_resolution(config.getfloat('encoder_resolution', 1., above=0.)) # Must be calibrated by user in Happy Hare + self._last_time = None + self._counts = self._last_count = 0 + self._counter = pulse_counter.MCU_counter(self.printer, encoder_pin, self.sample_time, self.poll_time) + self._counter.setup_callback(self._counter_callback) + self._movement = False + + # For clog/runout functionality + self.extruder_name = config.get('extruder', 'extruder') + # The runout headroom that MMU will attempt to maintain (closest MMU comes to triggering runout) + self.desired_headroom = config.getfloat('desired_headroom', 6., above=0.) + # The "damping" effect of last measurement. Higher value means clog_length will be reduced more slowly + self.average_samples = config.getint('average_samples', 4, minval=1) + # The extrusion interval where new detection_length is calculated (also done on toolchange) + self.next_calibration_point = self.calibration_length = config.getfloat('calibration_length', 10000., minval=50.) # 10m + # Detection length will be set by MMU calibration + self.detection_length = self.min_headroom = config.getfloat('detection_length', 10., above=2.) # TODO this is now in flowguard! + self.event_delay = config.getfloat('event_delay', 2., above=0.) + self.pause_delay = config.getfloat('pause_delay', 0, above=0.) + self.runout_gcode = '__MMU_ENCODER_RUNOUT' + self.insert_gcode = '__MMU_ENCODER_INSERT' + self._enabled = True # Runout/Clog functionality + self.min_event_systime = self.reactor.NEVER + self.extruder = None + self.filament_detected = False + self.detection_mode = self.RUNOUT_STATIC + self.last_extruder_pos = self.filament_runout_pos = 0. + self.filament_runout_pos = self.min_headroom = self.detection_length + + # For flowrate functionality + self.flowrate_last_encoder_pos = 0. + self.extrusion_flowrate = 0. + self.samples = [] + self.flowrate_samples = config.getint('flowrate_samples', 20, minval=5) + + # Register event handlers + self.printer.register_event_handler('klippy:ready', self._handle_ready) + self.printer.register_event_handler('klippy:connect', self._handle_connect) + self.printer.register_event_handler('idle_timeout:printing', self._handle_printing) + self.printer.register_event_handler('idle_timeout:ready', self._handle_not_printing) + self.printer.register_event_handler('idle_timeout:idle', self._handle_not_printing) + + def _handle_connect(self): + try: + self.extruder = self.printer.lookup_object(self.extruder_name) + except Exception: + pass # Can set this later + + def _handle_ready(self): + self.min_event_systime = self.reactor.monotonic() + 2. # Don't process events too early + self._reset_filament_runout_params() + self._extruder_pos_update_timer = self.reactor.register_timer(self._extruder_pos_update_event) + + def _handle_printing(self, print_time): + self.reactor.update_timer(self._extruder_pos_update_timer, self.reactor.NOW) # Enabled + + def _handle_not_printing(self, print_time): + self.reactor.update_timer(self._extruder_pos_update_timer, self.reactor.NEVER) # Disabled + + def _get_extruder_pos(self, eventtime=None): + if eventtime is None: + eventtime = self.reactor.monotonic() + + print_time = self.printer.lookup_object('mcu').estimated_print_time(eventtime) + + if not self.extruder: + return 0. + + return self.extruder.find_past_position(print_time) + + # Called periodically to check filament movement + def _extruder_pos_update_event(self, eventtime): + if self._enabled: + extruder_pos = self._get_extruder_pos(eventtime) + + # First lets see if we got encoder movement since last invocation + if self._movement: + self._movement = False + self.filament_runout_pos = max(extruder_pos + self.detection_length, self.filament_runout_pos) + + if extruder_pos >= self.next_calibration_point: + if self.next_calibration_point > 0: + self._update_detection_length() + self.next_calibration_point = extruder_pos + self.calibration_length + if self.filament_runout_pos - extruder_pos < self.min_headroom: + self.min_headroom = self.filament_runout_pos - extruder_pos + if self._logger and self.min_headroom < self.desired_headroom: + if self.detection_mode == self.RUNOUT_AUTOMATIC: + self._logger("Automatic clog detection: new min_headroom (< %.1fmm desired): %.1fmm" % (self.desired_headroom, self.min_headroom)) + elif self.detection_mode == self.RUNOUT_STATIC: + self._logger("Warning: Only %.1fmm of headroom to clog/runout" % self.min_headroom) + self._handle_filament_event(extruder_pos < self.filament_runout_pos) + + # Flowrate calc. Depends of calibration accuracy of encoder + encoder_pos = self.get_distance() + # If encoder has moved, record the extruder and encoder movement for flow rate calcs + if encoder_pos > self.flowrate_last_encoder_pos: + self._record(encoder_pos, extruder_pos) + self.flowrate_last_encoder_pos = encoder_pos + + self.last_extruder_pos = extruder_pos + return eventtime + self.CHECK_MOVEMENT_TIMEOUT + + def _reset_filament_runout_params(self, eventtime=None): + if eventtime is None: + eventtime = self.reactor.monotonic() + self.last_extruder_pos = self._get_extruder_pos(eventtime) + self.flowrate_last_encoder_pos = self.get_distance() + self.extrusion_flowrate = 0. + self.samples = [] + self.filament_runout_pos = self.last_extruder_pos + self.detection_length + self.desired_headroom # Add headroom to decrease sensitivity on startup + self.next_calibration_point = self.last_extruder_pos + self.calibration_length + self.min_headroom = self.detection_length + + # Called periodically to tune the clog detection length + def _update_detection_length(self, increase_only=False): + if not self._enabled: return + if self.detection_mode != self.RUNOUT_AUTOMATIC: + return + current_detection_length = self.detection_length + if self.min_headroom < self.desired_headroom: + # Maintain headroom + extra_length = min((self.desired_headroom - self.min_headroom), self.desired_headroom) + self.detection_length += extra_length + if self._logger: + self._logger("Automatic clog detection: maintaining headroom by adding %.1fmm to detection_length" % extra_length) + elif not increase_only: + # Average down + sample = self.detection_length - (self.min_headroom - self.desired_headroom) + self.detection_length = ((self.average_samples * self.detection_length) + self.desired_headroom - self.min_headroom) / self.average_samples + if self._logger: + self._logger("Automatic clog detection: averaging down detection_length with new %.1fmm measurement" % sample) + else: + return + + self.min_headroom = self.detection_length + self.filament_runout_pos = self.last_extruder_pos + self.detection_length + if round(self.detection_length, 1) != round(current_detection_length, 1): # Persist if significant + if self._logger: + self._logger("Automatic clog detection: reset detection_length to %.1fmm" % self.min_headroom) + self.set_clog_detection_length(self.detection_length) + + # Called to see if state update requires callback notification + def _handle_filament_event(self, filament_detected): + if self.filament_detected == filament_detected: + return + self.filament_detected = filament_detected + eventtime = self.reactor.monotonic() + if eventtime < self.min_event_systime or self.detection_mode == self.RUNOUT_DISABLED or not self._enabled: + return + is_printing = self.printer.lookup_object("idle_timeout").get_status(eventtime)["state"] == "Printing" + if filament_detected: + if not is_printing and self.insert_gcode is not None: + # Insert detected + self.min_event_systime = self.reactor.NEVER + logging.info("MMU: Encoder Sensor %s: insert event detected, Time %.2f" % (self.name, eventtime)) + self.reactor.register_callback(self._insert_event_handler) + else: + if is_printing and self.runout_gcode is not None: + # Runout detected + self.min_event_systime = self.reactor.NEVER + logging.info("MMU: Encoder Sensor %s: runout event detected, Time %.2f" % (self.name, eventtime)) + self.reactor.register_callback(self._runout_event_handler) + + def _runout_event_handler(self, eventtime): + # Pausing from inside an event requires that the pause portion of pause_resume execute immediately. + pause_resume = self.printer.lookup_object('pause_resume') + pause_resume.send_pause_command() + if self.pause_delay: + self.printer.get_reactor().pause(eventtime + self.pause_delay) + self._exec_gcode(self.runout_gcode) + + def _insert_event_handler(self, eventtime): + self._exec_gcode(self.insert_gcode) + + def _exec_gcode(self, command): + try: + self.gcode.run_script(command) + except Exception: + logging.exception("MMU: Error running mmu encoder handler: `%s`" % command) + self.min_event_systime = self.reactor.monotonic() + self.event_delay + + def get_clog_detection_length(self): + return self.detection_length + + def set_clog_detection_length(self, clog_length): + self.detection_length = max(clog_length, 2.) + self._reset_filament_runout_params() + + def note_clog_detection_length(self): + self._update_detection_length() + + def set_mode(self, mode): + if self.RUNOUT_DISABLED <= mode <= self.RUNOUT_AUTOMATIC: + self.detection_mode = mode + + def set_extruder(self, extruder_name): + self.extruder = self.printer.lookup_object(extruder_name) + if not self.extruder: + raise self.printer.config.error("Extruder named `%s` not found" % extruder_name) + self.extruder_name = extruder_name + self.filament_runout_pos = self.min_headroom = self.detection_length + + def set_logger(self, log): + self._logger = log + + def enable(self): + self._reset_filament_runout_params() + self._enabled = True + + def disable(self): + self._enabled = False + + def is_enabled(self): + return self._enabled + + def _record(self, encoder_pos, extruder_pos): + self.samples.append((encoder_pos, extruder_pos)) + if len(self.samples) > self.flowrate_samples: + self.samples = self.samples[-self.flowrate_samples:] + encoder_movement = encoder_pos - self.samples[0][0] + extruder_movement = extruder_pos - self.samples[0][1] + new_extrusion_flowrate = (encoder_movement / extruder_movement) if extruder_movement > 0. else 1. + self.extrusion_flowrate = (self.extrusion_flowrate + new_extrusion_flowrate) / 2. + + # Callback for MCU_counter + def _counter_callback(self, print_time, count, count_time): + if self._last_time is None: # First sample + self._last_time = print_time + elif count_time > self._last_time: + self._last_time = count_time + new_counts = count - self._last_count + self._counts += new_counts + self._movement = new_counts > 0 + else: # No counts since last sample + self._last_time = print_time + self._last_count = count + + def set_resolution(self, resolution): + self.resolution = resolution + + def get_resolution(self): + return self.resolution + + def get_counts(self): + return self._counts + + def get_distance(self): + return self._counts * self.resolution + + def set_distance(self, new_distance): + self._counts = int(round(new_distance / self.resolution)) + + def reset_counts(self): + self._counts = 0 + + def get_status(self, eventtime): + return { + 'encoder_pos': round(self.get_distance(), 1), + 'detection_length': round(self.detection_length, 1), + 'min_headroom': round(self.min_headroom, 1), + 'headroom': round(self.filament_runout_pos - self.last_extruder_pos, 1), + 'desired_headroom': round(self.desired_headroom, 1), + 'detection_mode': self.detection_mode, + 'enabled': self._enabled, + 'flow_rate': int(round(min(self.extrusion_flowrate, 1.) * 100)) + } + +def load_config_prefix(config): + return MmuEncoder(config) diff --git a/klippy/extras/mmu_espooler.py b/klippy/extras/mmu_espooler.py new file mode 100644 index 000000000000..65d82c71ae24 --- /dev/null +++ b/klippy/extras/mmu_espooler.py @@ -0,0 +1,642 @@ +# Happy Hare MMU Software +# +# Implements h/w "eSpooler" control for a MMU unit that is powered by a DC motor +# (normally PWM speed controlled) that can be used to rewind a filament spool or be +# driven peridically in the forward direction to provide "forward assist" functionality. +# For simplicity of setup it is assumed that all pins are of the same type/config per mmu_unit. +# Control is via direct control or klipper events. +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, time + +from . import output_pin + +MAX_SCHEDULE_TIME = 5.0 + +class MmuESpooler: + + def __init__(self, config, first_gate=0, num_gates=23): + self.config = config + self.first_gate = first_gate + self.num_gates = num_gates + self.name = config.get_name().split()[-1] + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.mmu = None + self.respool_gates = [] # List of gates that can perform respool operation + self.assist_gates = [] # List of gates that can perform assist operation + self.burst_gates = {} # Key:Gate, Value:(operation, callback_timer) for gates currently executing a "burst" + + # The following implement the "burst assist". Currently only the print_assist_gate has burst_trigger_enabled + # but the orthogonal indicators would allow for future change in behavior + self.print_assist_gate = None # Current gate in "print assist" mode (should only be one or None) + self.burst_trigger_enabled = {} # Key: Gate, Value: True|False representing if trigger is enabled for each gate + self.burst_trigger_state = {} # Key: Gate, Value: 0|1 trigger button state for each gate trigger + self.back_to_back_burst_count = {} # Key: Gate, Value: Count of back-to-back bursts for each gate + + # Get config + self.motor_mcu_pins = {} # Key: pin_name, Value: mcu_pin + self.last_value = {} # Key: pin_name, Value: Last pwm value + self.operation = {} # Key: Gate, Value: (operation, pwm_value) tuple + ppins = self.printer.lookup_object('pins') + buttons = self.printer.load_object(config, 'buttons') + + # These params are assumed to be shared accross the espooler unit + self.is_pwm = config.getboolean("pwm", True) + self.hardware_pwm = config.getboolean("hardware_pwm", False) + self.scale = config.getfloat('scale', 1., above=0.) + self.cycle_time = config.getfloat("cycle_time", 0.100, above=0., maxval=MAX_SCHEDULE_TIME) + self.shutdown_value = config.getfloat('shutdown_value', 0., minval=0., maxval=self.scale) / self.scale + start_value = config.getfloat('value', 0., minval=0., maxval=self.scale) / self.scale + + for gate in range(self.first_gate, self.first_gate + self.num_gates + 1): + self.respool_motor_pin = config.get('respool_motor_pin_%d' % gate, None) + self.assist_motor_pin = config.get('assist_motor_pin_%d' % gate, None) + self.enable_motor_pin = config.get('enable_motor_pin_%d' % gate, None) + self.assist_trigger_pin = config.get('assist_trigger_pin_%d' % gate, None) + + valid_gate = False # This is hack to support HHv3 (remove in HHv4) + # Setup pins + if self.respool_motor_pin and not self._is_empty_pin(self.respool_motor_pin): + if self.is_pwm: + mcu_pin = ppins.setup_pin("pwm", self.respool_motor_pin) + mcu_pin.setup_cycle_time(self.cycle_time, self.hardware_pwm) + else: + mcu_pin = ppins.setup_pin("digital_out", self.respool_motor_pin) + + valid_gate = True + name = "respool_%d" % gate + mcu_pin.setup_max_duration(0.) + mcu_pin.setup_start_value(start_value, self.shutdown_value) + self.motor_mcu_pins[name] = mcu_pin + self.last_value[name] = start_value + self.respool_gates.append(gate) + + if self.assist_motor_pin and not self._is_empty_pin(self.assist_motor_pin): + if self.is_pwm: + mcu_pin = ppins.setup_pin("pwm", self.assist_motor_pin) + mcu_pin.setup_cycle_time(self.cycle_time, self.hardware_pwm) + else: + mcu_pin = ppins.setup_pin("digital_out", self.assist_motor_pin) + + valid_gate = True + name = "assist_%d" % gate + mcu_pin.setup_max_duration(0.) + mcu_pin.setup_start_value(start_value, self.shutdown_value) + self.motor_mcu_pins[name] = mcu_pin + self.last_value[name] = start_value + self.assist_gates.append(gate) + + if self.enable_motor_pin and not self._is_empty_pin(self.enable_motor_pin): + mcu_pin = ppins.setup_pin("digital_out", self.enable_motor_pin) + + valid_gate = True + name = "enable_%d" % gate + mcu_pin.setup_max_duration(0.) + mcu_pin.setup_start_value(self.last_value, self.shutdown_value) + self.motor_mcu_pins[name] = mcu_pin + self.last_value[name] = start_value + + if self.assist_trigger_pin and not self._is_empty_pin(self.assist_trigger_pin): + buttons.register_buttons( + [self.assist_trigger_pin], + lambda eventtime, state, gate=gate: self._handle_button_advance(eventtime, state, gate) + ) + + if valid_gate: + from .mmu import Mmu # For operation names + self.operation[gate] = (Mmu.ESPOOLER_OFF, 0) + self.back_to_back_burst_count[gate] = 0 + self.burst_trigger_state[gate] = 0 + else: + # Hack to support on HH v3 + self.num_gates = gate + 1 + break + + # Setup minimum number of gcode request queues + self.gcrqs = {} + for mcu_pin in self.motor_mcu_pins.values(): + mcu = mcu_pin.get_mcu() + # TODO Temporary workaround to allow Kalico to work since it lacks GCodeRequestQueue + if hasattr(output_pin, 'GCodeRequestQueue'): + self.gcrqs.setdefault(mcu, output_pin.GCodeRequestQueue(config, mcu, self._set_pin)) + else: + self.gcrqs.setdefault(mcu, GCodeRequestQueue(config, mcu, self._set_pin)) + + # Setup event handler for DC espooler motor burst operation + self.printer.register_event_handler("mmu:espooler_burst", self._handle_espooler_burst) + self.printer.register_event_handler("mmu:disabled", self._handle_mmu_disabled) + + # Register event handlers + self.printer.register_event_handler('klippy:ready', self._handle_ready) + + + def _handle_ready(self): + self.toolhead = self.printer.lookup_object('toolhead') + self.mmu = self.printer.lookup_object('mmu') + + # Setup extruder monitor + try: + self.extruder_monitor = self.ExtruderMonitor(self) + except Exception as e: + self.mmu.log_error(str(e)) + self.extruder_monitor = None + + + def _handle_mmu_disabled(self): + """ + Event indicating that the MMU unit was disabled. Make sure the espooler triggers are disabled + """ + self.reset_print_assist_mode() + + + def _valid_gate(self, gate): + return gate is not None and self.first_gate <= gate < self.first_gate + self.num_gates + + + def _is_empty_pin(self, pin): + if pin == '': return True + ppins = self.printer.lookup_object('pins') + pin_params = ppins.parse_pin(pin, can_invert=True, can_pullup=True) + pin_resolver = ppins.get_pin_resolver(pin_params['chip_name']) + real_pin = pin_resolver.aliases.get(pin_params['pin'], '_real_') + return real_pin == '' + + + def _handle_button_advance(self, eventtime, state, gate): + """ + Callback from button sensor to initiate burst assist + """ + self.mmu.log_trace("ESPOOLER: Trigger fired for gate %d, state=%s" % (gate, state)) + self.burst_trigger_state[gate] = state + if self.mmu and self.mmu.espooler_assist_burst_trigger and self.burst_trigger_enabled.get(gate, False): # Don't handle if not ready or disabled + if self.mmu.espooler_assist_burst_trigger and state: + self.back_to_back_burst_count[gate] += 1 + self.advance(gate) + else: + # Allow future triggers + self.back_to_back_burst_count[gate] = 0 + + + def _set_burst_trigger_enable(self, gate, enable): + from .mmu import Mmu # For operation names + + if self._valid_gate(gate): + cur_enabled = self.burst_trigger_enabled.get(gate, False) + if not cur_enabled and enable: + # Turn on and if currently triggered immediately advance + self.burst_trigger_enabled[gate] = True + if self.burst_trigger_state.get(gate, 0): + self.advance(gate) + elif cur_enabled and not enable: + self.burst_trigger_enabled[gate] = False + self.back_to_back_burst_count[gate] = 0 + # Turn off espooler can cancel any burst timer + self.set_operation(gate, 0, Mmu.ESPOOLER_OFF) + + + # Logic to handle a short "jog" rotation in assist or rewind direction ---------------------------------------------- + + def advance(self, gate=None): + """ + Direct call to initiate in print burst assist. + If called with gate=None then it is likely from the extruder monitor so apply to the + gate in "print assist" mode + """ + from .mmu import Mmu # For operation names + + if gate is None: + gate = self.print_assist_gate + + if gate is None: + self.mmu.log_trace("ESPOOLER: In print assist advance() called but no gate in 'print' mode (ignored)") + return + + self.burst(gate, Mmu.ESPOOLER_ASSIST) + + def burst(self, gate, operation): + """ + Direct call to rotate spool in a burst defined by operation (ESPOOLER_ASSIST or ESPOOLER_REWIND). + The burst will automatically be terminated after configured rotate parameters + (used in spool drying rotation, filament tightening and manual jogging) + """ + from .mmu import Mmu # For operation names + + if operation == Mmu.ESPOOLER_ASSIST: + power = self.mmu.espooler_assist_burst_power + duration = self.mmu.espooler_assist_burst_duration + elif operation == Mmu.ESPOOLER_REWIND: + power = self.mmu.espooler_rewind_burst_power + duration = self.mmu.espooler_rewind_burst_duration + else: + return + + self._handle_espooler_burst(gate, power / 100, duration, operation) + + + def _handle_espooler_burst(self, gate, value, duration, operation): + """ + Rotate burst: short jog movement of spool in selected direction + - Only allowed when gate is ESPOOLER_OFF or ESPOOLER_PRINT. + - While active for a gate, no other operations for that gate are allowed. + - Stops via scheduled callback at end of duration or manual ESPOOLER_OFF. + """ + from .mmu import Mmu # For operation names + + if not self._valid_gate(gate): + return + + # Per-gate lock: ignore if this gate is already in a rotation burst + if gate in self.burst_gates: + self.mmu.log_debug("Got espooler burst event for gate %d but burst already active (ignored)" % gate) + return + + if duration <= 0 or value == 0: + self.mmu.log_debug("Got bad espooler burst event for gate %d: duration=%.1f, value=%.1f (ignored)" % (gate, duration, value)) + return + + # Only allowed if not moving (OFF or PRINT) but always allow interuption of in-print assist gate + cur_op, cur_value = self.get_operation(gate) + msg = "ESPOOLER: Got espooler rotate event for gate %d: value=%.2f duration=%.1f" % (gate, value, duration) + if cur_op in [Mmu.ESPOOLER_OFF, Mmu.ESPOOLER_PRINT] or gate == self.print_assist_gate: + self.mmu.log_trace(msg) + + # Schedule future return to ESPOOLER_OFF / ESPOOLER_PRINT state + waketime = self.reactor.monotonic() + duration + timer = self.reactor.register_timer(lambda pt: self._stop_espooler_burst(gate=gate), waketime) + + # Take per-gate lock and start rewind motor + self.set_operation(gate, value, operation) + self.burst_gates[gate] = (operation, timer) + else: + msg += " (Ignored because espooler state is %s, value: %.2f)" % (cur_op, cur_value) + self.mmu.log_trace(msg) + + + def _stop_espooler_burst(self, gate): + """ + Scheduled (timer event) callback to terminate an espooler burst + """ + from .mmu import Mmu # For operation names + operation = Mmu.ESPOOLER_PRINT if gate == self.print_assist_gate else Mmu.ESPOOLER_OFF + if gate in self.burst_gates: + self.set_operation(gate, 0, operation) # This will call _dequeue_espooler_burst() + + # Monitor triggers for print assist gate + if gate == self.print_assist_gate: + if self.burst_trigger_state.get(gate, 0): + # Still triggered + if self.back_to_back_burst_count[gate] < self.mmu.espooler_assist_burst_trigger_max: + self.back_to_back_burst_count[gate] += 1 + self.advance(gate) + else: + self.mmu.log_error("Espooler assist temporarily suspended because of suspected malfunction. Assist trigger sensor may be stuck in triggered state") + else: + # Trigger has cleared, allow future triggers + self.back_to_back_burst_count[gate] = 0 + + return self.reactor.NEVER # This is setup as a one-shot timer (so early cancellation is possible) + + + def _dequeue_espooler_burst(self, gate): + """ + Cancel "espooler off" callback and remove from burst list. Send completion event + """ + # Cancel callback and remove from burst list + if gate in self.burst_gates: + timer = self.burst_gates[gate][1] + try: + self.reactor.unregister_timer(timer) + except Exception as e: + self.mmu.log_debug("Error cancelling burst callback: Exception: %s" % str(e)) + del self.burst_gates[gate] + + # Notify listeners + self.printer.send_event("mmu:espooler_burst_done", gate) + + + def set_print_assist_mode(self, gate): + """ + Efficient method to turn on in-print assist + """ + from .mmu import Mmu # For operation names + if self.print_assist_gate != gate: + self.set_operation(gate, 0, Mmu.ESPOOLER_PRINT) + + + def reset_print_assist_mode(self): + """ + Reset any gate in the sticky "in-print assist" mode. This is called a lot so should be efficient + """ + from .mmu import Mmu # For operation names + pg = self.print_assist_gate + if pg is not None: + self.print_assist_gate = None + self.mmu.log_trace("ESPOOLER: Cancelling in-print assist for gate %d" % pg) + self._update_pwm(pg, 0, Mmu.ESPOOLER_OFF) + self.operation[pg] = (Mmu.ESPOOLER_OFF, 0) + + # Disable all triggers + if self.mmu.espooler_assist_burst_trigger: + self._set_burst_trigger_enable(pg, False) + if self.extruder_monitor: + self.extruder_monitor.watch(False) + + self._dequeue_espooler_burst(pg) + + + # Change operation in progress and DC motor PWM control ------------------------------------------------------------- + + def set_operation(self, gate, value, operation): + """ + Direct call to change the operation of the espooler and adjust DC motor + Operations are: + ESPOOLER_OFF = Force motor off + ESPOOLER_REWIND = Set motor in rewind (retract) direction + ESPOOLER_ASSIST = Set motor in forward (assist) direction + ESPOOLER_PRINT = Set stick "in-print assist" mode and clear former gate in that mode + """ + from .mmu import Mmu # For operation names + + # To aid debugging... + if self.mmu.log_enabled(Mmu.LOG_TRACE): + self.mmu.log_trace("ESPOOLER: set_operation(gate=%s, value=%s, operation=%s)" % (gate, value, operation)) + + gates = [gate] + if gate is None: + gates = range(self.first_gate, self.first_gate + self.num_gates) + + for g in gates: + if not self._valid_gate(g): + self.mmu.log_trace("ESPOOLER: Trying to set Espooler operation of illegal gate %d (ignored)" % g) + continue + + cur_op, cur_value = self.get_operation(g) + + # OFF ---------------------------------------- + if operation == Mmu.ESPOOLER_OFF: + # Always update PWM as safety precaution + self._update_pwm(g, 0, Mmu.ESPOOLER_OFF) + + if g != self.print_assist_gate: + self.operation[g] = (Mmu.ESPOOLER_OFF, 0) + else: + self.operation[g] = (Mmu.ESPOOLER_PRINT, 0) + + if cur_op != Mmu.ESPOOLER_OFF: + self.mmu.log_debug("Espooler for gate %d turned off" % g) + + # Ensure any existing burst is canceled + self._dequeue_espooler_burst(g) + + # ASSIST or REWIND --------------------------- + elif operation in [Mmu.ESPOOLER_ASSIST, Mmu.ESPOOLER_REWIND]: + if cur_op not in [Mmu.ESPOOLER_OFF, Mmu.ESPOOLER_PRINT]: + # Stop PWM before sending new + self._update_pwm(g, 0, operation) + + conf_gates = self.assist_gates if operation == Mmu.ESPOOLER_ASSIST else self.respool_gates + if g in conf_gates: + self._update_pwm(g, value, operation) + self.operation[g] = (operation, value) + self.mmu.log_debug("Espooler for gate %d set to %s (pwm: %.2f)" % (g, operation, value)) + else: + self.mmu.log_debug("Espooler for gate %d not configured to perform %s operation" % (g, operation)) + + # Ensure any existing burst is canceled + self._dequeue_espooler_burst(g) + + # SPECIAL PRINT ASSIST MODE ------------------ + elif operation == Mmu.ESPOOLER_PRINT: + # Practically this will only be called on a single gate at a time + self.mmu.log_trace("ESPOOLER: Entering in-print assist mode for gate %d" % g) + + # Only one gate can be in print assist mode at a time so clear previous + if g != self.print_assist_gate and self.print_assist_gate is not None: + self.reset_print_assist_mode() + + # Only set sticky in-print trigger mode if pwm value is 0, else ignore + if value == 0: + self.print_assist_gate = g + + self._update_pwm(g, 0, operation) + self.operation[g] = (operation, 0) + + # Enable appropriate triggers + if self.mmu.espooler_assist_burst_trigger: + self._set_burst_trigger_enable(g, True) + elif self.extruder_monitor: + self.extruder_monitor.watch(True) + + self.mmu.log_debug("Espooler for gate %d set to %s (pwm: %.2f)" % (g, operation, value)) + + # Ensure any existing burst is canceled + self._dequeue_espooler_burst(g) + + + def get_operation(self, gate): + """ + Return tuple of current operation and pwm value for gate + """ + from .mmu import Mmu # For operation names + return self.operation.get(gate, (Mmu.ESPOOLER_OFF, 0)) + + + def _update_pwm(self, gate, value, operation): + """ + Set the PWM or digital signal for espooler on gate + The operation is used to assertain motor direction + """ + from .mmu import Mmu # For operation names + + if self.mmu.log_enabled(Mmu.LOG_STEPPER): + self.mmu.log_stepper("ESPOOLER: _update_pwm(%s, %s, %s)" % (gate, value, operation)) + + def _schedule_set_pin(name, value): + mcu_pin = self.motor_mcu_pins.get(name, None) + if mcu_pin: + estimated_print_time = mcu_pin.get_mcu().estimated_print_time(self.printer.reactor.monotonic()) + if self.mmu.log_enabled(Mmu.LOG_STEPPER): + self.mmu.log_stepper("ESPOOLER: --> _schedule_set_pin(name=%s, value=%s) @ print_time: %.8f" % (name, value, estimated_print_time)) + self.gcrqs[mcu_pin.get_mcu()].send_async_request((name, value)) + + # Sanity check + if operation == Mmu.ESPOOLER_OFF: + value = 0 + + # Clamp and scale value + value = max(0, min(1, value)) / self.scale + if not self.is_pwm: + value = 1 if value > 0 else 0 + + # Update PWM signal + if self.get_operation(gate) != (operation, value): + if value == 0: # Stop motor + _schedule_set_pin('enable_%d' % gate, 0) + _schedule_set_pin('respool_%d' % gate, 0) + _schedule_set_pin('assist_%d' % gate, 0) + else: + active_motor_name = 'respool_%d' % gate if operation == Mmu.ESPOOLER_REWIND else 'assist_%d' % gate + inactive_motor_name = 'assist_%d' % gate if operation == Mmu.ESPOOLER_REWIND else 'respool_%d' % gate + _schedule_set_pin(inactive_motor_name, 0) + _schedule_set_pin(active_motor_name, value) + _schedule_set_pin('enable_%d' % gate, 1) + + + # This is the actual callback method to update pin signal (pwm or digital) + def _set_pin(self, print_time, action): + from .mmu import Mmu # For operation names + + name, value = action + mcu_pin = self.motor_mcu_pins.get(name, None) + if mcu_pin: + if value == self.last_value.get(name, None): + return + if self.mmu.log_enabled(Mmu.LOG_STEPPER): + self.mmu.log_stepper("ESPOOLER: -----> _set_pin(name=%s, value=%s) @ print_time: %.8f" % (name, value, print_time)) + if self.is_pwm and not name.startswith('enable_'): + mcu_pin.set_pwm(print_time, value) + else: + mcu_pin.set_digital(print_time, value) + self.last_value[name] = value + + + # ------------------------------------------------------------------------------------------------------------------- + + def get_status(self, eventtime): + return { + 'espooler': [v[0] for v in self.operation.values()] + } + + + # Class to monitor extruder movement an generate espooler "advance" events + class ExtruderMonitor: + + CHECK_MOVEMENT_PERIOD = 1. # How often to check extruder movement + + def __init__(self, espooler): + self.espooler = espooler + self.reactor = espooler.reactor + self.estimated_print_time = espooler.printer.lookup_object('mcu').estimated_print_time + self.extruder = espooler.printer.lookup_object(espooler.mmu.extruder_name, None) + if not self.extruder: + raise espooler.config.error("Extruder named `%s` not found. Espooler extruder monitor disabled" % espooler.mmu.extruder_name) + + self.enabled = False + self.last_extruder_pos = None + self._extruder_pos_update_timer = self.reactor.register_timer(self._extruder_pos_update_event) + + def watch(self, enable): + if not self.enabled and enable: + # Ensure first burst after initial extruder movement + self.last_extruder_pos = self._get_extruder_pos() - self.espooler.mmu.espooler_assist_extruder_move_length + 1. + self.enabled = True + self.reactor.update_timer(self._extruder_pos_update_timer, self.reactor.NOW) # Enabled + elif not enable: + self.last_extruder_pos = None + self.enabled = False + self.reactor.update_timer(self._extruder_pos_update_timer, self.reactor.NEVER) # Disabled + + def _get_extruder_pos(self, eventtime=None): + if eventtime is None: + eventtime = self.reactor.monotonic() + print_time = self.estimated_print_time(eventtime) + if self.extruder: + pos = self.extruder.find_past_position(print_time) + return pos + else: + return 0. + + # Called periodically to check extruder movement + def _extruder_pos_update_event(self, eventtime): + extruder_pos = self._get_extruder_pos(eventtime) + #self.espooler.mmu.log_trace("ESPOOLER: current_extruder_pos: %s (last: %s)" % (extruder_pos, self.last_extruder_pos)) + if self.last_extruder_pos is not None and extruder_pos > self.last_extruder_pos + self.espooler.mmu.espooler_assist_extruder_move_length: + self.espooler.advance() # Initiate burst + self.last_extruder_pos = extruder_pos + return eventtime + self.CHECK_MOVEMENT_PERIOD + +def load_config_prefix(config): + return MmuESpooler(config) + + + +###################################################################### +# G-Code request queuing helper +# This is included to allow Kalico to work since it has not yet picked +# up this klipper functionality 4/18/25 +# Copyright (C) 2017-2024 Kevin O'Connor +###################################################################### + +PIN_MIN_TIME = 0.100 + +# Helper code to queue g-code requests +class GCodeRequestQueue: + def __init__(self, config, mcu, callback): + self.printer = printer = config.get_printer() + self.mcu = mcu + self.callback = callback + self.rqueue = [] + self.next_min_flush_time = 0. + self.toolhead = None + mcu.register_flush_callback(self._flush_notification) + printer.register_event_handler("klippy:connect", self._handle_connect) + def _handle_connect(self): + self.toolhead = self.printer.lookup_object('toolhead') + def _flush_notification(self, print_time, clock): + rqueue = self.rqueue + while rqueue: + next_time = max(rqueue[0][0], self.next_min_flush_time) + if next_time > print_time: + return + # Skip requests that have been overridden with a following request + pos = 0 + while pos + 1 < len(rqueue) and rqueue[pos + 1][0] <= next_time: + pos += 1 + req_pt, req_val = rqueue[pos] + # Invoke callback for the request + min_wait = 0. + ret = self.callback(next_time, req_val) + if ret is not None: + # Handle special cases + action, min_wait = ret + if action == "discard": + del rqueue[:pos+1] + continue + if action == "delay": + pos -= 1 + del rqueue[:pos+1] + self.next_min_flush_time = next_time + max(min_wait, PIN_MIN_TIME) + # Ensure following queue items are flushed + self.toolhead.note_mcu_movequeue_activity(self.next_min_flush_time) + def _queue_request(self, print_time, value): + self.rqueue.append((print_time, value)) + self.toolhead.note_mcu_movequeue_activity(print_time) + def queue_gcode_request(self, value): + self.toolhead.register_lookahead_callback( + (lambda pt: self._queue_request(pt, value))) + def send_async_request(self, value, print_time=None): + if print_time is None: + systime = self.printer.get_reactor().monotonic() + print_time = self.mcu.estimated_print_time(systime + PIN_MIN_TIME) + while 1: + next_time = max(print_time, self.next_min_flush_time) + # Invoke callback for the request + action, min_wait = "normal", 0. + ret = self.callback(next_time, value) + if ret is not None: + # Handle special cases + action, min_wait = ret + if action == "discard": + break + self.next_min_flush_time = next_time + max(min_wait, PIN_MIN_TIME) + if action != "delay": + break diff --git a/klippy/extras/mmu_extruder.py b/klippy/extras/mmu_extruder.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/klippy/extras/mmu_led_effect.py b/klippy/extras/mmu_led_effect.py new file mode 100644 index 000000000000..8f160115ac2d --- /dev/null +++ b/klippy/extras/mmu_led_effect.py @@ -0,0 +1,1722 @@ +# Happy Hare MMU Software +# Wrapper around led_effect klipper module (included) to replicate any effect on entire strip +# as well as on each individual LED for per-gate effects. This relies on the [mmu_leds] section +# for each mmu_unit +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging + +# Klipper imports +from .mmu_leds import MmuLeds + +# [mmu_led_effect] is a simple wrapper that makes it easy to define led animations +# +# E.g. If you have setup the following config in mmu_hardware.cfg for 4-gate MMU +# [mmu_leds unit0] +# exit_leds: neopixel:mmu_leds (1-4) +# status_leds: neopixel:mmu_leds (5) +# +# E.g. You define "my_flash" like this: +# [mmu_led_effect my_flash] +# +# This will create effects on each of these segments elements without laborous +# error prone repetition: +# "unit0_mmu_flash_exit" on 'exit' portion of the strip (leds 1,2,3,4) +# "unit0_mmu_flash_status" on the status LED (led 5) +# "unit0_mmu_flash_exit_1" for gate 0 (led 1) +# "unit0_mmu_flash_exit_2" for gate 1 (led 2) +# "unit0_mmu_flash_exit_3" for gate 2 (led 3) +# "unit0_mmu_flash_exit_4" for gate 3 (led 4) +# +# Created effects can be restricted with by specifing 'define_on' and a let of segment +# names or 'gates' to indicate creation on exit/entry gates. If ommitted all possible +# effects will be created +# +# Then you can set effects with commands like: +# _MMU_SET_LED_EFFECT EFFECT=my_flash_exit # apply effect to all exit leds +# _MMU_SET_LED_EFFECT EFFECT=my_flash_exit_2 # apply effect entry led for gate #1 +# +# or set simple RBGW color with commands like: +# SET_LED LED=mmu_exit_led INDEX=2 RED=50 GREEN=50 BLUE=50 WHITE=0 TRANSMIT=1 +# +# Note that gates start at 0, but led indices and effect naming starts from 1, +# so remember: led_index = gate + 1 +# +class MmuLedEffect: + + def __init__(self, config): + self.printer = config.get_printer() + + mmu_machine = self.printer.lookup_object('mmu_machine', None) + if mmu_machine is None: + raise config.error("[mmu_led_effect] requires [mmu_machine] to be loaded first") + + define_on_str = config.get('define_on', "").strip() + _ = config.get('layers') + + for unit_index, mmu_unit in enumerate(mmu_machine.units): + mmu_leds = self.printer.lookup_object('mmu_leds %s' % mmu_unit.name, None) + if mmu_leds: + frame_rate = mmu_leds.frame_rate + define_on = [segment.strip() for segment in define_on_str.split(',') if segment.strip()] + if define_on and not all(e in MmuLeds.SEGMENTS + ['gates'] for e in define_on): + raise config.error("Unknown LED segment name specified in '%s'" % define_on_str) + config.fileconfig.set(config.get_name(), 'frame_rate', config.get('frame_rate', frame_rate)) + led_effect_section = config.get_name().split()[1] + + # This condition makes it a no-op if [mmu_leds] is not present or led_effects not installed + for segment in MmuLeds.SEGMENTS: + led_segment_name = "unit%d_mmu_%s_leds" % (unit_index, segment) + led_chain = self.printer.lookup_object(led_segment_name) + num_leds = led_chain.led_helper.led_count + leds_per_gate = num_leds // mmu_unit.num_gates + + if num_leds > 0: + # Full segment effects + if not define_on or segment in define_on: + section_to = "_led_effect unit%d_%s_%s" % (unit_index, led_effect_section, segment) + self._add_led_effect(config, section_to, led_segment_name) + + # Per gate + if segment in MmuLeds.PER_GATE_SEGMENTS and (not define_on or 'gates' in define_on): + for idx in range(mmu_unit.first_gate, mmu_unit.first_gate + mmu_unit.num_gates): + led0 = (idx - mmu_unit.first_gate) * leds_per_gate + 1 + led_spec = str(led0) + if leds_per_gate > 1: + led_spec = "%d-%d" % (led0, led0 + leds_per_gate - 1) + section_to = "_led_effect %s_%s_%d" % (led_effect_section, segment, idx) + self._add_led_effect(config, section_to, "%s (%s)" % (led_segment_name, led_spec)) + + def _add_led_effect(self, config, section_to, leds): + config.fileconfig.add_section(section_to) + config.fileconfig.set(section_to, 'leds', leds) + items = config.fileconfig.items(config.get_name()) + for item in (i for i in items if i[0] != 'define_on'): + config.fileconfig.set(section_to, item[0], item[1]) + + c = config.getsection(section_to) + led_effect = _ledEffect(c) + logging.info("MMU: Created: %s on %s" % (c.get_name(), leds)) + self.printer.add_object(c.get_name(), led_effect) # Register _led_effect to stop it trying to be loaded by klipper + +def load_config_prefix(config): + return MmuLedEffect(config) + + + + + + +# -------------------------------------------------------------------------------------- +# +# This is and embedded version (v0.0.18-0-g24d26c72) of the excellent led_effects +# It is included for ease of Happy Hare setup and to avoid dependencies that were +# tripping up users. It doesn't not prelude the adding of the original led-effects +# as well. +# +# Changes: +# - Command names have been changed to hide from original (added '_MMU_' prefix) +# - 'ledEffect' -> '_ledEffect' to avoid name collision with real module +# - def load_config_prefix(config) removed +# - self.handler loads object 'mmu_led_effect' +# +# -------------------------------------------------------------------------------------- + +# Support for addressable LED visual effects +# using neopixel and dotstar LEDs +# +# Copyright (C) 2020 Paul McGowan +# co-authored by Julian Schill +# +# This file may be distributed under the terms of the GNU GPLv3 license. + +from math import cos, exp, pi +from random import randint + +ANALOG_SAMPLE_TIME = 0.001 +ANALOG_SAMPLE_COUNT = 5 +ANALOG_REPORT_TIME = 0.05 + +COLORS = 4 + +###################################################################### +# Custom color value list, returns lists of [r, g ,b] values +# from a one dimensional list +###################################################################### + +class colorArray(list): + def __init__(self, num_colors, kwargs): + self.n=num_colors + super(colorArray,self).__init__(kwargs) + + def __getitem__(self, a): + if isinstance(a, int): + return super(colorArray, self).__getitem__( + slice(a*self.n, a*self.n+self.n)) + if isinstance(a, slice): + start = a.start*self.n if a.start != None else None + stop = a.stop*self.n if a.stop != None else None + return colorArray(self.n, + super(colorArray, self).__getitem__( + slice(start, stop, a.step))) + def __getslice__(self, a, b): + return self.__getitem__(slice(a,b)) + def __setitem__(self, a, v): + if isinstance(a, int): + for i in range(self.n): + super(colorArray, self).__setitem__(a*self.n + i, v[i]) + def __len__(self): + return super(colorArray, self).__len__() // self.n + def reverse(self): + self.__init__(self.n, [c for cl in range(len(self)-1,-1, -1) + for c in self[cl]]) + def shift(self, shift=1, direction=True): + if direction: + shift *= -1 + self.__init__(self.n, self[shift:] + self[:shift]) + def padLeft(self, v, a): + self.__init__(self.n, v * a + self) + def padRight(self, v, a): + self += v * a + +###################################################################### +# LED Effect handler +###################################################################### + +class ledFrameHandler: + def __init__(self, config): + self.printer = config.get_printer() + self.gcode = self.printer.lookup_object('gcode') + self.printer.load_object(config, "display_status") + self.heaters = {} + self.printProgress = 0 + self.effects = [] + self.stepperPositions = [0.0,0.0,0.0] + self.stepperTimer = None + self.heaterCurrent = {} + self.heaterTarget = {} + self.heaterLast = {} + self.heaterTimer = None + self.homing = {} + self.homing_start_flag = {} + self.homing_end_flag = {} + self.printer.register_event_handler('klippy:ready', self._handle_ready) + self.printer.register_event_handler("homing:homing_move_begin", + self._handle_homing_move_begin) + self.printer.register_event_handler("homing:homing_move_end", + self._handle_homing_move_end) + self.ledChains=[] + self.gcode.register_command('_MMU_STOP_LED_EFFECTS', + self.cmd_STOP_LED_EFFECTS, + desc=self.cmd_STOP_LED_EFFECTS_help) + self.shutdown = False + + cmd_STOP_LED_EFFECTS_help = 'Stops all led_effects' + + def _transmit_chain(self, chain): + + # Force update (dotstar workaround) + if hasattr(chain, "prev_data"): + chain.prev_data = None + + helper = getattr(chain, 'led_helper', None) + if helper is None: + raise RuntimeError("Klipper version not compatible: chain has no 'led_helper'.") + + # Request a transmit + helper.need_transmit = True + + if hasattr(helper, '_check_transmit'): + helper._check_transmit() + elif hasattr(helper, 'check_transmit'): + # Older Klipper / Kalico API + helper.check_transmit(None) + else: + raise RuntimeError("Klipper version not compatible: led_helper missing '_check_transmit' and 'check_transmit'.") + + + def _handle_ready(self): + self.shutdown = False + self.reactor = self.printer.get_reactor() + self.printer.register_event_handler('klippy:shutdown', + self._handle_shutdown) + self.printProgress = 0 + self.displayStatus = self.printer.lookup_object('display_status') + self.progressTimer = self.reactor.register_timer(self._pollProgress, + self.reactor.NOW) + self.frameTimer = self.reactor.register_timer(self._getFrames, + self.reactor.NOW) + + def _handle_shutdown(self): + self.shutdown = True + for effect in self.effects: + if not effect.runOnShutown: + for chain in self.ledChains: + chain.led_helper.set_color(None, (0.0, 0.0, 0.0, 0.0)) + self._transmit_chain(chain) + + pass + + def _handle_homing_move_begin(self, hmove): + endstops_being_homed = [name for es,name in hmove.endstops] + + for endstop in endstops_being_homed: + if endstop in self.homing_start_flag: + self.homing_start_flag[endstop] += 1 + else: + self.homing_start_flag[endstop] = 0 + + self.homing[endstop]=True + + def _handle_homing_move_end(self, hmove): + endstops_being_homed = [name for es,name in hmove.endstops] + + for endstop in endstops_being_homed: + if endstop in self.homing_end_flag: + self.homing_end_flag[endstop] += 1 + else: + self.homing_end_flag[endstop] = 0 + self.homing[endstop]=False + + def addEffect(self, effect): + + if effect.heater: + effect.heater=effect.heater.strip('\"\'') + if effect.heater.startswith("temperature_fan ") or effect.heater.startswith("temperature_sensor "): + self.heaters[effect.heater] = self.printer.lookup_object(effect.heater) + else: + pheater = self.printer.lookup_object('heaters') + + heater = pheater.lookup_heater(effect.heater) + if heater is None: + raise self.printer.config_error( + "LED Effect '%s': unknown heater '%s'." + % (effect.name, effect.heater,)) + self.heaters[effect.heater] = heater + self.heaterLast[effect.heater] = 100 + self.heaterCurrent[effect.heater] = 0 + self.heaterTarget[effect.heater] = 0 + + if not self.heaterTimer: + self.heaterTimer = self.reactor.register_timer(self._pollHeater, + self.reactor.NOW) + + if effect.stepper: + self.toolhead = self.printer.lookup_object('toolhead') + self.kin = self.toolhead.get_kinematics() + + if not self.stepperTimer: + self.stepperTimer = self.reactor.register_timer( + self._pollStepper, + self.reactor.NOW) + + if effect in self.effects: + self.effects.remove(effect) + + self.effects.append(effect) + + def _pollHeater(self, eventtime): + for heater in self.heaters.keys(): + current, target = self.heaters[heater].get_temp(eventtime) + self.heaterCurrent[heater] = current + self.heaterTarget[heater] = target + if target > 0: + self.heaterLast[heater] = target + return eventtime + 0.3 #sensors get updated every 300ms + + def _pollStepper(self, eventtime): + + kin_spos = {s.get_name(): s.get_commanded_position() + for s in self.kin.get_steppers()} + + pos = self.kin.calc_position(kin_spos) + + for i in range(3): + if pos[i] >= self.kin.axes_min[i] and pos[i] <= self.kin.axes_max[i]: + self.stepperPositions[i] = int( + ((pos[i] - self.kin.axes_min[i]) / \ + (self.kin.axes_max[i] - self.kin.axes_min[i]) + * 100)- 1) + return eventtime + 0.5 + + def _pollProgress(self, eventtime): + status = self.displayStatus.get_status(eventtime) + p = status.get('progress') + if p is not None: + self.printProgress = int(p * 100) + return eventtime + 1 + + def _getColorData(self, colors, fade): + clamp = (lambda x : 0.0 if x < 0.0 else 1.0 if x > 1.0 else x) + colors = [x*clamp(fade) for x in colors] + colors=colors + [0.0] * (4 - len(colors)) + colors=colors[:4] + colors = [clamp(x) for x in colors] + return tuple(colors) + + def _getFrames(self, eventtime): + chainsToUpdate = set() + + frames = [(effect, effect.getFrame(eventtime)) for effect in self.effects] + + #first set all LEDs to 0, that should be updated + for effect, (frame, update) in frames: + if update: + for i in range(effect.ledCount): + chain,index=effect.leds[i] + chain.led_helper.led_state[index] = (0.0, 0.0, 0.0, 0.0) + chainsToUpdate.add(chain) + + #then sum up all effects for that LEDs + for effect, (frame, update) in frames: + if update: + for i in range(effect.ledCount): + chain,index=effect.leds[i] + + current_state=list(chain.led_helper.led_state[index]) + effect_state=self._getColorData(frame[i*COLORS:i*COLORS+COLORS], + effect.fadeValue) + + next_state=[min(1.0,a+b) for a,b in \ + zip(current_state, effect_state)] + + chain.led_helper.led_state[index] = tuple(next_state) + chainsToUpdate.add(chain) + + for chain in chainsToUpdate: + if not self.shutdown: + self._transmit_chain(chain) + if self.effects: + next_eventtime=min(self.effects, key=lambda x: x.nextEventTime)\ + .nextEventTime + else: + next_eventtime = eventtime + # run at least with 10Hz + next_eventtime=min(next_eventtime, eventtime + 0.1) + return next_eventtime + + def parse_chain(self, chain): + chain = chain.strip() + leds=[] + parms = [parameter.strip() for parameter in chain.split() + if parameter.strip()] + if parms: + chainName=parms[0].replace(':',' ') + ledIndices = ''.join(parms[1:]).strip('()').split(',') + for led in ledIndices: + if led: + if '-' in led: + start, stop = map(int,led.split('-')) + if stop == start: + ledList = [start-1] + elif stop > start: + ledList = list(range(start-1, stop)) + else: + ledList = list(reversed(range(stop-1, start))) + for i in ledList: + leds.append(int(i)) + else: + for i in led.split(','): + leds.append(int(i)-1) + + return chainName, leds + else: + return None, None + + def cmd_STOP_LED_EFFECTS(self, gcmd): + ledParam = gcmd.get('LEDS', "") + stopAll = (ledParam == "") + + for effect in self.effects: + stopEffect = stopAll + if not stopAll: + try: + chainName, ledIndices = self.parse_chain(ledParam) + chain = self.printer.lookup_object(chainName) + except Exception as e: + raise gcmd.error("Unknown LED '%s'" % (ledParam,)) + + if ledIndices == [] and chain in effect.ledChains: + stopEffect = True + else: + for index in ledIndices: + if (chain,index) in effect.leds: + stopEffect=True + + if stopEffect: + if effect.enabled: + effect.set_fade_time(gcmd.get_float('FADETIME', 0.0)) + effect.set_enabled(False) + +def load_config(config): + return ledFrameHandler(config) + +###################################################################### +# LED Effect +###################################################################### + +class _ledEffect: + def __init__(self, config): + self.config = config + self.printer = config.get_printer() + self.gcode = self.printer.lookup_object('gcode') + self.gcode_macro = self.printer.load_object(config, 'gcode_macro') + self.handler = self.printer.load_object(config, 'mmu_led_effect') + self.frameRate = 1.0 / config.getfloat('frame_rate', + default=24, minval=1, maxval=60) + self.enabled = False + self.iteration = 0 + self.layers = [] + self.analogValue = 0 + self.button_state = 0 + self.fadeValue = 0.0 + self.fadeTime = 0.0 + self.fadeEndTime = 0 + + #Basic functions for layering colors. t=top and b=bottom color + self.blendingModes = { + 'top' : (lambda t, b: t ), + 'bottom' : (lambda t, b: b ), + 'add' : (lambda t, b: t + b ), + 'subtract' : (lambda t, b: (b - t) * (b - t > 0)), + 'subtract_b': (lambda t, b: (t - b) * (t - b > 0)), + 'difference': (lambda t, b: (t - b) * (t > b) + (b - t) * (t <= b)), + 'average' : (lambda t, b: 0.5 * (t + b)), + 'multiply' : (lambda t, b: t * b), + 'divide' : (lambda t, b: t / b if b > 0 else 0 ), + 'divide_inv': (lambda t, b: b / t if t > 0 else 0 ), + 'screen' : (lambda t, b: 1.0 - (1.0-t)*(1.0-b) ), + 'lighten' : (lambda t, b: t * (t > b) + b * (t <= b)), + 'darken' : (lambda t, b: t * (t < b) + b * (t >= b)), + 'overlay' : (lambda t, b: \ + 2.0 * t * b if t > 0.5 else \ + 1.0 - (2.0 * (1.0-t) * (1.0-b))) + } + + self.name = config.get_name().split()[1] + + self.autoStart = config.getboolean('autostart', False) + self.runOnShutown = config.getboolean('run_on_error', False) + self.heater = config.get('heater', None) + self.analogPin = config.get('analog_pin', None) + self.buttonPins = config.getlist('button_pins', None) + self.stepper = config.get('stepper', None) + self.recalculate = config.get('recalculate', False) + self.endstops = [x.strip() for x in config.get('endstops','').split(',')] + self.layerTempl = self.gcode_macro.load_template(config, 'layers') + self.configLayers = [] + self.configLeds = config.get('leds') + + self.nextEventTime = 0 + self.printer.register_event_handler('klippy:ready', self._handle_ready) + self.gcode.register_mux_command('_MMU_SET_LED_EFFECT', 'EFFECT', self.name, + self.cmd_SET_LED_EFFECT, + desc=self.cmd_SET_LED_EFFECT_help) + + if self.analogPin: + ppins = self.printer.lookup_object('pins') + self.mcu_adc = ppins.setup_pin('adc', self.analogPin) + if hasattr(self.mcu_adc, 'setup_adc_sample'): + try: + # New klipper (>= v0.13.0-557) + self.mcu_adc.setup_adc_sample(ANALOG_REPORT_TIME, ANALOG_SAMPLE_TIME, ANALOG_SAMPLE_COUNT) + self.mcu_adc.setup_adc_callback(self.adcCallback) + except TypeError: + # A few versions of klipper had these signatures + self.mcu_adc.setup_adc_sample(ANALOG_SAMPLE_TIME, ANALOG_SAMPLE_COUNT) + self.mcu_adc.setup_adc_callback(ANALOG_REPORT_TIME, self.adcCallback) + elif hasattr(self.mcu_adc, 'setup_minmax'): + # Kalico and older klipper + self.mcu_adc.setup_minmax(ANALOG_SAMPLE_TIME, ANALOG_SAMPLE_COUNT) + self.mcu_adc.setup_adc_callback(ANALOG_REPORT_TIME, self.adcCallback) + else: + raise RuntimeError( + "Klipper version not compatible: mcu_adc missing 'setup_adc_sample' and 'setup_minmax'.") + + query_adc = self.printer.load_object(self.config, 'query_adc') + query_adc.register_adc(self.name, self.mcu_adc) + + if self.buttonPins: + buttons = self.printer.load_object(config, "buttons") + buttons.register_buttons(self.buttonPins, self.button_callback) + + cmd_SET_LED_EFFECT_help = 'Starts or Stops the specified led_effect' + + def _handle_ready(self): + self.configChains = self.configLeds.split('\n') + self.ledChains = [] + self.leds = [] + self.enabled = self.autoStart + if not self.enabled: + self.nextEventTime = self.handler.reactor.NEVER + self.printer.register_event_handler('klippy:shutdown', + self._handle_shutdown) + #map each LED from the chains to the "pixels" in the effect frame + for chain in self.configChains: + chainName, ledIndices = self.handler.parse_chain(chain) + if chainName is not None: + ledChain = self.printer.lookup_object(chainName) + + #Add each discrete chain to the collection + if ledChain not in self.ledChains: + self.ledChains.append(ledChain) + + if ledIndices == [] : + for i in range(ledChain.led_helper.led_count): + self.leds.append((ledChain, int(i))) + else: + for led in ledIndices: + if led > ledChain.led_helper.led_count: + raise self.printer.config_error( + "LED effect '%s': index out of range for chain '%s' with %d LEDs." + % (self.name, chainName, ledChain.led_helper.led_count)) + self.leds.append((ledChain, led)) + + self.ledCount = len(self.leds) + self.frame = [0.0] * COLORS * self.ledCount + + #enumerate all effects from the subclasses of _layerBase... + self.availableLayers = {str(c).rpartition('.layer')[2]\ + .replace("'>", "")\ + .lower() : c + for c in self._layerBase.__subclasses__() + if str(c).startswith(" COLORS: + raise Exception( + "LED effect '%s': Color %s has too many elements." % (self.name, str(i),)) + palette=[pad(c) for c in palette] # pad to COLORS colors + palette=[k for c in palette for k in c] # flatten list + except Exception as e: + raise self.printer.config_error( + "LED effect '%s': Error parsing palette in '%s' for layer \"%s\": %s"\ + % (self.name, self.config.get_name(), parms[0], e,)) + self.layers.insert(0, layer(handler = self, + frameHandler = self.handler, + effectRate = float(parms[1]), + effectCutoff = float(parms[2]), + paletteColors = palette, + frameRate = self.frameRate, + ledCount = len(self.leds), + blendingMode = parms[3])) + + self.handler.addEffect(self) + + def getFrame(self, eventtime): + if not self.enabled and self.fadeValue <= 0.0: + if self.nextEventTime < self.handler.reactor.NEVER: + # Effect has just been disabled. Set colors to 0 and update once. + self.nextEventTime = self.handler.reactor.NEVER + self.frame = [0.0] * COLORS * self.ledCount + update = True + else: + update = False + else: + update = True + if eventtime >= self.nextEventTime: + self.nextEventTime = eventtime + self.frameRate + + self.frame = [0.0] * COLORS * self.ledCount + for layer in self.layers: + layerFrame = layer.nextFrame(eventtime) + + if layerFrame: + blend = self.blendingModes[layer.blendingMode] + self.frame = [blend(t, b) for t, b in zip(layerFrame, self.frame)] + + if (self.fadeEndTime > eventtime) and (self.fadeTime > 0.0): + remainingFade = ((self.fadeEndTime - eventtime) / self.fadeTime) + else: + remainingFade = 0.0 + + self.fadeValue = 1.0-remainingFade if self.enabled else remainingFade + + return self.frame, update + + def set_enabled(self, state): + if self.enabled != state: + self.enabled = state + self.nextEventTime = self.handler.reactor.NOW + self.handler._getFrames(self.handler.reactor.NOW) + + def reset_frame(self): + for layer in self.layers: + layer.frameNumber = 0 + + def set_fade_time(self, fadetime): + self.fadeTime = fadetime + self.fadeEndTime = self.handler.reactor.monotonic() + fadetime + if self.fadeTime == 0.0: + self.fadeValue = 0.0 + + def cmd_SET_LED_EFFECT(self, gcmd): + parmFadeTime = gcmd.get_float('FADETIME', 0.0) + + if gcmd.get_int('STOP', 0) >= 1: + if self.enabled: + self.set_fade_time(parmFadeTime) + self.set_enabled(False) + else: + if self.recalculate: + kwargs = self.layerTempl.create_template_context() + kwargs['params'] = gcmd.get_command_parameters() + kwargs['rawparams'] = gcmd.get_raw_command_parameters() + self._generateLayers(kwargs) + if gcmd.get_int('REPLACE',0) >= 1: + for led in self.leds: + for effect in self.handler.effects: + if effect is not self and led in effect.leds: + if effect.enabled: + effect.set_fade_time(parmFadeTime) + effect.set_enabled(False) + + if not self.enabled: + self.set_fade_time(parmFadeTime) + if gcmd.get_int('RESTART', 0) >= 1: + self.reset_frame() + self.set_enabled(True) + + def get_status(self, eventtime): + return {'enabled':self.enabled} + + def _handle_shutdown(self): + self.set_enabled(self.runOnShutown) + + def adcCallback(self, *args): + # Old klipper: _adc_callback(read_time, read_value) + # New klipper: _adc_callback(samples) where samples is a list of (read_time, read_value) + if len(args) == 1: + samples = args[0] + read_time, read_value = samples[-1] + elif len(args) == 2: + read_time, read_value = args + else: + raise TypeError("_adc_callback expected (read_time, read_value) or (samples), got %d args" % len(args)) + + self.analogValue = int(read_value * 1000.0) / 10.0 + + def button_callback(self, eventtime, state): + self.button_state = state + + ###################################################################### + # LED Effect layers + ###################################################################### + + # super class for effect animations. new animations should + # inherit this and return 1 frame of [r, g, b] * + # per call of nextFrame() + class _layerBase(object): + def __init__(self, **kwargs): + self.handler = kwargs['handler'] + self.frameHandler = kwargs['frameHandler'] + self.ledCount = kwargs['ledCount'] + self.paletteColors = colorArray(COLORS, kwargs['paletteColors']) + self.effectRate = kwargs['effectRate'] + self.effectCutoff = kwargs['effectCutoff'] + self.frameRate = kwargs['frameRate'] + self.blendingMode = kwargs['blendingMode'] + self.frameNumber = 0 + self.thisFrame = [] + self.frameCount = 1 + self.lastAnalog = 0 + + def nextFrame(self, eventtime): + if not self.frameCount: + return [0] * COLORS * self.ledCount + self.frameNumber += 1 + self.frameNumber = self.frameNumber * \ + ( self.frameNumber < self.frameCount ) + self.lastFrameTime = eventtime + + return self.thisFrame[self.frameNumber] + + def _decayTable(self, factor=1, rate=1): + + frame = [] + + p = (1.0 / self.frameRate) + r = (p/15.0)*factor + + for s in range(0, int((rate<1)+rate)): + frame.append(1.0) + for x in range(2, int(p / rate)): + b = exp(1)**-(x/r) + if b>.004: + frame.append(b) + return frame + + def _gradient(self, palette, steps, reverse=False, toFirst=False): + palette = colorArray(COLORS, palette[:]) + if reverse: palette.reverse() + + if len(palette) == 1: + return colorArray(COLORS, palette * steps) + + if toFirst: + palette += palette[0] + + paletteIntervals = len(palette)-1 + stepIntervals = steps if toFirst else steps-1 + if stepIntervals != 0: + intervals_per_step = float(paletteIntervals) / stepIntervals + else: + intervals_per_step = 0 + + gradient=palette[0] + + for i in range(1,steps): + j = intervals_per_step * i + k = int(j) + r = j-k + k = min(k, len(palette)-1) + + if ( (k+1) >= len(palette) ) | (r == 0.0) : + z = palette[k] + else: + z = [((1-r)*palette[k][m] + r*palette[k+1][m]) for m in range(COLORS)] + gradient += z + return gradient + + #Individual effects inherit from the LED Effect Base class + #each effect must support the nextFrame() method either by + #using the method from the base class or overriding it. + + #Solid color + class layerStatic(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerStatic, self).__init__(**kwargs) + + self.paletteColors = colorArray(COLORS, self.paletteColors) + + gradientLength = int(self.ledCount) + gradient = colorArray(COLORS, self._gradient(self.paletteColors, + gradientLength)) + + self.thisFrame.append(gradient[0:self.ledCount]) + self.frameCount = len(self.thisFrame) + + #Slow pulsing of color + class layerBreathing(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerBreathing, self).__init__(**kwargs) + + brightness = [] + + p = (1 / self.frameRate) * (self.effectRate * 0.5) + o = int(p) + f = 2 * pi + + for x in range(0, int(p)): + if x < p: + v = (exp(-cos((f / p) * (x+o)))-0.367879) / 2.35040238 + else: + v = 0 + + #clamp values + if v > 1.0: + v = 1.0 + elif v < 0.0: + v = 0.0 + + brightness.append(v) + + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + + for b in brightness: + self.thisFrame += [[b * i for i in color] * self.ledCount] + + self.frameCount = len(self.thisFrame) + class layerLinearFade(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerLinearFade, self).__init__(**kwargs) + + gradientLength = int(self.effectRate / self.frameRate) + if gradientLength == 0: gradientLength = 1 + + gradient = colorArray(COLORS, self._gradient(self.paletteColors, + gradientLength, toFirst=True)) + + for i in range(gradientLength): + self.thisFrame.append(gradient[i]*self.ledCount) + + self.frameCount = len(self.thisFrame) + + #Turns the entire strip on and off + class layerBlink(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerBlink, self).__init__(**kwargs) + + dutyCycle= max(0,min(1.0, self.effectCutoff)) + frameCountOn = int(( 1.0 / self.frameRate ) * self.effectRate\ + * dutyCycle) + frameCountOff = int(( 1.0 / self.frameRate ) * self.effectRate\ + * (1-dutyCycle)) + + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + self.thisFrame += [color * self.ledCount] * frameCountOn + self.thisFrame += [[0]*COLORS * self.ledCount] * frameCountOff + + self.frameCount = len(self.thisFrame) + + #Random flashes with decay + class layerTwinkle(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerTwinkle, self).__init__(**kwargs) + + self.thisFrame = colorArray(COLORS, ([0.0]*COLORS) * self.ledCount) + self.lastBrightness = [-1] * self.ledCount + self.decayTable = self._decayTable(factor=1 / self.effectCutoff) + self.decayLen = len(self.decayTable) + self.colorCount = len(self.paletteColors) - 1 + + def nextFrame(self, eventtime): + + for i in range(0, self.ledCount): + + r = randint(0, self.colorCount) + color = self.paletteColors[r] + + if randint(0, 255) > 254 - self.effectRate: + self.lastBrightness[i] = 0 + self.thisFrame[i] = color + + if self.lastBrightness[i] != -1: + if self.lastBrightness[i] == self.decayLen: + self.lastBrightness[i] = -1 + self.thisFrame[i] = ([0.0]*COLORS) + else: + x = self.lastBrightness[i] + self.lastBrightness[i] += 1 + self.thisFrame[i] = [self.decayTable[x] * l + for l in self.thisFrame[i]] + + return self.thisFrame + + #Blinking with decay + class layerStrobe(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerStrobe, self).__init__(**kwargs) + + frameRate = int(1.0 / self.frameRate) + if self.effectRate==0: + frameCount = 1 + else: + frameCount = max(1,int(frameRate / self.effectRate)) + if self.effectCutoff==0: self.effectCutoff=0.001 + decayTable = self._decayTable(factor=1 / self.effectCutoff, + rate=1) + if len(decayTable) > frameCount: + decayTable = decayTable[:frameCount] + else: + decayTable += [0.0] * (frameCount - len(decayTable)) + + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + + for b in decayTable: + self.thisFrame += [[b * i for i in color] * self.ledCount] + + self.frameCount = len(self.thisFrame) + + #Lights move sequentially with decay + class layerComet(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerComet, self).__init__(**kwargs) + if self.effectRate > 0: + self.direction = True + else: + self.direction = False + self.effectRate *= -1 + + if self.effectCutoff <= 0: self.effectCutoff = .1 + + decayTable = self._decayTable(factor=len(self.paletteColors) * \ + self.effectCutoff, rate=1) + + gradient = self.paletteColors[0] + \ + self._gradient(self.paletteColors[1:], len(decayTable)+1) + + decayTable = [c for b in zip(decayTable, decayTable, decayTable, decayTable) \ + for c in b] + + comet = colorArray(COLORS, [a * b for a, b in zip(gradient,decayTable)]) + + comet.padRight([0.0]*COLORS, self.ledCount - len(comet)) + + if self.direction: comet.reverse() + else: comet.shift(self.ledCount - len(comet)) + + if self.effectRate == 0: + self.thisFrame.append(comet[0:self.ledCount]) + else: + for i in range(len(comet)): + comet.shift(int(self.effectRate+(self.effectRate < 1)), + self.direction) + self.thisFrame.append(comet[:self.ledCount]) + + for x in range(int((1/self.effectRate)-(self.effectRate <= 1))): + self.thisFrame.append(comet[:self.ledCount]) + + self.frameCount = len(self.thisFrame) + + #Lights move sequentially with decay + class layerChase(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerChase, self).__init__(**kwargs) + + if self.effectRate > 0: + self.direction = True + else: + self.direction = False + self.effectRate *= -1 + + if len(self.paletteColors) == 1: + self.paletteColors += colorArray(COLORS,COLORS*[0]) + + decayTable = self._decayTable(factor=len(self.paletteColors) * \ + self.effectCutoff, rate=1) + + gradient = self.paletteColors[0] + \ + self._gradient(self.paletteColors[1:], len(decayTable)+1) + + decayTable = [c for b in zip(decayTable, decayTable, decayTable, decayTable) \ + for c in b] + gradient = colorArray(COLORS, [a * b + for a, b in zip(gradient,decayTable)]) + + k=int(self.ledCount/len(gradient))+1 + chase = colorArray(COLORS,k*gradient) + + if self.direction: chase.reverse() + if self.effectRate == 0: + self.thisFrame.append(chase[0:self.ledCount]) + else: + for _ in range(len(chase)): + chase.shift(int(self.effectRate+(self.effectRate < 1)), + self.direction) + self.thisFrame.append(chase[0:self.ledCount]) + + for _ in range(int((1/self.effectRate)-(self.effectRate <= 1))): + self.thisFrame.append(chase[0:self.ledCount]) + + self.frameCount = len(self.thisFrame) + + #Cylon, single LED bounces from start to end of strip + class layerCylon(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerCylon, self).__init__(**kwargs) + + self.paletteColors = colorArray(COLORS, self.paletteColors) + + if self.effectRate <= 0: + raise self.handler.printer.config_error( + "LED Effect '%s': effect rate for cylon must be > 0" % (self.handler.name,)) + + # How many frames per sweep animation. + frames = int(self.effectRate / self.frameRate) + + direction = True + + for _ in range(len(self.paletteColors) % 2 + 1): + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + + for frame in range(frames): + pct = frame / (frames - 1) + newFrame = [] + + p = int(round((self.ledCount - 2) * pct) if direction else 1 + round(((self.ledCount - 2) * (1 - pct)))) + + for i in range(self.ledCount): + newFrame += color if p == i else [0.0] * COLORS + + self.thisFrame.append(newFrame) + + direction = not direction + + self.frameCount = len(self.thisFrame) + + #Color gradient over all LEDs + class layerGradient(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerGradient, self).__init__(**kwargs) + + direction = -1 if self.effectRate < 0 else 1 + + if self.effectRate == 0: + gradientLength = self.ledCount + else: + gradientLength=abs(int(1/(self.effectRate * self.frameRate))) + gradient = colorArray(COLORS, self._gradient(self.paletteColors, + gradientLength, + toFirst=True)) + + for i in range(gradientLength if self.effectRate != 0 else 1): + frame = colorArray(COLORS, ([0.0]*COLORS) * self.ledCount) + for led in range(self.ledCount): + frame[led] = gradient[ int(i*direction + \ + self.effectCutoff * gradientLength * led \ + / self.ledCount ) % gradientLength] + self.thisFrame.append(frame) + + self.frameCount = len(self.thisFrame) + + class layerPattern(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerPattern, self).__init__(**kwargs) + + self.paletteColors = colorArray(COLORS, self.paletteColors) + frame = colorArray(COLORS, []) + + for i in range(int(self.ledCount/len(self.paletteColors))+1): + frame+=(self.paletteColors) + + if int(self.effectRate/self.frameRate) == 0: + self.thisFrame.append(frame) + else: + for _ in range(len(self.paletteColors) * (self.ledCount-1)): + for _ in range(int(self.effectRate/self.frameRate)): + self.thisFrame.append(colorArray(COLORS, frame)[:COLORS*self.ledCount]) + frame.shift(int(self.effectCutoff)) + + self.frameCount = len(self.thisFrame) + + #Responds to heater temperature + class layerHeater(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerHeater, self).__init__(**kwargs) + + if len(self.paletteColors) == 1: + self.paletteColors += self.paletteColors + + gradient = colorArray(COLORS, self._gradient(self.paletteColors[:-1], 200) + + self.paletteColors[-1:]) + + for i in range(len(gradient)): + self.thisFrame.append(gradient[i] * self.ledCount) + + self.frameCount = len(self.thisFrame) + + if self.handler.heater is None: + raise self.handler.printer.config_error( + "LED Effect '%s' has no heater defined." % (self.handler.name)) + + def nextFrame(self, eventtime): + heaterTarget = self.frameHandler.heaterTarget[self.handler.heater] + heaterCurrent = self.frameHandler.heaterCurrent[self.handler.heater] + heaterLast = self.frameHandler.heaterLast[self.handler.heater] + + if heaterTarget > 0.0 and heaterCurrent > 0.0: + if (heaterCurrent >= self.effectRate): + if (heaterCurrent <= heaterTarget-2): + s = int(((heaterCurrent - self.effectRate) / (heaterTarget - self.effectRate)) * 200) + s = min(len(self.thisFrame)-1,s) + return self.thisFrame[s] + elif self.effectCutoff > 0: + return None + else: + return self.thisFrame[-1] + else: + return None + + elif self.effectRate > 0 and heaterCurrent > 0.0: + if heaterCurrent >= self.effectRate and heaterLast > 0: + s = int(((heaterCurrent - self.effectRate) / heaterLast) * 200) + s = min(len(self.thisFrame)-1,s) + return self.thisFrame[s] + + return None + + #Responds to heater temperature + class layerTemperature(_layerBase): + def __init__(self, **kwargs): + + super(_ledEffect.layerTemperature, self).__init__(**kwargs) + if len(self.paletteColors) == 1: + self.paletteColors = colorArray(COLORS, ([0.0]*COLORS)) + self.paletteColors + gradient = colorArray(COLORS, self._gradient(self.paletteColors, 200)) + for i in range(len(gradient)): + self.thisFrame.append(gradient[i] * self.ledCount) + self.frameCount = len(self.thisFrame) + + if self.handler.heater is None: + raise self.handler.printer.config_error( + "LED Effect '%s' has no heater defined." % (self.handler.name)) + + def nextFrame(self, eventtime): + if self.effectCutoff == self.effectRate: + s = 200 if self.frameHandler.heaterCurrent[self.handler.heater] >= self.effectRate else 0 + else: + s = int(((self.frameHandler.heaterCurrent[self.handler.heater] - + self.effectRate) / + (self.effectCutoff - self.effectRate)) * 200) + + s = min(len(self.thisFrame)-1,s) + s = max(0,s) + return self.thisFrame[s] + class layerHeaterGauge(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerHeaterGauge, self).__init__(**kwargs) + + if self.effectRate < 0: + self.effectRate = self.ledCount + + if self.effectCutoff < 0: + self.effectCutoff = self.ledCount + + if self.effectRate == 0: + trailing = colorArray(COLORS, [0.0]*COLORS * self.ledCount) + else: + trailing = colorArray(COLORS, self._gradient(self.paletteColors[1:], + int(self.effectRate), True)) + trailing.padLeft([0.0]*COLORS, self.ledCount) + + if self.effectCutoff == 0: + leading = colorArray(COLORS, [0.0]*COLORS * self.ledCount) + else: + leading = colorArray(COLORS, self._gradient(self.paletteColors[1:], + int(self.effectCutoff), False)) + leading.padRight([0.0]*COLORS, self.ledCount) + + gradient = colorArray(COLORS, trailing + self.paletteColors[0] + leading) + gradient.shift(len(trailing), 0) + frames = [gradient[:self.ledCount]] + + for i in range(0, self.ledCount): + gradient.shift(1,1) + frames.append(gradient[:self.ledCount]) + + self.thisFrame.append(colorArray(COLORS, [0.0]*COLORS * self.ledCount)) + for i in range(1, 101): + x = int((i / 101.0) * self.ledCount) + self.thisFrame.append(frames[x]) + + self.frameCount = len(self.thisFrame) + + def nextFrame(self, eventtime): + heaterTarget = self.frameHandler.heaterTarget[self.handler.heater] + heaterCurrent = self.frameHandler.heaterCurrent[self.handler.heater] + heaterLast = self.frameHandler.heaterLast[self.handler.heater] + + if heaterTarget > 0.0: + p = int(heaterCurrent/heaterTarget * 100.0) + elif heaterLast > 0.0: + p = int(heaterCurrent/heaterLast * 100.0) + else: + p = 0 + + p = min(len(self.thisFrame)-1,p) + p = max(0,p) + + return self.thisFrame[p] + + class layerTemperatureGauge(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerTemperatureGauge, self).__init__(**kwargs) + + trailing = colorArray(COLORS, self._gradient(self.paletteColors[1:], + int(self.ledCount), True)) + trailing.padLeft([0.0]*COLORS, self.ledCount) + + leading = colorArray(COLORS, [0.0]*COLORS * self.ledCount) + + gradient = colorArray(COLORS, trailing + self.paletteColors[0] + leading) + gradient.shift(len(trailing), 0) + frames = [gradient[:self.ledCount]] + + for i in range(0, self.ledCount): + gradient.shift(1,1) + frames.append(gradient[:self.ledCount]) + + self.thisFrame.append(colorArray(COLORS, [0.0]*COLORS * self.ledCount)) + self.steps = 255 + for i in range(1, self.steps + 1): + x = int((i / float(self.steps + 1)) * self.ledCount) + frames2=colorArray(COLORS,[]) + + for idx,led in enumerate(frames[x]): + + brightness = min(1.0,max(0.0,len(frames[x]) * (float(i) / float(self.steps + 1)) - int(idx/COLORS))) + + frames2.append(led*brightness) + + self.thisFrame.append(frames2) + + self.frameCount = len(self.thisFrame) + + def nextFrame(self, eventtime): + if self.effectCutoff == self.effectRate: + s = len(self.thisFrame) if self.frameHandler.heaterCurrent[self.handler.heater] >= self.effectRate else 0 + else: + s = int(((self.frameHandler.heaterCurrent[self.handler.heater] - + self.effectRate) / + (self.effectCutoff - self.effectRate)) * self.steps) + + s = min(len(self.thisFrame)-1,s) + s = max(0,s) + + + + return self.thisFrame[s] + + + #Responds to analog pin voltage + class layerAnalogPin(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerAnalogPin, self).__init__(**kwargs) + + if len(self.paletteColors) == 1: + self.paletteColors = [0.0]*COLORS + self.paletteColors + + gradient = colorArray(COLORS, self._gradient(self.paletteColors, 101)) + + for i in range(len(gradient)): + self.thisFrame.append(gradient[i] * self.ledCount) + + def nextFrame(self, eventtime): + v = int(self.handler.analogValue * self.effectRate) + + if v > 100: v = 100 + + if v > self.effectCutoff: + return self.thisFrame[v] + else: + return self.thisFrame[0] + + #Lights illuminate relative to stepper position + class layerStepper(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerStepper, self).__init__(**kwargs) + + if self.effectRate < 0: + self.effectRate = self.ledCount + + if self.effectCutoff < 0: + self.effectCutoff = self.ledCount + + if self.effectRate == 0: + trailing = colorArray(COLORS, [0.0]*COLORS * self.ledCount) + else: + trailing = colorArray(COLORS, self._gradient(self.paletteColors[1:], + int(self.effectRate), True)) + trailing.padLeft([0.0]*COLORS, self.ledCount) + + if self.effectCutoff == 0: + leading = colorArray(COLORS, [0.0]*COLORS * self.ledCount) + else: + leading = colorArray(COLORS, self._gradient(self.paletteColors[1:], + int(self.effectCutoff), False)) + leading.padRight([0.0]*COLORS, self.ledCount) + + gradient = colorArray(COLORS, trailing + self.paletteColors[0] + leading) + gradient.shift(len(trailing)-1, 0) + frames = [gradient[:self.ledCount]] + + for i in range(0, self.ledCount): + gradient.shift(1,1) + frames.append(gradient[:self.ledCount]) + + for i in range(101): + x = int((i / 101.0) * self.ledCount) + self.thisFrame.append(frames[x]) + + self.frameCount = len(self.thisFrame) + + def nextFrame(self, eventtime): + if self.handler.stepper == 'x': axis = 0 + elif self.handler.stepper == 'y': axis = 1 + else: axis = 2 + + p = self.frameHandler.stepperPositions[int(axis)] + + if p < 0 : p=0 + if p > 100 : p=100 + return self.thisFrame[int((p - 1) * (p > 0))] + + class layerStepperColor(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerStepperColor, self).__init__(**kwargs) + + if len(self.paletteColors) == 1: + self.paletteColors = [0.0]*COLORS + self.paletteColors + + gradient = colorArray(COLORS, self._gradient(self.paletteColors, 101)) + + for i in range(len(gradient)): + self.thisFrame.append(gradient[i] * self.ledCount) + + def nextFrame(self, eventtime): + if self.handler.stepper == 'x': axis = 0 + elif self.handler.stepper == 'y': axis = 1 + else: axis = 2 + + p = self.frameHandler.stepperPositions[int(axis)]*self.effectRate+self.effectCutoff + + if p < 0 : p=0 + if p > 100 : p=100 + + return self.thisFrame[int(p)] + + #Shameless port of Fire2012 by Mark Kriegsman + + #Shamelessly appropriated from the Arduino FastLED example files + #Fire2012.ino by Daniel Garcia + class layerFire(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerFire, self).__init__(**kwargs) + + self.heatMap = [0.0] * self.ledCount + self.gradient = colorArray(COLORS, self._gradient(self.paletteColors, + 102)) + self.frameLen = len(self.gradient) + self.heatLen = len(self.heatMap) + self.heatSource = int(self.ledCount / 10.0) + self.effectRate = int(self.effectRate) + + if self.heatSource < 1: + self.heatSource = 1 + + def nextFrame(self, eventtime): + frame = [] + + for h in range(self.heatLen): + c = randint(0,int(self.effectCutoff)) + self.heatMap[h] -= (self.heatMap[h] - c >= 0 ) * c + + for i in range(self.ledCount - 1, self.heatSource, -1): + d = (self.heatMap[i - 1] + + self.heatMap[i - 2] + + self.heatMap[i - 3] ) / 3 + + self.heatMap[i] = d * (d >= 0) + + if randint(0, 100) < self.effectRate: + h = randint(0, self.heatSource) + self.heatMap[h] += randint(90,100) + if self.heatMap[h] > 100: + self.heatMap[h] = 100 + + for h in self.heatMap: + frame += self.gradient[int(h)] + + return frame + + #Fire that responds relative to actual vs target temp + class layerHeaterFire(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerHeaterFire, self).__init__(**kwargs) + + self.heatMap = [0.0] * self.ledCount + self.gradient = colorArray(COLORS, self._gradient(self.paletteColors, + 102)) + self.frameLen = len(self.gradient) + self.heatLen = len(self.heatMap) + self.heatSource = int(self.ledCount / 10.0) + + if self.handler.heater is None: + raise self.handler.printer.config_error( + "LED Effect '%s' has no heater defined." % (self.handler.name)) + + if self.heatSource < 1: + self.heatSource = 1 + + def nextFrame(self, eventtime): + frame = [] + spark = 0 + heaterTarget = self.frameHandler.heaterTarget[self.handler.heater] + heaterCurrent = self.frameHandler.heaterCurrent[self.handler.heater] + heaterLast = self.frameHandler.heaterLast[self.handler.heater] + + if heaterTarget > 0.0 and heaterCurrent > 0.0: + if (heaterCurrent >= self.effectRate): + if heaterCurrent <= heaterTarget-2: + spark = int((heaterCurrent / heaterTarget) * 80) + brightness = int((heaterCurrent / heaterTarget) * 100) + elif self.effectCutoff > 0: + spark = 0 + else: + spark = 80 + brightness = 100 + elif self.effectRate > 0 and heaterCurrent > 0.0: + if heaterCurrent >= self.effectRate: + spark = int(((heaterCurrent - self.effectRate) + / heaterLast) * 80) + brightness = int(((heaterCurrent - self.effectRate) + / heaterLast) * 100) + + if spark > 0 and heaterTarget != 0: + cooling = int((heaterCurrent / heaterTarget) * 20) + + for h in range(self.heatLen): + c = randint(0, cooling) + self.heatMap[h] -= (self.heatMap[h] - c >= 0 ) * c + + for i in range(self.ledCount - 1, self.heatSource, -1): + d = (self.heatMap[i - 1] + + self.heatMap[i - 2] + + self.heatMap[i - 3] ) / 3 + + self.heatMap[i] = d * (d >= 0) + + if randint(0, 100) < spark: + h = randint(0, self.heatSource) + self.heatMap[h] += brightness + if self.heatMap[h] > 100: + self.heatMap[h] = 100 + + for h in self.heatMap: + frame += self.gradient[int(h)] + + return frame + + else: + return None + + #Progress bar using M73 gcode command + class layerProgress(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerProgress, self).__init__(**kwargs) + + if self.effectRate < 0: + self.effectRate = self.ledCount + + if self.effectCutoff < 0: + self.effectCutoff = self.ledCount + + if self.effectRate == 0: + trailing = colorArray(COLORS, [0.0]*COLORS * self.ledCount) + else: + trailing = colorArray(COLORS, self._gradient(self.paletteColors[1:], + int(self.effectRate), True)) + trailing.padLeft([0.0]*COLORS, self.ledCount) + + if self.effectCutoff == 0: + leading = colorArray(COLORS, [0.0]*COLORS * self.ledCount) + else: + leading = colorArray(COLORS, self._gradient(self.paletteColors[1:], + int(self.effectCutoff), False)) + leading.padRight([0.0]*COLORS, self.ledCount) + + gradient = colorArray(COLORS, trailing + self.paletteColors[0] + leading) + gradient.shift(len(trailing), 0) + frames = [gradient[:self.ledCount]] + + for i in range(0, self.ledCount): + gradient.shift(1,1) + frames.append(gradient[:self.ledCount]) + + self.thisFrame.append(colorArray(COLORS, [0.0]*COLORS * self.ledCount)) + for i in range(1, 101): + x = int((i / 101.0) * self.ledCount) + self.thisFrame.append(frames[x]) + + self.frameCount = len(self.thisFrame) + + def nextFrame(self, eventtime): + p = self.frameHandler.printProgress + return self.thisFrame[p] #(p - 1) * (p > 0)] + + class layerHoming(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerHoming, self).__init__(**kwargs) + + self.paletteColors = colorArray(COLORS, self.paletteColors) + + gradientLength = int(self.ledCount) + gradient = colorArray(COLORS, self._gradient(self.paletteColors, + gradientLength)) + + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + self.thisFrame.append(colorArray(COLORS,color*self.ledCount)) + + self.decayTable = self._decayTable(factor=self.effectRate) + self.decayTable.append(0.0) + self.decayLen = len(self.decayTable) + self.counter=self.decayLen-1 + self.coloridx=-1 + self.my_flag={} + for endstop in self.handler.endstops: + self.frameHandler.homing_end_flag[endstop] = 0 + self.my_flag[endstop] = self.frameHandler.homing_end_flag[endstop] + + def nextFrame(self, eventtime): + for endstop in self.handler.endstops: + + if self.my_flag[endstop] != self.frameHandler.homing_end_flag[endstop]: + self.counter = 0 + self.coloridx = (self.coloridx + 1) % len(self.paletteColors) + self.my_flag[endstop] = self.frameHandler.homing_end_flag[endstop] + + frame = [self.decayTable[self.counter] * i for i in self.thisFrame[self.coloridx ]] + if self.counter < self.decayLen-1: + self.counter += 1 + + return frame + class layerSwitchButton(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerSwitchButton, self).__init__(**kwargs) + self.last_state = 0 + self.coloridx = 0 + self.fadeValue = 0.0 + self.paletteColors = colorArray(COLORS, self.paletteColors) + + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + self.thisFrame.append(colorArray(COLORS,color*self.ledCount)) + + def nextFrame(self, eventtime): + if self.handler.button_state > self.last_state: + self.coloridx = (self.coloridx + 1) % len(self.paletteColors) + + self.last_state = self.handler.button_state + + if self.last_state: + if self.effectRate > 0 and self.fadeValue < 1.0: + self.fadeValue += (self.handler.frameRate / self.effectRate) + else: + self.fadeValue = 1.0 + else: + if self.effectCutoff > 0 and self.fadeValue > 0.0: + self.fadeValue -= (self.handler.frameRate / self.effectCutoff) + else: + self.fadeValue = 0.0 + + if self.fadeValue < 0: self.fadeValue = 0 + if self.fadeValue > 1.0: self.fadeValue = 1.0 + return [self.fadeValue * i for i in self.thisFrame[self.coloridx]] + + class layerToggleButton(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerToggleButton, self).__init__(**kwargs) + self.last_state = 0 + self.last_coloridx = 0 + self.coloridx = 0 + self.fadeInValue = 0.0 + self.fadeOutValue = 0.0 + self.active = False + self.paletteColors = colorArray(COLORS, self.paletteColors) + + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + self.thisFrame.append(colorArray(COLORS,color*self.ledCount)) + + def nextFrame(self, eventtime): + if self.handler.button_state > self.last_state: + self.last_coloridx = self.coloridx + self.coloridx = (self.coloridx + 1) % len(self.paletteColors) + self.last_state = self.handler.button_state + self.fadeInValue = 0 + self.fadeOutValue = 1.0 + + self.last_state = self.handler.button_state + + if self.effectRate > 0 and self.fadeInValue < 1.0: + self.fadeInValue += (self.handler.frameRate / self.effectRate) + else: + self.fadeInValue = 1.0 + if self.effectCutoff > 0 and self.fadeOutValue > 0.0: + self.fadeOutValue -= (self.handler.frameRate / self.effectCutoff) + else: + self.fadeOutValue = 0.0 + + if self.fadeInValue < 0: self.fadeInValue = 0 + if self.fadeInValue > 1.0: self.fadeInValue = 1.0 + + if self.fadeOutValue < 0: self.fadeOutValue = 0 + if self.fadeOutValue > 1.0: self.fadeOutValue = 1.0 + + frameIn = [self.fadeInValue * i for i in self.thisFrame[self.coloridx]] + frameOut = [self.fadeOutValue * i for i in self.thisFrame[self.last_coloridx]] + + return [ i + o for i, o in zip(frameIn,frameOut)] + + class layerFlashButton(_layerBase): + def __init__(self, **kwargs): + super(_ledEffect.layerFlashButton, self).__init__(**kwargs) + self.last_state = 0 + self.active = False + self.coloridx = 0 + self.fadeValue = 0.0 + self.paletteColors = colorArray(COLORS, self.paletteColors) + + for c in range(0, len(self.paletteColors)): + color = self.paletteColors[c] + self.thisFrame.append(colorArray(COLORS,color*self.ledCount)) + + def nextFrame(self, eventtime): + + if self.handler.button_state > self.last_state: + self.coloridx = (self.coloridx + 1) % len(self.paletteColors) + self.active = True + + self.last_state=self.handler.button_state + + if self.active: + if self.effectRate > 0 and self.fadeValue < 1.0: + self.fadeValue += (self.handler.frameRate / self.effectRate) + else: + self.fadeValue = 1.0 + if self.fadeValue >= 1.0: + self.fadeValue = 1.0 + self.active = False + else: + if self.effectCutoff > 0 and self.fadeValue > 0.0: + self.fadeValue -= (self.handler.frameRate / self.effectCutoff) + else: + self.fadeValue = 0.0 + + if self.fadeValue <= 0: + self.fadeValue = 0 + + return [self.fadeValue * i for i in self.thisFrame[self.coloridx]] + +# -------------------------------------------------------------------------------------- +# ----------------------- End of klipper_led_effects import ---------------------------- +# -------------------------------------------------------------------------------------- diff --git a/klippy/extras/mmu_leds.py b/klippy/extras/mmu_leds.py new file mode 100644 index 000000000000..1201dcb52df7 --- /dev/null +++ b/klippy/extras/mmu_leds.py @@ -0,0 +1,206 @@ +# Happy Hare MMU Software +# +# Allows for flexible creation of virtual leds chains - one for each of the supported +# segments (exit, entry, status). Entry and exit are indexed by gate number. +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, re + +# Klipper imports +from . import led as klipper_led + +class VirtualMmuLedChain: + def __init__(self, config, unit_name, segment, config_chains): + self.printer = config.get_printer() + self.name = "%s_mmu_%s_leds" % (unit_name, segment) + self.config_chains = config_chains + + # Create temporary config section just to access led helper + led_section = "led %s" % self.name + config.fileconfig.add_section(led_section) + led_config = config.getsection(led_section) + self.led_helper = klipper_led.LEDHelper(led_config, self.update_leds, sum(len(leds) for chain_name, leds in config_chains)) + config.fileconfig.remove_section(led_section) + + # We need to configure the chain now so we can validate + self.leds = [] + for chain_name, leds in self.config_chains: + try: + chain = self.printer.load_object(config, chain_name) + if chain: + for led in leds: + self.leds.append((chain, led)) + except Exception as e: + raise config.error("MMU LED chain '%s' referenced in '%s' cannot be loaded:\n%s" % (chain_name, self.name, str(e))) + + # Register led object with klipper + logging.info("MMU: Created: %s" % led_section) + self.printer.add_object(self.name, self) + + def update_leds(self, led_state, print_time): + chains_to_update = set() + for color, (chain, led) in zip(led_state, self.leds): + chain.led_helper.led_state[led] = color + chains_to_update.add(chain) + for chain in chains_to_update: + chain.led_helper.need_transmit = True + if hasattr(chain.led_helper, '_check_transmit'): + chain.led_helper._check_transmit() # New klipper + else: + chain.led_helper.check_transmit(None) # Older klipper / Kalico + + def get_status(self, eventtime=None): + state = [] + chain_status = {} + for chain, led in self.leds: + if chain not in chain_status: + status = chain.led_helper.get_status(eventtime)['color_data'] + chain_status[chain] = status + state.append(chain_status[chain][led]) + return {"color_data": state} + + +class MmuLeds: + + PER_GATE_SEGMENTS = ['exit', 'entry'] + SEGMENTS = PER_GATE_SEGMENTS + ['status', 'logo'] + + def __init__(self, config, *args): + if len(args) < 2: + raise config.error("[%s] cannot be instantiated directly. It must be loaded by [mmu_unit]" % config.get_name()) + self.mmu_machine, self.mmu_unit, self.first_gate, self.num_gates = args + self.name = config.get_name().split()[-1] + self.printer = config.get_printer() + self.frame_rate = config.getint('frame_rate', 24) + + # Create virtual led chains + self.virtual_chains = {} + for segment in self.SEGMENTS: + name = "%s_leds" % segment + config_chains = [self.parse_chain(line) for line in config.get(name, '').split('\n') if line.strip()] + self.virtual_chains[segment] = VirtualMmuLedChain(config, "unit%d" % self.mmu_unit.unit_index, segment, config_chains) + + num_leds = len(self.virtual_chains[segment].leds) + if segment in self.PER_GATE_SEGMENTS and num_leds > 0 and num_leds % self.num_gates: + raise config.error("Number of MMU '%s' LEDs (%d) cannot be spread over num_gates (%d)" % (segment, num_leds, self.num_gates)) + + # Check for LED chain overlap or unavailable LEDs + used = {} + for segment in self.SEGMENTS: + for led in self.virtual_chains[segment].leds: + obj, index = led + if index >= obj.led_helper.led_count: + raise config.error("MMU LED (with index %d) on segment %s isn't available" % (index + 1, segment)) + if led in used: + raise config.error("Same MMU LED (with index %d) used more than one segment: %s and %s" % (index + 1, used[led], segment)) + else: + used[led] = segment + + # Read default effects for each segment and other options + self.enabled = config.get('enabled', True) + self.animation = config.get('animation', True) + self.exit_effect = config.get('exit_effect', 'gate_status') + self.entry_effect = config.get('entry_effect', 'filament_color') + self.status_effect = config.get('status_effect', 'filament_color') + self.logo_effect = MmuLeds.string_to_rgb(config.get('logo_effect', '(0,0,0.3)')) + self.white_light = MmuLeds.string_to_rgb(config.get('white_light', '(1,1,1)')) + self.black_light = MmuLeds.string_to_rgb(config.get('black_light', '(0.01,0,0.02)')) + self.empty_light = MmuLeds.string_to_rgb(config.get('empty_light', '(0,0,0)')) + + # Read operation to effect mappings + self.effects = {} + self.effect_rgb = {} + effect_keys = [ + 'effect_loading', + 'effect_loading_extruder', + 'effect_unloading', + 'effect_unloading_extruder', + 'effect_heating', + 'effect_selecting', + 'effect_checking', + 'effect_initialized', + 'effect_error', + 'effect_complete', + 'effect_gate_selected', + 'effect_gate_available', + 'effect_gate_unknown', + 'effect_gate_empty', + 'effect_gate_available_sel', + 'effect_gate_unknown_sel', + 'effect_gate_empty_sel' + ] + for key in effect_keys: + parts = [part.strip() for part in config.get(key, '').split(",", 1)] + effect = parts[0] + rgb_string = parts[1] if len(parts) == 2 else config.get('empty_light', '(0,0,0)') + operation = key[len('effect_'):] + self.effects[operation] = effect + self.effect_rgb[effect] = MmuLeds.string_to_rgb(rgb_string) + self.effect_rgb[''] = (0,0,0) + + def parse_chain(self, chain): + chain = chain.strip() + leds=[] + parms = [parameter.strip() for parameter in chain.split() if parameter.strip()] + if parms: + chain_name = parms[0].replace(':',' ') + led_indices = ''.join(parms[1:]).strip('()').split(',') + for led in led_indices: + if led: + if '-' in led: + start, stop = map(int,led.split('-')) + if stop == start: + ledList = [start-1] + elif stop > start: + ledList = list(range(start-1, stop)) + else: + ledList = list(reversed(range(stop-1, start))) + for i in ledList: + leds.append(int(i)) + else: + for i in led.split(','): + leds.append(int(i)-1) + return chain_name, leds + else: + return None, None + + def get_effect(self, operation): + return self.effects.get(operation, '') + + def get_rgb_for_effect(self, effect): + return self.effect_rgb[effect] + + def get_status(self, eventtime=None): + status = {segment: len(self.virtual_chains[segment].leds) for segment in self.SEGMENTS} + status.update({ + 'enabled': self.enabled, + 'animation': self.animation, + 'exit_effect': self.exit_effect, + 'entry_effect': self.entry_effect, + 'status_effect': self.status_effect, + 'logo_effect': self.logo_effect, + 'num_gates': self.num_gates, + }) + return status + + @staticmethod + def string_to_rgb(rgb_string): + if not isinstance(rgb_string, tuple): + rgb = re.sub(r"[\"'()]", '', rgb_string) # Clean up strings + rgb = tuple(float(x) for x in rgb.split(',')) + else: + rgb = rgb_string + if len(rgb) != 3: + raise ValueError("%s is not a valid rgb tuple" % str(rgb_string)) + return rgb + +def load_config_prefix(config): + return MmuLeds(config) diff --git a/klippy/extras/mmu_machine.py b/klippy/extras/mmu_machine.py new file mode 100644 index 000000000000..6564a0df30e9 --- /dev/null +++ b/klippy/extras/mmu_machine.py @@ -0,0 +1,1357 @@ +# Happy Hare MMU Software +# +# Definition of basic physical characteristics of MMU (including type/style) +# - allows for hardware configuration validation +# +# Implementation of "MMU Toolhead" to allow for: +# - "drip" homing and movement without pauses +# - bi-directional syncing of extruder to gear rail or gear rail to extruder +# - extra "standby" endstops +# - extruder endstops and extruder only homing +# - switchable drive steppers on rails +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Based on code by Kevin O'Connor +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, importlib, math, os, time, re + +# Klipper imports +import stepper, chelper, toolhead +from kinematics.extruder import PrinterExtruder, DummyExtruder, ExtruderStepper +from .homing import Homing, HomingMove + +from . import mmu_leds + +# For toolhead synchronization +EPS = 1e-6 # ~1 microsecond safety +SYNC_AIR_GAP = 0.001 # Sync time air gap +MOVE_HISTORY_EXPIRE = 30.0 # From motion_queuing.py + +# TMC chips to search for +TMC_CHIPS = ["tmc2209", "tmc2130", "tmc2208", "tmc2660", "tmc5160", "tmc2240"] + +# Stepper config sections +SELECTOR_STEPPER_CONFIG = "stepper_mmu_selector" # Optional +GEAR_STEPPER_CONFIG = "stepper_mmu_gear" + +SHAREABLE_STEPPER_PARAMS = ['rotation_distance', 'gear_ratio', 'microsteps', 'full_steps_per_rotation'] +OTHER_STEPPER_PARAMS = ['step_pin', 'dir_pin', 'enable_pin', 'endstop_pin', 'rotation_distance', 'pressure_advance', 'pressure_advance_smooth_time'] + +SHAREABLE_TMC_PARAMS = ['run_current', 'hold_current', 'interpolate', 'sense_resistor', 'stealthchop_threshold'] + +# Vendor MMU's supported +VENDOR_ERCF = "ERCF" +VENDOR_TRADRACK = "Tradrack" +VENDOR_PRUSA = "Prusa" +VENDOR_ANGRY_BEAVER = "AngryBeaver" +VENDOR_BOX_TURTLE = "BoxTurtle" +VENDOR_NIGHT_OWL = "NightOwl" +VENDOR_3MS = "3MS" +VENDOR_3D_CHAMELEON = "3DChameleon" +VENDOR_PICO_MMU = "PicoMMU" +VENDOR_QUATTRO_BOX = "QuattroBox" +VENDOR_MMX = "MMX" +VENDOR_VVD = "VVD" +VENDOR_KMS = "KMS" +VENDOR_EMU = "EMU" +VENDOR_OTHER = "Other" + +UNIT_ALT_DISPLAY_NAMES = { + VENDOR_ANGRY_BEAVER: "Angry Beaver", + VENDOR_BOX_TURTLE: "Box Turtle", + VENDOR_NIGHT_OWL: "Night Owl", + VENDOR_VVD: "BTT VVD", +} + +VENDORS = [VENDOR_ERCF, VENDOR_TRADRACK, VENDOR_PRUSA, VENDOR_ANGRY_BEAVER, VENDOR_BOX_TURTLE, VENDOR_NIGHT_OWL, VENDOR_3MS, VENDOR_3D_CHAMELEON, VENDOR_PICO_MMU, VENDOR_QUATTRO_BOX, VENDOR_MMX, VENDOR_VVD, VENDOR_KMS, VENDOR_EMU, VENDOR_OTHER] + + +# Define type/style of MMU and expand configuration for convenience. Validate hardware configuration +class MmuMachine: + + def __init__(self, config): + # Essential information for validation and setup + self.config = config + self.printer = config.get_printer() + self.gate_counts = list(config.getintlist('num_gates', [])) + self.num_units = len(self.gate_counts) + if self.num_units < 1: + raise config.error("Invalid or missing num_gates parameter in section [mmu_machine]") + self.num_gates = sum(self.gate_counts) + self.mmu_vendor = config.getchoice('mmu_vendor', {o: o for o in VENDORS}, VENDOR_OTHER) + self.mmu_version_string = config.get('mmu_version', "1.0") + version = re.sub("[^0-9.]", "", self.mmu_version_string) or "1.0" + try: + self.mmu_version = float(version) + except ValueError: + raise config.error("Invalid version parameter") + + # Hack to bring some v4 functionality into v3 + # vvv + + # Create a minimal fake MmuUnit object + self.units = [] # Unit by index + self.unit_by_gate = [] # Quick unit lookup by gate + next_gate = 0 + for i in range(self.num_units): + unit = MmuUnit("unit%d" % i, i, next_gate, self.gate_counts[i]) + self.units.append(unit) + self.unit_by_gate[next_gate:next_gate + self.gate_counts[i]] = [unit] * self.gate_counts[i] + next_gate += self.gate_counts[i] + + orig_section = 'mmu_leds' + if config.has_section(orig_section): + raise config.error("v3.4 requires update of [mmu_leds] section for multiple mmu units") + + # Load optional mmu_leds module for each mmu unit in v4 style + for unit in self.units: + section = 'mmu_leds %s' % unit.name + if config.has_section(section): + c = config.getsection(section) + unit.leds = mmu_leds.MmuLeds(c, self, unit, unit.first_gate, unit.num_gates) + logging.info("MMU: Created: %s" % c.get_name()) + self.printer.add_object(c.get_name(), unit.leds) # Register mmu_leds to stop it being loaded by klipper + + # ^^^ + # Hack to bring some v4 functionality into v3 + + # MMU design for control purposes can be broken down into the following choices: + # - Selector type or no (Virtual) selector + # - Does each gate of the MMU have different BMG drive gears (or similar). I.e. drive rotation distance is variable + # - Does each gate of the MMU have different bowden path + # - Does design require "bowden move" (i.e. non zero length bowden) + # - Is filament always gripped by MMU + # - Does selector mechanism still allow selection of gates when filament is loaded (implied for Multigear designs) + # - Does design has a filament bypass + selector_type = 'LinearServoSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 # Will allow mmu_gate sensor and extruder sensor to share the same pin + filament_always_gripped = 0 # Whether MMU design has ability to release filament (overrides gear/extruder syncing) + can_crossload = 0 # Design allows preloading/eject in one gate while another gate is loaded (also requires post_gear sensor) + has_bypass = 0 # Whether MMU design has bypass gate (also has to be calibrated on type-A designs with LinearServoSelector) + + if self.mmu_vendor == VENDOR_ERCF: + selector_type = 'LinearServoSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 0 + can_crossload = 0 + has_bypass = 1 + + elif self.mmu_vendor == VENDOR_TRADRACK: + selector_type = 'LinearServoSelector' + variable_rotation_distances = 0 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 0 + can_crossload = 0 + has_bypass = 1 + + elif self.mmu_vendor == VENDOR_PRUSA: + raise config.error("Prusa MMU is not yet supported") + + elif self.mmu_vendor == VENDOR_ANGRY_BEAVER: + selector_type = 'VirtualSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 0 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_BOX_TURTLE: + selector_type = 'VirtualSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_NIGHT_OWL: + selector_type = 'VirtualSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_3MS: + selector_type = 'VirtualSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 0 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_3D_CHAMELEON: + selector_type = 'RotarySelector' + variable_rotation_distances = 0 + variable_bowden_lengths = 1 + require_bowden_move = 1 + filament_always_gripped = 0 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_PICO_MMU: + selector_type = 'ServoSelector' + variable_rotation_distances = 0 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 0 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_QUATTRO_BOX: + selector_type = 'VirtualSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_MMX: + selector_type = 'ServoSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 0 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_VVD: + selector_type = 'IndexedSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_KMS: + selector_type = 'VirtualSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 0 + require_bowden_move = 1 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 0 + + elif self.mmu_vendor == VENDOR_EMU: + selector_type = 'VirtualSelector' + variable_rotation_distances = 1 + variable_bowden_lengths = 1 + require_bowden_move = 1 + filament_always_gripped = 1 + can_crossload = 1 + has_bypass = 1 + + # Still allow MMU design attributes to be altered or set for custom MMU + self.selector_type = config.getchoice('selector_type', {o: o for o in ['VirtualSelector', 'LinearSelector', 'LinearServoSelector', 'LinearMultiGearSelector', 'RotarySelector', 'MacroSelector', 'ServoSelector', 'IndexedSelector']}, selector_type) + self.variable_rotation_distances = bool(config.getint('variable_rotation_distances', variable_rotation_distances)) + self.variable_bowden_lengths = bool(config.getint('variable_bowden_lengths', variable_bowden_lengths)) + self.require_bowden_move = bool(config.getint('require_bowden_move', require_bowden_move)) + self.filament_always_gripped = bool(config.getint('filament_always_gripped', filament_always_gripped)) + self.can_crossload = bool(config.getint('can_crossload', can_crossload)) + self.has_bypass = bool(config.getint('has_bypass', has_bypass)) + + # Other attributes + self.display_name = config.get('display_name', UNIT_ALT_DISPLAY_NAMES.get(self.mmu_vendor, self.mmu_vendor)) + + self.environment_sensor = config.get('environment_sensor', '') + self.filament_heater = config.get('filament_heater', '') + + # Special handling for EMU MMU's that can have a heater and environment sensor per gate + self.environment_sensors = list(config.getlist('environment_sensors', [])) + if len(self.environment_sensors) not in [0, self.num_gates]: + raise config.error("'environment_sensors' must be empty or a comma separated list of 'num_gate' elements") + self.filament_heaters = list(config.getlist('filament_heaters', [])) + if len(self.filament_heaters) not in [0, self.num_gates]: + raise config.error("'filament_heaters' must be empty, a single value or a comma separated list of 'num_gate' elements") + self.max_concurrent_heaters = config.getint('max_concurrent_heaters', self.num_gates) + + # Check mutually exclusive environment options + if (self.environment_sensor or self.filament_heater) and (self.environment_sensors or self.filament_heaters): + raise config.error("Can't configure single multiple MMU heaters/environment sensors") + + # Check all heater and environment sensor objects are valid + for obj_name in self.filament_heaters + [self.filament_heater] + self.environment_sensors + [self.environment_sensor]: + if obj_name: + try: + # If we can't load heater/sensor then it is misconfigured + obj = self.printer.load_object(config, obj_name) + except Exception as e: + raise config.error("Object '%s' could not be loaded as a valid heater or environment sensor in [mmu_machine]\nError: %s" % (obj_name, str(e))) + + # By default HH uses a modified homing extruder. Because this might have unknown consequences on certain + # set-ups it can be disabled. If disabled, homing moves will still work, but the delay in mcu to mcu comms + # can lead to several mm of error depending on speed. Also homing of just the extruder is not possible. + self.homing_extruder = bool(config.getint('homing_extruder', 1, minval=0, maxval=1)) + + # Expand config to allow lazy (incomplete) repetitious gear configuration for type-B MMU's + self.multigear = False + + # Find the TMC controller for base stepper so we can fill in missing config for other matching steppers + base_tmc_chip = base_tmc_section = None + for chip in TMC_CHIPS: + base_tmc_section = '%s %s' % (chip, GEAR_STEPPER_CONFIG) + if config.has_section(base_tmc_section): + base_tmc_chip = chip + break + + last_gear = 24 + for i in range(1, last_gear): # Don't allow "_0" or it is confusing with unprefixed initial stepper + section = "%s_%d" % (GEAR_STEPPER_CONFIG, i) + if not config.has_section(section): + last_gear = i + break + + self.multigear = True + + # Share stepper config section with additional steppers + stepper_section = "%s_%d" % (GEAR_STEPPER_CONFIG, i) + for key in SHAREABLE_STEPPER_PARAMS: + if not config.fileconfig.has_option(stepper_section, key) and config.fileconfig.has_option(GEAR_STEPPER_CONFIG, key): + base_value = config.fileconfig.get(GEAR_STEPPER_CONFIG, key) + if base_value: + logging.info("MMU: Sharing gear stepper config %s=%s with %s" % (key, base_value, stepper_section)) + config.fileconfig.set(stepper_section, key, base_value) + + # IF TMC controller for this additional stepper matches the base we can fill in missing TMC config + if base_tmc_chip: + tmc_section = '%s %s_%d' % (base_tmc_chip, GEAR_STEPPER_CONFIG, i) + if config.has_section(tmc_section): + for key in SHAREABLE_TMC_PARAMS: + if config.fileconfig.has_option(base_tmc_section, key) and not config.fileconfig.has_option(tmc_section, key): + base_value = config.fileconfig.get(base_tmc_section, key) + if base_value: + logging.info("MMU: Sharing gear tmc config %s=%s with %s" % (key, base_value, tmc_section)) + config.fileconfig.set(tmc_section, key, base_value) + + # H/W validation checks + if self.multigear and last_gear != self.num_gates: + raise config.error("MMU is configured with %d gates but %d gear stepper configurations were found" % (self.num_gates, last_gear)) + + # TODO add more h/w validation here based on num_gates & vendor, virtual selector, etc + # TODO would allow for easier to understand error messages for conflicting or missing + # TODO hardware definitions. + + # TODO: Temp until restructured to allow multiple MMU's of different types. + gate_count = 0 + self.unit_status = {} + for i, unit in enumerate(self.gate_counts): + unit_info = {} + unit_info['name'] = self.display_name + unit_info['vendor'] = self.mmu_vendor + unit_info['version'] = self.mmu_version_string + unit_info['num_gates'] = unit + unit_info['first_gate'] = gate_count + unit_info['selector_type'] = self.selector_type + unit_info['variable_rotation_distances'] = self.variable_rotation_distances + unit_info['variable_bowden_lengths'] = self.variable_bowden_lengths + unit_info['require_bowden_move'] = self.require_bowden_move + unit_info['filament_always_gripped'] = self.filament_always_gripped + unit_info['can_crossload'] = self.can_crossload + unit_info['has_bypass'] = self.has_bypass + unit_info['multi_gear'] = self.multigear + if self.environment_sensor or self.filament_heater: + # Single heater/sensor + unit_info['environment_sensor'] = self.environment_sensor + unit_info['filament_heater'] = self.filament_heater + elif self.environment_sensors or self.filament_heaters: + # Per-gate heater/sensors + unit_info['environment_sensors'] = self.environment_sensors[gate_count:gate_count + unit] + unit_info['filament_heaters'] = self.filament_heaters[gate_count:gate_count + unit] + gate_count += unit + self.unit_status["unit_%d" % i] = unit_info + self.unit_status['num_units'] = len(self.gate_counts) + + def get_mmu_unit_by_index(self, index): # Hack to allow some v4 functionality into the v3 line + if index >= 0 and index < self.num_units: + return self.units[index] + return None + + def get_mmu_unit_by_gate(self, gate): # Hack to allow some v4 functionality into the v3 line + if gate >= 0 and gate < self.num_gates: + return self.unit_by_gate[gate] + return None + + def get_status(self, eventtime): + return self.unit_status + +# This is a Hack to allow some v4 functionality into the v3 line. Skeletal MmuUnit object +class MmuUnit: + def __init__(self, name, unit_index, first_gate, num_gates): + self.name = name + self.unit_index = unit_index + self.first_gate = first_gate + self.num_gates = num_gates + self.leds = None + + def manages_gate(self, gate): + return self.first_gate <= gate < self.first_gate + self.num_gates + + +# Main code to track events (and their timing) on the MMU Machine implemented as additional "toolhead" +# (code pulled from toolhead.py) +class MmuToolHead(toolhead.ToolHead, object): + + # Gear/Extruder synchronization modes (None = unsynced) + EXTRUDER_SYNCED_TO_GEAR = 1 # Aka 'gear+extruder' + EXTRUDER_ONLY_ON_GEAR = 2 # Aka 'extruder' (only) + GEAR_SYNCED_TO_EXTRUDER = 3 # Aka 'extruder+gear' + GEAR_ONLY = 4 # Aka 'gear' (same state as unsync() but with protective wait) + + def __init__(self, config, mmu): + self.mmu = mmu + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + + self.all_mcus = [m for n, m in self.printer.lookup_objects(module='mcu')] # Older Klipper + self.mcu = self.all_mcus[0] # Older Klipper + #self.mcu = self.printer.lookup_object('mcu') # Klipper approx >= 0.13.0-328 (safer lookup, guarantee's primary mcu or config error) + + self._resync_lock = self.reactor.mutex() + + if hasattr(toolhead, 'BUFFER_TIME_HIGH'): + time_high = toolhead.BUFFER_TIME_HIGH + else: + # Backward compatibility for older klipper, like on Sovol or Creality K1 series printers + # On Creality K1, these attributes are expected to exist in any Toolhead + self.buffer_time_low = config.getfloat('buffer_time_low', 1.000, above=0.) + self.buffer_time_high = config.getfloat('buffer_time_high', 2.000, above=self.buffer_time_low) + self.buffer_time_start = config.getfloat('buffer_time_start', 0.250, above=0.) + self.move_flush_time = config.getfloat('move_flush_time', 0.050, above=0.) + self.last_kin_flush_time = self.force_flush_time = self.last_kin_move_time = 0. + time_high = self.buffer_time_high + + if hasattr(toolhead, 'LookAheadQueue'): + try: + self.lookahead = toolhead.LookAheadQueue(self) # Klipper < 3.13.0-46 + except: + self.lookahead = toolhead.LookAheadQueue() # >= Klipper 3.13.0-46 + self.lookahead.set_flush_time(time_high) + else: + # Klipper backward compatibility + self.move_queue = toolhead.MoveQueue(self) + self.move_queue.set_flush_time(time_high) + self.commanded_pos = [0., 0., 0., 0.] + + # For Creality Ender3v3 series (custom klipper) + self.gap_auto_comp = None + + # MMU velocity and acceleration control + self.gear_max_velocity = config.getfloat('gear_max_velocity', 300, above=0.) + self.gear_max_accel = config.getfloat('gear_max_accel', 500, above=0.) + self.selector_max_velocity = config.getfloat('selector_max_velocity', 250, above=0.) + self.selector_max_accel = config.getfloat('selector_max_accel', 1500, above=0.) + + self.max_velocity = max(self.selector_max_velocity, self.gear_max_velocity) + self.max_accel = max(self.selector_max_accel, self.gear_max_accel) + + min_cruise_ratio = 0.5 + if config.getfloat('minimum_cruise_ratio', None) is None: + req_accel_to_decel = config.getfloat('max_accel_to_decel', None, above=0.) + if req_accel_to_decel is not None: + config.deprecate('max_accel_to_decel') + min_cruise_ratio = 1. - min(1., (req_accel_to_decel / self.max_accel)) + self.min_cruise_ratio = config.getfloat('minimum_cruise_ratio', min_cruise_ratio, below=1., minval=0.) + self.square_corner_velocity = config.getfloat('square_corner_velocity', 5., minval=0.) + self.junction_deviation = self.max_accel_to_decel = 0. + self.requested_accel_to_decel = self.min_cruise_ratio * self.max_accel # Backward klipper compatibility 31de734d193d + self._calc_junction_deviation() + + # This is now a big switch that gates changes to the Toolhead in Klipper + self.motion_queuing = self.printer.load_object(config, 'motion_queuing', None) + + # Input stall detection + self.check_stall_time = 0. + self.print_stall = 0 + # Input pause tracking + self.can_pause = True + if self.mcu.is_fileoutput(): + self.can_pause = False + self.need_check_pause = -1. + # Print time tracking + self.print_time = 0. + self.special_queuing_state = "NeedPrime" + self.priming_timer = None + self.drip_completion = None # TODO No longer part of Klipper >v0.13.0-46 + + if not self.motion_queuing: + # Flush tracking + self.flush_timer = self.reactor.register_timer(self._flush_handler) + self.do_kick_flush_timer = True + self.last_flush_time = self.last_sg_flush_time = self.min_restart_time = 0. # last_sg_flush_time deprecated + self.need_flush_time = self.step_gen_time = self.clear_history_time = 0. + # Kinematic step generation scan window time tracking + self.kin_flush_delay = toolhead.SDS_CHECK_TIME # Happy Hare: Use base class + self.kin_flush_times = [] + + if self.motion_queuing: + ffi_main, ffi_lib = chelper.get_ffi() + self.trapq_finalize_moves = ffi_lib.trapq_finalize_moves # Want my own binding so I know its available + + # Setup for generating moves + try: + self.motion_queuing.register_flush_callback(self._handle_step_flush, can_add_trapq=True) # Latest klipper >= v0.13.0-267 + except AttributeError: + self.motion_queuing.setup_lookahead_flush_callback(self._check_flush_lookahead) + + self.trapq = self.motion_queuing.allocate_trapq() + self.trapq_append = self.motion_queuing.lookup_trapq_append() + else: + # Setup iterative solver + ffi_main, ffi_lib = chelper.get_ffi() + self.trapq = ffi_main.gc(ffi_lib.trapq_alloc(), ffi_lib.trapq_free) + self.trapq_append = ffi_lib.trapq_append + self.trapq_finalize_moves = ffi_lib.trapq_finalize_moves + # Motion flushing + self.step_generators = [] + self.flush_trapqs = [self.trapq] + + # Create kinematics class + gcode = self.printer.lookup_object('gcode') + self.Coord = gcode.Coord + self.extruder = DummyExtruder(self.printer) + self.extra_axes = [self.extruder] + + self.printer.register_event_handler("klippy:shutdown", self._handle_shutdown) + + # Create MMU kinematics + try: + self.kin = MmuKinematics(self, config) + steppers = list(self.kin.rails[1].get_steppers()) + self.all_gear_rail_steppers = steppers.copy() + self.selected_gear_steppers = steppers.copy() + except config.error: + raise + except self.printer.lookup_object('pins').error: + raise + except: + msg = "Error loading MMU kinematics" + logging.exception("MMU: %s" % msg) + raise config.error(msg) + + self.mmu_machine = self.printer.lookup_object("mmu_machine") + self.mmu_extruder_stepper = None + if self.mmu_machine.homing_extruder: + # Create MmuExtruderStepper for later insertion into PrinterExtruder on Toolhead (on klippy:connect) + self.mmu_extruder_stepper = MmuExtruderStepper(config.getsection('extruder'), self.kin.rails[1]) # Only first extruder is handled + + # Nullify original extruder stepper definition so Klipper doesn't try to create it again. Restore in handle_connect() so config lookups succeed + self.old_ext_options = {} + self.config = config + for i in SHAREABLE_STEPPER_PARAMS + OTHER_STEPPER_PARAMS: + if config.fileconfig.has_option('extruder', i): + self.old_ext_options[i] = config.fileconfig.get('extruder', i) + config.fileconfig.remove_option('extruder', i) + + self.printer.register_event_handler('klippy:connect', self.handle_connect) + + # Add useful debugging command + gcode.register_command('_MMU_DUMP_TOOLHEAD', self.cmd_DUMP_RAILS, desc=self.cmd_DUMP_RAILS_help) + + # Bi-directional sync management of gear(s) and extruder(s) + self.mmu_toolhead = self # Make it easier to read code and distinquish printer_toolhead from mmu_toolhead + self.sync_mode = None + + def handle_connect(self): + self.printer_toolhead = self.printer.lookup_object('toolhead') + + printer_extruder = self.printer_toolhead.get_extruder() + if self.mmu_machine.homing_extruder: + # Restore original extruder options in case user macros reference them + for key, value in self.old_ext_options.items(): + self.config.fileconfig.set('extruder', key, value) + + # Now we can switch in homing MmuExtruderStepper + printer_extruder.extruder_stepper = self.mmu_extruder_stepper + self.mmu_extruder_stepper.stepper.set_trapq(printer_extruder.get_trapq()) + else: + self.mmu_extruder_stepper = printer_extruder.extruder_stepper + + # Ensure the correct number of axes for convenience - MMU only has two + # Also, handle case when gear rail is synced to extruder + def set_position(self, newpos, homing_axes=()): + for _ in range(4 - len(newpos)): + newpos.append(0.) + super(MmuToolHead, self).set_position(newpos, homing_axes) + + def get_selector_limits(self): + return self.selector_max_velocity, self.selector_max_accel + + def get_gear_limits(self): + return self.gear_max_velocity, self.gear_max_accel + + # Gear/Extruder synchronization and stepper swapping management... + + def select_gear_stepper(self, gate): + if not self.mmu_machine.multigear: return + with self._resync_lock: + if gate == 0: + self._reconfigure_rail_no_lock([GEAR_STEPPER_CONFIG]) + elif gate > 0: + self._reconfigure_rail_no_lock(["%s_%d" % (GEAR_STEPPER_CONFIG, gate)]) + else: + self._reconfigure_rail_no_lock(None) + + def _reconfigure_rail_no_lock(self, selected): + m_th = self.mmu_toolhead + gear_rail = m_th.get_kinematics().rails[1] + mmu_trapq = m_th.get_trapq() + + prev_sync_mode = self.sync_mode + if self.sync_mode not in [self.GEAR_ONLY, None]: + self._resync_no_lock(None) # Unsync first + else: + ths = [self.printer_toolhead, self.mmu_toolhead] + t_cut = self._quiesce_align_get_tcut(ths, full=False) + + # Activate only the desired gear steppers + pos = [0., self.mmu_toolhead.get_position()[1], 0.] + gear_rail.steppers = [] + + self.selected_gear_steppers = [] + for s in self.all_gear_rail_steppers: + if selected and s.get_name() in selected: + self.selected_gear_steppers.append(s) + gear_rail.steppers.append(s) + self._register(m_th, s, trapq=mmu_trapq) # s.set_trapq(mmu_trapq) + else: + # Cripple unused/unwanted gear steppers + self._unregister(m_th, s) # s.set_trapq(None) + + if selected: + if not gear_rail.steppers: + raise self.printer.command_error("None of these '%s' gear steppers where found!" % selected) + gear_rail.set_position(pos) + + # Restore previous syncing mode + # (Not needed in practice) + # if prev_sync_mode != self.sync_mode: + # self._resync_no_lock(prev_sync_mode) + + # Register stepper step generator with desired toolhead and add toolhead's trapq + def _register(self, toolhead, stepper, trapq=None): + trapq = trapq or toolhead.get_trapq() + stepper.set_trapq(trapq) # Restore movement + if not self.motion_queuing: + # klipper 0.13.0 <= 195 we also register step generators from mmu_toolhead + if stepper.generate_steps not in self.mmu_toolhead.step_generators: + if hasattr(toolhead, 'register_step_generator'): + toolhead.register_step_generator(stepper.generate_steps) + else: + toolhead.step_generators.append(stepper.generate_steps) + + # Unregister stepper step generator with desired toolhead and remove from trapq + def _unregister(self, toolhead, stepper): + stepper.set_trapq(None) # Cripple movement + if not self.motion_queuing: + # klipper 0.13.0 <= 195 we also unregister step generators from mmu_toolhead + if stepper.generate_steps in self.mmu_toolhead.step_generators: + if hasattr(toolhead, 'unregister_step_generator'): + toolhead.unregister_step_generator(stepper.generate_steps) + else: + # Why did Kalico remove this function? + toolhead.step_generators.remove(stepper.generate_steps) + + def quiesce(self, full_quiesce=True): + with self._resync_lock: + ths = [self.printer_toolhead, self.mmu_toolhead] + t_cut = self._quiesce_align_get_tcut(ths, full=full_quiesce, wait=full_quiesce) + + # Drain required toolheads, align to a common future time, materialize it, + # and return a strict fence time t_cut. 'wait' shouldn't be needed but + # is helpful when debugging + def _quiesce_align_get_tcut(self, ths, full=False, wait=False): + start = time.time() + # Drain whatever is already planned + for th in ths: + th.flush_step_generation() + if full: + for th in ths: + th.wait_moves() + for th in ths: + th.flush_step_generation() + + # Align planners to a common future time + last_times = [th.get_last_move_time() for th in ths] + t_future = max(last_times) + SYNC_AIR_GAP + for th, lm in zip(ths, last_times): + dt = t_future - lm + if dt > 0.0: + th.dwell(dt) + + # Materialize the air gap before choosing the fence + for th in ths: + th.flush_step_generation() + + # Optional wait and flush to aid debugging + if wait: + for th in ths: + th.wait_moves() + for th in ths: + th.flush_step_generation() + + self.mmu.log_stepper("_quiesce_align_get_tcut(full=%s, wait=%s) Elapsed:%.6f" % (full, wait, time.time() - start)) + return t_future + EPS + + def is_synced(self): + return self.sync_mode is not None + + # Is extruder stepper synced to gear rail (general MMU synced movement) + def is_extruder_synced_to_gear(self): + return self.sync_mode in [self.EXTRUDER_SYNCED_TO_GEAR, self.EXTRUDER_ONLY_ON_GEAR] + + # Is gear rail synced to extruder (for in print syncing) + def is_gear_synced_to_extruder(self): + return self.sync_mode == self.GEAR_SYNCED_TO_EXTRUDER + + def sync(self, new_sync_mode): + with self._resync_lock: + return self._resync_no_lock(new_sync_mode) + + def unsync(self): + with self._resync_lock: + return self._resync_no_lock(None) + + # This resets the state of mmu/extruder synchronization carefully avoiding stepcompress issues. + # It is quite tricky to do efficiently without timing windows. The goal is to do the minimum + # of waiting and only drain everything IF the extruder is being synced to gear rail or + # gear rail synced to the extruder + def _resync_no_lock(self, new_sync_mode): + + if new_sync_mode == self.sync_mode: + return new_sync_mode + + prev_sync_mode = self.sync_mode + ffi_main, ffi_lib = chelper.get_ffi() + + def _finalize_if_valid(tq, t): + if tq is not None and tq != ffi_main.NULL: + self.trapq_finalize_moves(tq, t, t - MOVE_HISTORY_EXPIRE) + + self.mmu.log_stepper("resync(%s --> %s)" % (self.sync_mode_to_string(self.sync_mode), self.sync_mode_to_string(new_sync_mode))) + + # ---------------- Phase A: FENCE if messing with extruder ----------- + + user_control_states = [self.GEAR_SYNCED_TO_EXTRUDER, None] + relocated_extruder_states = [self.EXTRUDER_SYNCED_TO_GEAR, self.EXTRUDER_ONLY_ON_GEAR] + mmu_control_states = relocated_extruder_states + [self.GEAR_ONLY] + full_quiesce = ( + (new_sync_mode in user_control_states and self.sync_mode in mmu_control_states) or + (new_sync_mode in mmu_control_states and self.sync_mode in user_control_states) + ) + + ths = [self.printer_toolhead, self.mmu_toolhead] + gear_rail = self.mmu_toolhead.get_kinematics().rails[1] + t0 = self._quiesce_align_get_tcut(ths, full=full_quiesce, wait=full_quiesce) # Build cutover fence + + # UNSYNC current mode (if any) to base state at t0 + if self.sync_mode not in [self.GEAR_ONLY, None]: + + # ---------------- Phase B: UNSYNC current mode at t0 ---------------- + + self.mmu.log_stepper("unsync(%s)" % ("mode=gear_only" if new_sync_mode == self.GEAR_ONLY else "")) + + # Figure out who is CURRENTLY driving (old owner) and who will receive (new owner) + if self.sync_mode in [self.EXTRUDER_SYNCED_TO_GEAR, self.EXTRUDER_ONLY_ON_GEAR]: + driving_toolhead = self.mmu_toolhead # OLD owner (mmu/gear) + following_toolhead = self.printer_toolhead # NEW owner (printer/extruder) + following_steppers = [following_toolhead.get_extruder().extruder_stepper.stepper] + old_trapq = driving_toolhead.get_trapq() # trapq we’re finalizing + new_trapq = self._prev_trapq # trapq saved during sync() + pos = [following_toolhead.get_position()[3], 0., 0.] + + # Always remove the extruder stepper we appended to the gear rail (will always be at end of list) + gear_rail.steppers = gear_rail.steppers[:-len(following_steppers)] + + elif self.sync_mode == self.GEAR_SYNCED_TO_EXTRUDER: + driving_toolhead = self.printer_toolhead # OLD owner (printer/extruder) + following_toolhead = self.mmu_toolhead # NEW owner (mmu/gear) + # All gear-rail steppers were following the extruder + following_steppers = following_toolhead.get_kinematics().rails[1].get_steppers() + old_trapq = driving_toolhead.get_extruder().get_trapq() # trapq we’re finalizing + new_trapq = self._prev_trapq # trapq saved during sync() + pos = [0., following_toolhead.get_position()[1], 0.] + + # Hard close the old trapq up to the fence (don’t wipe) + # Anything <= t0 moves to history so it can’t be emitted later. + _finalize_if_valid(old_trapq, t0) + + # Rebind steppers back to the NEW owner’s trapq and restore kinematics + for i, s in enumerate(following_steppers): + s.set_stepper_kinematics(self._prev_sk[i]) + s.set_rotation_distance(self._prev_rd[i]) + # s.set_trapq(new_trapq) # Attach to NEW owner on the pre-saved trapq + self._unregister(driving_toolhead, s) # Detach from OLD owner… + self._register(following_toolhead, s, trapq=new_trapq) # …then attach to NEW owner on the pre-saved trapq + # Coordinate-only seed (timing will be enforced by advancing the receiver) + s.set_position(pos) + + # Restore previously disabled gear steppers (after the extruder is back on printer toolhead) + if self.sync_mode == self.EXTRUDER_ONLY_ON_GEAR: + for s in self.selected_gear_steppers: + self._register(self.mmu_toolhead, s) # s.set_trapq(self.mmu_toolhead.get_trapq()) + s.set_position([0., self.mmu_toolhead.get_position()[1], 0.]) + + ## Required for klipper >= 0.13.0-330 + #if self.motion_queuing and hasattr(self.motion_queuing, 'check_step_generation_scan_windows'): + # self.motion_queuing.check_step_generation_scan_windows() + + # Debugging + #logging.info("MMU: ////////// CUTOVER fence t_cut=%.6f, old_trapq=%s, new_trapq=%s, from.last=%.6f, to.last=%.6f", + # t0, self._match_trapq(old_trapq), self._match_trapq(new_trapq), + # driving_toolhead.get_last_move_time(), + # following_toolhead.get_last_move_time()) + + # FORCE the RECEIVER (following_toolhead) planner to >= t0 and materialize it + dt = max(EPS, t0 - following_toolhead.get_last_move_time()) + if dt: following_toolhead.dwell(dt) + following_toolhead.flush_step_generation() + + if self.sync_mode == self.GEAR_SYNCED_TO_EXTRUDER: + self.printer.send_event("mmu:unsynced") + + # Now “unsynced” at t0 + + # Required for klipper >= 0.13.0-330 + if self.motion_queuing and hasattr(self.motion_queuing, 'check_step_generation_scan_windows'): + self.motion_queuing.check_step_generation_scan_windows() + + self.sync_mode = self.GEAR_ONLY if new_sync_mode == self.GEAR_ONLY else None + if new_sync_mode in [self.GEAR_ONLY, None]: + return prev_sync_mode + + # ---------------- Phase C: SYNC into new mode at t1 ----------------- + + self.mmu.log_stepper("sync(mode=%d %s)" % (new_sync_mode, ("gear+extruder" if new_sync_mode == self.EXTRUDER_SYNCED_TO_GEAR else "extruder" if new_sync_mode == self.EXTRUDER_ONLY_ON_GEAR else "extruder+gear"))) + + t1 = t0 + EPS # Later fence for the second cut over (t1 = t0 + EPS) + + # Figure out driver and follower based on sync mode + if new_sync_mode in [self.EXTRUDER_SYNCED_TO_GEAR, self.EXTRUDER_ONLY_ON_GEAR]: + driving_toolhead = self.mmu_toolhead # NEW owner (mmu/gear) + following_toolhead = self.printer_toolhead # OLD owner (printer/extruder) + following_steppers = [following_toolhead.get_extruder().extruder_stepper.stepper] + self._prev_trapq = following_steppers[0].get_trapq() # Save the *old* trapq **before** any rebind/unregister + driving_trapq = driving_toolhead.get_trapq() + s_alloc = ffi_lib.cartesian_stepper_alloc(b"y") + pos = [0., driving_toolhead.get_position()[1], 0.] + + # Cripple gear steppers and inject the extruder steppers into the gear rail + if new_sync_mode == self.EXTRUDER_ONLY_ON_GEAR: + for s in self.all_gear_rail_steppers: + self._unregister(self.mmu_toolhead, s) # s.set_trapq(None) + # Inject the extruder steppers to the end of the gear rail + gear_rail.steppers.extend(following_steppers) + + elif new_sync_mode == self.GEAR_SYNCED_TO_EXTRUDER: + driving_toolhead = self.printer_toolhead # NEW owner (printer/extruder) + following_toolhead = self.mmu_toolhead # OLD owner (mmu/gear) + following_steppers = list(following_toolhead.get_kinematics().rails[1].get_steppers()) + self._prev_trapq = following_toolhead.get_trapq() + driving_trapq = driving_toolhead.get_extruder().get_trapq() + s_alloc = ffi_lib.extruder_stepper_alloc() + pos = [driving_toolhead.get_position()[3], 0., 0.] + + else: + raise ValueError("Invalid sync_mode: %d" % new_sync_mode) + + # Hard close the old trapq up to the fence + _finalize_if_valid(self._prev_trapq, t1) + + # Switch ownership: unregister from the old TH, register on the new TH + self._prev_sk, self._prev_rd = [], [] + for s in following_steppers: + s_kinematics = ffi_main.gc(s_alloc, ffi_lib.free) + self._prev_sk.append(s.set_stepper_kinematics(s_kinematics)) + self._prev_rd.append(s.get_rotation_distance()[0]) + + # Remove from following toolhead, then attach to driving toolhead’s trapq + # s.set_trapq(driving_trapq) + self._unregister(following_toolhead, s) + self._register(driving_toolhead, s, trapq=driving_trapq) + + # Fix position + s.set_position(pos) + + # Debugging + #logging.info("MMU: ////////// CUTOVER fence t_cut=%.6f, old_trapq=%s, new_trapq=%s, from.last=%.6f, to.last=%.6f", + # t1, self._match_trapq(self._prev_trapq), self._match_trapq(driving_trapq), + # following_toolhead.get_last_move_time(), + # driving_toolhead.get_last_move_time()) + + # FORCE the NEW/RECEIVER (driving_toolhead) planner to >= t1 and materialize it + dt = max(EPS, t1 - driving_toolhead.get_last_move_time()) + driving_toolhead.dwell(dt) + driving_toolhead.flush_step_generation() + + # Now “synced” at t1 + + self.sync_mode = new_sync_mode + if self.sync_mode == self.GEAR_SYNCED_TO_EXTRUDER: + self.printer.send_event("mmu:synced") + + return prev_sync_mode + + def is_selector_homed(self): + return self.kin.get_status(self.reactor.monotonic())["selector_homed"] + + def get_status(self, eventtime): + res = super(MmuToolHead, self).get_status(eventtime) + res.update(dict(self.get_kinematics().get_status(eventtime))) + res.extend({ + 'filament_pos': self.mmu_toolhead.get_position()[1], + 'sync_mode': self.sync_mode + }) + return res + + cmd_DUMP_RAILS_help = "For debugging: dump current configuration of MMU Toolhead rails" + def cmd_DUMP_RAILS(self, gcmd): + show_endstops = gcmd.get_int('ENDSTOPS', 1, minval=0, maxval=1) + msg = self.dump_rails(show_endstops) + gcmd.respond_raw(msg) + + def _match_trapq(self, trapq): + p_th_trapq = self.printer_toolhead.get_trapq() + m_th_trapq = self.mmu_toolhead.get_trapq() + e_trapq = self.printer_toolhead.get_extruder().get_trapq() + ffi_main, ffi_lib = chelper.get_ffi() + if trapq is p_th_trapq: + return "Printer Toolhead" + elif trapq is m_th_trapq: + return "MMU Toolhead" + elif trapq is e_trapq: + return "Extruder" + elif trapq is None: + return "None" + elif trapq is ffi_main.NULL: + return "NULL" + return hex(id(trapq)) + + def sync_mode_to_string(self, mode): + if mode == self.EXTRUDER_SYNCED_TO_GEAR: + return "gear+extruder" + elif mode == self.EXTRUDER_ONLY_ON_GEAR: + return "extruder" + elif mode == self.GEAR_SYNCED_TO_EXTRUDER: + return "extruder+gear" + elif mode == self.GEAR_ONLY: + return "unsynced/gear" + return "unsynced" + + def dump_rails(self, show_endstops=True): + msg = "MMU TOOLHEAD: %s Last move time: %s\n" % (self.get_position(), self.mmu_toolhead.get_last_move_time()) + extruder_name = self.printer_toolhead.get_extruder().get_name() + for axis, rail in enumerate(self.get_kinematics().rails): + msg += "\n" if axis > 0 else "" + header = "RAIL: %s (Steppers: %d, Default endstops: %d, Extra endstops: %d) %s" % (rail.rail_name, len(rail.steppers), len(rail.endstops), len(rail.extra_endstops), '-' * 100) + msg += header[:100] + "\n" + gsd = None + if axis == 1: + rail_steppers = list(self.all_gear_rail_steppers) + for s in rail.get_steppers(): + if s not in rail_steppers: + rail_steppers.append(s) + else: + rail_steppers = rail.get_steppers() + for idx, s in enumerate(rail_steppers): + suffix = "" + if axis == 1: + if gsd is None: + gsd = s.get_step_dist() + if s in self.all_gear_rail_steppers and s not in self.selected_gear_steppers: + suffix = "*** INACTIVE ***" + msg += "Stepper %d: %s (trapq: %s) %s\n" % (idx, s.get_name(), self._match_trapq(s.get_trapq()), suffix) + msg += " - Commanded Pos: %.2f, " % s.get_commanded_position() + msg += "MCU Pos: %.2f, " % s.get_mcu_position() + rd = s.get_rotation_distance() + msg += "Rotation Dist: %.6f (in %d steps, step_dist=%.6f)\n" % (rd[0], rd[1], s.get_step_dist()) + + if show_endstops: + msg += "Endstops:\n" + for (mcu_endstop, name) in rail.endstops: + if mcu_endstop.__class__.__name__ == "MockEndstop": + msg += "- None (Mock - cannot home rail)\n" + else: + mcu_name = mcu_endstop.get_mcu().get_name() if hasattr(mcu_endstop, 'get_mcu') else mcu_endstop.__class__.__name__ + msg += "- %s%s, mcu: %s, pin: %s" % (name," (virtual)" if rail.is_endstop_virtual(name) else "", mcu_name, mcu_endstop._pin) + msg += " on: %s\n" % ["%d: %s" % (idx, s.get_name()) for idx, s in enumerate(mcu_endstop.get_steppers())] + msg += "Extra Endstops:\n" + for (mcu_endstop, name) in rail.extra_endstops: + mcu_name = mcu_endstop.get_mcu().get_name() if hasattr(mcu_endstop, 'get_mcu') else mcu_endstop.__class__.__name__ + msg += "- %s%s, mcu: %s, pin: %s" % (name, " (virtual)" if rail.is_endstop_virtual(name) else "", mcu_name, mcu_endstop._pin) + msg += " on: %s\n" % ["%d: %s" % (idx, s.get_name()) for idx, s in enumerate(mcu_endstop.get_steppers())] + + if axis == 1: # Gear rail + if self.is_gear_synced_to_extruder(): + msg += "SYNCHRONIZED: Gear rail synced to extruder '%s'\n" % extruder_name + if self.is_extruder_synced_to_gear(): + msg += "SYNCHRONIZED: Extruder '%s' synced to gear rail\n" % extruder_name + + e_stepper = self.printer_toolhead.get_extruder().extruder_stepper + msg += "\nPRINTER TOOLHEAD: %s Last move time: %s\n" % (self.printer_toolhead.get_position(), self.printer_toolhead.get_last_move_time()) + header = "Extruder Stepper: %s %s %s" % (extruder_name, "(MmuExtruderStepper)" if isinstance(e_stepper, MmuExtruderStepper) else "(Non Homing Default)", '-' * 100) + msg += header[:100] + "\n" + msg += " - Stepper trapq: %s\n" % self._match_trapq(e_stepper.stepper.get_trapq()) + msg += " - Commanded Pos: %.2f, " % e_stepper.stepper.get_commanded_position() + msg += "MCU Pos: %.2f, " % e_stepper.stepper.get_mcu_position() + rd = e_stepper.stepper.get_rotation_distance() + esd = e_stepper.stepper.get_step_dist() + msg += "Rotation Dist: %.6f (in %d steps, step_dist=%.6f)\n" % (rd[0], rd[1], esd) + if gsd is not None: + msg += "Step size ratio of gear:extruder = %.2f:1" % (gsd/esd) + return msg + + +# MMU Kinematics class +# (loosely based on corexy.py) +class MmuKinematics: + def __init__(self, toolhead, config): + self.printer = config.get_printer() + self.toolhead = toolhead + self.mmu_machine = self.printer.lookup_object('mmu_machine') + + # Setup "axis" rails + self.rails = [] + if self.mmu_machine.selector_type in ['LinearSelector', 'LinearServoSelector', 'LinearMultiGearSelector', 'RotarySelector']: + self.rails.append(MmuLookupMultiRail(config.getsection(SELECTOR_STEPPER_CONFIG), need_position_minmax=True, default_position_endstop=0.)) + self.rails[0].setup_itersolve('cartesian_stepper_alloc', b'x') + elif self.mmu_machine.selector_type in ['IndexedSelector']: + self.rails.append(MmuLookupMultiRail(config.getsection(SELECTOR_STEPPER_CONFIG), need_position_minmax=False, default_position_endstop=0.)) + self.rails[0].setup_itersolve('cartesian_stepper_alloc', b'x') + else: + self.rails.append(DummyRail()) + self.rails.append(MmuLookupMultiRail(config.getsection(GEAR_STEPPER_CONFIG), need_position_minmax=False, default_position_endstop=0.)) + self.rails[1].setup_itersolve('cartesian_stepper_alloc', b'y') + + for s in self.get_steppers(): + s.set_trapq(toolhead.get_trapq()) + if not self.toolhead.motion_queuing: + toolhead.register_step_generator(s.generate_steps) + + # Setup boundary checks + self.selector_max_velocity, self.selector_max_accel = toolhead.get_selector_limits() + self.gear_max_velocity, self.gear_max_accel = toolhead.get_gear_limits() + self.move_accel = None + self.limits = [(1.0, -1.0)] * len(self.rails) + + def get_steppers(self): + return [s for rail in self.rails for s in rail.get_steppers()] + + # Aka which stepper to choose to report movement + def calc_position(self, stepper_positions): + positions = [] + for i, r in enumerate(self.rails): + if isinstance(r, DummyRail): + positions.append(0.) + continue + s = next((s for s in r.steppers if s.get_trapq()), None) if i == 1 else None + name = s.get_name() if s else r.get_name() + positions.append(stepper_positions[name]) + return positions + + def set_position(self, newpos, homing_axes): + for i, rail in enumerate(self.rails): + if i == 1 and self.toolhead.is_gear_synced_to_extruder(): + continue + rail.set_position(newpos) + if i in homing_axes: + self.limits[i] = rail.get_range() + + def home(self, homing_state): + for axis in homing_state.get_axes(): + if not axis == 0: # Saftey: Only selector (axis[0]) can be homed TODO: make dependent on exact configuration + continue + rail = self.rails[axis] + position_min, position_max = rail.get_range() + hi = rail.get_homing_info() + homepos = [None, None, None, None] + homepos[axis] = hi.position_endstop + forcepos = list(homepos) + if hi.positive_dir: + forcepos[axis] -= 1.5 * (hi.position_endstop - position_min) + else: + forcepos[axis] += 1.5 * (position_max - hi.position_endstop) + homing_state.home_rails([rail], forcepos, homepos) # Perform homing + + def set_accel_limit(self, accel): + self.move_accel = accel + + def check_move(self, move): + if self.mmu_machine.selector_type in ['LinearSelector', 'LinearServoSelector', 'LinearMultiGearSelector', 'RotarySelector']: + limits = self.limits + xpos, _ = move.end_pos[:2] + if xpos != 0. and (xpos < limits[0][0] or xpos > limits[0][1]): + raise move.move_error() + + if move.axes_d[0]: # Selector + move.limit_speed(self.selector_max_velocity, min(self.selector_max_accel, self.move_accel or self.selector_max_accel)) + elif move.axes_d[1]: # Gear + move.limit_speed(self.gear_max_velocity, min(self.gear_max_accel, self.move_accel or self.gear_max_accel)) + + def get_status(self, eventtime): + axes = [a for a, (l, h) in zip("xy", self.limits) if l <= h] + return { + 'homed_axes': "".join(axes), + 'selector_homed': self.limits[0][0] <= self.limits[0][1], + } + + +# Extend Klipper homing module to leverage MMU "toolhead" +# (code pulled from homing.py) +class MmuHoming(Homing, object): + def __init__(self, printer, mmu_toolhead): + super(MmuHoming, self).__init__(printer) + self.toolhead = mmu_toolhead # Override default toolhead + + def home_rails(self, rails, forcepos, movepos): + # Notify of upcoming homing operation + self.printer.send_event("homing:home_rails_begin", self, rails) + # Alter kinematics class to think printer is at forcepos + homing_axes = [axis for axis in range(3) if forcepos[axis] is not None] + startpos = self._fill_coord(forcepos) + homepos = self._fill_coord(movepos) + self.toolhead.set_position(startpos, homing_axes=homing_axes) + # Perform first home + endstops = [es for rail in rails for es in rail.get_endstops()] + hi = rails[0].get_homing_info() + hmove = HomingMove(self.printer, endstops, self.toolhead) # Happy Hare: Override default toolhead + hmove.homing_move(homepos, hi.speed) + # Perform second home + if hi.retract_dist: + # Retract + startpos = self._fill_coord(forcepos) + homepos = self._fill_coord(movepos) + axes_d = [hp - sp for hp, sp in zip(homepos, startpos)] + move_d = math.sqrt(sum([d*d for d in axes_d[:3]])) + retract_r = min(1., hi.retract_dist / move_d) + retractpos = [hp - ad * retract_r + for hp, ad in zip(homepos, axes_d)] + self.toolhead.move(retractpos, hi.retract_speed) + # Home again + startpos = [rp - ad * retract_r + for rp, ad in zip(retractpos, axes_d)] + self.toolhead.set_position(startpos) + hmove = HomingMove(self.printer, endstops, self.toolhead) # Happy Hare: Override default toolhead + hmove.homing_move(homepos, hi.second_homing_speed) + if hmove.check_no_movement() is not None: + raise self.printer.command_error( + "Endstop %s still triggered after retract" + % (hmove.check_no_movement(),)) + # Signal home operation complete + self.toolhead.flush_step_generation() + self.trigger_mcu_pos = {sp.stepper_name: sp.trig_pos + for sp in hmove.stepper_positions} + self.adjust_pos = {} + self.printer.send_event("homing:home_rails_end", self, rails) + if any(self.adjust_pos.values()): + # Apply any homing offsets + kin = self.toolhead.get_kinematics() + homepos = self.toolhead.get_position() + kin_spos = {s.get_name(): (s.get_commanded_position() + + self.adjust_pos.get(s.get_name(), 0.)) + for s in kin.get_steppers()} + newpos = kin.calc_position(kin_spos) + for axis in homing_axes: + homepos[axis] = newpos[axis] + self.toolhead.set_position(homepos) + + +_StepperPrinterRail = stepper.PrinterRail if hasattr(stepper, 'PrinterRail') else stepper.GenericPrinterRail + + +# Extend PrinterRail to allow for multiple (switchable) endstops and to allow for no default endstop +# (defined in stepper.py) +class MmuPrinterRail(_StepperPrinterRail, object): + def __init__(self, config, **kwargs): + self.printer = config.get_printer() + self.config = config + self.rail_name = config.get_name() + self.query_endstops = self.printer.load_object(config, 'query_endstops') + self.extra_endstops = [] + self.virtual_endstops = [] + # Starting with klipper v0.13.0-79 there is a `config.get('endstop_pin')` with no default which throws an error + # So we must put a fake value in there to avoid the error, but we don't want to do that on older versions + if config.get('endstop_pin', None) is None and hasattr(super(MmuPrinterRail, self), 'lookup_endstop'): + config.fileconfig.set(config.section, 'endstop_pin', 'mock') + super(MmuPrinterRail, self).__init__(config, **kwargs) + + # Prior to klipper v0.13.0-79 this was done in the base class + if (len(self.get_steppers()) == 0): + self.add_stepper_from_config(config) + + def lookup_endstop(self, endstop_pin, name, **kwargs): + if endstop_pin == 'mock': + return self.MockEndstop() + elif hasattr(super(MmuPrinterRail, self), 'lookup_endstop'): + return super(MmuPrinterRail, self).lookup_endstop(endstop_pin, name, **kwargs) + + def add_stepper_from_config(self, config, **kwargs): + if not self.endstops and config.get('endstop_pin', None) is None: + # No endstop defined, so configure a mock endstop. The rail is, of course, only homable + # if it has a properly configured endstop at runtime + self.endstops = [(self.MockEndstop(), "mock")] # Hack: pretend we have a default endstop so super class will work + + logging.info("MMU: Setting up mmu stepper %s" % config.section) + + if hasattr(super(MmuPrinterRail, self), 'add_extra_stepper'): + super(MmuPrinterRail, self).add_extra_stepper(config, **kwargs) + else: + super(MmuPrinterRail, self).add_stepper_from_config(config, **kwargs) + + # Setup default endstop similarly to "extra" endstops with vanity sensor name + endstop_pin = config.get('endstop_pin', None) + if endstop_pin and endstop_pin != 'mock': + last_mcu_es=self.endstops[-1] + # Remove the default endstop name if alternative name is specified + endstop_name = config.get('endstop_name', None) + if endstop_name: + self.endstops.pop() + self.endstops.append((last_mcu_es[0], endstop_name)) + qee = self.query_endstops.endstops + if qee: + qee.pop() + self.query_endstops.register_endstop(self.endstops[0][0], endstop_name) + self.extra_endstops.append((last_mcu_es[0], endstop_name)) + self.extra_endstops.append((last_mcu_es[0], 'default')) + if 'virtual_endstop' in endstop_pin: + self.virtual_endstops.append(endstop_name) + if 'virtual_endstop' in endstop_pin: + self.virtual_endstops.append('default') + + # Handle any extra endstops + extra_endstop_pins = config.getlist('extra_endstop_pins', []) + extra_endstop_names = config.getlist('extra_endstop_names', []) + if extra_endstop_pins: + if len(extra_endstop_pins) != len(extra_endstop_names): + raise self.config.error("`extra_endstop_pins` and `extra_endstop_names` are different lengths") + for idx, pin in enumerate(extra_endstop_pins): + name = extra_endstop_names[idx] + self.add_extra_endstop(pin, name) + + # backwards compatibility (klipper v0.13.0-79) ... old: add_extra_stepper. new: add_stepper_from_config + def add_extra_stepper(self, config, **kwargs): + self.add_stepper_from_config(config, **kwargs) + + def add_extra_endstop(self, pin, name, register=True, bind_rail_steppers=True, mcu_endstop=None): + is_virtual = 'virtual_endstop' in pin + if is_virtual: + if name not in self.virtual_endstops: + self.virtual_endstops.append(name) + else: + raise self.config.error("Extra virtual endstop '%s' defined more than once" % name) + if mcu_endstop is None: + ppins = self.printer.lookup_object('pins') + mcu_endstop = ppins.setup_pin('endstop', pin) + self.extra_endstops.append((mcu_endstop, name)) + if bind_rail_steppers: + for s in self.steppers if not is_virtual else [self.steppers[-1]]: + try: + mcu_endstop.add_stepper(s) + except Exception as e: + logging.info("MMU: Not possible to add stepper %s to endstop %s because: %s" % (s.get_name(), name, str(e))) + if register: # and not self.is_endstop_virtual(name): + self.query_endstops.register_endstop(mcu_endstop, name) + return mcu_endstop + + def get_extra_endstop_names(self): + return [x[1] for x in self.extra_endstops] + + # Returns the endstop of given name as list to match get_endstops() + def get_extra_endstop(self, name): + for x in self.extra_endstops: + if x[1] == name: + return [x] + return None + + def is_endstop_virtual(self, name): + return name in self.virtual_endstops if name else False + + def set_direction(self, direction): + for stepper in self.steppers: + if not isinstance(stepper, (MmuExtruderStepper, ExtruderStepper)): + stepper.set_dir_inverted(direction) + + class MockEndstop: + def add_stepper(self, *args, **kwargs): + pass + + +# Wrapper for multiple stepper motor support +def MmuLookupMultiRail(config, need_position_minmax=True, default_position_endstop=None, units_in_radians=False): + rail = MmuPrinterRail(config, need_position_minmax=need_position_minmax, default_position_endstop=default_position_endstop, units_in_radians=units_in_radians) + for i in range(1, 23): # Don't allow "_0" or it is confusing with unprefixed initial stepper + section_name = "%s_%d" % (config.get_name(), i) + if not config.has_section(section_name): + break + rail.add_stepper_from_config(config.getsection(section_name)) + return rail + + +# Extend ExtruderStepper to allow for adding and managing endstops (useful only when part of gear rail, not operating as an Extruder) +class MmuExtruderStepper(ExtruderStepper, object): + def __init__(self, config, gear_rail): + super(MmuExtruderStepper, self).__init__(config) + + # Ensure corresponding TMC section is loaded so endstops can be added and to prevent error later when toolhead is created + for chip in TMC_CHIPS: + try: + _ = self.printer.load_object(config, '%s extruder' % chip) + break + except: + pass + + # This allows for setup of stallguard as an option for nozzle homing + endstop_pin = config.get('endstop_pin', None) + if endstop_pin: + mcu_endstop = gear_rail.add_extra_endstop(endstop_pin, 'mmu_ext_touch', bind_rail_steppers=False) + mcu_endstop.add_stepper(self.stepper) + + # Override to add QUIET option to control console logging + def cmd_SET_PRESSURE_ADVANCE(self, gcmd): + pressure_advance = gcmd.get_float('ADVANCE', self.pressure_advance, minval=0.) + smooth_time = gcmd.get_float('SMOOTH_TIME', self.pressure_advance_smooth_time, minval=0., maxval=.200) + self._set_pressure_advance(pressure_advance, smooth_time) + msg = "pressure_advance: %.6f\n" "pressure_advance_smooth_time: %.6f" % (pressure_advance, smooth_time) + self.printer.set_rollover_info(self.name, "%s: %s" % (self.name, msg)) + if not gcmd.get_int('QUIET', 0, minval=0, maxval=1): + gcmd.respond_info(msg, log=False) + +class DummyRail: + def __init__(self): + self.steppers = [] + self.endstops = [] + self.extra_endstops = [] + self.rail_name = "Dummy" + + def get_name(self): + return self.rail_name + + def get_steppers(self): + return self.steppers + + def get_endstops(self): + return self.endstops + + def set_position(self, newpos): + pass + + +def load_config(config): + return MmuMachine(config) diff --git a/klippy/extras/mmu_sensors.py b/klippy/extras/mmu_sensors.py new file mode 100644 index 000000000000..88e52ab1c104 --- /dev/null +++ b/klippy/extras/mmu_sensors.py @@ -0,0 +1,808 @@ +# Happy Hare MMU Software +# Easy setup of all sensors for MMU +# +# Pre-gate sensors: +# Simplifed filament switch sensor easy configuration of pre-gate sensors used to detect runout and insertion of filament +# and preload into gate and update gate_map when possible to do so based on MMU state, not printer state +# Essentially this uses the default `filament_switch_sensor` but then replaces the runout_helper +# Each has name `mmu_pre_gate_X` where X is gate number +# +# mmu_gear sensor(s): +# Wrapper around `filament_switch_sensor` setting up insert/runout callbacks with modified runout event handling +# Named `mmu_gear_X` where X is the gate number +# +# mmu_gate sensor(s): +# Wrapper around `filament_switch_sensor` setting up insert/runout callbacks with modified runout event handling +# Named `mmu_gate` +# +# extruder & toolhead sensor: +# Wrapper around `filament_switch_sensor` disabling all functionality - just for visability +# Named `extruder` & `toolhead` +# +# sync feedback sensor(s): +# Creates buttons handlers (with filament_switch_sensor for visibility and control) and publishes events based on state change +# Named `sync_feedback_compression` & `sync_feedback_tension` +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# RunoutHelper based on: +# Generic Filament Sensor Module Copyright (C) 2019 Eric Callahan +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. + +import logging, time + +import configparser, configfile + +INSERT_GCODE = "__MMU_SENSOR_INSERT" +REMOVE_GCODE = "__MMU_SENSOR_REMOVE" +RUNOUT_GCODE = "__MMU_SENSOR_RUNOUT" +CLOG_GCODE = "__MMU_SENSOR_CLOG" +TANGLE_GCODE = "__MMU_SENSOR_TANGLE" + + +# ------------------------------------------------------------------------------------------------- +# Enhanced "runout helper" that gives greater control of when filament sensor events are fired and +# direct access to button events in addition to creating a "remove" / "runout" distinction +class MmuRunoutHelper: + + def __init__(self, printer, name, event_delay, gcodes, insert_remove_in_print, button_handler, switch_pin): + """ + gcodes: dict of gcode macros to call for each event type. + Any key can be omitted or set to None/"" to disable that event. + """ + + self.printer, self.name = printer, name + + # Expecting a dict with keys like "insert", "remove", "runout", "clog", "tangle" + self.gcodes = gcodes or {} + + self.insert_remove_in_print = insert_remove_in_print + self.button_handler = button_handler + self.switch_pin = switch_pin + self.reactor = self.printer.get_reactor() + self.gcode = self.printer.lookup_object('gcode') + + self.min_event_systime = self.reactor.NEVER + self.event_delay = event_delay # Time between generated events + self.filament_present = False + self.sensor_enabled = True + self.runout_suspended = None + self.button_handler_suspended = False + + self.printer.register_event_handler("klippy:ready", self._handle_ready) + + # Replace previous runout_helper mux commands with ours + prev = self.gcode.mux_commands.get("QUERY_FILAMENT_SENSOR") + if prev: + _, prev_values = prev + prev_values[self.name] = self.cmd_QUERY_FILAMENT_SENSOR + + prev = self.gcode.mux_commands.get("SET_FILAMENT_SENSOR") + if prev: + _, prev_values = prev + prev_values[self.name] = self.cmd_SET_FILAMENT_SENSOR + + + def _handle_ready(self): + self.min_event_systime = self.reactor.monotonic() + 2. # Time to wait before first events are processed + + + def _insert_event_handler(self, eventtime): + insert_gcode = self.gcodes.get("insert") + self._exec_gcode("%s EVENTTIME=%s" % (insert_gcode, eventtime) if insert_gcode else None) + + + def _remove_event_handler(self, eventtime): + remove_gcode = self.gcodes.get("remove") + self._exec_gcode("%s EVENTTIME=%s" % (remove_gcode, eventtime) if remove_gcode else None) + + + def _runout_event_handler(self, eventtime, event_type): + # Pausing from inside an event requires that the pause portion of pause_resume execute immediately. + pause_resume = self.printer.lookup_object('pause_resume') + pause_resume.send_pause_command() + handler_gcode = self.gcodes.get(event_type) + self._exec_gcode("%s EVENTTIME=%s" % (handler_gcode, eventtime) if handler_gcode else None) + + + def _exec_gcode(self, command): + if command: + try: + self.gcode.run_script(command) + except Exception: + logging.exception("MMU: Error running mmu sensor handler: `%s`" % command) + self.min_event_systime = self.reactor.monotonic() + self.event_delay + + + # Latest klipper v0.12.0-462 added the passing of eventtime + # old: note_filament_present(self, is_filament_present): + # new: note_filament_present(self, eventtime, is_filament_present): + def note_filament_present(self, *args): + if len(args) == 1: + eventtime = self.reactor.monotonic() + is_filament_present = args[0] + else: + eventtime = args[0] + is_filament_present = args[1] + + prev_filament_present = self.filament_present + self.filament_present = bool(is_filament_present) + + # Button handlers are used for sync feedback state switches + if self.button_handler and not self.button_handler_suspended: + self.button_handler(eventtime, self.name, is_filament_present, self) + + if prev_filament_present == is_filament_present: + return + + # Don't handle too early or if disabled + if eventtime >= self.min_event_systime and self.sensor_enabled: + self._process_state_change(eventtime, is_filament_present) + + + def _process_state_change(self, eventtime, is_filament_present): + # Determine "printing" status + now = self.reactor.monotonic() + print_stats = self.printer.lookup_object("print_stats", None) + if print_stats is not None: + is_printing = print_stats.get_status(now)["state"] == "printing" + else: + is_printing = self.printer.lookup_object("idle_timeout").get_status(now)["state"] == "Printing" + + insert_gcode = self.gcodes.get("insert") + remove_gcode = self.gcodes.get("remove") + runout_gcode = self.gcodes.get("runout") + + if is_filament_present and insert_gcode: # Insert detected + if not is_printing or (is_printing and self.insert_remove_in_print): + #logging.info("MMU: filament sensor %s: insert event detected, Eventtime %.2f" % (self.name, eventtime)) + self.min_event_systime = self.reactor.NEVER # Prevent more callbacks until this one is complete + self.reactor.register_callback(lambda reh: self._insert_event_handler(eventtime)) + + else: # Remove or Runout detected + if is_printing and self.runout_suspended is False and runout_gcode: + #logging.info("MMU: filament sensor %s: runout event detected, Eventtime %.2f" % (self.name, eventtime)) + self.min_event_systime = self.reactor.NEVER # Prevent more callbacks until this one is complete + self.reactor.register_callback(lambda reh: self._runout_event_handler(eventtime, "runout")) + elif remove_gcode and (not is_printing or self.insert_remove_in_print): + # Just a "remove" event + #logging.info("MMU: filament sensor %s: remove event detected, Eventtime %.2f" % (self.name, eventtime)) + self.min_event_systime = self.reactor.NEVER # Prevent more callbacks until this one is complete + self.reactor.register_callback(lambda reh: self._remove_event_handler(eventtime)) + + + def note_clog_tangle(self, event_type): + #logging.info("MMU: filament sensor %s: %s event detected, Eventtime %.2f" % (self.name, event_type, eventtime)) + now = self.reactor.monotonic() + self.min_event_systime = self.reactor.NEVER # Prevent more callbacks until this one is complete + self.reactor.register_callback(lambda reh: self._runout_event_handler(now, event_type)) + + + def enable_runout(self, restore): + self.runout_suspended = not restore + + + def enable_button_feedback(self, restore): + self.button_handler_suspended = not restore + + + def get_status(self, eventtime=None): + return { + "filament_detected": bool(self.filament_present), + "enabled": bool(self.sensor_enabled), + "runout_suspended": bool(self.runout_suspended), + } + + + cmd_QUERY_FILAMENT_SENSOR_help = "Query the status of the Filament Sensor" + def cmd_QUERY_FILAMENT_SENSOR(self, gcmd): + if self.filament_present: + msg = "MMU Sensor %s: filament detected" % (self.name) + else: + msg = "MMU Sensor %s: filament not detected" % (self.name) + gcmd.respond_info(msg) + + + cmd_SET_FILAMENT_SENSOR_help = "Sets the filament sensor on/off" + def cmd_SET_FILAMENT_SENSOR(self, gcmd): + self.sensor_enabled = bool(gcmd.get_int("ENABLE", 1)) + + + +# ------------------------------------------------------------------------------------------------- +# Analog Filament Tension Sensor used for proportional sync-feedback +# Maps sensor range to [-1,1] +class MmuProportionalSensor: + + def __init__(self, config, name): + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.name = name + self._last_extreme = None + + # Config + self._pin = config.get('sync_feedback_analog_pin') + max_tension = config.getfloat('sync_feedback_analog_max_tension', 1) + max_compression = config.getfloat('sync_feedback_analog_max_compression', 0) + + # Determine the actual raw min/max sensor values + raw_min = min(max_tension, max_compression) + raw_max = max(max_tension, max_compression) + mid_point = (max_tension + max_compression) / 2.0 + + self._neutral_point = config.getfloat('sync_feedback_analog_neutral_point', mid_point, minval=raw_min, maxval=raw_max) + + self._gamma = config.getfloat('sync_feedback_analog_gamma', 1) # Not exposed + self._sample_time = config.getfloat('sync_feedback_analog_sample_time', 0.005) # Not exposed + self._sample_count = config.getint('sync_feedback_analog_sample_count', 5) # Not exposed + self._report_time = config.getfloat('sync_feedback_analog_report_time', 0.100) # Not exposed + + self._reversed = (max_compression < max_tension) + eps = 1e-12 + if not self._reversed: + # Tension low, Compression high value + self._d_neg = max(self._neutral_point - max_tension, eps) + self._d_pos = max(max_compression - self._neutral_point, eps) + else: + # Compression low, Tension high value + self._d_pos = max(self._neutral_point - max_compression, eps) + self._d_neg = max(max_tension - self._neutral_point, eps) + + # State + self.value_raw = 0.0 # Raw ADC value + self.value = 0.0 # In [-1.0, 1.0] + + # Setup ADC + ppins = self.printer.lookup_object('pins') + self.adc = ppins.setup_pin('adc', self._pin) + + if hasattr(self.adc, "setup_minmax"): + # Kalico and older klipper + self.adc.setup_minmax(self._sample_time, self._sample_count) + self.adc.setup_adc_callback(self._report_time, self._adc_callback) + else: + try: + # New klipper (>= v0.13.0-557) + self.adc.setup_adc_sample(self._report_time, self._sample_time, self._sample_count) + self.adc.setup_adc_callback(self._adc_callback) + except TypeError: + # A few versions of klipper had these signatures + self.adc.setup_adc_sample(self._sample_time, self._sample_count) + self.adc.setup_adc_callback(self._report_time, self._adc_callback) + + # Attach runout_helper (no gcode actions; just enable/disable plumbing to remove UI nag) + clog_gcode = ("%s SENSOR=%s" % (CLOG_GCODE, name)) + tangle_gcode = ("%s SENSOR=%s" % (TANGLE_GCODE, name)) + self.runout_helper = MmuRunoutHelper( + self.printer, + self.name, # Name exposed to QUERY_/SET_FILAMENT_SENSOR + 0, # Event_delay (not used here) + { + "clog": clog_gcode, + "tangle": tangle_gcode, + }, + insert_remove_in_print=False, + button_handler=None, # No button handler for analog + switch_pin=self._pin + ) + + # Expose status + self.printer.add_object(self.name, self) + + def _map_reading(self, v_raw): + n = self._neutral_point + + v = float(v_raw) + # Map around neutral_point into [-1, 1] + if not self._reversed: + if v >= n: + y = (v - n) / self._d_pos + else: + y = -(n - v) / self._d_neg + else: + if v <= n: + y = (n - v) / self._d_pos + else: + y = -(v - n) / self._d_neg + + # Optional shaping (gamma=1 => linear) + if self._gamma != 1.0: + y = (abs(y) ** self._gamma) * (1.0 if y >= 0 else -1.0) + + # Clamp + if y < -1.0: y = -1.0 + if y > 1.0: y = 1.0 + return y + + def _adc_callback(self, *args): + # Old klipper: _adc_callback(read_time, read_value) + # New klipper: _adc_callback(samples) where samples is a list of (read_time, read_value) + if len(args) == 1: + samples = args[0] + read_time, read_value = samples[-1] + elif len(args) == 2: + read_time, read_value = args + else: + raise TypeError("_adc_callback expected (read_time, read_value) or (samples), got %d args" % len(args)) + + self.value_raw = float(read_value) + self.value = self._map_reading(read_value) # Mapped & scaled value + + # Publish sync-feedback event immediately if extreme to match switch sensors + # TODO really extreme should be determined by is_extreme() in mmu_sync_feedback manager (with hysteresis), but object hasn't been created yet + # TODO so for now, use absolute extremes + if abs(self.value) >= 1.0: + extreme = 1 if self.value > 0 else -1 + if extreme != self._last_extreme: # Avoid repeated events + self._last_extreme = extreme + self.printer.send_event("mmu:sync_feedback", read_time, self.value) + + def get_status(self, eventtime): + return { + "enabled": bool(self.runout_helper.sensor_enabled), + "value": self.value, # in [-1.0, 1.0] (mapped) + "value_raw": self.value_raw, # raw + } + + + +# ------------------------------------------------------------------------------------------------- +# EXPERIMENTAL/HACK +# Support ViViD analog buffer "endstops" +# This class implments both the filament switch sensor and endstop. However: +# * it will not display in UI because no filament_switch_sensor exists in config +# * does not involve the mcu in the homing process so it can't be accurate +# * suffers from inherent averaging lag for analog inputs +class MmuAdcSwitchSensor: + + def __init__(self, config, name_prefix, gate, switch_pin, event_delay, + a_range, + insert=False, remove=False, runout=False, clog=False, tangle=False, + insert_remove_in_print=False, button_handler=None, + a_pullup=4700.): + + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self._pin = switch_pin + self._steppers = [] + self._trigger_completion = None + self._last_trigger_time = None + + buttons = self.printer.load_object(config, 'buttons') + a_min, a_max = a_range + buttons.register_adc_button(switch_pin, a_min, a_max, a_pullup, self._button_handler) + self.name = name = "%s_%d" % (name_prefix, gate) + insert_gcode = ("%s SENSOR=%s%s" % (INSERT_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if insert else None + remove_gcode = ("%s SENSOR=%s%s" % (REMOVE_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if remove else None + runout_gcode = ("%s SENSOR=%s%s" % (RUNOUT_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if runout else None + clog_gcode = ("%s SENSOR=%s%s" % (CLOG_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if clog else None + tangle_gcode = ("%s SENSOR=%s%s" % (TANGLE_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if tangle else None + self.runout_helper = MmuRunoutHelper( + self.printer, + name, + event_delay, + { + "insert": insert_gcode, + "remove": remove_gcode, + "runout": runout_gcode, + "clog": clog_gcode, + "tangle": tangle_gcode, + }, + insert_remove_in_print, + button_handler, + switch_pin, + ) + self.get_status = self.runout_helper.get_status + + + def _button_handler(self, eventtime, state): + self.runout_helper.note_filament_present(eventtime, state) + if self._trigger_completion is not None: + self._last_trigger_time = eventtime + self._trigger_completion.complete(True) + + + # Required to implement an endstop ------- + + def query_endstop(self, print_time): + return self.runout_helper.filament_present + + + def setup_pin(self, pin_type, pin_name): + return self + + + def add_stepper(self, stepper): + self._steppers.append(stepper) + + + def get_steppers(self): + return list(self._steppers) + + + def home_start(self, print_time, sample_time, sample_count, rest_time, triggered): + self._trigger_completion = self.reactor.completion() + self._last_trigger_time = None + self._homing = True + self._triggered = triggered + + if self.runout_helper.filament_present == self._triggered: + self._last_trigger_time = print_time + self._trigger_completion.complete(True) + + return self._trigger_completion + + + def home_wait(self, home_end_time): + self._homing = False + self._trigger_completion = None + + if self._last_trigger_time is None: + raise self.printer.command_error("No trigger on %s after full movement" % self.name) + + return self._last_trigger_time + + + +# ------------------------------------------------------------------------------------------------- +# EXPERIMENTAL +# Standalone Hall Filament Sensor Endstop using Multi-Use Pins +# Can coexists with standard Klipper hall_filament_width_sensor by sharing the ADC pins +class MmuHallEndstop: + def __init__(self, config, name, pin1, pin2, cal_dia1, raw_dia1, cal_dia2, raw_dia2, + hall_runout_dia=1., + insert=False, remove=False, runout=False, clog=False, tangle=False): + + self.printer = config.get_printer() + self.name = name + + # Configurable sampling for fast endstop response + # Defaults: 1ms sample, 8 samples = 8ms. Report every 10ms. + self.sample_time = config.getfloat('hall_sample_time', 0.001, above=0.0) + self.sample_count = config.getint('hall_sample_count', 8, minval=1) + self.report_time = config.getfloat('hall_report_time', 0.010, above=0.0) + + # Sensor configuration for diameter calculation + self.pin1_name = pin1 + self.pin2_name = pin2 + self.dia1 = cal_dia1 + self.rawdia1 = raw_dia1 + self.dia2 = cal_dia2 + self.rawdia2 = raw_dia2 + self.hall_min_diameter = hall_runout_dia + + # State + self.lastFilamentWidthReading = 0 + self.lastFilamentWidthReading2 = 0 + self.diameter = 0 + self.is_active = True # Always active for endstop purposes? or should be toggleable? + + # Endstop state variables + self._steppers = [] + self._trigger_completion = None + self._last_trigger_time = None + self._homing = False + self._triggered = False + + # Setup Hardware (Multi-Use) + ppins = self.printer.lookup_object('pins') + + _kalico = hasattr(self.adc, "setup_minmax") # Kalico and older klipper + # ADC 1 + if self.pin1_name: + ppins.allow_multi_use_pin(self.pin1_name) + self.mcu_adc = ppins.setup_pin('adc', self.pin1_name) + if _kalico: + self.mcu_adc.setup_minmax(self.sample_time, self.sample_count) + else: + self.mcu_adc.setup_adc_sample(self.sample_time, self.sample_count) + self.mcu_adc.setup_adc_callback(self.report_time, self.adc_callback) + + # ADC 2 (Optional) + self.mcu_adc2 = None + if self.pin2_name: + ppins.allow_multi_use_pin(self.pin2_name) + self.mcu_adc2 = ppins.setup_pin('adc', self.pin2_name) + if _kalico: + self.mcu_adc2.setup_minmax(self.sample_time, self.sample_count) + else: + self.mcu_adc2.setup_adc_sample(self.sample_time, self.sample_count) + self.mcu_adc2.setup_adc_callback(self.report_time, self.adc2_callback) + + # Setup runout helper/virtual sensor for MMU integration + event_delay = 0.5 + insert_gcode = ("%s SENSOR=%s" % (INSERT_GCODE, name)) if insert else None + remove_gcode = ("%s SENSOR=%s" % (REMOVE_GCODE, name)) if remove else None + runout_gcode = ("%s SENSOR=%s" % (RUNOUT_GCODE, name)) if runout else None + + # We pass "None" for switch_pin because we manage the pin state via ADC logic + self.runout_helper = MmuRunoutHelper( + self.printer, + name, + event_delay, + { + "insert": insert_gcode, + "remove": remove_gcode, + "runout": runout_gcode + }, + insert_remove_in_print=False, + button_handler=None, + switch_pin=None + ) + + self.printer.add_object("mmu_hall_endstop %s" % name, self) + + def _calc_diameter(self): + # Duplicate of Klipper hall_filament_width_sensor logic + try: + val_sum = self.lastFilamentWidthReading + self.lastFilamentWidthReading2 + slope = (self.dia2 - self.dia1) / (self.rawdia2 - self.rawdia1) + diameter_new = round(slope * (val_sum - self.rawdia1) + self.dia1, 2) + # Use same smoothing factor as Klipper? Or faster for endstop? + # Klipper: self.diameter = (5.0 * self.diameter + diameter_new) / 6 + # For endstop we probably want instant reaction or less smoothing + self.diameter = (2.0 * self.diameter + diameter_new) / 3 # Slightly faster smoothing + except ZeroDivisionError: + self.diameter = 1.75 # Default fallback + + def adc_callback(self, read_time, read_value): + self.lastFilamentWidthReading = round(read_value * 10000) + self._calc_diameter() + self._check_trigger(read_time) + + def adc2_callback(self, read_time, read_value): + self.lastFilamentWidthReading2 = round(read_value * 10000) + self._calc_diameter() + self._check_trigger(read_time) + + def _check_trigger(self, eventtime): + is_present = self.diameter > self.hall_min_diameter + self.runout_helper.note_filament_present(eventtime, is_present) + + if self._homing: + if is_present == self._triggered: + if self._trigger_completion is not None: + self._last_trigger_time = eventtime + self._trigger_completion.complete(True) + self._trigger_completion = None + + def get_status(self, eventtime): + status = self.runout_helper.get_status(eventtime) + status.update({ + "Diameter": self.diameter, + "Raw": (self.lastFilamentWidthReading + self.lastFilamentWidthReading2) + }) + return status + + # Required to implement a HH MMU endstop ------- + + def query_endstop(self, print_time): + return self.runout_helper.filament_present + + def setup_pin(self, pin_type, pin_name): + return self + + def add_stepper(self, stepper): + self._steppers.append(stepper) + + def get_steppers(self): + return list(self._steppers) + + def home_start(self, print_time, sample_time, sample_count, rest_time, triggered): + self._trigger_completion = self.reactor.completion() + self._last_trigger_time = None + self._homing = True + self._triggered = triggered + + if self.runout_helper.filament_present == self._triggered: + self._last_trigger_time = print_time + self._trigger_completion.complete(True) + + return self._trigger_completion + + def home_wait(self, home_end_time): + self._homing = False + self._trigger_completion = None + + if self._last_trigger_time is None: + raise self.printer.command_error("No trigger on %s after full movement" % self.name) + + return self._last_trigger_time + + + +class MmuSensors: + + def __init__(self, config): + from .mmu import Mmu # For sensor names + + self.printer = config.get_printer() + self.sensors = {} + mmu_machine = self.printer.lookup_object("mmu_machine", None) + num_units = mmu_machine.num_units if mmu_machine else 1 + event_delay = config.get('event_delay', 0.5) + + # Setup "mmu_pre_gate" sensors... + for gate in range(23): + switch_pin = config.get('pre_gate_switch_pin_%d' % gate, None) + if switch_pin: + self._create_mmu_sensor(config, Mmu.SENSOR_PRE_GATE_PREFIX, gate, switch_pin, event_delay, insert=True, remove=True, runout=True, insert_remove_in_print=True) + + # Setup single "mmu_gate" sensor(s)... + switch_pins = list(config.getlist('gate_switch_pin', [])) + if switch_pins: + if len(switch_pins) not in [1, num_units]: + raise config.error("Invalid number of pins specified with gate_switch_pin. Expected 1 or %d but counted %d" % (num_units, len(switch_pins))) + self._create_mmu_sensor(config, Mmu.SENSOR_GATE, None, switch_pins, event_delay, runout=True) + + # Setup "mmu_gear" sensors... + for gate in range(23): + switch_pin = config.get('post_gear_switch_pin_%d' % gate, None) + if switch_pin: + a_range = config.getfloatlist('post_gear_analog_range_%d' % gate, None, count=2) + if a_range is not None: + a_pullup = config.getfloat('post_gear_analog_pullup_resister_%d' % gate, 4700.) + s = MmuAdcSwitchSensor(config, Mmu.SENSOR_GEAR_PREFIX, gate, switch_pin, event_delay, a_range, runout=True, a_pullup=a_pullup) + self.sensors["%s_%d" % (Mmu.SENSOR_GEAR_PREFIX, gate)] = s + else: + self._create_mmu_sensor(config, Mmu.SENSOR_GEAR_PREFIX, gate, switch_pin, event_delay, runout=True) + + # Setup single extruder (entrance) sensor... + switch_pin = config.get('extruder_switch_pin', None) + if switch_pin: + self._create_mmu_sensor(config, Mmu.SENSOR_EXTRUDER_ENTRY, None, switch_pin, event_delay, insert=True, runout=True) + + # Setup single toolhead sensor... + switch_pin = config.get('toolhead_switch_pin', None) + if switch_pin: + self._create_mmu_sensor(config, Mmu.SENSOR_TOOLHEAD, None, switch_pin, event_delay) + + # For Qidi printers or any other that use a hall_filament_width_sensor as an endstop + hall_sensor_endstop = config.get('hall_sensor_endstop', None) + if hall_sensor_endstop is not None: + if hall_sensor_endstop == 'gate': + target_name = Mmu.SENSOR_GATE + elif hall_sensor_endstop == 'extruder': + target_name = Mmu.SENSOR_EXTRUDER_ENTRY + elif hall_sensor_endstop == 'toolhead': + target_name = Mmu.SENSOR_TOOLHEAD + else: + target_name = hall_sensor_endstop + + self.hall_pin1 = config.get('hall_adc1') + self.hall_pin2 = config.get('hall_adc2') + self.hall_dia1 = config.getfloat('hall_cal_dia1', 1.5) + self.hall_dia2 = config.getfloat('hall_cal_dia2', 2.0) + self.hall_rawdia1 = config.getint('hall_raw_dia1', 9500) + self.hall_rawdia2 = config.getint('hall_raw_dia2', 10500) + self.hall_runout_dia = config.getfloat('hall_min_diameter', 1.0) + # self.hall_runout_dia_max = config.getfloat('hall_max_diameter', 2.0) - Unused for trigger + + s = MmuHallEndstop(config, target_name, self.hall_pin1, self.hall_pin2, + self.hall_dia1, self.hall_rawdia1, self.hall_dia2, self.hall_rawdia2, + hall_runout_dia=self.hall_runout_dia, + insert=True, runout=True) + self.sensors[target_name] = s + + # Setup motor syncing feedback sensors... + switch_pins = list(config.getlist('sync_feedback_tension_pin', [])) + if switch_pins: + if len(switch_pins) not in [1, num_units]: + raise config.error("Invalid number of pins specified with sync_feedback_tension_pin. Expected 1 or %d but counted %d" % (num_units, len(switch_pins))) + self._create_mmu_sensor(config, Mmu.SENSOR_TENSION, None, switch_pins, 0, clog=True, tangle=True, button_handler=self._sync_tension_callback) + switch_pins = list(config.getlist('sync_feedback_compression_pin', [])) + if switch_pins: + if len(switch_pins) not in [1, num_units]: + raise config.error("Invalid number of pins specified with sync_feedback_compression_pin. Expected 1 or %d but counted %d" % (num_units, len(switch_pins))) + self._create_mmu_sensor(config, Mmu.SENSOR_COMPRESSION, None, switch_pins, 0, clog=True, tangle=True, button_handler=self._sync_compression_callback) + + # Setup analog (proportional) sync feedback + # Uses single analog input; value scaled in [-1, 1] + analog_pin = config.get('sync_feedback_analog_pin', None) + if analog_pin: + self.sensors[Mmu.SENSOR_PROPORTIONAL] = MmuProportionalSensor(config, name=Mmu.SENSOR_PROPORTIONAL) + + + def _create_mmu_sensor( + self, config, name_prefix, gate, switch_pins, event_delay, + insert=False, remove=False, runout=False, clog=False, tangle=False, + insert_remove_in_print=False, button_handler=None, + ): + switch_pins = [switch_pins] if not isinstance(switch_pins, list) else switch_pins + for unit, switch_pin in enumerate(switch_pins): + if not self._is_empty_pin(switch_pin): + # name must match mmu_sensor_manager + if gate is not None: + name = "%s_%d" % (name_prefix, gate) + elif len(switch_pins) > 1: + name = "unit_%d_%s" % (unit, name_prefix) + else: + name = name_prefix + sensor = name if gate is not None else "%s_sensor" % name + section = "filament_switch_sensor %s" % sensor + config.fileconfig.add_section(section) + config.fileconfig.set(section, "switch_pin", switch_pin) + config.fileconfig.set(section, "pause_on_runout", "False") + fs = self.printer.load_object(config, section) + + # Replace with custom runout_helper because of state specific behavior + insert_gcode = ("%s SENSOR=%s%s" % (INSERT_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if insert else None + remove_gcode = ("%s SENSOR=%s%s" % (REMOVE_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if remove else None + runout_gcode = ("%s SENSOR=%s%s" % (RUNOUT_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if runout else None + clog_gcode = ("%s SENSOR=%s%s" % (CLOG_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if clog else None + tangle_gcode = ("%s SENSOR=%s%s" % (TANGLE_GCODE, name, (" GATE=%d" % gate) if gate is not None else "")) if tangle else None + ro_helper = MmuRunoutHelper( + self.printer, + sensor, + event_delay, + { + "insert": insert_gcode, + "remove": remove_gcode, + "runout": runout_gcode, + "clog": clog_gcode, + "tangle": tangle_gcode, + }, + insert_remove_in_print, + button_handler, + switch_pin + ) + fs.runout_helper = ro_helper + fs.get_status = ro_helper.get_status + self.sensors[name] = fs + + + def _is_empty_pin(self, switch_pin): + if switch_pin == '': return True + ppins = self.printer.lookup_object('pins') + pin_params = ppins.parse_pin(switch_pin, can_invert=True, can_pullup=True) + pin_resolver = ppins.get_pin_resolver(pin_params['chip_name']) + real_pin = pin_resolver.aliases.get(pin_params['pin'], '_real_') + return real_pin == '' + + + def _sync_tension_callback(self, eventtime, t_sensor_name, tension_state, runout_helper): + """ + Button event handler for sync-feedback tension switch + """ + from .mmu import Mmu # For sensor names + c_sensor_name = t_sensor_name.replace(Mmu.SENSOR_TENSION, Mmu.SENSOR_COMPRESSION) + compression_sensor = self.printer.lookup_object("filament_switch_sensor %s" % c_sensor_name, None) + compression_enabled = compression_sensor.runout_helper.sensor_enabled if compression_sensor else False + compression_state = compression_sensor.runout_helper.filament_present if compression_enabled else False + + if compression_enabled: + event_value = 0 if tension_state == compression_state else (-1 if tension_state else 1) # {-1,0,1} + else: + event_value = -tension_state # {0,-1} + + # Send event now so it is processed as early as possible + self.printer.send_event("mmu:sync_feedback", eventtime, event_value) + + + def _sync_compression_callback(self, eventtime, c_sensor_name, compression_state, runout_helper): + """ + Button event handler for sync-feedback compression switch + """ + from .mmu import Mmu + t_sensor_name = c_sensor_name.replace(Mmu.SENSOR_COMPRESSION, Mmu.SENSOR_TENSION) + tension_sensor = self.printer.lookup_object("filament_switch_sensor %s" % t_sensor_name, None) + tension_enabled = tension_sensor.runout_helper.sensor_enabled if tension_sensor else False + tension_state = tension_sensor.runout_helper.filament_present if tension_enabled else False + + if tension_enabled: + event_value = 0 if compression_state == tension_state else (1 if compression_state else -1) # {-1,0,1} + else: + event_value = compression_state # {1,0} + + # Send event now so it is processed as early as possible + self.printer.send_event("mmu:sync_feedback", eventtime, event_value) + + +def load_config(config): + return MmuSensors(config) diff --git a/klippy/extras/mmu_servo.py b/klippy/extras/mmu_servo.py new file mode 100644 index 000000000000..9783ec1f3433 --- /dev/null +++ b/klippy/extras/mmu_servo.py @@ -0,0 +1,115 @@ +# Happy Hare MMU Software +# Custom servo support that carefully synchronizes PWM changes to avoid "kickback" caused +# by a truncated final pulse with digital servos. +# All existing servo functionality is available with the addition of a 'DURATION' +# parameter for setting PWM pulse train with auto off +# +# Copyright (C) 2022-2026 moggieuk#6538 (discord) +# moggieuk@hotmail.com +# +# Based on original servo.py Copyright (C) 2017-2020 Kevin O'Connor +# +# (\_/) +# ( *,*) +# (")_(") Happy Hare Ready +# +# This file may be distributed under the terms of the GNU GPLv3 license. +# +import logging, time + +SERVO_SIGNAL_PERIOD = 0.020 +PIN_MIN_TIME = 0.100 + +class MmuServo: + def __init__(self, config): + self.printer = config.get_printer() + self.min_width = config.getfloat('minimum_pulse_width', .001, above=0., below=SERVO_SIGNAL_PERIOD) + self.max_width = config.getfloat('maximum_pulse_width', .002, above=self.min_width, below=SERVO_SIGNAL_PERIOD) + self.max_angle = config.getfloat('maximum_servo_angle', 180.) + self.angle_to_width = (self.max_width - self.min_width) / self.max_angle + self.width_to_value = 1. / SERVO_SIGNAL_PERIOD + self.last_value = self.last_value_time = 0. + initial_pwm = 0. + iangle = config.getfloat('initial_angle', None, minval=0., maxval=360.) + if iangle is not None: + initial_pwm = self._get_pwm_from_angle(iangle) + else: + iwidth = config.getfloat('initial_pulse_width', 0., minval=0., maxval=self.max_width) + initial_pwm = self._get_pwm_from_pulse_width(iwidth) + self.last_value = initial_pwm + + # 50% of the "off" period is the best place to change PWM signal + self.pwm_period_safe_offset = SERVO_SIGNAL_PERIOD - (SERVO_SIGNAL_PERIOD - self.max_width) / 2 + + # Setup mcu_servo pin + ppins = self.printer.lookup_object('pins') + self.mcu_servo = ppins.setup_pin('pwm', config.get('pin')) + self.mcu_servo.setup_max_duration(0.) + self.mcu_servo.setup_cycle_time(SERVO_SIGNAL_PERIOD) + self.mcu_servo.setup_start_value(initial_pwm, 0.) + + # Register command + servo_name = config.get_name().split()[1] + gcode = self.printer.lookup_object('gcode') + gcode.register_mux_command("SET_SERVO", "SERVO", servo_name, self.cmd_SET_SERVO, desc=self.cmd_SET_SERVO_help) + + def get_status(self, eventtime): + return {'value': self.last_value} + + def _set_pwm(self, print_time, value, duration): + if value == self.last_value: + return + + print_time = max(print_time, self.last_value_time + PIN_MIN_TIME) + pwm_start_time = self._get_synced_print_time(print_time) + if duration is None: + self.mcu_servo.set_pwm(pwm_start_time, value) + self.last_value = value + self.last_value_time = pwm_start_time + else: + # Translate duration to ticks to avoid any secondary mcu clock skew + mcu = self.mcu_servo.get_mcu() + cmd_clock = mcu.print_time_to_clock(pwm_start_time) + burst = int(duration / SERVO_SIGNAL_PERIOD) * SERVO_SIGNAL_PERIOD + cmd_clock += mcu.seconds_to_clock(max(SERVO_SIGNAL_PERIOD, burst) + self.pwm_period_safe_offset) + pwm_end_time = mcu.clock_to_print_time(cmd_clock) + # Schedule PWM burst + self.mcu_servo.set_pwm(pwm_start_time, value) + self.mcu_servo.set_pwm(pwm_end_time, 0.) + # Update time tracking + self.last_value = 0. + self.last_value_time = pwm_end_time + + # Return a print_time that is a safe place to change PWM signal + def _get_synced_print_time(self, print_time): + if self.last_value != 0.: # If servo already off time syncing is not necessary + skew = (print_time - self.last_value_time) % SERVO_SIGNAL_PERIOD + print_time -= skew # Align on previous SERVO_SIGNAL_PERIOD boundary + print_time += self.pwm_period_safe_offset + return print_time + + def _get_pwm_from_angle(self, angle): + angle = max(0., min(self.max_angle, angle)) + width = self.min_width + angle * self.angle_to_width + return width * self.width_to_value + + def _get_pwm_from_pulse_width(self, width): + width = max(self.min_width, min(self.max_width, width)) if width else width + return width * self.width_to_value + + cmd_SET_SERVO_help = "Set servo angle" + def cmd_SET_SERVO(self, gcmd): + duration = gcmd.get_float('DURATION', None, minval=PIN_MIN_TIME) + width = gcmd.get_float('WIDTH', None) + angle = gcmd.get_float('ANGLE', None) + self.set_position(width, angle, duration) + + def set_position(self, width=None, angle=None, duration=None): + duration = max(duration, SERVO_SIGNAL_PERIOD) if duration else None + if width is not None or angle is not None: + value = self._get_pwm_from_pulse_width(width) if width is not None else self._get_pwm_from_angle(angle) + pt = self.printer.lookup_object('toolhead').get_last_move_time() + self._set_pwm(pt, value, duration) + +def load_config_prefix(config): + return MmuServo(config) diff --git a/klippy/extras/toolhead_move_patterns.py b/klippy/extras/toolhead_move_patterns.py new file mode 100644 index 000000000000..4237be802681 --- /dev/null +++ b/klippy/extras/toolhead_move_patterns.py @@ -0,0 +1,67 @@ +import logging +import math + + +class ToolheadMovePatterns: + """Nozzle cleaning implementation""" + + def __init__(self, config) -> None: + self.printer = config.get_printer() + self.reactor = self.printer.get_reactor() + self.gcode = self.printer.lookup_object("gcode") + self.toolhead = None + self.printer.register_event_handler("klippy:ready", self.handle_ready) + + self.gcode.register_command( + "CIRCUMFERENCE", self.cmd_CIRCUMFERENCE, self.cmd_CIRCUMFERENCE_helper + ) + + self.gcode.register_command( + "ELLIPSE", self.cmd_ELLIPSE, self.cmd_ELLIPSE_helper + ) + + def handle_ready(self) -> None: + """Handle klippy ready event""" + self.toolhead = self.printer.lookup_object("toolhead") + + def move_toolhead(self, x, y, speed) -> None: + self.toolhead.manual_move([x, y], speed) + self.toolhead.wait_moves() + + cmd_CIRCUMFERENCE_helper = "Moves the toolhead in a circle" + + def cmd_CIRCUMFERENCE(self, gcmd) -> None: + center_x = gcmd.get("CENTER_X", 0, parser=float) + center_y = gcmd.get("CENTER_Y", 0, parser=float) + radius = gcmd.get("RADIUS", 0, parser=float, minval=0) + vel = gcmd.get("VELOCITY", 50, parser=int, minval=50) + iterations = gcmd.get("ITERATIONS", 5, parser=int, minval=1) + for _ in range(0, iterations): + for angle in range(0, 360, 20): + x_position = center_x + radius * math.cos(math.radians(angle)) + y_position = center_y + radius * math.sin(math.radians(angle)) + self.move_toolhead(x_position, y_position, vel) + + cmd_ELLIPSE_helper = "Moves the toolhead in a ellipse" + + def cmd_ELLIPSE(self, gcmd) -> None: + """Move the toolhead in a ellipse + + h = center x + k = center y + """ + center_h = gcmd.get("H", 0, parser=float) + center_k = gcmd.get("K", 0, parser=float) + x_width = gcmd.get("X_WIDTH", 0, parser=float) + y_width = gcmd.get("Y_WIDTH", 0, parser=float) + vel = gcmd.get("VELOCITY", 50, parser=int) + iterations = gcmd.get("ITERATIONS", 5, parser=int, minval=1) + for _ in range(0, iterations): + for angle in range(0, 360, 10): + x_position = center_h + x_width * math.cos(math.radians(angle)) + y_position = center_k + y_width * math.sin(math.radians(angle)) + self.move_toolhead(x_position, y_position, vel) + + +def load_config(config): + return ToolheadMovePatterns(config)