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.

VitalCNC main run screen on the Vital-MILL-Pro profile, showing DRO, overrides, G-code list, jogging controls, and a live 3D toolpath view
The run screen on a Vital · MILL Pro profile — DRO, overrides, jogging, G-code list, and the live 3D toolpath view, all on one screen.

Everything on that screen is driven by the same registers and the same scripting API documented in the rest of this manual:

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.

Three VitalCNC diagnostic tools open at once: Macro Debug Messages log, MotionScope live motion trace plotting command vs. feedback counts, and HiCON Controller Status showing per-motor values and raw digital I/O
Macro Debug Messages, MotionScope, and HiCON Controller Status — open together while tracing a toolpath live.
Diagnostics read the same state your scripts do

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.

ToDo
Turn the partDrag with the left button.
Slide itDrag with the right button.
ZoomScroll. 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 programClick 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-codeClick 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:

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.

Audience: machine builders and operators · XHC WHB04B-4 / -6

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.

"Connected" means the device is open

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

DialSelectsOFF / Lead position
AxisWhich axis the wheel jogs — X Y Z A B C OFF stops motion immediately, it does not merely stop feeding counts
FeedMillimetres per detent — the step multiplier Lead is a real detent with no step size; the wheel trims feed override instead of moving
The pendant's step multiplier is not set in software

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 })
MultiplierSet byCycled by
Screen / keyboard stepCore.setSoftJogSteps The on-screen JOG STEP button
Pendant stepThe 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:

ReasonFix
Axis dial is OFFTurn it to an axis
No step size selectedFeed dial is on Lead — turn it to a step
Machine not enabledEnable the machine
Machine busyWait 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.

PathMeaning
pendant/Connected1 = the HID device is open. See 1.1 — this is not "reports are arriving".
pendant/Axis0-5 = X Y Z A B C, −1 = OFF.
pendant/AxisNameText: X … C, or OFF.
pendant/StepMmMillimetres per detent. 0 = no usable step (Lead).
pendant/CountsLast wheel delta, signed.
pendant/CountsTotalAccumulated counts — bind to a DRO to watch the wheel turn.
pendant/ButtonLast button code.
pendant/Lead1 = feed dial on Lead; the wheel trims feed override.
pendant/DetentBad1 = a dial is between detents. The reading is not trusted.
pendant/BlockedText: why the last wheel movement was refused. Empty = fine.
pendant/DeviceText: connection detail.
pendant/StatusText: 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
Fn-shifted buttons are not bound

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.

Scope

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.

HostFileThreadTableCan it move the machine?
Macro engineMacros/m<N>.luaInterpretervc.* Yes — motion is stopped before the script runs
Screen enginemain.luaInterfaceCore.* Gui.* Only by submitting G-code
PLC engine Shippedplc.luaOwn workervc.* (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.
Caution

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

FunctionReturnsNotes
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)boolBlocks 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 returnsDrops queued motion and faults the program with msg.Shipped
Warning

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

FunctionReturnsNotes
vc.axis_get_pos(ax)numberWork position. ax is vc.AXIS_X … vc.AXIS_W.Shipped
vc.axis_get_machine_pos(ax)numberMachine position.Shipped
vc.spindle_get_rpm(sp)numberCommanded spindle speed.Shipped
vc.cntl_get_feed_rate()numberModal F word.Shipped
vc.cntl_get_fro() / set_fro(s)numberFeed-rate override, 1.0 = 100 %.Shipped
vc.cntl_get_line_nbr()numberCurrently returns 0 — not yet wired.In development

Tooling

See 5.2 for the model behind these.

FunctionReturnsNotes
vc.tool_get_selected()numberThe T word being requested.Shipped
vc.tool_get_current()numberTool 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.

FunctionReturnsNotes
vc.io_get_signal(sig)boolErrors 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)boolBreaks on E-Stop. false = timed out.Shipped
vc.io_has_signal(sig)boolFor genuinely optional hardware.Shipped
vc.io_get_analog(sig)numberReal 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_pinvariesRaw point by device/pin.Shipped
vc.reg_find(path) / reg_create(…)handleLook up or publish a register. See chapter 3.Shipped
vc.reg_get(h) / reg_set(h, v)numberBy handle, not by path. Shared with the screen.Shipped

Operator and diagnostics

FunctionNotes
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

ConstantValue
vc.AXIS_X … vc.AXIS_W0 … 8
vc.SPINDLE_STOP / CW / CCW0 / 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.

FunctionPurpose
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
There is no 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.*

FunctionReturnsPurpose
Gui.message(title, text)—Information dialog
Gui.warning(title, text)—Warning dialog
Gui.question(title, text)boolYes / 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.

FunctionFileWhen
initialize()main.luaOnce, when the profile loads. Put setup here.
<your name>()main.luaWhen a button with a "Lua Function" action is pressed.
init()plc.luaOnce when the profile loads, and again after a driver reconnect. Fetch your handles here. Shipped
scan()plc.luaEvery scan period, for as long as the PLC is running. Shipped
mcode_execute(m,p,q,r,l)Macros/m<N>.luaWhen that M-code runs. p q r l are the P/Q/R/L words.
Note

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.

Safety

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.

Note

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 PLCWhy
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 happensResult
A scan raisesLogged and counted. The next scan runs normally.
10 scans in a row raiseThe PLC disables itself and says so. A script failing every scan is broken now, not intermittently.
A scan takes longer than its periodCounted as an overrun, not an error — it was correct, just late. Logged at most once every 5 seconds.
50 scans in a row overrunThe PLC disables itself: the script cannot finish in time, and waiting will not fix it. Shorten it or raise the period.
init() raisesThe 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.

RegisterMeaning
core/plc/Running1 while scanning, 0 when stopped or self-disabled.
core/plc/ScanMsHow long the last scan took.
core/plc/ScanMaxMsThe worst since load. This is the one to tune against — the instantaneous value never shows you the spike that hurt.
core/plc/PeriodMsThe configured period, so a screen can show ScanMs against its budget.
core/plc/ErrorsScan faults since load.
core/plc/OverrunsLate scans since load.
core/plc/LastErrorText 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.

SettingKeyDefaultRange
PLC scan periodplc/scanMs20 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.

Caution

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.

Let the LED blink, not the scan

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.

Pick a name that is yours

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.

Audience: machine builders and integrators · Applies to screens, macros and drivers

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
Registers are not I/O

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.

Why this exists

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.

KindWritten byNotes
Driver telemetrythe driverRead-only in practice. Arc voltage, torch up/down.
Setpointscreen, macro or coreThe driver reads it and programs hardware.
Machine settingthe operatorMarked 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.

FunctionDoes
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 can go stale, and says so

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.

GroupWho owns itWriting 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 reconnect does not clear core's registers

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.

This is how PLC state reaches the screen

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.

Create is idempotent, on purpose

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.

Some registers are already there before anything runs

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.

Re-registering is safe, and does NOT reset the value

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.

Editing is off by default, and numbers only

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
Registers are not a substitute for interlocks

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.

Audience: machine builders and integrators · Applies when Machine = Plasma in VitalCAM

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?

OwnerParametersWhy
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.

The one line to remember

Kerf is the only cut-chart value a machine-side correction can never fix. Everything else is the controller's.

cut chart library cutcharts/*.json VitalCAM picks the row uses: kerf, feed writes: the SELECTION VitalCNC resolves the same row + machine overrides uses: heights, delays, volts G-code: ( @VC#-CUT MAT=… THK=… ) The G-code carries the selection, not the heights.
Both sides read the same chart. Only one of them can be corrected at the table.

4.2The cut chart

Three layers, in priority order. A lookup takes the first layer that supplies each field.

LayerFileWritten 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.

KeyMeaning
processIdA numbered process on a power supply: amperage, gas and consumable set together. 4510 = 45 A mild steel mechanised.
materialmild_steel, stainless, aluminum. Machine-readable; the UI prettifies it.
thicknessPlate thickness. In the library, in that library's own units.
verifiedHas 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.

Generic charts are starting points

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.

RuleApplies toReason
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 )
ChannelCarriesStatus
@VC#-CUTThe 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#-THCTorch-height setpoints: arc voltage, auto-ref, delay, anti-dive, limits. Shipped
@VC#-PLASMAPower console: amps, gas, pressure. Shipped
STATE=ON / OFFAn 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.

Units in directives are always millimetres

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.

time Z plate pierce H cut H 1 rapid 2 probe 3 fire + pierce delay 4 descend 5 THC delay, then engage
Probe finds the plate; every height after that is measured from it, not from Z zero.
StepWhat happensGoverned by
1Rapid to the pierce point at safe height.safe Z
2Probe 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
3Retract to pierce height, fire the torch, wait.pierce height, pierce delay
4Descend to cut height and start the cut.cut height
5Wait for the arc to stabilise, then hand height control to the THC.THC delay, arc voltage
Pierce height is not cut height

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.

ReferenceWhere it isWhat 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.

A short THC delay corrupts the reference

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:

ModeA 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.
Check the mode before trusting the number

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

SettingWhat it doesSource
Probe modeOff / every pierce / skip within a radius. Bowed sheet wants every pierce.Machine
Skip distanceRadius inside which a re-probe is skipped.Machine
Probe feedFeed for the probing move and the Z plunge.Machine
Float offsetSwitch overtravel — how far the torch keeps moving after the float switch trips. Subtracted from every probed height.Machine
Pierce heightHeight to fire at, above the probed plate.Chart
Cut heightHeight to cut at, above the probed plate.Chart

Torch height control

SettingWhat it doesSource
Arc voltageTarget 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 referenceSample 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 delayWait after the arc transfers before engaging height control. Too short and the THC chases the ignition transient.Chart
DeadbandVoltage tolerance before correcting. Stops the torch hunting.Machine
Deadband hysteresisCorrection starts at ± deadband but does not stop until the voltage crosses the setpoint itself. That asymmetry is what stops the torch hunting.Machine
Anti-diveFreeze THC below a velocity threshold — see 4.5.2, including the unit trap when switching between percent and absolute.Machine
Height max / minAbsolute heights above the plate, not corrections. Converted on the way to the controller — see 4.5.1.Machine
THC outputWhich output enables the THC. Wiring.Machine

Power console and path

SettingWhat it doesSource
Amps / gas / pressureProgrammed into the power supply. Long torch leads need more pressure — the chart's lead-length table handles that.Chart
Kerf widthPath offset, kerf/2. Frozen at post time.Chart → CAM
Cut speedThe 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
OverburnKeep cutting past 360° on a hole to clean up the closing point.Machine
Corner loopsLoop 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:

FieldSourceEditable 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.

Retargeting the groups

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:

  1. Press Stop.
  2. Jog the tool to roughly where you want to carry on. Height does not matter; only X and Y are used.
  3. 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 actionDoes
ReverseJogStartStops the program and starts travelling back along the cut.
ReverseJogStopStops 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

  1. 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.
  2. Cut a test row at chart values. One straight cut in the material you run most. Check the kerf width against the chart figure.
  3. 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.
  4. 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.
  5. Verify anti-dive on a corner. Cut a square. If the torch dives at the corners, raise anti-dive until it stops.
  6. 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.
Before the first pierce

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.

Audience: machine builders and integrators · Assumes a configured profile with a motion driver loaded

Reading the status chips

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.

spindle unclamp cylinder — pushes DOWN to release Belleville springs — pull UP, ≈1 tonne on BT30 drawbar — grips the pull stud pull stud taper + holder flange groove — what the fork grips
Springs clamp the tool; air releases it. Nothing powered is needed to hold a tool.
TermWhat it is
TaperThe 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 studA threaded knob screwed into the top of the holder. Also called a retention knob.
DrawbarA rod down the centre of the spindle that grips the pull stud and pulls it up, wedging the taper home.
Belleville springsA 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 cylinderA pneumatic or hydraulic cylinder that pushes the drawbar down against the springs to release the pull stud.
Flange grooveThe V-groove around the holder's flange. This is what a fork or gripper holds.
Drive dogsKeys 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.
Why the drawbar output means "unclamp"

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.

5.2The tool state model

Two separate facts, mirroring Mach4's mcToolGetSelected / mcToolGetCurrent split. Keeping them apart is the basis of a safe changer.

FactMacro APIMeaningLifetime
Selected toolvc.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 toolvc.tool_get_current() What is physically clamped in the taper. 0 means the spindle is empty. Machine state — survives Reset and restart
Commitvc.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:

Caution

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:

EventSelected toolCurrent tool
Program end / M30preservedpreserved
Resetcleared to 0preserved
Application restart0restored from the profile

Persistence is per profile, so two machines on one PC never overwrite each other. Shipped

Warning

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.

ConstantSignal rowDirectionUsed for
vc.OSIG_DRAWBARDrawbarOutputEnergise to release the tool
vc.OSIG_AIR_BLASTAir BlastOutputTaper blow-off while the drawbar is open
vc.OSIG_DUST_BOOTDust BootOutputLift the shoe clear before a change
vc.ISIG_TOOL_IN_SPINDLETool In SpindleInputProves a holder is present in the taper
vc.ISIG_DRAWBAR_UNCLAMPEDDrawbar UnclampedInputProves the drawbar reached release
vc.ISIG_DRAWBAR_CLAMPEDDrawbar ClampedInputProves the drawbar reached clamp
vc.OSIG_GP1 … 25Generic Output nOutputAnything machine-specific — a carousel motor, a shot pin
vc.ISIG_GP1 … 25Generic Input nInputAnything machine-specific — an in-position switch
vc.OSIG_ANA1 … 8Analog Output nOutput0-10 V drives, a VFD reference, a proportional valve
vc.ISIG_ANA1 … 8Analog Input nInputA 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 does not apply to analog

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

Note

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.

CodeEffect
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 … M221MachMotion 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.

Warning — these act early

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.

Caution — L is a state, not a wait mode

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.

SensorStoredWhat it meansAction
not fittedanyNothing to check againstTrust stored; note it in the trace
false0Agreed — spindle emptyProceed
falseT nTool was pulled by handWarn, set current to 0, continue
trueT nAgreed — spindle loadedProceed
true0A tool is present but unidentifiedPrompt 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.

ELEVATION zSafe — all X/Y traverses zClear — above the pull studs zFork — taper seated in the fork SP table PLAN rack rail — forks open toward the operator 1 2 3 4 yPocket — flange captured yApproach — clear of the forks slide in / out pocketPitchX
The six commissioning values. All are MACHINE coordinates (G53).

Put-away and pick-up are mirror images:

Put the old tool awayPick the new tool up
  1. Retract to zSafe
  2. Move to the pocket at yApproach
  3. Orient the spindle (BT30 drive dogs)
  4. Drop to zFork
  5. Slide +Y into the fork jaws
  6. Air blast on, drawbar to unclamp
  7. Lift to zClear — the taper leaves the holder
  8. Commit current tool = 0
  1. Traverse in X along the rack at zClear
  2. Lower to zFork into the new holder
  3. Drawbar to clamp, confirm, air blast off
  4. Commit current tool = n
  5. Slide −Y out of the fork
  6. Retract to zSafe
Caution

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.

How much warning the PLC gets is decided by the part program

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.

SettingKeyDefaultRange
PLC scan periodplc/scanMs20 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.

One writer per output

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:

ApproachWorks during machining?Why
Jog a spare CNC axis (U/V/W) from the PLCNo 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/OYes 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:

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

CORRECT — clear ready on the same scan the target changes targetready T3 T5 indexing — M6 waits here STALE — ready cleared late one scan of false "ready" for the OLD pocket
Clear the interlock on the same scan you notice the target moved.
  1. Clear the interlock before the changer moves, not after it arrives. Otherwise there is a full scan period in which M6 can read a "ready" that belongs to the previous pocket.
  2. 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, so atc/Ready simply keeps whatever value it had. Wrap anything an M6 waits on so a fault cannot leave a stale "ready" behind, and watch core/plc/Errors. A script that fails every scan disables itself after ten, and then every later M6 waits out its full timeout — treat a stopped PLC as a fault condition.
  3. 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_ms polling 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.

CallEffect
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
Warning

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

  1. Bind the signals first. Config → I/O, map every signal your macro references. An unbound signal will stop the macro on the first call.
  2. Dry run. Leave the macro's dryRun flag set. Every line the macro would send is printed instead. Read the whole sequence before anything moves.
  3. Measure the geometry by hand. Jog to each fork and record the machine coordinates. Do not derive them from the drawing.
  4. 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.
  5. First live change with a hand on the E-Stop. Empty spindle, one tool in one fork, and single-step it.
Caution

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.

Audience: machine builders and integrators · Assumes a configured profile with a motion driver loaded

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:

ColumnWhat it does
SignalThe canonical name. The list comes from the motion core, so every signal the core knows about appears here.
Mapping EnabledUnticked rows are ignored entirely.
Device / PinChosen from the catalogue your driver published. Pins are not typed by hand — if a pin is missing, the driver is not connected.
Active LowThe 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.
DescriptionYour own note. Carried through to diagnostics.
A signal with no binding is not an error

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:

CodeSpindle OnSpindle FwdSpindle Rev
M3 S<rpm> — clockwiseononoff
M4 S<rpm> — counter-clockwiseonoffon
M5 — stopoffoffoff

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

CodeCoolant FloodCoolant Mist
M7 — mist onunchangedon
M8 — flood ononunchanged
M9 — all coolant offoffoff

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:

OnOffOutput #
M200M2010
M202M2031
M204M2052
………
M220M22110

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.

P is the M-code number, not the pin

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)
WordMeaningDefault
PInput's M-code numberrequired
LState to wait for — 1 high, 0 low1
QTimeout in seconds0 = 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".

M66 is not a safety interlock

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:

AppliedMeaning
M3 M4 M5 M7 M8 M9With 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 M63With 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–M221When 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.

Do not use M64/M65 to fire something at a position

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.

How the synchronisation actually works

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:

  1. 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.
  2. Is the signal bound? Diagnostics → Registers… lists every point live. Watch it while you toggle.
  3. Does the M-code reach it? Type M8 into MDI and watch the same value. MDI runs one block with an empty queue, so timing is not a factor.
  4. 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.
If an output does nothing

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.

ModeYou writeMeaningSuits
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:

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.

RegisterDirectionMeaning
CmdVelocityyou writeVelocity 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.
CmdPositionyou writePosition mode. Where to go, in units. Seeded with the motor's current position, so an untouched target holds still rather than travelling to zero.
InPositioncore writesPosition mode. 1 once the motor is within the band of its target and stopped. This is what a script waits on.
Enableyou write0 holds the motor stopped without touching CmdVelocity — the M5 of an aux spindle. Defaults to 1.
Positioncore writesIntegrated position, units. Monotonic — a spindle's keeps growing.
PositionMod / RPMcore writesRotary conveniences: position within one rev, and speed in rev/min.
Velocitycore writesThe live ramped speed (what the motor is actually being commanded, not your setpoint).
AtLimit / Fault / Voltscore writesSoft-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
    }
  }
]
KeyUnitMeaning
path—The input, named as it appears in Register Diagnostics.
centre_voltsVThe reading that means stopped. 0 for a ±10V joystick; 5 for a 0–10V pot centred at half scale.
deadband_voltsVHow far either side of centre still counts as stopped. This is what stops a control creeping at rest.
max_voltsVIgnore anything further from centre than this, so a faulty input cannot ask for more than full speed.
units_per_voltspeed per voltSpeed, 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

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:

TabSettingWhat it does
InputsX Home, Y Home …The home switch for each axis.
MotorsHoming typeHow the motor finds home: a switch, the encoder index, both, the drive's own homing, or where it stands. None leaves it out.
MotorsSearch directionWhich way it moves to find the switch.
MotorsHome speedSpeed while searching.
MotorsBack-off speedSpeed leaving the switch. Slower gives a more repeatable home.
MotorsHome accel0 uses the motor's normal acceleration.
AxesHoming SequenceThe order for Home All. See 8.3.
AxesHome OffsetHow 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

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 actionDoes
HomeAllHomes every axis that has a homing type, in order.
Home XHomes 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:

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

SettingWhereDoes
Deref on DisarmAxes tabDisabling the machine marks the axis as needing homing again. Off unless you tick it.
Require homing before running a programInterp / TP tabCycle Start, Run From Here and Return on Path wait until every axis with a homing type is homed. Jog and MDI still work.
Homing timeoutHiCON driver settingsCancels 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.

VitalCNC Machine Builder's Manual · Chapters 1–8 of 12 · Subsequent chapters in preparation