diff --git a/forgetest/forgetest/suite/cloud.py b/forgetest/forgetest/suite/cloud.py index bd1702e..1c16e6b 100644 --- a/forgetest/forgetest/suite/cloud.py +++ b/forgetest/forgetest/suite/cloud.py @@ -87,6 +87,9 @@ FORGECTRL_LOG = "/data/log/forgefirm/forgectrl/forgectrl.log" LIMITS_MARK = "job limits from the header: " EFFECTIVE_MARK = "effective limits: coolant ceiling " SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED") +# The service's connect-time hunt, as the client logs its request: a few +# seconds after the controller starts, right behind the session. +HUNT_REQUEST = "service action request: hunt" RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed @@ -145,7 +148,7 @@ def return_head(ctx, feed=2400): "head not back at the start after the return jog: %s", pos) -def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90): +def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90, poll=1.0): t0 = time.time() last = None while time.time() - t0 < timeout: @@ -157,7 +160,7 @@ def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90): return m if m.get("controller") == "motion-fault": break - time.sleep(1) + time.sleep(poll) return last @@ -240,17 +243,22 @@ def gfhome_homing(ctx, ev, g): covers=_HOMING_PATH + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"), ("grblhal-glowforge", "src/**")], requires=["forgectrl.auth", "motion.pacing"], actions=["lid"], - steps=["Bed clear; cloud credentials configured; the machine on the network. The test " - "turns cloud mode and the gfcloud homing on itself when they are off, and puts " - "the settings back at the end.", - "Open the lid when told and leave it open through the cloud client's connect and its " - "hunt; close it when told. Nothing else: the switch back and the $H homing run on " - "their own, and the head ends parked at the home corner."], + steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. " + "The test turns cloud mode and the gfcloud homing on itself when they are off, and " + "puts the settings back at the end.", + "Open the lid the moment you are told, with a hand ready on it: the cloud client's " + "first hunt begins a few seconds after its controller starts and must find the lid " + "open. Leave it open through the connect and the hunt; close it when told. Nothing " + "else: the switch back and the $H homing run on their own, and the head ends parked " + "at the home corner."], description="One round trip with the two service-driven motions on it. POST /mode switches " - "to the cloud controller: gfcloud comes up under supervision, authenticates and " + "to the cloud controller with the lid closed (no controller starts with the " + "enclosure open): gfcloud comes up under supervision, authenticates and " "establishes its service session (its own log lines are the evidence; the " - "connect-time firmware probe is recorded when configured). Its connect-time hunt " - "runs with the lid OPEN, as the factory's does - it completes, nothing before its " + "connect-time firmware probe is recorded when configured). The lid is opened the " + "moment the controller is up, before the client requests its connect-time hunt " + "(a hunt requested before the lid was open fails the test), so the hunt runs " + "with the lid OPEN, as the factory's does - it completes, nothing before its " "end is refused for the lid, the lens homes - and with the extraction fans off it " "is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row " "reads unjudged); the camera service survives the switch. The lid closed, the " @@ -275,17 +283,32 @@ def mode_switch(ctx): except OSError: pass lamp0 = hw.sysfs_read("pic/lid_led") + ctx.check(ctx.switch("lid") is True, "close the lid first: no controller starts with it open") - ctx.act("lid", "open", text="Leave it open: the cloud client connects and hunts with the lid open.") + # The switch is made with the enclosure closed (the supervisor holds + # every spawn until it is). The lid opens the moment the controller is + # up: the client requests its connect-time hunt a few seconds after + # its start, right behind its session, and the hunt must find the lid + # open. The poll is tight so the window is not spent waiting. log_offset = log_size(GFCLOUD_LOG) st, body = fc.post("/mode", data={"controller": "cloud"}) ctx.log("POST /mode controller=cloud -> %s %s", st, body) ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body) - m = wait_mode(ctx, fc, "cloud", timeout=90) + m = wait_mode(ctx, fc, "cloud", timeout=90, poll=0.2) ev["mode_cloud"] = m ctx.log("mode after switch: %s", m) ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running", "cloud controller did not come up: %s", m) + ctx.act("lid", "open", text="Now, at once: the cloud client's first hunt begins a few seconds " + "after its controller starts and must find the lid open. Leave it open through the " + "connect and the hunt.") + # The order is the proof: the hunt's request is not in the log yet + # when the lid reads open. + ev["hunt_before_lid_open"] = log_has(log_offset, HUNT_REQUEST) + ctx.check(not ev["hunt_before_lid_open"], + "the client requested its hunt before the lid was open (the hunt follows the " + "controller's start by a few seconds): the lid opened too late, so a hunt with the " + "lid open is not proven") # the client's own session lines are the evidence of a live cloud session session = wait_session(ctx, log_offset) probe = None diff --git a/forgetest/tests/test_cloud_suite.py b/forgetest/tests/test_cloud_suite.py index 66cfc4e..7e245e9 100644 --- a/forgetest/tests/test_cloud_suite.py +++ b/forgetest/tests/test_cloud_suite.py @@ -558,12 +558,16 @@ class CloudSuiteTests(unittest.TestCase): HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False, "fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}} - def mode_switch_setup(self, hunt_lines=None, home_complete=True): + def mode_switch_setup(self, hunt_lines=None, home_complete=True, lid_late=False): """The fakes a mode-switch run needs: grbl to answer $H, the lid lamp attr, homing_mode = gfcloud, the service lines landing on - the switch to cloud (the hunt reads as a run to the cooling - engine while it lasts), the re-hunt on the lid close, and gfhome - finishing the homing after $H.""" + the switch to cloud - the client's start at once, its session and + hunt once the lid reads open, as on the bench where the lid opens + behind the controller's start and ahead of the hunt (the hunt + reads as a run to the cooling engine while it lasts) - the re-hunt + on the lid close, and gfhome finishing the homing after $H. With + lid_late the hunt is requested before the lid opens: the race the + test must call.""" self.grbl = helpers.FakeGrbl().start() os.makedirs(self.sysfs + "pic", exist_ok=True) self._attr("pic/lid_led", "236") @@ -575,22 +579,35 @@ class CloudSuiteTests(unittest.TestCase): hunt_part = hunt_lines(hunt_part) fc = self.fc + def hunt(): + self.append(hunt_part, delay=0.0) + time.sleep(4.0) # the hunt outlasts the session wait on the bench + fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False} + def on_post(path, form): if path == "/mode" and form.get("controller") == "cloud": fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE) def land(): self.append(pre, delay=0.0) + if lid_late: + return + t0 = time.time() + while fc.state["status"]["switches"]["lid"] and time.time() - t0 < 5: + time.sleep(0.02) time.sleep(0.3) - self.append(hunt_part, delay=0.0) - time.sleep(4.0) # the hunt outlasts the session wait on the bench - fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False} + hunt() threading.Thread(target=land, daemon=True).start() elif path == "/mode" and form.get("controller") == "grbl": self.grbl.state = "Idle" return None self.fc.on_post = on_post + def lid_opened(): + if lid_late: + self.append(hunt_part, delay=0.0) + self.lid(False) + def lid_closed(): self.lid(True) self.append(close_part, delay=0.2) @@ -605,7 +622,7 @@ class CloudSuiteTests(unittest.TestCase): f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete " b"(service quiet 8s, 3 motion windows)\n") self.home_hook = homing - return {"Open the lid.": lambda: self.lid(False), "Close the lid.": lid_closed} + return {"Open the lid.": lid_opened, "Close the lid.": lid_closed} def wait_home_command(self): while "$H" not in self.grbl.sent: @@ -633,6 +650,29 @@ class CloudSuiteTests(unittest.TestCase): self.assertEqual(self.script.asked, []) self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:]) + def test_mode_switch_opens_the_lid_behind_the_controller_and_ahead_of_the_hunt(self): + # No controller starts with the enclosure open: the switch is made + # lid closed, the lid opens once the controller is up, and the + # hunt's request lands after that - the order the test records. + hooks = self.mode_switch_setup() + threading.Thread(target=self.wait_home_command, daemon=True).start() + run = self.run_test(cloud.mode_switch, hooks=hooks, test_id="cloud.mode-switch") + self.assertFalse(run.evidence["hunt_before_lid_open"]) + acts = [(a["channel"], a["state"]) for a in run.evidence["actions"]] + self.assertEqual(acts, [("lid", "open"), ("lid", "close")]) + i_post = next(i for i, l in enumerate(run.lines) if "POST /mode controller=cloud" in l) + i_open = next(i for i, l in enumerate(run.lines) if "ACT lid open" in l) + self.assertLess(i_post, i_open) + + def test_mode_switch_refuses_to_start_with_the_lid_open(self): + hooks = self.mode_switch_setup() + self.lid(False) + self.assertFails(cloud.mode_switch, "close the lid first", hooks=hooks) + + def test_mode_switch_fails_when_the_hunt_was_requested_before_the_lid_opened(self): + hooks = self.mode_switch_setup(lid_late=True) + self.assertFails(cloud.mode_switch, "before the lid was open", hooks=hooks) + def test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self): def refused(hunt_part): i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)