From db6015dc810cba80f62da694c9758937b187e6f7 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Tue, 15 Sep 2026 15:41:35 -0400 Subject: [PATCH] forgetest: cloud.mode-switch opens the lid behind the controller's start, ahead of the hunt The supervisor holds every controller spawn until the enclosure is closed (forgectrl 0.1.25), and the test opened the lid before it asked for the cloud controller: POST /mode answered "waiting, the lid is open" and the controller never came up. The round trip now switches with the lid closed, polls /mode five times a second, and opens the lid the moment the controller is running. The client requests its connect-time hunt a few seconds after its start, right behind its session, so the hunt still finds the lid open. The order is recorded and judged: the hunt's request line must not be in the client's log when the lid reads open (hunt_before_lid_open), and the test refuses to start with the lid open. The catalog text tells the operator to open the lid at once, with a hand ready on it. Proof. Host: test_cloud_suite drives the round trip with the hunt landing only once the lid reads open, as on the bench, plus the lost race (the hunt requested before the lid opened fails the test with "before the lid was open") and the start with the lid open refused; 8 mode-switch cases green. Bench reference (dev image 20260915001814, forgectrl 0.1.25): cloud.mode-switch PASS in 118 s, the lid open 4 s before the client requested its hunt, no refusal before the hunt's end, the lens homed, the exhaust row unjudged, 5 service motions after the lid closed, $H under gfhome homed in 48.4 s with 9 motion windows. No catalog consequence beyond the test itself: its covers map is unchanged. --- forgetest/forgetest/suite/cloud.py | 49 ++++++++++++++++++------- forgetest/tests/test_cloud_suite.py | 56 ++++++++++++++++++++++++----- 2 files changed, 84 insertions(+), 21 deletions(-) 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)