# Interactively identify printer axis motors via web ui prompts # # Copyright (C) 2026 the Klipper contributors # # This file may be distributed under the terms of the GNU GPLv3 license. import re AXES = "xyz" STEPPER_NAME_R = re.compile(r'(?i)^stepper_([a-z])(\d*)$') # Stepper pins that identify which motor/driver socket drives the rail. # When the changes are written through the SAVE_CONFIG mechanism these # pins are swapped between the stepper sections (the sections keep their # names, so all changes end up in the "#*#" block at the end of the # config file) MOTOR_OPTIONS = ('step_pin', 'dir_pin', 'enable_pin') class AxisDiscovery: def __init__(self, config): self.printer = printer = config.get_printer() self.gcode = printer.lookup_object('gcode') self.wizard = None # Register g-code commands handlers = ['AXIS_DISCOVERY', 'AXIS_DISCOVERY_NEXT', 'AXIS_DISCOVERY_REPEAT', 'AXIS_DISCOVERY_ANSWER', 'AXIS_DISCOVERY_APPLY', 'AXIS_DISCOVERY_CANCEL'] for cmd in handlers: func = getattr(self, 'cmd_' + cmd) desc = getattr(self, 'cmd_' + cmd + '_help', None) self.gcode.register_command(cmd, func, desc=desc) printer.register_event_handler("klippy:disconnect", self._handle_disconnect) def _handle_disconnect(self): self.wizard = None # Prompt emission (compatible with the web ui "action:prompt_*" protocol) def _prompt_action(self, action, msg=None): self.gcode.respond_raw("// action:prompt_%s%s" % (action, "" if msg is None else " " + msg)) def _prompt_begin(self, headline): self._prompt_action("begin", headline) def _prompt_text(self, msg): self._prompt_action("text", msg) def _prompt_button(self, msg): self._prompt_action("button", msg) def _prompt_footer_button(self, msg): self._prompt_action("footer_button", msg) def _prompt_show(self): self._prompt_action("show") def _prompt_end(self): self._prompt_action("end") # Wizard helpers def _check_not_printing(self): vsd = self.printer.lookup_object('virtual_sdcard', None) if vsd is not None and vsd.is_active(): raise self.printer.command_error( "Command not available during a print") def _get_wizard(self, gcmd): if self.wizard is None: raise gcmd.error( "No axis discovery in progress (run AXIS_DISCOVERY first)") return self.wizard def _lookup_motors(self): toolhead = self.printer.lookup_object('toolhead') kin = toolhead.get_kinematics() if kin.__class__.__name__ != 'CartKinematics': raise self.printer.command_error( "AXIS_DISCOVERY supports [cartesian] kinematics only") rails = getattr(kin, 'rails', None) if rails is None or len(rails) != 3: raise self.printer.command_error( "AXIS_DISCOVERY is not supported with a dual carriage") raw_config = self.printer.lookup_object('configfile').get_status( 0.)['config'] motors = [] for rail, axis in zip(rails, AXES): for mcu_stepper in rail.get_steppers(): name = mcu_stepper.get_name() m = STEPPER_NAME_R.match(name) if m is None or m.group(1).lower() != axis: raise self.printer.command_error( "Unexpected stepper section '%s'" % (name,)) dir_pin = raw_config.get(name, {}).get('dir_pin', '') motors.append({ 'name': name, 'axis': axis, 'suffix': m.group(2), 'inverted': str(dir_pin).strip().startswith('!'), 'answer': None, }) return motors def _move_current(self, wizard): self._check_not_printing() motor = wizard['motors'][wizard['index']] force_move = self.printer.lookup_object('force_move') stepper_enable = self.printer.lookup_object('stepper_enable') mcu_stepper = force_move.lookup_stepper(motor['name']) did_enable = stepper_enable.set_motors_enable([motor['name']], True) try: force_move.manual_move(mcu_stepper, wizard['distance'], wizard['speed'], wizard['accel']) finally: if did_enable: stepper_enable.set_motors_enable([motor['name']], False) def _show_question(self, wizard): motor = wizard['motors'][wizard['index']] self._prompt_begin("Axis Discovery (%d/%d)" % (wizard['index'] + 1, len(wizard['motors']))) self._prompt_text("Moving %s in the positive direction..." % (motor['name'],)) self._prompt_text("Which motor position moved and in which" " direction?") for axis in AXES: slots = [m for m in wizard['motors'] if m['axis'] == axis] slots.sort(key=lambda m: (len(m['suffix']), m['suffix'])) for slot in slots: short = axis.upper() + slot['suffix'] slot_param = "" if slot['suffix']: slot_param = " SLOT=%s" % (slot['suffix'],) self._prompt_action("button_group_start") self._prompt_button( "%s+|AXIS_DISCOVERY_ANSWER I=%d AXIS=%s%s DIR=+" % (short, wizard['index'], axis, slot_param)) self._prompt_button( "%s-|AXIS_DISCOVERY_ANSWER I=%d AXIS=%s%s DIR=-" % (short, wizard['index'], axis, slot_param)) self._prompt_action("button_group_end") self._prompt_footer_button("Move again|AXIS_DISCOVERY_REPEAT") self._prompt_footer_button("Cancel|AXIS_DISCOVERY_CANCEL") self._prompt_show() def _calc_changes(self, wizard): motors = wizard['motors'] # Determine the target motor position of each motor. Answers # without an explicit SLOT are assigned to the remaining free # positions of their axis. taken = set() targets = [None] * len(motors) for i, motor in enumerate(motors): axis, suffix, sign = motor['answer'] if suffix is not None: targets[i] = (axis, suffix) taken.add(targets[i]) for i, motor in enumerate(motors): if targets[i] is not None: continue axis = motor['answer'][0] free = sorted( [m['suffix'] for m in motors if m['axis'] == axis and (axis, m['suffix']) not in taken], key=lambda s: (len(s), s)) if not free: raise self.printer.command_error( "Axis %s has no free motor positions left" % (axis.upper(),)) targets[i] = (axis, free[0]) taken.add(targets[i]) rename_map = {} invert_new = set() info = [] for i, motor in enumerate(motors): t_axis, t_suffix = targets[i] sign = motor['answer'][2] new_name = "stepper_%s%s" % (t_axis, t_suffix) label = motor['name'] if new_name != motor['name']: rename_map[motor['name'].lower()] = new_name.lower() label += " -> " + new_name if sign < 0: invert_new.add(new_name.lower()) label += " (dir inverted)" info.append(label) return rename_map, invert_new, info def _show_summary(self, wizard): rename_map, invert_new, info = self._calc_changes(wizard) self._prompt_begin("Axis Discovery - confirm changes") if not rename_map and not invert_new: self._prompt_text("No changes required.") else: for line in info: self._prompt_text(line) self._prompt_text("The changes are saved to the SAVE_CONFIG" " block at the end of the config file.") self._prompt_footer_button( "Apply & Restart|AXIS_DISCOVERY_APPLY") self._prompt_footer_button("Cancel|AXIS_DISCOVERY_CANCEL") self._prompt_show() # G-code commands cmd_AXIS_DISCOVERY_help = "Interactively identify axis motors" def cmd_AXIS_DISCOVERY(self, gcmd): self._check_not_printing() distance = gcmd.get_float('DISTANCE', 10., above=0., maxval=100.) speed = gcmd.get_float('SPEED', 25., above=0., maxval=200.) accel = gcmd.get_float('ACCEL', 0., minval=0.) motors = self._lookup_motors() # Manual stepper moves invalidate the toolhead position toolhead = self.printer.lookup_object('toolhead') toolhead.get_kinematics().clear_homing_state(AXES) self.wizard = {'motors': motors, 'index': -1, 'spec': {}, 'unspecified': {}, 'distance': distance, 'speed': speed, 'accel': accel} self._prompt_begin("Axis Discovery") self._prompt_text("Detected motors:") for motor in motors: self._prompt_text("- %s (axis %s, dir_pin %s)" % (motor['name'], motor['axis'].upper(), "inverted" if motor['inverted'] else "normal")) self._prompt_text("Each motor is moved %.1fmm in the positive" " direction. Watch which carriage moves and where" " it goes." % (distance,)) self._prompt_text("Homing is invalidated, the printer must be" " re-homed after discovery.") self._prompt_footer_button("Start|AXIS_DISCOVERY_NEXT I=0") self._prompt_footer_button("Cancel|AXIS_DISCOVERY_CANCEL") self._prompt_show() cmd_AXIS_DISCOVERY_NEXT_help = "Move the next motor and request input" def cmd_AXIS_DISCOVERY_NEXT(self, gcmd): wizard = self._get_wizard(gcmd) expected = gcmd.get_int('I', None) if expected is not None and expected != wizard['index'] + 1: # Stale prompt button (eg, clicked twice); ignore it return if wizard['index'] + 1 >= len(wizard['motors']): self._show_summary(wizard) return wizard['index'] += 1 self._move_current(wizard) self._show_question(wizard) cmd_AXIS_DISCOVERY_REPEAT_help = "Repeat the move of the current motor" def cmd_AXIS_DISCOVERY_REPEAT(self, gcmd): wizard = self._get_wizard(gcmd) if wizard['index'] < 0 or wizard['index'] >= len(wizard['motors']): raise gcmd.error("No motor is currently being identified") self._move_current(wizard) self._show_question(wizard) cmd_AXIS_DISCOVERY_ANSWER_help = \ "Report the observed motor position and direction" def cmd_AXIS_DISCOVERY_ANSWER(self, gcmd): wizard = self._get_wizard(gcmd) expected = gcmd.get_int('I', None) if expected is not None and expected != wizard['index']: raise gcmd.error( "Stale axis discovery answer (motor index %d expected)" % (wizard['index'],)) if wizard['index'] < 0 or wizard['index'] >= len(wizard['motors']): raise gcmd.error("No motor is awaiting an answer") motor = wizard['motors'][wizard['index']] axis = gcmd.get('AXIS').lower() if axis not in AXES: raise gcmd.error("Invalid AXIS '%s'" % (axis,)) suffix = gcmd.get('SLOT', None) if suffix is not None and (axis, suffix) not in [ (m['axis'], m['suffix']) for m in wizard['motors']]: raise gcmd.error("Invalid SLOT '%s' for axis %s" % (suffix, axis.upper())) direction = gcmd.get('DIR') if direction == '+': sign = 1 elif direction == '-': sign = -1 else: raise gcmd.error("Invalid DIR '%s' (must be + or -)" % (direction,)) # Check that the target motor position is still free and that the # axis does not receive more motors than it has positions used = len([1 for (a, s) in wizard['spec'] if a == axis]) \ + wizard['unspecified'].get(axis, 0) avail = len([1 for m in wizard['motors'] if m['axis'] == axis]) if used >= avail: raise gcmd.error( "Axis %s has no free motor positions left" % (axis.upper(),)) if suffix is None: wizard['unspecified'][axis] = \ wizard['unspecified'].get(axis, 0) + 1 else: if (axis, suffix) in wizard['spec']: raise gcmd.error( "Motor stepper_%s%s has already been assigned" % (axis, suffix)) wizard['spec'][(axis, suffix)] = motor motor['answer'] = (axis, suffix, sign) wizard['index'] += 1 if wizard['index'] < len(wizard['motors']): self._move_current(wizard) self._show_question(wizard) else: self._show_summary(wizard) cmd_AXIS_DISCOVERY_APPLY_help = "Apply axis discovery changes and restart" def cmd_AXIS_DISCOVERY_APPLY(self, gcmd): wizard = self._get_wizard(gcmd) for motor in wizard['motors']: if motor['answer'] is None: raise gcmd.error("Motor %s has not been identified yet" % (motor['name'],)) self._check_not_printing() rename_map, invert_new, info = self._calc_changes(wizard) if not rename_map and not invert_new: self.wizard = None self._prompt_end() gcmd.respond_info("Axis discovery: no changes required") return self._prompt_end() # The stepper sections keep their names and the motor/driver pins # are swapped between them, so all changes end up in the "#*#" # block at the end of the config file touched = self._apply_autosave(rename_map, invert_new, wizard['motors']) self.wizard = None gcmd.respond_info("Axis discovery: changes saved to the" " SAVE_CONFIG block, restarting...") try: self.gcode.run_script_from_command("SAVE_CONFIG") except self.printer.command_error as e: # Do not leave pending entries behind (they would otherwise be # applied by any later SAVE_CONFIG command) self._discard_autosave(touched) raise gcmd.error("Unable to save via SAVE_CONFIG: %s" % (e,)) cmd_AXIS_DISCOVERY_CANCEL_help = "Abort the axis discovery wizard" def cmd_AXIS_DISCOVERY_CANCEL(self, gcmd): self.wizard = None self._prompt_end() gcmd.respond_info("Axis discovery cancelled (re-home the printer)") # SAVE_CONFIG support def _source_of(self, rename_map, motors): # Map each stepper section to the section whose motor/driver # options it receives (sections keep their names) source_of = {} for motor in motors: new_name = rename_map.get(motor['name'].lower(), motor['name']) source_of[new_name.lower()] = motor['name'] return source_of def _raw_section(self, raw, name): if name in raw: return raw[name] return raw.get(name.lower(), {}) def _tmc_sections(self, raw, sec): out = {} for key, opts in raw.items(): parts = key.split(' ', 1) if (len(parts) == 2 and parts[0].lower().startswith('tmc') and parts[1].strip().lower() == sec.lower()): out[parts[0]] = opts return out def _autosave_ready(self, rename_map, invert_new, motors): # Returns None when the changes can be written through the # SAVE_CONFIG mechanism, otherwise the reason they cannot cfgname = self.printer.get_start_args()['config_file'] files = {} try: self._read_config_file(cfgname, files, set()) except self.printer.command_error as e: return str(e) main_path = os.path.abspath(cfgname) include_sections = set() for fname, lines in files.items(): if fname == main_path: continue for line in lines: hm = SECTION_HEADER_R.match(line) if hm is not None: include_sections.add(hm.group(2).strip().lower()) raw = self.printer.lookup_object('configfile').get_status( 0.)['config'] for slot, src in self._source_of(rename_map, motors).items(): for name in set([slot, src]): if name.lower() in include_sections: return ("section [%s] is defined in an included config" " file" % (name,)) ssec = self._raw_section(raw, slot) osec = self._raw_section(raw, src) for opt in MOTOR_OPTIONS: if (opt in ssec) != (opt in osec): return ("option '%s' is not defined in both [%s] and" " [%s]" % (opt, slot, src)) s_tmc = self._tmc_sections(raw, slot) o_tmc = self._tmc_sections(raw, src) if sorted(s_tmc) != sorted(o_tmc): return ("[%s] and [%s] do not use the same tmc driver" " sections" % (slot, src)) for chip in s_tmc: if sorted(s_tmc[chip]) != sorted(o_tmc[chip]): return ("[%s %s] and [%s %s] do not define the same" " options" % (chip, slot, chip, src)) return None def _apply_autosave(self, rename_map, invert_new, motors): pconfig = self.printer.lookup_object('configfile') raw = pconfig.get_status(0.)['config'] touched = [] for slot, src in self._source_of(rename_map, motors).items(): ssec = self._raw_section(raw, slot) osec = self._raw_section(raw, src) invert = slot in invert_new for opt in MOTOR_OPTIONS: if opt not in osec: continue val = str(osec[opt]).strip() if opt == 'dir_pin' and invert: val = self._invert_pin(val, slot) if val == str(ssec.get(opt, '')).strip(): # No change necessary; keep the config file untouched continue pconfig.set(slot, opt, val) touched.append((slot, opt)) s_tmc = self._tmc_sections(raw, slot) o_tmc = self._tmc_sections(raw, src) for chip, opts in o_tmc.items(): for opt, val in opts.items(): if str(val).strip() == str( s_tmc.get(chip, {}).get(opt, '')).strip(): continue pconfig.set("%s %s" % (chip, slot), opt, str(val).strip()) touched.append(("%s %s" % (chip, slot), opt)) return touched def _discard_autosave(self, touched): # Remove the entries set by a SAVE_CONFIG command that failed, so # that they do not affect any later SAVE_CONFIG command pconfig = self.printer.lookup_object('configfile') fileconfig = pconfig.fileconfig sections = set([section for section, option in touched]) for section, option in touched: if fileconfig.has_section(section): fileconfig.remove_option(section, option) if not fileconfig.options(section): fileconfig.remove_section(section) pending = dict(pconfig.status_save_pending) for section in sections: if fileconfig.has_section(section): pending[section] = { opt: fileconfig.get(section, opt) for opt in fileconfig.options(section)} else: pending.pop(section, None) pconfig.status_save_pending = pending pconfig.save_config_pending = bool(pending) def _invert_pin(self, pin, section): pin = pin.strip() if not pin: raise self.printer.command_error( "Empty dir_pin in section '%s'" % (section,)) if pin.startswith('!'): return pin[1:] return '!' + pin def load_config(config): return AxisDiscovery(config)