diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 8543238..edce36f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -281,7 +281,11 @@ most every 30 s otherwise. What holds: read the switches; `ctx.act` asks it and then waits for forgectrl's reading exactly as it waits for an operator's hand. An action the box fails to perform falls back to the operator's notice, and the record - says so (`evidence.actions[].by`, `fixture_error`). + says so (`evidence.actions[].by`, `fixture_error`); in an unattended + run there is nobody to fall back to, and the test ends ERROR naming + the refusal. Two button presses are spaced by the tool (the last + pulse's end plus 300 ms), so the controller sees the release between + them and not one long press. - **An operator test the fixture can run alone runs unattended.** A test declares its actions and, with `hands=(...)`, whatever else it asks of a person ("app" for a job in the Glowforge app). An `operator` test diff --git a/fixture/README.md b/fixture/README.md index 31f5415..9e99570 100644 --- a/fixture/README.md +++ b/fixture/README.md @@ -98,7 +98,9 @@ to arm the laser for a live test; by default that press is a person's. The file is mode 0600 and bench-local. With the fixture up, an `operator` test whose actions it covers runs in the unattended queue; `live` tests keep their kind and their acknowledgment, and every action -records who performed it. +records who performed it. Presses are spaced by the tool: the next one +waits for the last pulse to end plus a 300 ms release, so the machine +sees two presses and not one long one. ## What keeps it safe diff --git a/forgetest/forgetest/baseline.py b/forgetest/forgetest/baseline.py index 499188e..818fb34 100644 --- a/forgetest/forgetest/baseline.py +++ b/forgetest/forgetest/baseline.py @@ -550,6 +550,19 @@ class Baseline: try: with hw.Grbl() as g: rep = g.status_report() + if rep["state"].startswith(("Hold", "Door")): + # a job left held (a pause test that failed there) refuses + # a jog: the soft reset ends it where it stopped, position + # kept, and hands the controller back + held = rep["state"] + g.realtime(0x18) + deadline = time.time() + 5 + while time.time() < deadline: + rep = g.status_report() + if not rep["state"].startswith(("Hold", "Door")): + break + time.sleep(0.2) + self.log("controller reset out of %s: now %s" % (held, rep["state"])) if rep["state"].startswith("Alarm"): g.command("$X") g.command("$J=G91X%.3fY%.3fF1200" % (-dx, -dy)) diff --git a/forgetest/forgetest/fixture.py b/forgetest/forgetest/fixture.py index c6e9d44..26f4b75 100644 --- a/forgetest/forgetest/fixture.py +++ b/forgetest/forgetest/fixture.py @@ -33,6 +33,8 @@ CHANNELS = ("lid", "interlock", "button") MDNS_GROUP = "224.0.0.251" MDNS_PORT = 5353 RESOLVE_TTL_S = 300.0 # a resolved address is trusted this long +BUTTON_GAP_S = 0.3 # the release between two presses: long enough for the controller to see it +PULSE_WAIT_S = 1.5 # a pulse in progress ends within the firmware's 500 ms clamp class FixtureError(Exception): @@ -226,6 +228,7 @@ class Fixture: self._resolved_at = time.time() if self.ip_override else 0.0 self._lock = threading.Lock() self.last_state = None + self._pulse_end = 0.0 # when this client's last press ends (its pulse_ms) # -- address ------------------------------------------------------- def address(self, refresh=False): @@ -293,7 +296,7 @@ class Fixture: if channel == "button": if state != "press": raise FixtureError("the button is only ever pressed") - st, body = self._request("POST", "/button", {}) + st, body = self._press() else: if state not in ("open", "close"): raise FixtureError("%s: unknown state %r" % (channel, state)) @@ -304,6 +307,30 @@ class Fixture: self.last_state = body return body + def _press(self): + """One press, spaced from the last one. The controller detects + the button's rising edge, so a press must follow a release it has + seen: the next press waits for the last pulse to end (the + fixture's own pulse_ms) plus BUTTON_GAP_S. A 409 for a pulse in + progress (a press this client did not time) is waited out once + against the fixture's state, then retried.""" + wait = self._pulse_end + BUTTON_GAP_S - time.time() + if wait > 0: + time.sleep(wait) + st, body = self._request("POST", "/button", {}) + if st == 409 and "in progress" in str(body.get("error", "")): + deadline = time.time() + PULSE_WAIT_S + while time.time() < deadline: + time.sleep(0.1) + s, b = self._request("GET", "/") + if s == 200 and not b.get("button_pulsing"): + break + time.sleep(BUTTON_GAP_S) + st, body = self._request("POST", "/button", {}) + if st == 200: + self._pulse_end = time.time() + float(body.get("pulse_ms") or 500) / 1000.0 + return st, body + def release(self): st, body = self._request("POST", "/release", {}) if st != 200: diff --git a/forgetest/forgetest/runner.py b/forgetest/forgetest/runner.py index 7538688..0fbb55d 100644 --- a/forgetest/forgetest/runner.py +++ b/forgetest/forgetest/runner.py @@ -338,11 +338,16 @@ class Context: try: fixture.act(channel, state) except _fixture.FixtureError as e: + rec["fixture_error"] = str(e) + if self.run.unattended: + # nobody is in the room to do it instead: the run ends + # here, as the harness's failure, not the machine's + self.log("ACT %s %s: fixture failed (%s) - unattended, nobody to ask", channel, state, e) + raise # the box did not do it: the operator is asked instead, # and the record says so self.log("ACT %s %s: fixture failed (%s) - asking the operator", channel, state, e) rec["by"] = "operator" - rec["fixture_error"] = str(e) self.notice(wording) else: self.notice(wording) @@ -900,6 +905,10 @@ class Runner: result, message = _campaign.ABORTED, str(e) or "aborted" except Failed as e: result, message = _campaign.FAIL, str(e) + except _fixture.FixtureError as e: + # the bench actuator could not perform a step: the test was + # not judged, and the machine is not the one at fault + result, message = _campaign.ERROR, "the fixture could not perform a step: %s" % e except Exception as e: # noqa: BLE001 - an erroring test is a failed test result, message = _campaign.ERROR, "%s: %s" % (type(e).__name__, e) run.log(traceback.format_exc().rstrip()) diff --git a/forgetest/tests/helpers.py b/forgetest/tests/helpers.py index 619de4f..001f4cb 100644 --- a/forgetest/tests/helpers.py +++ b/forgetest/tests/helpers.py @@ -184,6 +184,8 @@ class FakeGrbl: self.mpos = [0.0, 0.0, 0.0] self.sent = [] self.extra = b"" # text pushed to the client on the next poll + self.reset_to = None # the state a soft reset (^X) lands in, when set + self.on_command = None # called with every command line received self._sock = _socket.socket() self._sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1) self._sock.bind(("127.0.0.1", 0)) @@ -228,10 +230,16 @@ class FakeGrbl: buf = buf[1:] out += ("<%s|MPos:%.3f,%.3f,%.3f|FS:0,0>\r\n" % (self.state, *self.mpos)).encode() elif buf[0] in (0x18, 0x85, 0x7E, 0x21): + if buf[0] == 0x18: + self.sent.append("^X") + if self.reset_to: + self.state = self.reset_to buf = buf[1:] elif b"\n" in buf: line, buf = buf.split(b"\n", 1) self.sent.append(line.strip().decode("utf-8", "replace")) + if self.on_command: + self.on_command(self.sent[-1]) out += b"ok\r\n" else: break diff --git a/forgetest/tests/test_baseline.py b/forgetest/tests/test_baseline.py index b8c8053..5cbdf78 100644 --- a/forgetest/tests/test_baseline.py +++ b/forgetest/tests/test_baseline.py @@ -119,6 +119,33 @@ 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_held_controller_is_reset_before_the_return_jog(self): + # a pause test that failed while held leaves the controller in + # Hold, which refuses a jog: the baseline resets out of it first + import helpers + fc = helpers.FakeForgectrl().start() + dev = helpers.FakeGrbl().start() + try: + dev.state = "Hold:0" + dev.reset_to = "Idle" + dev.on_command = lambda line: self._pos(0, 0, 0) if line.startswith("$J=") else None + b = self.bl() + cap = b.capture() + self._pos(221, 0, 0) # 4.144 mm into the held move + left = b.enforce("post", captured=cap) + items = {x.item: x for x in left} + self.assertTrue(items["position"].action.startswith("restored"), items["position"].action) + self.assertEqual(dev.sent[0], "^X") + jogs = [l for l in dev.sent if l.startswith("$J=")] + self.assertEqual(len(jogs), 1) + self.assertIn("X-4.144", jogs[0]) + self.assertTrue(any("reset out of Hold:0" in l for l in self.lines), self.lines) + finally: + dev.stop() + fc.stop() + for k in ("GRBL_HOST", "GRBL_PORT"): + os.environ.pop(k, None) + def _pos_bytes(self, x, y, z, processed, total): with open(self.sysfs + "cnc/position", "wb") as f: f.write(struct.pack("<3i2I", x, y, z, processed, total)) diff --git a/forgetest/tests/test_fixture.py b/forgetest/tests/test_fixture.py index 4aa729d..6ae84ee 100644 --- a/forgetest/tests/test_fixture.py +++ b/forgetest/tests/test_fixture.py @@ -37,6 +37,7 @@ class FakeFixture: self.state = {"lid": "closed", "interlock": "closed", "button": "idle"} self.button_enabled = button_enabled self.calls = [] + self.presses = 0 # presses the device performed srv = self class H(BaseHTTPRequestHandler): @@ -54,7 +55,8 @@ class FakeFixture: def _state(self): return {"device": "forgefixture", "hostname": "forgefixture", "version": "1.0.0", "idf": "v5.5.5", "uptime_s": 12, "channels": dict(srv.state), - "button_enabled": srv.button_enabled, "button_pulsing": False, + "button_enabled": srv.button_enabled, + "button_pulsing": srv.state["button"] == "pressed", "wifi": {"connected": True, "ip": "127.0.0.1", "rssi": -50}} def _auth(self): @@ -84,7 +86,10 @@ class FakeFixture: if self.path == "/button": if not srv.button_enabled: return self._json(409, {"error": "button disabled: the enable jumper is out"}) + if srv.state["button"] == "pressed": + return self._json(409, {"error": "a button pulse is in progress"}) srv.state["button"] = "pressed" + srv.presses += 1 threading.Timer(0.05, lambda: srv.state.__setitem__("button", "idle")).start() s = self._state() s["pulse_ms"] = 200 @@ -217,6 +222,37 @@ class ClientTests(unittest.TestCase): f.act("button", "press") self.assertIn("jumper", str(cm.exception)) + def test_two_presses_are_spaced_so_the_controller_sees_the_release(self): + f = self.client() + f.status() + t0 = time.time() + f.act("button", "press") + f.act("button", "press") + took = time.time() - t0 + posts = [c for c in self.dev.calls if c[:2] == ("POST", "/button")] + self.assertEqual(len(posts), 2) # no 409 round trip was needed + self.assertEqual(self.dev.presses, 2) + # the second waited for the first pulse (200 ms as reported) and the gap + self.assertGreaterEqual(took, 0.2 + fx.BUTTON_GAP_S - 0.05) + + def test_a_pulse_in_progress_is_waited_out_then_retried(self): + f = self.client() + f.status() + self.dev.state["button"] = "pressed" # a press this client did not time + threading.Timer(0.3, lambda: self.dev.state.__setitem__("button", "idle")).start() + f.act("button", "press") + posts = [c for c in self.dev.calls if c[:2] == ("POST", "/button")] + self.assertEqual(len(posts), 2) # the 409, then the press + self.assertEqual(self.dev.presses, 1) + + def test_a_pulse_that_never_ends_is_the_fixtures_error(self): + f = self.client() + f.status() + self.dev.state["button"] = "pressed" + with self.assertRaises(fx.FixtureError) as cm: + f.act("button", "press") + self.assertIn("in progress", str(cm.exception)) + def test_a_wrong_key_is_refused(self): f = self.client(key="wrong") with self.assertRaises(fx.FixtureError) as cm: @@ -426,6 +462,22 @@ class RoutingTests(unittest.TestCase): self.assertIn("the box is off", rec["fixture_error"]) self.assertTrue(seen and "lid" in seen[0].lower()) + def test_a_failing_box_in_an_unattended_run_ends_the_test_as_an_error(self): + # nobody is in the room: the run must not wait ACT_TIMEOUT_S for a + # hand that is not there; it ends at once, as the harness's error + self.stub.fail = True + t0 = time.time() + b, order = self.run_queue("unattended") + results = {x["test"]: x["result"] for x in b["done"]} + self.assertEqual(results["r.lid"], "ERROR") + self.assertLess(time.time() - t0, 20) + rec = self.last_result("r.lid") + self.assertIn("the fixture could not perform a step", rec["message"]) + self.assertIn("the box is off", rec["message"]) + act = rec["evidence"]["actions"][0] + self.assertEqual((act["by"], act["fixture_error"]), ("fixture", "the box is off")) + self.assertFalse(any("asking the operator" in l for l in rec["log"]), rec["log"]) + def test_what_the_box_still_holds_is_released_after_a_run(self): def holds(ctx): ctx.act("lid", "open") # and never closes it