mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
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.
This commit is contained in:
@@ -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**
|
||||
|
||||
+12
-6
@@ -588,9 +588,12 @@ until `releases/v<version>/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
|
||||
|
||||
@@ -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("<I", int(v)) for k, v in sorted(h.items()))
|
||||
total = 8 + len(recs)
|
||||
return bytes([POWER]) + b"GF1" + struct.pack("<I", total) + recs
|
||||
|
||||
|
||||
def leg(dx_steps, dy_steps, period):
|
||||
"""One straight leg: Bresenham over the major axis, one step every
|
||||
`period` ticks, laser never commanded. X: DIR set = negative; Y: DIR
|
||||
set = positive (the hardware convention)."""
|
||||
ax, ay = abs(dx_steps), abs(dy_steps)
|
||||
major = max(ax, ay)
|
||||
if major == 0:
|
||||
return b""
|
||||
xbit = X_STEP | (X_DIR if dx_steps < 0 else 0)
|
||||
ybit = Y_STEP | (Y_DIR if dy_steps > 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("<I", data, 4)[0]
|
||||
tags = {data[p:p + 4].decode("ascii"): struct.unpack_from("<I", data, p + 4)[0]
|
||||
for p in range(8, total - 7, 8)}
|
||||
return tags, data[total:]
|
||||
@@ -5,10 +5,12 @@ the web-service homing ($H) on the way back; the job-behavior tests run in
|
||||
cloud mode and leave the machine there (see enter_cloud)."""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
from .. import puls
|
||||
from ..baseline import read_position, read_program_total
|
||||
|
||||
_CLOUD_COVERS = [("forgefirm-app", "**"), ("python3-gfhardware", "**"), ("python3-gfutilities", "**"),
|
||||
@@ -328,7 +330,14 @@ def mode_switch(ctx):
|
||||
# lid close, the hunt after a print) to finish before it ends, so the next
|
||||
# run - or the operator - gets a quiet machine.
|
||||
|
||||
WS_MARKS = ("RX-EVENT: ready", "RX-EVENT: closed", "RECONNECTING", "CLOSING")
|
||||
# The offline service (gfcloud --offline, or the marker file at start):
|
||||
# the machine driven from a local socket, no web session at all. Its log
|
||||
# mark counts among the websocket state marks as "not connected".
|
||||
OFFLINE_MARK = "OFFLINE service"
|
||||
OFFLINE_MARKER = "/run/gfcloud-offline"
|
||||
OFFLINE_SOCKET = "/run/gfcloud-offline.sock"
|
||||
JOB_DIR = "/tmp/forgetest" # the jobs the offline tests write (tmpfs)
|
||||
WS_MARKS = ("RX-EVENT: ready", "RX-EVENT: closed", "RECONNECTING", "CLOSING", OFFLINE_MARK)
|
||||
ACTIVITY_MARKS = ("start motion", "start return home", "starting run", "starting z homing cycle")
|
||||
LOG_TAIL_BYTES = 4 << 20
|
||||
QUIET_S = 8 # the re-hunt's motions are ~4 s apart (a lid image between them)
|
||||
@@ -484,9 +493,17 @@ def session_live(pid):
|
||||
last_pid = m
|
||||
last = last_pid if last_pid is not None else last_any
|
||||
where = "pid %s" % pid if last_pid is not None else "newest lines"
|
||||
if last == OFFLINE_MARK:
|
||||
return False, "%s: offline service, no web session" % where
|
||||
return last == "RX-EVENT: ready", "%s: last websocket state %s" % (where, last)
|
||||
|
||||
|
||||
def client_offline(pid):
|
||||
"""The running cloud client is the offline service."""
|
||||
live, detail = session_live(pid)
|
||||
return (not live) and "offline" in detail
|
||||
|
||||
|
||||
def wait_quiet(ctx, offset, quiet_s=None, timeout=None):
|
||||
"""The service's moves are over: the machine idle and no new service
|
||||
activity in the log (a motion, a park, a run, a lens homing) for
|
||||
@@ -564,13 +581,19 @@ def enter_cloud(ctx):
|
||||
fc = ctx.forgectrl
|
||||
st, m = fc.get("/mode")
|
||||
ctx.check(st == 200 and isinstance(m, dict), "GET /mode -> %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'")
|
||||
|
||||
@@ -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))})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user