cloud.pause-resume reads the job's limits through; pin the pass-through

The pulse header's envelope now reaches the cooling engine: the cloud
client (python3-gfhardware c34faa1) derives the coolant window and the
fans' minimum speeds from the header and rides them on every report,
and the engine (forgectrl 57f6064) resolves each as the stricter of its
setting and the job's, never looser and never overruling an off gate.
cloud.pause-resume, which runs a real print, now also reads the client's
"job limits from the header:" line and the engine's "effective limits:"
line from the two logs; two host cases cover the failure paths. The
needle guard lists the engine's phrases as not the app's.

COOLING.md section 2 explains what a cloud job brings with it; BRINGUP
item 19 records the pass-through as landed. Pins: forgectrl 57f6064,
forgefirm-app c34faa1 (0.1.14+git); fetch-verified.
This commit is contained in:
ScottW514
2026-08-21 18:07:41 -04:00
parent 8ad3f79e26
commit d530f773e2
7 changed files with 98 additions and 13 deletions
+10 -2
View File
@@ -1325,8 +1325,16 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
flow window and the flow rise (`forgectrl/src/gates.c`, `SERVICES.md`
"Gate settings", `COOLING.md` §8a, `cooling.gate-off` in the catalog,
bench PASS 2026-08-21 on dev image `20260821210903`).
The fan gates, the pass-through of header limits, the coolant critical
tier and the watch-only board temperatures follow on it.
**The pass-through is in:** the cloud client derives the job's limits
from the header (`CMrx`/`CMrn` as degrees, `EFrx`/`IFrx`/`AArx` as the
minimum speeds their maximum periods mean) and rides them on every
`/cool/state`; the engine resolves each as the stricter of local and
header, never loosening and never overruling an off gate, logs the
effective set and publishes it in `/cool/status`; the coolant ceiling
is the live consumer, the floors wait for the fan gates
(`cloud.pause-resume` checks both log lines on a real print). The fan
gates, the coolant critical tier and the watch-only board temperatures
follow.
Nothing here can put energy where it was not commanded: the hardware chain
is the emission boundary and no header field touches it, and forgectrl runs
+13
View File
@@ -76,6 +76,19 @@ a program is playing, the engine additionally stops motion and locks the laser
latch itself. It also refuses to let exhaust and intake drop below cooldown
duty while a program is still running.
**A cloud job brings its own envelope.** The pulse file the Glowforge service
sends opens with the job's operating limits, and the cloud client hands the
ones the engine has a use for along with every report: the coolant window
and the fans' minimum speeds. The engine takes each only where it is
stricter than the setting on the Machine tab: a ceiling can only come down
for a job, a floor can only go up, a looser value is noted in the log and
ignored, and a gate you turned off (§8a) stays off whatever the job says.
The coolant ceiling is the one limit a job can tighten today (the service
sends 33 °C on a cut, which is also the shipped default); the fan floors
are carried and logged ahead of the airflow gates. The effective set shows
in the log as `effective limits:` and in `/cool/status` as `limits`. A GRBL
job has no header and runs on the settings alone.
**If a diagnostic takes the hardware over** (§6), the engine suspends its own
writes and publishes fire-blocked until the diagnostic finishes.
+23 -1
View File
@@ -15,6 +15,11 @@ _CLOUD_COVERS = [("forgefirm-app", "**"), ("python3-gfhardware", "**"), ("python
GF_LATEST = "/data/forgefirm/gf-latest.json"
GFCLOUD_LOG = "/data/log/forgefirm/gfcloud/gfcloud.log"
FORGECTRL_LOG = "/data/log/forgefirm/forgectrl/forgectrl.log"
# The client names the limits it derived from the pulse header once per
# job; the engine names the effective set whenever it changes.
LIMITS_MARK = "job limits from the header: "
EFFECTIVE_MARK = "effective limits: coolant ceiling "
SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
@@ -761,6 +766,7 @@ def hunt_lid_open(ctx):
def pause_resume(ctx):
ev = ctx.evidence
offset = enter_cloud(ctx)
fc_offset = log_size(FORGECTRL_LOG)
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)")
@@ -825,9 +831,25 @@ def pause_resume(ctx):
ctx.confirm("Did the app show the print's progress advancing while it cut, rather than "
"standing still or jumping straight to nearly finished?")
# The job's envelope, from the same print: the client derives the
# limits the header carries (a cut job carries the coolant window and
# the air-assist tach maximum) and hands them to the engine with every
# report, and the engine names the effective set it is running on,
# with the header's ceiling beside its own.
limits = next((ln.split(LIMITS_MARK, 1)[1].strip() for ln in lines if LIMITS_MARK in ln), None)
ev["header_limits"] = limits
ctx.log("job limits from the header: %s", limits)
ctx.check(limits is not None, "the client named no job limits from the header")
ctx.check("coolant_max_c=" in limits, "the header's coolant ceiling did not reach the engine: %s", limits)
eff = [ln.strip()[:200] for ln in log_lines_since(FORGECTRL_LOG, fc_offset) if EFFECTIVE_MARK in ln]
ev["effective_limits"] = eff[-3:]
ctx.log("engine effective limits: %s", eff[-1] if eff else None)
ctx.check(any("header " in ln and "header none" not in ln for ln in eff),
"the engine never resolved an effective ceiling against the header's: %s", eff[-2:])
settle_cloud(ctx, offset)
ctx.log("PASS: button pause/resume mid-print, warm-up and rest observed, progress reported, "
"job completed and parked")
"the job's limits passed through, job completed and parked")
@test("cloud.oversize-stream", title="A print longer than the ring is fed while it plays",
+4 -3
View File
@@ -35,9 +35,10 @@ SIBLINGS = {"python3-gfhardware": "python3-gfhardware", "python3-gfutilities": "
BUILT_PHRASES = ("warm up: holding", "cool down: holding", "warm up: skipped", "cool down: skipped",
'finished with event ":cancelled"', 'finished with event ":completed"',
"motion [", "print [")
# Phrases that are not the app's: forgetest's own log, or a line prefix
# of the log format rather than a message.
NOT_APP = ("PASS", "gfcloud", "[")
# Phrases that are not the app's: forgetest's own log, a line prefix of
# the log format rather than a message, or a line of forgectrl's log (the
# engine's effective-limits line, judged from its own file).
NOT_APP = ("PASS", "gfcloud", "[", "effective limits: coolant ceiling", "header")
PLACEHOLDER = re.compile(r"%(?:\([^)]*\))?[-+ #0]*\d*(?:\.\d+)?[sdifrxXeEgGcu]|%%")
+45 -4
View File
@@ -40,6 +40,13 @@ COOL_DOWN_LINE = ("2026-08-17T09:45:31.700000+00:00 gfcloud[1522] INFO "
"machine:_dwell cool down: holding 10.0 s")
PROGRESS_LINE = ("2026-08-17T09:44:05.200000+00:00 gfcloud[1522] INFO "
"machine:__init__ print:progress: reporting against 47848 bytes every 30 s")
LIMITS_LINE = ("2026-08-17T09:44:01.900000+00:00 gfcloud[1522] INFO "
"machine:_motion job limits from the header: air_assist_min_rpm=116 "
"coolant_max_c=33.0 coolant_min_c=5.0")
# What the engine logs when the job's limits reach it (forgectrl's log).
EFFECTIVE_LINE = ("2026-08-17T09:44:02.050000+00:00 forgectrl[410] INFO cool: effective limits: "
"coolant ceiling 33.0 C (local 33.0, header 33.0) resume 31.0 C; floors coolant "
"5.0 C, exhaust 0 rpm, intake 0 rpm, air assist 116 rpm (from the header, no gate yet)")
def cut(lines, marker, count=1):
@@ -94,8 +101,14 @@ class CloudSuiteTests(unittest.TestCase):
self._pos(0, 0, 3)
os.environ["GF_SYSFS_ROOT"] = self.sysfs
self.fc = helpers.FakeForgectrl().start()
self.saved = (cloud.GFCLOUD_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S, cloud.HUNT_TIMEOUT_S)
self.fclog = os.path.join(self.tmp, "forgectrl.log")
open(self.fclog, "wb").close()
self.saved = (cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S,
cloud.HUNT_TIMEOUT_S)
cloud.GFCLOUD_LOG = self.log
cloud.FORGECTRL_LOG = self.fclog
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
cloud.QUIET_TIMEOUT_S = 3
cloud.HUNT_TIMEOUT_S = 8
@@ -105,7 +118,8 @@ class CloudSuiteTests(unittest.TestCase):
if self.script:
self.script.stop = True
self.fc.stop()
cloud.GFCLOUD_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S, cloud.HUNT_TIMEOUT_S = self.saved
(cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S,
cloud.HUNT_TIMEOUT_S) = self.saved
os.environ.pop("GF_SYSFS_ROOT", None)
shutil.rmtree(self.tmp, ignore_errors=True)
@@ -295,11 +309,17 @@ class CloudSuiteTests(unittest.TestCase):
# a rest, and before it reported a print's progress; the replay
# carries those lines where it emits them now, rather than editing
# what the machine actually said that day.
run_pre = run_pre + [WARM_UP_LINE, PROGRESS_LINE]
run_pre = run_pre + ([LIMITS_LINE] if self.client_limits else []) + [WARM_UP_LINE, PROGRESS_LINE]
pre, rest = pre + run_pre + [rest[0]], rest[1:]
mid, tail = cut(rest, at_end)
tail = tail + [COOL_DOWN_LINE]
return {"Click Done here": lambda: self.append(pre, delay=0.1),
def at_done():
self.append(pre, delay=0.1)
if self.engine_line:
with open(self.fclog, "ab") as f:
f.write((self.engine_line + "\n").encode())
return {"Click Done here": at_done,
at_run: lambda: (self.append(mid, delay=0.05), self.append(tail, delay=tail_delay))}
def test_pause_resume_passes_on_the_machines_lines(self):
@@ -321,6 +341,27 @@ class CloudSuiteTests(unittest.TestCase):
self.assertTrue(any("PASS: button pause/resume" in l for l in run.lines))
# the post-print hunt was waited out
self.assertTrue(any("machine is quiet" in l for l in run.lines))
# the job's envelope passed through: the client's line and the engine's
self.assertEqual(ev["header_limits"],
"air_assist_min_rpm=116 coolant_max_c=33.0 coolant_min_c=5.0")
self.assertEqual(len(ev["effective_limits"]), 1)
self.assertIn("header 33.0", ev["effective_limits"][0])
def test_pause_resume_fails_when_the_client_names_no_header_limits(self):
self.client_limits = False
self.in_cloud(pid=1522)
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
hooks = self.replay_print("pause", "Press the button once NOW", "current state: MachineState.IDLE")
self.fc.state["cool"]["armed"] = True
self.assertFails(cloud.pause_resume, "named no job limits", hooks=hooks)
def test_pause_resume_fails_when_the_engine_resolves_against_no_header(self):
self.engine_line = EFFECTIVE_LINE.replace("header 33.0", "header none 0.0")
self.in_cloud(pid=1522)
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
hooks = self.replay_print("pause", "Press the button once NOW", "current state: MachineState.IDLE")
self.fc.state["cool"]["armed"] = True
self.assertFails(cloud.pause_resume, "never resolved an effective ceiling", hooks=hooks)
def test_pause_resume_fails_when_the_print_is_cancelled_instead(self):
self.in_cloud(pid=1522)
@@ -2,5 +2,5 @@
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "9e44fdc8b8ac389164f9b79e967ebdc978a476c4"
SRCREV = "57f6064214939422eec51f6044b5868dc839a291"
PV = "0.1.0"
@@ -4,9 +4,9 @@
# because the component entry already identifies the pinned source
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
# meta-glowforge-bsp pins the same repository; move both together.
SRCREV = "b82d37984b16ee426f24eaf980346b3692e9af5d"
SRCREV = "c34faa1402d09c888ab8f7eccdf26dd8ec4be405"
# Bump PV with every SRCREV move: the hash-derived package version is not
# monotonic on its own and buildhistory QA fails the build when it sorts
# backwards.
PV = "0.1.13+git"
PV = "0.1.14+git"