diff --git a/forgetest/forgetest/baseline.py b/forgetest/forgetest/baseline.py index 5366319..400f306 100644 --- a/forgetest/forgetest/baseline.py +++ b/forgetest/forgetest/baseline.py @@ -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): diff --git a/forgetest/forgetest/runner.py b/forgetest/forgetest/runner.py index 6b7067e..c6155ec 100644 --- a/forgetest/forgetest/runner.py +++ b/forgetest/forgetest/runner.py @@ -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: diff --git a/forgetest/forgetest/suite/forgectrl.py b/forgetest/forgetest/suite/forgectrl.py index 6994b50..fabf45c 100644 --- a/forgetest/forgetest/suite/forgectrl.py +++ b/forgetest/forgetest/suite/forgectrl.py @@ -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]) diff --git a/forgetest/forgetest/suite/motion.py b/forgetest/forgetest/suite/motion.py index d66e262..6f66e38 100644 --- a/forgetest/forgetest/suite/motion.py +++ b/forgetest/forgetest/suite/motion.py @@ -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) diff --git a/forgetest/tests/test_baseline.py b/forgetest/tests/test_baseline.py index 08a236f..f965af5 100644 --- a/forgetest/tests/test_baseline.py +++ b/forgetest/tests/test_baseline.py @@ -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