Skip to content

grblHAL Files Screen Module

The grblHAL Files Screen (grblhal_module_files) implements the SD-card file browser for pendant firmware targets that communicate with a grblHAL controller. It is the grblHAL-specific counterpart to the equivalent screens in the FluidNC and Grbl modules, but differs fundamentally in how the file tree is obtained and navigated:

  • grblHAL lists the whole SD card flat and recursively in one $F request — no [DIR:] entries are emitted. The response is a sequence of [FILE:/absolute/path.ext|SIZE:n] lines terminated by a synthesized [FILE_LIST_END] sentinel.
  • All directory navigation happens locally on the pendant after the single initial scan. No per-directory re-queries are issued to the controller.
  • Rename is not supported by the grblHAL SD protocol; the 4-position switch’s fourth position therefore repeats Run rather than exposing a rename action.

  1. Module Position in the System
  2. File Structure
  3. Architecture Overview
  4. Core Data Structures
  5. State Machines
  6. grblHAL SD Protocol
  7. File Listing Flow
  8. Local Directory Navigation
  9. File Action Modes
  10. Component Interactions
  11. Key Functions Reference
  12. Memory Safety Strategy
  13. Dependencies

UI_Framework_&_Screens

cnc_shared

(shared CNC screens)

grblhal_module

grblhal_module_files

(current module)

grblhal_module_screen_router

grblhal_module_connection_status

grblhal_module_change_tool

grblhal_module_probe

The files screen belongs to the grblhal_module subtree inside UI_Framework_&_Screens. It is created by the screen router (grblhal_module_screen_router.md) and shares common CNC infrastructure (status screen, macro manager, message box) via cnc_shared.md.


main/display/cnc/grblhal/screens/
└── files_screen.cpp ← entire module (namespace filesScreen)
├── FileEntry data structure for display rows
├── RawFile flat-listing record from $F
├── FileListState enum – async listing state machine
├── FileActionMode enum – 4-position switch actions
├── build_current_view() local tree navigation
├── process_listing_line() line parser for $F output
└── compareFileEntries() sort comparator

Everything lives inside the filesScreen namespace, with static internal linkage. There is no public header — the screen is reached exclusively through createScreen(ESP3DScreenType::files) dispatched by the screen router.


UI Layer

grblhal_module_files

CNC Firmware Integration

grblHAL Controller

$F output lines

firmware_file_entry

callback

onFileEntryUpdate()

push_back(RawFile)

[FILE_LIST_END]

local rebuild

populates

path prefix filter

updateItemList()

stall detected

item click

confirm launch

confirm delete

item click

launch confirmed

grblHAL

$F / $F= / $FD=

esp3d_gcode_handler_service

(grblHAL target)

ESP3DValues

firmware_file_entry

firmware_status

Flat Listing

all_files_ (RawFile[])

Current View

file_entries_ (FileEntry[])

Local Navigation

current_path

process_listing_line()

build_current_view()

Watchdog Timer

(10 s stall detection)

ListMenuScreen

(common_screens)

Loading Spinner

messageBoxScreen

(confirm/cancel)

macroManager

(cnc_shared)

statusScreen

(cnc_shared)

DecisionRationale
Single $F scan, local navigationgrblHAL emits the entire tree at once — no per-directory command exists
RawFile master list kept in memoryEnables instant subdirectory traversal without controller round-trips
[FILE_LIST_END] sentinelgrblHAL’s "ok" line is intercepted by the gcode handler which synthesises this marker; the screen relies on the sentinel, not on parsing "ok"
No rename action on position 3$F protocol has no rename command; position 3 of the switch repeats Run
Watchdog timer (10 s)Guards against the controller failing to emit the end-of-listing sentinel

struct RawFile {
std::string path; // full absolute path, e.g. "/subdir/CUBE.gcode"
std::string size; // size in bytes as string (may be empty)
};
static std::vector<RawFile> all_files_;

The master flat listing — one entry per file reported by $F. Populated once per scan. Directory navigation never touches the controller again; build_current_view() derives the visible tree from this vector.

struct FileEntry {
std::string name; // display name (basename or directory component)
std::string size; // formatted size (empty for directories)
enum class Type : uint8_t {
File, Dir, Parent, Info, Error
} type;
};
static std::vector<FileEntry> file_entries_; // current directory view

The display list for the current directory. Rebuilt by build_current_view() every time the user navigates into or out of a directory. Info and Error rows are tagged non-selectable by the UI manager.

enum class FileActionMode : uint8_t {
Process_file = 0, // LV_SYMBOL_DRIVE – $F=<path>
Set_as_macro = 1, // ESP3D_SYMBOL_ROBOT – pendant-side macro tagging
Delete_file = 2, // LV_SYMBOL_TRASH – $FD=<path>
Process_file_2 = 3 // LV_SYMBOL_DRIVE – repeats Run (no rename on grblHAL)
};

Maps directly to the 4-position hardware switch position. On touch-only hardware, positions 0–2 are emulated (position 3 is suppressed).


5.1 File Listing State Machine (FileListState)

Section titled “5.1 File Listing State Machine (FileListState)”

module loaded

start_sd_listing()\n$F sent to controller

[FILE_LIST_END] received\nfinalize_listing()

[MSG:ERR:...] received\nor firmware error status

watchdog fires (10 s stall)\nno new entries

user presses Refresh\nstart_sd_listing() again

user presses Refresh

user presses Refresh

screen destroyed

screen destroyed

screen destroyed

Idle

Collecting

Done

Error

Timeout

StateMeaning
IdleInitial state, no scan started yet
CollectingReceiving [FILE:...] lines from $F; listing_active = true
DoneFull listing received; all_files_ is the authoritative source
ErrorSD card mount error or firmware error status received
TimeoutWatchdog fired; no progress for 10 consecutive seconds

The watchdog fires every 1 second (WATCHDOG_INTERVAL_MS = 1000). It compares total_entries_processed against last_entry_count. If unchanged for MAX_STALL_COUNT = 10 consecutive ticks (10 seconds), the listing is aborted with a Timeout state.

Yes

No

Yes

No

Watchdog tick

(every 1 s)

total_entries_processed

== last_entry_count?

stall_count++

stall_count = 0

last_entry_count = total

stall_count

>= 10?

list_state = Timeout

listing_active = false

show error message

show buttons


CommandPurpose
$FList all files recursively, flat format, absolute paths
$F=<absolute-path>Run (stream) a file
$FD=<absolute-path>Delete a file

Note: Rename is not supported. The 4-position switch’s third position (index 3) therefore repeats the Run action.

[FILE:/path/to/file.gcode|SIZE:315841]
[FILE:/another/file.nc|SIZE:1024]
[FILE:/deep/subdir/file.tap|SIZE:4096]
...
[FILE_LIST_END]
  • Each line begins with [FILE: and contains the full absolute path (no separate [DIR:] entries).
  • The |SIZE:n segment is optional but typically present.
  • The [FILE_LIST_END] sentinel is synthesized by the gcode handler (esp3d_gcode_handler_service for grblHAL target) when it sees the "ok" that terminates the $F command. The screen itself never parses raw "ok" lines.
[MSG:ERR: Failed to mount device]

Detected by is_error_message(). Causes immediate transition to the Error state, clearing both all_files_ and file_entries_.

Yes

No

Yes

No

Yes

Yes

No

No

Raw line from

firmware_file_entry callback

is_end_marker()?

is_error_message()?

is_file_entry()?

Ignore line

finalize_listing()

Error state

clear lists

show error UI

extract_entry_name()

extract_entry_size()

free heap

>= 30 KB?

all_files_.push_back(RawFile)

total_entries_processed++


The complete sequence from screen creation to a populated file list:

ListMenuScreenbuild_current_view()process_listing_line()ESP3DValuesGCodeHandlerServicefilesScreen::create()LVGL / UI TaskListMenuScreenbuild_current_view()process_listing_line()ESP3DValuesGCodeHandlerServicefilesScreen::create()LVGL / UI Taskloop[For each [FILE:...] line]createScreen(files)subscribe firmware_file_entrysubscribe firmware_statuscreate with "Loading..." placeholderlv_timer (500 ms delay)sendGcode("$F")start_watchdog_timer()hide action buttonscreate_loading_spinner()set firmware_file_entry = lineonFileEntryUpdate(line)extract path + sizeall_files_.push_back(RawFile)update_scan_status() labelset firmware_file_entry = "[FILE_LIST_END]"onFileEntryUpdate("[FILE_LIST_END]")finalize_listing()stop_watchdog_timer()destroy_loading_spinner()build_current_view()populates file_entries_updateItemList(file_entries_)show action buttons

all_files_ is not cleared on screen destruction or messageBox transitions. This means:

  • Returning from a confirmation dialog immediately re-displays the existing list without a re-scan.
  • all_files_ is only cleared explicitly by start_sd_listing() (new scan) or on SD error.

user selects 'subdir' dir entry

user selects 'deep' dir entry

user selects '..' parent

user selects '..' parent

/ (root)

all_files_ filtered to direct children

/subdir

files + further subdirs

/subdir/deep

files only

There are no controller requests during navigation. Every directory change rebuilds file_entries_ from all_files_ using build_current_view().

No

Yes

No

Yes

done

build_current_view()

current_path = e.g. '/subdir'

prefix = current_path + '/'

For each RawFile in all_files_

rf.path starts

with prefix?

rel = rf.path.substr(prefix.size())

rel contains '/'?

file_entries_.emplace_back(rel, rf.size, File)

dir = rel.substr(0, slash)

if not in subdirs: subdirs.push_back(dir)

For each dir in subdirs:

file_entries_.emplace_back(dir, Dir)

FunctionInputOutput
get_parent_path(path)/subdir/deep/subdir
get_parent_path(path)/subdir/
get_parent_path(path)// (no-op)
append_to_path(name)/ + "subdir"/subdir
append_to_path(name)/subdir + "deep"/subdir/deep
1. Parent (..) entry — always first
2. Error / Info placeholder rows — second
3. Files (alphabetical, case-insensitive)
4. Directories (alphabetical, case-insensitive)

The 4-position hardware switch (or 3-position touch emulation) controls what happens when the user selects a file:

screen created\n(reads initial switch position)

switch → pos 1

switch → pos 2

switch → pos 3

switch → pos 2

switch → pos 1

switch → pos 0

switch → pos 3

Process_file

Set_as_macro

Delete_file

Process_file_2

isDirty()? saveToFile()\non mode exit

Identical to Process_file\n(no rename on grblHAL)

Switch PositionIconAction on File Select
0 — Process_fileLV_SYMBOL_DRIVEShow confirmation → $F=<path> → transition to statusScreen
1 — Set_as_macroESP3D_SYMBOL_ROBOT (or LV_SYMBOL_FILE if macros disabled)Show file info / toggle macro tag via macroManager
2 — Delete_fileLV_SYMBOL_TRASHShow confirmation → $FD=<path> → update local all_files_
3 — Process_file_2LV_SYMBOL_DRIVESame as position 0 (grblHAL has no rename)
  • Dir entry: descend into directory (local, no network request).
  • Parent .. entry: ascend to parent (local).
  • Error / Info row: ignored (non-selectable).
  • Directory in Delete mode: rejected with ESP3D_ERROR_BEEP — only files can be deleted.

The footer bar always shows the current action mode icon and a translated label. On touch hardware the footer’s left zone doubles as a mode-cycle button (3 states: Run → Macro → Delete).


Core Platform

CNC Firmware Integration

common_screens

cnc_shared

grblhal_module_files

sendGcode($F, $F=, $FD=)

firmware_file_entry updates

onFileEntryUpdate callback

on_firmware_status_update callback

create / updateItemList

show_confirmation

show_information

show_error

isMacro / toggleMacro

isDirty / saveToFile

setInitialState (JobStatus)

transition back to

registerScreen

getComponent

readByte macros_enabled

filesScreen

(files_screen.cpp)

macroManager

(macro_manager.cpp)

statusScreen

(status_screen.cpp)

mainScreen

(main_screen.cpp)

ListMenuScreen

messageBoxScreen

(confirm / info / error)

GCodeHandlerService

(grblHAL target)

ESP3DValues

firmware_file_entry

firmware_status

UIManager

(screen registration)

ESP3DSettings

esp3d_macros_enabled

EventHandlerEffect
Screen createdcreate() → onScreenCreated()Subscribe to values; decide scan vs. reuse list
LVGL LV_EVENT_DELETEonScreenDestroy()Unregister from UIManager; delete C++ instance
prepareForDestruction()Called before any transitionStop watchdog; unsubscribe; hide spinner; save macros if dirty
redirect_target_ setcreate() startDeferred redirect via lv_timer; skips full screen build
User actionDestination screen
Confirm job launchstatusScreen (JobStatus / Control mode)
Back buttonmainScreen
Confirm / cancel dialog closesReturns to filesScreen (re-created, reuses all_files_)

11.1 process_listing_line(const char *line) → bool

Section titled “11.1 process_listing_line(const char *line) → bool”

Parses a single line emitted by the grblHAL gcode handler. Returns true to continue, false when the listing is complete or aborted.

Input variants
[FILE:/path/name.ext|SIZE:n] → add RawFile to all_files_
[FILE_LIST_END] → finalize_listing()
[MSG:ERR: ...] → error state, clear lists
anything else → ignored

Memory guard: if free internal heap drops below MIN_FREE_HEAP_BYTES (30 KB), finalize_listing() is called immediately to stop collecting and display what has been gathered so far.

Rebuilds file_entries_ from all_files_ for the current directory (current_path). Immediate file children become File entries; deeper paths contribute at most one Dir entry for their immediate subdirectory component.

Called by: display_file_list(), which is in turn called by finalize_listing() and by every local navigation action.

Single authoritative entry point for populating the list widget. Sequence:

  1. Call build_current_view().
  2. Update the list title to current_path.
  3. If file_entries_ is empty → display no_files info message.
  4. Sort with compareFileEntries().
  5. Prepend a Parent entry if current_path != "/".
  6. Call ListMenuScreen::updateItemList().
  7. Restore focus to pending_job_selection_index if set (returns from cancel).

Strict weak ordering for std::sort. Priority: Parent → Error/Info → File (alpha) → Dir (alpha). Case-insensitive via strcasecmp.

Internal storage for each file returned by $F. Fields: path (full absolute path), size (byte count as string). Stored in all_files_ (static, persists across screen visits).

Shared termination for both the normal end-of-list path and the low-memory / stall cutoff path:

  1. stop_watchdog_timer()
  2. list_state = Done, listing_active = false
  3. destroy_loading_spinner()
  4. display_file_list()
  5. Show action buttons

11.7 setRedirectTarget(ESP3DScreenType target)

Section titled “11.7 setRedirectTarget(ESP3DScreenType target)”

Sets a one-shot redirect. When create() is next called (e.g., after a messageBox closes), it immediately navigates to target via a zero-delay LVGL timer instead of building the files screen again. Used after a successful job launch to jump to statusScreen.


The ESP32 heap is severely constrained (see docs/guides/esp32_memory_constraints.md). The module applies several defensive patterns:

static const uint32_t MIN_FREE_HEAP_BYTES = 30000; // 30 KB

Checked in process_listing_line() before every push_back. If the threshold is breached, finalize_listing() is called immediately — the user gets whatever files were already collected.

Every std::vector mutation that occurs within an LVGL callback context is wrapped in try/catch(const std::exception&). An uncaught std::bad_alloc in LVGL context calls std::terminate and reboots the board. On catch, the function logs the error and returns with the partially built structure, never propagating the exception.

12.3 file_entries_ Preservation Across Dialogs

Section titled “12.3 file_entries_ Preservation Across Dialogs”

prepareForDestruction() deliberately does not clear file_entries_ or all_files_. This preserves the user’s directory view across confirmation-dialog round-trips (confirm launch, confirm delete, cancel). The lists are only cleared by:

  • start_sd_listing() — explicit refresh
  • on_firmware_status_update() — SD card error
  • process_listing_line() — mount error line

12.4 new (std::nothrow) for Screen Instance

Section titled “12.4 new (std::nothrow) for Screen Instance”
files_screen_obj_instance = new (std::nothrow) ListMenuScreen(...);

Prevents a thrown std::bad_alloc from escaping. Null is checked immediately; on failure the function returns without showing the screen.


DependencyModuleRole
ListMenuScreencommon_screens.mdBase widget for the scrollable file list
messageBoxScreencommon_screens.mdConfirmation and error dialogs
macroManagercnc_shared.mdPendant-side macro tagging and persistence
statusScreencnc_shared.mdTransition target after job launch
mainScreencnc_shared.mdBack-navigation target
ESP3DGCodeHandlerServicecnc_grblhal.mdSends $F, $F=, $FD= commands
ESP3DValuesvalues.mdDelivers firmware_file_entry and firmware_status callbacks
ESP3DSettingsesp3d_core.mdReads esp3d_macros_enabled setting
ESP3DTranslationServicetranslations.mdLocalized UI strings
UIManagerui_core.mdScreen registration and component lookup
VirtualButtonsComponentui_components.mdAction buttons and switch position read
esp3d_resourcesui_core.mdImage descriptors (ok_b, refresh_b, back_b, etc.)
ESP3DValuesIndexDirectionPurpose
firmware_file_entrySubscribeReceives each parsed [FILE:...] line and the [FILE_LIST_END] sentinel
firmware_statusSubscribeDetects "error:" prefixed status to abort listing on SD failure

Both subscriptions are established in create() and torn down in prepareForDestruction(). start_sd_listing() unsubscribes and resubscribes firmware_file_entry defensively to prevent double-subscription across refresh cycles.

ScreenModuleRelationship
Screen routergrblhal_module_screen_router.mdDispatches createScreen(ESP3DScreenType::files)
Connection statusgrblhal_module_connection_status.mdIndependent; shown on the main and status screens
Change toolgrblhal_module_change_tool.mdLaunched when controller reports tool-change request
Probegrblhal_module_probe.mdIndependent probing workflow

For the equivalent files screen implementations in other firmware targets, see fluidnc_module_files.md (event-driven, per-directory scan) and grbl_module_files.md (background scan task with dirent).


  • Sort comparator fixed. std::sort requires a strict weak ordering (compare(x, x) must be false). The previous early return true for Parent / Error / Info entries broke it when two such entries were compared (undefined behaviour, possible crash). Entries are now sorted by rank first: Parent, then Error / Info, then files, then directories; Parent / Error / Info are equivalent among themselves.
  • Error during a listing. A firmware error status while listing (prefix tested case-insensitively: handlers publish ERROR:<code> or Error:…) shows the translated no_sd_card message: an error there means the card is missing or unreadable (for example FluidNC 4.x sdmmc_card_init failed, 3.x Failed to mount device). last_error_status is not read at that point, since the handler publishes firmware_status before it and it still holds the previous error; the raw firmware text stays in the logs and the message history.
  • Listing lines arrive through firmware_file_entry, a stream value that is never merged or skipped, set with set_value_wait() by the handler, so a burst of lines at 115200 baud is no longer truncated (see values.md).