Introduction
VitalCNC
A Windows based CNC Software for mills, routers, lathe, water-jet, plasma tables etc, utilizing VSI HiCON motion controllers. It provides a Lua scripting engine underneath every screen, and diagnostics deep enough to debug even the controller itself, not just the G-code running on it.
Everything on that screen is driven by the same registers and the same scripting API documented in the rest of this manual:
- Position & motion — per-axis DRO, jogging (screen, keyboard, or USB pendant — Chapter 1), work offsets, homing, soft/hard limits.
- Program execution — G-code list with line highlighting, cycle start/feed hold/stop, single-block, and override sliders for feed, rapid, and spindle.
- Live 3D toolpath view — orbit/pan/zoom, a view cube for top, front, side and corner views (0.3), rendered from the same motion stream the controller is executing.
- Tool & spindle control — tool table, CW/CCW/stop, RPM readout, and — on plasma profiles — the cut-chart-driven pierce/cut sequence (Chapter 4).
- Probing — corner, outer, pocket, angle, and Z-touch-off routines that write straight into work offsets.
- Config & diagnostics — motors, I/O, tool changers (Chapter 5), and machine I/O mapping (Chapter 6), all reachable without leaving the run screen.
0.2Built-in diagnostics
The tools below ship with every VitalCNC install — nothing extra to download when a machine is misbehaving and you need to see what the controller actually sees, not what the G-code says it should be doing.
- MotionScope — a live, streaming trace of the HiCON 1 kHz motion loop. Pick any motor as X/Y/AXIS, plot command vs. feedback counts or following error, freeze the trace, and export the window to CSV for offline analysis. This is usually the fastest way to tell "the controller commanded it and the drive didn't follow" apart from "the controller never commanded it."
- HiCON Controller Status — the raw per-motor table (Command, StepGen, Feedback, FollowErr) plus buffer health (Vectors/FIFO) and every digital input/output pin, with outputs click-to-toggle for bench testing without a running program.
- Macro Debug Messages — the live console for everything a profile's
Lua scripts print, including the PLC scan (Section 2.6) and tool-state
changes as they happen — the same place you will be looking while writing or debugging a
plc.luaor a macro. - Register Diagnostics (Section 3.5) — inspect and watch any published register live, the same values your screens and scripts read.
None of these tools are a simulator or a separate debug build — MotionScope and HiCON
Controller Status are reading the same live feedback and register values that
plc.lua, your macros, and the screen objects read. If a diagnostic panel
disagrees with what the machine is physically doing, that is itself the bug report.
0.3The toolpath view
The 3D view shows the loaded program and, while it runs, the path the tool has actually cut.
| To | Do |
|---|---|
| Turn the part | Drag with the left button. |
| Slide it | Drag with the right button. |
| Zoom | Scroll. The point under the pointer stays where it is. |
| Look from the top, the front, a side or a corner | Click that face of the view cube in the top-right corner. The cube turns with the part, so it always shows which way you are looking. |
| Fit the whole program | Click the house above the cube, double-click empty space, or press R. |
| Perspective or flat, rapids shown or hidden, the tool marker, the DRO | Click the gear under the cube, or right-click anywhere in the view. |
| Find a move in the G-code | Click the move. The G-code list jumps to its line; clicking a line lights up its move. |
The cube fades when the pointer is away from it so it does not hide the path underneath. Move toward it and it comes back.
0.4How this manual is organized
Each chapter stands on its own — jump straight to the one covering the subsystem you are commissioning:
- Chapter 1 — USB Pendant Support
- Chapter 2 — Scripting API
- Chapter 3 — Registers
- Chapter 4 — Plasma
- Chapter 5 — Tool Changers
- Chapter 6 — Machine I/O
- Chapter 7 — Aux Motors
- Chapter 8 — Homing
Chapters 9–12 (profiles & config, motion, screen designer, probing) are in preparation — see the sidebar.
Chapter 1
USB Pendant Support
The XHC handwheel: what its two dials actually control, why a wheel that looks dead is usually refusing for a good reason, and how to put its state on a screen.
1.1Connecting
One setting: Config → General → Enable the XHC USB pendant. Off closes the USB connection and releases the device, so a vendor utility or another program can claim it. The setting belongs to the PC, not to a machine profile — a pendant unplugged for another application is unplugged for every profile.
Not "reports are arriving". The pendant sends nothing when nobody is touching it — measured, 45 seconds untouched produces zero reports. So silence cannot distinguish a resting pendant from a dead one, and there is deliberately no report-flow timeout. Do not add one.
1.2The two dials
| Dial | Selects | OFF / Lead position |
|---|---|---|
| Axis | Which axis the wheel jogs — X Y Z A B C | OFF stops motion immediately, it does not merely stop feeding counts |
| Feed | Millimetres per detent — the step multiplier | Lead is a real detent with no step size; the wheel trims feed override instead of moving |
It is decoded from the physical feed knob, from a fixed table of detent codes. No script can change it — the number printed under the operator's thumb is the number that applies.
The only transform is units: the dial is printed in millimetres, so on an inch machine the figure is multiplied by 25.4. Without that, "0.001" would move 25× less than the dial says.
There are two step multipliers, and they are independent
A profile's initialize() usually contains something like:
Core.setSoftJogSteps({ 1.0, 0.1, 0.01, 0.001 })
| Multiplier | Set by | Cycled by |
|---|---|---|
| Screen / keyboard step | Core.setSoftJogSteps | The on-screen JOG STEP button |
| Pendant step | The pendant's feed knob | Nothing — the knob position is the value |
They do not track each other, and are not meant to. The XHC reports an absolute detent position, so turning the knob to 0.01 selects 0.01 whatever the screen button last showed.
Axis OFF stops motion. Under the accumulate model a fast spin can still have millimetres pending, and at the 5 or 10 mm detents that is a lot of travel to watch play out after the operator has said stop. XHC shipped the same fix in v3.84.
1.3Why counts, never a velocity
The pendant sends the controller signed counts and nothing else. It never latches a speed.
That also makes it fail-safe. If the pendant dies mid-spin the machine finishes the last increment and stops, because there is no standing speed left to honour.
1.4When the wheel does nothing
Four refusals, all legitimate, all indistinguishable from a broken pendant unless the machine says which one it is:
| Reason | Fix |
|---|---|
| Axis dial is OFF | Turn it to an axis |
| No step size selected | Feed dial is on Lead — turn it to a step |
| Machine not enabled | Enable the machine |
| Machine busy | Wait for the program to finish or stop it |
The reason is published as text on pendant/Blocked — empty when the
wheel is working. Bind it to a label on your screen. It is the single
most useful thing this chapter offers.
1.5Pendant registers Shipped
The pendant publishes its whole state as registers, so a diagnostics page is a matter of dropping DROs and LEDs on the canvas and binding paths — there is no pendant window to wait for.
| Path | Meaning |
|---|---|
| pendant/Connected | 1 = the HID device is open. See 1.1 — this is not "reports are arriving". |
| pendant/Axis | 0-5 = X Y Z A B C, −1 = OFF. |
| pendant/AxisName | Text: X … C, or OFF. |
| pendant/StepMm | Millimetres per detent. 0 = no usable step (Lead). |
| pendant/Counts | Last wheel delta, signed. |
| pendant/CountsTotal | Accumulated counts — bind to a DRO to watch the wheel turn. |
| pendant/Button | Last button code. |
| pendant/Lead | 1 = feed dial on Lead; the wheel trims feed override. |
| pendant/DetentBad | 1 = a dial is between detents. The reading is not trusted. |
| pendant/Blocked | Text: why the last wheel movement was refused. Empty = fine. |
| pendant/Device | Text: connection detail. |
| pendant/Status | Text: the one-line status also shown in the status bar. |
-- Refuse to start a program while the pendant has an axis selected.
-- A dial left on Z is a hand on the wheel waiting to happen.
-- reg_find returns nil when no pendant is fitted, which is the whole check.
local axis = vc.reg_find("pendant/Axis")
if axis and vc.reg_get(axis) >= 0 then
vc.fail("pendant axis dial is on " .. vc.reg_get_text(vc.reg_find("pendant/AxisName"))
.. " - turn it to OFF before running")
end
The chord latch stops them misfiring, but binding them to macros is a configuration question rather than a driver one. They are logged, not actioned.
Chapter 2
Scripting API
Everything a machine builder can call from Lua — the macro API, the screen API, and where each one runs.
This chapter covers the API available to scripts you write in a profile.
It does not cover the C API a motion driver implements
(vcAPI_io_register_io, vc_driver_start and the rest) — that is
a separate interface for people writing hardware drivers, and nothing here is callable
from Lua.
2.1Where scripts run
There are three script hosts. They are separate Lua states, so globals are never shared between them — a variable set in one is invisible to the others. Use registers to pass values across.
| Host | File | Thread | Table | Can it move the machine? |
|---|---|---|---|---|
| Macro engine | Macros/m<N>.lua | Interpreter | vc.* | Yes — motion is stopped before the script runs |
| Screen engine | main.lua | Interface | Core.* Gui.* | Only by submitting G-code |
| PLC engine Shipped | plc.lua | Own worker | vc.* (restricted — see 2.6) | Not through the planner — but it commands Aux Motors by writing their command registers (5.8.2), and independent drives via I/O (5.8.1), while machining continues. |
The PLC engine deliberately has no access to dialogs or screen objects. A scan running on its own thread must not block on an operator, and a message box from a worker thread would deadlock the interface.
2.2Macro API — vc.*
Available inside M Code macros mcode_execute() in any Macros/m<N>.lua.
The machine is already stopped when your macro is launched.
Motion and program flow
| Function | Returns | Notes | |
|---|---|---|---|
| vc.exec_line(str) | — | Runs one G-code line. Queues motion; it does not wait. | Shipped |
| vc.exec_file(path) | — | Runs a G-code file inline. | Shipped |
| vc.cntl_wait_for_stop(ms) | bool | Blocks until the machine has actually stopped. 0 waits forever and always returns true; pass a real timeout if you want to detect a stall. | Shipped |
| vc.sleep_ms(ms) | — | Plain delay. Does not break on E-Stop — never poll with it. | Shipped |
| vc.abort() | — | Drops queued motion, keeps running. | Shipped |
| vc.fail(msg) | never returns | Drops queued motion and faults the program with msg. | Shipped |
vc.exec_line only queues motion, while M62–M66
act the instant their block is read. Send a move and toggle an output without a
cntl_wait_for_stop between them and the output fires while the axis is
still travelling. Wrap every send in a helper that does both.
Machine state
| Function | Returns | Notes | |
|---|---|---|---|
| vc.axis_get_pos(ax) | number | Work position. ax is vc.AXIS_X … vc.AXIS_W. | Shipped |
| vc.axis_get_machine_pos(ax) | number | Machine position. | Shipped |
| vc.spindle_get_rpm(sp) | number | Commanded spindle speed. | Shipped |
| vc.cntl_get_feed_rate() | number | Modal F word. | Shipped |
| vc.cntl_get_fro() / set_fro(s) | number | Feed-rate override, 1.0 = 100 %. | Shipped |
| vc.cntl_get_line_nbr() | number | Currently returns 0 — not yet wired. | In development |
Tooling
See 5.2 for the model behind these.
| Function | Returns | Notes | |
|---|---|---|---|
| vc.tool_get_selected() | number | The T word being requested. | Shipped |
| vc.tool_get_current() | number | Tool physically in the spindle, 0 = empty. | Shipped |
| vc.tool_set_current(n) | — | Commit, after the physical change. | Shipped |
Machine I/O
Signal constants and paths are covered in 5.5.
| Function | Returns | Notes | |
|---|---|---|---|
| vc.io_get_signal(sig) | bool | Errors if the signal is not bound. | Shipped |
| vc.io_set_signal(sig, on) | — | Errors if unbound or if it is an input. | Shipped |
| vc.io_wait_signal(sig, want, s) | bool | Breaks on E-Stop. false = timed out. | Shipped |
| vc.io_has_signal(sig) | bool | For genuinely optional hardware. | Shipped |
| vc.io_get_analog(sig) | number | Real value of an analog signal. Active-low not applied. | Shipped |
| vc.io_set_analog(sig, v) | — | Drive an analog output, in the driver's units. | Shipped |
| vc.io_get_pin(path) / set_pin / wait_pin | varies | Raw point by device/pin. | Shipped |
| vc.reg_find(path) / reg_create(…) | handle | Look up or publish a register. See chapter 3. | Shipped |
| vc.reg_get(h) / reg_set(h, v) | number | By handle, not by path. Shared with the screen. | Shipped |
Operator and diagnostics
| Function | Notes | |
|---|---|---|
| vc.msgbox(text) | Blocking prompt; the program waits for OK. | Shipped |
| print(...) | Goes to Tools → View Macro Debug Messages, or the debugger's Output panel. | Shipped |
| vc.print_err(msg) | Same destination, marked as an error. | Shipped |
Constants
| Constant | Value |
|---|---|
| vc.AXIS_X … vc.AXIS_W | 0 … 8 |
| vc.SPINDLE_STOP / CW / CCW | 0 / 1 / −1 |
| vc.HOST | "core" | "debugger" |
| vc.OSIG_* / vc.ISIG_* | Signal constants — see 5.5 Shipped |
2.3Screen API — Core.*
Available in main.lua: in initialize(), in button handlers,
and in the PLC scan. All Shipped unless marked.
| Function | Purpose |
|---|---|
| Core.submitLine(str) | Queue one G-code line |
| Core.submitFile(path) | Run a program file |
| Core.runMDI(str) | Execute as if typed into MDI |
| Core.setRunSource(n) | Choose which source Cycle Start runs |
| Core.getPos(axis) | Read a value: "X"…"W", "F" feed, "S" spindle, "T" current tool, "NT" next tool |
| Core.getStatus() | Machine state as a string |
| Core.isBusy() | True while a program is running |
| Core.cycleStart() / feedHold() / stop() | Program control |
| Core.reset() / eStop() | Reset; emergency stop |
| Core.enable(on) | Drive enable |
| Core.homeAll() | Start the homing sequence |
| Core.jog(axis, dir) / jogStop() | Jog control |
| Core.setJogStep(v) / setSoftJogSteps(t) | Set the step, or the list the on-screen STEP button cycles. The XHC pendant does not use this list — its step comes from the knob on the pendant. See 1.2. |
| Core.getJogIncrement() / setJogIncrement(v) | Current increment |
| Core.isJogContinuous() / setJogContinuous(b) | Continuous vs incremental |
| Core.setFro(scale) | Feed-rate override |
Core.* PLC API
The PLC lives on the core's vc.* table, not the screen's — a PLC script
uses the same register and I/O calls a macro does, minus anything that could move the
machine or block. See 2.6.
Nor is there a call to start it. The scan period is a setting, and the PLC
runs whenever a profile contains a plc.lua — there is nothing to
launch from a script.
2.4Dialogs — Gui.*
| Function | Returns | Purpose |
|---|---|---|
| Gui.message(title, text) | — | Information dialog |
| Gui.warning(title, text) | — | Warning dialog |
| Gui.question(title, text) | bool | Yes / No prompt |
Screen engine only. From a macro use vc.msgbox(); from the PLC, nothing —
a worker thread must never block on an operator.
2.5Entry points
VitalCNC calls these by name if you define them. Nothing to register.
| Function | File | When |
|---|---|---|
| initialize() | main.lua | Once, when the profile loads. Put setup here. |
| <your name>() | main.lua | When a button with a "Lua Function" action is pressed. |
| init() | plc.lua | Once when the profile loads, and again after a driver reconnect. Fetch your handles here. Shipped |
| scan() | plc.lua | Every scan period, for as long as the PLC is running. Shipped |
| mcode_execute(m,p,q,r,l) | Macros/m<N>.lua | When that M-code runs. p q r l are the P/Q/R/L words. |
Only P, Q, R and L reach a macro,
and an omitted word arrives as 0 — so P0 and "no P" are
indistinguishable. If the difference matters, require a non-zero value.
2.6The PLC — plc.lua Shipped
Put a file called plc.lua in a profile folder, beside
vccore_config.json, and it becomes that machine's PLC. No file means no
PLC; nothing else changes.
The PLC runs on the host PC, soft real-time. A scan happens when the operating system gets round to it — usually within a millisecond of its period, occasionally much later. Anything needing guaranteed latency belongs in the controller, not here.
Two entry points
init() runs once at profile load; scan() runs every period.
Locals declared at the top of the file survive between scans, because the Lua state is
persistent. That split is the whole point: look your handles up once, in
init(), and use them for the life of the session.
local torchUp, lamp
function init() -- once, and again after a reconnect
torchUp = vc.reg_find("HiCON/ArcPro/TorchUp")
lamp = vc.OSIG_GP1
end
function scan() -- every tick: fast, and never blocking
if torchUp then
vc.io_set_signal(lamp, vc.reg_get(torchUp) ~= 0)
end
end
If you have written Mach4 PLC scripts, note what is absent: there is no
if mcLuaPLCFirstRun then … end wrapper, because the file is not re-executed
every cycle. init() is that wrapper, done properly.
It is scan(), not repeat(). repeat is a Lua
reserved word (repeat … until), so function repeat() will not
even parse.
What a scan may call
The PLC gets a smaller vc table: registers, I/O, and machine-state
reads. Everything in 2.2 except the entries below. A refused name is nil
rather than a stub that raises, so if vc.exec_line then is an honest
test.
| Not available to a PLC | Why |
|---|---|
| vc.exec_line, vc.exec_file | A PLC that can emit G-code is a second interpreter with no coordination with the first. Use I/O to command Aux Motors instead. |
| vc.sleep_ms, vc.cntl_wait_for_stop, vc.io_wait_signal, vc.io_wait_pin |
One blocking call makes every later scan late, and the failure is invisible — the PLC just gets slow. Track state across scans instead of waiting inside one. |
| vc.msgbox | It blocks until the operator dismisses it. From a 50 Hz scan that stacks modal dialogs faster than anyone can close them. |
| vc.abort, vc.fail | These fault the running program. That is a macro's job — see the error policy below. |
| vc.cntl_set_fro, vc.tool_set_current | Steering the running program, likewise. |
vc.HOST reads "plc" inside a scan and "core"
inside a macro, if a shared helper file genuinely needs to tell them apart.
Errors: the opposite of a macro
A macro that fails should stop the program. A PLC that fails must not — a
typo in a branch that only runs on Tuesdays must not stop a cut. So a fault in
scan() is logged and counted, and the machine carries on.
| What happens | Result |
|---|---|
| A scan raises | Logged and counted. The next scan runs normally. |
| 10 scans in a row raise | The PLC disables itself and says so. A script failing every scan is broken now, not intermittently. |
| A scan takes longer than its period | Counted as an overrun, not an error — it was correct, just late. Logged at most once every 5 seconds. |
| 50 scans in a row overrun | The PLC disables itself: the script cannot finish in time, and waiting will not fix it. Shorten it or raise the period. |
init() raises | The PLC refuses to start and reports why. That one is loud — it is setup, and you are right there. |
Health, without writing anything
The engine publishes its own state as registers, on every machine, whether or not a
plc.lua exists. Bind them to an LED or a Label like any other register
(3.3) — an LED on core/plc/Running is a PLC watchdog lamp
that costs nothing to build.
| Register | Meaning |
|---|---|
| core/plc/Running | 1 while scanning, 0 when stopped or self-disabled. |
| core/plc/ScanMs | How long the last scan took. |
| core/plc/ScanMaxMs | The worst since load. This is the one to tune against — the instantaneous value never shows you the spike that hurt. |
| core/plc/PeriodMs | The configured period, so a screen can show ScanMs against its budget. |
| core/plc/Errors | Scan faults since load. |
| core/plc/Overruns | Late scans since load. |
| core/plc/LastError | Text of the most recent failure; empty when clean. |
The status bar also carries a permanent PLC indicator whenever the
profile has a plc.lua — "PLC ok", or the reason in red if it has stopped.
A PLC that has quietly stopped scanning is the worst outcome there is, so it is never
left to a message you might have missed.
Editing without restarting
Tools → PLC has Scanning (start/stop) and Reload plc.lua (Ctrl+Shift+P). Reload re-reads the file from disk and starts it again. A reload that fails to compile leaves the PLC stopped with the error, rather than silently carrying on with the previous version.
Registers survive a reload: vc.reg_create adopts an existing path and
keeps its value, so a counter the operator has been watching does not reset every time
you fix a typo.
| Setting | Key | Default | Range |
|---|---|---|---|
| PLC scan period | plc/scanMs | 20 ms (50 Hz) | 5 – 1000 ms |
Disabled machines, and the output gate
The scan keeps running while the machine is disabled or E-stopped. Driving a lamp that says "E-STOPPED" is a legitimate PLC job, and a PLC that stopped thinking the moment the machine was disabled could not do it.
What stops is its writes to physical points — io_set_signal,
io_set_pin, io_set_analog are suppressed until the machine is
enabled again. Registers are never gated, so the screen keeps updating. Suppression is
silent: on a disabled machine it is the normal state, not a fault.
One writer per output. If a macro sets OSIG_GP1 and the
PLC drives it every scan, the PLC wins and the macro's write vanishes. Decide who owns
each output, and write it down where the next person will find it.
Printing
print() and vc.print_err() go to Tools → View
Macro Debug Messages, tagged [plc]. Print on change,
never every scan — one print() in a 50 Hz scan is 3,000 lines a
minute.
Driving the screen from a scan
A PLC scan never touches a screen object. The scan writes a register, the object is bound to that register in the designer, and the screen redraws at 50 Hz.
So "blink a lamp when the coolant is low" is two halves that never meet in code.
1. The scan publishes a condition. Create the register once in
init() — that is also what makes it appear in the designer's list — and
write it each scan:
-- plc.lua
local lowCoolant, level
function init()
-- Create once, keep the handle. reg_create adopts the path if it already
-- exists, so a reload does not fight the previous run.
lowCoolant = vc.reg_create("mill/LowCoolant", "Coolant below minimum", false)
level = vc.reg_find("HiCON/AI/CoolantLevel") -- nil if not fitted
end
function scan()
if not level then return end -- no sensor, nothing to say
vc.reg_set(lowCoolant, vc.reg_get(level) < 20.0)
end
2. The screen decides how that looks. Drop an LED on
the canvas, set its Data source to mill/LowCoolant, and tick
Blink. No script on the screen side at all. The LED lights whenever the
value is non-zero and blinks at its own configured rate.
Use the Blink tick rather than toggling the register yourself. A hand-rolled blink picks up scan jitter and looks unwell, and it buries a display choice in your machine logic.
The same shape covers the rest. A Label bound to a register shows text the scan wrote
with vc.reg_set_text, and several LEDs can each watch a register. The same
register is readable from a macro, from a driver, or from the Registers window.
Publish under a prefix that belongs to the machine — mill/,
press/ — not under core/, which the motion core owns and may
add to. See 3.4.
Chapter 3
Registers
A named value anything can publish and anything can read — the way a driver gets a number onto a screen without the core learning what the number means.
3.1What a register is
A register is how data moves between the parts of VitalCNC. One component publishes a named value; any other reads it — the driver, the core, a macro, the screen. It is the suite's internal data bus.
HiCON/ArcPro/CurrentTipVolts driver publishes, you read
core/cut/CutHeight resolved from the cut chart
core/plasma/ProbeSpeed machine setting, you own it
pendant/Axis which axis the MPG dial selects
I/O is hardware — a digital input, a relay output, an analog channel. It has a wire, a terminal and a pin name, and you bind it in Config → I/O.
A register is data. Nothing is wired to it. It carries a value from whoever knows it to whoever needs it. Arc voltage reaches the screen as a register because the controller already measured it — you do not wire an analog input to read it back.
Separate lists in separate places: the I/O grid offers only pins, the register window only registers. A register can never drive a relay, and a pin is never something a script publishes.
The path is group/name, split on the last slash — so
a group may itself contain slashes (HiCON/ArcPro). That is the whole
addressing scheme; there is nothing else to learn.
Without it, every value a driver wants to show needs a new entry in the core's data-source list and a rebuild of three programs. With it, the driver publishes a path and the core never learns what a tip volt is.
Everything is a double, including bits (0/1). A double carries every 32-bit integer exactly, so bitmasks and encoder counts are not approximations — 253 is the limit, which no controller value approaches. The few values a number cannot carry — a firmware version, a status line — have a separate text payload.
| Kind | Written by | Notes |
|---|---|---|
| Driver telemetry | the driver | Read-only in practice. Arc voltage, torch up/down. |
| Setpoint | screen, macro or core | The driver reads it and programs hardware. |
| Machine setting | the operator | Marked persistent — survives a restart. |
A register is never reset by a re-publish. A driver reconnecting after a cable glitch re-registers its whole catalog, and if that wiped values every operator tweak would vanish with the glitch.
3.2Reading and writing Shipped
You look a path up once and get back a handle. Everything after that takes the handle, not the path.
| Function | Does |
|---|---|
| vc.reg_find(path) | Handle, or nil if there is no
such register. Look up once, keep the result. |
| vc.reg_create(path, display, persist) | Create your own and return its handle. Idempotent — see 3.4. |
| vc.reg_get(h) | Value. |
| vc.reg_set(h, v) | Write. Accepts a number or a boolean. |
| vc.reg_get_text(h) | Text payload. |
| vc.reg_set_text(h, s) | Write the text payload. |
| vc.reg_list(prefix) | Every register path, optionally
filtered. Discovery — feed what you find to reg_find. |
Look the handle up once, in setup. A path is a text search of the whole catalogue, and a misspelt one fails silently on the day it finally runs.
Worked examples
Every path below is real and present on a stock install — open Diagnostics → Registers… and you will see them listed.
-- ── Look up once ────────────────────────────────────────────────────────
-- At the top of the macro, or in init() for a plc.lua that keeps running.
local ignite = vc.reg_find("core/plasma/TorchOnHeight")
local pierce = vc.reg_find("core/cut/PierceHeight")
local thcOn = vc.reg_find("core/cut/ThcEnabled")
-- ── Read ────────────────────────────────────────────────────────────────
local h = vc.reg_get(ignite) -- 3.0 (mm)
local pz = vc.reg_get(pierce) -- resolved from the chart
local on = vc.reg_get(thcOn) > 0.5 -- booleans arrive as 0 / 1
-- ── Write ───────────────────────────────────────────────────────────────
vc.reg_set(ignite, 4.0) -- a number
vc.reg_set(thcOn, false) -- a boolean is fine too
-- ── Text payload ────────────────────────────────────────────────────────
-- A few values are words, not numbers. They live beside the value, not instead
-- of it, so the same handle answers both calls.
local ver = vc.reg_find("core/base/Version")
print(vc.reg_get_text(ver)) -- "0.6.0"
vc.reg_set_text(vc.reg_find("core/cut/Note"), "operator: nozzle changed")
-- ── Optional hardware ───────────────────────────────────────────────────
-- reg_find returns nil rather than raising, so nil IS the "is it fitted?"
-- answer. There is no separate reg_is_present: one call, one concept.
local volts = vc.reg_find("HiCON/ArcPro/CurrentTipVolts")
if volts then
print(string.format("tip volts: %.1f", vc.reg_get(volts)))
end
-- ── Discovery ───────────────────────────────────────────────────────────
-- reg_list is the one call that still speaks in paths: it is how you find out
-- what exists before you have a handle to anything.
for _, path in ipairs(vc.reg_list("core/")) do
print(string.format("%-34s %g", path, vc.reg_get(vc.reg_find(path))))
end
A handle is a number, not an address. When a driver reconnects its registers are
republished and the old handles retire: vc.reg_get on one raises an error
telling you to call vc.reg_find again. It never quietly points somewhere
else.
Registers under core/, and any you created yourself, are not the
driver's to withdraw: their handles stay valid across a reconnect.
Writing needs the handle to be a register. A handle that names a real output cannot drive hardware through these calls.
Which registers are yours to write
They look identical in the tree and behave differently. The difference is not cosmetic — writing the wrong kind is a change that silently disappears.
| Group | Who owns it | Writing from Lua |
|---|---|---|
| core/plasma/… | You — the machine's own settings | Yes. Seeded once with a sane default, then never overwritten. Marked persistent, so the value is meant to outlive a restart. |
| core/cut/… | The cut chart | Pointless. Every re-resolve republishes the whole group,
so your value survives only until the next @VC#-CUT line. To make
a correction stick, save it to the chart as an override. |
| core/base/… | The core | No. Identity — version and name. Read it to check what you are running against. |
| core/aux_motor<N>/… | Split — see 5.8.2 | CmdVelocity and Enable yes — they are the command surface of an Aux Motor. Everything else in the group is a live readout the core rewrites every tick. |
| HiCON/… | The driver | Setpoints yes, readings no. A reading is overwritten on the next status packet, which arrives within milliseconds. |
A driver drops and republishes its whole catalogue whenever it reconnects, so
anything under its own prefix is transient by nature. Everything under
core/ is deliberately exempt: it survives the reconnect with its value
and its identity intact, which is what makes core/plasma a safe place to
keep a machine setting an operator adjusted.
3.3Binding to a screen Shipped
No code required. Drop an LED or a Label on the canvas and pick the register path in its Data source list — every register currently on the bus is offered there, after the standard states and the driver's diagnostic keys. The screen refreshes at 50 Hz like every other binding.
An LED lights whenever the bound value is non-zero, and has a Blink option with its own rate. Its Rounded Rectangle shape fills its box freely in both axes, so "a filled rectangle whose background blinks on a condition" is that object with two boxes ticked — no scripting of any kind.
A plc.lua scan runs on its own thread and must never touch a
screen object — that rule is what stops a slow scan from freezing the
interface. So it does not draw anything. It publishes a value, and the object bound to
that path paints it on the interface thread, 50 times a second.
The division is worth stating plainly: the PLC decides what is true, the screen decides what that looks like. A scan that computed a colour would be the wrong half of the program doing it.
This is the intended way to build a diagnostics page: bind the paths, arrange them, done. It is also why the pendant needs no window of its own — see below.
Register binding is on the LED, the Label and the seven-segment DRO. The bar meter and the remaining data-driven objects still bind to the standard sources and diagnostic keys only.
3.4Publishing your own Shipped
A register must be created before it can be written; reg_find on an
unknown path returns nothing. That asymmetry is deliberate — a lookup that created on
miss would turn every typo into a phantom register nobody ever feeds.
From a macro — Lua
This is the one you will actually use. A machine builder writes Lua, not C++: the host is already built and there is nowhere to add C++ to it.
-- Create returns a handle, exactly like find. The path needs a GROUP — the part
-- before the last slash — because that is what the tree branches on, and a
-- register with no group could never be addressed again.
local litres = vc.reg_create("shop/CoolantLitres", "Coolant remaining (L)", true)
vc.reg_set(litres, 18.5)
print(vc.reg_get(litres))
The third argument is persist. True marks a value the operator owns — something they tuned that should outlive a restart — and the host saves it per profile. Leave it false for anything you recompute on every run; saving a live reading only restores a stale number over a real one.
vc.reg_create on a path that already exists returns the same handle and
keeps the value in it. So you can call it at the top of a macro that
runs a hundred times a shift without wiping what the operator set.
A group you create is yours, not a driver's. A driver reconnect clears the driver's catalogue and leaves yours standing, value and handle intact — otherwise your macro's own state would vanish the first time a cable was nudged.
Pick a group name that is yours: shop/, the machine's name, anything
that is not core/ or a driver's prefix. It appears as its own branch in
Register Diagnostics with no further work, and a screen object can bind to it the same
day.
From a driver — C API
The device name is the group; path lookup splits on the LAST slash, so a device
called HiCON/ArcPro plus a register CurrentTipVolts addresses
as HiCON/ArcPro/CurrentTipVolts. Register once at connect, keep the
handle, and write through it — the handle is O(1) and safe to use from the servo
thread.
int dev = vcAPI_io_register_device(io, "HiCON/ArcPro", "Plasma THC");
VcIoHandle h = nullptr;
vcAPI_io_register_reg(io, dev, "CurrentTipVolts", "Live tip voltage (V)",
/*persist=*/0, &h);
vcAPI_io_write(h, volts); // atomic store, lock-free — servo-thread safe
double v = vcAPI_io_read(h); // atomic load
vcAPI_io_write_text(io, h, "OK"); // the few that carry words (mutex-guarded)
persist = 1 marks a value the operator owns — a machine setting that
should outlive a restart — and the host saves it per profile. Live readings are
0: they are re-published every packet and saving them would restore a
stale number over a real one.
VitalCNC publishes some itself — the pendant creates its pendant/*
paths at startup. That is why they appear in Diagnostics → Registers… on a
machine with no scripts at all. Read and bind them like any other; you never create
them.
Registering a path that already exists returns the same handle and keeps what is in it — the same rule at every level. That is how a driver re-publishes its whole catalogue on reconnect without wiping a setting the operator just typed.
3.5Register Diagnostics Shipped
Diagnostics → Registers… lists every register on the bus, grouped by path and refreshing live. It answers the question nothing else can: is the component publishing anything at all?
Grouping is derived from the path, not from a schema, so a driver that invents a new group gets a new branch with no code change. Filter by any part of a path.
One Value column: a number where the meaning is numeric, the words where it is not
(core/base/Version reads 0.6.0, not 0).
(persistent) in the description marks a value the operator owns rather
than a live reading.
Tick Allow editing and a numeric value becomes writable by double-click. That is genuinely how you prove something is wired before any script exists — and also how someone fires a torch while leaning over the table. Not the default, and not remembered between sessions.
Text values are not editable here: there is no set-text call on the driver interface, so an edit would appear to work and then be silently overwritten by the next refresh.
3.6Recipes
Refuse to start with a worn nozzle
-- In m3.lua, before the torch fires.
local rVolts = vc.reg_find("HiCON/ArcPro/LastGoodTipVolts")
local rTarget = vc.reg_find("core/cut/ArcVoltage")
if rVolts and rTarget then
local volts, target = vc.reg_get(rVolts), vc.reg_get(rTarget)
if target > 0 and volts > target * 1.15 then
vc.fail(string.format("arc voltage %.0f V is %.0f%% over target - check consumables",
volts, (volts / target - 1) * 100))
end
end
Warn on an unverified cut chart row
if vc.reg_get(vc.reg_find("core/cut/Verified")) == 0 then
vc.msgbox("This material/thickness has never been cut on this machine.\n"
.. vc.reg_get_text(vc.reg_find("core/cut/Note")))
end
Mirror a driver value onto a physical lamp
-- Torch-up indicator on a spare output, for an operator who cannot see the screen.
-- nil from reg_find is how you ask "is this hardware fitted?".
local torchUp = vc.reg_find("HiCON/ArcPro/TorchUp")
if torchUp then
vc.io_set_signal(vc.OSIG_GP1, vc.reg_get(torchUp) ~= 0)
end
Count pierces on this set of consumables
-- A register of your own, created on first use and persistent, so the count is
-- still there tomorrow morning. Call create unconditionally: it adopts the
-- existing one and keeps the running total.
local pierces = vc.reg_create("shop/PierceCount", "Pierces this set", true)
vc.reg_set(pierces, vc.reg_get(pierces) + 1)
-- Bind shop/PierceCount to a DRO on the screen and the operator watches it climb.
if vc.reg_get(pierces) > 400 then
vc.msgbox("400 pierces on this nozzle - inspect before the next plate.")
end
Guard against an edge-start row
-- Thick plate that will not pierce. Better to stop than to bury the torch.
if vc.reg_get(vc.reg_find("core/cut/EdgeStart")) ~= 0 then
vc.fail("this thickness requires an edge start - lead-in must begin off the plate")
end
A macro reads a register at one instant. Anything that must hold throughout a move belongs in the driver or in hardware, not in a script that has already returned. Reading arc-OK once before a cut does not keep the arc lit.
A note on the PLC
The register bus is how the PLC and everything else talk to each other. A
plc.lua scan reads and writes these same registers — that is deliberately
the only channel between it and your macros, because the two run in separate
Lua states on separate threads and share no globals at all. See 2.6.
The warning above applies to a scan with more force than to a macro, not less: a PLC reading arc-OK 50 times a second still does not keep the arc lit, and running more often makes it look more like an interlock without making it one.
Chapter 4
Plasma Cutting
Which cut parameters belong to the CAM, which belong to the controller, and how a cut chart gets from a JSON file to a torch height.
4.1Who owns which number
A plasma cut needs about twenty numbers. They split cleanly on one question: is it baked into the geometry, or is it a setpoint the machine acts on?
| Owner | Parameters | Why |
|---|---|---|
| VitalCAM frozen at post time |
kerf width lead-in / lead-out hole rules, corner loops |
The toolpath is already offset by kerf/2 when the G-code is written. Changing kerf at the machine cannot move a line that is already cut. |
| VitalCNC resolved at run time |
pierce height, cut height pierce delay, THC delay arc voltage, THC on/off amps, gas, pressure |
These describe how a worn nozzle on this table behaves today. The machine is the only thing that knows that. |
Cut speed sits in between: it is written as an F word, but the operator
already has feed override, so it needs no separate mechanism.
Kerf is the only cut-chart value a machine-side correction can never fix. Everything else is the controller's.
4.2The cut chart
Three layers, in priority order. A lookup takes the first layer that supplies each field.
| Layer | File | Written by |
|---|---|---|
| 3. Machine overrides wins | machine-overrides.json | The operator, from the plasma panel. Keyed on process + material + thickness. |
| 2. Library | cutcharts/*.json | The vendor, or you. Never written to by VitalCNC or VitalCAM. |
| 1. Posted fallback last resort | the G-code header | VitalCAM, at post time. Used only when the machine has no matching library. |
The point of the split is that layer 2 can be replaced wholesale — a vendor revision, a different consumable set — without losing layer 3. Overrides are keyed on the same triple that keys a chart row, so they re-attach to the new library automatically.
| Key | Meaning |
|---|---|
| processId | A numbered process on a power supply: amperage, gas and consumable set together. 4510 = 45 A mild steel mechanised. |
| material | mild_steel, stainless, aluminum. Machine-readable; the UI prettifies it. |
| thickness | Plate thickness. In the library, in that library's own units. |
| verified | Has a human actually cut this row and confirmed it? Ships false on every generic chart. |
Drop a new .json into cutcharts/ and it appears in the Material
box. A library declares its own units — an inch chart and a metric chart can sit side by
side, converted only on the way out.
A row marked unverified has never been cut on any machine, let alone yours. Test on scrap, then save what worked as an override — that is what flips it to verified.
4.3Between the rows
Ask for 8 mm when the chart has 6 and 10. Some values blend; some must not.
| Rule | Applies to | Reason |
|---|---|---|
| Blend linear | cut speed, arc voltage, kerf width, cut height, pierce height | Continuous quantities. Halfway between two rows is a sane halfway value. |
| Take the thicker row | pierce delay, THC on/off, edge-start required | A pierce delay that is too short blows molten metal back into the nozzle. Too long only wastes a little consumable life. Err thick. |
| Clamp, never extrapolate | anything past either end of the chart | 25 mm on a chart that stops at 12 uses the 12 mm row. A straight-line guess past the end of a chart is not a cut parameter, it is a wish. |
An interpolated row is never marked verified, and never carries the gauge label of either neighbour. Both VitalCAM and the plasma panel say which case you got — exact, interpolated, above range, below range — because the numbers look identical in a DRO either way.
4.4The handoff — @VC#-
VitalCAM writes tagged comments into the G-code. Any other controller reads them as ordinary comments and ignores them.
( @VC#-CUT LIB=vitalcam-45a-metric PROC=4510 MAT=mild_steel THK=6.0
PIERCEH=3.8 CUTH=1.5 PIERCEDELAY=0.5 )
( @VC#-THC VOLTAGE=128 AUTOREF=1 DELAY=0.5 ANTIDIVE=85 )
( @VC#-PLASMA AMPS=45 GAS=AIR PRESSURE=5.5 )
| Channel | Carries | Status |
|---|---|---|
| @VC#-CUT | The chart selection plus what the CAM resolved. The machine re-resolves and its answer wins. | Shipped in the core In development in the post |
| @VC#-THC | Torch-height setpoints: arc voltage, auto-ref, delay, anti-dive, limits. | Shipped |
| @VC#-PLASMA | Power console: amps, gas, pressure. | Shipped |
| STATE=ON / OFF | An action, not a value. Dispatched to the driver only after motion has drained and the hardware confirms stopped. | Shipped |
PIERCEH, CUTH and PIERCEDELAY are the
fallback, so a program from another shop still runs on a machine with no matching
library. A machine that has the library compares them with its own answer and warns if
they disagree — that means the two shops are on different chart revisions.
Regardless of G20/G21. A directive is machine-readable
metadata; letting a modal change its meaning would make a program's own header ambiguous.
4.5The pierce sequence
Every pierce is the same five steps. The heights come from the resolved chart row.
| Step | What happens | Governed by |
|---|---|---|
| 1 | Rapid to the pierce point at safe height. | safe Z |
| 2 | Probe down to find the plate. Plates warp — this is why heights are relative to the plate, not to Z zero. | probe mode, probe feed, float offset |
| 3 | Retract to pierce height, fire the torch, wait. | pierce height, pierce delay |
| 4 | Descend to cut height and start the cut. | cut height |
| 5 | Wait for the arc to stabilise, then hand height control to the THC. | THC delay, arc voltage |
Piercing at cut height throws molten metal straight back into the nozzle and destroys consumables in minutes. Pierce high, then come down.
Edge start. Thick plate near the top of a chart often cannot be pierced
at all. Those rows are flagged edgeStartRequired, and the lead-in must begin
off the edge of the plate.
4.5.1 Two reference positions
Every torch height is "above the reference" — but there are two references, and they are not the same place.
| Reference | Where it is | What is measured from it |
|---|---|---|
| Plate host side |
The probed plate surface. If probing is skipped, wherever Z was when the torch was commanded on. | Torch-on height, pierce height, cut height, max/min cut height — everything on the settings screen. |
| Engage controller side |
Wherever Z actually is when the THC delay expires — i.e. at cut height, already moving in XY. | The THC correction limits. The height controller never learns where the plate is; it only knows where it started correcting from. |
The two differ by exactly the cut height, so the limits have to be converted on the way down:
up correction = max cut height - cut height
down correction = cut height - min cut height
That is why the screen wants absolute heights above the plate (cut 1.0, max 1.4, min 0.7) while the controller wants corrections (up 0.4, down 0.3), and why both must come out positive. Max below cut height means the torch can never correct upward; min above it means it can never correct down.
The engage reference is captured the instant the THC delay expires. If Z is still moving then, every height limit for that cut is off by the distance it had left to go. Set the delay long enough for Z to settle, not just for the arc to stabilise.
4.5.2 Anti-dive, and its unit trap
In a corner the machine slows down, the arc lengthens, arc voltage rises, and an unfrozen THC reads that as "too high" and drives the torch into the plate. Anti-dive freezes height control below a velocity threshold.
That threshold can be expressed two ways, and the same number means different things in each mode:
| Mode | A threshold of 85 means |
|---|---|
| Percent usual | 85 % of the programmed feed. Follows the job — a slow chart row and a fast one both freeze at the same relative point. |
| Absolute | 85 units/min, flat. Independent of the programmed feed. |
Switching modes does not rescale the value. A threshold of 85 that meant "85 % of feed" becomes "85 mm/min" — on a 1800 mm/min cut, anti-dive goes from engaging below 1530 mm/min to essentially never engaging. The torch will dive in corners and nothing will look misconfigured.
VitalCNC keeps the unit in the parameter name for this reason — the percent threshold and the absolute threshold are separate settings, and only the one matching the selected mode is shown.
4.6Parameter reference
Heights and probing
| Setting | What it does | Source |
|---|---|---|
| Probe mode | Off / every pierce / skip within a radius. Bowed sheet wants every pierce. | Machine |
| Skip distance | Radius inside which a re-probe is skipped. | Machine |
| Probe feed | Feed for the probing move and the Z plunge. | Machine |
| Float offset | Switch overtravel — how far the torch keeps moving after the float switch trips. Subtracted from every probed height. | Machine |
| Pierce height | Height to fire at, above the probed plate. | Chart |
| Cut height | Height to cut at, above the probed plate. | Chart |
Torch height control
| Setting | What it does | Source |
|---|---|---|
| Arc voltage | Target voltage. Arc voltage tracks arc length, so holding voltage holds height. Rises as the nozzle wears — the number one reason to override a chart row. | Chart |
| Auto reference | Sample the actual voltage at cut start and hold that instead of the chart figure. Self-corrects for consumable wear; useless on a plate that is not flat. | Machine |
| THC delay | Wait after the arc transfers before engaging height control. Too short and the THC chases the ignition transient. | Chart |
| Deadband | Voltage tolerance before correcting. Stops the torch hunting. | Machine |
| Deadband hysteresis | Correction starts at ± deadband but does not stop until the voltage crosses the setpoint itself. That asymmetry is what stops the torch hunting. | Machine |
| Anti-dive | Freeze THC below a velocity threshold — see 4.5.2, including the unit trap when switching between percent and absolute. | Machine |
| Height max / min | Absolute heights above the plate, not corrections. Converted on the way to the controller — see 4.5.1. | Machine |
| THC output | Which output enables the THC. Wiring. | Machine |
Power console and path
| Setting | What it does | Source |
|---|---|---|
| Amps / gas / pressure | Programmed into the power supply. Long torch leads need more pressure — the chart's lead-length table handles that. | Chart |
| Kerf width | Path offset, kerf/2. Frozen at post time. | Chart → CAM |
| Cut speed | The F word. Operator feed override still applies. | Chart → CAM |
| Hole diameter / feed % | Holes under roughly 2.5× thickness get THC off and reduced feed — a small hole distorts arc voltage and the THC dives. | Machine |
| Overburn | Keep cutting past 360° on a hole to clean up the closing point. | Machine |
| Corner loops | Loop outside sharp corners instead of stopping. Keeps speed up so anti-dive is not needed as hard. | Machine |
Saving a correction In development
A value adjusted at the machine applies for the rest of the program. Save to chart makes it permanent for that process, material and thickness, so the next job on the same material starts there. Nothing writes an override on its own.
4.7The Plasma screen object Shipped
Drag Plasma from the screen designer toolbox. It draws the torch at each height it actually visits, left to right, with the value governing each step above it and a timeline of what the machine is doing beneath.
The shape carries information a column of spin boxes cannot: which number is which height, what order things happen in, and that pierce height sits above cut height. Field colour says who owns the value:
| Field | Source | Editable here |
|---|---|---|
| Probe distance / speed / offset, torch-on height, arc-OK delay, THC delay, THC retract, max & min cut height | core/plasma | Yes — properties of the table. |
| Pierce height, pierce delay, cut height | core/cut | No — resolved from the cut chart, overrides already applied. |
Chart fields are read-only on purpose. A value typed over a resolved one would survive until the next lookup and then vanish, which looks exactly like the machine ignoring the operator. Correcting one is saving an override — a different action, in the cut-chart editor.
The object reads nothing itself. It publishes the register paths it wants, the host feeds values in, and an edit is a request — the number on screen moves only when the write comes back. A rejected write shows as the field not changing rather than as a value that isn't really set.
Machine group and chart group are object properties. Point them at a driver's
own group (say HiCON/ArcPro) to drive hardware registers directly
instead of the core's.
4.8Return on Path
The cut stopped part way: a torch lost its arc, a tool broke. To carry on from a point of your choosing:
- Press Stop.
- Jog the tool to roughly where you want to carry on. Height does not matter; only X and Y are used.
- Press Return on Path.
VitalCNC finds the nearest point on the cut, tells you which line it is on and how far away you are, and asks whether to start the spindle and coolant again if the program had them on. Press Go: the tool lifts to the profile's Safe Z height, moves over the point, comes down onto it, and the program carries on from exactly there, even part way round an arc.
Add the button in the screen designer with the action ReturnOnPath.
It works on any machine, not only plasma.
It picks the nearest cut, never a rapid, and it refuses a drilling cycle or a cutter-compensated (G41/G42) section. Pick a point on an ordinary cut instead.
4.9Backing up along the cut
When a torch loses its arc part way through a cut, you do not want to start the part again. Reverse jog drives the machine back along the path it just cut, so you can re-pierce at a point that is already cut and carry on.
Add two buttons in the screen designer:
| Button action | Does |
|---|---|
| ReverseJogStart | Stops the program and starts travelling back along the cut. |
| ReverseJogStop | Stops going back. The machine holds where it is. |
It retraces at 30% of the speed each move was cut at, so you can watch it. It stops on its own when it reaches the start of the path.
The path is remembered from the last Stop or Reset. After either of those there is nothing to go back along, and the button will tell you so.
4.10Commissioning a table
- Float offset first. Probe a known-flat plate, then measure the real gap with feeler gauges. The difference is the float offset. Every height downstream is wrong by this amount until it is right.
- Cut a test row at chart values. One straight cut in the material you run most. Check the kerf width against the chart figure.
- Tune cut height by dross. Dross on the underside that chips off means too fast or too high; dross that welds on means too slow. Adjust, then save the override.
- Set arc voltage from the cut, not the chart. With height right, read the actual arc voltage and save that. Or turn on auto reference and let it sample.
- Verify anti-dive on a corner. Cut a square. If the torch dives at the corners, raise anti-dive until it stops.
- Mark the row verified. Saving an override does this. It is how the shop's chart becomes worth more than the generic one it started as.
Confirm the torch actually retracts on E-stop, and that the probe input is wired to the float switch and not left floating. An unwired probe input reads "plate found" instantly and the sequence pierces at whatever height it happens to be at.
Chapter 5
Automatic Tool Changers
How VitalCNC tracks what is in the spindle, how a macro reaches machine I/O, and how to interlock a changer so a tool change stalls instead of colliding.
Items marked Shipped work in the current build. Items marked In development are designed and scheduled but not yet callable — they are documented here so you can plan wiring and macro structure around them, not so you can call them today.
5.1How a tool is held
The holder is not bolted in — it is pulled into a tapered bore and held by spring force. Everything the changer does follows from that.
| Term | What it is |
|---|---|
| Taper | The holder's tapered shank (BT30, ISO30, HSK) seats in a matching bore in the spindle nose. The taper locates the tool — it is what gives the tool its concentricity. |
| Pull stud | A threaded knob screwed into the top of the holder. Also called a retention knob. |
| Drawbar | A rod down the centre of the spindle that grips the pull stud and pulls it up, wedging the taper home. |
| Belleville springs | A stack of disc springs supplying the pull — around a tonne of force on a BT30. The springs hold the tool; no power is required. |
| Unclamp cylinder | A pneumatic or hydraulic cylinder that pushes the drawbar down against the springs to release the pull stud. |
| Flange groove | The V-groove around the holder's flange. This is what a fork or gripper holds. |
| Drive dogs | Keys on the spindle nose that engage slots in the holder flange to transmit torque. They are why the spindle must be oriented before a holder goes in. |
Springs clamp, air unclamps. An unpowered machine holds its tool, so losing air or
power cannot drop one. That is why the Drawbar output means release, and
why commissioning with the air disconnected is safe.
- Air blast — clears chips and coolant from the taper while the drawbar is open. One trapped chip causes runout and can mark the taper permanently.
- Spindle orientation —
M19stops the spindle where the drive dogs line up. Skip it and the holder lands on the dogs instead of seating.
5.2The tool state model
Two separate facts, mirroring Mach4's mcToolGetSelected /
mcToolGetCurrent split. Keeping them apart is the basis of a safe changer.
| Fact | Macro API | Meaning | Lifetime |
|---|---|---|---|
| Selected tool | vc.tool_get_selected() | The T word. What the program has asked for. Set by any block containing a T, with or without M6. |
Program state — cleared by Reset |
| Current tool | vc.tool_get_current() | What is physically clamped in the taper. 0 means the spindle is empty. |
Machine state — survives Reset and restart |
| Commit | vc.tool_set_current(n) | Records that tool n is now in the spindle. Call only after the swap has physically happened. |
— |
All three: Shipped
Bind a DRO to ToolNumber and another to NextToolNumber. They
agree except between a T word and its M6, and after a failed
change — which is when you want to see them differ.
5.3Who commits a tool change
This decides how you write your macro:
- With an
m6.lua— the macro owns the commit. The controller will not touchcurrent_tool. Never callvc.tool_set_current()and it never updates. - Without one — the controller commits the swap itself, so a manual machine gets a correct DRO with no Lua at all.
A prompt-only m6.lua still owns the commit. It must call
vc.tool_set_current() after the operator confirms, or the DRO sticks on
the previous tool. The stock profile macros already do.
Commit once per physical half: 0 as soon as the spindle
is confirmed empty, the new number only after clamp confirms. An abort in between then
leaves a truthful value instead of claiming a tool that is sitting in a fork.
5.4Persistence and Reset
A holder stays clamped through all three, so current_tool is machine
state, not program state:
| Event | Selected tool | Current tool |
|---|---|---|
| Program end / M30 | preserved | preserved |
| Reset | cleared to 0 | preserved |
| Application restart | 0 | restored from the profile |
Persistence is per profile, so two machines on one PC never overwrite each other. Shipped
The stored value is what the controller last observed. Swap a holder by hand with the software closed and it is simply wrong. Only a tool-present sensor catches that — see 5.6.
5.5Addressing machine I/O
Two routes. Prefer the first.
Predefined signals, by constant
Bind signals in Config → I/O, then use the constant:
vc.io_set_signal(vc.OSIG_DRAWBAR, true)
Use the constant, never the name as text. A misspelled constant is nil
and errors on that line; a misspelled string just looks unwired.
| Constant | Signal row | Direction | Used for |
|---|---|---|---|
| vc.OSIG_DRAWBAR | Drawbar | Output | Energise to release the tool |
| vc.OSIG_AIR_BLAST | Air Blast | Output | Taper blow-off while the drawbar is open |
| vc.OSIG_DUST_BOOT | Dust Boot | Output | Lift the shoe clear before a change |
| vc.ISIG_TOOL_IN_SPINDLE | Tool In Spindle | Input | Proves a holder is present in the taper |
| vc.ISIG_DRAWBAR_UNCLAMPED | Drawbar Unclamped | Input | Proves the drawbar reached release |
| vc.ISIG_DRAWBAR_CLAMPED | Drawbar Clamped | Input | Proves the drawbar reached clamp |
| vc.OSIG_GP1 … 25 | Generic Output n | Output | Anything machine-specific — a carousel motor, a shot pin |
| vc.ISIG_GP1 … 25 | Generic Input n | Input | Anything machine-specific — an in-position switch |
| vc.OSIG_ANA1 … 8 | Analog Output n | Output | 0-10 V drives, a VFD reference, a proportional valve |
| vc.ISIG_ANA1 … 8 | Analog Input n | Input | A pressure transducer, a load cell, arc voltage |
Signal constants: Shipped — the six ATC rows appear in Config → I/O: In development
Analog signals
Analog uses its own pair of calls. io_get_signal returns a boolean, so
reading a 0-10 V input through it gives you "not zero" and nothing else.
local bar = vc.io_get_analog(vc.ISIG_ANA2) -- air pressure, in the driver's units
vc.io_set_analog(vc.OSIG_ANA1, 7.5) -- 7.5 on analog output 1
Active-low means "this contact reads inverted" — a statement about a switch. Inverting 7.3 volts is not a meaningful operation, so the analog calls ignore the flag rather than quietly negating a reading. Scale and offset belong to the driver, which is the only thing that knows the hardware range.
For machine-specific hardware, alias a generic constant:
local sigCarouselIndex = vc.OSIG_GP3
local sigCarouselReady = vc.ISIG_GP7
Raw points, by device and pin
For a pin with no signal row, address it directly as device/pin:
vc.io_set_pin("M31/DO.07", true) -- HiCON board output 7
local v = vc.io_get_pin("SIM/DI.00") -- simulator generic input 0
Names come from the loaded driver — M31 (HiCON), SIM,
[0] EL1008 (EtherCAT). The Config → I/O dropdowns list what is available.
Path access: Shipped
An unbound signal raises rather than reading false — a drawbar
"confirmed" by an unwired pin is the most dangerous reading there is. Use
vc.io_has_signal() for genuinely optional hardware.
I/O straight from G-code — M62 to M66
A G-code program can drive I/O directly, by logical number rather than by name. Same codes as LinuxCNC and MachMotion.
| Code | Effect |
|---|---|
| M62 P<n> | Turn output n ON |
| M63 P<n> | Turn output n OFF |
| M64 P<n> | Turn output n ON — identical to M62 here |
| M65 P<n> | Turn output n OFF — identical to M63 here |
| M66 P<n> L<s> Q<sec> | Wait until input n reads state s, giving up after sec seconds |
| M200 … M221 | MachMotion output pairs: index is (code−200)/2, even turns ON, odd turns OFF |
| M19 R<deg> | Orient the spindle to an angle — needed before a holder is inserted |
M62 P0 ( drawbar unclamp on )
G4 P0.5 ( let it move )
M66 P1 L1 Q3 ( wait up to 3 s for input 1 to go high )
M63 P0 ( drawbar off )
P is not a pin — it is the number assigned to a binding in Config → I/O.
A number with no binding behind it is unusable.
M62–M66 fire when the line is interpreted, not when the motion
before them finishes — the interpreter runs ahead. An M62 between two moves
triggers while the first is still travelling. Force a stop first (exact-stop or dwell),
or drive the output from a macro instead.
Here L is the desired level: L1 high, L0 low.
It is not LinuxCNC's wait-type argument — anything but L0
means "wait high", so a ported program can wait on the wrong edge silently.
Q is seconds; a timeout faults the program. An unbound input number fails
immediately rather than reading low.
In a macro prefer vc.io_set_signal(vc.OSIG_DRAWBAR, true) — it names what
it switches, it is checked, and renumbering a binding cannot break it.
5.6The M6 macro
M6 runs Macros/<profile>/m6.lua. The filename is the whole
binding, and scripts hot-reload on save. All motion is stopped before your macro is
entered, so it is safe to prompt or drive I/O immediately.
Reconciling stored state against the machine
Persistence says what the controller last saw; the sensor says what is true now. When they disagree, the sensor wins.
| Sensor | Stored | What it means | Action |
|---|---|---|---|
| not fitted | any | Nothing to check against | Trust stored; note it in the trace |
| false | 0 | Agreed — spindle empty | Proceed |
| false | T n | Tool was pulled by hand | Warn, set current to 0, continue |
| true | T n | Agreed — spindle loaded | Proceed |
| true | 0 | A tool is present but unidentified | Prompt to remove it, re-read, fault if still loaded |
5.7Rack ATC geometry
Holders sit in open forks, held by the V-flange groove. The forks do not move — the spindle slides sideways into and out of them. Four Z heights and two Y positions define the whole change.
Put-away and pick-up are mirror images:
| Put the old tool away | Pick the new tool up |
|---|---|
|
|
zClear must be above the pull studs of every parked holder. The spindle
traverses the whole rack at this height with the drawbar open — get it wrong and the
nose sweeps the tops of the tools.
5.8PLC scan and interlocks
A carousel must index the target pocket into place, ideally while the current tool is still cutting. That cannot live in a macro — every macro stops the machine first. It belongs in the PLC scan.
Where the next tool comes from
There is one source and it is already in the PLC's hands:
vc.tool_get_selected() — the T word the program has most
recently selected. (The screen sees the same number as
Core.getPos("NT"), and the tool actually in the spindle as
"T".)
T and M6 are separate operations.
T5 only selects: it sets the number and nothing moves.
M6 is what performs the change. A block may carry both, but it does not
have to — and that is the whole basis of pre-staging.
The number changes when the T block is interpreted,
which runs ahead of the machine by whatever is queued — not when the tool change
happens. So the lead time is however far apart T and M6 are
written:
T1 M6 (change to tool 1)
G43 H1
T5 <-- select the NEXT tool here; the scan sees 5 immediately
G1 X… Y… F800 (…minutes of cutting; the carousel indexes during this…)
M6 <-- arrives to find atc/Ready already true
With T5 M6 on one line the carousel only starts indexing when
M6 runs, so the machine stands still while it does. Emitting the next
T early is what makes it fast.
The scan needs no notion of "when the change is coming" and should not try to guess one. It watches the target, indexes when it moves, and reports arrival — the same logic whether it had four minutes of warning or none.
The PLC scan
Put a plc.lua in the profile folder, define init() and
scan(), and VitalCNC runs it on its own thread while the profile is loaded.
That is your PLC, and it keeps scanning while a program cuts — which a macro cannot do.
Full details in 2.6.
| Setting | Key | Default | Range |
|---|---|---|---|
| PLC scan period | plc/scanMs | 20 ms (50 Hz) | 5 – 1000 ms |
PLC engine and configurable scan period: Shipped
Scan period is a responsiveness control, not a safety one — safety comes from the macro's blocking wait and its timeout. Keep each scan short: read a few signals, set a few outputs, return. A scan that overruns its period is counted, and sustained overrunning disables the PLC rather than letting it quietly run late.
The interlock
The two run on different threads and share no globals, so they communicate through a register — a named, thread-safe value, equivalent to Mach4's RegFile.
-- plc.lua — runs on its own thread, every 20 ms
local ready, staged, indexCmd, inPosition
function init()
ready = vc.reg_create("atc/Ready", "Carousel in position", false)
staged = vc.reg_create("atc/Staged", "Pocket being indexed", false)
indexCmd = vc.OSIG_GP3
inPosition = vc.ISIG_GP7
end
function scan()
local want = vc.tool_get_selected()
if want ~= vc.reg_get(staged) then
vc.reg_set(staged, want)
vc.reg_set(ready, 0) -- interlock DOWN first
vc.io_set_signal(indexCmd, true) -- start indexing
end
if vc.io_get_signal(inPosition) then
vc.io_set_signal(indexCmd, false)
vc.reg_set(ready, 1) -- interlock UP
end
end
-- Macros/m6.lua — runs with the machine already stopped
-- io_wait_pin resolves a REGISTER by path just as it does a physical point, and
-- it breaks on E-Stop. Do not hand-roll this with sleep_ms — see below.
if not vc.io_wait_pin("atc/Ready", true, 10.0) then
vc.fail("carousel did not reach pocket " .. vc.tool_get_selected() .. " within 10 s")
end
-- only now is it safe to plunge
M6 blocks with the machine stopped, and a timeout faults instead of colliding. Note which side does the waiting: the blocking wait exists in a macro and is deliberately absent from the PLC. That asymmetry is the whole design — the macro has a program to hold up, the scan has 20 ms.
In this example the PLC owns OSIG_GP3 and nothing else may touch it. If
m6.lua also drove that output, the scan would overwrite it within 20 ms and
the macro's write would vanish with no error anywhere. Write down which subsystem owns
each output before you have two.
5.8.1 Moving a carousel while the machine is cutting
Index the next pocket without interrupting the cut. Not as a CNC axis:
| Approach | Works during machining? | Why |
|---|---|---|
| Jog a spare CNC axis (U/V/W) from the PLC | No | Jog runs only when the motion pipeline is idle, and the driver refuses a jog unless the machine is Idle. Programmed motion always wins. |
| Independent drive commanded through I/O | Yes | The drive does its own positioning. Nothing goes through the trajectory planner, so it overlaps cutting freely. |
The reason is lookahead: every queued vector carries absolute positions for all axes, planned before the PLC decided to move anything. Moving a changer axis now would simply be undone by vectors already in the queue.
Use its own drive, commanded by I/O. Three patterns that work while cutting:
- Servo or VFD with position presets — the PLC puts the pocket number on a few outputs plus a "go" strobe, and reads an "in position" input.
- Stepper with an index sensor — the PLC pulses a step output and counts index pulses on an input until it reaches the target pocket.
- Geneva or pneumatic indexer — the PLC fires a cylinder once per station and reads a position switch.
The PLC sets outputs, reads inputs, and raises the interlock register on arrival. M6 waits on it. That is why the PLC has no motion API.
Those three put the positioning intelligence in the external drive. For a plain stepper or servo on a spare motor output, the machine can drive it itself — see Aux Motors, which you command by speed or by position and which keeps running while the machine cuts.
Three hazards to design for
- Clear the interlock before the changer moves, not after it
arrives. Otherwise there is a full scan period in which
M6can read a "ready" that belongs to the previous pocket. - An error in
scan()does not stop the PLC — but it does skip that scan's work. The engine logs it, counts it, and runs the next scan normally, which is right for a lamp and dangerous for an interlock: the line that raises the fault is never reached, soatc/Readysimply keeps whatever value it had. Wrap anything anM6waits on so a fault cannot leave a stale "ready" behind, and watchcore/plc/Errors. A script that fails every scan disables itself after ten, and then every laterM6waits out its full timeout — treat a stopped PLC as a fault condition. - Waits must break on E-Stop and Reset. A macro blocked on a
register whose PLC has stopped scanning must still abort when the operator hits
E-Stop. Use the provided wait functions rather than a hand-rolled
sleep_mspolling loop, which will not.
5.8.2 Aux Motors
Aux Motors are their own chapter now — see Chapter 7. A carousel driven by one is the example here because a tool changer is what needs it; the motor itself, its registers, its velocity and position modes, and how a script or an M-code commands one are all described there.
5.9Failure handling
A failed tool change stops the program. You do not have to remember to arrange this:
any uncaught error in a macro — a deliberate fault, a bad signal name, an
ordinary typo — aborts queued motion and faults the interpreter against the
M6 block, carrying the message.
| Call | Effect | |
|---|---|---|
| vc.fail(msg) | Drops queued motion and stops the program with your message. Never returns. | Shipped |
| vc.abort() | Drops queued motion but keeps running. | Shipped |
| vc.msgbox(text) | Blocking operator prompt; the program waits on it. | Shipped |
| error(msg) | Same outcome as vc.fail — this is the default for any bug. | Shipped |
The one way to make a macro unsafe is to wrap it in pcall and swallow
the error. A tool change that fails silently will cut with the wrong tool, or with none.
When a change does fail, the current tool is left at whatever was last committed — which is the truth, because nothing moved after that point. Recover the holder by hand, then correct the controller's belief before running again.
Where macro output goes
VitalCNC has no console, so print() from a macro is routed for you:
under the controller it appears in Tools → View Macro Debug Messages;
under the Lua debugger it appears in that window's Output panel. Nothing to configure.
Shipped
5.10Commissioning a changer
- Bind the signals first. Config → I/O, map every signal your macro references. An unbound signal will stop the macro on the first call.
- Dry run. Leave the macro's
dryRunflag set. Every line the macro would send is printed instead. Read the whole sequence before anything moves. - Measure the geometry by hand. Jog to each fork and record the machine coordinates. Do not derive them from the drawing.
- Step it in the debugger. Open the macro in the Lua editor, set the
entry to
mcode_execute,mcode = 6, and Run. Motion is mocked and logged, so nothing moves. - First live change with a hand on the E-Stop. Empty spindle, one tool in one fork, and single-step it.
Commission with the drawbar air disconnected for the first pass if you can. Most first-run faults are geometry — a Z height a few millimetres out — and they are far cheaper to find with a spindle that cannot release a holder.
Chapter 6
Machine I/O from G-code
How a G-code word reaches a physical pin: binding a signal, the M-codes that drive one, and — the part that catches people — when the pin actually changes relative to the motion around it.
6.1How a signal reaches a pin Shipped
Nothing in G-code names a pin. It names a signal — "Spindle Fwd", "Coolant Flood" — and your profile says which pin that signal lives on. Change the wiring and you change one row in the config; the G-code never mentions it.
Bindings live in Config → Machine Configuration → Inputs / Outputs. Each row is one signal:
| Column | What it does |
|---|---|
| Signal | The canonical name. The list comes from the motion core, so every signal the core knows about appears here. |
| Mapping Enabled | Unticked rows are ignored entirely. |
| Device / Pin | Chosen from the catalogue your driver published. Pins are not typed by hand — if a pin is missing, the driver is not connected. |
| Active Low | The contact reads inverted. Applies to boolean signals only. |
| M-code # | The logical number that M62–M66 and
M200–M221 address. Blank means no M-code can reach this
signal. See 6.4. |
| Description | Your own note. Carried through to diagnostics. |
An unbound signal does nothing, with no warning — a mill has no torch height control and should not be nagged about it. So when an output does nothing, check its binding row before you check the G-code.
6.2Spindle — M3, M4, M5 Shipped
Three signals, driven together from the commanded direction:
| Code | Spindle On | Spindle Fwd | Spindle Rev |
|---|---|---|---|
M3 S<rpm> — clockwise | on | on | off |
M4 S<rpm> — counter-clockwise | on | off | on |
M5 — stop | off | off | off |
Bind whichever of the three your machine needs. A contactor with one coil and no reverse uses Spindle On alone; a reversible starter uses Fwd and Rev and can leave On unbound.
The S word is a speed, not a signal. It reaches an analog or PWM
spindle drive through the driver's own output configuration, which is separate from these
contacts — so a machine can have a working M3 contactor and no speed control,
or the reverse.
6.3Coolant — M7, M8, M9 Shipped
| Code | Coolant Flood | Coolant Mist |
|---|---|---|
M7 — mist on | unchanged | on |
M8 — flood on | on | unchanged |
M9 — all coolant off | off | off |
M7 and M8 are independent — both can be on at once, and only
M9 clears them.
6.4General outputs — M62–M65, M200–M221 Shipped
These drive an output by number rather than by name, so they can reach any
output you have given an M-code # to — typically Generic Output 1
upward, but equally Air Blast or Dust Boot.
M62 P2 (output 2 on, AT the next move)
M63 P2 (output 2 off, AT the next move)
M64 P2 (output 2 on, immediately)
M65 P2 (output 2 off, immediately)
M62/M63 are synchronized with motion and
M64/M65 are immediate. That is the LinuxCNC
split, and it gives Mach4's M62/M63 behaviour. Which one you want
is the subject of 6.6, and it matters more than it looks.
M200–M221 address the same outputs as fixed pairs, for
compatibility with posts that emit them. Even is on, odd is off:
| On | Off | Output # |
|---|---|---|
M200 | M201 | 0 |
M202 | M203 | 1 |
M204 | M205 | 2 |
| … | … | … |
M220 | M221 | 10 |
So M204 reaches the same output as M62 P2 — but not with the
same timing: M200–M221 are immediate, like M64.
Pick one style per machine; mixing them in a single program is legal and confusing.
M62 P2 means "the output whose M-code # is 2", not pin 2
of anything. If no row carries that number, the command is accepted and does nothing.
6.5Waiting on an input — M66 Shipped
M66 P1 (wait for input 1 to read high, forever)
M66 P1 L0 (wait for it to read LOW)
M66 P1 L1 Q30 (wait up to 30 seconds, then fail)
| Word | Meaning | Default |
|---|---|---|
P | Input's M-code number | required |
L | State to wait for — 1 high, 0 low | 1 |
Q | Timeout in seconds | 0 = wait forever |
A timeout stops the program with an error, which is what you want for
an interlock: a clamp that never confirms must not be cut around. Q is worth
setting on anything mechanical — a wait with no timeout and a failed sensor hangs the
program with no message beyond "still running".
It runs in the interpreter, in software, and it can only stop the program. Anything that must stop the machine — guard, E-Stop, drive disable — belongs in hardware and in the controller, not in a G-code line.
6.6When the pin actually changes Shipped
This is the part worth reading twice, because the two families behave differently and the difference only shows up at speed.
VitalCNC reads ahead. While the machine is cutting line 100, the interpreter may already have parsed line 140 to keep the lookahead planner fed. So "when does an M-code take effect" has two possible answers, and both are in use:
| Applied | Meaning | |
|---|---|---|
| M3 M4 M5 M7 M8 M9 | With the motion | The change is attached to the moves that follow it and happens as those moves
begin executing. M8 cannot come on early, and an M9 at
the end of a program cannot shut the coolant off while the last cut is still
running. |
| M62 M63 | With the motion | The change attaches to the next move and is handed to the controller keyed to that move's own vector, so the pin switches at that point on the path — not when the host got round to it. This is what you want for anything positional. |
| M64 M65 M200–M221 | When the line is read | The output changes as soon as the interpreter reaches it — which, mid-program, can be well before the surrounding motion reaches the machine. |
This matches LinuxCNC exactly, and matches Mach4 for M62/M63.
A program written for either behaves here as it does there.
Anything that must happen at a point on the path needs M62/M63.
The immediate forms fire early, and how early depends on how full the queue is. If you
must use one, put a G61 or a G4 around it.
M62/M63 are attached to the move that follows them and
switch exactly as that move starts — the controller does the timing, not the PC.
M200–M221 switch immediately; posts use them for setup, not
at a position.
6.7Testing it without cutting Shipped
Prove the pin before you trust the G-code. Working outward:
- Is the pin wired? Open the driver's own diagnostics — for HiCON, Diagnostics → HiCON Driver Status — and toggle the output there. That exercises driver and wiring with the interpreter out of the picture entirely.
- Is the signal bound? Diagnostics → Registers… lists every point live. Watch it while you toggle.
- Does the M-code reach it? Type
M8into MDI and watch the same value. MDI runs one block with an empty queue, so timing is not a factor. - Does it behave in a program? Only now run a real file. If something switches at the wrong moment rather than not at all, the binding is fine and 6.6 is your answer.
In order: the row's Mapping Enabled tick, then Device and Pin, then —
for M62-family codes only — the M-code #. A blank number is
the usual cause of "the pin works from diagnostics but not from the program".
Chapter 7
Aux Motors
A motor the machine drives that is not one of the coordinated axes: an Aux spindle, a carousel, a pusher, a lifting axis. It is commanded by a script or a custom M-code rather than by G-code words, and it keeps moving while a program cuts.
7.1What an Aux Motor is
An Aux Motor is a motor the machine drives that is not one of its axes. It has no part in a toolpath and no work offsets, and no G-code word moves it. You tell it a speed or a position by writing a register, and it keeps running while a program cuts.
Use one for a spindle driven by a stepper, a tool carousel, a pusher or tailstock, a conveyor, or a Z the operator drives by hand.
7.2Two modes: velocity and position
Each Aux Motor works one of two ways. You pick which when you set it up.
| Mode | You write | Meaning | Suits |
|---|---|---|---|
| Velocity default |
CmdVelocity | A speed, in units/s. The motor ramps to it and holds it until told otherwise. Position is wherever integrating that has got to. | Anything with no destination — a spindle, a conveyor, a knob someone is holding. |
| Position | CmdPosition | A place, in units. The core works out the speed that arrives there and stops: it accelerates, cruises at the motor's maximum if there is room, and decelerates into the target. | Anything that does have one — a carousel index, a tailstock, a pusher, a lift with named heights. |
In position mode the motor slows down and stops on the target — it will not
overshoot it or hunt around it. InPosition turns 1 once it is
there and stopped.
Stop leaves a position-mode motor where it is. It does not carry on to the target afterwards. Write the target again to resume the move.
7.3Configuring one
Configure one in the Aux Motors table under Config → Core Config → Motors: give it a name, pick an unassigned motor slot, say whether it is rotary, and choose its Type. The dropdown only offers slots nothing else has claimed — one output, one owner — and the dialog refuses to save a duplicate. Its counts per unit, speed and acceleration limits and direction are the motor's, set in the motor tree above the table like any other motor's. Role changes (moving a motor between an axis and this table) take effect on reload, with the machine disabled.
Two types:
- Digital Spindle — the G-code
Sword sets its speed,M3/M4its direction andM5stops it. More than one motor may be a digital spindle; they all follow the sameS. - Script Command — driven from Lua, at whatever speed or position your script asks for.
7.4The register contract
Each Aux Motor publishes a core/aux_motor<N>/ register
group (N is its row in the Aux Motors table). Everything below binds to DROs and
labels through the ordinary register picker — no screen scripting needed.
| Register | Direction | Meaning |
|---|---|---|
| CmdVelocity | you write | Velocity mode. Speed, in units/s — or RPM if the motor is rotary. Negative runs it backwards. Capped at the motor's maximum and ramped at its acceleration. |
| CmdPosition | you write | Position mode. Where to go, in units. Seeded with the motor's current position, so an untouched target holds still rather than travelling to zero. |
| InPosition | core writes | Position mode. 1 once the motor is within the band of its target and stopped. This is what a script waits on. |
| Enable | you write | 0 holds the motor stopped without touching CmdVelocity — the M5 of an aux spindle. Defaults to 1. |
| Position | core writes | Integrated position, units. Monotonic — a spindle's keeps growing. |
| PositionMod / RPM | core writes | Rotary conveniences: position within one rev, and speed in rev/min. |
| Velocity | core writes | The live ramped speed (what the motor is actually being commanded, not your setpoint). |
| AtLimit / Fault / Volts | core writes | Soft-limit contact, fault flag with a text message, and — for an analog source — the reading after centre/deadband, which is the number to watch while commissioning a knob. |
7.5Commanding one from Lua
You command an Aux Motor by writing one of its registers. There is no G-code word for it.
7.5.1 Velocity — a stepper spindle
-- init():
aux_vel = vc.reg_find("core/aux_motor0/CmdVelocity")
aux_en = vc.reg_find("core/aux_motor0/Enable")
-- scan(): spindle-on turns on the motor; speed in RPM
if vc.io_get_signal(vc.OSIG_SPINDLE_ON) then
vc.reg_set(aux_en, 1); vc.reg_set(aux_vel, 1200)
else
vc.reg_set(aux_vel, 0)
end
Tick Rotary on the motor and set its counts per unit to counts per
revolution. Speed is then in RPM, in and out: you write RPM to
CmdVelocity and read it back on
core/aux_motor0/RPM.
7.5.2 Position — and waiting for it to arrive
-- In a MACRO (an M-code, a tool change, a screen button) you may block:
-- the machine is waiting for you by definition.
-- Example Macro Call: M110 P5.25 -> go to position 5.25 units
function mcode_execute(mcode, p, q, r, l)
local cmd_pos = vc.reg_find("core/aux_motor0/CmdPosition")
local in_pos = vc.reg_find("core/aux_motor0/InPosition")
vc.reg_set(cmd_pos, p) -- M110 P5.25 -> go to 5.25 units
local waited = 0
while vc.reg_get(in_pos) == 0 do
vc.sleep_ms(10) -- wait 10 milliseconds
waited = waited + 10
if waited > 30000 then -- never wait forever on a stalled axis
vc.fail("Aux Motor 0 did not reach " .. p .. " in 30 s")
end
end
end
A PLC scan must never wait. The loop above
belongs in a macro. A scan has to finish quickly, so spread a move across scans: write
the target in one, check InPosition on later ones.
7.5.3 Driving one from a program
Save that macro as Macros/m110.lua and your program can move
the motor:
M110 P5.25 (send it to 5.25, and wait until it gets there)
The P, Q, R and L words reach the
macro as its arguments, so one M-code can carry a target, a speed and a timeout.
7.6Commanding one from an analog input
A motor's command normally comes from its register. It can come from an analog input
instead — a ±10V joystick for a hand-driven Z, a 0–10V pot for a conveyor. The core
reads the input every servo tick and uses it as the speed command in
place of CmdVelocity.
Speed only. An analog input cannot command a position — see 7.7. There is no dialog for this: the Aux Motors table creates the motor, and the source is written in the profile.
"aux_motors": [
{
"name": "Z Manual",
"motor_idx": 5,
"min_limit": -150.0,
"max_limit": 0.0,
"source": "analog",
"analog": {
"path": "HiCON/AI.03",
"centre_volts": 0.0,
"deadband_volts": 0.15,
"max_volts": 10.0,
"units_per_volt": 2.0,
"invert": false
}
}
]
| Key | Unit | Meaning |
|---|---|---|
| path | — | The input, named as it appears in Register Diagnostics. |
| centre_volts | V | The reading that means stopped. 0 for a ±10V joystick; 5 for a 0–10V pot centred at half scale. |
| deadband_volts | V | How far either side of centre still counts as stopped. This is what stops a control creeping at rest. |
| max_volts | V | Ignore anything further from centre than this, so a faulty input cannot ask for more than full speed. |
| units_per_volt | speed per volt | Speed, not distance. 2.0 on a ±10V input gives 20 mm/s fully deflected (or 20 RPM if the motor is rotary). The motor's own maximum still applies. |
| invert | — | Swaps the direction. |
Setting it up. Watch core/aux_motor<N>/Volts,
which shows the reading after centre and deadband. With the control at rest it must read
exactly zero — if it does not, fix centre_volts or widen
deadband_volts before letting the motor move. Then deflect it
slightly and watch Position and Velocity.
Enable still gates it, so a script can make the control live
only while a pedal is held or no program is running.
7.7What to know before wiring one
- It only moves while the machine is ENABLED. Disarm, E-Stop and Stop all zero the setpoint and hold position. An analog source must pass back through zero after a stop before it will move again — a knob still deflected at the moment of an E-Stop cannot restart the motor on the next enable.
- Its faults are machine faults. An Aux Motor is armed like any other, so its following-error or limit trip disarms the whole machine. Trivial for a spindle; worth knowing for a knob-driven axis.
- It is not a jog wheel. Commands take effect within roughly a tenth of a second (the motion stream's buffering). Right for a feed knob or a spindle; wrong for anything that needs handwheel feel — that is what the MPG pendant is for.
- A joystick cannot command a position. In velocity mode a stick still deflected after a Stop is handled by release-to-zero — it must pass back through zero before it may move the motor again. A position dial has no equivalent: it goes on naming a destination, so the next enable would drive there on its own. The core refuses that combination rather than ship it.
- Soft limits clamp, hard limits stop. A linear Aux Motor with configured bounds decelerates at the bound and refuses to continue outward (motion back in is always allowed). Rotary motors have no bounds.
Chapter 8
Homing
8.1What homing does
Homing drives each axis to a switch at a known place on the machine. After that, positions mean something again and the axis's soft limits are on. Until an axis is homed, its soft limits are off.
Home after every power-up, and after anything that may have moved an axis while the drives were off.
8.2Setting it up
Three tabs in Config:
| Tab | Setting | What it does |
|---|---|---|
| Inputs | X Home, Y Home … | The home switch for each axis. |
| Motors | Homing type | How the motor finds home: a switch, the encoder index, both, the drive's own homing, or where it stands. None leaves it out. |
| Motors | Search direction | Which way it moves to find the switch. |
| Motors | Home speed | Speed while searching. |
| Motors | Back-off speed | Speed leaving the switch. Slower gives a more repeatable home. |
| Motors | Home accel | 0 uses the motor's normal acceleration. |
| Axes | Homing Sequence | The order for Home All. See 8.3. |
| Axes | Home Offset | How far to move off the switch before calling it home. |
Enter speeds and distances in the machine's own units, inches or millimetres.
EtherCAT servos can home just like steppers. Wire the home switch to a HiCON input, bind it on the Inputs tab, and pick Home Sensor. The index pulse is not available on EtherCAT drives, so Index Pulse and Sensor + Index can't be chosen for them. Use a slow back-off speed for a repeatable home.
8.3Homing order
- 1 homes first. Higher numbers follow.
- Axes with the same number home at the same time.
- 0 leaves the axis out of Home All.
- If an axis fails, the others with its number finish, and higher numbers do not start.
Home Z first, so the tool clears the work. Axes that share one switch need different numbers.
8.4Homing from the screen
Add buttons in the screen designer:
| Button action | Does |
|---|---|
| HomeAll | Homes every axis that has a homing type, in order. |
| Home X | Homes one axis. Home Y, Z, A, B and C work the same. |
The machine must be enabled. From a script, use Core.homeAll() or
Core.homeAxis("X").
Each DRO row shows a dot by the axis letter: green when the axis is homed, orange when it still needs homing. An axis with no homing type has no dot.
If nothing is set up to home, the button says where to set it.
8.5Gantries
A gantry axis has two motors. Tick Independent on the Axes tab to choose how they home:
- Not ticked: the second motor follows the first, and both stop at the first motor's switch.
- Ticked: each motor finds its own switch, which squares the gantry. Bind the second motor's switch as Motor N Home on the Inputs tab, where N is its motor number. Without it, both use the axis switch.
Squaring distance moves the second motor a little further after its switch, for a frame that is not quite square. Squaring limit is the most you allow. A larger distance is refused before anything moves.
A drive that does its own homing always homes each motor on its own.
8.6Options
| Setting | Where | Does |
|---|---|---|
| Deref on Disarm | Axes tab | Disabling the machine marks the axis as needing homing again. Off unless you tick it. |
| Require homing before running a program | Interp / TP tab | Cycle Start, Run From Here and Return on Path wait until every axis with a homing type is homed. Jog and MDI still work. |
| Homing timeout | HiCON driver settings | Cancels Home All if it takes longer. None unless you set one. |
With Deref on Disarm ticked, that axis's soft limits are off after every disable, until you home again.