Skip to content

Network Scan Screens

The network scan screens module provides three asynchronous scan-and-select screens that let the user discover and configure network endpoints directly from the pendant’s touchscreen. Each screen scans for a different type of network target, presents results in a scrollable list, and persists the user’s choice to NVS before initiating a connection.

ScreenFileBuild guardTarget
wifiScanScreenwifi_scan_screen.cppESP3D_WIFI_FEATUREWiFi access points (802.11)
scanBTScreenscan_bt_screen.cppESP3D_BT_SERIAL_FEATURE || ESP3D_BT_BLE_FEATUREBluetooth Classic / BLE devices
serverScanScreenserver_scan_screen.cppESP3D_IP_CNC_CLIENT_FEATURECNC servers discovered via mDNS

All three screens share an identical structural pattern: a ListMenuScreen base, a FreeRTOS scan task that runs off the LVGL thread, a polled completion timer, a loading spinner, and three virtual buttons (Select / Refresh / Back). The sections below describe the shared architecture, then document each screen’s specialisations.


External Dependencies

network_scan_screens (current module)

UI Framework (LVGL - Core 1)

extends

owns

owns

registers with

instantiates

instantiates

instantiates

scans via

checks mode

password via

connects via

connects via

navigates to

scans via

scans via

checks mode

PIN via

discovers via

checks mode

connects via

connects via

reads/writes

reads/writes

reads/writes

errors via

errors via

errors via

i18n

i18n

i18n

UIManager

GenericScreen

ListMenuScreen

ListMenuComponent

VirtualButtonsComponent

wifiScanScreen

wifi_scan_screen.cpp

scanBTScreen

scan_bt_screen.cpp

serverScanScreen

server_scan_screen.cpp

ESP3DWifiClient

esp3d_wifi_client.h

ESP3DBTSerialClient

esp3d_bt_serial_client.h

ESP3DBTBleClient

esp3d_bt_ble_client.h

ESP3DNetwork

esp3d_network.h

ESP3DmDNS

esp3d_mdns.h

ESP3DSocketClient

esp3d_socket_client.h

ESP3DWebsocketClient

esp3d_websocket_client.h

ESP3DSettings

esp3d_settings.h

ESP3DTranslationService

inputScreen

messageBoxScreen

connectionStatusScreen


All three screens follow the same lifecycle governed by LVGL timers and FreeRTOS tasks.

Userscan_check_timer (250 ms)FreeRTOS Scan TaskLVGL / UIManagercreate()Caller (e.g. settings_list_screen)Userscan_check_timer (250 ms)FreeRTOS Scan TaskLVGL / UIManagercreate()Caller (e.g. settings_list_screen)Screen visible, spinner shown, buttons hiddenalt[Item confirmed (Select / touch)][Refresh][Back]create()validate radio modenew ListMenuScreen(initial "loading" item)registerScreen(type, screen, prepareForDestruction) + onScreenDestroy callbackonScreenCreated()lv_timer 500 ms → start_xxx_scan()esp3d_task_create_unpinned(scan_task)lv_timer_create(scan_check_timer_cb, 250ms)blocking scan (WiFi / BT / mDNS)scan_completed = true, vTaskDelete()scan_check_timer_cb firesdestroy_loading_spinner(), re-enable buttonsupdateItemList(results), move_to_index(current)touch/encoder → select item OR press buttonsave to NVSinitiate connectionESP3D_TRANSITION_START → cleanup_timer_cbstart_xxx_scan() (clear cache first)clear cache, ESP3D_TRANSITION_START → settingsprepareForDestruction()UIManager::unregisterScreen()delete ListMenuScreen instance

All three screens use a consistent set of macros to avoid double-free and race conditions between LVGL callbacks and navigation transitions.

"create()"

"onScreenCreated() + start_xxx_scan()"

"scan_check_timer_cb (scan_completed)"

user selects item

"cleanup_timer_cb fires - prepareForDestruction()"

"Back button - prepareForDestruction()"

"scan_result_valid=false - messageBoxScreen.show_error()"

user acknowledges

"onScreenDestroy (LV_EVENT_DELETE) - delete ListMenuScreen"

Created

Scanning

Displaying

Confirming

Destroying

Error

Key lifecycle guards:

VariablePurpose
is_prepared_for_destruction_Set by prepareForDestruction(); prevents re-entry via ESP3D_PREPARE_DESTRUCTION_GUARD
cleanup_executed_Prevents the cleanup body from running more than once
transition_timerDelayed screen switch; destroyed by cleanup_timer_cb before the next screen is created
cleanup_timerBridges the gap between button-release and destruction; created by ESP3D_TRANSITION_START

connection_status_screen

modal_dialog_screens

screen_base_infrastructure

network_scan_screens

wifiScanScreen

scanBTScreen

serverScanScreen

ListMenuScreen

GenericScreen

ListMenuComponent

VirtualButtonsComponent

inputScreen

messageBoxScreen

connectionStatusScreen

See screen_base_infrastructure for ListMenuScreen, GenericScreen, ListMenuComponent, and VirtualButtonsComponent.

See modal_dialog_screens for inputScreen and messageBoxScreen.

See connection_status_screen for the post-connect status screen used after WiFi credential confirmation.


Each screen defines a private struct that holds per-item data. The struct is passed generically through ListMenuComponent’s void*-based item API; the display callback casts it back to the concrete type.

WifiApInfo

+string ssid

+string signal_str

+int32_t signal

+bool is_protected

BTDeviceInfo

+string address

+string display

+string signal_strength

ServerInfo

+string hostname

+string ip_addr

+string port_str

+uint32_t port_num

+bool found

ServerInfo::found (a14) is false for the saved server added to the list although it did not answer the mDNS scan.


The following data flow is identical across all three screens. Only the blocking API call and result type differ.

FreeRTOS Task (unpinned)

LVGL Thread (Core 1)

No - next tick

Yes

creates

flag polled by

start_xxx_scan()

Clear list

Update title to Scanning

create_loading_spinner

Hide all 3 buttons

esp3d_task_create_unpinned

(xxx_scan_task, 4096 B stack)

lv_timer_create

(scan_check_timer_cb, 250 ms)

scan_completed?

stop_xxx_scan

destroy_loading_spinner

Show all 3 buttons

updateItemList(results)

move_to_index(current_config)

Blocking scan API

esp_wifi_scan_start

btClient.scan

esp3dmDNS.servicesScan

Build local result vector

Move to static storage

scan_completed = true

vTaskDelete(nullptr)

All scan tasks guard result-vector growth against std::bad_alloc. On allocation failure the loop breaks and whatever entries were collected so far are displayed. The ListMenuScreen is constructed with new (std::nothrow) and validated before registering with UIManager; a null result leaves the previous screen intact.

// Pattern used in all three scan tasks:
try {
local_xxx.emplace_back(...);
} catch (const std::exception &e) {
esp3d_log_e("OOM: %s — keeping %d entries", e.what(), local_xxx.size());
break;
}

All three screens use the same three-button configuration.

┌──────────┬──────────┬──────────┐
│ Select │ Refresh │ Back │
│ (ok_b) │(refresh_b│ (back_b) │
│ Button 0 │ Button 1 │ Button 2 │
└──────────┴──────────┴──────────┘
ButtonPress soundRelease action
0 – SelectESP3D_SELECTION_BEEPInvoke onXxxItemClick for the focused item
1 – RefreshESP3D_SELECTION_BEEPClear cache, re-run scan
2 – BackESP3D_BACK_BEEPClear cache, transition to settings

All three buttons are hidden via show_button(N, false) during the active scan and restored in destroy_loading_spinner().


Scans for visible 802.11 access points, displays them sorted by signal strength (descending), and lets the user connect by selecting an AP. Protected networks trigger a password prompt via inputScreen.

The screen guards creation with a radio mode check. It handles three distinct WiFi driver states at scan time:

Radio modeDriver state at scanBehaviour
wifi_staRunning STAesp_wifi_scan_start() runs directly
wifi_ap_configAP-only (WIFI_MODE_AP)Temporarily switches to WIFI_MODE_APSTA; restores WIFI_MODE_AP after scan
Any (SSID empty / uninitialised)Stopped or not initialisedFull esp_wifi_init() + set_mode(STA) + start(); torn down after scan
┌─────────────────────────────────────────────────────────┐
│ [✓] MyNetwork 75% [lock] │
│ OpenNetwork 60% │
│ AnotherAP 30% [lock] │
└─────────────────────────────────────────────────────────┘
↑ ↑ ↑
LV_SYMBOL_OK (active SSID) signal lock icon
  • Active SSID (matches esp3d_sta_ssid NVS value): shown with LV_SYMBOL_OK in ESP3D_INDICATOR_SUCCESS_COLOR
  • Lock icon (ESP3D_SYMBOL_LOCK): displayed when authmode != WIFI_AUTH_OPEN
  • Long SSIDs are truncated with LV_LABEL_LONG_DOT; label width is reduced dynamically when the signal percentage and/or lock icon are present

No

Yes

onPasswordOK

User selects AP

Protected?

Write SSID to NVS

Clear password in NVS

Save list selection index

Read current password from NVS

inputScreen::show_text_input

(Cancel returns to wifi_scan)

Write SSID + password to NVS

Write esp3d_radio_mode = wifi_sta

setModeAsync(wifi_sta)

Navigate to connectionStatusScreen

(return_screen = settings,

open_server_scan_when_connected = true)

open_server_scan_when_connected (a14): once WiFi connects, the connection status screen jumps to the server scan screen when the output is a WiFi IP CNC client and no server address is saved yet, so the WiFi setup continues with the server choice.

setModeAsync() is used instead of the synchronous setMode() to avoid blocking the LVGL task for the duration of WiFi reconnection (xEventGroupWaitBits(portMAX_DELAY), up to 10 retries).

scanned_aps_ is preserved across the transition to inputScreen so the list is instantly restored on Cancel. The saved_selection_ index restores the highlight to the previously focused item. The cache is only cleared on Refresh or Back.

Setting indexValue written
esp3d_sta_ssidSelected AP SSID
esp3d_sta_passwordPassword (empty for open networks)
esp3d_radio_modewifi_sta (ensures begin() auto-connects on next boot)

wifi_scan_task

(FreeRTOS, unpinned)

esp_wifi_scan_start

(blocking, max 15 APs)

esp_wifi_scan_get_ap_records

esp_wifi_clear_ap_list

Filter: getSignal > 0

Sort: descending by numeric RSSI

scanned_aps_ = std::move(local_aps)

Restore WIFI_MODE_AP if scan_was_ap_config

Stop/deinit driver if scan_started_driver

scan_completed = true

vTaskDelete


Scans for nearby Bluetooth Classic (SPP) or BLE (GATT) devices and lets the user select one as the CNC host. BT Classic requires a PIN confirmation (empty = SEC_NONE); BLE connects directly using the stack’s own pairing mechanism.

Returning to the settings screen from the BT scan is wrapped in try / catch (a14): an exception in createScreen() is logged instead of terminating. Each item logs its signal_strength.

Because BT scanning has a known maximum duration (returned by getScanMaxDuration()), the spinner label shows a live countdown. Each scan_check_timer_cb tick recalculates remaining seconds from (scan_max_duration - elapsed_ms) / 1000 and updates the label.

┌─────────────────────────────────────────────────────────┐
│ [✓] None │
│ [✓] FluidNC CNC (currently connected, inserted) │
│ Printer001 88% │
│ OtherDevice 45% │
└─────────────────────────────────────────────────────────┘
  • None (MAC 00:00:00:00:00:00): always first; selects no host
  • Currently connected device: if absent from scan results, inserted at position 1 with no signal percentage
  • Devices whose name is empty after str_trim are discarded from the result set

onBtPinOK

Cancel

User selects BT Classic device

Save selected_index_

Store pending_address_ and pending_name_

Read current PIN from NVS

inputScreen::show_pin_input

(allow_empty=true means SEC_NONE)

Write PIN to NVS

Write MAC address to NVS

btSerialClient.clearBondedDevices

btSerialClient.connect

Navigate to settings

via lv_timer 100 ms

Restore saved_selection_

Return to scan_bt list

BLE selection saves the address immediately and calls btBleClient.connect(). No sub-screen is shown. refresh_visible_items() updates the active-device marker in-place on the existing list.

ModeSetting indexValue
BT Serialesp3d_btserial_addressMAC address string
BT Serialesp3d_btserial_pinPIN string (empty = SEC_NONE)
BT BLEesp3d_btble_addressMAC address string

bt_scan_task

(FreeRTOS, unpinned)

btSerialClient.scan

or btBleClient.scan

(blocking)

Filter: non-empty trimmed name

Sort: RSSI descending

Prepend None

Insert connected device at pos 1 if not in results

scanned_devices_ = std::move(local_devices)

scan_completed = true

vTaskDelete


Discovers CNC servers on the LAN using mDNS service discovery. The mDNS service type is selected at build time:

  • ESP3D_SOCKET_CLIENT_FEATURE → queries _telnet._tcp
  • ESP3D_WS_CLIENT_SERVICE_FEATURE → queries _ws._tcp

The user selects a discovered server; its resolved IPv4 address and port are saved to NVS and a connection is initiated.

┌─────────────────────────────────────────────────────────┐
│ [✓] fluidnc.local 23 │
│ grblhal-machine.local 80 │
└─────────────────────────────────────────────────────────┘
↑ ↑
hostname (mDNS service name) port
  • Active server: LV_SYMBOL_OK when both IP and port match esp3d_socket_client_address / esp3d_socket_client_port
  • Saved server not answering (a14): when a server is saved but not in the scan results, it is still added first in the list, with its IP as name, so the user sees what is configured and can confirm or replace it. Its row shows LV_SYMBOL_WARNING (warning color) instead of the check, and its label is dimmed with LV_OPA_50 (opacity rather than color, since the list selection style overrides child text colors, not opacity)
  • Port is right-aligned; hostname is truncated with LV_LABEL_LONG_DOT when needed

Yes

No

User selects server

Write ip_addr to

esp3d_socket_client_address

Write port_num to

esp3d_socket_client_port

Already connected

to same IP:port?

Skip connect()

avoid TLSF/lwIP race condition

esp3dSocketClient.connect()

or esp3dWebsocketClient.connect()

ESP3D_TRANSITION_START

navigate to settings

The already-connected guard prevents a known crash: calling connect() on an active socket triggers closeSocket() while lwIP may be delivering packets. This corrupts TLSF free-block pointers and causes a StoreProhibited exception a few seconds later.

Setting indexValue
esp3d_socket_client_addressResolved IPv4 string
esp3d_socket_client_portPort number (uint32)

server_scan_task

(FreeRTOS, unpinned)

esp3dmDNS.servicesScan

(_telnet or _ws, _tcp)

blocking ~3 s

Iterate records

Extract first IPv4 from mdns_ip_addr_t

ip4addr_ntoa then copy immediately

scanned_servers_ = std::move(local_servers)

scan_completed = true

vTaskDelete

ip4addr_ntoa returns a pointer into a static buffer. The string must be copied into a std::string immediately to avoid stale-pointer bugs.


WiFi protected

BT Classic

WiFi open / BLE / Server

onPasswordOK

onBtPinOK

WiFi

BT Serial

BT BLE

Server

Settings screen

user taps network entry

open scan screen

create()

Async scan task starts

(500 ms delay)

Results displayed

in scrollable list

User selects item

Needs credentials?

inputScreen: WiFi password

(text input)

inputScreen: PIN

(numeric, allow_empty)

Write to NVS directly

Initiate connection

setModeAsync / btClient.connect

or socketClient.connect

Navigate to

connectionStatusScreen

return_screen = settings

settings screen

Refresh list in place

stay on scan_bt


common_screens

opens

returns to

uses

uses

uses

screen_base_infrastructure

ListMenuScreen, GenericScreen

modal_dialog_screens

inputScreen, messageBoxScreen

connection_status_screen

settings_selection_screens

network_scan_screens

current module

system_lifecycle_screens

cnc_shared

settings_list_screen

Entry points from settings_list_screen.cpp:

HandlerScreen opened
onWifiSSIDClick / onForgetWifiClickwifiScanScreen::create()
BT host selection / onForgetBTHostClickscanBTScreen::create()
onServerAddressClickserverScanScreen::create()

ConcernMitigation
LVGL single-thread ruleAll blocking scan calls run in dedicated FreeRTOS tasks; LVGL objects are only touched from the 250 ms check-timer callback running on Core 1
RAM fragmentationResult vectors use emplace_back with try/catch; shrink_to_fit() after clearing; ListMenuScreen uses new (std::nothrow)
Heap diagnosticsEvery create(), prepareForDestruction(), and onScreenDestroy() logs esp_get_free_heap_size()
Stack sizeAll three scan tasks use 4096 bytes of stack via esp3d_task_create_unpinned
WiFi driver statewifiScanScreen handles three driver states: running STA, AP-only (APSTA workaround during scan), and uninitialised (full init + start, torn down after scan)
Double connect crashserverScanScreen checks isConnected() && same IP:port before calling connect() to avoid a known race in the TLSF/lwIP allocator
WiFi / BT mutual exclusionBoth cannot be active simultaneously on this board; the radio mode guard at create() prevents opening the wrong scan screen