From 9cc2e4eef4cb4a8d04688ca2ea3c7a31f7f48ca1 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sat, 22 Aug 2026 19:19:50 -0400 Subject: [PATCH] forgetest: the machine's print behavior under the offline service Four cloud tests no longer need the app, an account, a network, or anything on the bed: cloud.lid-interlock-abort, lid-during-button-wait, paused-lid-cancel and oversize-stream run under the offline service (enter_offline restarts the cloud client with the /run marker for that one start; Offline is the socket; offline_job writes the job). The jobs come from forgetest/puls.py: the header of a factory print of this machine type (134 tags, MCsn 0, so the client's limits and settings come from where a service job's do) over a square traced at a steady feed with a leading power byte of zero and no LASER bit anywhere - the arm unlocks the latch, the beam is never commanded, so the tests stay live and need no scrap. A job longer than the ring (33 MiB of ticks, an hour of squares) is an 87 kB gzip written in a tenth of a second, in place of a full-bed raster designed in the app. session_live reads the offline mark as "no web session"; enter_cloud restarts an offline client with the service, so cloud.pause-resume (the one real print left, with cloud.mode-switch the service-protocol half of the catalog) follows the offline tests without the operator's hand. Replays over a fake socket; the contract and BRINGUP say how the cloud catalog splits. Catalog consequence: the four re-ported tests move; nothing else is invalidated. --- docs/ACCEPTANCE.md | 15 ++ docs/BRINGUP.md | 18 +- forgetest/forgetest/puls.py | 143 ++++++++++++++ forgetest/forgetest/suite/cloud.py | 288 +++++++++++++++++++++++----- forgetest/tests/test_cloud_suite.py | 209 ++++++++++++++++---- 5 files changed, 590 insertions(+), 83 deletions(-) create mode 100644 forgetest/forgetest/puls.py diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index d121add..b92ca73 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -149,6 +149,21 @@ bench, or one whose `/data` has been wiped, starts from a full campaign. it carries the two service-driven motions with it: the connect-time hunt run with the lid open, and the web-service homing (`$H` with `homing_mode = gfcloud`) after the switch back. + The cloud tests split by what they prove. The service protocol (sign-in, + the WebSocket, the hunt, the print from the app, progress, the job's + limits reaching the engine) is `cloud.mode-switch` and one real print, + `cloud.pause-resume`. The machine's print behavior (the lid and + interlock aborts, the button-wait cancel, a paused print ended by the + lid, a print longer than the ring with the app's cancel) runs under the + **offline service** (`enter_offline`: the cloud client restarted with + the `/run/gfcloud-offline` marker, no account, no network; the test + hands it a synthesized job over `/run/gfcloud-offline.sock` and reads + the machine's events back, see `forgetest/puls.py` and the cloud + client's `docs/CLOUD.md`). Those jobs carry no laser command, so + nothing is on the bed and nothing burns, but the arm still unlocks the + latch, so they stay `live`. The offline client is left running; the + next test that needs the service restarts it (`enter_cloud` does), as + does a mode switch or a controller restart. 4. Or hand the whole list to a queue. **Run what is left** offers two: **Unattended** takes every `auto` test the campaign does not already count as satisfied, and needs nobody in the room; **Operator and live** diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 6634e24..df20889 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -588,9 +588,12 @@ until `releases/v/acceptance.json` is committed. its floor), `camera.*`, `laser.*` (emission witness, arm-wait lid, disarm-in-hold, armed kill, pause/resume/lid-cancel) and `cloud.*` (the mode round trip with the - lid-open hunt and the web-service homing on it, and the job-behavior - tests). Tests that share a setup are merged; the `auto` tests stay - separate for failure isolation. 27 are `auto`, 8 `operator`, 8 `live`. + lid-open hunt and the web-service homing on it, one real print, and the + job-behavior tests under the offline service: the cloud client driven + from a local socket with a synthesized laser-free job, no account, no + network, nothing on the bed). Tests that share a setup are merged; the + `auto` tests stay separate for failure isolation. 27 are `auto`, 8 + `operator`, 8 `live`. - **The operator's part is asked for by name, not by popup** (`docs/ACCEPTANCE.md` "The operator's part"): a Ready prompt before a timed step, a standing notice the test takes down when the machine shows @@ -1151,9 +1154,12 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`. Cutting the operator's part of a campaign: the forgetest-only step (the operator channel, the merged mode-switch, the sensor witnesses, the steps pane, the journal, per-test implementation hashing) is done - and bench-validated (CAMPAIGN-LOG 2026-08-22). The steps after it, an - offline cloud service for the machine-behavior tests and a bench - actuator for the lid, interlock and button, are planned, not started. Catalog + and bench-validated (CAMPAIGN-LOG 2026-08-22). The offline cloud + service for the machine-behavior tests is code-complete (gfutilities + `OfflineService`, `gfcloud --offline`, `forgetest/puls.py`, four tests + re-ported) with host replays; **its bench run on the next dev image is + owed.** A bench actuator for the lid, interlock and button is planned, + not started. Catalog gaps left from the tool's own plan: `cooling.confirm-escalate` and `cooling.fire-gate-blocks-arm` are not ported (both need the pump switched by hand mid-run, so they are bench-tab material first), and whether diff --git a/forgetest/forgetest/puls.py b/forgetest/forgetest/puls.py new file mode 100644 index 0000000..0fe67d1 --- /dev/null +++ b/forgetest/forgetest/puls.py @@ -0,0 +1,143 @@ +"""Pulse files for the offline cloud service: a job the machine will run +without the service having cut it. + +A pulse file is a header (`\\x80GF1`, a little-endian total length, then +8-byte records of a 4-character tag and a uint32) followed by the step +stream, one byte per tick of the header's STfr (the kernel feeder +contract: bit 0 X step, bit 1 X dir, bit 2 Y step, bit 3 Y dir, bit 4 +LASER, bits 5-6 Z, bit 7 marks a power byte whose low 7 bits set the +duty). The header here is the one a factory print of this machine type +carries (134 tags, MCsn 0 = not locked to a serial, PDfm 0), so the +client's header check, its job limits (the coolant window, the air +assist floor), its fan duties and its stepper settings all come from the +same place a service job's do. The stream is a square traced at a steady +feed with the laser never commanded: a leading power byte of zero, no +LASER bit anywhere. Nothing in a file from here can put energy into the +tube; the arm and the latch are exercised, the beam is not. + +A file longer than the kernel ring (32 MiB of ticks) is written gzip- +compressed, whole file in the stream, as the service serves one: the +client inflates it as the ring drains, and the gzip trailer gives it the +job's length for its progress report. +""" +import gzip +import struct + +STEPS_PER_MM = 53.333 # boards/glowforge.h, x8 microstepping + +# The header of a factory print for this machine type, as captured. +PRINT_HEADER = { + "AAfc": 3632880242, "AAid": 204, "AAin": 0, "AAix": 0, "AArd": 1023, "AArn": 0, + "AArx": 64500, "AAsn": 0, "AAsx": 0, "AAwd": 1023, "AAwn": 0, "AAwx": 64500, + "BDbs": 0, "BDpe": 0, "BDps": 0, "BTcx": 12800, "BTfc": 4294711296, "BTfo": 4294942296, + "BTin": 2147483648, "BTix": 2147483647, "BTrn": 2147483648, "BTrx": 9440, + "BTwn": 2147483648, "BTwx": 8928, "BTxb": 15, "CCrp": 10000, "CCup": 1, "CCwp": 5000, + "CFrh": 1, "CMin": 10000, "CMix": 50000, "CMrn": 5000, "CMrx": 33000, "CMts": 0, + "CMwn": 7000, "CMwx": 31000, "EFid": 0, "EFin": 0, "EFix": 0, "EFrd": 65535, "EFrn": 0, + "EFrx": 0, "EFwd": 65535, "EFwn": 0, "EFwx": 0, "FTcx": 30000, "FTin": 2147483648, + "FTix": 2147483647, "FTrn": 2147483648, "FTrx": 14575, "FTwn": 2147483648, "FTwx": 14075, + "FTxb": 15, "HAai": 0, "HAar": 0, "HAsi": 2, "HAsr": 4, "HAxi": 0, "HAxr": 132, "HAyi": 0, + "HAyr": 112, "HAzi": 0, "HAzr": 0, "HIix": 12, "HIrx": 1023, "HTcx": 12800, "HTfc": 51200, + "HTfo": 5000, "HTin": 2147483648, "HTix": 2147483647, "HTrn": 2147483648, "HTrx": 9440, + "HTwn": 2147483648, "HTwx": 8928, "HTxb": 15, "IFid": 0, "IFin": 0, "IFix": 0, + "IFrd": 43278, "IFrn": 0, "IFrx": 0, "IFwd": 43278, "IFwn": 0, "IFwx": 0, "IRpd": 1000, + "IRwb": 3, "IRwc": 688, "IRwx": 275, "IRxb": 3, "IRxc": 1022, "IRxx": 374, "IRyb": 3, + "IRyc": 0, "IRyx": 0, "IRzb": 3, "IRzc": 0, "IRzx": 0, "ITcx": 19200, "ITfc": 153600, + "ITfo": 0, "ITin": 2147483648, "ITix": 2147483647, "ITrn": 2147483648, "ITrx": 15840, + "ITwn": 2147483648, "ITwx": 15328, "ITxb": 15, "LTcx": 12800, "LTfc": 51200, "LTfo": 5000, + "LTin": 2147483648, "LTix": 2147483647, "LTrn": 2147483648, "LTrx": 9440, + "LTwn": 2147483648, "LTwx": 8928, "LTxb": 15, "MCsn": 0, "PCid": 19795, "PDct": 5, + "PDfm": 0, "PTmn": 0, "PTmx": 1023, "STfr": 10000, "TRuc": 0, "XSdm": 1, "XShc": 33, + "XSmm": 8, "XSrc": 135, "YSdm": 1, "YShc": 5, "YSmm": 8, "YSrc": 22, "ZSmd": 0, +} + +X_STEP, X_DIR, Y_STEP, Y_DIR, LASER = 0x01, 0x02, 0x04, 0x08, 0x10 +POWER = 0x80 + + +def header_bytes(tags=None): + """The header record block, the template with `tags` laid over it.""" + h = dict(PRINT_HEADER) + if tags: + h.update(tags) + recs = b"".join(k.encode("ascii") + struct.pack(" 0 else 0) + out = bytearray() + gap = bytes(period - 1) + err = 0 + x_major = ax >= ay + minor = ay if x_major else ax + for _ in range(major): + b = xbit if x_major else ybit + err += minor + if err >= major: + err -= major + b |= ybit if x_major else xbit + out.append(b) + out += gap + return bytes(out) + + +def square_stream(side_mm=40.0, feed_mm_min=600.0, step_hz=10000, repeats=1): + """A closed square traced `repeats` times at a steady feed: +X, +Y, + -X, -Y, the head back where it started. Leads with a power byte of + zero, as the feeder contract wants before any laser-on byte (there + is none).""" + steps = int(round(side_mm * STEPS_PER_MM)) + period = max(1, int(round(step_hz / (feed_mm_min / 60.0 * STEPS_PER_MM)))) + one = (leg(steps, 0, period) + leg(0, steps, period) + + leg(-steps, 0, period) + leg(0, -steps, period)) + return bytes([POWER]) + bytes(period) + one * repeats + + +def seconds_of(stream, step_hz=10000): + return len(stream) / float(step_hz) + + +def write_job(path, side_mm=40.0, feed_mm_min=600.0, seconds=None, step_hz=10000, + compress=None, tags=None): + """Write a job to `path`: a square of `side_mm` at `feed_mm_min`, + repeated until the stream is at least `seconds` long (one square if + None). Compressed when `compress` is true, or, left None, when the + stream is longer than the kernel ring (32 MiB of ticks). Returns + (payload bytes, seconds, compressed).""" + one = square_stream(side_mm, feed_mm_min, step_hz, 1) + repeats = 1 + if seconds: + per = seconds_of(one, step_hz) + repeats = max(1, int(seconds / per) + (1 if seconds % per else 0)) + body = square_stream(side_mm, feed_mm_min, step_hz, repeats) + head = header_bytes(dict(tags or {}, STfr=step_hz)) + if compress is None: + compress = len(body) > 32 * 1024 * 1024 + data = head + body + if compress: + data = gzip.compress(data, compresslevel=6) + with open(path, "wb") as f: + f.write(data) + return len(body), seconds_of(body, step_hz), compress + + +def parse(data): + """(header dict, payload) of a raw or gzip-compressed pulse file.""" + if data[:2] == b"\x1f\x8b": + data = gzip.decompress(data) + if data[1:4] != b"GF1": + raise ValueError("not a GF1 pulse file") + total = struct.unpack_from(" %s", st) + offline_marker_off(ctx) + live = detail = None if m.get("mode") == "cloud" and m.get("controller") == "running": live, detail = session_live(m.get("pid")) - ctx.check(live, "cloud mode is up (pid %s) but the client has no live service session (%s) - " + ctx.check(live or "offline" in detail, + "cloud mode is up (pid %s) but the client has no live service session (%s) - " "check credentials and network, or restart the controller", m.get("pid"), detail) + if live: ctx.log("cloud mode already up (pid %s), service session live - reusing it", m.get("pid")) offset = log_size(GFCLOUD_LOG) else: + if detail: + ctx.log("the running client is the offline service: restarting it with the service") offset = fresh_cloud_connect(ctx) hunt = wait_action_finished(ctx, offset, "hunt", HUNT_TIMEOUT_S) ctx.check(hunt, "the service sent no connect-time hunt (or it never finished) within %d s", @@ -581,6 +604,151 @@ def enter_cloud(ctx): return log_size(GFCLOUD_LOG) +def offline_marker_off(ctx): + """No client started from here on comes up offline.""" + try: + os.remove(OFFLINE_MARKER) + ctx.log("offline marker removed") + except OSError: + pass + + +def enter_offline(ctx): + """Cloud mode with the OFFLINE service: the machine driven from the + local socket, no web session. A running offline client is reused; + otherwise the marker is set and the client started fresh (restarted + in cloud mode, switched to from GRBL mode, the change declared) and + its listener waited for. The marker is taken down again at once: it + only ever applies to that one start. Returns the log offset where the + test's own window begins.""" + fc = ctx.forgectrl + st, m = fc.get("/mode") + ctx.check(st == 200 and isinstance(m, dict), "GET /mode -> %s", st) + if m.get("mode") == "cloud" and m.get("controller") == "running" and client_offline(m.get("pid")): + ctx.log("the offline service is already up (pid %s) - reusing it", m.get("pid")) + return log_size(GFCLOUD_LOG) + offset = log_size(GFCLOUD_LOG) + with open(OFFLINE_MARKER, "w") as f: + f.write("forgetest %s\n" % ctx.test.id) + try: + if m.get("mode") == "cloud": + ctx.log("cloud mode: restarting the client offline (was pid %s)", m.get("pid")) + st, body = fc.post("/controller/stop") + ctx.check(st == 200, "controller stop refused: %s %s", st, body) + m = wait_mode(ctx, fc, "cloud", want_controller="standby", timeout=30) + ctx.check(m and m.get("controller") == "standby", "the cloud client did not stop: %s", m) + st, body = fc.post("/controller/start") + ctx.check(st == 200, "controller start refused: %s %s", st, body) + else: + ctx.log("%s mode: switching to cloud, offline", m.get("mode")) + st, body = fc.post("/mode", data={"controller": "cloud"}) + ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body) + m = wait_mode(ctx, fc, "cloud", timeout=90) + ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running", + "cloud controller did not come up: %s", m) + ctx.mode_changed("cloud") + got = wait_log(ctx, offset, [OFFLINE_MARK], 60) + ctx.check(got[OFFLINE_MARK], "the client did not come up as the offline service within 60 s") + ctx.log("offline service up (pid %s): %s", m.get("pid"), message(got[OFFLINE_MARK])) + finally: + offline_marker_off(ctx) + ctx.check(wait_quiet(ctx, offset), "the machine was not quiet %d s after the offline start", QUIET_TIMEOUT_S) + return log_size(GFCLOUD_LOG) + + +class Offline: + """The socket the offline service takes actions on: send the + service's messages, read the machine's events.""" + + def __init__(self, path=None): + self.path = path or OFFLINE_SOCKET + self.sock = None + self.buf = b"" + self.events = [] + + def __enter__(self): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(5) + self.sock.connect(self.path) + self.sock.settimeout(0.05) + return self + + def __exit__(self, *a): + if self.sock: + try: + self.sock.close() + except OSError: + pass + return False + + def send(self, msg): + self.sock.sendall((json.dumps(msg) + "\n").encode()) + + def poll(self): + """Collect whatever events have arrived; returns the new ones.""" + new = [] + try: + while True: + d = self.sock.recv(65536) + if not d: + break + self.buf += d + except (socket.timeout, OSError): + pass + while b"\n" in self.buf: + line, self.buf = self.buf.split(b"\n", 1) + if line.strip(): + try: + ev = json.loads(line.decode("utf-8", "replace")) + except ValueError: + ev = {"raw": line.decode("utf-8", "replace")} + self.events.append(ev) + new.append(ev) + return new + + def print_ready(self, action_id, path, settings=None): + self.send({"id": action_id, "action_type": "print", "status": "ready", + "motion_url": "file://" + path, "settings": settings or {}}) + + def cancel(self, action_id, action_type="print"): + self.send({"id": action_id, "action_type": action_type, "status": "cancelled"}) + + +def offline_events(off): + """Every event the offline service has handed back so far, by name.""" + off.poll() + return [e.get("event") for e in off.events if e.get("event")] + + +def offline_job(ctx, name, **kw): + """Write a synthesized, laser-never-commanded job for the offline + service and return its path (under JOB_DIR, tmpfs).""" + os.makedirs(JOB_DIR, exist_ok=True) + path = os.path.join(JOB_DIR, name) + n, seconds, compressed = puls.write_job(path, **kw) + ctx.log("job %s: %d payload bytes, %.0f s at STfr %d%s", name, n, seconds, + kw.get("step_hz", 10000), " (gzip)" if compressed else "") + ctx.evidence.setdefault("jobs", {})[name] = {"payload_bytes": n, "seconds": round(seconds, 1), + "compressed": compressed} + return path + + +def offline_start_print(ctx, off, action_id, path, offset, settings=None): + """Hand the offline service a print and take it to its run: the + client loads the job, lights the button and waits; the operator's + press arms it and the run starts. Returns the 'starting run' line.""" + off.print_ready(action_id, path, settings) + got = wait_log(ctx, offset, ["waiting for button"], 120) + ctx.check(got["waiting for button"], "the offline print never reached the button wait (refused, " + "or the job did not load)") + ctx.act("button", "press", text="The button is lit white: the press arms the print and the " + "head starts to move. Nothing fires: the job commands no laser.", + until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=180) + got = wait_print_running(ctx, offset, 10) + ctx.check(got, "the print did not start after the press") + return got + + def settle_cloud(ctx, offset): """End of a cloud test: the service's follow-up moves (a hunt after a print, the re-hunt after a lid close) done and the machine idle.""" @@ -614,6 +782,14 @@ def latch_locked(): APP_PRINT_CUE = ("In the Glowforge app: scrap on the bed, lid closed, a SMALL engrave or score job " "(about 30 s) set up. Click Done here, then press Print in the app and press the " "physical button when it lights white.") +OFFLINE_STEP = ("The machine in cloud mode under the OFFLINE service (the test starts it: no " + "account, no network, no job from the app; the job is a square the laser never " + "fires on). Nothing on the bed is needed. The client is left offline in cloud " + "mode; the next test that needs the service, a mode switch, or a controller " + "restart brings the service back.") +ARM_STEP = ("Press the physical button when it lights white (the arm) - once per print. The " + "latch unlocks for the run, so the test is a live one even though the job carries " + "no laser command.") CANCELLED = 'finished with event ":cancelled"' COMPLETED = 'finished with event ":completed"' @@ -658,13 +834,12 @@ def judge_abort_tail(ctx, ev, offset, tag, fin): subsystem="cloud", kind="live", est_min=11, covers=_CLOUD_COVERS + [("forgectrl", "src/super.c")], requires=["laser.emission-witness"], actions=["button", "lid", "interlock"], - steps=[CLOUD_STEP, LID_STEP, - "The app open in a browser; scrap on the bed and a small engrave/score job " - "ready - the test runs TWO prints.", + steps=[OFFLINE_STEP, ARM_STEP, LID_STEP, + "The head needs 40 mm of free +X and +Y travel. The test runs TWO prints.", "Be able to open the remote-interlock loop for the second print: unplug the Pro's " "interlock plug, or pull the jumper at J8 on a Basic/Plus. Restore it at the end.", - "Print 1: press the button to start, open the lid a few seconds in. Print 2: press the " - "button to start, open the interlock a few seconds in, then open the lid as well while " + "Print 1: press the button to start, open the lid when told. Print 2: press the " + "button to start, open the interlock when told, then open the lid as well while " "the head is parking."], description="Both enclosure triggers, on one setup, behaving as the factory's do. The lid: the " "edge reaches the controlled stop within milliseconds, the head returns home at " @@ -676,12 +851,18 @@ def lid_interlock_abort(ctx): ev = ctx.evidence sw = (ctx.forgectrl.status().get("switches") or {}) ctx.check(sw.get("interlock_ok"), "the interlock loop already reads open - close it before this test") - offset = enter_cloud(ctx) + offset = enter_offline(ctx) + job = offline_job(ctx, "abort.puls", seconds=40) + off = Offline().__enter__() + try: + lid_interlock_abort_body(ctx, ev, off, job, offset) + finally: + off.__exit__(None, None, None) + +def lid_interlock_abort_body(ctx, ev, off, job, offset): # -- print 1: the lid ---------------------------------------------------- - ctx.instruct(APP_PRINT_CUE) - got = wait_print_running(ctx, offset, 300) - ctx.check(got, "print 1 never reached its run within 300 s (not started, or the button not pressed)") + offline_start_print(ctx, off, 9001, job, offset) ctx.act("lid", "open", text="The head is moving: leave the lid open until the head has returned " "to the corner.", timeout=60) needles = ["lid opened", "lid opened mid-run; stopping motion", "start return home", @@ -714,12 +895,11 @@ def lid_interlock_abort(ctx): judge_abort_tail(ctx, ev, offset, "lid", fin) ctx.act("lid", "close") settle_cloud(ctx, offset) + ev["events_print1"] = offline_events(off) # -- print 2: the interlock, with the lid opened during the park --------- offset = log_size(GFCLOUD_LOG) - ctx.instruct(APP_PRINT_CUE) - got = wait_print_running(ctx, offset, 300) - ctx.check(got, "print 2 never reached its run within 300 s (not started, or the button not pressed)") + offline_start_print(ctx, off, 9002, job, offset) ctx.act("interlock", "open", text="The head is moving.", timeout=60) sw = (ctx.forgectrl.status().get("switches") or {}) ev["interlock_ok_after_pull"] = sw.get("interlock_ok") @@ -752,6 +932,7 @@ def lid_interlock_abort(ctx): ev["restored"] = {"lid": sw.get("lid"), "interlock_ok": sw.get("interlock_ok")} ctx.check(sw.get("interlock_ok"), "the interlock loop is still open - restore it before continuing") settle_cloud(ctx, offset) + ev["events_print2"] = offline_events(off) ctx.log("PASS: lid open -> stop in %s ms and park with the lid open; interlock open -> the same " "tail with the park running through a lid edge; both prints ':cancelled'", ev.get("edge_to_stop_ms")) @@ -760,18 +941,26 @@ def lid_interlock_abort(ctx): @test("cloud.lid-during-button-wait", title="Lid open at the cloud button prompt cancels the print", subsystem="cloud", kind="operator", est_min=6, covers=_CLOUD_COVERS, requires=[], actions=["lid"], - steps=[CLOUD_STEP, LID_STEP, "The app open; any small job ready (nothing will fire).", - "Print from the app; when the button lights white, do NOT press it - open the lid, and " - "close it when told."], + steps=[OFFLINE_STEP, LID_STEP, + "When the button lights white, do NOT press it - open the lid, and close it when told. " + "Nothing moves and nothing fires."], description="A cloud print waiting for the button is cancelled by the lid: the wait ends " "with the lid named as the reason, the laser latch relocks, the armed window " "closes, no run starts, and the job ends ':cancelled'.") def lid_during_button_wait(ctx): ev = ctx.evidence - offset = enter_cloud(ctx) - ctx.instruct("In the Glowforge app: lid closed, a small job set up. Click Done here, then press " - "Print in the app. When the button lights white, do NOT press it.") - got = wait_log(ctx, offset, ["waiting for button"], 300) + offset = enter_offline(ctx) + job = offline_job(ctx, "wait.puls", seconds=20) + off = Offline().__enter__() + try: + lid_during_button_wait_body(ctx, ev, off, job, offset) + finally: + off.__exit__(None, None, None) + + +def lid_during_button_wait_body(ctx, ev, off, job, offset): + off.print_ready(9003, job) + got = wait_log(ctx, offset, ["waiting for button"], 120) ctx.check(got["waiting for button"], "the print never reached the button wait") ctx.act("lid", "open", text="The button is lit: do NOT press it.", timeout=120) relock = "button wait lid opened - relocking the laser" @@ -803,6 +992,7 @@ def lid_during_button_wait(ctx): ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"]) ctx.act("lid", "close") settle_cloud(ctx, offset) + ev["events"] = offline_events(off) ctx.log("PASS: lid open at the button prompt cancelled the print; latch locked, armed=false, " "button dark") @@ -922,13 +1112,10 @@ def pause_resume(ctx): subsystem="cloud", kind="live", est_min=12, covers=_CLOUD_COVERS, requires=["cloud.lid-interlock-abort", "cloud.pause-resume"], actions=["button"], - steps=[CLOUD_STEP, - "Scrap on the bed and a LONG job ready in the app - one whose run time is longer " - "than the ring holds (over an hour at the usual print tick). A full-bed raster " - "engrave is the easy way to get one.", - "Print from the app and press the button when it lights. The test lets it cut for " - "about two minutes, then asks for a press (pause) and another (resume), then for the " - "cancel from the app."], + steps=[OFFLINE_STEP, ARM_STEP, + "The head needs 40 mm of free +X and +Y travel. The job is an hour of squares, " + "longer than the ring holds; the test lets it run for about two minutes, asks for a " + "press (pause) and another (resume), then cancels it the way the app would."], description="The service sends one pulse file for a print however long it is, and a long one " "is several times the ring: the machine holds the job in memory, fills the ring, " "starts, and tops the ring up as it drains. This checks the signature of that - " @@ -939,12 +1126,19 @@ def pause_resume(ctx): "cleanly.") def oversize_stream(ctx): ev = ctx.evidence - offset = enter_cloud(ctx) + offset = enter_offline(ctx) + job = offline_job(ctx, "long.puls", seconds=3500) before = hw.sysfs_int("cnc/underruns", 0) ev["underruns_before"] = before - ctx.instruct("Start the long print from the app and press the button when it lights.") - got = wait_print_running(ctx, offset, 600) - ctx.check(got, "the print never reached its run within 600 s") + off = Offline().__enter__() + try: + oversize_stream_body(ctx, ev, off, job, offset, before) + finally: + off.__exit__(None, None, None) + + +def oversize_stream_body(ctx, ev, off, job, offset, before): + offline_start_print(ctx, off, 9004, job, offset) # The load says so in as many words, and the device is in live-feed mode. got = wait_log(ctx, offset, ["job is longer than the ring"], 30) @@ -1011,13 +1205,11 @@ def oversize_stream(ctx): # place the service-side cancel is exercised: the same tail as a lid # or interlock abort - stop, park back to the job start, relock, # disarm, ':cancelled' - judged in full. - ctx.notice("Now cancel the print from the app. The test watches for the cancel.") + off.cancel(9004) + ctx.log("cancelled the print as the app would") svc_stop = "action cancelled mid-run; stopping motion" - try: - got = wait_log(ctx, offset, [svc_stop, "start return home", "return home complete"], 300) - fin = wait_action_finished(ctx, offset, "print", 60) - finally: - ctx.clear_notice() + got = wait_log(ctx, offset, [svc_stop, "start return home", "return home complete"], 300) + fin = wait_action_finished(ctx, offset, "print", 60) ev["service_cancel"] = {k: message(v) for k, v in got.items()} ev["print_finished"] = message(fin) for k, v in ev["service_cancel"].items(): @@ -1032,6 +1224,7 @@ def oversize_stream(ctx): ev["button_dark"] = hw.button_lit() ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"]) settle_cloud(ctx, offset) + ev["events"] = offline_events(off) ctx.log("PASS: a job longer than the ring ran live-fed, total grew, no underrun; the app's cancel " "stopped it, parked, relocked and reported ':cancelled'") @@ -1040,8 +1233,8 @@ def oversize_stream(ctx): subsystem="cloud", kind="live", est_min=6, covers=_CLOUD_COVERS, requires=["cloud.pause-resume", "cloud.lid-interlock-abort"], actions=["button", "lid"], - steps=[CLOUD_STEP, LID_STEP, - "The app open in a browser; scrap on the bed and a small engrave/score job ready.", + steps=[OFFLINE_STEP, ARM_STEP, LID_STEP, + "The head needs 40 mm of free +X and +Y travel.", "Press the button to start; when asked, press it again (pause), then open the lid and " "leave it open until the head is back; close it when told."], description="A job paused on the button is cancelled by the lid, from the state the factory " @@ -1052,12 +1245,18 @@ def oversize_stream(ctx): "ended that way: cloud.oversize-stream.)") def paused_lid_cancel(ctx): ev = ctx.evidence - offset = enter_cloud(ctx) + offset = enter_offline(ctx) + job = offline_job(ctx, "paused.puls", seconds=60) + off = Offline().__enter__() + try: + paused_lid_cancel_body(ctx, ev, off, job, offset) + finally: + off.__exit__(None, None, None) + +def paused_lid_cancel_body(ctx, ev, off, job, offset): # -- paused on the button, then the lid --------------------------------- - ctx.instruct(APP_PRINT_CUE) - got = wait_print_running(ctx, offset, 300) - ctx.check(got, "the print never reached its run within 300 s (not started, or the button not pressed)") + offline_start_print(ctx, off, 9005, job, offset) ctx.act("button", "press", text="The head is moving: the press pauses the print.", until=lambda: log_has(offset, PAUSE_LINES[0]), timeout=PRESS_TIMEOUT_S, fail=False) got = wait_log(ctx, offset, list(PAUSE_LINES), 10) @@ -1092,4 +1291,5 @@ def paused_lid_cancel(ctx): ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"]) ctx.act("lid", "close") settle_cloud(ctx, offset) + ev["events"] = offline_events(off) ctx.log("PASS: a paused print cancelled by the lid stopped, parked, relocked and reported ':cancelled'") diff --git a/forgetest/tests/test_cloud_suite.py b/forgetest/tests/test_cloud_suite.py index d5f2c9e..52baa84 100644 --- a/forgetest/tests/test_cloud_suite.py +++ b/forgetest/tests/test_cloud_suite.py @@ -103,6 +103,42 @@ class Script: time.sleep(0.02) +OFFLINE_LINE = ("2026-08-22T23:00:00.100000+00:00 gfcloud[3100] INFO offline:open OFFLINE service: " + "no web session; listening on /run/gfcloud-offline.sock") + + +class FakeOffline: + """The offline service's socket, as the tests see it: what was sent, + and a hook that lands the machine's lines for each message.""" + on_send = None + sent = [] + + def __init__(self, path=None): + self.events = [] + self.buf = b"" + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def send(self, msg): + FakeOffline.sent.append(msg) + if FakeOffline.on_send: + FakeOffline.on_send(msg) + + def poll(self): + return [] + + def print_ready(self, action_id, path, settings=None): + self.send({"id": action_id, "action_type": "print", "status": "ready", + "motion_url": "file://" + path, "settings": settings or {}}) + + def cancel(self, action_id, action_type="print"): + self.send({"id": action_id, "action_type": action_type, "status": "cancelled"}) + + class CloudSuiteTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.mkdtemp(prefix="forgetest-cloud-") @@ -126,10 +162,16 @@ class CloudSuiteTests(unittest.TestCase): self.homelog = os.path.join(self.tmp, "gfhome.log") open(self.homelog, "wb").close() self.saved = (cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S, - cloud.HUNT_TIMEOUT_S, cloud.GFHOME_LOG) + cloud.HUNT_TIMEOUT_S, cloud.GFHOME_LOG, cloud.Offline, cloud.OFFLINE_MARKER, + cloud.JOB_DIR) cloud.GFCLOUD_LOG = self.log cloud.FORGECTRL_LOG = self.fclog cloud.GFHOME_LOG = self.homelog + cloud.Offline = FakeOffline + cloud.OFFLINE_MARKER = os.path.join(self.tmp, "offline-marker") + cloud.JOB_DIR = os.path.join(self.tmp, "jobs") + FakeOffline.sent = [] + FakeOffline.on_send = None self.engine_line = EFFECTIVE_LINE # what the engine logs at the print; None = nothing self.client_limits = True # the client names its header limits cloud.QUIET_S = 0.4 @@ -144,7 +186,8 @@ class CloudSuiteTests(unittest.TestCase): if self.grbl: self.grbl.stop() (cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S, - cloud.HUNT_TIMEOUT_S, cloud.GFHOME_LOG) = self.saved + cloud.HUNT_TIMEOUT_S, cloud.GFHOME_LOG, cloud.Offline, cloud.OFFLINE_MARKER, + cloud.JOB_DIR) = self.saved os.environ.pop("GF_SYSFS_ROOT", None) os.environ.pop("GF_LEDS_ROOT", None) shutil.rmtree(self.tmp, ignore_errors=True) @@ -176,6 +219,23 @@ class CloudSuiteTests(unittest.TestCase): def lid(self, closed): self.fc.state["status"]["switches"]["lid"] = bool(closed) + def in_offline(self, pid=3100): + """Cloud mode with the offline service already up (its mark is the + newest websocket-state line for the pid).""" + self.in_cloud(pid=pid) + self.append([OFFLINE_LINE.replace("gfcloud[3100]", "gfcloud[%d]" % pid)]) + + def offline_print_hooks(self, pre, cancel_tail=None): + """The fake socket's reactions: a print lands its prologue through + the run (the operator's press is already in it), a cancel lands the + app-cancel tail.""" + def on_send(msg): + if msg["action_type"] == "print" and msg["status"] == "ready": + self.append(pre, delay=0.1) + elif msg["status"] == "cancelled" and cancel_tail: + self.append(cancel_tail, delay=0.05) + FakeOffline.on_send = on_send + def run_test(self, fn, hooks=None, test_id="cloud.x"): run = Run("test", test_id, test_id) run.baseline_captured = {"mode": self.fc.state["mode"]["mode"], "position": [0, 0, 0]} @@ -194,6 +254,96 @@ class CloudSuiteTests(unittest.TestCase): return cm.exception # -- session detection ------------------------------------------------- + def test_offline_mark_is_not_a_live_session(self): + self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[3100] INFO websocket:_on_open RX-EVENT: ready", + OFFLINE_LINE]) + live, detail = cloud.session_live(3100) + self.assertFalse(live) + self.assertIn("offline", detail) + self.assertTrue(cloud.client_offline(3100)) + + # -- the offline entry ---------------------------------------------------------- + def test_enter_offline_reuses_a_running_offline_client(self): + self.in_offline() + run = Run("test", "cloud.x", "cloud.x") + run.baseline_captured = {"mode": "cloud", "position": [0, 0, 0]} + ctx = Context(run, None, helpers.make_test("cloud.x", [])) + cloud.enter_offline(ctx) + self.assertEqual(self.fc.posts, []) + self.assertFalse(os.path.exists(cloud.OFFLINE_MARKER)) + + def test_enter_offline_from_grbl_sets_the_marker_for_one_start_and_declares_the_mode(self): + seen = {} + + def on_post(path, form): + if path == "/mode": + seen["marker_at_start"] = os.path.exists(cloud.OFFLINE_MARKER) + self.append([OFFLINE_LINE], delay=0.2) + return None + self.fc.on_post = on_post + run = Run("test", "cloud.x", "cloud.x") + run.baseline_captured = {"mode": "grbl", "position": [0, 0, 0]} + ctx = Context(run, None, helpers.make_test("cloud.x", [])) + cloud.enter_offline(ctx) + self.assertEqual([p for p, _ in self.fc.posts], ["/mode"]) + self.assertTrue(seen["marker_at_start"]) # the client started offline + self.assertFalse(os.path.exists(cloud.OFFLINE_MARKER)) # and the marker is gone again + self.assertEqual(run.baseline_captured["mode"], "cloud") + self.assertTrue(any("offline service up" in l for l in run.lines)) + + def test_enter_offline_restarts_a_service_client_in_cloud_mode(self): + self.in_cloud(pid=2278) + + def on_post(path, form): + if path == "/controller/stop": + self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0) + elif path == "/controller/start": + self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=3100) + self.append([OFFLINE_LINE], delay=0.2) + return None + self.fc.on_post = on_post + run = Run("test", "cloud.x", "cloud.x") + run.baseline_captured = {"mode": "cloud", "position": [0, 0, 0]} + ctx = Context(run, None, helpers.make_test("cloud.x", [])) + cloud.enter_offline(ctx) + self.assertEqual([p for p, _ in self.fc.posts], ["/controller/stop", "/controller/start"]) + + def test_enter_cloud_restarts_an_offline_client_with_the_service(self): + self.in_offline() + with open(cloud.OFFLINE_MARKER, "w") as f: + f.write("stale\n") + lines = fixture("huntlid") + pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED") + hunt_part, _close = cut(post, "_switch_event lid closed") + + def on_post(path, form): + if path == "/controller/stop": + self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0) + elif path == "/controller/start": + self.assertFalse(os.path.exists(cloud.OFFLINE_MARKER)) # taken down before the start + self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=2278) + self.append(pre + hunt_part, delay=0.2) + return None + self.fc.on_post = on_post + run = Run("test", "cloud.x", "cloud.x") + run.baseline_captured = {"mode": "cloud", "position": [0, 0, 0]} + ctx = Context(run, None, helpers.make_test("cloud.x", [])) + cloud.enter_cloud(ctx) + self.assertEqual([p for p, _ in self.fc.posts], ["/controller/stop", "/controller/start"]) + self.assertTrue(any("offline service: restarting it with the service" in l for l in run.lines)) + + def test_offline_jobs_are_the_synthesized_square(self): + from forgetest import puls + run = Run("test", "cloud.x", "cloud.x") + ctx = Context(run, None, helpers.make_test("cloud.x", [])) + path = cloud.offline_job(ctx, "t.puls", seconds=5) + tags, payload = puls.parse(open(path, "rb").read()) + self.assertEqual(tags["MCsn"], 0) + self.assertEqual(tags["STfr"], 10000) + self.assertFalse(any(b & 0x10 for b in payload if not b & 0x80)) # no LASER bit anywhere + self.assertEqual(payload[0], 0x80) # a leading power byte of zero + self.assertEqual(run.evidence["jobs"]["t.puls"]["compressed"], False) + def test_session_live_is_per_client(self): lines = fixture("huntlid") # the old client (1927) closed and left; the new one (2278) is ready @@ -499,9 +649,11 @@ class CloudSuiteTests(unittest.TestCase): return pre, rest, stop + [tail[0]], tail[1:] def abort_hooks(self, pre, lid_tail, ilk_stop, ilk_park, prints): - def next_print(): - prints.append(1) - self.append(pre, delay=0.1) + def on_send(msg): + if msg["status"] == "ready": + prints.append(msg["id"]) + self.append(pre, delay=0.1) + FakeOffline.on_send = on_send def pull_interlock(): self.fc.state["status"]["switches"]["interlock_ok"] = False @@ -510,8 +662,7 @@ class CloudSuiteTests(unittest.TestCase): def restore(): self.lid(True) self.fc.state["status"]["switches"]["interlock_ok"] = True - return {"Click Done here": next_print, - "leave the lid open until the head has returned": lambda: (self.lid(False), + return {"leave the lid open until the head has returned": lambda: (self.lid(False), self.append(lid_tail, delay=0.05)), "Close the lid.": lambda: self.lid(True), "Open the remote-interlock loop": pull_interlock, @@ -519,14 +670,15 @@ class CloudSuiteTests(unittest.TestCase): "Restore the remote-interlock loop": restore} def test_lid_interlock_abort_on_the_bench_excerpt(self): - self.in_cloud(pid=1522) - self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"]) + self.in_offline() prints = [] run = self.run_test(cloud.lid_interlock_abort, hooks=self.abort_hooks(*self.abort_parts(), prints), test_id="cloud.lid-interlock-abort") ev = run.evidence - self.assertEqual(len(prints), 2) # two prints, one cue each + self.assertEqual(prints, [9001, 9002]) # two prints, handed over the socket + self.assertTrue(FakeOffline.sent[0]["motion_url"].endswith("abort.puls")) + self.assertEqual(ev["jobs"]["abort.puls"]["compressed"], False) # print 1: the lid self.assertLess(ev["edge_to_stop_ms"], 60) self.assertIn(":cancelled", ev["lid_log"]["print finished"]) @@ -545,14 +697,13 @@ class CloudSuiteTests(unittest.TestCase): self.assertTrue(any("PASS: lid open" in l for l in run.lines), run.lines) def test_lid_interlock_abort_refuses_when_the_loop_is_already_open(self): - self.in_cloud(pid=1522) + self.in_offline() self.fc.state["status"]["switches"]["interlock_ok"] = False self.assertFails(cloud.lid_interlock_abort, "already reads open") def test_lid_interlock_abort_fails_when_the_park_stops_at_the_lid(self): # the regression this guards: a park an open lid can interrupt - self.in_cloud(pid=1522) - self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"]) + self.in_offline() pre, lid_tail, ilk_stop, ilk_park = self.abort_parts() ilk_park = [l for l in ilk_park if "return home complete" not in l] saved = cloud.wait_log @@ -568,8 +719,7 @@ class CloudSuiteTests(unittest.TestCase): def test_lid_interlock_abort_fails_when_the_lid_stop_is_not_edge_driven(self): # a polled stop (the pre-parity behavior) shows up as a long edge->stop gap - self.in_cloud(pid=1522) - self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"]) + self.in_offline() pre, lid_tail, ilk_stop, ilk_park = self.abort_parts() lid_tail = [l.replace("2026-08-17T09:42:30.838627", "2026-08-17T09:42:31.838627") if "lid opened mid-run; stopping motion" in l else l for l in lid_tail] @@ -577,16 +727,16 @@ class CloudSuiteTests(unittest.TestCase): hooks=self.abort_hooks(pre, lid_tail, ilk_stop, ilk_park, [])) def test_lid_during_button_wait_on_the_bench_excerpt(self): - self.in_cloud(pid=1927) - self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1927] INFO websocket:_on_open RX-EVENT: ready"]) + self.in_offline() lines = fixture("buttonwait") pre, rest = cut(lines, "waiting for button") pre, rest = pre + [rest[0]], rest[1:] - hooks = {"Click Done here": lambda: self.append(pre, delay=0.1), - "do NOT press it": lambda: (self.lid(False), self.append(rest, delay=0.05)), + self.offline_print_hooks(pre) + hooks = {"do NOT press it": lambda: (self.lid(False), self.append(rest, delay=0.05)), "Close the lid.": lambda: self.lid(True)} run = self.run_test(cloud.lid_during_button_wait, hooks=hooks, test_id="cloud.lid-during-button-wait") ev = run.evidence + self.assertEqual([m["status"] for m in FakeOffline.sent], ["ready"]) self.assertEqual(ev["runs_started_after_wait"], 0) self.assertIn(":cancelled", ev["log"]["print finished"]) self.assertTrue(ev["latch_locked"]) @@ -611,23 +761,17 @@ class CloudSuiteTests(unittest.TestCase): return pre, paused, rest, app_cancel def test_paused_lid_cancel_on_the_bench_excerpt(self): - self.in_cloud(pid=1522) - self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"]) + self.in_offline() pre, paused, lid_tail, _app = self.cancel_parts() - prints = [] - - def next_print(): - prints.append(1) - self.append(pre, delay=0.1) + self.offline_print_hooks(pre) run = self.run_test(cloud.paused_lid_cancel, - hooks={"Click Done here": next_print, - "the press pauses the print": lambda: self.append(paused, delay=0.05), + hooks={"the press pauses the print": lambda: self.append(paused, delay=0.05), "The print is paused: leave the lid open": lambda: ( self.lid(False), self.append(lid_tail, delay=0.05)), "Close the lid.": lambda: self.lid(True)}, test_id="cloud.paused-lid-cancel") ev = run.evidence - self.assertEqual(len(prints), 1) # one print, one cue + self.assertEqual(len(FakeOffline.sent), 1) # one print, handed over the socket self.assertEqual(ev["paused"], {"button pressed mid-run; pausing": True, "paused at": True}) self.assertIn(":cancelled", ev["lid_from_pause"]["print finished"]) self.assertTrue(ev["lid_from_pause"]["return home complete"]) @@ -640,14 +784,13 @@ class CloudSuiteTests(unittest.TestCase): def test_paused_lid_cancel_fails_when_the_paused_print_resumes_instead(self): # a lid that resumed (or was ignored) leaves the print ':completed' - self.in_cloud(pid=1522) - self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"]) + self.in_offline() pre, paused, lid_tail, _app = self.cancel_parts() lid_tail = [l.replace(':cancelled"', ':completed"') for l in lid_tail] + self.offline_print_hooks(pre) self.assertFails( cloud.paused_lid_cancel, "the print did not end ':cancelled'", - hooks={"Click Done here": lambda: self.append(pre, delay=0.1), - "the press pauses the print": lambda: self.append(paused, delay=0.05), + hooks={"the press pauses the print": lambda: self.append(paused, delay=0.05), "The print is paused: leave the lid open": lambda: ( self.lid(False), self.append(lid_tail, delay=0.05))})