FluidNC Module β Change Tool Screen
Section titled β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.
Table of Contents
Section titled βTable of Contentsβ- Module Overview
- Architecture Position
- Core Abstractions
- Component Architecture
- Data Flow & Value Subscriptions
- Position Display & Throttle
- User Interaction Model
- Screen Lifecycle
- Memory Safety
- Key Constants & Timeouts
- Dependencies
Module Overview
Section titled βModule Overviewβ| Property | Value |
|---|---|
| File | main/display/cnc/fluidnc/screens/change_tool_screen.cpp |
| Namespace | changeToolScreen |
| Public API | changeToolScreen::create() |
| Return screen | ESP3DScreenType::main |
| Firmware target | FluidNC 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.
Architecture Position
Section titled βArchitecture Positionβ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).
Core Abstractions
Section titled βCore AbstractionsβTool Change State Machine
Section titled βTool Change State Machineβ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.
Tool Change Modes
Section titled βTool Change Modesβ| Mode | GCode sent | FluidNC behaviour | Movement |
|---|---|---|---|
SET (M61Q) | M61Q<num> | Registers tool number in parser state only | None |
CHANGE (M6T) | M6T<num> | Triggers atc_manual macro: moves to change position, pauses in HOLD, resumes after user confirmation, probes with ETS | Yes |
The mode button label reflects the active mode:
- SET mode: label shows
M61Q - CHANGE mode: label shows
TX >whereXis 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.
Panel Items
Section titled βPanel ItemsβThe interactive panel contains exactly two items managed by PanelComponent:
| Constant | panel_id variable | Widget | Action |
|---|---|---|---|
ITEM_ID_MODE = 0 | panel_id_mode | mode_btn | Toggle SET / CHANGE mode |
ITEM_ID_TARGET = 1 | panel_id_target | target_btn | Select 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.
Component Architecture
Section titled βComponent Architectureβ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_bStatic Variable Groups
Section titled βStatic Variable GroupsβAll UI state is held in static file-scope variables (single-instance screen):
| Group | Key variables |
|---|---|
| Screen/component instances | screen_instance, panel_component, firmware_status_component, connection_status_component |
| State tracking | current_state, current_mode, current_tool, target_tool, previous_tool, saved_target_tool |
| Firmware tracking | last_firmware_state, pending_timeout_revert |
| Probe flow | awaiting_probe_decision, returning_from_probe |
| Position throttle | last_wpos_display_update_ms, last_mpos_display_update_ms, wpos_update_pending, mpos_update_pending |
| Timers | timeout_timer, position_flush_timer, transition_timer, cleanup_timer |
Data Flow & Value Subscriptions
Section titled βData Flow & Value SubscriptionsβThe screen subscribes to 8 indices in the ESP3DValues observable store during create() and unsubscribes in prepareForDestruction().
| Index | Callback | Effect |
|---|---|---|
parser_state | on_parser_state_update | Extracts T value β updates current_tool (editable states only) |
firmware_status | on_firmware_status_update | Drives the full state machine |
position_wx/wy/wz | on_positions_update (WPos) | Updates WPos labels with 250 ms throttle |
position_mx/my/mz | on_positions_update (MPos) | Updates MPos labels with 250 ms throttle |
server_status | on_connection_status_update | Logged only (no UI action) |
on_firmware_status_update β Transition Logic
Section titled βon_firmware_status_update β Transition Logicβ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)on_parser_state_update β Tool Extraction
Section titled βon_parser_state_update β Tool Extractionβ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.
Position Display & Throttle
Section titled βPosition Display & Throttleβ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 timestampTier 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-opWhy 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.
User Interaction Model
Section titled βUser Interaction ModelβVirtual Buttons
Section titled βVirtual ButtonsβButton configuration is dynamic β it changes on every setToolChangeState() and updateVirtualButtons() call:
| State | Button 0 | Button 1 | Button 2 |
|---|---|---|---|
| IDLE / SUCCESS / FAILED / CANCELLED | ok_b enabled | change_b enabled (if valid op) | back_b enabled |
| MOVING_TO_CHANGE / PROBING | disabled | stop_b enabled (CANCEL) | disabled |
| WAITING_USER | disabled | change_b enabled (RESUME) | disabled |
| Awaiting probe decision | all disabled | all disabled | all 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>] β CANCELLEDButton 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).
Panel Encoder Interaction
Section titled βPanel Encoder Interactionβ| Event | Item | Effect |
|---|---|---|
ON_ACTIVE on MODE | mode button | Toggle CHANGE β SET instantly; stay in navigation mode |
ON_ACTIVE on TARGET | target button | Open inputScreen for numeric entry (0β255, max 3 digits) |
ON_CHANGE (encoder rotate) on TARGET | target button | Increment/decrement target_tool (wraps 0β255) β only in editing mode |
ON_FOCUS | either | Logged only |
Probe Prompt Flow
Section titled βProbe Prompt Flowβ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")Screen Lifecycle
Section titled βScreen LifecycleβCreation (create())
Section titled βCreation (create())βESP3D_SAVE_SOUND_STATE_AND_DISABLEβ mutes audio during construction- Destroy any pre-existing
screen_instance(guard against double-create) - Reset transition flags via
ESP3D_TRANSITION_RESET - Read
parser_statefrom store β initializecurrent_tool - Restore
target_toolfromsaved_target_toolif applicable (preserves userβs selection across FAILED/CANCELLED cycles) new (std::nothrow) GenericScreenwith 3 virtual button configscreateToolSelectorHeader()βPanelComponentwith 2 itemscreatePositionDisplay()β 6 position labelscreateStatusBar()β status label + spinner + probe prompt buttons (hidden)new (std::nothrow) FirmwareStatusComponent+ConnectionStatusComponent- Start
position_flush_timer(300 ms) - Subscribe to 8
ESP3DValuesindices - Initialize position labels from current store values
setToolChangeState(IDLE or FAILED)based on current firmware statehandleProbeScreenReturn()β handles result if returning from probe screenESP3D_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:
- Saves
target_toolβsaved_target_tool(for FAILED/CANCELLED; clears on SUCCESS) - Unsubscribes all 8
ESP3DValuesindices - Calls
prepareForDestruction()on all three components - Deletes
timeout_timerandposition_flush_timer - Removes LVGL encoder event callback
- Calls
GenericScreen::prepareForDestruction() - 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().
Target Tool Persistence
Section titled βTarget Tool Persistenceβ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.
Memory Safety
Section titled βMemory SafetyβThis module follows the projectβs embedded memory safety rules (see docs/guides/esp32_memory_constraints.md):
| Pattern | Where applied |
|---|---|
new (std::nothrow) | All four heap-allocated objects (GenericScreen, PanelComponent, FirmwareStatusComponent, ConnectionStatusComponent) |
| Null + validity check after every allocation | if (!ptr || !ptr->isValid()) guard, log error, delete, set nullptr, return or degrade |
lv_obj_is_valid() before every LVGL access | All UI element pointer dereferences |
Fixed-size snprintf buffers | All string formatting β no std::string in hot paths |
| No uncaught exceptions in LVGL context | Degraded-overlay pattern: failed overlay is skipped silently rather than throwing |
| Double-free guard | cleanup_executed flag prevents prepareForDestruction() from running twice |
| Encoder callback cleanup | lv_obj_remove_event_cb() called in prepareForDestruction() before the LVGL object is deleted |
Key Constants & Timeouts
Section titled βKey Constants & Timeoutsβ| Constant | Value | Purpose |
|---|---|---|
TOOL_CHANGE_TIMEOUT_MS | 60 000 ms | Auto-cancel M6T operation and revert tool if firmware does not complete |
POSITION_DISPLAY_MIN_INTERVAL_MS | 250 ms | Minimum interval between label updates per axis group (WPos and MPos tracked separately) |
| Flush timer period | 300 ms | position_flush_timer period (throttle interval + 50 ms margin) |
RETURN_SCREEN | ESP3DScreenType::main | Navigation target for Button 2 (BACK) |
| Tool number range | 0 β 255 | Valid range for current_tool and target_tool (uint8_t), wraps on encoder rotate |
| Input editor max length | 3 digits | Enforced by inputScreen for numeric tool entry |
Dependencies
Section titled βDependenciesβ| Module | Role |
|---|---|
| fluidnc_module_connection_status.md | ConnectionStatusComponent and ConnectionStatusConfig β overlay showing transport / server link state |
cnc_shared.md β firmware_status | FirmwareStatusComponent and FirmwareStatusConfig β overlay showing live FluidNC status string |
| fluidnc_module_probe.md | Optional post-change tool length probe; result consumed via probeScreen::getResult() and probeScreen::clearResult() |
| common_screens.md β modal dialogs | inputScreen::create() for numeric tool number entry (0β255, max 3 digits) |
| ui_components.md | GenericScreen, PanelComponent, VirtualButtonsComponent, RotaryBaseComponent |
| Core_Platform_&_Infrastructure.md β values | ESP3DValues observable store β subscribe, unsubscribe, get_value |
| CNC_Firmware_Integration.md β cnc_fluidnc | esp3dGcodeHandler.sendGcode() dispatches M6T, M61Q, GRBL_RT_CYCLE_START; extractValue() parses parser state |
| Core_Platform_&_Infrastructure.md β translations | esp3dTranslationService.translate() for all UI label strings |
| ui_core.md | UIManager (ui_manager) β screen registration, style helpers, orientation angle |
a14 Changes
Section titled βa14 Changesβ- Tool changer detection (
$CD). The screen callsesp3dGcodeHandler.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; with0(noatc:/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 forTOOL_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/PROBINGsends a feed hold andrequestResetBeforeResume(): 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 freshTOOL_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 ofcreate()(normal return, early return, exception), so the buzzer can never stay muted until reboot. - Small
mm/inunit after the MPos / WPos titles (unit of the reported positions).
UX flow: change_tool_screen.md.