mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
forgetest: the hand-back never moves the head across a lost counter frame
Seen on the bench reference, twice in one session: at the end of a passing run the hand-back jogged the head 30 mm into the back-left stop blocks, from a head that had not moved. The baseline compares the kernel's step counters at the end of a run with the start and jogs the head back by the difference. The GRBL controller zeroes those counters at every start (the lens's startup reference) and at every home (home_completed()), and rewrites its anchor, /run/grblhal.homed, each time. Across either event the difference between two counter readings is not a distance the head traveled. It stayed hidden because the counters normally read zero between tests. homing.manual broke that: a manual home zeroes the counters 30 mm out from where the test began, so after the test's own correct return they read -6400, and the next test that restarts the controller (setup.check-flow-verify, then motion.release) ended at 0, "expected -6400", and was "returned" by 30 mm. An operator who jogs the head from a Grbl client and then starts any takeover test from the page would have met the same thing, by whatever distance they had jogged. The baseline's capture() now records the counters' frame, the anchor's inode and mtime. If the frame changed during the run and the test did not vouch for the new one, the hand-back logs that the two readings share no frame, and moves nothing. ctx.counters_rezeroed() is how a test vouches: it now sets rezero_declared beside the position it expects. The start_reads argument it briefly took is gone, since it made the baseline accept counters that nothing after it could live with. homing.manual restarts the controller once more after returning the head, so it ends with the counters at zero where it began, and declares that. events.stream waits for its three places. A stream an earlier test closed keeps its place until the daemon's next write to it (its keep-alive), as documented, so run straight after forgectrl.lease the third stream drew 503. The test now opens the three once they can be opened, and says so in its log. Proven. test_baseline gains test_a_lost_counter_frame_never_moves_the_head: counters at -6400, a new anchor, counters at 0: no jog, no leftover, and the log says why; the same counters with the frame intact are still a displaced head; a declared re-zero is held to the position it declared. With the frame check unable to see the change (the first cut of the test reused an inode inside one clock tick) the case fails with ['position'], which is the old behavior. The unit suite passes. On the bench reference, arranged so a failure would move the head away from the stop: the head jogged to +60 mm (counters 12800), motion.release run, PASS, "the controller re-zeroed its counters during the run ... the head is not moved", and nothing moved. forgectrl.lease then events.stream: two logged waits, PASS. homing.manual then setup.check-flow-verify, the sequence that drove the head into the stop: both PASS with a clean hand-back.
This commit is contained in:
@@ -214,6 +214,23 @@ def write_led(name, value):
|
||||
f.write(str(value))
|
||||
|
||||
|
||||
# The GRBL controller zeroes the kernel's step counters at every start (the
|
||||
# lens reference) and at every home, and rewrites its home anchor each time.
|
||||
# The anchor's identity is therefore the counters' frame: if it changed
|
||||
# during a run, the counters at the end are not in the frame of the counters
|
||||
# at the start, and their difference is not a distance the head traveled.
|
||||
ANCHOR_PATH = os.environ.get("FORGETEST_ANCHOR", "/run/grblhal.homed")
|
||||
|
||||
|
||||
def counter_frame():
|
||||
"""What identifies the frame the step counters are in, or None."""
|
||||
try:
|
||||
st = os.stat(ANCHOR_PATH)
|
||||
return [st.st_ino, st.st_mtime_ns]
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def read_position():
|
||||
"""(x, y, z) step counters, or None when unreadable."""
|
||||
try:
|
||||
@@ -445,7 +462,8 @@ class Baseline:
|
||||
# -- capture -------------------------------------------------------
|
||||
def capture(self):
|
||||
"""Record the preserved state before a run."""
|
||||
cap = {"sysfs": {}, "position": read_position(), "settings": None, "mode": None}
|
||||
cap = {"sysfs": {}, "position": read_position(), "frame": counter_frame(),
|
||||
"settings": None, "mode": None}
|
||||
for attr in PRESERVED_SYSFS:
|
||||
cap["sysfs"][attr] = hw.sysfs_read(attr)
|
||||
st, body = self.fc_get("/settings")
|
||||
@@ -829,7 +847,19 @@ class Baseline:
|
||||
# action re-zeroes them at its start, so they preserve nothing.
|
||||
was = captured.get("position")
|
||||
now = read_position()
|
||||
if was is not None and now is not None and now != was and not self.cloud_mode():
|
||||
# A controller start or a home during the run re-zeroed the counters
|
||||
# wherever the head then stood. Unless the test said where that was
|
||||
# (ctx.counters_rezeroed), the two readings share no frame: on the
|
||||
# bench reference a head that had not moved was "returned" 30 mm into
|
||||
# the stop blocks this way, twice. Nothing is moved on a guess.
|
||||
frame_lost = ("frame" in captured and captured["frame"] != counter_frame()
|
||||
and not captured.get("rezero_declared"))
|
||||
if was is not None and now is not None and now != was and frame_lost and not self.cloud_mode():
|
||||
self.log("position: %s, and %s when the run began, but the controller re-zeroed its "
|
||||
"counters during the run (a controller start or a home): the two are not in "
|
||||
"one frame, so the head's position cannot be judged and the head is not moved"
|
||||
% (now, was))
|
||||
elif was is not None and now is not None and now != was and not self.cloud_mode():
|
||||
spm = self._counter_spm
|
||||
act = self._return_head(was, now, spm)
|
||||
if position_quantized(was, now, spm):
|
||||
|
||||
@@ -541,19 +541,17 @@ class Context:
|
||||
def takeover(self):
|
||||
return Takeover(self.run.log, self.test.id)
|
||||
|
||||
def counters_rezeroed(self, start_reads=None):
|
||||
def counters_rezeroed(self):
|
||||
"""Tell the baseline the kernel position counters were re-zeroed
|
||||
during this run. With no argument they were re-zeroed at the head's
|
||||
starting position (cloud mode's connect clears them): counters at
|
||||
(0,0,0) afterward mean the head is back where the run found it.
|
||||
`start_reads` is for a re-zero taken somewhere else (a manual home
|
||||
after an outbound jog): what the counters read, in the new frame,
|
||||
with the head at its starting position."""
|
||||
at the head's starting position during this run (cloud mode's
|
||||
connect clears them): counters at (0,0,0) afterward mean the head
|
||||
is back where the run found it."""
|
||||
cap = self.run.baseline_captured
|
||||
if cap and cap.get("position") is not None:
|
||||
cap["position"] = [int(v) for v in start_reads] if start_reads is not None else [0, 0, 0]
|
||||
self.log("position counters re-zeroed during the run; the baseline expects %s at the end"
|
||||
% (tuple(cap["position"]),))
|
||||
cap["position"] = [0, 0, 0]
|
||||
cap["rezero_declared"] = True # the test vouches for the new frame
|
||||
self.log("position counters re-zeroed at the starting position; the baseline "
|
||||
"expects (0,0,0) at the end")
|
||||
|
||||
def mode_changed(self, mode):
|
||||
"""Declare a deliberate controller-mode change for the operator:
|
||||
|
||||
@@ -497,9 +497,23 @@ def events_stream(ctx):
|
||||
fc = hw.Forgectrl()
|
||||
streams = []
|
||||
try:
|
||||
for src in ("127.0.0.2", "127.0.0.3", "127.0.0.4"):
|
||||
s = _EventStream(fc.base, src)
|
||||
streams.append(s)
|
||||
# A stream an earlier test closed keeps its place until the daemon's
|
||||
# next write to it (its keep-alive), so the three places may not be
|
||||
# free yet: that is the daemon as documented, and the last part of
|
||||
# this test measures it. The three are opened once they can be.
|
||||
deadline = time.time() + 25
|
||||
while True:
|
||||
for src in ("127.0.0.2", "127.0.0.3", "127.0.0.4"):
|
||||
streams.append(_EventStream(fc.base, src))
|
||||
if all(s.status == 200 for s in streams) or time.time() > deadline:
|
||||
break
|
||||
ctx.log("an earlier stream still holds a place (%s): waiting for it to be given back",
|
||||
[s.status for s in streams])
|
||||
for s in streams:
|
||||
s.close()
|
||||
streams = []
|
||||
ctx.sleep(3)
|
||||
for s, src in zip(streams, ("127.0.0.2", "127.0.0.3", "127.0.0.4")):
|
||||
hello = s.text(1.0)
|
||||
ctx.check(s.status == 200 and "text/event-stream" in s.head and "event: hello" in hello,
|
||||
"GET /events from %s -> %s, %r", src, s.status, hello[:120])
|
||||
|
||||
@@ -1990,8 +1990,6 @@ def manual_home(ctx):
|
||||
try:
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
machine_idle(ctx)
|
||||
k_start = _kernel_position()
|
||||
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)
|
||||
moved_out = True
|
||||
@@ -2012,10 +2010,6 @@ def manual_home(ctx):
|
||||
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[0] == 0 and k1[1] == 0, "the kernel counters were not cleared: %s", k1)
|
||||
# The home cleared the counters out here, not where the run found
|
||||
# the head: the hand-back is told what the start reads in the new
|
||||
# frame, or it would "return" the head by the length of the jog.
|
||||
ctx.counters_rezeroed([k_start[i] - k0[i] for i in range(3)])
|
||||
ctx.check(abs(rep["MPos"][0] - want[0]) < 0.01 and abs(rep["MPos"][1] - want[1]) < 0.01,
|
||||
"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])
|
||||
@@ -2042,6 +2036,18 @@ def manual_home(ctx):
|
||||
r = g.command("$J=G91X-30F2400") # back to where the test found the head
|
||||
ctx.check(not any(x.startswith("error") for x in r), "the return jog was refused: %s", r)
|
||||
wait_idle(ctx, g)
|
||||
machine_idle(ctx)
|
||||
# The home cleared the kernel's counters 30 mm out, so the return
|
||||
# leaves them at -30 mm with the head where the run found it. The
|
||||
# hand-back compares counters, and a controller start zeroes them:
|
||||
# left like this, the next test that restarts the controller reads
|
||||
# as 30 mm out of place and the hand-back "returns" a head that
|
||||
# never moved (on the bench reference it drove the head into the
|
||||
# stop blocks). One more start, with the head back, leaves the
|
||||
# counters at zero where the run began, which is what the rest of
|
||||
# the catalog counts on.
|
||||
_drop_reference(ctx, fc)
|
||||
ctx.counters_rezeroed()
|
||||
machine_idle(ctx)
|
||||
ctx.log("PASS: a manual home shipped nothing, declared %s, turned the soft limits on, kept Z, "
|
||||
"and reads back as manual", want)
|
||||
|
||||
@@ -139,6 +139,49 @@ class BaselineTests(unittest.TestCase):
|
||||
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
|
||||
self.assertEqual(items["position"].found, [1000, 0, 0])
|
||||
|
||||
def test_a_lost_counter_frame_never_moves_the_head(self):
|
||||
# The controller zeroes the step counters at every start and at
|
||||
# every home, and rewrites its anchor then. A run that began at
|
||||
# -6400 steps and restarted the controller ends at 0 with the head
|
||||
# where it was: the difference is not a distance, and the hand-back
|
||||
# must neither jog the head nor call it a leftover.
|
||||
anchor = os.path.join(self.tmp, "grblhal.homed")
|
||||
old_path, baseline.ANCHOR_PATH = baseline.ANCHOR_PATH, anchor
|
||||
try:
|
||||
with open(anchor, "w") as f:
|
||||
f.write("0 0 0 4 startup")
|
||||
self._pos(-6400, 0, 0)
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
self.assertIsNotNone(cap["frame"])
|
||||
os.unlink(anchor) # a controller start: a new anchor file
|
||||
with open(anchor + ".new", "w") as f:
|
||||
f.write("0 0 0 4 startup")
|
||||
os.replace(anchor + ".new", anchor)
|
||||
os.utime(anchor, ns=(5, 5)) # a freed inode can come straight back, inside one clock tick
|
||||
self._pos(0, 0, 0)
|
||||
left = b.enforce("post", captured=cap)
|
||||
self.assertEqual([x.item for x in left], [])
|
||||
self.assertTrue(any("re-zeroed its counters during the run" in l for l in self.lines), self.lines)
|
||||
# the same counters with the frame intact are a displaced head, as before
|
||||
self.lines.clear()
|
||||
self._pos(-6400, 0, 0)
|
||||
cap = b.capture()
|
||||
self._pos(0, 0, 0)
|
||||
left = b.enforce("post", captured=cap)
|
||||
self.assertEqual([x.item for x in left], ["position"])
|
||||
# and a test that vouches for the new frame is held to it
|
||||
self._pos(-6400, 0, 0)
|
||||
cap = b.capture()
|
||||
os.utime(anchor, ns=(1, 1)) # re-zeroed, and declared (ctx.counters_rezeroed)
|
||||
cap["position"], cap["rezero_declared"] = [0, 0, 0], True
|
||||
self._pos(0, 0, 0)
|
||||
self.assertEqual(b.enforce("post", captured=cap), [])
|
||||
self._pos(1000, 0, 0)
|
||||
self.assertEqual([x.item for x in b.enforce("post", captured=cap)], ["position"])
|
||||
finally:
|
||||
baseline.ANCHOR_PATH = old_path
|
||||
|
||||
def test_the_counters_are_read_at_the_modes_scale(self):
|
||||
# 6400 steps is 30 mm at x32 (a return within the bound; on the
|
||||
# host it stops at the missing controller) and 120 mm at x8
|
||||
|
||||
Reference in New Issue
Block a user