Skip to content

FluidNC Module β€” Change Tool Screen

The Change Tool Screen is the FluidNC-specific UI for managing CNC tool changes from the pendant. It provides two operating modes β€” a full motorized tool change via FluidNC’s atc_manual macro (M6T) and an instant in-place tool registration (M61Q) β€” plus live machine and work position readouts and an optional post-change probing workflow.


  1. Module Overview
  2. Architecture Position
  3. Core Abstractions
  4. Component Architecture
  5. Data Flow & Value Subscriptions
  6. Position Display & Throttle
  7. User Interaction Model
  8. Screen Lifecycle
  9. Memory Safety
  10. Key Constants & Timeouts
  11. Dependencies

PropertyValue
Filemain/display/cnc/fluidnc/screens/change_tool_screen.cpp
NamespacechangeToolScreen
Public APIchangeToolScreen::create()
Return screenESP3DScreenType::main
Firmware targetFluidNC only

The screen is a FluidNC-specific implementation. Equivalent screens for grbl and grblHAL firmwares live in their own sibling modules. All three share the CNC-shared infrastructure described in cnc_shared.md.


UI Framework (UIManager, GenericScreen, PanelComponent, VirtualButtonsComponent)
|
+--> changeToolScreen::create() [this module]
|
+--> FirmwareStatusComponent [cnc_shared.md]
+--> ConnectionStatusComponent [fluidnc_module_connection_status.md]
+--> probeScreen [fluidnc_module_probe.md]
+--> inputScreen::create() [common_screens.md]
|
+--> ESP3DValues (subscribe) [Core_Platform_&_Infrastructure.md]
+--> esp3dGcodeHandler [CNC_Firmware_Integration.md]

The screen is reached from main_screen.cpp (see cnc_shared.md β€” navigation_screens) via createScreen(ESP3DScreenType::change_tool).


The screen owns a local ToolChangeState enum that drives all UI updates, button states, and GCode dispatch. The state machine advances exclusively through firmware status callbacks and user actions β€” never by polling.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ create() β”‚
β–Ό β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”Œβ”€β”€β”€β”€β–Άβ”‚ IDLE │◀── recovery (fw β†’ IDLE after HOLD/error) β”‚
β”‚ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚ Button1 (START, CHANGE mode) β”‚
β”‚ β”‚ M6T<target> sent β”‚
β”‚ β–Ό β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Button1 (CANCEL) β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ MOVING_TO_CHANGE │─────────────────────▢│ CANCELLED β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚ fw β†’ HOLD β”‚
β”‚ β”‚ (atc_manual pauses) β”‚
β”‚ β–Ό β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Button1 (CANCEL) β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ WAITING_USER │─────────────────────▢│ CANCELLED β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚ Button1 (RESUME) β”‚
β”‚ β”‚ GRBL_RT_CYCLE_START sent β”‚
β”‚ β”‚ fw β†’ RUN β”‚
β”‚ β–Ό β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Button1 (CANCEL) β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ PROBING │─────────────────────▢│ CANCELLED β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚ fw β†’ IDLE β”‚
β”‚ β–Ό β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Button1 (SET mode) β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ SUCCESS │◀───────────────────────│ IDLE β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ M61Q sent β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚
β”‚ ALARM / timeout (60s) β†’ FAILED from any active state β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recovery path: When last_firmware_state contained HOLD or error and the firmware transitions to IDLE, any FAILED or CANCELLED state is automatically cleared back to IDLE (or back to FAILED if target equals current tool in CHANGE mode).

Timeout handling: If the 60-second timeout fires while the firmware is still busy, pending_timeout_revert is set and the FAILED state is applied on the next firmware status callback rather than immediately, to avoid a race between the revert command and any in-flight firmware state change.


ModeGCode sentFluidNC behaviourMovement
SET (M61Q)M61Q<num>Registers tool number in parser state onlyNone
CHANGE (M6T)M6T<num>Triggers atc_manual macro: moves to change position, pauses in HOLD, resumes after user confirmation, probes with ETSYes

The mode button label reflects the active mode:

  • SET mode: label shows M61Q
  • CHANGE mode: label shows TX > where X is the current tool number

Tapping the mode button (or pressing Button 0 while mode is focused) toggles instantly between the two modes. The toggle is always instant-action β€” it never enters encoder editing mode.


The interactive panel contains exactly two items managed by PanelComponent:

Constantpanel_id variableWidgetAction
ITEM_ID_MODE = 0panel_id_modemode_btnToggle SET / CHANGE mode
ITEM_ID_TARGET = 1panel_id_targettarget_btnSelect target tool (0–255)

Both items are disabled whenever the state is non-editable (MOVING_TO_CHANGE, WAITING_USER, PROBING) or while awaiting_probe_decision is true.


GenericScreen (screen_instance)
β”‚
β”œβ”€β”€ Container (lv_obj_t)
β”‚ β”‚
β”‚ β”œβ”€β”€ createToolSelectorHeader()
β”‚ β”‚ mode_btn ──┐
β”‚ β”‚ target_btn ─┴── PanelComponent (2 items)
β”‚ β”‚
β”‚ β”œβ”€β”€ createPositionDisplay()
β”‚ β”‚ MPos title β”‚ WPos title
β”‚ β”‚ X: [mpos] β”‚ [wpos]
β”‚ β”‚ Y: [mpos] β”‚ [wpos]
β”‚ β”‚ Z: [mpos] β”‚ [wpos]
β”‚ β”‚
β”‚ └── createStatusBar()
β”‚ status_label
β”‚ status_spinner (hidden unless MOVING/PROBING)
β”‚ └── createProbePromptButtons() (hidden until M61Q succeeds)
β”‚ probe_btn β”‚ ignore_btn
β”‚
β”œβ”€β”€ Overlays (on container)
β”‚ β”œβ”€β”€ FirmwareStatusComponent
β”‚ └── ConnectionStatusComponent
β”‚
└── Bottom button bar (GenericScreen)
Button 0: ok_b
Button 1: change_b / stop_b (dynamic)
Button 2: back_b

All UI state is held in static file-scope variables (single-instance screen):

GroupKey variables
Screen/component instancesscreen_instance, panel_component, firmware_status_component, connection_status_component
State trackingcurrent_state, current_mode, current_tool, target_tool, previous_tool, saved_target_tool
Firmware trackinglast_firmware_state, pending_timeout_revert
Probe flowawaiting_probe_decision, returning_from_probe
Position throttlelast_wpos_display_update_ms, last_mpos_display_update_ms, wpos_update_pending, mpos_update_pending
Timerstimeout_timer, position_flush_timer, transition_timer, cleanup_timer

The screen subscribes to 8 indices in the ESP3DValues observable store during create() and unsubscribes in prepareForDestruction().

IndexCallbackEffect
parser_stateon_parser_state_updateExtracts T value β†’ updates current_tool (editable states only)
firmware_statuson_firmware_status_updateDrives the full state machine
position_wx/wy/wzon_positions_update (WPos)Updates WPos labels with 250 ms throttle
position_mx/my/mzon_positions_update (MPos)Updates MPos labels with 250 ms throttle
server_statuson_connection_status_updateLogged only (no UI action)
Received firmware status string
β”‚
β”œβ”€β”€ pending_timeout_revert == true?
β”‚ YES β†’ setToolChangeState(FAILED, timeout msg), clear flag, return
β”‚
β”œβ”€β”€ current_state == MOVING_TO_CHANGE?
β”‚ "HOLD" in value β†’ β†’ WAITING_USER ("Install tool TN")
β”‚
β”œβ”€β”€ current_state == WAITING_USER?
β”‚ "RUN" in value β†’ β†’ PROBING
β”‚
β”œβ”€β”€ current_state == PROBING?
β”‚ "IDLE" in value β†’ β†’ SUCCESS
β”‚ current_tool = target_tool
β”‚ auto-advance: target_tool = current_tool + 1
β”‚
β”œβ”€β”€ "ALARM" in value AND state is non-editable?
β”‚ revertToolToFirmware(), β†’ FAILED, cancel timeout_timer
β”‚
└── (was HOLD/error) AND (now IDLE) AND (state is FAILED/CANCELLED)?
β†’ IDLE (or FAILED if same-tool conflict in CHANGE mode)

The helper extract_tool_from_parser_state() calls esp3dGcodeHandler.extractValue(str, 'T') to parse the T field from the FluidNC GCode parser state string (e.g., "G0 G54 G17 T2 F0 S0"). The result updates current_tool only when the screen is in an editable state.


The position display shows Machine Position (MPos) and Work Position (WPos) for X, Y, Z simultaneously. Because the firmware emits all six axes in rapid succession within a single status report cycle, a two-tier throttle prevents excessive LVGL label updates.

Tier 1 β€” per-callback gate (250 ms)

on_positions_update(idx, value)
β”‚
β”œβ”€β”€ idx is wx/wy/wz? β†’ WPos group, check last_wpos_display_update_ms
β”‚ └── interval < 250ms β†’ wpos_update_pending = true, return (skip)
β”‚ └── interval >= 250ms β†’ read all 3 WPos from store, update labels, reset timestamp
β”‚
└── idx is mx/my/mz? β†’ MPos group, check last_mpos_display_update_ms
└── interval < 250ms β†’ mpos_update_pending = true, return (skip)
└── interval >= 250ms β†’ read all 3 MPos from store, update labels, reset timestamp

Tier 2 β€” flush timer (300 ms)

position_flush_timer_cb() [runs every 300ms]
β”‚
└── wpos_update_pending OR mpos_update_pending?
YES β†’ clear both flags, call updatePositionDisplay() (reads all 6 values from store)
NO β†’ no-op

Why separate timestamps and flags per group: handle() dispatches all position callbacks from one status report in the same LVGL tick. A shared timestamp would allow the WPos pass-through to reset the gate for MPos, starving one group. Separate state per group ensures each advances independently.

Why read from the store rather than the callback value: When the first callback of a group passes the throttle gate, the remaining two axes of that group would be blocked. Re-reading from esp3dXValues.get_value() inside the passing callback ensures all three labels are updated atomically from the latest stored values.


Button configuration is dynamic β€” it changes on every setToolChangeState() and updateVirtualButtons() call:

StateButton 0Button 1Button 2
IDLE / SUCCESS / FAILED / CANCELLEDok_b enabledchange_b enabled (if valid op)back_b enabled
MOVING_TO_CHANGE / PROBINGdisabledstop_b enabled (CANCEL)disabled
WAITING_USERdisabledchange_b enabled (RESUME)disabled
Awaiting probe decisionall disabledall disabledall disabled

A β€œvalid operation” for CHANGE mode requires current_tool != target_tool. SET mode is always valid.

Button 0 (OK) β€” toggles encoder between PANEL_NAVIGATION_MODE and PANEL_EDITING_MODE for the TARGET item; instantly toggles mode when MODE item is focused.

Button 1 β€” context-sensitive:

Button 1 released
β”‚
β”œβ”€β”€ awaiting_probe_decision β†’ ignored
β”‚
β”œβ”€β”€ editable state (IDLE/SUCCESS/FAILED/CANCELLED)
β”‚ β”œβ”€β”€ CHANGE mode
β”‚ β”‚ β”œβ”€β”€ current_tool == target_tool β†’ FAILED (same tool error)
β”‚ β”‚ └── else β†’ startToolChange() [M6T<target>]
β”‚ └── SET mode β†’ executeSetCurrentTool() [M61Q<target>]
β”‚
β”œβ”€β”€ WAITING_USER β†’ send GRBL_RT_CYCLE_START β†’ PROBING
β”‚
└── MOVING_TO_CHANGE or PROBING β†’ stopToolChange()
revertToolToFirmware() [M61Q<previous_tool>]
β†’ CANCELLED

Button 2 (BACK) β€” blocked during MOVING_TO_CHANGE, WAITING_USER, PROBING, and awaiting_probe_decision. Otherwise triggers the standard ESP3D_TRANSITION_START cleanup sequence back to RETURN_SCREEN (main).

EventItemEffect
ON_ACTIVE on MODEmode buttonToggle CHANGE ↔ SET instantly; stay in navigation mode
ON_ACTIVE on TARGETtarget buttonOpen inputScreen for numeric entry (0–255, max 3 digits)
ON_CHANGE (encoder rotate) on TARGETtarget buttonIncrement/decrement target_tool (wraps 0↔255) β€” only in editing mode
ON_FOCUSeitherLogged only

After a successful M61Q (SET mode), the screen overlays two buttons and disables everything else to offer an optional tool-length probe:

executeSetCurrentTool()
β”‚
β”œβ”€β”€ M61Q<target> sent
β”œβ”€β”€ current_tool = target_tool (immediate, no wait)
└── showProbePrompt(true)
awaiting_probe_decision = true
Probe btn + Ignore btn shown
status_label = "probe prompt" translation
all panel items disabled
all virtual buttons disabled
User taps "Probe tool" User taps "Ignore"
β”‚ β”‚
β”œβ”€β”€ showProbePrompt(false) β”œβ”€β”€ showProbePrompt(false)
β”œβ”€β”€ returning_from_probe = true └── β†’ SUCCESS ("Current tool set to TX")
β”œβ”€β”€ prepareForDestruction()
β”œβ”€β”€ navigate to probeScreen
β”‚ probeScreen::setReturnScreen(change_tool)
β”‚ probeScreen::clearResult()
β”‚
└── (back in create() next cycle)
handleProbeScreenReturn()
getResult() == SUCCESS β†’ β†’ SUCCESS ("Probe success")
getResult() == FAILED β†’ β†’ FAILED ("Probe failed")
getResult() == CANCELLED/NONE β†’ β†’ SUCCESS ("Probe cancelled β€” tool set")

  1. ESP3D_SAVE_SOUND_STATE_AND_DISABLE β€” mutes audio during construction
  2. Destroy any pre-existing screen_instance (guard against double-create)
  3. Reset transition flags via ESP3D_TRANSITION_RESET
  4. Read parser_state from store β†’ initialize current_tool
  5. Restore target_tool from saved_target_tool if applicable (preserves user’s selection across FAILED/CANCELLED cycles)
  6. new (std::nothrow) GenericScreen with 3 virtual button configs
  7. createToolSelectorHeader() β†’ PanelComponent with 2 items
  8. createPositionDisplay() β†’ 6 position labels
  9. createStatusBar() β†’ status label + spinner + probe prompt buttons (hidden)
  10. new (std::nothrow) FirmwareStatusComponent + ConnectionStatusComponent
  11. Start position_flush_timer (300 ms)
  12. Subscribe to 8 ESP3DValues indices
  13. Initialize position labels from current store values
  14. setToolChangeState(IDLE or FAILED) based on current firmware state
  15. handleProbeScreenReturn() β€” handles result if returning from probe screen
  16. ESP3D_RESTORE_SOUND_STATE

Destruction (prepareForDestruction() + onScreenDestroy())

Section titled β€œDestruction (prepareForDestruction() + onScreenDestroy())”

prepareForDestruction() is guarded by cleanup_executed and is_prepared_for_destruction to prevent double-execution. It:

  1. Saves target_tool β†’ saved_target_tool (for FAILED/CANCELLED; clears on SUCCESS)
  2. Unsubscribes all 8 ESP3DValues indices
  3. Calls prepareForDestruction() on all three components
  4. Deletes timeout_timer and position_flush_timer
  5. Removes LVGL encoder event callback
  6. Calls GenericScreen::prepareForDestruction()
  7. Nulls all UI element pointers (prevents dangling LVGL object references)

onScreenDestroy() (LVGL LV_EVENT_DELETE handler) then deletes the C++ objects (panel_component, firmware_status_component, connection_status_component, screen_instance) and calls ui_manager.unregisterScreen().

saved_target_tool (static, initialized to -1) bridges consecutive create() calls. After FAILED or CANCELLED, the value is saved so the user does not have to re-enter their target. On SUCCESS it is cleared to -1 so the next open starts fresh with current_tool + 1.


This module follows the project’s embedded memory safety rules (see docs/guides/esp32_memory_constraints.md):

PatternWhere applied
new (std::nothrow)All four heap-allocated objects (GenericScreen, PanelComponent, FirmwareStatusComponent, ConnectionStatusComponent)
Null + validity check after every allocationif (!ptr || !ptr->isValid()) guard, log error, delete, set nullptr, return or degrade
lv_obj_is_valid() before every LVGL accessAll UI element pointer dereferences
Fixed-size snprintf buffersAll string formatting β€” no std::string in hot paths
No uncaught exceptions in LVGL contextDegraded-overlay pattern: failed overlay is skipped silently rather than throwing
Double-free guardcleanup_executed flag prevents prepareForDestruction() from running twice
Encoder callback cleanuplv_obj_remove_event_cb() called in prepareForDestruction() before the LVGL object is deleted

ConstantValuePurpose
TOOL_CHANGE_TIMEOUT_MS60 000 msAuto-cancel M6T operation and revert tool if firmware does not complete
POSITION_DISPLAY_MIN_INTERVAL_MS250 msMinimum interval between label updates per axis group (WPos and MPos tracked separately)
Flush timer period300 msposition_flush_timer period (throttle interval + 50 ms margin)
RETURN_SCREENESP3DScreenType::mainNavigation target for Button 2 (BACK)
Tool number range0 – 255Valid range for current_tool and target_tool (uint8_t), wraps on encoder rotate
Input editor max length3 digitsEnforced by inputScreen for numeric tool entry

ModuleRole
fluidnc_module_connection_status.mdConnectionStatusComponent and ConnectionStatusConfig β€” overlay showing transport / server link state
cnc_shared.md β€” firmware_statusFirmwareStatusComponent and FirmwareStatusConfig β€” overlay showing live FluidNC status string
fluidnc_module_probe.mdOptional post-change tool length probe; result consumed via probeScreen::getResult() and probeScreen::clearResult()
common_screens.md β€” modal dialogsinputScreen::create() for numeric tool number entry (0–255, max 3 digits)
ui_components.mdGenericScreen, PanelComponent, VirtualButtonsComponent, RotaryBaseComponent
Core_Platform_&_Infrastructure.md β€” valuesESP3DValues observable store β€” subscribe, unsubscribe, get_value
CNC_Firmware_Integration.md β€” cnc_fluidncesp3dGcodeHandler.sendGcode() dispatches M6T, M61Q, GRBL_RT_CYCLE_START; extractValue() parses parser state
Core_Platform_&_Infrastructure.md β€” translationsesp3dTranslationService.translate() for all UI label strings
ui_core.mdUIManager (ui_manager) β€” screen registration, style helpers, orientation angle
  • Tool changer detection ($CD). The screen calls esp3dGcodeHandler.requestToolChangerScan() once per controller session (at creation, or when the controller connects after the screen opened). While the answer is pending (fw_tool_changer = s) Start stays disabled in M6T mode with an explanation; with 0 (no atc: / m6_macro:) M6T cannot start and the no-ATC message says why (cleared when going back to M61Q); ? (unknown) keeps M6T allowed. See cnc_fluidnc.md.
  • M6T without a tool changer. FluidNC then only updates the tool number: no motion, no HOLD, it stays Idle and pushes [GC:…T<n>]. β€œParser T == target while Idle” is treated as done once the machine stayed Idle for TOOL_CHANGE_NO_ATC_SETTLE_MS (1 s, about 5 auto-reports at 200 ms); any other status in between (an ATC starting to move or holding) cancels that decision. Nothing is reverted and the next tool is auto-suggested, like a completed change.
  • Cancel while moving. Cancel during MOVING_TO_CHANGE / PROBING sends a feed hold and requestResetBeforeResume(): the next resume becomes a soft reset, so the cancelled sequence can never restart (same as probe STOP).
  • No timeout while the user works. No timeout runs while the screen waits for the user to swap the tool (WAITING_USER); a fresh TOOL_CHANGE_TIMEOUT_MS (60 s) starts when the sequence goes on. A pending timeout ends on IDLE only: any other change (for example HOLD β†’ RUN after a late Resume) is not the end of the sequence; an ALARM ends it through the alarm handling.
  • Error display. Errors get a warning icon under the message (error color, orange for a warning); the message has a fixed width and scrolls instead of going edge to edge. Error values are matched case-insensitively (ERROR:<code> / Error:…).
  • Probe from the tool change. β€œUser stopped the probe” and β€œbacked out without probing” are reported like β€œIgnore”: M61Q already succeeded, nothing was cancelled. The probe screen opened from here measures Z and shows a hint instead of Idle.
  • Sound mute guard. SoundMuteGuard (RAII) restores the saved mute state on every exit path of create() (normal return, early return, exception), so the buzzer can never stay muted until reboot.
  • Small mm / in unit after the MPos / WPos titles (unit of the reported positions).

UX flow: change_tool_screen.md.