diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 4a0a961..d837636 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -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 diff --git a/docs/COOLING.md b/docs/COOLING.md index b7aec40..0d8c228 100644 --- a/docs/COOLING.md +++ b/docs/COOLING.md @@ -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. diff --git a/forgetest/forgetest/suite/cloud.py b/forgetest/forgetest/suite/cloud.py index 409e3b2..0286d70 100644 --- a/forgetest/forgetest/suite/cloud.py +++ b/forgetest/forgetest/suite/cloud.py @@ -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", diff --git a/forgetest/tests/test_cloud_needles.py b/forgetest/tests/test_cloud_needles.py index bdff3b8..e48bf71 100644 --- a/forgetest/tests/test_cloud_needles.py +++ b/forgetest/tests/test_cloud_needles.py @@ -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]|%%") diff --git a/forgetest/tests/test_cloud_suite.py b/forgetest/tests/test_cloud_suite.py index 30111e7..1414cbc 100644 --- a/forgetest/tests/test_cloud_suite.py +++ b/forgetest/tests/test_cloud_suite.py @@ -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) diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc index b082da2..a58ae4b 100644 --- a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc @@ -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" diff --git a/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app-pin.inc b/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app-pin.inc index 85bf286..c644d4e 100644 --- a/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app-pin.inc +++ b/meta-forgefirm/recipes-forgefirm/forgefirm-app/forgefirm-app-pin.inc @@ -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"