Skip to content

Jog Screen

The jog screen (main/display/cnc/screens/jog_screen.cpp, namespace jogScreen) is the primary CNC manual motion control interface. It provides real-time axis position display, step-based and continuous jogging, homing, and work-coordinate zeroing — all driven by a rotary encoder, a 4-position hardware switch (or touch), and three virtual buttons.

The screen is shared across all supported CNC firmware targets (FluidNC, grbl, grblHAL). It lives in the cnc_shared module group and is reachable from the main screen. It returns to ESP3DScreenType::main on Back.


Input Hardware

Core Services

jogScreen namespace

UI Framework

switch_event_cb

encoder_event_handler

onButton*Press/Release

on_positions_update

on_axis_count_update

on_connection_status_update

on_planner_buffer_update

on_pins_state_update

UIManager

(screen lifecycle, theming, lock)

GenericScreen

(LVGL screen + container)

PanelComponent

(encoder-driven item list)

VirtualButtonsComponent

(3 bottom buttons)

FirmwareStatusComponent

(top-right status bar)

ConnectionStatusComponent

(top-left transport icon)

create()

updateAxisDisplay()

updatePositionDisplay()

executeJog()

startContinuousJog()

stopContinuousJog()

updateJogUIMode()

AxisValues map

(step / feedrate per axis)

ESP3DValues

(observable system)

ESP3DSettings

(NVS settings)

ESP3DGCodeHostService

(gcode pipeline)

esp3dGcodeHandler

(send gcode)

ESP3DTranslationService

4-pos switch

(axis selector / lock)

Rotary encoder

Touch / virtual buttons


┌──────────────────────────────────────────────┐
│ [ConnStatus] [FirmwareStatus] │
│ │
│ ┌──────┐ │
│ │ X │ ← Axis chip/button │
│ └──────┘ │
│ ┌──────────────────────────────────────┐ │
│ │ 123.456 │ │ ← WPos (big label)
│ │ MPos:125.000 │ │ ← MPos (small label)
│ └──────────────────────────────────────┘ │
│ ┌─────────────┐ ┌─────────────────────┐ │
│ │ 1.000mm │ │ 3000mm/min │ │ ← Step / Feedrate buttons
│ └─────────────┘ └─────────────────────┘ │
│ ████████████████████████████░░░░░░░░░░░░░ │ ← Planner buffer bar
│ │
│ [X][Y][Z] [A][B][C] │ ← Limit/probe pin indicators
│ │
│ [home] [homeAll] [zero] │ ← Home / Zero buttons
│──────────────────────────────────────────────│
│ [OK/Jog-] [mode] [Back/Jog+] │ ← Virtual buttons
└──────────────────────────────────────────────┘
ElementLVGL objectDescription
Axis chipbtnAxe + axe_labelShows current axis letter (X/Y/Z/A/B/C) or lock icon; tappable to cycle axis (touch-only) or cycle extended axes (hardware switch + >4 axes)
Position buttonposition_btnContains WPos big label and MPos small label; is also the jog panel item. a14: unit badge (mm / in of the reported positions, degrees for a rotary axis) top right and active WCS badge (G54…, --- while unknown) bottom right; the button is widened for the badges and slides while the custom edit button is shown
Step buttonstep_btnCycles through preset step sizes + Custom slot; is the step panel item
Feedrate buttonfeedrate_btnCycles through preset feedrates + Custom slot; is the feedrate panel item
Planner barplanner_progress_barColour-coded bar showing free planner buffer (green/yellow/red)
Pin indicatorspins_labels[0..5]Square chips for X/Y/Z (linear row) and A/B/C (rotary row); hidden unless that pin is active
Home buttonhome_btnHomes the current single axis ($H{axis}); hidden when single-axis homing is disabled
Home All buttonhome_all_btnHomes all axes ($H); hidden when no axis has a homing cycle
Zero buttonzero_btnZeroes WPos for the current axis with G10 L20 P0 {axis}0 (a14, was G92): the zero is set in the active WCS and stored by the controller; G92 is a temporary offset added to every WCS, lost at power off on grbl. Same as the probe screen, FluidDial and ESP3D-WebUI
WCS button (a14)—Each press activates the next work coordinate system (G54 → G59 → G54), sent at once like Home / Zero. Its text is the WCS the next press sends; disabled unless idle, connected, unlocked and no job running (gcStateScreen::canChangeModalState()); a WCS sent but not yet reported by [GC:] lets a fast second press continue from it
Stop Jog button (a14)—Replaces Home / Home All / WCS / Zero while the machine state is Jog or Home (shown even when locked). In Jog: purge the queued jog lines, then a single realtime 0x85. In Home: soft reset (homing ignores jog cancel and feed hold), same sequence as the status screen STOP; the icon is then a stop
Edit indicatoredit_btnSmall edit symbol; appears above Step or Feedrate when on the Custom slot in editing mode; tapping or pressing OK launches the numeric input
Virtual buttonsVirtualButtonsComponent3 buttons whose meaning changes with JogUIMode

The centre virtual button (Button 1) cycles through three modes on each release. The mode governs what the left (Button 0) and right (Button 2) virtual buttons do.

create()

Button 1 released\nor OK pressed on jog item

Button 1 released

Button 1 released

Back released → return to main screen

Btn0 press = Jog-\nBtn2 press = Jog+

Btn0 hold = continuous Jog-\nBtn2 hold = continuous Jog+\nBtn0/2 release = stop jog

NAVIGATION

JOG_SEQUENTIAL

JOG_CONTINUOUS

ModeButton 0Button 1 (centre)Button 2
NAVIGATIONOK — toggle encoder editing mode; or enter jog mode when jog item is focusedMode cycleBack
JOG_SEQUENTIALJog − (single step on press)Mode cycleJog + (single step on press)
JOG_CONTINUOUSJog − (large distance on hold; stops on release)Mode cycleJog + (large distance on hold; stops on release)

LVGL constraint: Button press/release callbacks run on Core 1 inside the LVGL task. startContinuousJog() and stopContinuousJog() send GCode via esp3dGcodeHandler and must never block the LVGL thread. See screens_architecture.md for the threading model.


The screen supports 3 to 6 axes. A 4-position hardware switch (positions 0–3) selects the active axis. Position 3 is special:

  • axis_count <= 3: position 3 = lock state — toggles the user lock; the axis chip displays a lock icon.
  • axis_count = 4: position 3 = the 4th real axis (A, or U when grblHAL $376 renames it).
  • axis_count >= 5: position 3 = extended axis slot that cycles through A/B/C via axis chip tap.

axis_count ≤ 3

axis_count = 4

axis_count ≥ 5

Switch pos 0

Axis X (idx 0)

Switch pos 1

Axis Y (idx 1)

Switch pos 2

Axis Z (idx 2)

Switch pos 3

User lock toggle

Axis A (idx 3)

Ext. slot - cycles A→B→C via chip tap

Touch-only boards (!ESP3D_HARDWARE_SWITCH_FEATURE): tapping the axis chip cycles through all real axes in ordinal order (X→Y→Z→A→B→C→X). The lock position is excluded from the touch cycle.

axisLetter(real_idx) reads ESP3DValuesIndex::axis_names from the values system. This string is populated by grblHAL when $376 renames axes (e.g. "XYZUVW"). FluidNC and standard grbl fall back to "XYZABC". Both the displayed axis chip and the jog GCode command letter use the same source, so the letter is always firmware-accurate.


Each real axis (indices 0–5) has an AxisValues entry in axis_values_map:

struct AxisValues {
std::vector<std::string> step_values; // preset list from settings (e.g. "0.01;0.1;1;10")
std::vector<std::string> feedrate_values; // preset list from settings
size_t current_step_index; // 0..step_values.size() (last = Custom slot)
size_t current_feedrate_index;
std::string step_unit; // "mm" / "in" / "deg"
std::string feedrate_unit; // "mm/min" / "in/min" / "deg/min"
std::string custom_step_value; // session-only typed override; never persisted
std::string custom_feedrate_value;
};
AxisStep presets (string)Feedrate presets (string)Saved step idx (byte)Saved feedrate idx (byte)
Xesp3d_x_jog_stepsesp3d_x_jog_feedratesesp3d_x_jog_step_idxesp3d_x_jog_feedrate_idx
Yesp3d_y_jog_stepsesp3d_y_jog_feedratesesp3d_y_jog_step_idxesp3d_y_jog_feedrate_idx
Zesp3d_z_jog_stepsesp3d_z_jog_feedratesesp3d_z_jog_step_idxesp3d_z_jog_feedrate_idx
Aesp3d_a_jog_stepsesp3d_a_jog_feedratesesp3d_a_jog_step_idxesp3d_a_jog_feedrate_idx
Besp3d_b_jog_stepsesp3d_b_jog_feedratesesp3d_b_jog_step_idxesp3d_b_jog_feedrate_idx
Cesp3d_c_jog_stepsesp3d_c_jog_feedratesesp3d_c_jog_step_idxesp3d_c_jog_feedrate_idx

Preset strings are semicolon-separated. Since a14 the linear axes lists hold one section per work unit ("mm;0.01;0.1;1;10;50;100;in;0.001;0.01;0.1;1;2;4"), and the screen uses the section of the active jog unit as is, with no conversion (see jog_unit_lists.md); rotary axes use a plain list in degrees. The lists are reloaded when the jog unit changes (indices restored from their saved settings, then clamped). The selected index byte is persisted to NVS on every encoder step. Indices are validated at load time and reset to 0 if out of bounds, handling preset list changes across firmware upgrades cleanly. The axis_values_map is not cleared across screen transitions unless axis_count changes, preserving user-selected indices when navigating back to the jog screen.

The cycler includes one extra slot past the presets (index == step_values.size()). When selected:

  • The edit indicator (EDIT) appears above the active Step or Feedrate button.
  • Pressing OK, or tapping the indicator, opens the input_screen.md for floating-point input.
  • The typed value is stored in custom_step_value / custom_feedrate_value — session-only, never written to NVS.
  • If no value has been typed yet, the first preset is used as the actual jog value so the machine never receives an empty command string.

Jog unit (a14): for linear axes, the work unit G20/G21 (current_unit), or the report unit ($13 / report_inches, report_unit) when esp3d_jog_position_unit is on (read at each screen creation; the settings screen recreates the jog screen on return). Rotary axes always use degrees / degrees per minute. A change of the work unit (or of the report unit in position-unit mode) reloads the lists and labels, so the displayed unit always matches what the firmware applies (a 25.4× mistake otherwise).


Builds and sends:

$J=G91[unit]{axis}{sign}{step}F{feedrate}

[unit] (jogUnitWord(), non-modal inside a $J): rotary axis → always G21 (degrees are never scaled, while a modal G20 could scale them on a firmware treating every axis as linear); linear axis → none in classic mode (the modal G20/G21 applies), the report unit word in position-unit mode.

Before sending, the planner buffer (ESP3DValuesIndex::buffer_blocks) is compared against ESP3D_PLANNER_BUFFER_SIZE_THRESHOLD = 5. If available blocks are at or below this threshold the command is silently dropped to avoid overflowing the firmware’s motion planner.

Continuous Jog (startContinuousJog / stopContinuousJog)

Section titled “Continuous Jog (startContinuousJog / stopContinuousJog)”

Sends a single $J=G91 with a very large distance so the machine jogs until explicitly cancelled:

stopContinuousJog()CNC Firmwareesp3dGcodeHandlerstartContinuousJog()onButton0/2PressUser (button hold)stopContinuousJog()CNC Firmwareesp3dGcodeHandlerstartContinuousJog()onButton0/2PressUser (button hold)alt[duration >= 1000 ms][duration < 1000 ms]Button pressedstartContinuousJog(direction)GRBL_RT_JOG_CANCEL (realtime, high priority)purgeByRequestId(ESP3D_JOG_REQUEST_ID)esp3d_hal::wait(20 ms)$J=G91{axis}[sign]10000F{feedrate}continuous motionButton releasedstopContinuousJog()GRBL_RT_JOG_CANCEL (realtime, high priority)purgeByRequestId()purgeByRequestId()GRBL_RT_JOG_CANCEL (realtime, normal)stop motion

The 20 ms delay before sending the large-distance command gives the firmware time to process the pre-start cancel and clear its internal buffer.


position_flush_timer 300msLVGL LabelsThrottle 250ms per groupon_positions_updateESP3DValuesCNC Firmwareposition_flush_timer 300msLVGL LabelsThrottle 250ms per groupon_positions_updateESP3DValuesCNC Firmwarealt[within 250 ms window][outside window]status report WPos and MPoscallback WPos group or MPos groupcheck elapsed time for this groupdrop and set pending flagupdatePositionDisplay sync stored valuepass and clear pending flaglv_label_set_text

WPos (work position) drives the large label. MPos (machine position) drives the MPos:... small label. They have independent 250 ms timestamps and pending flags because handle() dispatches all position callbacks from a single status report in the same LVGL tick — a shared timestamp would throttle the second group whenever the first passed through.

The 300 ms flush timer runs continuously and only calls updatePositionDisplay() when either pending flag is set. This guarantees the display converges to the correct value even when motion has stopped and no further status reports arrive.


The bar maps available buffer blocks to 0–100 %. Colour thresholds are computed from planner_buffer_max_size (read from ESP3DValuesIndex::planner_blocks at creation):

Available blocksBar colourDesign token
0 – 5RedESP3D_ACCENT_ALERT_COLOR
6 – (5 + max/2)YellowESP3D_ACCENT_ACTION_COLOR
Above yellow thresholdGreenESP3D_ACCENT_SELECT_COLOR

The red threshold is fixed at 5 to match ESP3D_PLANNER_BUFFER_SIZE_THRESHOLD. The yellow/green boundary divides the remaining range equally so the bar is visually proportional regardless of the firmware’s planner size.


The screen participates in two independent lock dimensions managed by UIManager:

Lock typeTriggerUIManager callVisual effect
User lockSwitch to position 3 when axis_count <= 3userSetLockState(true/false)Lock icon on axis chip; lock beep
System lockserver_status != "C" or canSendData() == falsesystemSetLockState(true/false)All jog/home/zero disabled; labels dimmed

Both combine in ui_manager.getLockState(). When locked:

  • All interactive buttons are visually disabled (LV_STATE_DISABLED).
  • Position labels are dimmed using the text_disabled opacity token.
  • The jog command path aborts before sending.
  • updateAxisDisplay() applies change detection before any LVGL calls to avoid redundant work on every status report.

on_connection_status_update schedules a 1-second deferred updateAxisDisplay() via axis_refresh_timer to batch rapid consecutive status changes and avoid LVGL thread saturation during reconnection.


Visibility is evaluated on every updateAxisDisplay() call using three helpers that read from ESP3DValues:

HelperValues index readReturns true when
canHomeAxis(idx)homing_cycle_{x|y|z|a|b|c}Value is "1"
canHomeSingleAxis(idx)homing_single_{x|y|z|a|b|c}Value is "1"
anyAxisCanHome()Iterates all active axes via canHomeAxisAt least one axis returns true

Button layout adapts dynamically to the visible combination:

Since a14 the bottom row holds 1 to 4 buttons, left to right Home, Home All, WCS, Zero (Zero keeps its right slot): 1 = centre, 2 = left / right, 3 = left / centre / right, 4 = the free width split in 5 equal gaps (edges included: the slot margins do not leave room for 4 buttons on a 240 px wide area). The button size is defined per resolution where the default does not fit 4 buttons (res_320_240: 45 px). updateBottomButtons() has its own change detection, so it is called on every update that can change them: the homing capabilities ($/axes/*/homing/* arrive seconds after connection, possibly after the screen was built) and the machine state. While jogging or homing only Stop Jog is shown (centred).


Hardware encoder rotated while the jog item is active (handwheel_jog.md):

  • The LVGL encoder events are only the permission gate (handwheelOnGateEvent): screen awake, jog item active, mode allows it. The detents actually moved are read from the encoder driver (absolute position sampled every 10 ms, nothing lost, see physical_input.md).
  • Sensitivity 0 (classic, esp3d_jog_wheel_sensitivity, read at screen entry): one $J per batch, distance = detents × step, at the selected feedrate. Every detent is kept even when the machine cannot keep up, and stopping the wheel cancels nothing: 1 detent = 1 full step.
  • Sensitivity 1–100: the feedrate follows the wheel (wheel speed × step, capped at the selected feedrate); above a speed threshold (log-interpolated between sensitivity 1 and 100, with hysteresis and confirmation; calibrated 2026-10-19: slow 10–20, normal 20–80, fast 290–700 detents/s) a detent is worth the next step of the axis list; the distance is capped to what the machine can follow (estimated backlog) and detents beyond are dropped. Nothing is left queued, so stopping the wheel stops the axis without a jog cancel.
  • Reversal (any sensitivity): jog cancel 0x85 + purge of the queued jog lines, then the opposite batch goes out on a later tick (the controller needs about 20 ms to process the cancel, deferredJogSendCb). A reversal is treated as an emergency reflex (field report 2026-10-14).
  • Detents that cannot be sent now (planner buffer full, cancel just sent) are kept and retried on the next tick while the gate is armed.
  • Planner bar: the denominator (planner_blocks) is resynchronized on every update, since it may arrive after the screen was created; an unknown max shows the neutral select color instead of red.
  • Updates arriving right after unsubscribe() are ignored (same guard as on_axis_count_update).
  • The initial focus is restored with setFocusedItem(id, true) (no beep at creation).
  • The WCS badge is refreshed on each opening (grbl / grblHAL only report the parser state at connection; $G is re-sent when connected).

The PanelComponent manages three encoder-navigable items. See screens_architecture.md for the full PanelComponent contract.

ItemLVGL objectPanel mode interactionEncoder action
step_item_idstep_btnNAVIGATION → EDITING (OK)Cycle step preset (±1 slot, wraps to Custom)
feedrate_item_idfeedrate_btnNAVIGATION → EDITING (OK)Cycle feedrate preset (±1 slot, wraps to Custom)
jog_item_idposition_btnAlways EDITING when focusedExecute single jog in encoder direction

The onPanelEvent() unified callback handles all panel events and applies automatic mode switching:

  • Focusing jog_item_id while in NAVIGATION → auto-switch to JOG_SEQUENTIAL.
  • Focusing step_item_id or feedrate_item_id while in JOG_SEQUENTIAL → auto-switch back to NAVIGATION.
  • suppress_focus_event_ prevents re-entrancy during programmatic transitions.

Touch-only boards (!ESP3D_HARDWARE_ENCODER_FEATURE): the encoder is emulated when a panel item is active. setEncoderEmulation(true) remaps Button 1 to CW step and Button 2 to CCW step, swapping icons accordingly. See input_emulation.md for the full emulation architecture.


ESP3DValues subscriptions active while screen is alive

axis_count

on_axis_count_update

pin_states

on_pins_state_update

buffer_blocks

on_planner_buffer_update

server_status

on_connection_status_update

position_wx/wy/wz/wa/wb/wc

on_positions_update

position_mx/my/mz/ma/mb/mc

on_positions_update

initializeAxisValues()

updateAxisDisplay()

updatePinsStates()

planner_progress_bar value + colour

UIManager system lock

axis_refresh_timer

position_big_label (WPos)

position_small_label (MPos)

All subscriptions are cleaned up in prepareForDestruction(). Each callback guards against post-unsubscribe delivery (a queued value snapshot may arrive after unsubscribe() returns) with a jog_screen_obj_instance->isValid() guard at entry.


GenericScreenLVGL timer phase 2LVGL timer phase 1jogScreen createmain_screen / UIManagerGenericScreenLVGL timer phase 2LVGL timer phase 1jogScreen createmain_screen / UIManagerScreen fully interactivecreate()ESP3D_TRANSITION_RESETinitializeAxisValues()createScreenInstance() GenericScreen + PanelComponentcreateAxisButton, createPositionButton, createPinsContainersregisterScreen UIManager + onScreenDestroy CBlv_timer_create deferredFirmwareStatusComponent, ConnectionStatusComponent, step/feedrate buttons, planner barlv_timer_create deferredhome/zero/edit buttons, setupPanelItems, setupEventCallbacks, setupSubscriptions, updateAxisDisplayBack button releasedcleanup_timer_cb prepareForDestructionunsubscribe all, delete components, remove callbacksLVGL LV_EVENT_DELETE fires onScreenDestroydelete jog_screen_obj_instance, unregister from UIManager

Creation is split across two deferred LVGL timer phases (ESP3D_DEFERED_SCREEN_CREATION_DELAY_MS between each) to stay within budget and avoid starving other LVGL tasks during the initialization burst.

Focus and editing state (last_focused_item_id, last_focused_was_editing) survive screen transitions. Returning from the Custom-value input screen restores the previously active panel item and its editing mode exactly as it was before the transition.


User input

jog_screen callbacks

ESP3DValues observable

parsed status reports

parsed status reports

parsed status reports

parsed status reports

parsed status reports

parsed status reports

read on-demand by canHomeAxis

ON_CHANGE step

ON_CHANGE feedrate

ON_CHANGE jog

JOG_SEQUENTIAL Btn0/2 press

JOG_CONTINUOUS Btn0/2 press-release

CNC Firmware

(grbl / FluidNC / grblHAL)

server_status

axis_count

position_wx..wc / position_mx..mc

buffer_blocks

pin_states

homing_cycle_* / homing_single_*

on_connection_status_update - system lock

on_axis_count_update - re-init axis values

on_positions_update - WPos/MPos labels throttled

on_planner_buffer_update - progress bar

on_pins_state_update - pin indicators

home button visibility

Hardware switch - switch_event_cb

Encoder - encoder_event_handler

Virtual buttons - onButton0/1/2 Press/Release

Touch axis chip - onAxisButtonEvent

updateAxisDisplay()

PanelComponent - onPanelEvent()

updateJogUIMode()

updateStepValue(direction)

updateFeedrateValue(direction)

executeJog()

startContinuousJog()

stopContinuousJog()

esp3dGcodeHandler

$J=G91{axis}{sign}{step}F{feedrate}


ConstantValuePurpose
ESP3D_PLANNER_BUFFER_SIZE_THRESHOLD5Minimum free planner blocks before a jog command is sent
CONTINUOUS_JOG_DISTANCE_LINEAR_MM10 000 mmDistance sent for continuous linear (X/Y/Z) jog
CONTINUOUS_JOG_DISTANCE_ROTARY_DEG36 000 degDistance sent for continuous rotary (A/B/C) jog
POSITION_DISPLAY_MIN_INTERVAL_MS250 msPer-group throttle interval for WPos/MPos label updates
position_flush_timer period300 msTimer that syncs labels after a throttled update when motion stops
Continuous jog stop strategy threshold1 000 msDuration above which realtime cancel is preferred over queue purge