diff --git a/docs/src/gcode.html.in b/docs/src/gcode.html.in
index 6e4c641129f..02353bbcf36 100644
--- a/docs/src/gcode.html.in
+++ b/docs/src/gcode.html.in
@@ -165,6 +165,7 @@ tr.head td, tr.head th { background: black; color: white; }
| G10 L2 | P R | Set Coordinate System |
| G10 L20 | P | Set Coordinate System |
| G28, G28.1 | | Go/Set Predefined Position |
+ | G28.2 | P | Home from G-code |
| G30, G30.1 | | Go/Set Predefined Position |
| G53 | | Move in Machine Coordinates |
| G52, G92 | | Coordinate System Offset |
diff --git a/docs/src/gcode/g-code.adoc b/docs/src/gcode/g-code.adoc
index 12dbcdf599f..7e27083cfad 100644
--- a/docs/src/gcode/g-code.adoc
+++ b/docs/src/gcode/g-code.adoc
@@ -74,6 +74,7 @@ as the 'L number', and so on for any other letter.
|<> |Plane Select
|<> |Set Units of Measure
|<> |Go to Predefined Position
+|<> |Home from G-code
|<> |Go to Predefined Position
|<> |Spindle Synchronized Motion
|<> |Rigid Tapping
@@ -1070,6 +1071,81 @@ It is an error if :
* Cutter Compensation is turned on
+[[gcode:g28.2]]
+== G28.2 Home from G-code(((G28.2 Home from G-code)))
+
+This non-modal code lets a program or MDI line reference the machine
+instead of requiring the operator to use the GUI's *Home All* button. It
+follows the same modal-group-0 pattern as `G28.1`/`G30.1`, takes no axis
+words, and *requires* a `P` word saying what to home.
+
+* 'G28.2 P-1' - runs the homing cycle on every joint, in `HOME_SEQUENCE`
+ order (the same operation as the GUI *Home All*). This form requires the
+ config to set `[JOINT_n]HOME_SEQUENCE`; exactly as with *Home All*, a
+ config with no `HOME_SEQUENCE` cannot home all joints at once and the
+ command fails, leaving the machine in joint mode.
+* 'G28.2 Pn' - runs the homing cycle on joint 'n' only, where 'n' is the
+ 0-based joint number matching its `[JOINT_n]` INI section (the same
+ numbering used by `HOME_SEQUENCE` and by joint jogging). Other joints are
+ left as they are, and `HOME_SEQUENCE` is not required for the `Pn` form.
+ How many joints actually home depends on the `HOME_SEQUENCE` of joint 'n':
++
+--
+** a *negative* (synchronized) sequence homes the whole synchronized group -
+ `Pn` on either joint of a gantry pair homes both;
+** a *positive* sequence shared with other joints homes only joint 'n' -
+ use `G28.2 P-1` to home the shared group together;
+** no sequence, or a sequence not shared with any other joint, homes just
+ joint 'n'.
+--
+
+[WARNING]
+There is no bare `G28.2`. Homing drives joints onto their switches at
+homing speed, ignoring soft limits, from wherever the machine happens to
+be, so homing the whole machine has to be asked for explicitly with `P-1`
+rather than being what a truncated or mistyped line does. A `G28.2` with no
+`P` word is an error and stops the program.
+
+.G28.2 Example Lines
+[source,ngc]
+----
+G28.2 P-1 (home all joints, in HOME_SEQUENCE order)
+G28.2 P1 (home joint 1 only)
+----
+
+A queued `G28.2` dips motion into free mode for the duration of the homing
+cycle and restores whatever mode (manual/MDI/auto) was active once it
+finishes, so the mode dip is invisible at the task level. Motion still
+enforces its own safety: the home is honored only when the machine is idle
+(in position with no queued motion) or in joint mode, and a home is refused
+mid-motion. Homing inhibits and per-joint limit handling are unchanged.
+
+[NOTE]
+When the homing cycle finishes, the interpreter resyncs its model of the
+current position from the machine (the same way it does after probing or a
+tool change), so a following move made in incremental distance mode
+(`G91`), or an arc whose center is given with `I`/`J`/`K`, is computed from
+where the machine actually is after homing. `G28.2` is a queue point: read
+ahead stops at it and resumes once the cycle has run. `G5x`/`G92` offsets
+that were active before the `G28.2` are still applied afterward and now
+refer to the newly established machine zero; clear or re-establish them if
+that is not what you want.
+
+[NOTE]
+There is no G-code unhome. Clearing a joint's reference is done from the
+GUI, halui or linuxcncrsh.
+
+[NOTE]
+`G28.2` is a LinuxCNC extension; there is no standard Fanuc equivalent.
+
+It is an error if:
+
+* Cutter radius compensation is active (`G41`, `G41.1`, `G42` or `G42.1`).
+* An axis word is present ('G28.2' takes no axis words).
+* The 'P' word is missing.
+* The 'P' word is not a whole number, or is negative other than `P-1`.
+* The 'P' word names a joint number the machine does not have.
+
[[gcode:g30-g30.1]]
== G30, G30.1 Go/Set Predefined Position(((G30 Go/Set Predefined Position)))
diff --git a/docs/src/gcode/overview.adoc b/docs/src/gcode/overview.adoc
index 62c6fd87198..85ff1a127a5 100644
--- a/docs/src/gcode/overview.adoc
+++ b/docs/src/gcode/overview.adoc
@@ -964,7 +964,7 @@ The modal groups are shown in the following Table.
[width="80%",cols="4,6",options="header"]
|===
|Modal Group Meaning | Member Words
-|Non-modal codes (Group 0) | G4, G10 G28, G30, G52, G53, G92, G92.1, G92.2, G92.3,
+|Non-modal codes (Group 0) | G4, G10 G28, G28.2, G30, G52, G53, G92, G92.1, G92.2, G92.3,
|Motion (Group 1) | G0, G1, G2, G3, G33, G38.n, G73, G76, G80, G81
G82, G83, G84, G85, G86, G87, G88, G89
|Plane selection (Group 2) | G17, G18, G19, G17.1, G18.1, G19.1
diff --git a/docs/src/remap/remap.adoc b/docs/src/remap/remap.adoc
index 218062048cd..75e2a0980fa 100644
--- a/docs/src/remap/remap.adoc
+++ b/docs/src/remap/remap.adoc
@@ -1851,7 +1851,7 @@ All the listed G-codes are already defined in the current implementation of Linu
|25 | | | | | | | | | |
|26 | | | | | | | | | |
|27 | | | | | | | | | |
-|28 |G28 |G28.1 | | | | | | | |
+|28 |G28 |G28.1 |G28.2 | | | | | | |
|29 | | | | | | | | | |
|===
diff --git a/lib/python/common/hal_glib.py b/lib/python/common/hal_glib.py
index a157a3f47c0..d5c49163990 100644
--- a/lib/python/common/hal_glib.py
+++ b/lib/python/common/hal_glib.py
@@ -287,7 +287,7 @@ class _GStat(GObject.GObject):
STATE_ON = linuxcnc.STATE_ON
STATE_OFF = linuxcnc.STATE_OFF
- group0 = ('G4', 'G10','G28', 'G30', 'G52', 'G53', 'G92', 'G92.1', 'G92.2', 'G92.3')
+ group0 = ('G4', 'G10','G28', 'G28.2', 'G30', 'G52', 'G53', 'G92', 'G92.1', 'G92.2', 'G92.3')
group1 = ('G0', 'G1', 'G2', 'G3', 'G33', 'G38.n', 'G73', 'G76', 'G80', 'G81',\
'G82', 'G83', 'G84', 'G85', 'G86', 'G87', 'G88', 'G89')
group2 = ('G17', 'G18', 'G19', 'G17.1', 'G18.1', 'G19.1')
diff --git a/lib/python/qtvcp/lib/mdi_text.py b/lib/python/qtvcp/lib/mdi_text.py
index e1a4cdca880..dd477c905c9 100644
--- a/lib/python/qtvcp/lib/mdi_text.py
+++ b/lib/python/qtvcp/lib/mdi_text.py
@@ -27,6 +27,7 @@ def gcode_titles():
'G21': 'Set Units to Millimeters',
'G28': 'Go to Predefined Position',
'G28.1': 'Set Predefined Position',
+ 'G28.2': 'Home from G-code',
'G30': 'Go to Predefined Position',
'G30.1': 'Set Predefined Position',
'G33': 'Spindle Synchronized Motion',
@@ -209,6 +210,7 @@ def gcode_descriptions(gcode):
'G21': G21,
'G28': G28,
'G28.1': G28_1,
+ 'G28.2': G28_2,
'G30': G30,
'G30.1': G30_1,
'G33': G33,
@@ -467,6 +469,29 @@ def gcode_descriptions(gcode):
G28.1 - stores the current absolute position into
parameters 5161-5166.
"""
+
+G28_2 = """G28.2 Home from G-code
+Runs the homing cycle, the same operation as the GUI
+Home All button, so a program or MDI line can
+reference the machine. Takes no axis words, and
+requires a P word saying what to home.
+
+G28.2 P-1 - homes all joints, in HOME_SEQUENCE order
+(the config must set HOME_SEQUENCE).
+
+G28.2 Pn - homes joint n only, where n is the
+0-based joint number matching its [JOINT_n] INI
+section. On a synchronized (negative HOME_SEQUENCE)
+joint pair, Pn on either joint homes both.
+
+There is no bare G28.2: homing the whole machine has
+to be asked for with P-1.
+
+It is an error if cutter compensation is on, if the P
+word is missing or is not -1 or a whole joint number,
+or if Pn names a joint the machine does not have.
+"""
+
G30 = """G30 Go to Predefined Position
G30 uses the values stored in parameters 5181-5189
as the X Y Z A B C U V W final point to move to.
diff --git a/src/emc/motion/command.c b/src/emc/motion/command.c
index a938fa5d613..17e9c767a37 100644
--- a/src/emc/motion/command.c
+++ b/src/emc/motion/command.c
@@ -1505,8 +1505,23 @@ void emcmotCommandHandler_locked(void *arg, long servo_period)
rtapi_print_msg(RTAPI_MSG_DBG, "JOINT_HOME");
rtapi_print_msg(RTAPI_MSG_DBG, " %d", joint_num);
- if (emcmotStatus->motion_state != EMCMOT_MOTION_FREE) {
- /* can't home unless in free mode */
+ /* Normally homing requires free (joint) mode. The queued G28.2 path
+ * (emctaskmain EMC_TASK_EXEC::WAITING_FOR_HOMING) dips motion into
+ * FREE before issuing this command; while that transition is still
+ * settling motion_state can read non-FREE for a cycle. Accept the
+ * command in exactly that window -- in position, nothing queued, and
+ * a FREE transition already pending (teleop and coord both cleared)
+ * -- so a G-code-triggered home from MDI / a program is honored.
+ *
+ * Do NOT accept it merely because motion is idle: an immediate home
+ * (halui, linuxcncrsh, c.home(n)) on an already-homed machine sitting
+ * in TELEOP is idle too, and do_homing() only advances in FREE
+ * (control.c), so accepting it there would silently drop the
+ * request (found in review of PR #4172). */
+ if (emcmotStatus->motion_state != EMCMOT_MOTION_FREE
+ && !(GET_MOTION_INPOS_FLAG() && emcmotStatus->depth == 0
+ && !emcmotInternal->teleoperating
+ && !emcmotInternal->coordinating)) {
reportError(_("must be in joint mode to home"));
return;
}
diff --git a/src/emc/motion/control.c b/src/emc/motion/control.c
index b509a133d3e..43ad5854168 100644
--- a/src/emc/motion/control.c
+++ b/src/emc/motion/control.c
@@ -2273,6 +2273,7 @@ static void update_status(void)
}
emcmotStatus->jogging_active = hal_get_bool(emcmot_hal_data->jog_is_active);
+ emcmotStatus->homing_active = get_homing_is_active();
/*! \todo FIXME - the rest of this function is stuff that was apparently
dropped in the initial move from emcmot.c to control.c. I
diff --git a/src/emc/motion/motion.h b/src/emc/motion/motion.h
index 06ff25eb0ba..b107b232811 100644
--- a/src/emc/motion/motion.h
+++ b/src/emc/motion/motion.h
@@ -675,6 +675,10 @@ Suggestion: Split this in to an Error and a Status flag register..
int numExtraJoints;
int stepping;
bool jogging_active;
+ bool homing_active; /* homing state machine is running (get_homing_is_active()).
+ Aggregate: stays true across the gap between
+ HOME_SEQUENCE groups, when every joint's per-joint
+ .homing flag is momentarily false. */
int switchkins_seq; /* echoes the config counter once acted on */
int switchkins_type; /* switchkins type now in force */
diff --git a/src/emc/nml_intf/canon.hh b/src/emc/nml_intf/canon.hh
index 51fce63005f..96e558dbf23 100644
--- a/src/emc/nml_intf/canon.hh
+++ b/src/emc/nml_intf/canon.hh
@@ -243,6 +243,13 @@ extern void SET_G92_OFFSET(double x, double y, double z,
extern void SET_XY_ROTATION(double t);
+/* G28.2: trigger the machine homing cycle from G-code (bare form = all
+ * joints, in HOME_SEQUENCE order). Maps to EMC_JOINT_HOME(-1). */
+extern void HOME_CYCLE(void);
+/* G28.2 Pn: home a single joint by its 0-based joint number (matching
+ * [JOINT_n] INI section numbering). Maps to EMC_JOINT_HOME(joint). */
+extern void HOME_CYCLE_JOINT(int joint);
+
/* Offset the origin to the point with absolute coordinates x, y, z,
a, b, c, u, v, and w. Values of x, y, z, a, b, c, u, v, and w are real
numbers. The units are whatever length units are being used at the time
diff --git a/src/emc/nml_intf/emc.cc b/src/emc/nml_intf/emc.cc
index bf20de160e3..fefc92b602f 100644
--- a/src/emc/nml_intf/emc.cc
+++ b/src/emc/nml_intf/emc.cc
@@ -1864,6 +1864,7 @@ void EMC_MOTION_STAT::update(CMS * cms)
EmcPose_update(cms, &eoffset_pose);
cms->update(numExtraJoints);
cms->update(jogging_active);
+ cms->update(homing_active);
cms->update(heartbeat);
}
diff --git a/src/emc/nml_intf/emc.hh b/src/emc/nml_intf/emc.hh
index bf4777478e7..dbe30abe555 100644
--- a/src/emc/nml_intf/emc.hh
+++ b/src/emc/nml_intf/emc.hh
@@ -216,7 +216,8 @@ enum class EMC_TASK_EXEC {
WAITING_FOR_DELAY = 8,
WAITING_FOR_SYSTEM_CMD = 9,
WAITING_FOR_SPINDLE_ORIENTED = 10,
- WAITING_FOR_KINS_SWITCH = 11
+ WAITING_FOR_KINS_SWITCH = 11,
+ WAITING_FOR_HOMING = 12
};
// types for EMC_TASK interpState
diff --git a/src/emc/nml_intf/emc_nml.hh b/src/emc/nml_intf/emc_nml.hh
index 7690268f9ee..2636d9c9302 100644
--- a/src/emc/nml_intf/emc_nml.hh
+++ b/src/emc/nml_intf/emc_nml.hh
@@ -1189,6 +1189,12 @@ class EMC_MOTION_STAT:public EMC_MOTION_STAT_MSG {
EmcPose eoffset_pose;
int numExtraJoints;
bool jogging_active;
+ // Aggregate "the homing state machine is running", from
+ // get_homing_is_active() in motion. Unlike the per-joint EMC_JOINT_STAT
+ // .homing flags, this stays true across the gap between HOME_SEQUENCE
+ // groups, where every joint momentarily reads .homing == false while the
+ // machine is still homing (see the race note in motion/homing.c).
+ bool homing_active;
uint64_t heartbeat; // motion controller's heartbeat counter
};
diff --git a/src/emc/nml_intf/emcops.cc b/src/emc/nml_intf/emcops.cc
index c6e77f40d9d..98e1eb26786 100644
--- a/src/emc/nml_intf/emcops.cc
+++ b/src/emc/nml_intf/emcops.cc
@@ -117,6 +117,7 @@ EMC_MOTION_STAT::EMC_MOTION_STAT()
eoffset_pose{},
numExtraJoints(0),
jogging_active(0),
+ homing_active(false),
heartbeat(0)
{
}
diff --git a/src/emc/rs274ngc/gcodemodule.cc b/src/emc/rs274ngc/gcodemodule.cc
index a4bdad6ef16..cb51aee8611 100644
--- a/src/emc/rs274ngc/gcodemodule.cc
+++ b/src/emc/rs274ngc/gcodemodule.cc
@@ -544,6 +544,9 @@ void SELECT_PLANE(CANON_PLANE pl) {
parse_state.canon->set_plane(static_cast(pl));
}
+void HOME_CYCLE(void) {}
+void HOME_CYCLE_JOINT(int) {}
+
void SET_TRAVERSE_RATE(double rate) {
parse_state.canon->set_traverse_rate(rate);
}
diff --git a/src/emc/rs274ngc/interp_array.cc b/src/emc/rs274ngc/interp_array.cc
index 63f6d20975a..6bfaa513b4e 100644
--- a/src/emc/rs274ngc/interp_array.cc
+++ b/src/emc/rs274ngc/interp_array.cc
@@ -84,7 +84,7 @@ const int Interp::gees[] = {
/* 220 */ -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
/* 240 */ -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
/* 260 */ -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-/* 280 */ 0, 0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+/* 280 */ 0, 0, 0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, // 282=G28.2
/* 300 */ 0, 0,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
/* 320 */ -1,-1,-1,-1,-1,-1,-1,-1,-1,-1, 1, 1,-1,-1,-1,-1,-1,-1,-1,-1,
/* 340 */ -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
diff --git a/src/emc/rs274ngc/interp_check.cc b/src/emc/rs274ngc/interp_check.cc
index 8be61f9c609..1eeb8625c1a 100644
--- a/src/emc/rs274ngc/interp_check.cc
+++ b/src/emc/rs274ngc/interp_check.cc
@@ -100,6 +100,7 @@ int Interp::check_g_codes(block_pointer block, //!< pointer to a block to be c
} else if (mode1 == G_5_2){
} else if (mode1 == G_6_2){
} else if (mode0 == G_28_1 || mode0 == G_30_1) {
+ } else if (mode0 == G_28_2) { // G-code homing
} else if (mode0 == G_52) {
} else if (mode0 == G_53) {
CHKS(((block->motion_to_be != G_0) && (block->motion_to_be != G_1)),
@@ -331,12 +332,14 @@ int Interp::check_other_codes(block_pointer block) //!< pointer to a block
(motion != G_6) && (motion != G_6_2) &&
(motion != G_2) && (motion != G_3) &&
(motion != G_74) && (motion != G_84) &&
+ (block->g_modes[GM_MODAL_0] != G_28_2) &&
(block->m_modes[9] != 50) && (block->m_modes[9] != 51) && (block->m_modes[9] != 52) &&
(block->m_modes[9] != 53) && (block->m_modes[5] != 62) && (block->m_modes[5] != 63) &&
(block->m_modes[5] != 64) && (block->m_modes[5] != 65) && (block->m_modes[5] != 66) &&
(block->m_modes[7] != 19) && (block->user_m != 1) &&
(block->o_type != M_98)),
_("P word with no G2 G3 G4 G10 G12.1 G64 G5 G5.2 G6, G6.2, G76 G82 G86 G88 G89"
+ " G28.2"
" or M50 M51 M52 M53 M62 M63 M64 M65 M66 M98 "
"or user M code to use it"));
int p_value = round_to_int(block->p_number);
diff --git a/src/emc/rs274ngc/interp_convert.cc b/src/emc/rs274ngc/interp_convert.cc
index 4ed206d83e7..93577daa852 100644
--- a/src/emc/rs274ngc/interp_convert.cc
+++ b/src/emc/rs274ngc/interp_convert.cc
@@ -3167,6 +3167,93 @@ Called by: convert_modal_0.
*/
+/*! convert_home_cycle
+
+Handles G28.2 (run the homing cycle) from a G-code line, so machines can
+reference themselves from MDI or a program instead of only from the GUI's
+*Home All* button.
+
+The P word is mandatory and says what to home: P-1 homes every joint in
+HOME_SEQUENCE order, and P0, P1, ... home a single joint by its 0-based
+joint number (matching [JOINT_n] INI section numbering, e.g. P1 ->
+JOINT_1). The single-joint form is the primitive raised in the PR #4172
+discussion for re-homing a joint that is switched between rotary-axis and
+spindle use mid-program
+(https://github.com/LinuxCNC/linuxcnc/pull/4172) -- it reuses the existing
+EMC_JOINT_HOME 'joint' field, so it needs no NML change and works
+identically on any kinematics.
+
+There is deliberately no bare form. G28.2 alone is refused, and homing
+every joint has to be asked for explicitly with P-1 (PR #4172, BsAtHome).
+Homing drives joints onto their switches at homing speed, ignoring soft
+limits, from wherever the machine happens to be; a G-code word that starts
+that on every joint of the machine should not be the one you get by
+mistyping or truncating a line, or by leaving a stale G28.2 in a file
+edited for a machine with different homing. A refusal, rather than doing
+nothing quietly: an operator whose G28.2 silently did nothing would have
+no way to tell it from a homing cycle that ran, and would go on to cut
+against a reference that was never re-established.
+
+The negative values the NML EMC_JOINT_HOME field can carry are not all
+G-code surface: -1 (all) is now spelled P-1, but -2 (volatile unhome) is
+refused here, as are all other negatives. Axis-letter forms (G28.2 X) are
+deliberately NOT supported either: resolving an axis letter to a joint
+needs the kinematics coordinate map and isn't trivial even on trivkins
+(duplicate letters on gantries), and homing is a joint concept, not an
+axis one.
+
+There is deliberately no G-code unhome. A G28.3 was part of the original
+proposal and was dropped during review of PR #4172: no use was found for it
+that a numbered parameter would not serve better under NO_FORCE_HOMING=1,
+and it was the one operation able to leave a running program on an
+unreferenced machine. The GUI, halui and linuxcncrsh keep their existing
+unhome.
+
+On a synchronized (negative HOME_SEQUENCE) joint pair, Pn on either joint
+homes both (motion's existing gantry-homing behavior); on a positive shared
+sequence Pn homes only the named joint -- use P-1 to home both.
+
+Motion still enforces its own safety (idle / not on limits). The joint
+number is range-checked against the machine's configured joint count in
+task (emcJointHome(), taskintf.cc), which is where that count is known --
+the interpreter has no joint count in its state.
+*/
+int Interp::convert_home_cycle(block_pointer block,
+ setup_pointer settings)
+{
+ CHKS((settings->cutter_comp_side != CUTTER_COMP::OFF),
+ "Cannot home (G28.2) with cutter radius compensation on");
+
+ CHKS((!block->p_flag),
+ "G28.2 requires a P word: P-1 homes every joint, or P0, P1, ..."
+ " home one joint by its number");
+
+ CHKS((block->p_number != round_to_int(block->p_number)),
+ "P value for G28.2 must be a whole joint number, or P-1 to home"
+ " every joint");
+
+ int joint = round_to_int(block->p_number);
+
+ CHKS((joint < -1),
+ "P value for G28.2 must be P-1 (every joint) or a joint number of"
+ " 0 or more");
+
+ if (joint < 0) {
+ HOME_CYCLE();
+ } else {
+ HOME_CYCLE_JOINT(joint);
+ }
+
+ // Homing re-establishes machine zero and, for an immediate home,
+ // rewrites the joint coordinate with no physical motion. Stop reading
+ // ahead here (INTERP_EXECUTE_FINISH, via execute_block) and, once the
+ // cycle has run, resync current_* from the machine in read_inputs() so
+ // a following G91 move or I/J/K arc centre is right. Same pattern as
+ // probing and tool change.
+ settings->home_flag = true;
+ return INTERP_OK;
+}
+
int Interp::convert_home(int move, //!< G-code, must be G_28 or G_30
block_pointer block, //!< pointer to a block of RS274 instructions
setup_pointer settings) //!< pointer to machine settings
@@ -4350,6 +4437,8 @@ int Interp::convert_modal_0(int code, //!< G-code, must be from group 0
CHP(convert_home(code, block, settings));
} else if ((code == G_28_1) || (code == G_30_1)) {
CHP(convert_savehome(code, block, settings));
+ } else if (code == G_28_2) {
+ CHP(convert_home_cycle(block, settings));
} else if ((code == G_52) || (code == G_92)) {
CHP(convert_axis_offsets(code, block, settings));
} else if ((code == G_5_3)||(code == G_6_3)) { // jjf
diff --git a/src/emc/rs274ngc/interp_execute.cc b/src/emc/rs274ngc/interp_execute.cc
index 2411aafc0cc..46eb9e1a873 100644
--- a/src/emc/rs274ngc/interp_execute.cc
+++ b/src/emc/rs274ngc/interp_execute.cc
@@ -199,8 +199,8 @@ Returned Value: int
convert_speed
convert_stop
convert_tool_select
- Otherwise, if the probe_flag in the settings is true,
- or the input_flag is set to true this returns
+ Otherwise, if the probe_flag, input_flag, toolchange_flag or home_flag
+ in the settings is true, this returns
INTERP_EXECUTE_FINISH.
Otherwise, it returns INTERP_OK.
@@ -327,6 +327,9 @@ int Interp::execute_block(block_pointer block, //!< pointer to a block of RS27
if (settings->kinsSwitch_flag)
return (INTERP_EXECUTE_FINISH);
+ if (settings->home_flag)
+ return (INTERP_EXECUTE_FINISH);
+
// All changes to settings are complete
write_canon_state_tag(block, settings);
return INTERP_OK;
diff --git a/src/emc/rs274ngc/interp_internal.hh b/src/emc/rs274ngc/interp_internal.hh
index b58d40e525f..b833a94e778 100644
--- a/src/emc/rs274ngc/interp_internal.hh
+++ b/src/emc/rs274ngc/interp_internal.hh
@@ -231,6 +231,7 @@ enum GCodes
G_21 = 210,
G_28 = 280,
G_28_1 = 281,
+ G_28_2 = 282, /* G-code homing cycle (home one/all joints) */
G_30 = 300,
G_30_1 = 301,
G_33 = 330,
@@ -760,6 +761,7 @@ struct setup
bool kinsSwitch_flag; // flag indicating waiting for kinematics switch done
int kins_type; // kinematics selected by G12.1/G13.1
bool toolchange_flag; // flag indicating we just had a tool change
+ bool home_flag; // flag indicating a G28.2 homing cycle just ran
int input_index; // channel queried
bool input_digital; // input queried was digital (false=analog)
bool cutter_comp_firstmove; // this is the first comp move
diff --git a/src/emc/rs274ngc/interp_setup.cc b/src/emc/rs274ngc/interp_setup.cc
index 3b5445795c3..d0611d9da9f 100644
--- a/src/emc/rs274ngc/interp_setup.cc
+++ b/src/emc/rs274ngc/interp_setup.cc
@@ -119,6 +119,7 @@ setup::setup() :
kinsSwitch_flag(0),
kins_type(0),
toolchange_flag(0),
+ home_flag(0),
input_index(0),
input_digital(0),
cutter_comp_firstmove(0),
diff --git a/src/emc/rs274ngc/rs274ngc_interp.hh b/src/emc/rs274ngc/rs274ngc_interp.hh
index 46491d6a627..cfbc3f34720 100644
--- a/src/emc/rs274ngc/rs274ngc_interp.hh
+++ b/src/emc/rs274ngc/rs274ngc_interp.hh
@@ -327,6 +327,8 @@ public:
setup_pointer settings);
int convert_savehome(int move, block_pointer block,
setup_pointer settings);
+ int convert_home_cycle(block_pointer block, // G28.2
+ setup_pointer settings);
int convert_length_units(int g_code, setup_pointer settings);
int convert_m(block_pointer block, setup_pointer settings);
int convert_modal_0(int code, block_pointer block,
diff --git a/src/emc/rs274ngc/rs274ngc_pre.cc b/src/emc/rs274ngc/rs274ngc_pre.cc
index 4163dc99a7c..3b4d2a40f57 100644
--- a/src/emc/rs274ngc/rs274ngc_pre.cc
+++ b/src/emc/rs274ngc/rs274ngc_pre.cc
@@ -1193,6 +1193,7 @@ int Interp::init()
//_setup.plane set in Interp::synch
_setup.probe_flag = false;
_setup.toolchange_flag = false;
+ _setup.home_flag = false;
_setup.input_flag = false;
_setup.kinsSwitch_flag = false;
_setup.input_index = -1;
@@ -1459,6 +1460,19 @@ int Interp::read_inputs(setup_pointer settings)
load_tool_table();
settings->toolchange_flag = false;
}
+ if (settings->home_flag) {
+ // A G28.2 homing cycle re-establishes machine zero and, for an
+ // immediate (index/switchless) home, rewrites the joint coordinate
+ // even when nothing physically moved. Pull the interpreter's model
+ // of the current position back in line with the machine so a
+ // following G91 move or an I/J/K arc centre is computed from where
+ // the tool actually is, not from the pre-home point. Same mechanism
+ // as the tool-change resync above, without the tool-table reload.
+ CHKS((GET_EXTERNAL_QUEUE_EMPTY() == 0),
+ _("Queue is not empty after homing"));
+ refresh_actual_position(&_setup);
+ settings->home_flag = false;
+ }
// always track toolchanger-fault and toolchanger-reason codes
settings->parameters[5600] = GET_EXTERNAL_TC_FAULT();
settings->parameters[5601] = GET_EXTERNAL_TC_REASON();
@@ -2692,6 +2706,7 @@ int Interp::on_abort(int reason, const char *message)
// if set, may cause a "Queue is not empty after tool change" error
_setup.toolchange_flag = false;
_setup.probe_flag = false;
+ _setup.home_flag = false;
_setup.input_flag = false;
_setup.kinsSwitch_flag = false;
diff --git a/src/emc/sai/saicanon.cc b/src/emc/sai/saicanon.cc
index 87c77b36fe7..fd946d5c3af 100644
--- a/src/emc/sai/saicanon.cc
+++ b/src/emc/sai/saicanon.cc
@@ -112,6 +112,9 @@ void SET_XY_ROTATION(double t) {
ECHO_WITH_ARGS("%.4f", t);
}
+void HOME_CYCLE(void) { ECHO_WITH_ARGS(""); }
+void HOME_CYCLE_JOINT(int joint) { ECHO_WITH_ARGS("%d", joint); }
+
void SET_G5X_OFFSET(int index,
double x, double y, double z,
double a, double b, double c,
diff --git a/src/emc/task/emccanon.cc b/src/emc/task/emccanon.cc
index 2c7e8ea5fa0..bb6c2deafb3 100644
--- a/src/emc/task/emccanon.cc
+++ b/src/emc/task/emccanon.cc
@@ -478,6 +478,31 @@ void SET_XY_ROTATION(double t) {
canon.xy_rotation = t;
}
+
+void HOME_CYCLE(void)
+{
+ // STRAIGHT_FEED/STRAIGHT_TRAVERSE buffer points into chained_points for
+ // arc-blend lookahead and only append to interp_list on flush (see
+ // see_segment()/flush_segments()). Without flushing here first, any
+ // motion queued just before this G28.2 would get silently reordered to
+ // execute AFTER the home instead of before it.
+ flush_segments();
+ auto msg = std::make_unique();
+ msg->joint = -1; // -1 = all joints (HOME_SEQUENCE order)
+ interp_list.append(std::move(msg));
+}
+
+/* G28.2 Pn -- home a single joint. joint is the interp's already-validated
+ * (non-negative) P value; task range-checks it against the machine's
+ * configured joint count in emcJointHome() (taskintf.cc). */
+void HOME_CYCLE_JOINT(int joint)
+{
+ flush_segments(); // see HOME_CYCLE
+ auto msg = std::make_unique();
+ msg->joint = joint;
+ interp_list.append(std::move(msg));
+}
+
void SET_G5X_OFFSET(int index,
double x, double y, double z,
double a, double b, double c,
diff --git a/src/emc/task/emctaskmain.cc b/src/emc/task/emctaskmain.cc
index 5e41127a5a2..febd808abc6 100644
--- a/src/emc/task/emctaskmain.cc
+++ b/src/emc/task/emctaskmain.cc
@@ -392,6 +392,39 @@ static EMC_TRAJ_SET_SPINDLESYNC *emcTrajSetSpindlesyncMsg;
//static EMC_MOTION_SET_AOUT *emcMotionSetAoutMsg;
//static EMC_MOTION_SET_DOUT *emcMotionSetDoutMsg;
+// G28.2 sequencing state (see EMC_TASK_EXEC::WAITING_FOR_HOMING):
+// homing only actually runs while motion is in FREE mode (control.c only
+// calls do_homing() there), so a queued home triggered from a program or
+// MDI while running in TELEOP/COORD would otherwise silently stall. We dip
+// motion into FREE for the duration and restore whatever mode it was in
+// before, invisibly to the task-level MDI/AUTO/MANUAL state
+// (mdiOrAuto is untouched) -- same principle as multichannel-DESIGN.txt's
+// "channel sessions do NOT flip the global teleop mode" for the analogous
+// per-channel-homing problem.
+static int homingWaitJoint = -1; // joint (-1 = all) we're waiting on
+static bool homingWaiting = false; // true while EMC_TASK_EXEC::WAITING_FOR_HOMING is active
+static bool homingStarted = false; // true once we've observed .homing go true at least once
+static double homingIssueTime = 0.0; // etime() when issued, for the start-timeout below
+// Some motion-side guards (e.g. "must be in joint mode to home",
+// motion.homing-inhibit, already-homing) reject with reportError() and a
+// bare return, without setting commandStatus to a failure -- so a rejected
+// home can look identical to an accepted one at the retval/NML level. Give
+// it this long to actually start (.homing go true) before treating it as
+// rejected; once started, there is no further timeout (real homing cycles
+// vary widely in duration, same as a GUI just watching .homing/.homed).
+static const double HOMING_START_TIMEOUT = 2.0;
+static EMC_TRAJ_MODE homingPriorMode = EMC_TRAJ_MODE::FREE; // mode to restore on success
+// True only while emcTaskExecute() is issuing a command taken off the
+// interp_list. emcTaskIssueCommand() serves both that queued path and the
+// immediate commands emcTaskPlan() sends straight through (the GUI's Home
+// button, halui, linuxcncrsh), and only the queued path is followed by
+// emcTaskCheckPostconditions() -- so only the queued path can ever reach
+// EMC_TASK_EXEC::WAITING_FOR_HOMING to undo the FREE-mode dip and restore the
+// previous trajectory mode. Dipping on an immediate command would therefore
+// strand the machine in FREE for good. Gate the sequencing on this flag so
+// an immediate home keeps its original pass-through behaviour.
+static bool issuingQueuedCommand = false;
+
static EMC_SPINDLE_SPEED *spindle_speed_msg;
static EMC_SPINDLE_ORIENT *spindle_orient_msg;
static EMC_SPINDLE_WAIT_ORIENT_COMPLETE *wait_spindle_orient_complete_msg;
@@ -1611,6 +1644,12 @@ static EMC_TASK_EXEC emcTaskCheckPreconditions(NMLmsg * cmd)
return EMC_TASK_EXEC::WAITING_FOR_MOTION_AND_IO;
break;
+ case EMC_JOINT_HOME_TYPE: // G28.2: program-order homing
+ // drain prior motion before homing; without this case a queued home
+ // hit default -> EMC_TASK_EXEC::ERROR and was silently dropped
+ // (never reached motion).
+ return EMC_TASK_EXEC::WAITING_FOR_MOTION;
+
default:
// unrecognized command
if (emc_debug & EMC_DEBUG_TASK_ISSUE) {
@@ -1675,10 +1714,54 @@ static int emcTaskIssueCommand(NMLmsg * cmd)
case EMC_JOINT_HOME_TYPE:
home_msg = reinterpret_cast(cmd);
- retval = emcJointHome(home_msg->joint);
+ homingWaiting = false; // default; set true below only if we actually issue a home
+ {
+ const int target_joint = home_msg->joint;
+ if (!issuingQueuedCommand) {
+ // Immediate command (the GUI Home button, halui, linuxcncrsh):
+ // pass it straight through, exactly as before this sequencing
+ // existed. Nothing calls emcTaskCheckPostconditions() for an
+ // immediate command, so a mode dip taken here would never be
+ // undone. See issuingQueuedCommand.
+ retval = emcJointHome(target_joint);
+ break;
+ }
+ // do_homing() (control.c) only advances while motion is in FREE
+ // mode, so a queued home while running in TELEOP/COORD would
+ // otherwise silently stall. Dip into FREE for the duration and
+ // restore whatever mode was active once homing finishes (or is
+ // found to have been rejected), invisibly to the task-level
+ // MDI/AUTO/MANUAL state.
+ homingPriorMode = emcStatus->motion.traj.mode;
+ if (homingPriorMode != EMC_TRAJ_MODE::FREE) {
+ emcTrajSetMode(EMC_TRAJ_MODE::FREE);
+ }
+ homingWaitJoint = target_joint;
+ homingStarted = false;
+ homingIssueTime = etime();
+ homingWaiting = true;
+ retval = emcJointHome(target_joint);
+ if (retval != 0) {
+ // emcJointHome() rejected the request outright (e.g. an
+ // invalid joint number) -- homing will never start, so the
+ // WAITING_FOR_HOMING poll below would never run to undo the
+ // FREE-mode dip either. Undo it here instead, or traj.mode
+ // (and therefore task.mode, which determineMode() derives
+ // from it) stays stuck at FREE/MANUAL until the operator
+ // manually cycles mode again.
+ homingWaiting = false;
+ if (homingPriorMode != EMC_TRAJ_MODE::FREE) {
+ emcTrajSetMode(homingPriorMode);
+ }
+ }
+ }
break;
case EMC_JOINT_UNHOME_TYPE:
+ // No sequencing here: with G28.3 dropped from this PR an unhome can
+ // only arrive as an immediate command (the GUI, halui, linuxcncrsh),
+ // never from the interpreter, so there is no queued path whose
+ // trajectory mode would need dipping and restoring.
unhome_msg = reinterpret_cast(cmd);
retval = emcJointUnhome(unhome_msg->joint);
break;
@@ -2538,6 +2621,13 @@ static EMC_TASK_EXEC emcTaskCheckPostconditions(NMLmsg * cmd)
return EMC_TASK_EXEC::WAITING_FOR_SPINDLE_ORIENTED;
break;
+ case EMC_JOINT_HOME_TYPE:
+ // homingWaiting is false when the command was passed straight through
+ // as an immediate command (see issuingQueuedCommand) -- the sequencing
+ // did not run, so there is nothing to wait for.
+ return homingWaiting ? EMC_TASK_EXEC::WAITING_FOR_HOMING : EMC_TASK_EXEC::DONE;
+ break;
+
case EMC_TRAJ_DELAY_TYPE:
case EMC_AUX_INPUT_WAIT_TYPE:
return EMC_TASK_EXEC::WAITING_FOR_DELAY;
@@ -2671,7 +2761,13 @@ static int emcTaskExecute(void)
}
} else {
// have an outstanding command
- if (0 != emcTaskIssueCommand(emcTaskCommand.get())) {
+ // This is the one emcTaskIssueCommand() call site followed by
+ // emcTaskCheckPostconditions(), i.e. the only one whose commands
+ // can reach a WAITING_FOR_* state. See issuingQueuedCommand.
+ issuingQueuedCommand = true;
+ const int issue_retval = emcTaskIssueCommand(emcTaskCommand.get());
+ issuingQueuedCommand = false;
+ if (0 != issue_retval) {
emcStatus->task.execState = EMC_TASK_EXEC::ERROR;
retval = -1;
} else {
@@ -2784,6 +2880,155 @@ static int emcTaskExecute(void)
break;
}
+ case EMC_TASK_EXEC::WAITING_FOR_HOMING:
+ // G28.2 sequencing: wait for the joint home issued in
+ // emcTaskIssueCommand to actually run to completion (do_homing() only
+ // advances while motion is in FREE, which is why we dipped into it
+ // there), then restore the prior trajectory mode. See the
+ // homingWaiting block of static state near the top of this file.
+ STEPPING_CHECK();
+ {
+ bool any_homing = false;
+ bool all_target_homed = true; // success criterion
+ int lo = (homingWaitJoint < 0) ? 0 : homingWaitJoint;
+ int hi = (homingWaitJoint < 0) ? (emcStatus->motion.traj.joints - 1) : homingWaitJoint;
+ for (int j = lo; j <= hi; j++) {
+ if (emcStatus->motion.joint[j].homing) {
+ any_homing = true;
+ }
+ if (!emcStatus->motion.joint[j].homed) {
+ all_target_homed = false;
+ }
+ }
+
+ bool success;
+ // "Is homing still running?" must come from motion's aggregate
+ // homing_active, not from OR-ing the per-joint .homing flags. On a
+ // machine that homes in several HOME_SEQUENCE groups the sequence
+ // machine finishes one group and can spend a cycle or more before
+ // the next group raises .homing, so there is a window in which
+ // every joint reads .homing == false while the machine is still
+ // homing. Task samples far coarser than the servo cycle, so it can
+ // land in that window, conclude homing stopped, and score a
+ // perfectly good home-all as "did not complete". motion/homing.c
+ // documents the same deassertion lag in its own words ("The homing
+ // status variable turns false before homing_active state turns
+ // false") and guards against it internally for the same reason.
+ //
+ // Single-joint Pn and single-sequence machines never hit the gap,
+ // which is why this only shows up on a multi-sequence home-all.
+ // The per-joint OR is kept as a belt-and-braces term: it can only
+ // extend the "still running" window, never shorten it.
+ const bool homing_running = emcStatus->motion.homing_active || any_homing;
+ if (homing_running) {
+ homingStarted = true;
+ break; // still running; no timeout once started (see HOMING_START_TIMEOUT comment)
+ }
+ if (!homingStarted) {
+ // Never observed homing go active: motion silently rejected
+ // it (a guard like "must be in joint mode", or
+ // motion.homing-inhibit, reports an operator error but does
+ // not fail the NML command -- see emcJointHome's caller),
+ // or this is the same task cycle it was issued in. Give it
+ // HOMING_START_TIMEOUT before concluding it was rejected.
+ if (etime() - homingIssueTime < HOMING_START_TIMEOUT) {
+ break;
+ }
+ emcOperatorError("G28.2 home did not start -- check machine mode, "
+ "motion.homing-inhibit, and whether a homing "
+ "cycle is already in progress");
+ emcStatus->task.execState = EMC_TASK_EXEC::ERROR;
+ emcTaskEager = 1;
+ homingWaiting = false;
+ // Nothing physically moved, so it's safe to restore the
+ // mode immediately instead of leaving the machine parked
+ // in FREE.
+ if (homingPriorMode != EMC_TRAJ_MODE::FREE) {
+ emcTrajSetMode(homingPriorMode);
+ }
+ break;
+ }
+ // It ran and has now stopped; did it reach the expected end state?
+ success = all_target_homed;
+
+ homingWaiting = false;
+ emcTaskEager = 1;
+
+ // Is it legal to hand the machine back to the coordinated mode it
+ // was in before the FREE dip? Motion refuses to (re-)enter TELEOP
+ // or COORD on non-identity kinematics unless *every* joint is
+ // homed -- switch_to_teleop_mode() (motion.c) and the EMCMOT_COORD
+ // case (command.c) both gate on
+ // "kinType != KINEMATICS_IDENTITY && !get_allhomed()".
+ //
+ // Restoring unconditionally means motion rejects the request, task
+ // still reports DONE, and the machine is stranded in FREE with the
+ // GUI's mode controls greyed out -- recoverable only by cycling the
+ // controller (PR #4172: Sigma1912's "g28.3 p0" on a gantry gave
+ // "all joints must be homed before going into coordinated mode",
+ // then needed F2). Mirror motion's own condition here and abort
+ // cleanly instead of wedging.
+ //
+ // With G28.3 gone from this PR the only command that reaches here
+ // is a home, which ends with its joints referenced, so on
+ // non-identity kinematics -- where a coordinated prior mode already
+ // implies the machine was fully homed -- this gate now guards a
+ // state no G-code can produce. It is kept because the alternative
+ // failure is silent: motion defers the COORD/TELEOP transition to
+ // its controller cycle, so a refused restore leaves no trace task
+ // could notice, and the operator gets a dead UI with no message.
+ //
+ // Mirroring the condition rather than issuing the restore and
+ // checking whether it took is deliberate: the COORD/TELEOP
+ // transition is deferred to the controller cycle (see
+ // "defer transition to controller cycle" in command.c), so an
+ // immediate read-back would race exactly the way the old
+ // .homing-based completion test did.
+ //
+ // Read the kinematics type from status, not from this file's
+ // static emcmotConfig: that copy is filled in once just before
+ // the main loop and never refreshed, while taskintf.cc re-reads
+ // the motion config whenever config_num changes and republishes
+ // it as traj.kinematics_type. The two agree today -- kinType is
+ // written exactly once, in init_comm_buffers() (motion.c), and a
+ // runtime switchkins change does not alter it (switchkins answers
+ // KINEMATICS_BOTH for every selectable type) -- so this is a
+ // matter of reading the copy that is maintained, not of avoiding
+ // a divergence that exists now.
+ const bool restore_ok = (homingPriorMode == EMC_TRAJ_MODE::FREE)
+ || (emcStatus->motion.traj.kinematics_type
+ == KINEMATICS_IDENTITY)
+ || all_homed();
+
+ if (success && !restore_ok) {
+ // The command itself did what was asked, but it left the
+ // machine partially referenced and motion will not take
+ // TELEOP/COORD back in that state. Stay in FREE and fail the
+ // program rather than report DONE and strand the operator.
+ emcOperatorError(_("G28.2 home succeeded but left the machine not "
+ "fully homed -- staying in joint mode, as "
+ "non-identity kinematics cannot re-enter "
+ "coordinated motion until every joint is homed"));
+ emcStatus->task.execState = EMC_TASK_EXEC::ERROR;
+ } else if (success) {
+ emcStatus->task.execState = EMC_TASK_EXEC::DONE;
+ if (homingPriorMode != EMC_TRAJ_MODE::FREE) {
+ emcTrajSetMode(homingPriorMode);
+ }
+ } else {
+ // Homing stopped without reaching the target state (aborted,
+ // faulted, ESTOP mid-cycle, ...) -- abort the program rather
+ // than let it continue unreferenced. Deliberately NOT restored to
+ // homingPriorMode here: an unhomed/partially-homed machine may
+ // not legally re-enter TELEOP/COORD, and FREE is the safe
+ // state to leave it in for an operator to intervene from.
+ emcOperatorError("G28.2 home did not complete for joint %s",
+ homingWaitJoint < 0 ? "ALL" : "requested");
+ emcStatus->task.execState = EMC_TASK_EXEC::ERROR;
+ }
+ }
+ break;
+
case EMC_TASK_EXEC::WAITING_FOR_DELAY:
STEPPING_CHECK();
// check if delay has passed
diff --git a/src/emc/task/taskintf.cc b/src/emc/task/taskintf.cc
index 585a22af698..9b6fab24c62 100644
--- a/src/emc/task/taskintf.cc
+++ b/src/emc/task/taskintf.cc
@@ -796,8 +796,35 @@ int emcJointOverrideLimits(int joint)
int emcJointHome(int joint)
{
- if (joint < -1 || joint >= EMCMOT_MAX_JOINTS) {
- return 0;
+ // Range-check against the machine's *configured* joint count, not against
+ // EMCMOT_MAX_JOINTS: joints[] is sized for the compile-time maximum, so a
+ // joint number between the configured count and that maximum passes a
+ // EMCMOT_MAX_JOINTS check, reaches motion, and is then silently dropped --
+ // do_home_joint() has no joint that could start homing, and says nothing.
+ // Task, waiting for a homing cycle that will never begin, sat out its
+ // start timeout with the machine parked in the FREE-mode dip and then
+ // reported the generic "home did not start" (PR #4172: Sigma1912's
+ // "g28.2 p5" on a 5-joint machine -- two seconds of the GUI jogging in
+ // joint mode, then a message naming nothing).
+ //
+ // Checked here rather than in the interpreter because the joint count is
+ // not part of interpreter state, and here it covers every caller (G28.2
+ // Pn, the GUI Home button, halui, linuxcncrsh) instead of just G-code.
+ //
+ // Reported with emcOperatorError(), not rcs_print(): an operator typing
+ // "G28.2 P5" needs to see it, and only the error channel reaches the GUI.
+ if (joint < -1 || joint >= TrajConfig.Joints) {
+ // Report only what the person reading it can act on: the joints this
+ // machine actually has, and the spelling that homes them all. "-1 for
+ // all" is the NML convention the GUI buttons, halui and linuxcncrsh
+ // use, and G28.2 P-1 is how G-code asks for the same thing, so it is
+ // safe to name here -- unlike -2 (volatile unhome), which no caller of
+ // this function can use and which convert_home_cycle() refuses
+ // (PR #4172, Sigma1912).
+ emcOperatorError("Cannot home invalid joint %d (this machine has "
+ "joints 0..%d; P-1 homes every joint)",
+ joint, TrajConfig.Joints - 1);
+ return EMCMOT_COMM_ERROR_COMMAND;
}
emcmotCommand.command = EMCMOT_JOINT_HOME;
@@ -808,8 +835,20 @@ int emcJointHome(int joint)
int emcJointUnhome(int joint)
{
- if (joint < -2 || joint >= EMCMOT_MAX_JOINTS) {
- return 0;
+ // See emcJointHome: bound by the configured joint count, and report it
+ // where the operator can see it. Motion does range-check the unhome
+ // path, but as "jno > all_joints", so the first unconfigured joint
+ // number slips through to an unrelated complaint about extra joints --
+ // and because an unconfigured joint reads as not homed, task's
+ // synchronous "no joint in range is still homed" test would then score
+ // that refusal as a *successful* unhome.
+ if (joint < -2 || joint >= TrajConfig.Joints) {
+ // See the note in emcJointHome() above on why the internal
+ // sentinels are not offered to the operator here.
+ emcOperatorError("Cannot unhome invalid joint %d (this machine "
+ "has joints 0..%d)",
+ joint, TrajConfig.Joints - 1);
+ return EMCMOT_COMM_ERROR_COMMAND;
}
emcmotCommand.command = EMCMOT_JOINT_UNHOME;
@@ -2172,6 +2211,7 @@ int emcMotionUpdate(EMC_MOTION_STAT * stat)
}
stat->jogging_active = emcmotStatus.jogging_active;
+ stat->homing_active = emcmotStatus.homing_active;
stat->numExtraJoints = emcmotStatus.numExtraJoints;
// set the status flag
diff --git a/tests/interp/g28.2/flush-order/checkresult b/tests/interp/g28.2/flush-order/checkresult
new file mode 100755
index 00000000000..24dc9aa53e3
--- /dev/null
+++ b/tests/interp/g28.2/flush-order/checkresult
@@ -0,0 +1,2 @@
+#!/bin/sh
+exit 0 # test failure is indicated by test.sh exit value
diff --git a/tests/interp/g28.2/flush-order/sim.tbl b/tests/interp/g28.2/flush-order/sim.tbl
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/interp/g28.2/flush-order/test-ui.py b/tests/interp/g28.2/flush-order/test-ui.py
new file mode 100755
index 00000000000..5666c1534af
--- /dev/null
+++ b/tests/interp/g28.2/flush-order/test-ui.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+
+"""
+Regression test for the flush_segments() ordering fix in HOME_CYCLE()/
+HOME_CYCLE()/HOME_CYCLE_JOINT()
+(emccanon.cc).
+
+STRAIGHT_FEED/STRAIGHT_TRAVERSE buffer points into chained_points for
+arc-blend lookahead and only reach interp_list on a flush (see
+see_segment()/flush_segments()). Without an explicit flush_segments() call
+at the start of the home canon functions, a queued move immediately
+before a G28.2 could silently get reordered to run *after* the home
+instead of before it.
+
+test.ngc queues "G1 X2" (a multi-cycle move, slow enough to poll mid-flight)
+immediately followed by "G28.2 P0". This script polls position and homed
+state throughout the run and asserts joint 0 never reports homed=1 before
+its position has actually reached the X2 target -- if the move were
+silently deferred to after the home (the bug), homed would flip true while
+position was still near its starting point.
+"""
+
+import linuxcnc
+import hal
+
+import sys
+import time
+
+h = hal.component("python-ui")
+h.ready()
+
+c = linuxcnc.command()
+s = linuxcnc.stat()
+
+
+def poll():
+ s.poll()
+
+
+def fail(msg):
+ print("FAIL: " + msg)
+ sys.exit(1)
+
+
+c.state(linuxcnc.STATE_ESTOP_RESET)
+c.state(linuxcnc.STATE_ON)
+c.mode(linuxcnc.MODE_AUTO)
+time.sleep(0.2)
+
+c.program_open("test.ngc")
+time.sleep(0.2)
+c.auto(linuxcnc.AUTO_RUN, 0)
+
+saw_position_near_target = False
+homed_while_short_of_target = None
+t0 = time.time()
+while time.time() - t0 < 10.0:
+ poll()
+ if s.position[0] > 1.9:
+ saw_position_near_target = True
+ if s.homed[0] and not saw_position_near_target:
+ homed_while_short_of_target = s.position[0]
+ break
+ if s.exec_state == linuxcnc.EXEC_DONE and s.interp_state == linuxcnc.INTERP_IDLE:
+ break
+ time.sleep(0.001)
+
+if homed_while_short_of_target is not None:
+ fail("joint 0 reported homed while X was still at {} (target 2.0) -- "
+ "the queued move was reordered to run after G28.2 P0".format(homed_while_short_of_target))
+
+if not saw_position_near_target:
+ fail("X never reached its target -- move did not run at all")
+
+t1 = time.time()
+while time.time() - t1 < 5.0:
+ poll()
+ if s.exec_state == linuxcnc.EXEC_DONE and s.interp_state == linuxcnc.INTERP_IDLE:
+ break
+ time.sleep(0.01)
+
+if not s.homed[0]:
+ fail("joint 0 never ended up homed")
+
+print("PASS: the queued move completed before G28.2 P0 homed the joint")
+print("done! it all worked")
+sys.exit(0)
diff --git a/tests/interp/g28.2/flush-order/test.ini b/tests/interp/g28.2/flush-order/test.ini
new file mode 100644
index 00000000000..38b6a739acb
--- /dev/null
+++ b/tests/interp/g28.2/flush-order/test.ini
@@ -0,0 +1,99 @@
+[EMC]
+DEBUG = 0
+VERSION = 1.1
+
+[DISPLAY]
+DISPLAY = ./test-ui.py
+
+[TASK]
+TASK = milltask
+CYCLE_TIME = 0.001
+
+[RS274NGC]
+PARAMETER_FILE = sim.var
+
+[EMCMOT]
+EMCMOT = motmod
+COMM_TIMEOUT = 4.0
+BASE_PERIOD = 0
+SERVO_PERIOD = 1000000
+
+[HAL]
+HALUI = halui
+HALFILE = LIB:core_sim.hal
+
+[TRAJ]
+NO_FORCE_HOMING = 1
+AXES = 3
+COORDINATES = X Y Z
+HOME = 0 0 0
+LINEAR_UNITS = inch
+ANGULAR_UNITS = degree
+DEFAULT_LINEAR_VELOCITY = 1.2
+MAX_LINEAR_VELOCITY = 4
+
+[KINS]
+JOINTS = 3
+KINEMATICS = trivkins
+
+[AXIS_X]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+
+[AXIS_Y]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+
+[AXIS_Z]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -4.0
+MAX_LIMIT = 4.0
+
+[JOINT_0]
+TYPE = LINEAR
+HOME = 0.000
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[JOINT_1]
+TYPE = LINEAR
+HOME = 0.000
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[JOINT_2]
+TYPE = LINEAR
+HOME = 0.0
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -4.0
+MAX_LIMIT = 4.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[EMCIO]
+TOOL_CHANGE_QUILL_UP = 1
+RANDOM_TOOLCHANGER = 0
+TOOL_TABLE = sim.tbl
diff --git a/tests/interp/g28.2/flush-order/test.ngc b/tests/interp/g28.2/flush-order/test.ngc
new file mode 100644
index 00000000000..0682100733b
--- /dev/null
+++ b/tests/interp/g28.2/flush-order/test.ngc
@@ -0,0 +1,6 @@
+G20
+G94
+F60
+G1 X2
+G28.2 P0
+M2
diff --git a/tests/interp/g28.2/flush-order/test.sh b/tests/interp/g28.2/flush-order/test.sh
new file mode 100755
index 00000000000..a16f6fa8522
--- /dev/null
+++ b/tests/interp/g28.2/flush-order/test.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+exec linuxcnc -r test.ini
diff --git a/tests/interp/g28.2/home-all-sequence/checkresult b/tests/interp/g28.2/home-all-sequence/checkresult
new file mode 100755
index 00000000000..24dc9aa53e3
--- /dev/null
+++ b/tests/interp/g28.2/home-all-sequence/checkresult
@@ -0,0 +1,2 @@
+#!/bin/sh
+exit 0 # test failure is indicated by test.sh exit value
diff --git a/tests/interp/g28.2/home-all-sequence/sim.tbl b/tests/interp/g28.2/home-all-sequence/sim.tbl
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/interp/g28.2/home-all-sequence/test-ui.py b/tests/interp/g28.2/home-all-sequence/test-ui.py
new file mode 100755
index 00000000000..b39441e6bb1
--- /dev/null
+++ b/tests/interp/g28.2/home-all-sequence/test-ui.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+
+"""
+G28.2 P-1 (home every joint) on a config that sets HOME_SEQUENCE.
+
+The other tests here only exercise G28.2 Pn on a single joint. P-1 goes
+through a different motion path -- do_home_joint(-1) -> do_home_all() ->
+the HOME_SEQUENCE state machine -- and needs HOME_SEQUENCE set, exactly
+like the GUI Home All button. This config has three single-joint sequence
+groups (0, 1, 2).
+
+Checks:
+ 1. G28.2 P-1 issued from MDI homes every joint and completes without a
+ task error.
+ 2. The task-level mode is not disturbed by the FREE-mode dip.
+ 3. Homing walks the sequence groups in order (joint 0, then 1, then 2) --
+ and the completion poll does not trip on the gap between groups, where
+ all per-joint .homing flags read false while the machine is still
+ homing. A false "did not complete" would abort the MDI command and
+ leave the machine unhomed in FREE.
+ 4. G28.2 P-1 from a program (AUTO) works the same way.
+ 5. A bare G28.2 (no P word) is refused outright, homes nothing, and does
+ not disturb the machine -- homing every joint has to be asked for.
+"""
+
+import linuxcnc
+import hal
+
+import sys
+import time
+
+h = hal.component("python-ui")
+h.ready()
+
+c = linuxcnc.command()
+s = linuxcnc.stat()
+e = linuxcnc.error_channel()
+
+
+def poll():
+ s.poll()
+
+
+def fail(msg):
+ print("FAIL: " + msg)
+ sys.exit(1)
+
+
+def drain_errors():
+ msgs = []
+ while True:
+ err = e.poll()
+ if not err:
+ return msgs
+ msgs.append(err[1])
+
+
+def wait_idle(timeout=15.0):
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ poll()
+ if s.exec_state == linuxcnc.EXEC_DONE and s.interp_state == linuxcnc.INTERP_IDLE:
+ return True
+ time.sleep(0.01)
+ return False
+
+
+def wait_homed(expected, timeout=15.0):
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ poll()
+ if list(s.homed[:3]) == expected:
+ return True
+ time.sleep(0.01)
+ return False
+
+
+c.state(linuxcnc.STATE_ESTOP_RESET)
+c.state(linuxcnc.STATE_ON)
+poll()
+if list(s.homed[:3]) != [0, 0, 0]:
+ fail("machine came up homed: {}".format(list(s.homed[:3])))
+
+c.mode(linuxcnc.MODE_MDI)
+time.sleep(0.2)
+poll()
+mode_before = s.task_mode
+drain_errors()
+
+# --- 1: G28.2 P-1 from MDI homes everything --------------------------------
+c.mdi("G28.2 P-1")
+
+# Watch the homed flags flip on in sequence order while the cycle runs.
+order = []
+t0 = time.time()
+while time.time() - t0 < 15.0:
+ poll()
+ for j in range(3):
+ if s.homed[j] and j not in order:
+ order.append(j)
+ if list(s.homed[:3]) == [1, 1, 1]:
+ break
+ time.sleep(0.005)
+
+if list(s.homed[:3]) != [1, 1, 1]:
+ fail("G28.2 P-1 did not home all joints: homed={}".format(list(s.homed[:3])))
+if order != [0, 1, 2]:
+ fail("joints did not home in HOME_SEQUENCE order: saw {}".format(order))
+
+if not wait_idle():
+ fail("G28.2 P-1 (MDI) did not return to idle/DONE")
+
+errs = drain_errors()
+if any("did not complete" in m or "did not start" in m for m in errs):
+ fail("G28.2 P-1 reported a spurious homing failure: {!r}".format(errs))
+poll()
+if s.exec_state == linuxcnc.EXEC_ERROR:
+ fail("G28.2 P-1 left task in EXEC_ERROR (errors={!r})".format(errs))
+if s.task_mode != mode_before:
+ fail("task_mode changed across G28.2 P-1: {} -> {}".format(mode_before, s.task_mode))
+print("PASS: G28.2 P-1 from MDI homes every joint in sequence order, mode untouched")
+
+# --- 2: and again from a program -----------------------------------------
+c.mode(linuxcnc.MODE_MANUAL)
+c.teleop_enable(0) # to joint mode, so the unhome is allowed
+time.sleep(0.3)
+c.unhome(-1)
+if not wait_homed([0, 0, 0]):
+ fail("could not unhome for the AUTO re-test: {}".format(list(s.homed[:3])))
+
+c.mode(linuxcnc.MODE_AUTO)
+time.sleep(0.2)
+c.program_open("test.ngc")
+time.sleep(0.2)
+drain_errors()
+c.auto(linuxcnc.AUTO_RUN, 0)
+
+if not wait_homed([1, 1, 1]):
+ fail("G28.2 P-1 from a program did not home all joints: {}".format(list(s.homed[:3])))
+if not wait_idle():
+ fail("program with G28.2 P-1 did not finish")
+errs = drain_errors()
+if errs:
+ fail("program with G28.2 P-1 raised errors: {!r}".format(errs))
+print("PASS: G28.2 P-1 from a program homes every joint")
+
+# --- 3: a bare G28.2 homes nothing ----------------------------------------
+# Homing every joint must be asked for with P-1. A G28.2 with no P word is
+# refused by the interpreter, before any homing cycle or FREE-mode dip, so a
+# truncated or mistyped line cannot start the whole machine homing.
+c.mode(linuxcnc.MODE_MANUAL)
+c.teleop_enable(0)
+time.sleep(0.3)
+c.unhome(-1)
+if not wait_homed([0, 0, 0]):
+ fail("could not unhome for the bare-G28.2 check: {}".format(list(s.homed[:3])))
+
+c.mode(linuxcnc.MODE_MDI)
+time.sleep(0.2)
+drain_errors()
+c.mdi("G28.2")
+wait_idle()
+time.sleep(0.5)
+poll()
+msgs = drain_errors()
+if not msgs:
+ fail("bare G28.2 was accepted silently")
+joined = " ".join(msgs)
+if "P word" not in joined:
+ fail("bare G28.2 gave an unexpected error: {!r}".format(msgs[0][:120]))
+if "P-1" not in joined:
+ fail("bare G28.2 error does not name P-1: {!r}".format(msgs[0][:120]))
+if list(s.homed[:3]) != [0, 0, 0]:
+ fail("bare G28.2 homed something: {}".format(list(s.homed[:3])))
+print("PASS: bare G28.2 is refused, names P-1, and homes nothing")
+
+print("done! it all worked")
+sys.exit(0)
diff --git a/tests/interp/g28.2/home-all-sequence/test.ini b/tests/interp/g28.2/home-all-sequence/test.ini
new file mode 100644
index 00000000000..b55ba992d9b
--- /dev/null
+++ b/tests/interp/g28.2/home-all-sequence/test.ini
@@ -0,0 +1,109 @@
+[EMC]
+DEBUG = 0
+VERSION = 1.1
+
+[DISPLAY]
+DISPLAY = ./test-ui.py
+
+[TASK]
+TASK = milltask
+CYCLE_TIME = 0.001
+
+[RS274NGC]
+PARAMETER_FILE = sim.var
+
+[EMCMOT]
+EMCMOT = motmod
+COMM_TIMEOUT = 4.0
+BASE_PERIOD = 0
+SERVO_PERIOD = 1000000
+
+[HAL]
+HALUI = halui
+HALFILE = LIB:core_sim.hal
+
+[TRAJ]
+AXES = 3
+COORDINATES = X Y Z
+HOME = 0 0 0
+# so the test can issue the G28.2 P-1 as a genuine first-home from MDI /
+# a program on an unreferenced machine (the unattended-power-up case),
+# not only as a re-home after the GUI has already homed it.
+NO_FORCE_HOMING = 1
+LINEAR_UNITS = inch
+ANGULAR_UNITS = degree
+DEFAULT_LINEAR_VELOCITY = 1.2
+MAX_LINEAR_VELOCITY = 4
+
+[KINS]
+JOINTS = 3
+KINEMATICS = trivkins
+
+[AXIS_X]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+
+[AXIS_Y]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+
+[AXIS_Z]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -4.0
+MAX_LIMIT = 4.0
+
+# Three distinct HOME_SEQUENCE groups: G28.2 P-1 has to walk all of
+# them, and the completion test has to stay "still homing" across the two
+# gaps between groups (where every joint's per-joint .homing flag is
+# momentarily false -- see the homing_active term in emctaskmain.cc).
+[JOINT_0]
+TYPE = LINEAR
+HOME = 0.000
+HOME_SEQUENCE = 0
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[JOINT_1]
+TYPE = LINEAR
+HOME = 0.000
+HOME_SEQUENCE = 1
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[JOINT_2]
+TYPE = LINEAR
+HOME = 0.0
+HOME_SEQUENCE = 2
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -4.0
+MAX_LIMIT = 4.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[EMCIO]
+TOOL_CHANGE_QUILL_UP = 1
+RANDOM_TOOLCHANGER = 0
+TOOL_TABLE = sim.tbl
diff --git a/tests/interp/g28.2/home-all-sequence/test.ngc b/tests/interp/g28.2/home-all-sequence/test.ngc
new file mode 100644
index 00000000000..be9b2ce1067
--- /dev/null
+++ b/tests/interp/g28.2/home-all-sequence/test.ngc
@@ -0,0 +1,3 @@
+(G28.2 P-1 from a program -- home every joint, in HOME_SEQUENCE order)
+G28.2 P-1
+M2
diff --git a/tests/interp/g28.2/home-all-sequence/test.sh b/tests/interp/g28.2/home-all-sequence/test.sh
new file mode 100755
index 00000000000..a16f6fa8522
--- /dev/null
+++ b/tests/interp/g28.2/home-all-sequence/test.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+exec linuxcnc -r test.ini
diff --git a/tests/interp/g28.2/immediate-mode-guard/checkresult b/tests/interp/g28.2/immediate-mode-guard/checkresult
new file mode 100755
index 00000000000..24dc9aa53e3
--- /dev/null
+++ b/tests/interp/g28.2/immediate-mode-guard/checkresult
@@ -0,0 +1,2 @@
+#!/bin/sh
+exit 0 # test failure is indicated by test.sh exit value
diff --git a/tests/interp/g28.2/immediate-mode-guard/sim.tbl b/tests/interp/g28.2/immediate-mode-guard/sim.tbl
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/interp/g28.2/immediate-mode-guard/test-ui.py b/tests/interp/g28.2/immediate-mode-guard/test-ui.py
new file mode 100755
index 00000000000..6a45e0b7c86
--- /dev/null
+++ b/tests/interp/g28.2/immediate-mode-guard/test-ui.py
@@ -0,0 +1,154 @@
+#!/usr/bin/env python3
+
+"""
+Regression test for the trajectory-mode guards on *immediate* home / unhome
+commands -- the ones the GUI Home/Unhome buttons, halui and linuxcncrsh send.
+Immediate commands reach emcTaskIssueCommand() but are never followed by
+emcTaskCheckPostconditions(), so the G28.2 queued-home sequencing
+(EMC_TASK_EXEC::WAITING_FOR_HOMING, which dips motion into FREE and restores
+the prior mode) never runs for them. They must therefore behave exactly as
+they did before PR #4172.
+
+Two things are checked, neither visible from outside without provoking it
+(motion does not change the trajectory mode by itself for a single-joint
+home or unhome, so any mode change seen here came from task):
+
+ 1. An immediate *unhome* from teleop is refused by motion ("must be in
+ joint mode or disabled to unhome"). Task must not dip into FREE ahead
+ of it and let it through, and must not touch the mode.
+
+ 2. An immediate *home* from teleop is refused with "must be in joint mode
+ to home", and the mode is left alone. do_homing() only advances while
+ motion is in FREE (control.c), and manual mode on an all-homed machine
+ is TELEOP, so accepting the command here would set the homing state
+ machine up and then never run it -- a silently-dropped home (found in
+ PR #4172 review of command.c). Before the fix the relaxed
+ EMCMOT_JOINT_HOME guard ("... or idle") accepted it.
+
+Config is trivkins so the machine can sit in TELEOP without being fully
+homed; the mode guard under test is kinematics-independent.
+"""
+
+import linuxcnc
+import hal
+
+import sys
+import time
+
+h = hal.component("python-ui")
+h.ready()
+
+c = linuxcnc.command()
+s = linuxcnc.stat()
+e = linuxcnc.error_channel()
+
+
+def poll():
+ s.poll()
+
+
+def fail(msg):
+ print("FAIL: " + msg)
+ sys.exit(1)
+
+
+def wait_homed(expected, timeout=10.0):
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ poll()
+ if list(s.homed[:3]) == expected:
+ return True
+ time.sleep(0.01)
+ return False
+
+
+def drain_errors():
+ msgs = []
+ while True:
+ err = e.poll()
+ if not err:
+ return msgs
+ msgs.append(err[1])
+
+
+c.state(linuxcnc.STATE_ESTOP_RESET)
+c.state(linuxcnc.STATE_ON)
+c.mode(linuxcnc.MODE_MANUAL)
+c.home(0)
+c.home(1)
+c.home(2)
+if not wait_homed([1, 1, 1]):
+ fail("initial home-all did not home all joints: {}".format(list(s.homed[:3])))
+
+# Get into a coordinated (teleop) mode -- what a stray FREE dip would destroy.
+c.teleop_enable(1)
+time.sleep(0.5)
+poll()
+mode_before = s.motion_mode
+if mode_before != linuxcnc.TRAJ_MODE_TELEOP:
+ fail("setup did not reach teleop mode (motion_mode={})".format(mode_before))
+drain_errors()
+
+# 1. Immediate unhome from teleop. Motion refuses this by design; task must
+# not dip into FREE first and thereby let it through.
+c.unhome(0)
+time.sleep(1.0)
+poll()
+
+if list(s.homed[:3]) != [1, 1, 1]:
+ fail(
+ "immediate unhome from teleop went through (homed={}) -- task dipped "
+ "into FREE before issuing it, bypassing motion's \"must be in joint "
+ "mode or disabled to unhome\" guard".format(list(s.homed[:3]))
+ )
+if s.motion_mode != mode_before:
+ fail(
+ "immediate unhome changed the trajectory mode {} -> {} and never "
+ "restored it (1=FREE 2=COORD 3=TELEOP)".format(mode_before, s.motion_mode)
+ )
+if not drain_errors():
+ fail("immediate unhome from teleop was silently ignored -- expected motion's refusal")
+print("PASS: an immediate unhome from teleop is refused, mode untouched")
+
+# 2. Immediate home from teleop. Must be refused with the joint-mode error,
+# mode untouched. Before the command.c fix this was silently accepted and
+# then never ran (do_homing() only advances in FREE).
+drain_errors()
+c.home(0)
+time.sleep(1.0)
+poll()
+errs = drain_errors()
+if not any("joint mode" in m for m in errs):
+ fail(
+ "immediate home from teleop was not refused (errors={!r}). do_homing() "
+ "only runs in FREE, so an accepted command here is a silently-dropped "
+ "home".format(errs)
+ )
+if s.motion_mode != mode_before:
+ fail(
+ "immediate home changed the trajectory mode {} -> {}".format(
+ mode_before, s.motion_mode
+ )
+ )
+homing_flags = [s.joint[j]["homing"] for j in range(3)]
+if any(homing_flags):
+ fail(
+ "immediate home from teleop left a joint in a homing state "
+ "(homing={}) -- it was accepted but cannot run".format(homing_flags)
+ )
+print("PASS: an immediate home from teleop is refused, mode untouched")
+
+# A legitimate home from joint mode must still work afterwards -- i.e. the
+# refused command left nothing stuck (e.g. get_homing_is_active() latched).
+c.teleop_enable(0)
+time.sleep(0.3)
+c.unhome(0)
+if not wait_homed([0, 1, 1]):
+ fail("could not unhome joint 0 from joint mode after the refused home")
+c.home(0)
+if not wait_homed([1, 1, 1]):
+ fail("a legitimate home from joint mode did not run after the refused home")
+print("PASS: homing still works normally from joint mode")
+
+print("done! it all worked")
+sys.exit(0)
diff --git a/tests/interp/g28.2/immediate-mode-guard/test.ini b/tests/interp/g28.2/immediate-mode-guard/test.ini
new file mode 100644
index 00000000000..eb1ecd04ca9
--- /dev/null
+++ b/tests/interp/g28.2/immediate-mode-guard/test.ini
@@ -0,0 +1,98 @@
+[EMC]
+DEBUG = 0
+VERSION = 1.1
+
+[DISPLAY]
+DISPLAY = ./test-ui.py
+
+[TASK]
+TASK = milltask
+CYCLE_TIME = 0.001
+
+[RS274NGC]
+PARAMETER_FILE = sim.var
+
+[EMCMOT]
+EMCMOT = motmod
+COMM_TIMEOUT = 4.0
+BASE_PERIOD = 0
+SERVO_PERIOD = 1000000
+
+[HAL]
+HALUI = halui
+HALFILE = LIB:core_sim.hal
+
+[TRAJ]
+AXES = 3
+COORDINATES = X Y Z
+HOME = 0 0 0
+LINEAR_UNITS = inch
+ANGULAR_UNITS = degree
+DEFAULT_LINEAR_VELOCITY = 1.2
+MAX_LINEAR_VELOCITY = 4
+
+[KINS]
+JOINTS = 3
+KINEMATICS = trivkins
+
+[AXIS_X]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+
+[AXIS_Y]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+
+[AXIS_Z]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -4.0
+MAX_LIMIT = 4.0
+
+[JOINT_0]
+TYPE = LINEAR
+HOME = 0.000
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[JOINT_1]
+TYPE = LINEAR
+HOME = 0.000
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[JOINT_2]
+TYPE = LINEAR
+HOME = 0.0
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -4.0
+MAX_LIMIT = 4.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[EMCIO]
+TOOL_CHANGE_QUILL_UP = 1
+RANDOM_TOOLCHANGER = 0
+TOOL_TABLE = sim.tbl
diff --git a/tests/interp/g28.2/immediate-mode-guard/test.sh b/tests/interp/g28.2/immediate-mode-guard/test.sh
new file mode 100755
index 00000000000..a16f6fa8522
--- /dev/null
+++ b/tests/interp/g28.2/immediate-mode-guard/test.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+exec linuxcnc -r test.ini
diff --git a/tests/interp/g28.2/invalid-pword/checkresult b/tests/interp/g28.2/invalid-pword/checkresult
new file mode 100755
index 00000000000..24dc9aa53e3
--- /dev/null
+++ b/tests/interp/g28.2/invalid-pword/checkresult
@@ -0,0 +1,2 @@
+#!/bin/sh
+exit 0 # test failure is indicated by test.sh exit value
diff --git a/tests/interp/g28.2/invalid-pword/sim.tbl b/tests/interp/g28.2/invalid-pword/sim.tbl
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/interp/g28.2/invalid-pword/test-ui.py b/tests/interp/g28.2/invalid-pword/test-ui.py
new file mode 100755
index 00000000000..4237a215b8c
--- /dev/null
+++ b/tests/interp/g28.2/invalid-pword/test-ui.py
@@ -0,0 +1,243 @@
+#!/usr/bin/env python3
+
+"""
+Regression test: a home or unhome for a joint number the machine does not have
+must be rejected immediately, with an error that names the bad joint, and must
+not disturb the trajectory mode.
+
+Reported on real hardware (Mesa 7I95T gantry) in PR #4172: "g28.2 p5" on a
+5-joint machine gave the generic
+
+ G28.2 home did not start -- check machine mode, motion.homing-inhibit, ...
+
+after a two-second stall, and left the GUI jogging in joint mode until the next
+G-code command happened to put it back.
+
+Two separate defects: emcJointHome()/emcJointUnhome() range-checked against
+EMCMOT_MAX_JOINTS (the compile-time maximum, 16) instead of the machine's
+configured joint count, so the command went to motion, which silently ignored
+it; and the FREE-mode dip taken for homing sequencing then sat there for the
+whole start timeout.
+
+This config has JOINTS = 3, so joint 3 and up are unconfigured. The unhome
+half is checked through the immediate NML path (what the GUI's Unhome button
+sends) rather than through G-code: G28.3 was dropped from this PR, so the
+interpreter can no longer issue an unhome at all.
+
+It also covers the rest of the P word's surface, which is the whole of what
+G28.2 can be asked to do: P-1 homes every joint, a plain joint number homes
+one, and everything else -- no P word, P-2, another negative, a fraction --
+is refused up front, homes nothing and leaves the trajectory mode alone.
+"""
+
+import linuxcnc
+import hal
+
+import os
+import sys
+import time
+
+h = hal.component("python-ui")
+h.ready()
+
+c = linuxcnc.command()
+s = linuxcnc.stat()
+e = linuxcnc.error_channel()
+
+
+def poll():
+ s.poll()
+
+
+def fail(msg):
+ print("FAIL: " + msg)
+ sys.exit(1)
+
+
+def near(a, b, tol=0.001):
+ return abs(a - b) < tol
+
+
+def wait_idle(timeout=10.0):
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ poll()
+ if s.interp_state == linuxcnc.INTERP_IDLE:
+ return True
+ time.sleep(0.01)
+ return False
+
+
+def wait_homed(expected, timeout=10.0):
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ poll()
+ if list(s.homed[:3]) == expected:
+ return True
+ time.sleep(0.01)
+ return False
+
+
+def drain_errors():
+ msgs = []
+ while True:
+ err = e.poll()
+ if not err:
+ return msgs
+ msgs.append(err[1])
+
+
+def mode_name(m):
+ return {linuxcnc.TRAJ_MODE_FREE: "FREE",
+ linuxcnc.TRAJ_MODE_COORD: "COORD",
+ linuxcnc.TRAJ_MODE_TELEOP: "TELEOP"}.get(m, str(m))
+
+
+c.state(linuxcnc.STATE_ESTOP_RESET)
+c.state(linuxcnc.STATE_ON)
+c.home(0)
+c.home(1)
+c.home(2)
+if not wait_homed([1, 1, 1]):
+ fail("initial home-all did not home all joints: {}".format(list(s.homed[:3])))
+
+# Establish a coordinated mode worth preserving.
+c.mode(linuxcnc.MODE_MDI)
+c.mdi("G0 X1")
+if not wait_idle():
+ fail("setup MDI move did not settle")
+poll()
+if not near(s.position[0], 1.0):
+ fail("setup MDI move did not run (X at {})".format(s.position[0]))
+drain_errors()
+poll()
+prior_mode = s.motion_mode
+print("setup: coordinated motion works, motion_mode={}".format(mode_name(prior_mode)))
+
+for cmd in ("G28.2 P3",):
+ drain_errors()
+ t0 = time.time()
+ c.mdi(cmd)
+ if not wait_idle():
+ fail("{} never returned to idle".format(cmd))
+ elapsed = time.time() - t0
+ time.sleep(0.3)
+ poll()
+
+ msgs = drain_errors()
+ if not msgs:
+ fail("{} on an unconfigured joint reported no error at all".format(cmd))
+ joined = " ".join(msgs)
+ if "did not start" in joined:
+ fail("{} stalled into the generic start-timeout error instead of being "
+ "rejected up front: {!r}".format(cmd, msgs[0][:120]))
+ if "joint" not in joined.lower():
+ fail("{} error does not mention the joint number: {!r}".format(cmd, msgs[0][:120]))
+ print("PASS: {} reported {!r}".format(cmd, msgs[0][:90]))
+
+ # The message must only advertise values the interpreter accepts. P-1
+ # (home every joint) is real G-code surface, so pointing at it is the
+ # help the operator wanted. -2 is the volatile-unhome NML sentinel, which
+ # no caller of this path can use and which G28.2 refuses, so naming it
+ # would send the operator to a second error (PR #4172, Sigma1912).
+ if "P-1" not in joined:
+ fail("{} error does not point at P-1 as the way to home every joint: "
+ "{!r}".format(cmd, msgs[0][:120]))
+ if "-2" in joined:
+ fail("{} error offers the volatile-unhome sentinel, which a P word "
+ "cannot express: {!r}".format(cmd, msgs[0][:120]))
+ print("PASS: {} error points at P-1 and offers no unusable sentinel".format(cmd))
+
+ if elapsed > 1.5:
+ fail("{} took {:.1f}s to be rejected -- it went to motion and sat out "
+ "the homing start timeout".format(cmd, elapsed))
+ print("PASS: {} was rejected in {:.2f}s, no start-timeout stall".format(cmd, elapsed))
+
+ if list(s.homed[:3]) != [1, 1, 1]:
+ fail("{} changed the homed state of a real joint: {}".format(cmd, list(s.homed[:3])))
+
+ if s.motion_mode != prior_mode:
+ fail("{} left the machine in {} (was {}) -- the FREE-mode dip taken for "
+ "homing sequencing was not undone, so the GUI is stuck jogging in "
+ "joint mode".format(cmd, mode_name(s.motion_mode), mode_name(prior_mode)))
+ print("PASS: {} left the trajectory mode untouched ({})".format(cmd, mode_name(s.motion_mode)))
+
+# P-1 (home every joint) is the one negative the P word accepts; it is
+# exercised where it belongs, on the HOME_SEQUENCE config in
+# ../home-all-sequence -- this config deliberately has no HOME_SEQUENCE, so a
+# home-all could not run here anyway. Everything else stays refused, and the
+# refusal must name P-1 so the operator is pointed at the spelling that
+# works. -2 is the volatile-unhome
+# sentinel EMC_JOINT_HOME carries for the GUI/halui/linuxcncrsh; it is not
+# G-code surface and must not become it by way of the P word.
+for bad, why in (("G28.2 P-2", "the volatile-unhome sentinel"),
+ ("G28.2 P-3", "a negative that is not -1"),
+ ("G28.2 P1.5", "a fractional joint number"),
+ ("G28.2", "no P word at all")):
+ drain_errors()
+ c.mdi(bad)
+ wait_idle()
+ time.sleep(0.3)
+ poll()
+ msgs = drain_errors()
+ joined = " ".join(msgs)
+ if not msgs:
+ fail("{!r} ({}) was accepted silently".format(bad, why))
+ if "P-1" not in joined:
+ fail("{!r} error does not name P-1: {!r}".format(bad, msgs[0][:120]))
+ if list(s.homed[:3]) != [1, 1, 1]:
+ fail("{!r} changed the homed state: {}".format(bad, list(s.homed[:3])))
+ if s.motion_mode != prior_mode:
+ fail("{!r} left the machine in {} (was {}) -- the refusal took the "
+ "FREE-mode dip".format(bad, mode_name(s.motion_mode),
+ mode_name(prior_mode)))
+ print("PASS: {!r} ({}) refused, names P-1, nothing homed or moved".format(bad, why))
+
+# The same bound applies to an unhome, which since G28.3 was dropped can only
+# arrive as an immediate command -- the GUI's Unhome button, halui,
+# linuxcncrsh, or c.unhome() here. Motion has its own check on this path, but
+# as "jno > all_joints", so joint 3 on a 3-joint machine slips past it into an
+# unrelated complaint about extra joints.
+drain_errors()
+c.mode(linuxcnc.MODE_MANUAL)
+time.sleep(0.2)
+c.unhome(3)
+time.sleep(0.5)
+poll()
+msgs = drain_errors()
+if not msgs:
+ fail("an immediate unhome of unconfigured joint 3 reported no error at all")
+if "extrajoint" in " ".join(msgs):
+ fail("unhome of joint 3 fell through the off-by-one into the extra-joint "
+ "branch: {!r}".format(msgs[0][:120]))
+if list(s.homed[:3]) != [1, 1, 1]:
+ fail("an immediate unhome of unconfigured joint 3 disturbed a real joint: {}".format(list(s.homed[:3])))
+print("PASS: immediate unhome of an unconfigured joint reported {!r}".format(msgs[0][:90]))
+
+c.mode(linuxcnc.MODE_MDI)
+time.sleep(0.2)
+
+# The rejection must not have cost anything: ordinary work continues.
+c.mode(linuxcnc.MODE_MDI)
+c.mdi("G0 X2")
+if not wait_idle():
+ fail("MDI move after the rejected G28.2 did not settle")
+poll()
+if not near(s.position[0], 2.0):
+ fail("coordinated motion did not survive the rejected Pn (X stayed at {})".format(s.position[0]))
+print("PASS: coordinated motion still works after the rejection")
+
+# A valid Pn on the same machine must still work, so the check is not just
+# refusing everything.
+drain_errors()
+c.mdi("G28.2 P1")
+if not wait_idle():
+ fail("a valid G28.2 P1 did not settle")
+time.sleep(0.3)
+poll()
+if list(s.homed[:3]) != [1, 1, 1]:
+ fail("valid G28.2 P1 left joint 1 unhomed: {}".format(list(s.homed[:3])))
+print("PASS: a valid G28.2 P1 still homes on the same machine")
+
+print("done! it all worked")
+sys.exit(0)
diff --git a/tests/interp/g28.2/invalid-pword/test.ini b/tests/interp/g28.2/invalid-pword/test.ini
new file mode 100644
index 00000000000..c0b3db5b31a
--- /dev/null
+++ b/tests/interp/g28.2/invalid-pword/test.ini
@@ -0,0 +1,99 @@
+[EMC]
+DEBUG = 0
+VERSION = 1.1
+
+[DISPLAY]
+DISPLAY = ./test-ui.py
+
+[TASK]
+TASK = milltask
+CYCLE_TIME = 0.001
+
+[RS274NGC]
+PARAMETER_FILE = sim.var
+
+[EMCMOT]
+EMCMOT = motmod
+COMM_TIMEOUT = 4.0
+BASE_PERIOD = 0
+SERVO_PERIOD = 1000000
+
+[HAL]
+HALUI = halui
+HALFILE = LIB:core_sim.hal
+
+[TRAJ]
+AXES = 3
+COORDINATES = X Y Z
+HOME = 0 0 0
+LINEAR_UNITS = inch
+ANGULAR_UNITS = degree
+DEFAULT_LINEAR_VELOCITY = 1.2
+NO_FORCE_HOMING = 1
+MAX_LINEAR_VELOCITY = 4
+
+[KINS]
+JOINTS = 3
+KINEMATICS = corexykins
+
+[AXIS_X]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+
+[AXIS_Y]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+
+[AXIS_Z]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -4.0
+MAX_LIMIT = 4.0
+
+[JOINT_0]
+TYPE = LINEAR
+HOME = 0.000
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[JOINT_1]
+TYPE = LINEAR
+HOME = 0.000
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[JOINT_2]
+TYPE = LINEAR
+HOME = 0.0
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -4.0
+MAX_LIMIT = 4.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[EMCIO]
+TOOL_CHANGE_QUILL_UP = 1
+RANDOM_TOOLCHANGER = 0
+TOOL_TABLE = sim.tbl
diff --git a/tests/interp/g28.2/invalid-pword/test.sh b/tests/interp/g28.2/invalid-pword/test.sh
new file mode 100755
index 00000000000..a16f6fa8522
--- /dev/null
+++ b/tests/interp/g28.2/invalid-pword/test.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+exec linuxcnc -r test.ini
diff --git a/tests/interp/g28.2/joint-pword/expected b/tests/interp/g28.2/joint-pword/expected
new file mode 100644
index 00000000000..4f2ebb97604
--- /dev/null
+++ b/tests/interp/g28.2/joint-pword/expected
@@ -0,0 +1,17 @@
+ N..... USE_LENGTH_UNITS(CANON_UNITS_MM)
+ N..... SET_G5X_OFFSET(1, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000)
+ N..... SET_G92_OFFSET(0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000)
+ N..... SET_XY_ROTATION(0.0000)
+ N..... SET_FEED_REFERENCE(CANON_XYZ)
+ N..... ON_RESET()
+ N..... HOME_CYCLE_JOINT(1)
+ N..... HOME_CYCLE()
+ N..... SET_G5X_OFFSET(1, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000)
+ N..... SET_XY_ROTATION(0.0000)
+ N..... SET_FEED_MODE(0, 0)
+ N..... SET_FEED_RATE(0.0000)
+ N..... STOP_SPINDLE_TURNING(0)
+ N..... SET_SPINDLE_MODE(0 0.0000)
+ N..... PROGRAM_END()
+ N..... ON_RESET()
+ N..... ON_RESET()
diff --git a/tests/interp/g28.2/joint-pword/test.ngc b/tests/interp/g28.2/joint-pword/test.ngc
new file mode 100644
index 00000000000..3818cb894d2
--- /dev/null
+++ b/tests/interp/g28.2/joint-pword/test.ngc
@@ -0,0 +1,6 @@
+; G28.2 Pn: home a single joint by its 0-based joint number (matching
+; [JOINT_n] INI section numbering), instead of every joint.
+; G28.2 P-1 is the explicit home-every-joint form; there is no bare G28.2.
+g28.2 p1 ; home joint 1 only
+g28.2 p-1 ; home all joints
+m2
diff --git a/tests/interp/g28.2/joint-pword/test.sh b/tests/interp/g28.2/joint-pword/test.sh
new file mode 100755
index 00000000000..dc8e7d0164e
--- /dev/null
+++ b/tests/interp/g28.2/joint-pword/test.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+# G28.2 Pn homes a single joint, G28.2 P-1 homes them all (no INI flag needed).
+rs274 -g test.ngc | awk '{$1=""; print}' | sed 's/-0\.0000/0.0000/g'
+exit "${PIPESTATUS[0]}"
diff --git a/tests/interp/g28.2/position-model/checkresult b/tests/interp/g28.2/position-model/checkresult
new file mode 100755
index 00000000000..24dc9aa53e3
--- /dev/null
+++ b/tests/interp/g28.2/position-model/checkresult
@@ -0,0 +1,2 @@
+#!/bin/sh
+exit 0 # test failure is indicated by test.sh exit value
diff --git a/tests/interp/g28.2/position-model/sim.tbl b/tests/interp/g28.2/position-model/sim.tbl
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/interp/g28.2/position-model/sim_extra.hal b/tests/interp/g28.2/position-model/sim_extra.hal
new file mode 100644
index 00000000000..be4334b20c6
--- /dev/null
+++ b/tests/interp/g28.2/position-model/sim_extra.hal
@@ -0,0 +1,2 @@
+# core_sim.hal only loops joints 0..2 back; this test adds a C (joint 3).
+net Cpos joint.3.motor-pos-cmd => joint.3.motor-pos-fb
diff --git a/tests/interp/g28.2/position-model/test-ui.py b/tests/interp/g28.2/position-model/test-ui.py
new file mode 100755
index 00000000000..dd1e78295e0
--- /dev/null
+++ b/tests/interp/g28.2/position-model/test-ui.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+
+"""
+Regression test: a G28.2 homing cycle resyncs the interpreter's model of
+the current position, so a following G91 (incremental) move or an I/J/K
+arc centre is computed from where the machine actually is after homing,
+not from the stale pre-home point.
+
+The motivating case (raised in PR #4172 review): a rotary head or a
+spindle re-tasked as a C axis is re-homed mid-program with G28.2 Pn.
+An immediate home rewrites that joint's coordinate to HOME_OFFSET with no
+physical motion (homing.c HOME_SET_INDEX_POSITION), so if the interpreter
+keeps its pre-home value a subsequent `G91 C90` targets `stale_C + 90` and
+the axis sweeps the difference -- on a wrapped rotary there is no soft
+limit to stop it.
+
+Checked here on joint 3 (C, angular) and joint 0 (X, linear): move the
+axis away from zero, re-home it (coordinate jumps back to 0, no motion),
+then make an incremental move and confirm it lands at 0 + increment.
+"""
+
+import linuxcnc
+import hal
+
+import sys
+import time
+
+h = hal.component("python-ui")
+h.ready()
+
+c = linuxcnc.command()
+s = linuxcnc.stat()
+
+
+def poll():
+ s.poll()
+
+
+def wait_idle(timeout=10.0):
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ poll()
+ if s.exec_state == linuxcnc.EXEC_DONE and s.interp_state == linuxcnc.INTERP_IDLE:
+ return True
+ time.sleep(0.01)
+ return False
+
+
+def wait_homed(expected, timeout=10.0):
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ poll()
+ if list(s.homed[:len(expected)]) == expected:
+ return True
+ time.sleep(0.01)
+ return False
+
+
+def fail(msg):
+ print("FAIL: " + msg)
+ sys.exit(1)
+
+
+def near(a, b, tol=0.01):
+ return abs(a - b) < tol
+
+
+def mdi(cmd):
+ c.mdi(cmd)
+ if c.wait_complete(10) == -1:
+ fail("MDI %r timed out" % cmd)
+ if not wait_idle():
+ fail("MDI %r did not settle" % cmd)
+ poll()
+
+
+c.state(linuxcnc.STATE_ESTOP_RESET)
+c.state(linuxcnc.STATE_ON)
+for j in range(4):
+ c.home(j)
+if not wait_homed([1, 1, 1, 1]):
+ fail("initial home-all did not home all joints: {}".format(list(s.homed[:4])))
+
+c.mode(linuxcnc.MODE_MDI)
+time.sleep(0.2)
+
+# --- rotary C (joint 3) ---
+mdi("G0 C30")
+if not near(s.position[5], 30.0):
+ fail("C did not reach 30 (got {})".format(s.position[5]))
+
+# Immediate re-home: C's coordinate snaps back to 0 with no physical move.
+mdi("G28.2 P3")
+if not near(s.position[5], 0.0):
+ fail("G28.2 P3 did not put C back to 0 (got {})".format(s.position[5]))
+
+# The interpreter must now know C is 0. Without the resync it still thinks
+# C is 30, so this incremental move targets 120 and the axis sweeps 90 deg
+# too far.
+mdi("G91 G0 C90")
+mdi("G90")
+if near(s.position[5], 120.0):
+ fail("G91 C90 after G28.2 P3 targeted stale_C + 90 = 120 -- "
+ "interpreter position not resynced after homing")
+if not near(s.position[5], 90.0):
+ fail("G91 C90 after G28.2 P3 landed at {}, expected 90".format(s.position[5]))
+print("PASS: G91 rotary move after G28.2 Pn is computed from the homed position")
+
+# --- linear X (joint 0) ---
+mdi("G0 X5")
+if not near(s.position[0], 5.0):
+ fail("X did not reach 5 (got {})".format(s.position[0]))
+
+mdi("G28.2 P0")
+if not near(s.position[0], 0.0):
+ fail("G28.2 P0 did not put X back to 0 (got {})".format(s.position[0]))
+
+mdi("G91 G0 X1")
+mdi("G90")
+if near(s.position[0], 6.0):
+ fail("G91 X1 after G28.2 P0 targeted stale_X + 1 = 6 -- "
+ "interpreter position not resynced after homing")
+if not near(s.position[0], 1.0):
+ fail("G91 X1 after G28.2 P0 landed at {}, expected 1".format(s.position[0]))
+print("PASS: G91 linear move after G28.2 Pn is computed from the homed position")
+
+# --- absolute moves are unaffected either way ---
+mdi("G0 X0 C0")
+if not (near(s.position[0], 0.0) and near(s.position[5], 0.0)):
+ fail("absolute move back to origin failed: X={} C={}".format(s.position[0], s.position[5]))
+print("PASS: absolute moves after G28.2 still land where asked")
+
+print("done! it all worked")
+sys.exit(0)
diff --git a/tests/interp/g28.2/position-model/test.ini b/tests/interp/g28.2/position-model/test.ini
new file mode 100644
index 00000000000..c725f5bf718
--- /dev/null
+++ b/tests/interp/g28.2/position-model/test.ini
@@ -0,0 +1,120 @@
+[EMC]
+DEBUG = 0
+VERSION = 1.1
+
+[DISPLAY]
+DISPLAY = ./test-ui.py
+
+[TASK]
+TASK = milltask
+CYCLE_TIME = 0.001
+
+[RS274NGC]
+PARAMETER_FILE = sim.var
+
+[EMCMOT]
+EMCMOT = motmod
+COMM_TIMEOUT = 4.0
+BASE_PERIOD = 0
+SERVO_PERIOD = 1000000
+
+[HAL]
+HALUI = halui
+HALFILE = LIB:core_sim.hal
+HALFILE = sim_extra.hal
+
+[TRAJ]
+AXES = 4
+COORDINATES = X Y Z C
+HOME = 0 0 0 0
+LINEAR_UNITS = inch
+ANGULAR_UNITS = degree
+DEFAULT_LINEAR_VELOCITY = 1.2
+MAX_LINEAR_VELOCITY = 4
+DEFAULT_ANGULAR_VELOCITY = 45
+MAX_ANGULAR_VELOCITY = 90
+
+[KINS]
+JOINTS = 4
+KINEMATICS = trivkins coordinates=XYZC
+
+[AXIS_X]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+
+[AXIS_Y]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+
+[AXIS_Z]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -4.0
+MAX_LIMIT = 4.0
+
+[AXIS_C]
+MAX_VELOCITY = 90
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -3600.0
+MAX_LIMIT = 3600.0
+
+[JOINT_0]
+TYPE = LINEAR
+HOME = 0.000
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[JOINT_1]
+TYPE = LINEAR
+HOME = 0.000
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[JOINT_2]
+TYPE = LINEAR
+HOME = 0.0
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -4.0
+MAX_LIMIT = 4.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[JOINT_3]
+TYPE = ANGULAR
+HOME = 0.0
+MAX_VELOCITY = 90
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 1000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -3600.0
+MAX_LIMIT = 3600.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[EMCIO]
+TOOL_CHANGE_QUILL_UP = 1
+RANDOM_TOOLCHANGER = 0
+TOOL_TABLE = sim.tbl
diff --git a/tests/interp/g28.2/position-model/test.sh b/tests/interp/g28.2/position-model/test.sh
new file mode 100755
index 00000000000..a16f6fa8522
--- /dev/null
+++ b/tests/interp/g28.2/position-model/test.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+exec linuxcnc -r test.ini
diff --git a/tests/interp/g28.2/sequencing/checkresult b/tests/interp/g28.2/sequencing/checkresult
new file mode 100755
index 00000000000..24dc9aa53e3
--- /dev/null
+++ b/tests/interp/g28.2/sequencing/checkresult
@@ -0,0 +1,2 @@
+#!/bin/sh
+exit 0 # test failure is indicated by test.sh exit value
diff --git a/tests/interp/g28.2/sequencing/sim.tbl b/tests/interp/g28.2/sequencing/sim.tbl
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/interp/g28.2/sequencing/test-ui.py b/tests/interp/g28.2/sequencing/test-ui.py
new file mode 100755
index 00000000000..67c05322a67
--- /dev/null
+++ b/tests/interp/g28.2/sequencing/test-ui.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+
+"""
+Regression test for the G28.2 Pn task-level sequencing behavior:
+
+ 1. G28.2 Pn's FREE-mode dip (needed because do_homing() only advances in
+ free mode) must be invisible at the task level -- task.mode should be
+ unaffected by it.
+ 2. A G28.2 Pn naming a joint that does not exist on the machine must be
+ rejected without leaving task.mode stuck (regression test for a bug
+ where an outright-rejected home left the FREE-mode dip unrestored,
+ which made task.mode read as MANUAL forever).
+"""
+
+import linuxcnc
+import hal
+
+import sys
+import time
+
+h = hal.component("python-ui")
+h.ready()
+
+c = linuxcnc.command()
+s = linuxcnc.stat()
+
+
+def poll():
+ s.poll()
+
+
+def wait_idle(timeout=5.0):
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ poll()
+ if s.exec_state == linuxcnc.EXEC_DONE and s.interp_state == linuxcnc.INTERP_IDLE:
+ return True
+ time.sleep(0.01)
+ return False
+
+
+def wait_homed(expected, timeout=5.0):
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ poll()
+ if list(s.homed[:3]) == expected:
+ return True
+ time.sleep(0.01)
+ return False
+
+
+def fail(msg):
+ print("FAIL: " + msg)
+ sys.exit(1)
+
+
+def near(a, b, tol=0.001):
+ return abs(a - b) < tol
+
+
+c.state(linuxcnc.STATE_ESTOP_RESET)
+c.state(linuxcnc.STATE_ON)
+c.home(0)
+c.home(1)
+c.home(2)
+if not wait_homed([1, 1, 1]):
+ fail("initial home-all did not home all joints: {}".format(list(s.homed[:3])))
+
+c.mode(linuxcnc.MODE_MDI)
+time.sleep(0.2)
+poll()
+mode_before = s.task_mode
+
+# Machine is still fully homed here, so this MDI G28.2 P1 is a redundant
+# re-home -- the NO_FORCE_HOMING gate doesn't apply (all_homed() is true
+# throughout), so this exercises the plain mode-dip-and-restore path.
+c.mdi("G28.2 P1")
+if not wait_idle():
+ fail("redundant G28.2 P1 did not complete")
+poll()
+if s.task_mode != mode_before:
+ fail("task_mode changed across a redundant G28.2 Pn: {} -> {}".format(mode_before, s.task_mode))
+print("PASS: G28.2 Pn's mode dip is invisible at the task level")
+
+# The machine is fully homed, so this reaches the actual Pn validation,
+# which must reject joint 99 without leaving task_mode stuck: an
+# outright-rejected home must still undo the FREE-mode dip (fix(g28):
+# "restore traj mode when G28.2 rejects the request outright").
+c.mdi("G28.2 P99")
+if not wait_idle():
+ fail("invalid-joint G28.2 P99 did not settle")
+poll()
+if s.task_mode != mode_before:
+ fail("task_mode after invalid Pn is {}, expected {} (MDI)".format(s.task_mode, mode_before))
+
+c.mdi("G0 X2")
+if not wait_idle():
+ fail("recovery MDI command after invalid Pn did not settle")
+poll()
+if not near(s.position[0], 2.0):
+ fail("MDI command after an invalid Pn was rejected -- task_mode stuck (X stayed at {})".format(s.position[0]))
+print("PASS: an invalid Pn does not leave task_mode stuck")
+
+print("done! it all worked")
+sys.exit(0)
diff --git a/tests/interp/g28.2/sequencing/test.ini b/tests/interp/g28.2/sequencing/test.ini
new file mode 100644
index 00000000000..eb1ecd04ca9
--- /dev/null
+++ b/tests/interp/g28.2/sequencing/test.ini
@@ -0,0 +1,98 @@
+[EMC]
+DEBUG = 0
+VERSION = 1.1
+
+[DISPLAY]
+DISPLAY = ./test-ui.py
+
+[TASK]
+TASK = milltask
+CYCLE_TIME = 0.001
+
+[RS274NGC]
+PARAMETER_FILE = sim.var
+
+[EMCMOT]
+EMCMOT = motmod
+COMM_TIMEOUT = 4.0
+BASE_PERIOD = 0
+SERVO_PERIOD = 1000000
+
+[HAL]
+HALUI = halui
+HALFILE = LIB:core_sim.hal
+
+[TRAJ]
+AXES = 3
+COORDINATES = X Y Z
+HOME = 0 0 0
+LINEAR_UNITS = inch
+ANGULAR_UNITS = degree
+DEFAULT_LINEAR_VELOCITY = 1.2
+MAX_LINEAR_VELOCITY = 4
+
+[KINS]
+JOINTS = 3
+KINEMATICS = trivkins
+
+[AXIS_X]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+
+[AXIS_Y]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+
+[AXIS_Z]
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+MIN_LIMIT = -4.0
+MAX_LIMIT = 4.0
+
+[JOINT_0]
+TYPE = LINEAR
+HOME = 0.000
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[JOINT_1]
+TYPE = LINEAR
+HOME = 0.000
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -40.0
+MAX_LIMIT = 40.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[JOINT_2]
+TYPE = LINEAR
+HOME = 0.0
+MAX_VELOCITY = 4
+MAX_ACCELERATION = 1000.0
+BACKLASH = 0.000
+INPUT_SCALE = 4000
+OUTPUT_SCALE = 1.000
+MIN_LIMIT = -4.0
+MAX_LIMIT = 4.0
+FERROR = 0.050
+MIN_FERROR = 0.010
+
+[EMCIO]
+TOOL_CHANGE_QUILL_UP = 1
+RANDOM_TOOLCHANGER = 0
+TOOL_TABLE = sim.tbl
diff --git a/tests/interp/g28.2/sequencing/test.sh b/tests/interp/g28.2/sequencing/test.sh
new file mode 100755
index 00000000000..a16f6fa8522
--- /dev/null
+++ b/tests/interp/g28.2/sequencing/test.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+exec linuxcnc -r test.ini