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
+84 -44
View File
@@ -1995,39 +1995,38 @@ def motor_release(ctx):
"energized with no fault, and the liveness probe and the witnessed jogs say the drivers are alive") "energized with no fault, and the liveness probe and the witnessed jogs say the drivers are alive")
@test("homing.manual", title="Manual home: $H declares the stop-block position and moves nothing", @test("homing.manual", title="Manual home: $H declares the stop-block position and jogs to an offset origin",
subsystem="motion", kind="auto", mode="grbl", est_min=4, subsystem="motion", kind="auto", mode="grbl", est_min=5,
covers=_MOTION_COVERS + [("forgectrl", "src/status.*"), ("forgectrl", "src/main.c")], covers=_MOTION_COVERS + [("forgectrl", "src/status.*"), ("forgectrl", "src/main.c")],
requires=["motion.jog-roundtrip"], requires=["motion.jog-roundtrip"],
steps=["Bed clear; the head needs 30 mm of free +X travel."], steps=["Bed clear; the head needs 35 mm of free +X travel and 3 mm of free +Y travel."],
description="With homing_mode = manual, $H ships nothing (the kernel's position record shows no " description="With homing_mode = manual and no manual home offset, $H ships nothing (the kernel's "
"byte played across it), sets X and Y to manual_home_x and manual_home_y (the origin " "position record shows no byte played across it), sets X and Y to X0 Y0 where the head "
"when they are unset) where the head stands, marks X and Y homed with their soft " "stands, marks X and Y homed with their soft limits on (a jog past the envelope is "
"limits on (a jog past the envelope is refused with error 15), leaves Z where it " "refused with error 15), leaves Z where it was, tells the sender the home was set by "
"was, tells the sender the home was set by hand, and the anchor's source reads back " "hand, and the anchor's source reads back as manual through forgectrl. With "
"as manual through forgectrl. Whatever a check decides, the false reference is " "manual_home_x = 5 and manual_home_y = 3, $H declares the head at X-5 Y-3, says so, and "
"dropped and the head returned before the machine is handed back.") "jogs it to X0 Y0 before it answers: the kernel plays the move, Grbl and forgectrl both "
"read X0 Y0, and the envelope starts at the origin. Whatever a check decides, the "
"settings are restored, the false reference is dropped and the head returned before the "
"machine is handed back.")
def manual_home(ctx): def manual_home(ctx):
ev = ctx.evidence ev = ctx.evidence
fc = hw.Forgectrl() fc = hw.Forgectrl()
settings = fc.settings() or {} settings = fc.settings() or {}
ev["homing_mode"] = settings.get("homing_mode") keys = ("homing_mode", "manual_home_x", "manual_home_y")
want = [] ev["found"] = {k: settings.get(k) for k in keys}
for key in ("manual_home_x", "manual_home_y"): offset = (5.0, 3.0)
try: moved = None # how far the head is from where the run found it
want.append(float(settings.get(key) or 0.0)) for k, v in (("homing_mode", "manual"), ("manual_home_x", ""), ("manual_home_y", "")):
except ValueError: st, body = (fc.post("/settings", data={k: v}) if v else fc.post("/settings", params={k: ""}))
want.append(0.0) ctx.check(st == 200, "%s=%r -> %s %s", k, v, st, body)
ev["manual_home"] = want
moved_out = False
st, body = fc.post("/settings", data={"homing_mode": "manual"})
ctx.check(st == 200, "homing_mode=manual -> %s %s", st, body)
try: try:
with ctx.grbl() as g: with ctx.grbl() as g:
clean_slate(ctx, g) clean_slate(ctx, g)
r = g.command("$J=G91X30F2400") # away from wherever the origin was r = g.command("$J=G91X30F2400") # away from wherever the origin was
ctx.check(not any(x.startswith("error") for x in r), "the outbound jog was refused: %s", r) ctx.check(not any(x.startswith("error") for x in r), "the outbound jog was refused: %s", r)
moved_out = True moved = [30.0, 0.0]
wait_idle(ctx, g) wait_idle(ctx, g)
z0 = g.status_report()["MPos"][2] z0 = g.status_report()["MPos"][2]
ctx.sleep(1.5) # the kernel has played the jog's tail ctx.sleep(1.5) # the kernel has played the jog's tail
@@ -2045,47 +2044,88 @@ def manual_home(ctx):
rep["state"], rep["MPos"], k0, k1, ev["homed_axes"], ev["home_source"], ev["pos"]) rep["state"], rep["MPos"], k0, k1, ev["homed_axes"], ev["home_source"], ev["pos"])
ctx.check(k1[3:] == k0[3:], "$H played pulse bytes: %s -> %s", k0[3:], k1[3:]) ctx.check(k1[3:] == k0[3:], "$H played pulse bytes: %s -> %s", k0[3:], k1[3:])
ctx.check(k1[0] == 0 and k1[1] == 0, "the kernel counters were not cleared: %s", k1) ctx.check(k1[0] == 0 and k1[1] == 0, "the kernel counters were not cleared: %s", k1)
ctx.check(abs(rep["MPos"][0] - want[0]) < 0.01 and abs(rep["MPos"][1] - want[1]) < 0.01, ctx.check(rep["MPos"][0] == 0 and rep["MPos"][1] == 0, "X and Y are %s, not X0 Y0", rep["MPos"][:2])
"X and Y are %s, not manual_home %s", rep["MPos"][:2], want)
ctx.check(rep["MPos"][2] == z0, "Z changed across the home: %s -> %s", z0, rep["MPos"][2]) ctx.check(rep["MPos"][2] == z0, "Z changed across the home: %s -> %s", z0, rep["MPos"][2])
ctx.check(rep["state"].startswith("Idle"), "not idle after the home: %s", rep["state"]) ctx.check(rep["state"].startswith("Idle"), "not idle after the home: %s", rep["state"])
ctx.check(MANUAL_NOTICE in text, "the sender was not told the home was set by hand") ctx.check(MANUAL_NOTICE in text, "the sender was not told the home was set by hand")
ctx.check((ev["homed_axes"] or 0) & 3 == 3, "forgectrl does not show X and Y homed") ctx.check((ev["homed_axes"] or 0) & 3 == 3, "forgectrl does not show X and Y homed")
ctx.check(ev["home_source"] == "manual", "the anchor's source reads %r", ev["home_source"]) ctx.check(ev["home_source"] == "manual", "the anchor's source reads %r", ev["home_source"])
ctx.check(ev["pos"] and abs(ev["pos"]["x"] - want[0]) < 0.02 and abs(ev["pos"]["y"] - want[1]) < 0.02, ctx.check(ev["pos"] and abs(ev["pos"]["x"]) < 0.02 and abs(ev["pos"]["y"]) < 0.02,
"forgectrl's position is not the declared one: %s", ev["pos"]) "forgectrl's position is not X0 Y0: %s", ev["pos"])
# The blocks are a wall: the envelope starts where the head stands. # The envelope starts at the origin.
ev["past_envelope"] = _refused(ctx, g, "$J=G91X-5F600") ev["past_envelope"] = _refused(ctx, g, "$J=G91X-5F600")
ctx.check(ev["past_envelope"] == "error:15", ctx.check(ev["past_envelope"] == "error:15",
"a jog past the envelope after the home got %s, not error:15", ev["past_envelope"]) "a jog past the envelope after the home got %s, not error:15", ev["past_envelope"])
# With an offset: the head at the blocks is minus it, and $H jogs
# it to the origin before it answers.
st, body = fc.post("/settings", data={"manual_home_x": "%g" % offset[0],
"manual_home_y": "%g" % offset[1]})
ctx.check(st == 200, "manual_home_x/_y = %s -> %s %s", offset, st, body)
k2 = _kernel_position()
r = g.command("$H", timeout=15)
refused = any(x.startswith("error") for x in r)
text = "\n".join(r) + drain_text(g, 1.5)
rep = g.status_report()
if not refused: # declared at minus the offset, then moved
moved = [30.0 + rep["MPos"][0] + offset[0], rep["MPos"][1] + offset[1]]
ctx.check(not refused, "$H with an offset refused: %s", r)
k3 = _kernel_position()
st = fc.status()
ev.update(offset_kernel_position=[list(k2), list(k3)], offset_mpos=rep["MPos"],
offset_state=rep["state"], offset_pos=st.get("pos"))
ctx.log("$H with offset %s: %s MPos %s; kernel record %s -> %s; forgectrl pos %s",
offset, rep["state"], rep["MPos"], k2, k3, ev["offset_pos"])
said = [ln for ln in text.splitlines() if "Manual home: the head is at X" in ln]
ev["offset_said"] = said
try:
words = said[-1].split("the head is at ")[1].split(";")[0].split()
declared = [float(words[0][1:]), float(words[1][1:])]
except (IndexError, ValueError):
declared = None
ctx.check(declared is not None and abs(declared[0] + offset[0]) < 0.01
and abs(declared[1] + offset[1]) < 0.01,
"the sender was told the head stood at %s, not minus %s", declared, offset)
ctx.check(k3[3:] != k2[3:], "the move to the origin played no pulse byte: %s -> %s", k2[3:], k3[3:])
ctx.check(k3[0] != 0 and k3[1] != 0, "the kernel counters did not move on X and Y: %s", k3)
ctx.check(rep["MPos"][0] == 0 and rep["MPos"][1] == 0,
"the head is at %s when $H answered, not X0 Y0", rep["MPos"][:2])
ctx.check(rep["state"].startswith("Idle"), "not idle when $H answered: %s", rep["state"])
ctx.check(ev["offset_pos"] and abs(ev["offset_pos"]["x"]) < 0.02 and abs(ev["offset_pos"]["y"]) < 0.02,
"forgectrl's position is not X0 Y0: %s", ev["offset_pos"])
ev["offset_past_envelope"] = _refused(ctx, g, "$J=G91Y-1F600")
ctx.check(ev["offset_past_envelope"] == "error:15",
"a jog behind the origin got %s, not error:15", ev["offset_past_envelope"])
finally: finally:
# Whatever a check above decided, the machine is handed back as it was # Whatever a check above decided, the machine is handed back as it was
# found: the setting restored, the false home dropped, the head returned. # found: the settings restored, the false home dropped, the head returned.
st, body = (fc.post("/settings", params={"homing_mode": ""}) if not ev["homing_mode"] for k in keys:
else fc.post("/settings", data={"homing_mode": ev["homing_mode"]})) v = ev["found"][k]
ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st) st, body = (fc.post("/settings", data={k: v}) if v else fc.post("/settings", params={k: ""}))
if moved_out: ctx.log("restore %s=%r -> %s", k, v, st)
if moved:
_drop_reference(ctx, fc) _drop_reference(ctx, fc)
with ctx.grbl() as g: with ctx.grbl() as g:
clean_slate(ctx, g) clean_slate(ctx, g)
r = g.command("$J=G91X-30F2400") # back to where the test found the head r = g.command("$J=G91X%.3fY%.3fF2400" % (-moved[0], -moved[1]))
ctx.check(not any(x.startswith("error") for x in r), "the return jog was refused: %s", r) ctx.check(not any(x.startswith("error") for x in r), "the return jog was refused: %s", r)
wait_idle(ctx, g) wait_idle(ctx, g)
machine_idle(ctx) machine_idle(ctx)
# The home cleared the kernel's counters 30 mm out, so the return # The home cleared the kernel's counters away from where the run
# leaves them at -30 mm with the head where the run found it. The # began, so the return leaves them off zero with the head where
# hand-back compares counters, and a controller start zeroes them: # the run found it. The hand-back compares counters, and a
# left like this, the next test that restarts the controller reads # controller start zeroes them: left like this, the next test
# as 30 mm out of place and the hand-back "returns" a head that # that restarts the controller reads as out of place and the
# never moved (on the bench reference it drove the head into the # hand-back "returns" a head that never moved (on the bench
# stop blocks). One more start, with the head back, leaves the # reference it drove the head into the stop blocks). One more
# counters at zero where the run began, which is what the rest of # start, with the head back, leaves the counters at zero where the
# the catalog counts on. # run began, which is what the rest of the catalog counts on.
_drop_reference(ctx, fc) _drop_reference(ctx, fc)
ctx.counters_rezeroed() ctx.counters_rezeroed()
machine_idle(ctx) machine_idle(ctx)
ctx.log("PASS: a manual home shipped nothing, declared %s, turned the soft limits on, kept Z, " ctx.log("PASS: a manual home with no offset shipped nothing and declared X0 Y0; with %s it declared "
"and reads back as manual", want) "minus that and jogged to X0 Y0 before answering; the soft limits start at the origin, Z was "
"kept, and the home reads back as manual", offset)
def _port_state(fc): def _port_state(fc):
+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. | | `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>`. | | `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>`. | | `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. | | `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. | | `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. | | `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 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 a sender connected, and the sender's response count stays exact; home
is refused unless homing_mode = manual is refused unless homing_mode = manual
7. manual_home_x and manual_home_y: the coordinate the stop blocks stand 7. manual_home_x and manual_home_y: how far in front of the stop blocks
for, the origin by default and never negative; the envelope starts at the origin lies, never negative. With an offset the head at the blocks
the blocks, and an out-of-range offset is clamped 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 9. a controller that starts over a released gantry (the marker in the
state directory) comes up locked and writes no current until $ME 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, 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 gfcloud_home_*, which may be negative, with the envelope reaching back
to it to it
Usage: manual_home_test.py [path/to/grblHAL_glowforge] Usage: manual_home_test.py [path/to/grblHAL_glowforge]
""" """
import os import os
import re
import shutil import shutil
import signal import signal
import subprocess import subprocess
@@ -103,6 +107,15 @@ class Session:
return 0 return 0
return sum(1 for b in data if not b & 0x80 and b & 0x35) 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): def attr_writes(self):
"""The X and Y step-current writes the hardware would have got, in """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 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) 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): def refused(s, line):
"""Send a line that must be refused, and return its error. The core holds """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 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): def test_stop_block_offsets(s):
"""manual_home_x and _y are the coordinate the stop blocks stand for: the """manual_home_x and _y are how far in front of the stop blocks the origin
origin by default, never negative, and the wall the envelope starts at.""" 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.sender.wait_state("Idle")
s.set_mode("manual", "manual_home_x = 12.5\nmanual_home_y = 8\n") 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": if s.sender.send("$H") != "ok":
fail("[offsets] $H under manual with offsets was refused") fail("[offsets] $H under manual with offsets was refused")
st = s.port.state() st = s.port.state() # $H answers once the head has stopped
if abs(st["mpos"][0] - 12.5) > 0.01 or abs(st["mpos"][1] - 8.0) > 0.01: s.sender.send("M5")
fail("[offsets] the home declared %r, not (12.5, 8)" % st["mpos"][:2]) 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": 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") s.sender.wait_state("Idle")
if s.sender.send("$J=G91 X-5 Y-5 F3000") != "ok": 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") s.sender.wait_state("Idle")
for line in ("$J=G91 X-1 F600", "$J=G91 Y-1 F600"): for line in ("$J=G91 X-1 F600", "$J=G91 Y-1 F600"):
r = refused(s, line) r = refused(s, line)
if r != "error:15": 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") 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") fail("[offsets] $H with out-of-range offsets was refused")
st = s.port.state() st = s.port.state()
if st["mpos"][0] != 0 or st["mpos"][1] > 1000: if st["mpos"][0] != 0 or st["mpos"][1] != 0:
fail("[offsets] a negative or oversized offset was not clamped: %r" % st["mpos"][:2]) 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") s.set_mode("manual")
print("PASS [offsets]: a manual home declared (12.5, 8), the envelope started at the blocks, " print("PASS [offsets]: a manual home declared (-12.5, -8) and jogged to the origin before $H "
"a negative offset became the origin and an oversized one the axis travel") "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): def test_offsets_belong_to_their_provider(s):
"""Both pairs set at once: a manual home declares manual_home_* and never """Both pairs set at once: a manual home declares minus manual_home_* and
looks at gfcloud_home_*, and a camera home (a stand-in runner that exits 0) never looks at gfcloud_home_*, and a camera home (a stand-in runner that
declares gfcloud_home_* and never looks at manual_home_*. A camera home may exits 0) declares gfcloud_home_* and never looks at manual_home_*. A camera
lie behind the origin, and the envelope reaches back to it.""" 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" 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.sender.wait_state("Idle")
s.set_mode("manual", both) s.set_mode("manual", both)
if s.sender.send("$H") != "ok": if s.sender.send("$H") != "ok":
fail("[own-offsets] the manual home was refused") fail("[own-offsets] the manual home was refused")
st = s.port.state() d = declared(s)
if abs(st["mpos"][0] - 7.0) > 0.01 or abs(st["mpos"][1] - 9.0) > 0.01: 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)" % st["mpos"][:2]) fail("[own-offsets] a manual home declared %r, not its own (-7, -9)" % (d,))
s.set_mode("gfcloud", both) s.set_mode("gfcloud", both)
if s.sender.send("$H", timeout=30.0) != "ok": if s.sender.send("$H", timeout=30.0) != "ok":
fail("[own-offsets] the camera home (stand-in runner) was refused") 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": if r != "error:15":
fail("[own-offsets] a jog to Y-0.75, off the bed, got %r, not error:15" % r) fail("[own-offsets] a jog to Y-0.75, off the bed, got %r, not error:15" % r)
s.set_mode("manual") 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") "(-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_port_panel_ops(s)
test_restart_while_released(s) test_restart_while_released(s)
test_stop_block_offsets(s) test_stop_block_offsets(s)
test_origin_jog_cancel(s)
test_offsets_belong_to_their_provider(s) test_offsets_belong_to_their_provider(s)
finally: finally:
s.close() s.close()