Skip to content

Firmware Simulator (`tools/fw_simulator`)

The firmware simulator is a PC-side development tool that impersonates CNC and 3D-printer firmware over a physical or virtual serial port. It lets developers exercise the pendant firmware end-to-end—including GCode dispatch, position updates, temperature feedback, and status polling—without needing real CNC hardware connected.

It is part of the broader Build & Development Tools family and pairs naturally with the Communication Clients (BT, serial, telnet, WebSocket) that can be used on the same workstation.


  1. Purpose and Scope
  2. Module Architecture
  3. File Overview
  4. Entry Point – fw_simulator.py
  5. Common Utilities – esp3d_common.py
  6. Firmware Modules
  7. GCode Command Coverage
  8. Data Flow
  9. State Management
  10. Temperature Simulation Model
  11. FluidNC Status Report Rotation
  12. Adding a New Firmware Module
  13. Usage
  14. Relationship to Other Modules

The pendant firmware running on the ESP32 communicates with the CNC/3D-printer controller via one of several transports (serial, USB-serial, Bluetooth, socket). During development it is impractical to have live CNC hardware connected on every workstation. The firmware simulator fills this gap:

  • Accepts the same GCode and proprietary command set the real firmware would accept.
  • Tracks machine state (positions, motion mode, temperatures) and generates realistic, protocol-correct responses.
  • Mirrors log lines from the pendant to the console so the developer can monitor both sides of the conversation.
  • Supports the pendant’s line-based serial protocol as well as the real-time single-byte command model used by Grbl-family firmwares.

Scope: Development/testing only. The simulator runs on a workstation (Python 3) and communicates with the ESP32 pendant over a real or virtual serial port pair (e.g. socat on Linux).


tools/fw_simulator/

imports

selects & calls processLine

selects & calls processLine

selects & calls processLine

selects & calls processLine

imports

imports

imports

imports

read/write

UART

fw_simulator.py

(Entry point / dispatcher)

esp3d_common.py

(Shared utilities)

fluidnc.py

(FluidNC CNC)

marlin.py

(Marlin 3D)

repetier.py

(Repetier 3D)

smoothieware.py

(Smoothieware 3D)

Serial Port

(115200 baud)

ESP32 Pendant

(serial_client / usb_serial_client)

Each firmware module is a self-contained plugin. The dispatcher (fw_simulator.py) owns the serial port and the read loop; firmware modules only implement command processing and state logic.


FileRole
fw_simulator.pyCLI entry point. Scans serial ports, opens the connection, selects the firmware plugin, and runs the main read loop.
esp3d_common.pyShared ANSI-color helpers, millisecond timer, and send_echo() / wait() utilities used by all firmware modules.
fluidnc.pySimulates FluidNC (CNC, 6-axis). Implements processLine() and the full Grbl-compatible status report format.
marlin.pySimulates Marlin (3D printing). Implements processLine() plus temperature physics for extruder and heated bed.
repetier.pySimulates Repetier (3D printing). Structurally identical to marlin.py; same GCode surface.
smoothieware.pySimulates Smoothieware (3D printing). Structurally identical to marlin.py; same GCode surface.

python fw_simulator.py <firmware>

where <firmware> is one of: marlin, repetier, smoothieware, grbl, fluidnc.

Startup sequence:

No

Yes

No

Yes

No

Yes

Yes

No

No

Yes

Yes

No

Yes

No

Start

argc >= 2?

Print usage and exit

Normalize fw_name to lowercase

Known firmware?

Print error and exit

Load firmware module

Enumerate serial ports

Port with SERIAL

in description?

Print error and exit

Open port at 115200 baud

fw has run method?

Call fw.run - byte-level loop

readline-based main loop

ser.in_waiting?

readline + decode UTF-8

Starts with bracket?

fw.processLine line ser

response not empty?

common.send_echo ser response

KeyboardInterrupt - exit

The dispatcher iterates serial.tools.list_ports.comports() and selects the first port whose description contains the string "SERIAL" (case-sensitive). On Linux this is typically a USB-serial adapter; on Windows it matches COM ports exposed by the pendant’s USB bridge. The port is opened at a fixed baud rate of 115 200.

Lines that begin with [ are silently ignored. This covers:

  • ESP3D log lines: [esp...]
  • ANSI escape sequences: [0;..., [1;...

This prevents the pendant’s own debug output from being fed back into the GCode processor.

If the selected firmware module exports a run(ser) function, the dispatcher delegates the entire event loop to it. This supports firmwares (e.g. Grbl real-time commands) that require byte-level rather than line-level reading.


ANSI terminal color constants used across all modules for console output:

ConstantColorUsed for
COL_BLUEBlueLines received from the pendant
COL_GREENGreenStatus / info messages from the simulator
COL_REDRedError messages
COL_PURPLEPurpleLines received during wait() delays
COL_ORANGEOrange(available, not currently assigned)
COL_CYANCyan(available, not currently assigned)
BOLD / UNDERLINE—Text decorations
END_COLResetTerminates color sequences

Returns round(time.time() * 1000) — a millisecond-precision timestamp used by the wait() spin-loops and by the Marlin-family temperature simulation.

Spins for durationms milliseconds while draining any incoming serial data and printing each received line in purple. Used by firmware modules to simulate command execution delay without blocking the serial RX path.

Writes msg + "\n" to the serial port (UTF-8 encoded), flushes, and prints the message to stdout without color markers.


Simulates FluidNC v3.x — the Grbl-based CNC firmware used with the pendant. Unlike the 3D-printer modules, FluidNC:

  • Tracks six axes: X, Y, Z, A, B, C.
  • Reports status in the Grbl real-time format (<Status|MPos:...|FS:...|Bf:...|WCO:...|...>).
  • Responds to $-prefixed commands ($H, $J=, $I).
  • Uses a rotating report counter to cycle optional status fields across successive ? queries.
Command prefixHandlerDescription
G0G0_G1_response()Rapid move (6-axis)
G1G0_G1_response()Linear move (6-axis)
G90G90_response()Absolute positioning mode
G91G91_response()Relative positioning mode
$HHome_response()Home all or selective axes
$J=Jog_response()Jog move (strips G90/G91/G21 prefixes)
$Iinfo_response()Return firmware version string
?status_response()Real-time status report
Any M, G, N, $—Generic ok (cmd) acknowledgment
<Idle|MPos:X,Y,Z,A,B,C|FS:0,0|Bf:15,128|[WCO|Ov|A:S|Pn]>

Optional fields rotate on a 10-report cycle (see §11).

Strips N<num> line-numbering prefix and *<checksum> suffix when present, then returns ok <N> or ok (cmd).


Simulates Marlin — the dominant 3D-printer firmware. Tracks 3-axis position (X, Y, Z) and two temperature channels (E0 extruder, B heated bed) with physics-based heat/cool simulation.

Command prefixHandlerDescription
G0 / G1G0_G1_response()Move (3-axis, absolute or relative)
G28G28_response()Home (with 3-second busy simulation)
G29 V4G29_V4_response()Auto bed leveling (4×4 grid, ~48 s total)
G90G90_response()Absolute mode
G91G91_response()Relative mode
M104M104_response()Set extruder temperature (non-blocking)
M105M105_response()Query temperatures
M106M106_response()Fan on
M107M107_response()Fan off
M109M109_response()Set extruder temperature and wait
M114M114_response()Query current position
M140M140_response()Set bed temperature (non-blocking)
M190M190_response()Set bed temperature and wait
M220M220_response()Set feed rate percentage
M108(in-loop detection)Cancel heating wait (during M109/M190)
ok T:210.25 /215.00 B:59.80 /60.00 @:127 B@:0

Simulates Repetier firmware. The implementation is structurally identical to marlin.py — same GCode coverage, same temperature simulation, same command-dispatch table. The distinction matters because the pendant firmware may apply Repetier-specific response parsing. Having a dedicated module keeps the door open for Repetier-specific divergence without touching the Marlin module.


Simulates Smoothieware. Again structurally identical to marlin.py. Smoothieware was a popular 3D-printer / CNC hybrid firmware; the simulation provides the same Marlin-compatible GCode surface so the same pendant UI flows can be tested.


CommandFluidNCMarlinRepetierSmoothieware
G0 / G1 (move)✓ (6-axis)✓ (3-axis)✓ (3-axis)✓ (3-axis)
G28 (home)$H variant✓✓✓
G29 V4 (bed leveling)—✓✓✓
G90 (absolute mode)✓✓✓✓
G91 (relative mode)✓✓✓✓
M104 (set extruder temp)—✓✓✓
M105 (query temps)—✓✓✓
M106 / M107 (fan)—✓✓✓
M108 (cancel wait)—✓✓✓
M109 (wait extruder temp)—✓✓✓
M114 (query position)—✓✓✓
M140 (set bed temp)—✓✓✓
M190 (wait bed temp)—✓✓✓
M220 (feed rate %)—✓✓✓
? (status report)✓———
$H (home)✓———
$J= (jog)✓———
$I (firmware info)✓———
Any other M / G / N / $Generic okGeneric okGeneric okGeneric ok

esp3d_commonFirmware Modulefw_simulator.pySerial PortESP32 Pendantesp3d_commonFirmware Modulefw_simulator.pySerial PortESP32 Pendantalt[command matched][unknown command]alt[line starts with bracket][normal GCode]GCode line e.g. G0 X10 Y20ser.readline()decode UTF-8 and strip whitespaceignore - pendant log lineprocessLine(line, ser)strip N-prefix and checksum suffixmatch against methods listupdate internal statereturn response stringecho:Unknown command ... oksend_echo(ser, response)write response + newlinefirmware response

Some commands (homing, bed leveling, wait-for-temperature) block until a condition is met. During the wait loop the module:

  1. Calls send_busy(ser, n) which sends periodic echo:busy: processing messages.
  2. Drains incoming serial bytes so the pendant heartbeat is not missed.
  3. For M109/M190: checks for M108 cancel commands received during the wait.
Firmware ModuleESP32 PendantFirmware ModuleESP32 Pendantalt[M108 received during wait]loop[Until T within 2 degrees of target]M109 S215echo:busy: processingupdateTemperatures()T:195.20 /215.00 B:59.10 /0.00 @:127 B@:0M108stop_heating = True - break loopok (M109 S215)

Each firmware module maintains module-level global state that persists across command calls within a session:

E0 extruder + B bed

1

2

FluidNCState

+dict positions: X Y Z A B C

+dict modes: absolute

+int report_counter

+G0_G1_response() : updates positions

+Home_response() : zeros selected axes

+Jog_response() : delegates to G0_G1

+status_response() : reads positions and report_counter

Marlin3DState

+dict positions: X Y Z

+dict modes: absolute

+bool stop_heating

+G0_G1_response() : updates positions

+G28_response() : zeros selected axes

+M104_response() : updates E0 target

+M109_response() : blocks until E0 near target

+M140_response() : updates B target

+M190_response() : blocks until B near target

TemperatureChannel

+float value

+float target

+int lastTime

+float heatspeed

+float coolspeed

+float variation

Note: State is global per Python process. Restarting the simulator resets all positions to 0 and temperatures to room temperature.


The three 3D-printer modules (Marlin, Repetier, Smoothieware) share identical temperature physics implemented in updateTemperatures(entry, timestp):

updateTemperatures()

Yes heating fast

No

Yes cooling fast

No

Yes at setpoint

No

Yes heating slow

No cooling slow

current value

value + 5 < target?

value += heatspeed x delta_t / 1000

value - 5 > target?

value -= coolspeed x delta_t / 1000

value within 2 of target?

value = target + variation x random -0.5 to 0.5

value < target?

value += heatspeed/3 x delta_t / 1000

value -= coolspeed/3 x delta_t / 1000

Channelheatspeedcoolspeedvariation
E0 (extruder)0.6 °C/s0.8 °C/s± 0.25 °C
B (heated bed)0.2 °C/s0.8 °C/s± 0.25 °C
  • The bed heats more slowly than the extruder (larger thermal mass).
  • A random variation term is applied when within ± 2 °C of the target to simulate realistic sensor noise.
  • When target = 0, the channel cools toward room temperature (20 °C).

The FluidNC ? command response varies across successive queries to exercise different pendant UI paths. The report_counter variable cycles 1 → 10 and selects which optional status fields are appended to each response:

first question mark query

next query

next query

next query

repeats through 10

counter reaches 11 resets to 1

report_counter = 1\nWCO field appended\nWCO 0.000 0.000 0.000 1.000 1.000 1.000

report_counter = 2\nOv and Pn fields appended\nOv 100 100 100 and Pn XYZ

report_counter = 3\nA and Pn fields appended\nA S and Pn P

report_counter = 4 through 10\nA and Pn fields appended\nA S and Pn P

Full status response anatomy:

<Idle|MPos:0.000,0.000,0.000,0.000,0.000,0.000|FS:0,0|Bf:15,128|WCO:...|Ov:...|A:S|Pn:P>
^ ^ ^ ^ ^ ^ ^ ^
| 6-axis machine position (X Y Z A B C) | | | | | Pin state
| Feed+Speed | | | Accessory state
Machine status Planner buffer WCO offset Overrides

To add support for a new firmware (e.g. grblhal):

1. Create tools/fw_simulator/grblhal.py and import the common module:

import esp3d_common as common

2. Define module-level state:

positions = {"X": 0.0, "Y": 0.0, "Z": 0.0}
modes = {"absolute": True}

3. Implement handler functions with the standard signature:

def my_cmd_response(cmd, line, ser):
# cmd = stripped command string (no N-prefix, no checksum)
# line = raw original line (used for N-number extraction in ok())
# ser = serial.Serial instance (for blocking commands that need to read/write)
return "response string\n"

4. Define the methods dispatch list:

methods = [
{"str": "G0", "fn": G0_G1_response},
{"str": "G1", "fn": G0_G1_response},
# ...
]

5. Implement processLine(line, ser) following the standard pattern:

def processLine(line, ser):
time.sleep(0.01)
cmd = line
if line.startswith("N"):
p = line.find(' ')
cmd = line[p+1:]
p = cmd.rfind('*')
cmd = cmd[:p]
for method in methods:
if cmd.startswith(method["str"]):
return method["fn"](cmd, line, ser)
if line.startswith(("M", "G", "N", "$")):
return ok(line)
if "[esp" in line or "[0;" in line or "[1;" in line:
return ""
return 'echo:Unknown command: "' + line + '"\nok'

6. Register the name in fw_simulator.py:

elif fw_name == "grblhal":
fw = grblhal

If the firmware requires byte-level (non-line) reading (e.g. for real-time single-byte commands), implement run(ser) instead of processLine(). The dispatcher will call run() in preference if it exists.


Terminal window
pip install pyserial
Terminal window
cd tools/fw_simulator
python fw_simulator.py <firmware>
ArgumentSimulates
fluidncFluidNC CNC (6-axis, Grbl-compatible)
marlinMarlin 3D printer
repetierRepetier 3D printer
smoothiewareSmoothieware
grblGrbl (referenced in dispatcher, module not included in this tree)
Serial ports detected:
- /dev/ttyUSB0: USB SERIAL converter
Found /dev/ttyUSB0 for TFT
Open port /dev/ttyUSB0
Now Simulating: fluidnc
ColorMeaning
BlueLines received from the ESP32 pendant
PurpleLines received during blocking wait loops
(default)Responses sent back to the pendant
GreenSimulator status messages
RedError conditions

On Linux, a null-modem pair can be created with socat to run the simulator without physical hardware:

Terminal window
# Create the pair
socat -d -d pty,raw,echo=0,link=/tmp/ttyFW pty,raw,echo=0,link=/tmp/ttyPendant
# Terminal 1: start the simulator (auto-detects /tmp/ttyFW if description matches)
python fw_simulator.py fluidnc
# Terminal 2: connect the pendant or a test client to /tmp/ttyPendant

Other Dev Tools

ESP32 Pendant Firmware

UART 115200 baud

USB-serial bridge

alternative test path

build context

tools_firmware_simulator

this module

serial_client

main/modules/serial/

usb_serial_client

main/modules/usb_serial/

CNC_Firmware_Integration

GCode host + handlers

tools_communication_clients

bt_client serial_client telnet

tools_build_scripts

build_mgr size_report

Related moduleRelationship
serial_clientThe primary transport on the pendant side that connects to the simulator’s serial port. The simulator replaces the physical CNC controller from this module’s perspective.
CNC Firmware IntegrationThe GCode host service and per-firmware response parsers (cnc_fluidnc, cnc_grbl, cnc_grblhal) running on the ESP32 that the simulator exercises end-to-end.
usb_serialAlternative transport; the simulator can also be reached via a USB-serial VCP adapter connected to the pendant’s USB OTG port.
tools_communication_clientsAlternative test clients (BT, telnet, WebSocket) that can inject GCode into the same pendant pipeline independently of the serial simulator.
tools_build_scriptsBuild management context; the simulator is a development-time companion tool, not a build artifact or flashed component.

Scope boundary: The simulator tests the communication and GCode parsing path only. LVGL UI rendering, hardware-specific BSP initialisation, and FreeRTOS scheduling are not exercised by this tool. For UI-level testing see the UI Framework & Screens documentation and the factory test applications in Board Support Packages.