homing.manual and the host harness: the manual home jogs to an offset origin

homing.manual runs two homes. With manual_home_x and _y unset, $H
plays no byte and declares X0 Y0 (the checks it had). With 5 and 3, $H
declares the head at minus them and jogs it to X0 Y0 before it answers:
the sender is told the declared position, the kernel plays the move,
Grbl and forgectrl read X0 Y0, and a jog behind the origin draws
error:15. The test restores homing_mode and both offsets as found, and
returns the head by what the machine read, so a move cut short still
comes home. The change stays inside the test's own function.

manual_home_test.py: case 7 is the offset's new meaning (declared at
minus the offset, the move dark under a modal M3, the port's home, the
clamps); a new case cancels the move with a jog cancel and expects it
to stop short with $H saying where; case 8 reads the declared position
from the sender's message. The bench README says so.

Proof: manual_home_test.py ALL PASS against the driver change (ten
cases), and its cancel case fails against a build with the driver's
cancel hook left out. On the bench reference, fx14 with the driver
change and this suite file in place of the image's, homing.manual PASS
in campaign c-20260926000059-e731: with no offset no byte and X0 Y0;
with 5 and 3 the sender was told X-5.002 Y-3.000, the kernel played
1067 and 640 steps, Grbl and forgectrl read X0 Y0, error:15 behind the
origin; settings restored and the baseline clean. The forgetest unit
tests pass.

Acceptance: homing.manual is the test; its covers are unchanged (the
driver's src/**, forgectrl's status and main.c).
This commit is contained in:
ScottW514
2026-09-26 10:02:10 -04:00
parent b2e0f91342
commit b494bbecb5
3 changed files with 194 additions and 70 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ page's takeover does that; from a host, stop them first.
| `z_envelope_test.py` | Host-side Z envelope harness (null-sink controller): the Z soft limit belongs to the driver, not to `$20`, so the driver re-applies `sys.work_envelope`, `sys.homed` and `sys.soft_limits` for Z from the settings-changed chain. Checks that an unreferenced Z is collapsed to where the lens stands and blocks a move each way, that X and Y stay free, and that neither a `$20` write (the core clears the soft-limit mask in the setter) nor a `$132` write (which un-homes the axis as well) frees Z. Runs in the grblHAL repo's CI. |
| `ctlport_test.py` | Host-side controller port and line-multiplexer harness (null-sink controller): a scripted sender that counts every `ok` and `error` runs beside the port's one client on `grbl.ctl`. A port jog's status goes to the port and never to the sender, a port error does not reach the sender's parser, a sender line cancels a port jog and gets its own `ok` (a line queued right behind the port's included), plus the refusals, the single client, a CR LF sender, a soft reset, and the client as the dead-man. `python3 ctlport_test.py <grblHAL_glowforge>`. |
| `cool_report_test.py` | Host-side cooling report harness (null-sink controller, a stand-in for forgectrl's listener on `FORGECTRL_PORT`): every `POST /cool/state` carries the secret the supervisor hands the controller at its spawn (`GF_REPORT_SECRET`) as `X-ForgeFIRM-Report`; with no secret the header is absent; a value that is not 32 hex digits, a CR LF with a header behind it included, never reaches the wire; and the homing runner the controller starts is handed the secret (it reports in the controller's place during a gfcloud `$H`), and none when the controller has none or a malformed one. `python3 cool_report_test.py <grblHAL_glowforge>`. |
| `manual_home_test.py` | Host-side manual homing and motor release harness (null-sink controller, `GFSINK_DUMP` and `GFSINK_ATTR_LOG`): `$H` under `homing_mode = manual` ships nothing and declares X0 Y0; while the motors are released (`$MD`) every motion source is refused and ships nothing, `$X` and a soft reset do not unlock it, and only `$ME` and a manual `$H` write the energize. Imports its sender and port client from `ctlport_test.py`. `python3 manual_home_test.py <grblHAL_glowforge>`. |
| `manual_home_test.py` | Host-side manual homing and motor release harness (null-sink controller, `GFSINK_DUMP` and `GFSINK_ATTR_LOG`): `$H` under `homing_mode = manual` ships nothing and declares X0 Y0, and with a manual home offset declares minus it and jogs dark to the origin before it answers, a jog cancel ending the move short; while the motors are released (`$MD`) every motion source is refused and ships nothing, `$X` and a soft reset do not unlock it, and only `$ME` and a manual `$H` write the energize. Imports its sender and port client from `ctlport_test.py`. `python3 manual_home_test.py <grblHAL_glowforge>`. |
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the density dose curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m4feeds [S] [F1] [F2]` (the density time base across feeds: one out-and-back line pair per feed at the same S under M4 density, one armed run; the operator reads within-line evenness and reversal darkness at both feeds - M4's velocity scaling is what holds dose per mm through the accel), `m4corner [S] [F]` (M4 velocity-scaled power into corners: a corner-heavy vector pattern at 30 % under M4 density, one armed run; the operator confirms every commanded segment marks - the floor makes a dropout unreachable - and the drill asserts the arm report, one discharge window and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <secs> [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
| `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. |
| `planner_blocks_test.py` | Host-side planner buffer depth harness (null-sink controller): `$398` written to 400 and to 1000, the controller restarted on the same settings store each time, and it must answer on the port, report the depth in its status report and run a move. A byte-wide ring index once made 255 blocks or more spin the start. Runs in the grblHAL repo's CI. |
+109 -25
View File
@@ -25,19 +25,23 @@ operator's own $ME or a manual $H energizes it:
6. the port's panel operations (release, energize, home) do the same with
a sender connected, and the sender's response count stays exact; home
is refused unless homing_mode = manual
7. manual_home_x and manual_home_y: the coordinate the stop blocks stand
for, the origin by default and never negative; the envelope starts at
the blocks, and an out-of-range offset is clamped
7. manual_home_x and manual_home_y: how far in front of the stop blocks
the origin lies, never negative. With an offset the head at the blocks
is declared at minus it and jogs to the origin, dark, before $H
answers, through the sender and through the port; the envelope starts
at the origin, and an out-of-range offset is clamped; a jog cancel
ends the move short of the origin and $H says where it stopped
9. a controller that starts over a released gantry (the marker in the
state directory) comes up locked and writes no current until $ME
8. each provider reads its own offsets and no other's: with both pairs set,
a manual home declares manual_home_* and a camera home declares
a manual home declares minus manual_home_* and a camera home declares
gfcloud_home_*, which may be negative, with the envelope reaching back
to it
Usage: manual_home_test.py [path/to/grblHAL_glowforge]
"""
import os
import re
import shutil
import signal
import subprocess
@@ -103,6 +107,15 @@ class Session:
return 0
return sum(1 for b in data if not b & 0x80 and b & 0x35)
def fire_ticks(self):
"""FIRE ticks shipped so far."""
try:
with open(self.dump, "rb") as f:
data = f.read()
except OSError:
return 0
return sum(1 for b in data if not b & 0x80 and b & 0x10)
def attr_writes(self):
"""The X and Y step-current writes the hardware would have got, in
order, as (x, y) pairs: ("0", "0") is a release, ("33", "5") the hold
@@ -150,6 +163,18 @@ class Session:
shutil.rmtree(self.workdir, ignore_errors=True)
def declared(s):
"""The position the last manual home with an offset told the sender the
head stood at, on the step grid, or None."""
with s.sender.lock:
lines = list(s.sender.other)
for line in reversed(lines):
m = re.search(r"Manual home: the head is at X(-?[0-9.]+) Y(-?[0-9.]+); moving it to the origin", line)
if m:
return float(m.group(1)), float(m.group(2))
return None
def refused(s, line):
"""Send a line that must be refused, and return its error. The core holds
a parser error against every g-code line that follows until an empty line
@@ -368,49 +393,107 @@ def test_restart_while_released(s):
def test_stop_block_offsets(s):
"""manual_home_x and _y are the coordinate the stop blocks stand for: the
origin by default, never negative, and the wall the envelope starts at."""
"""manual_home_x and _y are how far in front of the stop blocks the origin
lies, never negative: the head at the blocks is declared at minus them and
jogs to the origin, dark, before $H answers, and the envelope starts at
the origin."""
s.sender.wait_state("Idle")
s.set_mode("manual", "manual_home_x = 12.5\nmanual_home_y = 8\n")
s.sender.send("M3 S100") # a modal beam the jog must not carry
ticks, fire = s.motion_ticks(), s.fire_ticks()
if s.sender.send("$H") != "ok":
fail("[offsets] $H under manual with offsets was refused")
st = s.port.state()
if abs(st["mpos"][0] - 12.5) > 0.01 or abs(st["mpos"][1] - 8.0) > 0.01:
fail("[offsets] the home declared %r, not (12.5, 8)" % st["mpos"][:2])
st = s.port.state() # $H answers once the head has stopped
s.sender.send("M5")
if st["state"] != "Idle" or st["mpos"][0] != 0 or st["mpos"][1] != 0:
fail("[offsets] the head was not idle at the origin when $H answered: %r" % st)
if st["homed"] & 3 != 3:
fail("[offsets] X and Y are not marked homed: %r" % st)
d = declared(s)
if d is None or abs(d[0] + 12.5) > 0.01 or abs(d[1] + 8.0) > 0.01:
fail("[offsets] the sender was told the head stood at %r, not (-12.5, -8)" % (d,))
if s.motion_ticks() == ticks:
fail("[offsets] the move to the origin shipped nothing")
if s.fire_ticks() != fire:
fail("[offsets] the move to the origin shipped %d FIRE ticks" % (s.fire_ticks() - fire))
if s.sender.send("$J=G91 X5 Y5 F3000") != "ok":
fail("[offsets] a jog from the blocks onto the bed was refused")
fail("[offsets] a jog from the origin onto the bed was refused")
s.sender.wait_state("Idle")
if s.sender.send("$J=G91 X-5 Y-5 F3000") != "ok":
fail("[offsets] the jog back to the blocks was refused")
fail("[offsets] the jog back to the origin was refused")
s.sender.wait_state("Idle")
for line in ("$J=G91 X-1 F600", "$J=G91 Y-1 F600"):
r = refused(s, line)
if r != "error:15":
fail("[offsets] %s, behind the blocks, got %r, not error:15" % (line, r))
fail("[offsets] %s, behind the origin, got %r, not error:15" % (line, r))
s.set_mode("manual", "manual_home_x = -10.3\nmanual_home_y = 1e9\n")
if s.sender.send("$H") != "ok":
if s.sender.send("$H", timeout=30.0) != "ok":
fail("[offsets] $H with out-of-range offsets was refused")
st = s.port.state()
if st["mpos"][0] != 0 or st["mpos"][1] > 1000:
fail("[offsets] a negative or oversized offset was not clamped: %r" % st["mpos"][:2])
if st["mpos"][0] != 0 or st["mpos"][1] != 0:
fail("[offsets] the head did not end at the origin: %r" % st["mpos"][:2])
d = declared(s)
if d is None or d[0] != 0 or abs(d[1] + 279.0) > 0.01:
fail("[offsets] a negative offset and an oversized one declared %r, not (0, -279)" % (d,))
# The panel's Set home here: the port answers once the head has stopped,
# and the sender's count stays exact.
s.set_mode("manual", "manual_home_x = 12.5\nmanual_home_y = 8\n")
s.quiet()
before = s.sender.count()
if s.port.request("home") != "ok":
fail("[offsets] the port's home with offsets was refused")
st = s.port.state()
if st["state"] != "Idle" or st["mpos"][0] != 0 or st["mpos"][1] != 0:
fail("[offsets] the head was not idle at the origin when the port's home answered: %r" % st)
if s.sender.count() != before:
fail("[offsets] the sender got %d responses for the port's home" % (s.sender.count() - before))
s.set_mode("manual")
print("PASS [offsets]: a manual home declared (12.5, 8), the envelope started at the blocks, "
"a negative offset became the origin and an oversized one the axis travel")
print("PASS [offsets]: a manual home declared (-12.5, -8) and jogged to the origin before $H "
"answered, dark under a modal M3; the envelope started at the origin; a negative offset "
"became 0 and an oversized one the axis travel; the port's home answered at the origin")
def test_origin_jog_cancel(s):
"""A jog cancel during a manual home's move to the origin ends the move at
once, as it ends any jog, though the core's main loop is waiting for $H:
$H answers with the head short of the origin, and the sender is told
where it stopped."""
s.sender.wait_state("Idle")
s.set_mode("manual", "manual_home_y = 250\n")
n = s.sender.count()
s.sender.sock.sendall(("$H" + s.sender.eol).encode())
time.sleep(0.4) # into a move of about 1.6 s
s.sender.realtime(b"\x85")
end = time.time() + 5.0
while time.time() < end and s.sender.count() == n:
time.sleep(0.01)
with s.sender.lock:
r = s.sender.responses[n] if len(s.sender.responses) > n else None
st = s.port.state()
s.set_mode("manual")
if r != "ok":
fail("[origin-cancel] $H got %r" % r)
if st["state"] != "Idle" or st["mpos"][1] > -10.0:
fail("[origin-cancel] the jog cancel did not stop the move short of the origin: %r" % st)
if not s.sender.saw("Manual home: the move to the origin stopped at"):
fail("[origin-cancel] the sender was not told where the move stopped")
print("PASS [origin-cancel]: a jog cancel 0.4 s into the move to the origin stopped it at Y%.1f; "
"$H answered ok and said where it stopped" % st["mpos"][1])
def test_offsets_belong_to_their_provider(s):
"""Both pairs set at once: a manual home declares manual_home_* and never
looks at gfcloud_home_*, and a camera home (a stand-in runner that exits 0)
declares gfcloud_home_* and never looks at manual_home_*. A camera home may
lie behind the origin, and the envelope reaches back to it."""
"""Both pairs set at once: a manual home declares minus manual_home_* and
never looks at gfcloud_home_*, and a camera home (a stand-in runner that
exits 0) declares gfcloud_home_* and never looks at manual_home_*. A camera
home may lie behind the origin, and the envelope reaches back to it."""
both = "manual_home_x = 7\nmanual_home_y = 9\ngfcloud_home_x = -4.5\ngfcloud_home_y = 3.25\n"
s.sender.wait_state("Idle")
s.set_mode("manual", both)
if s.sender.send("$H") != "ok":
fail("[own-offsets] the manual home was refused")
st = s.port.state()
if abs(st["mpos"][0] - 7.0) > 0.01 or abs(st["mpos"][1] - 9.0) > 0.01:
fail("[own-offsets] a manual home declared %r, not its own (7, 9)" % st["mpos"][:2])
d = declared(s)
if d is None or abs(d[0] + 7.0) > 0.01 or abs(d[1] + 9.0) > 0.01:
fail("[own-offsets] a manual home declared %r, not its own (-7, -9)" % (d,))
s.set_mode("gfcloud", both)
if s.sender.send("$H", timeout=30.0) != "ok":
fail("[own-offsets] the camera home (stand-in runner) was refused")
@@ -431,7 +514,7 @@ def test_offsets_belong_to_their_provider(s):
if r != "error:15":
fail("[own-offsets] a jog to Y-0.75, off the bed, got %r, not error:15" % r)
s.set_mode("manual")
print("PASS [own-offsets]: with both pairs set, a manual home declared (7, 9) and a camera home "
print("PASS [own-offsets]: with both pairs set, a manual home declared (-7, -9) and a camera home "
"(-4.5, 3.25); the envelope reached back to the camera home on X and stopped at the origin on Y")
@@ -448,6 +531,7 @@ def main():
test_port_panel_ops(s)
test_restart_while_released(s)
test_stop_block_offsets(s)
test_origin_jog_cancel(s)
test_offsets_belong_to_their_provider(s)
finally:
s.close()