Skip to content

Working documentation

This page is generated from the project's working notes. It records a real retrofit in progress, corrections included. Anything marked πŸ”΄ or ⚠️ is unresolved or carries a caveat - read those before acting.

LinuxCNC motion pins, verified

10 September 2026. Before writing the HAL interlock for the drills and the saw, the motion-module pin semantics were checked against the LinuxCNC 2.9 documentation source and, where the docs are silent, the 2.9 C source.

πŸ”΄ This page exists because the pin semantics decide whether an interlock is real. Nothing on it is from memory.

⚠️ CORRECTED 10 September β€” the premise this page opened with was wrong

It said: "a wrong HAL pin name silently does nothing." That is not true. halcmd fails the net with "Pin '<name>' does not exist", the run script aborts, and LinuxCNC does not start. A wrong pin name is loud.

⭐ The real silent failure is a different and narrower thing, and it is still worth guarding: a pin that exists but is the wrong one for the job. motion.in-position and motion.motion-type both read "stopped" while the machine is jogging. Those load cleanly and lie. That is what the tripwires in hal-lint.py are actually for.

⚠️ The overstatement had a cost. It aimed the whole checking effort at typos, which the toolchain already catches, and away from the error that had this config dead on arrival for a week: the same component loadrt'd fourteen times. See the correction at the end of this page.

⚠️ Sourcing. linuxcnc.org is unreachable from the build environment. The quotes are from the doc source files in the LinuxCNC git repo, branch 2.9, that render into those pages, plus src/emc/motion/*.c where the docs do not answer the question. Anything marked [SOURCE] is behaviour read from C, not documented behaviour, and is therefore true of 2.9 but not contractual.


The finding that changed the design

πŸ”΄ motion.motion-inhibit does not exist. It is not in the man page, not in core-components.adoc, not in motion.c. The earlier draft of drills-and-saw-on-linuxcnc.md reached for a pin by that shape. A net line to a non-existent pin is exactly the silent no-op this page exists to prevent.

What does exist

Pin Stops G0 rapid? Stops feed? Stops jogs? Machine ends up
motion.enable βœ… yes, hard βœ… yes, hard βœ… yes πŸ”΄ machine OFF, amps dropped, motion queue cleared
motion.feed-hold see below βœ… πŸ”΄ no paused, machine still on
motion.feed-inhibit βœ… (see below) βœ… πŸ”΄ no decelerates at max accel, resumes when the pin goes false
motion.jog-inhibit no no ⚠️ prevents new jogs only program motion unaffected
motion.jog-stop / -immediate no no βœ… stops a running jog program motion unaffected
motion.homing-inhibit no no blocks homing
motion.misc-error-NN βœ… hard βœ… hard βœ… machine disabled, named error
spindle.N.inhibit no no no spindle speed forced to 0

motion.enable, verbatim:

If this bit is driven FALSE, motion stops, the machine is placed in the "machine off" state, and a message is displayed for the operator. For normal motion, drive this bit TRUE.

motion.feed-inhibit, verbatim:

When this pin is TRUE, machine motion is inhibited for G-code commands. If the machine is in the middle of a (non-spindle synchronized) move when this pin goes TRUE, the machine will decelerate to a stop at the maximum allowed acceleration rate. Motion resumes when this pin goes FALSE. Note: feed-inhibit applies to G-code commands -- not jogs.


πŸ”΄ Three traps, and they decide which pin to use

1. motion.feed-inhibit is defeatable by a line of G-code

[SOURCE] control.c gates it:

if ( enables & *emcmot_hal_data->feed_inhibit ) { scale = 0; }

feed_inhibit is a hal_bit_t, so this is enables & 1, and SS_ENABLED is 0x01. motion.feed-inhibit only acts while spindle-speed-override is enabled. It works by default, but πŸ”΄ M49 in the part program clears SS_ENABLED and the interlock silently stops working.

⚠️ Undocumented. For an interlock this is decisive: motion.enable is checked unconditionally in check_for_faults() and cannot be turned off from G-code. motion.feed-inhibit can.

2. motion.feed-hold and motion.adaptive-feed are inert unless the program enables them

motion.feed-hold needs M53 P1; motion.adaptive-feed needs M52 P1. A part program that never issues them leaves both pins doing nothing. πŸ”΄ Never use either as an interlock.

3. Neither feed pin touches jogging

Both say so: "applies to G-code commands -- not jogs." An operator jogging the gantry with a drill down is exactly the case this project needs covered, and the feed pins do not cover it. Cover jogging with motion.jog-inhibit (blocks new jogs) and motion.jog-stop (stops one already running) β€” ⚠️ jog-inhibit alone leaves a jog in progress running.


βœ… Does an inhibit stop a G0 rapid?

⚠️ The documentation never says the word "rapid" or "G0" in any inhibit-pin description, and the two doc sources disagree in tone: the man page says "machine motion is inhibited for G-code commands", core-components.adoc says "the feed rate is set to 0".

[SOURCE] resolves it. control.c computes net_feed_scale, and tpGetFeedScale() applies it to every trajectory segment regardless of motion type, the sole exception being spindle-position-synchronised moves. So yes β€” feed-inhibit, feed-hold and adaptive-feed=0 do stop a G0. True in 2.9, not guaranteed by the docs.


What motion.enable false actually costs

The docs say only the one sentence quoted above. The rest is [SOURCE] plus the homing docs:

Aborts the program? Effectively yes β€” set_operating_mode() calls tpClear(), so the queued trajectory is discarded
Following error? No β€” joint enable flags are cleared, and ferror is only checked on enabled joints
Re-homing? ⚠️ Only if VOLATILE_HOME is set for that joint: "this joint becomes unhomed whenever the machine transitions into the OFF state"
E-stop reset? No. motion.enable is not the E-stop path β€” that is iocontrol.0.emc-enable-in
Message "motion stopped by enable input"

πŸ”΄ It is a hard drop of the amp enables with the queue cleared, not a controlled stop. On a servo machine that is a coast or a drive fault. That is an acceptable cost for "a drill is down and the gantry is moving" β€” that is a crash in progress β€” but it is a real cost, not a free interlock.


πŸ”΄ The thing that makes this harder than it looked

IF.10 goes FALSE on every legitimate plunge. It means "everything on the head is retracted", so it is false for the whole of every drilling cycle, by design.

⚠️ So NOT(IF.10) -> motion.enable would drop the machine to "machine off" on every hole. The naive wiring in the first draft of drills-and-saw-on-linuxcnc.md was wrong.

βœ… The interlock has to be a conjunction:

   a drill is down   AND   an axis is actually moving   ->  stop

which is the same shape the saw needs (saw down AND both X and Y moving), and it means the interlock is built on velocity pins, not on the input alone.

⭐ motion.jog-inhibit is the one genuinely preventive half: while a drill is down, a jog cannot be started at all. Everything else in layer 3 is reactive.


M-codes and I/O, confirmed

M66 β€” and a warning that matters

Complete L modes:

L0 IMMEDIATE no waiting; current value into #5399
L1 RISE / L2 FALL wait for the edge
L3 HIGH / L4 LOW wait for the level

'Q-' - specifies the timeout in seconds for waiting. If the timeout is exceeded, the wait is interrupt, and the variable #5399 will be holding the value -1.

βœ… A timeout does NOT abort the program. Execution continues; testing #5399 for -1 is the caller's job. Every M66 in this project's subroutines does exactly that.

πŸ”΄ The docs rule M66 out for safety use in their own words:

In LinuxCNC these inputs are not monitored in real time and thus should not be used for timing-critical applications.

⚠️ This is why M66 on IF.10 is layer 1, not layer 3. It is a sequencing check at [TASK]CYCLE_TIME granularity (default 10 ms). The real-time guard is HAL.

βœ… But the ATC handshake IS achievable from G-code β€” the timing is not the problem

Every constraint in the Panasonic's Β§12-6 is a MINIMUM β€” β‰₯10 ms bit settle, β‰₯10 ms strobe. G4 guarantees at least; task's 10 ms cycle only ever adds. M66 polling at 10 ms is fine for a 25 s carousel wait.

πŸ”΄ What G-code lacks is a guaranteed exit path. An abort ends the program where it stands and nothing resets motion.digital-out-NN. That is the case that bricks the indexer, and the fix is [RS274NGC]ON_ABORT_COMMAND, not a HAL component. A HAL component only earns its place here if BUSY is wired and a sub-cycle response is genuinely needed.

⚠️ BUSY is not wired to any input (io-map.md lists COIN and the position echo only). So "release the strobe when BUSY asserts" is unimplementable at any layer β€” the implementable form is a timed pulse. That is a contradiction between carousel-indexer.md Β§12-6 and what the machine's wiring can do, and it is not yet resolved.

M62/M63 vs M64/M65

The M62 & M63 commands will be queued... The actual change of the specified outputs will happen at the beginning of the next motion command. If there is no subsequent motion command, the queued output changes won't happen.

M64 & M65 happen immediately as they are received by the motion controller. They are not synchronized with movement, and they will break blending.

⚠️ The trap for the saw: M62 P20 with no motion after it never fires. Always follow a queued output with a motion word.

[SOURCE] Queue depth is one pending change per output channel, all applied together at the start of the next segment. ⚠️ In-source comments claiming "the TP doesn't implement a queue of these" are stale.

Digital I/O count

Optionally the number of Digital I/O is set with num_dio... The default is 4 each.

πŸ”΄ There is no INI setting for this. num_dio is a loadrt motmod module parameter. The [EMCMOT] INI section "is a custom section and is not used by LinuxCNC directly" β€” it is a convention that HAL files read by substitution.

βœ… tuchop-sim.hal already loads num_dio=64 num_aio=16, which is the maximum for DIO (EMCMOT_MAX_DIO=64) and for AIO (EMCMOT_MAX_AIO=16). This matters: the numbering rule needs OF.48, so the default of 4 would have failed.

⚠️ Two doc defects: the man page says num_aio=[1-64]; the real maximum is 16. And the named-pin variants (motion.dout-<name>) are declared HAL_IN in the 2.9 source, which looks like a bug β€” use the numbered pins.

Subroutine parameters

On return from the subroutine, the values of parameters #1 through #30 ... will be restored... Parameters #1 - #30 are local to the subroutine.

πŸ”΄ An omitted argument reads as 0 silently. Named parameters (#<name>, #<_global>) raise an error instead when unset.

⚠️ This project's subroutines take #1/#2 and fall back to a built-in default when the value is 0. That is deliberate β€” a call with no timeout gets the safe default rather than an error β€” but it means a typo'd argument is indistinguishable from an omitted one. The generator always passes them explicitly.


πŸ”΄ What LinuxCNC's own documentation says about safety

πŸ”΄ CORRECTED β€” LinuxCNC does say it, in those words

An earlier draft of this page said no such statement exists. That was wrong. The plasmac man page (https://linuxcnc.org/docs/2.9/html/man/man9/plasmac.9.html) carries it verbatim:

DISCLAIMER: THE AUTHOR OF THIS SOFTWARE ACCEPTS ABSOLUTELY NO LIABILITY FOR ANY HARM OR LOSS RESULTING FROM ITS USE.

IT IS EXTREMELY UNWISE TO RELY ON SOFTWARE ALONE FOR SAFETY.

Any machinery capable of harming persons must have provisions for completely stopping all motors and moving parts etc. before persons enter any danger area.

⚠️ It is scoped to that component, not to LinuxCNC as a whole β€” it is the only statement of its kind in the entire rendered corpus. But it is official LinuxCNC documentation, and it is the sentence to quote.

⭐ This is exactly what retrofit-boundary.md already decided: OF.1, rail 8A, the drive-OK relays and the Z brake stay hardwired. The guards in tuchop-io.hal are functional interlocks that reduce damage. They are not the safety system.

The rest of what the docs say, and it is consistent:

⭐ The closest thing to a direct statement, about [HAL]SHUTDOWN:

because there is no guarantee this file will be executed (for instance, in the case of a computer crash), it is not a replacement for a proper physical e-stop chain or other protections against software failure.

Plus: the E-stop input "should be driven FALSE when an external E-Stop condition exists"; estop_latch describes itself as "part of a simple software ESTOP chain"; iocontrol's pins are "turned on and off in non-realtime context"; and the GUI docs list "Estop button hardwired in the estop chain" as recommended for any setup.

βœ… The honest reading: a HAL inhibit is a functional interlock, not a safety-rated one. It depends on the PC, the kernel, the realtime thread and the driver all still working. That is precisely why findings/retrofit-boundary.md keeps OF.1, rail 8A, the drive-OK relays and the Z brake hardwired.



πŸ”΄ This found a defect in the config that already exists

tuchop-io.hal section 7 already uses motion.feed-inhibit β€” for the HOLD chain, IF.7 with the two zone photocells:

net feed-inhibit not-holdok.out => motion.feed-inhibit

That was written on 3 September, before any of this was checked. Two problems, and the second is worse:

⚠️ M49 in a part program silently disables it The undocumented SS_ENABLED gating. A broken beam would then hold nothing
πŸ”΄ It does not affect jogging at all "feed-inhibit applies to G-code commands -- not jogs." An operator jogging with a photocell broken is not held

βœ… The fix, and why it is not simply "use motion.enable instead"

A controlled decel is the CORRECT behaviour for a hold. IF.7 and the photocells are a pause, not a crash β€” someone has stepped into a zone. Dropping to machine-off, clearing the queue and dumping the amp enables every time a beam is broken would be worse than the problem, and it is what makes people disable guards.

⭐ So keep feed-inhibit as the primary, and close its two holes:

Hole Closed by
Jogging is not covered motion.jog-inhibit (blocks a new jog) and motion.jog-stop (stops one already running). ⚠️ Both β€” jog-inhibit alone lets a jog already in flight continue
M49 can defeat it ⭐ A watchdog. If the inhibit is asserted and an axis is still moving after a short delay, drop motion.enable

πŸ”΄ The watchdog is the part worth stealing for anything else built on feed-inhibit. It costs a timedelay and reuses the velocity comparison the drill and saw guards already need, it never fires in normal operation because the controlled decel finishes first, and it converts a silent failure into a loud one. A guard that can be turned off by a line of G-code is not a guard.

⚠️ None of this makes it safety-rated. It is a functional interlock that depends on the PC, the kernel and the realtime thread. The real guarding stays hardwired β€” retrofit-boundary.md.


The velocity question, answered

πŸ”΄ motion.current-vel is the only velocity pin that is non-zero in all three motion states β€” coordinated (programs and G0 rapids), teleop jog, and free jog. Documented as "The current tool velocity in user units per second". It also collapses to zero when motion is inhibited, which is what a latch needs.

⚠️ Four plausible alternatives were considered and every one is wrong here:

Pin Why not
motion.in-position πŸ”΄ Not updated in teleop mode. Holds TRUE right through axis jogging β€” the default jog mode once homed
motion.motion-type πŸ”΄ Reads 0 (Idle) during ANY jog. It reflects the coordinated planner only
axis.L.teleop-vel-cmd πŸ”΄ Reads 0 for the whole of a program. Driven only by the teleop planner
joint.N.vel-fb πŸ”΄ Does not exist. There is no velocity feedback pin anywhere in the motion module
joint.N.vel-cmd Exists, but flagged (DEBUG) and documented as "subject to change or removal at any time"

βœ… Per-axis speed, needed for the saw, comes from ddt on joint.N.motor-pos-fb β€” measured reality rather than intent, and not (DEBUG)-flagged.

⚠️ All motion-module velocity pins are COMMANDED, not measured. The docs never say so. For "is it physically moving", the honest inputs are the encoder velocity pin or ddt on position feedback.

⚠️ And the jog-stop defect, found in round two

πŸ”΄ motion.jog-stop and motion.jog-stop-immediate carry the SAME SS_ENABLED defect as feed-inhibit β€” control.c gates them on the same enables & 1. So the pin that was supposed to close jog-inhibit's gap is itself defeatable by M49.

βœ… motion.jog-inhibit's own checks are unconditional, so it is clean. The gap it leaves β€” a jog already running β€” is closed by motion.enable, which is the only unconditional stop.


⭐ The alternative design, and why it was not used

The shipped plasmac component solves this exact problem differently. Its float switch also goes active during legitimate operation, and it handles that by having the G-code publish which operation is in progress:

if (sensor_active && program_is_idle && !(override_jog || state == PROBE_DOWN || state == PROBE_UP)) {
    jog_inhibit = TRUE;

That is necessary there because a probe move legitimately drives Z with the switch active.

πŸ”΄ Here it is not, and the velocity test is stronger. Nothing legitimately moves during a plunge, so the conjunction needs no authorisation signal β€” which means it cannot be defeated by a generator bug that leaves an authorisation output set. Simpler and harder to break, in the same move.


πŸ”΄ CORRECTED 10 September β€” M64 cannot fire mid-deceleration

This page, wait_stationary.ngc, ngc_lint.py and drills-and-saw-on-linuxcnc.md all argued that because M64 is documented as acting "immediately as they are received by the motion controller… not synchronized with movement", an M64 on the line after a G0 could execute while that G0 was still decelerating.

⚠️ It cannot. M64 is SET_AUX_OUTPUT_BIT with now=1; task's precondition for it is WAITING_FOR_MOTION, released only when motion.status == RCS_DONE, which requires in-position and an empty queue. On the commanded side the output cannot precede the end of the move.

What the documentation's phrase actually means is that the change does not happen at a chosen point along a path β€” which is also why it breaks blending. Not that it can overtake the move.

βœ… o<wait_stationary> stays, because there is a residual real gap: commanded-position-reached is not the same as physically stopped, and motion.current-vel is a commanded value that does not see following error either. But its justification in the comments was wrong, and a rule kept for a reason that turns out to be false is a rule nobody will trust the next time it is questioned.


πŸ”΄ CORRECTED 10 September β€” the error that mattered was not a pin name at all

A component may only be loadrt'd ONCE. The second loadrt or2 in a config fails with "or2: already exists", halcmd stops, and LinuxCNC exits.

tuchop-io.hal had 14 loadrt and2, 13 loadrt or2 and 9 loadrt not lines. πŸ”΄ It had never been able to start, through a week of work and every "the HAL check passes" written in this repo β€” because hal-lint.py merged repeated loads into one set of names and reported success.

⭐ The lesson is about the checker, not the config. A static check is only worth what its premise is worth. This one was built to catch a failure mode that does not exist (silent typos) and was structurally blind to the one that does. hal-lint.py now refuses a repeated load, and that check has a test proving it fires.

  • drills-and-saw-on-linuxcnc.md β€” the three-layer interlock this was checked for
  • retrofit-boundary.md β€” what stays hardwired and must never become software
  • ../linuxcnc-sim/tuchop-io.hal β€” where these pins get used