WiFi CNC Controller — Engineering Reference

Firmware version 1.0.0 — ESP32-S3 based 6-axis CNC motion controller with dual-mode operation: binary protocol (Mach3 plugin / LinuxCNC HAL) and GRBL-compatible G-code interpreter (any standard sender over WiFi telnet or USB serial).

Note: This document covers the open-source ESP32 firmware. The Mach3 plugin is referenced where it interacts with the firmware protocol, but plugin source code is not part of this repository. All 6 axes are free and open source — the firmware has no licensing restrictions.

System Architecture

+------------------------------------------------------------------+ | Host Computer | | +------------------+ +------------------+ +----------------+ | | | Mach3 + WiFiCNC | | LinuxCNC + HAL | | UGS / CNCjs | | | | Plugin (DLL) | | (wificnc-hal) | | / bCNC | | | +--------+---------+ +--------+---------+ +-------+--------+ | | | | | | | Binary Protocol Binary Protocol G-code text | | UDP 58427/58428 UDP 58427/58428 TCP 23 (telnet) | | TCP 58429 TCP 58429 or USB serial | +-----------|----------------------|--------------------|-----------+ | | | ============|======================|====================|=========== WiFi / USB | | | +-----------|----------------------|--------------------|-----------+ | v v v | | +--------------------------------------------------+ | | | CORE 0 — Communications | | | | | | | | udp_server tcp_server gcode_telnet gcode_uart | | | | | | | | | | | | v v v v | | | | protocol_handler gcode_interface | | | | | | | | | | | gcode_parser | | | | | gcode_planner (look-ahead) | | | | | gcode_arcs | | | | | grbl_settings | | | | | | | | | | +----------+----------+ | | | | | | | | | v | | | | planner_push_segment() | | | +--------------------------------------------------+ | | | | | ==================|============ Lock-free ring buffer ========= | | | (128 slots, SPSC, volatile | | v head/tail, memory barriers) | | +--------------------------------------------------+ | | | CORE 1 — Motion Control | | | | | | | | motion_control_task (prio 20) | | | | | | | | | v | | | | stepper ISR (IRAM, 1 MHz timer, direct GPIO) | | | | | | | | | v | | | | Step/Dir pins → Motor drivers → Steppers | | +--------------------------------------------------+ | | | | ESP32-S3 Firmware | +------------------------------------------------------------------+

Core Allocation & Task Map

TaskPriorityStackCorePurpose
motion_control_task2040961Segment execution, state machine
stepper ISRISRIRAM11 MHz timer, Bresenham stepping
udp_receive_task1040960Binary protocol motion/control
gcode_exec_task861440G-code parse + plan + emit
gcode_uart_task730720USB serial G-code input
gcode_telnet_task640960WiFi telnet server (port 23)
tcp_server_task540960Binary protocol config/handshake
wifi_manager540960WiFi STA/AP management
status_report_task320480UDP status broadcast
Critical Rule: Core 1 is exclusively for motion. No WiFi, no TCP, no telnet, no allocations. The stepper ISR runs from IRAM with direct GPIO register writes. No flash access, no cache misses.

File Map

Tiggy_Wifi_Controller/ firmware/ platformio.ini — PlatformIO config + board define (pio builds) CMakeLists.txt — Top-level ESP-IDF project file main/ main.c — Entry point, task creation config.h — Compile-time defaults, task config CMakeLists.txt — Component sources + board define (idf.py builds) pin_config.h — Board-specific GPIO defaults (compile-time) motion/ stepper.c/.h — Timer ISR, Bresenham, probe detection, NVS pin loading motion_control.c/.h — Core 1 state machine planner.c/.h — 128-slot lock-free ring buffer network/ wifi_manager.c/.h — STA/AP WiFi management udp_server.c/.h — UDP motion + status ports tcp_server.c/.h — TCP config + handshake protocol_handler.c/.h — Binary packet dispatch + preemption + pin reboot warning io/ gpio_control.c/.h — Limits, probe, spindle, misc I/O, NVS pin loading status/ status_reporter.c/.h — Periodic UDP status broadcast persist/ nvs_config.c/.h — NVS get/set with protocol key mapping + pin config keys gcode/G-CODE INTERPRETER (NEW) gcode_parser.c/.h — Tokenizer, modal groups, validation gcode_planner.c/.h — 64-slot look-ahead, junction velocity, corner smoothing gcode_interface.c/.h — Execution task, status, real-time cmds gcode_arcs.c/.h — G2/G3 arc linearization gcode_jog.c/.h — $J= jog command handler grbl_settings.c/.h — GRBL $N ↔ NVS mapping gcode_telnet.c/.h — TCP telnet server (port 23) gcode_uart.c/.h — USB serial handler (115200) protocol/ wifi_cnc_protocol.h — Shared packet definitions, CRC, pin config keys plugin/ WiFiCNC/ — Mach3 plugin (distributed separately; see www.tiggyengineering.com) linuxcnc/ wificnc-hal.c/.h — LinuxCNC HAL component tools/ protocol_test_gui.py — Python/tkinter test GUI (Pins tab, 6-axis jog) protocol_test.py — CLI test tool (+ pin config keys)

Green = new files, Yellow = modified files

Stepper Engine (motion/stepper.c)

Hardware timer ISR at 1 MHz resolution. Bresenham multi-axis step distribution with jerk-limited S-curve velocity profiles (trapezoidal fallback when $40=0). All state in DRAM for zero-latency ISR access.

Key Data Flow

wcnc_motion_segment_t
  → stepper_load_segment()
    → Compute Bresenham counters, accel/decel boundaries
    → Start GPTimer
      → ISR fires at current_interval (us)
        → Set direction pins
        → Wait dir_setup_us
        → Check probe pin (if WCNC_SEG_FLAG_PROBE)
        → Bresenham: counter += steps[axis], if overflow: step pin HIGH
        → Wait step_pulse_us
        → Step pins LOW
        → Update position[axis]
        → Compute next interval (7-phase S-curve or trapezoidal ramp)
        → Schedule next alarm

S-Curve (Jerk-Limited) Acceleration

When $40 (max jerk) is non-zero, the ISR uses a 7-phase velocity profile where acceleration ramps up and down smoothly instead of switching instantly:

Trapezoidal:  |¯¯¯¯¯|          |¯¯¯¯¯|     (acceleration)
              0  accel  cruise  decel   0

S-curve:      /¯¯¯¯¯\          /¯¯¯¯¯\     (acceleration)
             0  ramp    const   ramp    0

The 7 phases: jerk-up → const-accel → jerk-down → cruise → jerk-down → const-decel → jerk-up. Each jerk ramp is capped to ⅓ of the accel/decel phase to avoid overshooting.

All computation is integer add/subtract/compare using v² kinematic tracking (speed_sq += current_two_accel). No floating-point or flash function calls in the ISR — fully IRAM-safe.

SettingNVS KeyDefaultEffect
$40jerk_max1000Max jerk (mm/sec³). 0 = trapezoidal fallback.
Tuning: Lower jerk = smoother but slower transitions. Higher jerk = snappier. $40=500 for smooth finishing, $40=2000 for fast positioning. $40=0 disables S-curve entirely (original trapezoidal behaviour).

Probe Detection in ISR

When a segment carries WCNC_SEG_FLAG_PROBE, the ISR checks the probe pin via gpio_ll_get_level() (direct register read, no function call overhead) on every step tick. When triggered, motion stops immediately and probe_triggered is set. The G-code interface reads the stopped position as the probe result.

Step Rate Architecture

The stepper engine uses a single hardware timer (GPTimer) at 1 MHz resolution (1µs ticks). The Bresenham algorithm distributes steps across all active axes within a single ISR call.

Aggregate Rate Limit

CFG_MAX_AGGREGATE_STEP_RATE = 250,000 — this is the ISR firing rate cap, not a per-axis limit. The minimum timer interval is clamped to 4µs (= 1,000,000 / 250,000).

Practical Rate Limits

Each ISR call has fixed overhead from the direction setup and step pulse timing:

Timing ParameterNVS KeyDefaultDescription
Direction Setupdir_us2µsDelay after setting direction pins before stepping
Step Pulse Widthpulse_us3µsMinimum pulse HIGH time for stepper driver
Total ISR overhead5µsMinimum time per ISR call (direction + pulse)

With default timing (5µs overhead per ISR call), the practical maximum step rate is approximately 200 kHz (1/5µs). Reducing pulse_us to 2µs and dir_us to 1µs would yield ~333 kHz theoretical, but the 4µs minimum interval clamp limits this to 250 kHz.

Key takeaway: 250 kHz is the combined Bresenham step event rate, not per-axis. A single axis can reach the full rate. Multiple simultaneous axes share the same ISR firing but pulse in parallel within each call — the rate is not divided by the number of active axes. With default 3µs + 2µs timing, expect ~200 kHz practical maximum.

Per-Axis Rate Clamping

Each axis's max_rate (configured via $110-$115) is individually clamped to CFG_MAX_AGGREGATE_STEP_RATE at boot:

if (st.max_rate[i] > CFG_MAX_AGGREGATE_STEP_RATE)
    st.max_rate[i] = CFG_MAX_AGGREGATE_STEP_RATE;

This prevents any axis from being configured above the hardware-enforced ceiling.

Position Preservation

stepper_estop() stops the timer and disables outputs but never zeros the position counter. st.position[] always reflects the exact step where motion ceased. This is critical for E-stop recovery.

Planner Ring Buffer (motion/planner.c)

128-slot SPSC (single-producer, single-consumer) lock-free circular buffer. Producer: Core 0 (protocol handler or G-code planner). Consumer: Core 1 (motion control task).

FunctionCalled FromDescription
planner_push_segment()Core 0Add segment, blocks if full
planner_pop_segment()Core 1Consume next segment
planner_available()AnyFree slots (for back-pressure)
planner_capacity()AnyTotal capacity (128)
planner_is_empty()AnyCheck if buffer empty
planner_clear()Core 0Discard all segments (E-stop)
Lock-free guarantee: Uses volatile head/tail indices with compiler memory barriers. No mutexes, no spinlocks, no priority inversion between cores.

Motion Control Task (motion/motion_control.c)

Core 1 state machine. Pops segments from the ring buffer and loads them into the stepper engine. States: IDLE, RUN, HOLD, JOG, HOMING, PROBING, ALARM, ESTOP.

GPIO & I/O Control (io/gpio_control.c)

FunctionDescription
gpio_control_init()Load pin assignments from NVS (compile-time defaults as fallback), configure GPIO
gpio_control_get_probe()Read probe pin (with inversion)
gpio_control_get_limit(axis)Read limit switch for axis
gpio_control_get_limit_mask()All limit states as bitmask
gpio_control_set_spindle(bool)Enable/disable spindle output
gpio_control_set_misc_output(n, state)Set misc output pin
gpio_control_get_estop()Read E-stop input
gpio_control_set_led(bool)Legacy stub — LED now managed by status_led module

Supported Boards

BoardDefineCharge PumpMisc OutNotes
Waveshare ESP32-S3-ZeroBOARD_ESP32S3_ZERONo0Compact, limited GPIO
ESP32-S3-DevKitC-1BOARD_ESP32S3_DEVKITCYes2Most GPIO, recommended
ESP32-WROOM-32BOARD_ESP32_WROOM32No0Classic ESP32, input-only GPIOs

NVS Configuration (persist/nvs_config.c)

Non-volatile storage for all runtime settings. Values persist across power cycles. Accessible via binary protocol (WCNC_PKT_CONFIG_GET/SET) and GRBL $N=value commands.

Two access methods:

Runtime Pin Remapping

GPIO pin assignments are stored in NVS and can be changed at runtime without recompiling firmware. Compile-time defaults from pin_config.h serve as fallback values when no NVS entry exists.

How It Works

  1. Boot: stepper_init() and gpio_control_init() read pin assignments from NVS, falling back to compile-time pin_config.h defaults if no NVS entry exists
  2. Runtime storage: Pins are cached in static variables (st.step_pins[], s_limit_pins[], s_estop_pin, etc.) and used throughout the firmware
  3. Configuration: Pin assignments can be read/written via the binary protocol (CONFIG_GET/SET with keys 0x0500-0x0519) or the GUI Pins tab
  4. Reboot required: Pin changes are written to NVS immediately but only take effect after reboot. Reconfiguring GPIOs while the stepper ISR is running would be unsafe.

Pin Configuration Keys (0x0500–0x0519)

Protocol KeyNVS KeyDescriptionBoard Default
0x0500p_sxStep X GPIOBoard-specific
0x0501p_syStep Y GPIOBoard-specific
0x0502p_szStep Z GPIOBoard-specific
0x0503p_saStep A GPIOBoard-specific
0x0504p_sbStep B GPIOBoard-specific
0x0505p_scStep C GPIOBoard-specific
0x0506p_dxDir X GPIOBoard-specific
0x0507p_dyDir Y GPIOBoard-specific
0x0508p_dzDir Z GPIOBoard-specific
0x0509p_daDir A GPIOBoard-specific
0x050Ap_dbDir B GPIOBoard-specific
0x050Bp_dcDir C GPIOBoard-specific
0x050Cp_enEnable GPIOBoard-specific
0x050Dp_lxLimit X GPIOBoard-specific
0x050Ep_lyLimit Y GPIOBoard-specific
0x050Fp_lzLimit Z GPIOBoard-specific
0x0510p_laLimit A GPIOBoard-specific
0x0511p_lbLimit B GPIOBoard-specific
0x0512p_lcLimit C GPIOBoard-specific
0x0513p_prbProbe GPIOBoard-specific
0x0514p_estE-Stop GPIOBoard-specific
0x0515p_spnSpindle GPIOBoard-specific
0x0516p_ledStatus LED GPIOBoard-specific
0x0517p_cpCharge Pump GPIOBoard-specific*
0x0518p_mo0Misc Output 0 GPIOBoard-specific*
0x0519p_mo1Misc Output 1 GPIOBoard-specific*

* Charge pump and misc outputs only available on boards that define HAS_CHARGE_PUMP or MISC_OUTPUT_COUNT > 0 (e.g. ESP32-S3-DevKitC). All pin keys are WCNC_VAL_UINT8 (GPIO number 0–48).

Safety: Pin changes require a reboot to take effect. The firmware logs "Pin config 0x05XX updated — reboot required" when a pin key is written. Reassigning GPIOs while the stepper ISR is active would cause undefined behaviour.

GUI Pin Configuration

The protocol test GUI (tools/protocol_test_gui.py) includes a Pins tab for reading and writing all 26 pin assignments. The tab shows:

Network Stack

PortProtocolDirectionPurpose
58427UDPPC → ESP32Motion segments, jog, E-stop, home, I/O
58428UDPESP32 → PCStatus reports (50ms interval)
58429TCPBidirectionalHandshake, config get/set, keepalive
23TCP (telnet)BidirectionalG-code text interface (single client)
UART0SerialBidirectionalG-code text interface (115200 baud)

Ethernet (W5500) Setup

The Tiggy Pro board supports wired Ethernet via an external W5500 SPI module (such as the USR-ES1 breakout board). When a W5500 module is connected, the firmware automatically uses Ethernet instead of WiFi. If no module is detected at power-on, WiFi starts as normal. All your existing software (Mach3 plugin, LinuxCNC, G-code senders) works identically over Ethernet — the only difference is you connect via a network cable instead of WiFi.

What You Need

Wiring Diagram

Connect the W5500 module to the ESP32-S3-DevKitC using these 5 wires, plus power:

USR-ES1 W5500 Module ESP32-S3-DevKitC (Tiggy Pro board) ======================== ==================================== 3.3V ───────────────────────── 3V3 pin GND ───────────────────────── GND pin MOSI ───────────────────────── GPIO 45 MISO ───────────────────────── GPIO 46 SCLK ───────────────────────── GPIO 0 (BOOT button pin, safe after startup) CS ── tie to GND ──────────── (not connected to ESP32, tied to module GND) INT ───────────────────────── GPIO 47 RST ── tie to 3.3V ────────── (not connected to ESP32, tied to module 3V3)
Why CS is tied to GND: The W5500 is the only device on the SPI bus, so it’s always selected. This saves a GPIO pin. If you later add other SPI devices, you can reassign a GPIO for CS using the Protocol Tester.

Pin Summary

W5500 PinESP32 GPIONotes
MOSI45SPI data out (ESP32 → W5500)
MISO46SPI data in (W5500 → ESP32)
SCLK0SPI clock
INT47Interrupt (was Misc Input 2, reassigned for Ethernet)
CSTie to GND (always selected)
RSTTie to 3.3V (always running)
3.3V3V3Power supply
GNDGNDGround

Changing the Pins

The default pins work out of the box with no configuration. If your board uses different GPIOs, open the Protocol Tester (connect via WiFi first), go to the Pins tab, and change the Ethernet pin fields (Eth MOSI, Eth MISO, Eth SCLK, Eth INT). Click Write & Save and reboot the controller.

How It Works

  1. On power-up, the firmware tries to initialise the W5500 over SPI
  2. If a W5500 responds, the firmware uses Ethernet and waits up to 5 seconds for a DHCP address
  3. If Ethernet gets an IP address, WiFi is not started — everything runs over Ethernet
  4. If no W5500 is found (or no Ethernet cable plugged in), WiFi starts as normal

Nothing else changes. The same TCP/UDP ports, the same protocol, the same software on the PC side. Your Mach3 plugin, LinuxCNC HAL, or G-code sender connects by IP address — it doesn’t matter whether that IP came from WiFi or Ethernet.

Note: Ethernet uses GPIO 47, which was previously Misc Input 2 (reducing the Pro board from 4 to 3 misc inputs). If you don’t need Ethernet, you can reassign GPIO 47 back to Misc Input 2 in the Protocol Tester Pins tab. The Tiggy Standard board (S3-Zero) is not affected — it has no Ethernet support and keeps its original pin assignments.

I/O Expansion Module Setup

You can use a second ESP32 board as an I/O expansion module to add extra buttons, switches, and relay outputs to your CNC machine. This is useful for pendant controls, external indicator lights, or additional limit switches.

The I/O module runs the same firmware as the main motion controller — you just change one setting to switch it into I/O mode. No separate download or build is needed.

What You Need

Step-by-Step Setup

  1. Flash the firmware onto the second ESP32 board using the Web Flasher (same as the main controller)
  2. Connect to the board — it will create a WiFi hotspot called WiFiCNC-XXXX. Connect your PC to that hotspot, then open the Protocol Tester and connect to 192.168.4.1
  3. Set up WiFi — go to the WiFi tab in the Protocol Tester, enter your network name and password, click Save. The board will reboot and join your network
  4. Reconnect — connect your PC back to your normal WiFi network. Use the Protocol Tester’s Discover button to find the board’s new IP address
  5. Switch to I/O Module mode — in the Config tab, find the I/O Module section. Change Device Mode from “Motion Controller” to “I/O Module”
  6. Set the number of channels — enter how many I/O pins you want to use (e.g. 8 for 8 buttons/outputs)
  7. Set the direction — the Dir Mask controls which channels are inputs and which are outputs. Each bit is one channel: 0 = input (button/switch), 1 = output (LED/relay). For example, 00FF means channels 0–7 are outputs, 8–15 are inputs
  8. Assign GPIO pins — in the channel GPIO pin fields, enter the GPIO number for each channel. For example, if channel 0 is on GPIO 1, enter 1 in the first field. Set unused channels to 255
  9. Click Write & Save, then reboot the board
  10. Configure the Mach3 plugin — in the plugin’s I/O Module tab, tick Enable I/O Module, enter the I/O module’s IP address, and assign functions to each input/output channel

How the I/O Module Communicates

The I/O module uses the same TCP/UDP protocol as the main controller. It identifies itself as an I/O module during the handshake (the device_mode field in the handshake response is 1 instead of 0). The Mach3 plugin connects to both the main controller and the I/O module using separate IP addresses.

Tip: The I/O module does not run the stepper engine or motion planner — it skips those at startup. This means it uses less memory and boots faster. Any ESP32 board can be an I/O module, even the compact S3-Zero.

G-Code Interpreter

GRBL 1.1-compatible G-code interpreter enabling standalone CNC operation with any standard G-code sender (UGS, CNCjs, bCNC, LaserGRBL). Runs entirely on Core 0.

Data Flow

Telnet/UART byte stream → Real-time chars (?, !, ~, 0x18) handled immediately → Line accumulation on \n or \r → gcode_interface_submit_line() → FreeRTOS queue (8 slots) → gcode_exec_task picks line from queue → "$" prefix: handle_dollar_command() ($$, $#, $I, $G, $H, $X, $J=, $N=value) → Otherwise: gcode_parse_line() → gcode_validate_block() → execute_block() → compute_target() applies WCS + G92 + TLO + unit conversion → G0/G1: gcode_planner_line() → junction velocity → planner_push_segment() → G2/G3: gcode_arc_execute() → linearized line segments → G38.x: probe segment with ISR detection → M3/M4/M5: gpio_control_set_spindle() → Send "ok\r\n" or "error:N\r\n"

Parser & Modal State Machine (gcode_parser.c)

Parsing Pipeline

  1. Strip comments: (...) inline, ; end-of-line
  2. Character-by-character tokenization: extract letter-value pairs
  3. Dispatch G-codes by integer×10 representation (e.g., G38.2 = 382, G43.1 = 431)
  4. Modal group conflict detection (error 21 if two G-codes from same group)
  5. Populate gcode_block_t with all parsed data

Modal Groups

GroupNameCodesDefault
1MotionG0, G1, G2, G3, G38.2-.5, G80G0
2PlaneG17, G18, G19G17 (XY)
5Feed modeG93, G94G94
6DistanceG90, G91G90 (abs)
7UnitsG20, G21G21 (mm)
8TLOG43.1, G49G49 (none)
12WCSG54-G59G54
13Path controlG61, G64G64 (blend)
16Arc distanceG90.1, G91.1G91.1 (inc)

Key Data Structures

gcode_block_t — Parsed result from one line

gcode_state_t — Persistent modal state across lines

Look-Ahead Planner (gcode_planner.c)

64-slot circular buffer that sits above the existing 128-slot ring buffer. Computes junction velocities via forward/backward passes, then emits pre-planned wcnc_motion_segment_t to the ring buffer. Includes a one-move corner-smoothing buffer that inserts rounding arcs at sharp corners when G64 P tolerance is active.

Key insight: Core 1 code (motion_control, stepper, planner ring buffer) is completely untouched. The look-ahead planner outputs the same segment format the binary protocol uses.

Block Addition Pipeline (gcode_planner_line())

  1. Geometry: Compute delta mm → steps, distance, unit vector, step_event_count
  2. Axis-limited max speed: For each axis, limit = max_rate[i] / |unit_vec[i]|. Clamp nominal to minimum.
  3. Axis-limited acceleration: Same approach for acceleration.
  4. Junction entry speed: Dot product of prev/current unit vectors → cornering speed (see below)
  5. Backward pass: Walk newest→oldest, ensure kinematic feasibility
  6. Forward pass: Walk oldest→newest, clamp entry speeds to achievable values
  7. Adaptive emission: Emit settled blocks based on ring buffer fill level

Junction Velocity — Constant Velocity Vectoring (G64)

G64 Mode (Default — Path Blending)

The machine maintains speed through corners instead of stopping. Junction speed is computed from the angle between consecutive move vectors and a configurable deviation parameter.

Algorithm (GRBL Junction Deviation Method)

cos_theta = dot(prev_unit_vec, current_unit_vec)
sin_half  = sqrt(0.5 * (1.0 - cos_theta))

effective_deviation = max(junction_deviation, path_tolerance)

if sin_half > 0.99:
    v_junction = 0                // Direction reversal, must stop
elif sin_half < 0.006:
    v_junction = INFINITY         // Straight line, no speed limit
else:
    v_junction = sqrt(accel * effective_deviation * sin_half / (1.0 - sin_half))

v_junction = min(v_junction, prev_nominal, current_nominal)

Configuration

SettingNVS KeyDefaultEffect
$11junc_dev0.01 mmJunction deviation tolerance
G64 Pn0 mmPath tolerance (overrides $11 if larger)

G61 Mode (Exact Stop)

Every move decelerates to zero at its endpoint. max_entry_speed = 0 for all blocks. Use for precision positioning (drilling, probing).

Adaptive Emission

The emission logic monitors downstream ring buffer fill level to prevent underruns:

Ring Buffer StateLook-Ahead DepthBehaviour
>75% empty1 blockEmergency: emit immediately
>50% empty8 blocksReduced planning quality
≤50% empty20 blocksNormal: full velocity optimization

Corner Smoothing (gcode_planner.c)

When G64 P<tolerance> is active with a non-zero tolerance, the planner inserts rounding arcs at sharp corners so the machine curves through them instead of decelerating to the junction speed. This allows much higher sustained feed rates through complex toolpaths.

Architecture: One-Move Lookahead

A one-move buffer sits between the G-code executor and the planner. Only G1 linear feed moves go through the smoother — G0 rapids, arcs, and probes bypass it and flush the buffer first.

G-code executor
    ↓
gcode_planner_line_smooth()    // Buffers one move, detects corners
    ↓
gcode_planner_line()           // Unchanged, adds to look-ahead buffer
    ↓
ring buffer → stepper ISR

Corner Detection & Rounding

When two consecutive G1 moves form a corner with turn angle >18°, the smoother:

  1. Computes the turn angle α from direction vectors
  2. Calculates rounding radius: R = tolerance / (1 - cos(α/2))
  3. Calculates tangent length: t = R × tan(α/2)
  4. Shortens the first move to an approach point (corner - t × prev_dir)
  5. Inserts 2–10 arc segments from approach to departure point
  6. Buffers the remainder of the second move for the next corner check

Rounding Geometry

                   approach
      prev_move ----•
                     \  ← rounding arc (radius R)
                      ○ (programmed corner)
                     /
      new_move  ----•
                   departure

Deviation = R × (1 - cos(α/2)) = G64 P tolerance
Arc segments use Gram-Schmidt orthogonalization for N-dimensional axis space

Skip Conditions

Example

G64 P0.05         ; 0.05mm path tolerance
G1 X100 F600      ; First move
G1 Y100           ; 90° corner → R=0.17mm rounding arc inserted
                   ; Machine curves through at near-full speed
                   ; Max deviation from programmed path: 0.05mm

Arc Interpolation (gcode_arcs.c)

Converts G2 (CW) and G3 (CCW) arcs into short line segments. Supports:

Segment Count Calculation

theta_per_segment = 2 * acos(1 - tolerance / radius)
segments = ceil(|angular_travel| / theta_per_segment)
segments = clamp(segments, 1, 2000)

Arc tolerance is configurable via $12 (default 0.002 mm). Smaller tolerance = more segments = smoother arcs. Last segment always uses exact target coordinates to prevent floating-point drift.

Probing (G38.x)

G-CodeDirectionError on No Contact
G38.2Toward workpieceYes (alarm 4)
G38.3Toward workpieceNo
G38.4Away from workpieceYes (alarm 4)
G38.5Away from workpieceNo

Probe Sequence

  1. Flush all pending motion (gcode_planner_sync)
  2. Wait for stepper to complete
  3. Push single segment with WCNC_SEG_FLAG_PROBE | WCNC_SEG_FLAG_LAST
  4. Stepper ISR checks probe pin on every step tick via direct GPIO register read
  5. On trigger: ISR sets probe_triggered = true, stops immediately
  6. Record position from stepper counter as probe result
  7. Probe position queryable via $# (PRB field) or ? status

Jog Commands (gcode_jog.c)

$J=G91 X10 F1000       // Jog X +10mm at 1000mm/min (incremental)
$J=G90 X0 Y0 F500      // Jog to X0 Y0 at 500mm/min (absolute)
$J=G21 G91 Z-5 F200    // Jog Z -5mm in metric

Supports G90/G91 (distance mode), G20/G21 (units), any axis (X-C), F (feed rate required). Jog moves cancel on feed hold.

GRBL Settings (grbl_settings.c)

Maps GRBL $N=value commands to the NVS configuration system with automatic unit conversion.

$NNVS KeyDescriptionConversion
$0pulse_usStep pulse time (us)None
$1idle_msStep idle delay (ms)None
$2inv_stepStep port invert maskNone
$3inv_dirDir port invert maskNone
$5inv_limLimit pin invert maskNone
$6inv_prbProbe pin invertNone
$11junc_devJunction deviation (mm)None
$12arc_tolArc tolerance (mm)None
$23hm_dirHoming dir invert maskNone
$24hm_feedHoming feed (mm/min)mm/min → steps/sec
$25hm_seekHoming seek (mm/min)mm/min → steps/sec
$27hm_pullHoming pull-off (mm)mm → steps
$100-$105spm_x..spm_cSteps/mm per axisNone (float)
$110-$115rate_x..rate_cMax rate (mm/min)mm/min → steps/sec
$120-$125acc_x..acc_cAccel (mm/sec²)mm/sec² → steps/sec²
$130-$132trvl_x..trvl_zMax travel (mm)None
$40jerk_maxMax jerk (mm/sec³)None (float). 0 = trapezoidal.
Unit conversion: NVS stores rates as steps/sec and accel as steps/sec². GRBL $settings use mm/min and mm/sec². The conversion uses the axis's steps/mm value from NVS. Changing $100 (steps/mm) affects the interpretation of $110 (max rate) and $120 (accel).

Real-Time Commands

These bytes are detected in the raw byte stream and handled immediately (not queued):

ByteCharacterAction
0x3F?Send GRBL-format status report
0x21!Feed hold — controlled deceleration to stop
0x7E~Cycle start / resume from hold
0x18Ctrl-XSoft reset with full position preservation

Soft Reset Sequence (Ctrl-X)

  1. motion_estop() — stops steppers, position preserved in counter
  2. gcode_planner_clear() — discards look-ahead blocks, position NOT zeroed
  3. planner_clear() — discards ring buffer segments
  4. motion_reset() — state → IDLE
  5. gcode_planner_sync_position() — reads actual stepper position
  6. Parser re-initialized to defaults (G54, G17, G90, G21, G64), position preserved
  7. Line queue flushed, welcome message sent

Status Report Format

<Idle|MPos:0.000,0.000,0.000,0.000,0.000,0.000|FS:0,0|Bf:120,45>
<Run|MPos:35.123,20.456,-5.000,0.000,0.000,0.000|FS:600,12000|Bf:98,32|WCO:10.000,5.000,0.000>
FieldDescriptionSource
StateIdle, Run, Hold:0, Jog, Home, Alarm, Checkmotion_get_state()
MPosMachine position in mm (always authoritative)stepper_get_position() / steps_per_mm
FSFeed rate (mm/min), spindle speed (RPM)stepper_get_feed_rate()
BfRing buffer free, look-ahead buffer freeplanner_available(), gcode_planner_available()
WCOWork coordinate offset (sent every 10th query)s_state.wcs[] + g92_offset[]

Transports

Telnet (gcode_telnet.c) — Port 23

UART (gcode_uart.c) — USB Serial

Output conflict: Both telnet and UART can set the output callback. The last one to connect wins. In practice, use one or the other, not both simultaneously. The telnet handler clears the callback on disconnect.

Binary Protocol Specification

Magic: 0x574D4333 ("WMC3"), Protocol version: 1, CRC-16/CCITT checksum.

Packet Header (18 bytes)

typedef struct {
    uint32_t magic;             // 0x574D4333
    uint8_t  version;           // 1
    uint8_t  packet_type;       // WCNC_PKT_*
    uint16_t payload_length;    // Bytes after header
    uint32_t sequence;          // Incrementing counter
    uint32_t timestamp;         // Milliseconds
    uint16_t checksum;          // CRC-16/CCITT over entire packet
} wcnc_header_t;

Motion Segment Format

typedef struct {
    int32_t  steps[6];          // Step count per axis (signed)
    uint32_t duration_us;       // Segment duration (unused by stepper)
    uint32_t entry_speed_sqr;   // Entry speed squared / 1000
    uint32_t exit_speed_sqr;    // Exit speed squared / 1000
    uint32_t acceleration;      // Acceleration * 100 (steps/sec²)
    uint16_t segment_id;        // Tracking ID
    uint8_t  flags;             // WCNC_SEG_FLAG_*
} wcnc_motion_segment_t;

Segment Flags

FlagValueMeaning
WCNC_SEG_FLAG_RAPID0x01Rapid traverse (G0)
WCNC_SEG_FLAG_LAST0x02Last segment in sequence
WCNC_SEG_FLAG_PROBE0x04Probe move (check probe pin)
WCNC_SEG_FLAG_EXACT_STOP0x08Decel to zero at end

IO Control Packet (PC → ESP32, UDP)

Controls spindle, coolant, and misc outputs. Sent via UDP alongside motion packets. The ESP32 stores these states and reports them back in the status report (ground truth).

typedef struct {
    wcnc_header_t header;
    uint8_t  misc_outputs;    // Bitmask: bit 0..4 misc output states
    uint8_t  spindle_state;   // 0=off, 1=CW, 2=CCW
    uint16_t spindle_rpm;     // Target RPM (0..max_rpm)
    uint8_t  coolant_state;   // bit 0=flood, bit 1=mist
    uint8_t  reserved[3];
} wcnc_io_control_packet_t;  // Total: 26 bytes

Mach3 Plugin: Misc Output Triggers

Misc outputs (EXTACT1–5) are controlled via G-code or the Mach3 UI. The plugin activates Engine->OutSigs[EXTACT1..5].active = true at init so Mach3 processes these commands.

G-codeTypeEffect
M62 P0Synced with motionTurn ON misc output 1 (EXTACT1)
M63 P0Synced with motionTurn OFF misc output 1 (EXTACT1)
M64 P0ImmediateTurn ON misc output 1 (EXTACT1)
M65 P0ImmediateTurn OFF misc output 1 (EXTACT1)
P parameter is 0-indexed: P0=Misc1, P1=Misc2, P2=Misc3, P3=Misc4, P4=Misc5

M62/M63 are synchronized with motion (queued in the trajectory buffer). M64/M65 take effect immediately. Both result in an I/O control packet being sent to the controller firmware.

Status Report Extended Fields (v1.1)

Offset 46–49 of the status report payload (total 50 bytes):

FieldOffsetTypeDescription
misc_outputs46u8Bitmask of active misc outputs
misc_inputs47u8Bitmask of misc input states
spindle_state48u80=off, 1=CW, 2=CCW (from last IO control)
coolant_state49u8bit 0=flood, bit 1=mist (from last IO control)
Ground truth: The config dialog reads spindle/coolant/misc states from the ESP32 status report, not from Mach3 Engine fields. This eliminates the garbage-value problem where Engine->SpindleCW can contain non-boolean values (e.g. 7).

Status LED Module (io/status_led.c)

Drives the onboard LED with connection/state indication. Supports WS2812 RGB LEDs on ESP32-S3 boards via the RMT peripheral.

StateWS2812 (S3 boards)Plain LED (WROOM-32)
Disconnected (no host)Red, slow blinkOff
Connected, idleBlue, solidOn, solid
Connected, runningGreen, solidOn, slow blink
Alarm / E-stopYellow, fast blinkOn, fast blink

Board LED pins: S3-Zero = GPIO 21, S3-DevKitC = GPIO 48, WROOM-32 = GPIO 2. Colours are intentionally dim (20/255 brightness) to avoid distraction.

Binary / G-Code Coexistence

Both the binary protocol (Mach3/LinuxCNC) and G-code interpreter share the same planner ring buffer via planner_push_segment(). They are mutually exclusive at the motion level:

Preemption Mechanism

  1. When binary protocol receives PKT_MOTION_SEGMENT, it calls gcode_planner_preempt()
  2. This sets s_planner.preempted = true
  3. The G-code look-ahead planner checks this flag before every planner_push_segment() call and pauses emission
  4. Un-emitted look-ahead blocks are retained — no data loss
  5. When binary protocol sends PKT_RESET, it calls gcode_planner_release()
  6. G-code planner resumes emission
Practical usage: Connect UGS via telnet for G-code testing. Connect Mach3 via binary protocol for production. They can coexist on the same firmware; binary takes priority when it starts sending motion.

Critical Behaviour: Constant Velocity Vectoring

The #1 requirement for quality CNC motion. Without it, the machine stops between every G1 move, producing faceted surfaces and slow cycle times.

How It Works

When G64 is active (default), the planner computes a non-zero junction speed for every corner. The stepper never decelerates to zero unless:

Example: 90-Degree Corner

G64 P0.01         ; Path blending, 0.01mm tolerance
G1 X100 F600      ; Move X at 600mm/min
G1 Y100           ; 90-degree turn to Y
                   ; Junction speed: ~30mm/min (computed from deviation)
                   ; Machine rounds the corner smoothly
G61               ; Exact stop mode
G1 X100 F600      ; Move X at 600mm/min
G1 Y100           ; Full stop at corner, then accelerate to Y
                   ; Junction speed: 0mm/min (exact stop)

Critical Behaviour: Buffer Underrun Recovery

If the ring buffer runs dry (WiFi dropout, sender stall, heavy computation):

  1. Motion control task transitions to IDLE (existing behaviour)
  2. Position is preserved — stepper position counter is always accurate
  3. When new segments arrive, motion_control_task pops and resumes RUN — seamless
  4. planner->underrun_count increments for diagnostics
  5. Adaptive emission kicks in: look-ahead depth reduces to 1 to feed buffer ASAP
No data loss: The look-ahead buffer retains un-emitted blocks. A WiFi dropout only delays emission, never loses planned moves.

Critical Behaviour: E-Stop Position Preservation

This solves the Mach3 + SmoothStepper problem where E-stop loses position sync and the job can never resume.

Core principle: Position is always authoritative on the ESP32. The host reads position from the controller, never the other way around.

E-Stop Recovery Workflow

1. E-stop triggered
   → stepper_estop(): stops timer, disables outputs
   → position[] PRESERVED (not zeroed)
   → state → ESTOP

2. User fixes issue, sends Reset (Ctrl-X or PKT_RESET)
   → gcode_planner_clear(): discards look-ahead, position untouched
   → planner_clear(): discards ring buffer
   → motion_reset(): state → IDLE
   → gcode_planner_sync_position(): reads actual stepper position

3. Query position
   → "?" returns: <Idle|MPos:35.000,20.000,-5.000,...>
   → This IS the real physical position where the machine stopped

4. Resume
   → G-code sender reads MPos, knows exact position
   → Can re-home, continue from known position, or restart job
   → No phantom position drift, no lost steps in the controller

Critical Behaviour: Comms/Motion Isolation

Building & Flashing

Prerequisites

Board Selection

The board define controls which pin assignments are loaded from pin_config.h at compile time. It must be set in the build configuration for whichever build system you use:

PlatformIO (recommended)

Edit firmware/platformio.ini, change the -DBOARD_* flag in build_flags:

build_flags =
    -DBOARD_ESP32S3_ZERO       ; ← change this line
    -I../../protocol

ESP-IDF (idf.py)

Auto-detected from IDF target in pin_config.h (S3 → DevKitC, ESP32 → WROOM32). Override with a cmake define if needed:

idf.py build -DBOARD_ESP32S3_ZERO

Available Boards

DefineBoardNotes
BOARD_ESP32S3_ZEROWaveshare ESP32-S3-ZeroCompact, limited GPIO, no charge pump
BOARD_ESP32S3_DEVKITCESP32-S3-DevKitC-1Most GPIO, charge pump + 2 misc outputs
BOARD_ESP32_WROOM32ESP32-WROOM-32Classic ESP32, input-only GPIOs

Build & Flash

PlatformIO

cd firmware
pio run                          # Build
pio run -t upload                # Flash
pio device monitor               # Serial monitor
pio run -t upload -t monitor     # All in one

ESP-IDF

cd firmware
idf.py set-target esp32s3
idf.py build
idf.py -p COMx flash monitor

Connecting G-Code Senders

UGS (Universal G-code Sender)

  1. Connection type: Serial
  2. Port: socket://<ESP32_IP>:23
  3. Firmware: GRBL
  4. Click Connect

CNCjs

  1. Connection: Serial port
  2. Port: socket://<ESP32_IP>:23
  3. Controller type: Grbl

bCNC

  1. Port: socket://<ESP32_IP>:23
  2. Controller: GRBL1

Serial Terminal (direct USB)

  1. Connect USB cable to ESP32
  2. Open terminal at 115200 baud, 8N1
  3. Type G-code commands directly

Protocol Test GUI (binary protocol)

cd tools
python protocol_test_gui.py

Enter the ESP32's IP address and click Connect. Uses the binary protocol on UDP/TCP ports.

Testing Procedures

Basic Connectivity

telnet <ESP32_IP> 23
# Should see: Grbl 1.1h ['$' for help]
?
# Should see: <Idle|MPos:0.000,0.000,0.000,...>
$$
# Should see all settings dumped

Motion Test

G21              ; Set mm mode
G90              ; Absolute positioning
G64 P0.01        ; Path blending with 0.01mm tolerance
G1 X10 F600      ; Linear move 10mm at 600mm/min
G1 Y10           ; Should maintain speed through corner
G1 X0 Y0         ; Return to origin
?                ; Verify position back at 0,0,0

Arc Test

G2 X10 Y0 I5 J0 F600    ; CW semicircle
G3 X0 Y0 I-5 J0 F600    ; CCW semicircle back

E-Stop Recovery Test

G1 X100 F600     ; Start long move
; During motion: send Ctrl-X (0x18)
?                ; Check MPos — should show actual stopped position (e.g. X35.123)
G1 X0 F600       ; Return to zero from actual position
?                ; Verify X0.000

Probe Test

G38.2 Z-10 F100  ; Probe toward, expect contact
$#               ; Check PRB position
?                ; Verify stopped position matches probe result

GRBL Command Reference

CommandDescription
$$Dump all settings
$#Dump coordinate offsets (G54-G59, G28, G30, G92, PRB)
$GDump active modal state
$IBuild info
$HRun homing cycle (all axes)
$XKill alarm / unlock
$J=...Jog command (e.g. $J=G91 X10 F1000)
$N=valueSet setting N (e.g. $100=800)
$NQuery setting N

Supported G/M Codes

G-Codes

CodeGroupDescription
G0MotionRapid traverse
G1MotionLinear interpolation
G2MotionCW arc (IJK or R format)
G3MotionCCW arc (IJK or R format)
G4Non-modalDwell (P seconds)
G10 L2Non-modalSet WCS offset
G10 L20Non-modalSet WCS from current position
G17PlaneXY plane (default)
G18PlaneZX plane
G19PlaneYZ plane
G20UnitsInches mode
G21UnitsMillimeter mode (default)
G28Non-modalReturn to G28.1 stored position
G28.1Non-modalStore current position for G28
G30Non-modalReturn to G30.1 stored position
G30.1Non-modalStore current position for G30
G38.2MotionProbe toward, error on no contact
G38.3MotionProbe toward, no error
G38.4MotionProbe away, error on no contact
G38.5MotionProbe away, no error
G40Cutter compCancel cutter compensation (no-op, Mach3 compat)
G43TLOTool length offset from tool table (H word selects tool)
G43.1TLODynamic tool length offset
G49TLOCancel tool length offset
G53Non-modalMove in machine coordinates
G54-G59WCSSelect work coordinate system
G61PathExact stop mode
G64PathConstant velocity / path blending (default)
G80MotionCancel canned cycle
G90DistanceAbsolute mode (default)
G91DistanceIncremental mode
G90.1Arc distAbsolute arc centres
G91.1Arc distIncremental arc centres (default)
G92Non-modalSet coordinate offset
G92.1Non-modalClear G92 offset
G93Feed modeInverse time feed mode
G94Feed modeUnits per minute (default)

M-Codes

CodeDescription
M0Program pause (wait for cycle start ~)
M1Optional stop
M2Program end
M3Spindle CW
M4Spindle CCW
M5Spindle stop
M6Tool change (flushes motion, logs tool number)
M7Mist coolant on
M8Flood coolant on
M9All coolant off
M30Program end and rewind
M62 PnTurn ON misc output n (synced with motion), P0=Misc1 .. P4=Misc5
M63 PnTurn OFF misc output n (synced with motion)
M64 PnTurn ON misc output n (immediate)
M65 PnTurn OFF misc output n (immediate)

NVS Config Key Reference

NVS KeyTypeDefaultDescription
pulse_usu163Step pulse width (us)
dir_usu162Dir setup time (us)
idle_msu16255Stepper idle delay (ms)
spm_x..cfloat800.0Steps per mm per axis
rate_x..cu3220000Max rate (steps/sec)
acc_x..cu325000Acceleration (steps/sec²)
inv_stepu80x00Step pin invert mask
inv_diru80x00Dir pin invert mask
inv_enu80x00Enable pin invert
inv_limu80x3FLimit pin invert mask
inv_homu80x3FHome pin invert mask
inv_estu80x00E-stop pin invert
inv_prbu80x00Probe pin invert
hm_diru80x00Homing direction mask
hm_seeku325000Homing seek rate (steps/sec)
hm_feedu32500Homing feed rate (steps/sec)
hm_pullu32100Homing pulloff (steps)
junc_devfloat0.01Junction deviation (mm)
arc_tolfloat0.002Arc tolerance (mm)
jerk_maxfloat1000.0Max jerk (mm/sec³), 0=trapezoidal
trvl_x..zfloat200.0Max travel per axis (mm)
Pin Assignments (0x0500–0x0519) — require reboot
p_sx..p_scu8Board-specificStep GPIO per axis (X–C)
p_dx..p_dcu8Board-specificDir GPIO per axis (X–C)
p_enu8Board-specificStepper enable GPIO
p_lx..p_lcu8Board-specificLimit switch GPIO per axis (X–C)
p_prbu8Board-specificProbe input GPIO
p_estu8Board-specificE-Stop input GPIO
p_spnu8Board-specificSpindle enable GPIO
p_ledu8Board-specificStatus LED GPIO
p_cpu8Board-specificCharge pump GPIO (if available)
p_mo0, p_mo1u8Board-specificMisc output GPIOs (if available)

Memory Budget

ComponentSizeLocation
Planner ring buffer (128 × 52B)6656 BStatic (BSS)
Look-ahead buffer (64 × 120B)7680 BStatic (BSS)
Line queue (8 × 260B)2080 BFreeRTOS heap
Modal state + WCS offsets~424 BStatic (BSS)
Telnet + UART buffers~1536 BStack + static
GRBL settings cache + scratch~512 BStack
Stepper state (DRAM)~512 BDRAM (ISR-accessible)
S-curve phase state~64 BDRAM (ISR-accessible)
Corner smooth buffer (6 floats + flags)~32 BStatic (BSS)
Total G-code allocation~13 KB
Total firmware RAM: 48 KB / 320 KB (14.7%)

WiFi CNC Controller — Engineering Reference — Generated February 2026