Skip to content

grblHAL Module — Change Tool Screen

The grblhal_module_change_tool module implements the Tool Change screen for the grblHAL firmware target. It provides a complete user interface for two distinct tool-management operations:

  • CHANGE mode (T<n> M6): Drives the full grblHAL manual tool-change protocol — sends the motion command, waits for the firmware “TOOL” paused state, acknowledges the pause, optionally probes the new tool length with the tool setter ($TPW), then resumes machining with cycle start (~).
  • SET mode (M61Q<n>): Instantly updates the firmware’s current-tool register with no motion, then optionally prompts the user to probe the new tool on the probe screen.

The screen is integrated as a child of the grblhal_module and is reachable from the main screen via the standard screen router (grblhal_module_screen_router).


Core_Platform

grblhal_shared_components

UI_Framework

grblhal_module_change_tool

change_tool_screen.cpp

ToolChangeState Machine

ToolChangeMode - CHANGE / SET

PanelComponent

Mode toggle + Target tool

Position Display

MPos / WPos X·Y·Z

Status Bar + Spinner

Probe Prompt Buttons

GenericScreen

VirtualButtonsComponent

PanelComponent

inputScreen - numeric editor

FirmwareStatusComponent

ConnectionStatusComponent

probeScreen

ESP3DValues - Observable Store

esp3dGcodeHandler

ESP3DTranslationService


The CHANGE mode implementation faithfully follows the grblHAL manual tool-change protocol:

T<n> M6
│
└─► grblHAL enters "TOOL" paused state
│
└─► Pendant sends 0xA3 realtime ack
│
└─► User physically installs tool
│
├─► (Optional) Pendant sends $TPW
│ └─► Tool-setter probe cycles
│
└─► Pendant sends ~ (cycle start)
│
└─► grblHAL returns to IDLE

Important: grblHAL uses the standard T<n> M6 syntax. The FluidNC-only M6T<n> form is not used here. See fluidnc_module_change_tool for the FluidNC counterpart.


Panel Item 0 - Mode Toggle

Encoder tap or OK button

M61Q (SET mode)

M61Q<n> - no motion

Updates firmware tool register only

T<n> > (CHANGE mode)

T<n> M6 - full protocol

Physical tool swap required

ModeGCode SentMotionProtocol StepsProbe Option
SET (M61Q)M61Q<n>NoneInstant register updateYes — navigates to probeScreen via probe prompt
CHANGE (T<n> M6)T<n> M6YesFull 5-step protocolBuilt-in $TPW during WAITING_USER state

The screen owns a single-threaded state machine (ToolChangeState) driven exclusively by firmware status callbacks running on the LVGL thread (Core 1).

create()

Button1 (CHANGE mode)\nstartToolChange() - T<

Button1 (SET mode)\nexecuteSetCurrentTool() - M61Q sent

FW status contains "TOOL"\ntimeout timer cancelled

Button1 Cancel\nstopToolChange() - soft reset

Button1 ACK\nsendToolAck() - 0xA3 sent

Button0 Probe\nsendToolProbe() - $TPW sent

Button1 Finish\ncycle start ~ sent

FW returns to "TOOL"\nafter motion was observed

Button1 Cancel

FW status contains "IDLE"\ncurrent_tool updated

Button1 START again

Button1 START again

Button1 START again

ALARM or 60 s timeout

ALARM

ALARM or 60 s timeout

ALARM or 60 s timeout

IDLE

MOVING_TO_CHANGE

;n>

M6

sent

SUCCESS

WAITING_ACK

CANCELLED

WAITING_USER

PROBING

COMPLETING

FAILED

StateDescription
IDLEReady. Panel and target tool selector are editable. Both modes available.
MOVING_TO_CHANGET<n> M6 sent. Waiting for firmware to reach “TOOL” paused state. 60 s timeout active. Cancel available.
WAITING_ACKFirmware paused in “TOOL” state. Only the ACK action (0xA3) is offered. No timeout — user-driven.
WAITING_USERAck sent. User must physically install the new tool. $TPW (probe) and ~ (finish) are both available.
PROBING$TPW sent. Waiting for the tool-setter probe cycle to complete. Detected when firmware re-enters “TOOL” state after having left it. 60 s timeout active.
COMPLETINGCycle start ~ sent. Waiting for firmware to return to “IDLE”.
SUCCESSOperation completed. current_tool updated to target_tool. Panel re-enabled.
FAILEDOperation failed (alarm, timeout, or same-tool error). Panel re-enabled for retry.
CANCELLEDUser aborted. Soft reset issued. Tool number reverted in firmware via M61Q<previous_tool>.

Container children

GenericScreen - ESP3DScreenType::change_tool

lv_screen root

Rotatable container

VirtualButtonsComponent

3 hardware-mapped buttons

Tool Selector Header

mode_btn + target_btn

managed by PanelComponent

Position Display

MPos X/Y/Z · WPos X/Y/Z

6 live labels

Status Bar

status_label + spinner

probe_prompt_container

FirmwareStatusComponent

(overlay)

ConnectionStatusComponent

(overlay)

The PanelComponent manages two interactive items inside the Tool Selector Header:

ConstantPanel RoleDefault LabelInteraction
ITEM_ID_MODE (panel_id_mode)Mode toggle buttonM61Q or T<n> >Encoder tap → toggle CHANGE ↔ SET instantly
ITEM_ID_TARGET (panel_id_target)Target tool buttonT<n>Encoder scroll → increment/decrement · tap → opens numeric inputScreen (range 0–255)

Encoder navigation cycles focus between these two items. In SET mode the mode button label reads M61Q; in CHANGE mode it reads T<n> > where <n> is current_tool.


The screen subscribes to the following ESP3DValues observables on creation and unsubscribes in prepareForDestruction():

Value IndexCallbackPurpose
parser_stateon_parser_state_updateReads T field to track current_tool number
firmware_statuson_firmware_status_updatePrimary state machine driver (TOOL / IDLE / ALARM / HOLD transitions)
position_wx position_wy position_wzon_positions_updateUpdates WPos display (throttled per group)
position_mx position_my position_mzon_positions_updateUpdates MPos display (throttled per group)
server_statuson_connection_status_updateConnection monitoring (logged; overlays handled by ConnectionStatusComponent)

Position updates arrive at high frequency from the firmware status poll. A two-level throttle prevents excessive LVGL redraws while guaranteeing eventual consistency.

WPos

MPos

Yes

No

Yes

No

Flush Timer - 300 ms period

Yes

No

position_flush_timer_cb

wpos_pending or

mpos_pending?

updatePositionDisplay

all 6 labels from store

no-op

on_positions_update callback

(one of 6 axis indices)

WPos or MPos

group?

now − last_wpos_ms

< 250 ms?

now − last_mpos_ms

< 250 ms?

wpos_update_pending = true

return early

Update all 3 WPos labels

from values store

last_wpos_ms = now

mpos_update_pending = true

return early

Update all 3 MPos labels

from values store

last_mpos_ms = now

Design rationale: WPos and MPos maintain separate timestamps and separate pending flags because all six position callbacks fire in the same LVGL tick (from a single firmware status report). A shared timestamp would starve whichever group fires second; a shared flag would be cleared when one group passes the gate while the other is still pending.


Button labels and enabled states change dynamically based on current_state and firmware status. All updates go through updateVirtualButtons().

StateButton 0 (Left)Button 1 (Center)Button 2 (Right)
IDLE / SUCCESS / FAILED / CANCELLEDok_b ✓change_b (enabled only if fw=IDLE and valid op)back_b ✓
MOVING_TO_CHANGE / PROBING / COMPLETING—stop_b Cancel—
WAITING_ACK—ok_b ACK (0xA3)—
WAITING_USERzero_b $TPW probeplay_b Finish (~)—
Awaiting probe decisiondisableddisableddisabled

The center button is additionally disabled in CHANGE mode when current_tool == target_tool (would be a no-op change).


All commands are routed through esp3dGcodeHandler (see cnc_grblhal):

OperationCommandESP3DCommandTypePriority
Start tool changeT<n> M6normaldefault
Acknowledge pause0xA3 (GRBLHAL_RT_TOOL_ACK)realtimehigh
Tool-setter probe$TPWnormaldefault
Resume machining~ (GRBL_RT_CYCLE_START)realtimehigh
Abort / cancel0x18 (GRBL_RT_SOFT_RESET)realtimehigh
Set tool registerM61Q<n>normaldefault
Revert after cancel/timeoutM61Q<previous_tool>normaldefault

probeScreeninputScreenchange_tool_screenUserprobeScreeninputScreenchange_tool_screenUserTap target_btninputScreen::create(numeric, 0-255)inputEditorCallback(value)target_tool updatedSET mode → Button1 STARTexecuteSetCurrentTool() - M61Q sentshowProbePrompt(true)"Probe Tool" button in promptsetReturnScreen(change_tool)\nclearResult()\ncreate()handleProbeScreenReturn()getResult() mapped to\nSUCCESS / FAILED / SUCCESS(cancelled)

When the user taps “Ignore” in the probe prompt, the operation stays SUCCESS (M61Q already succeeded) without navigating away.


A 60-second LVGL timer (TOOL_CHANGE_TIMEOUT_MS = 60 000 ms) is created when:

  • T<n> M6 is sent (MOVING_TO_CHANGE state)
  • $TPW is sent (PROBING state)
  • Cycle start ~ is sent (COMPLETING state)

On expiry:

  1. Timer is deleted.
  2. revertToolToFirmware() sends M61Q<previous_tool>.
  3. If firmware is already IDLE → FAILED state set immediately.
  4. If firmware is still busy → pending_timeout_revert = true; the FAILED state is applied on the next on_firmware_status_update call.

The timeout timer is cancelled when:

  • Firmware naturally reaches WAITING_ACK (M6 motion phase complete — user-driven from here).
  • Firmware returns to IDLE (COMPLETING → SUCCESS).
  • $TPW probe completes (PROBING → WAITING_USER).
  • User triggers stopToolChange() (Cancel button).

Yes

No - already editable

Yes

Yes

No

No

FW status contains 'ALARM'

Current state is

an active operation?

revertToolToFirmware()

M61Q<previous_tool>

setToolChangeState(FAILED)

No state change

Previous state: HOLD / TOOL / error

New state: IDLE

State is CANCELLED

or FAILED?

CHANGE mode and

current == target?

FAILED - same tool error

IDLE - Ready


createScreen(ESP3DScreenType::change_tool)

changeToolScreen::create()

Cleanup existing instance if present

Reset transition + cleanup state flags

Read current_tool from parser_state

Restore saved_target_tool if re-entering

GenericScreen construction

3 virtual button configs

createToolSelectorHeader()

PanelComponent + 2 items

createPositionDisplay()

6 axis labels

createStatusBar()

status_label + spinner

probe_prompt_container

FirmwareStatusComponent (std::nothrow)

ConnectionStatusComponent (std::nothrow)

Start position_flush_timer

Subscribe to 9 value indices

Seed position display from values store

setToolChangeState() - determine initial state

handleProbeScreenReturn() if returning

Screen live - event loop

User or FW event

Back or transition triggered

prepareForDestruction()

unsubscribe all · delete timers · reset pointers

cleanup_timer_cb → transition_timer_cb

createScreen(next_screen_target)

onScreenDestroy()

delete components · delete screen_instance

On create(), the initial ToolChangeState is determined as follows:

Firmware StatusConditionInitial StateMessage
Contains "TOOL"AnyWAITING_ACKRe-entered with pending grblHAL tool change
Not "IDLE" or unavailableAnyFAILEDFirmware busy or disconnected
"IDLE"CHANGE mode and current_tool == target_toolFAILEDSame-tool error
"IDLE"OtherwiseIDLEReady
GuardPurpose
is_prepared_for_destructionPrevents double prepareForDestruction() calls
cleanup_executedPrevents double cleanup timer execution
ESP3D_PREPARE_DESTRUCTION_GUARD macroAtomically sets both flags at function entry
std::nothrow on all newAllocation failure degrades gracefully — no uncaught exception in LVGL context
Null and lv_obj_is_valid() checksSafe after partial construction or after prepareForDestruction()

The screen uses static-local variables to preserve context when navigating away to a sub-screen (input editor or probe screen) and returning:

VariablePreservedCleared When
saved_target_toolYes — set in prepareForDestruction()Successful completion, or normal IDLE restore
current_toolYes — staticUpdated live by on_parser_state_update
previous_toolYes — staticOverwritten at each startToolChange() call
returning_from_probeYes — staticCleared in handleProbeScreenReturn()
current_modeYes — staticNever cleared; persists across sessions
last_firmware_stateYes — staticUpdated on each on_firmware_status_update call
first_init (local static)Once onlyNever — controls first-time target default

Module / ComponentRoleReference
GenericScreenBase screen, container, virtual button infrastructureui_core
PanelComponentMode toggle and target tool selector with encoder navigationui_components
FirmwareStatusComponentFirmware state overlay at top of containerfirmware_status
ConnectionStatusComponentConnection state overlaygrblhal_module_connection_status
probeScreenOptional tool-length probing after SET modegrblhal_module_probe
inputScreenNumeric editor for target tool number (0–255)common_screens
ESP3DValuesObservable store: firmware_status, parser_state, positionsvalues
esp3dGcodeHandlerSends all GCode and realtime commands to the firmwarecnc_grblhal
ESP3DTranslationServiceAll user-visible stringstranslations
esp3d_screen_type.cppcreateScreen() router that instantiates this screengrblhal_module_screen_router

FileDescription
main/display/cnc/grblhal/screens/change_tool_screen.cppFull implementation — changeToolScreen namespace
main/display/cnc/grblhal/screens/change_tool_screen.hPublic API: changeToolScreen::create()
main/display/cnc/grblhal/screens/esp3d_screen_type.cppScreen router: maps ESP3DScreenType::change_tool → changeToolScreen::create()

The tool-change screen exists in all three CNC firmware targets. The grblHAL variant is differentiated by the grblHAL-specific 0xA3 realtime acknowledge command and the $TPW tool-setter probe integration:

TargetModuleKey Differentiator
grblHALgrblhal_module_change_tool (this document)T<n> M6 + 0xA3 ack + optional $TPW + ~ resume
grblgrbl_module_change_toolSame structure; different realtime constants
FluidNCfluidnc_module_change_toolM6T<n> syntax; different state detection logic
  • No timeout while the user works. No timeout runs while the screen waits for the user to swap the tool (WAITING_USER / WAITING_ACK); 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); 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.