cooling.gate-off: a gate setting trips in range and is off at its far end

The acceptance catalog gains the test behind forgectrl 9e44fdc: the
coolant ceiling set just over its legal minimum must trip OVERTEMP with
a hold and fire blocked at the next run start; set to its top the engine
must skip the gate (verdict OK), report it in gates_off on /status and
/cool/status, and log the run-start line; the original values are
restored, on failure too, and proven restored. Five host cases against a
scripted engine. The cooling covers map now names the files that carry
gate state (gates, settings, status, the panel) and corrects a glob that
matched nothing: the GRBL cooling client is src/glowforge_cooling.c, not
src/gfcool*. The fake forgectrl serves /logs/tail and keeps blank form
values as "clear", which is what the daemon does with them.

Docs: COOLING.md section 8 carries each setting's legal range and
recommended band and a new 8a on turning a gate off; SAFETY.md names
what no setting can reach; ACCEPTANCE.md records that gates are
exercised through the settings API, never GFCOOL_* env overrides;
BRINGUP item 19 records the pattern as landed and the catalog is 43.

forgectrl pin moves to 9e44fdc; fetch-verified.
This commit is contained in:
ScottW514
2026-08-21 17:06:49 -04:00
parent f4b70a0826
commit bf3483e994
8 changed files with 344 additions and 26 deletions
+144 -3
View File
@@ -1,13 +1,19 @@
"""cooling.* - the cooling engine: flow verification through forgectrl's
diagnostics runner (the same check the fire gate runs), and the fan
profile returning to idle after motion."""
diagnostics runner (the same check the fire gate runs), the fan profile
returning to idle after motion, and the gate settings: a value inside
the legal range trips the gate, the far end of the range turns it off
by value, and both are said out loud (the settings reply, /status, the
engine's run-start log line)."""
import time
from ..catalog import test
from .. import hw
_COOL_COVERS = [("forgectrl", "src/cool.*"), ("forgectrl", "src/diag.*"),
("grblhal-glowforge", "src/gfcool*"), ("kernel-module-glowforge", "src/thermal*"),
("forgectrl", "src/gates.*"), ("forgectrl", "src/settings.*"),
("forgectrl", "src/status.*"), ("forgectrl", "src/ui/**"),
("grblhal-glowforge", "src/glowforge_cooling.*"),
("kernel-module-glowforge", "src/thermal*"),
("kernel-module-glowforge", "src/pic*")]
@@ -168,3 +174,138 @@ def fans_quiet(ctx):
ctx.log("fans after: %s duty %s (settled in %s s)", ev["after"], ev["duty_after"], ev["settle_s"])
ctx.check(settle is not None, "fans did not return to the idle profile within %d s: %s, duty %s, "
"phase %s (idle reference %s)", COOLDOWN_TIMEOUT_S, ev["after"], ev["duty_after"], phase(), before)
GATE_KEYS = ("cool_temp_max", "cool_temp_resume")
VERDICT_WAIT_S = 20 # the engine reloads settings at run start and ticks at 1 Hz
GATE_LOG_LINES = "400" # how far back the run-start gate lines can sit in the forgectrl log
def _cool(fc):
st, c = fc.get("/cool/status")
return c if st == 200 and isinstance(c, dict) else {}
def _gate_state(fc, key):
g = (fc.settings().get("gates") or {}).get(key) or {}
return g.get("state"), g.get("value")
def _set_gates(ctx, fc, values):
"""POST the gate settings and confirm the reply carries them."""
st, body = fc.post("/settings", params=values)
ctx.check(st == 200 and isinstance(body, dict), "POST /settings %s -> %s %s", values, st, body)
for k, v in values.items():
ctx.check(body.get(k) == v, "settings reply has %s=%r, posted %r", k, body.get(k), v)
return body
def _run_session(ctx, g, fc, until, what):
"""M8 opens a run session (the engine re-reads its settings there and
ticks the gates at 1 Hz); wait for `until(cool)` to hold, then M9."""
g.command("M8")
try:
t0 = time.time()
c = {}
while time.time() - t0 < VERDICT_WAIT_S:
ctx.sleep(1)
c = _cool(fc)
if until(c):
break
ctx.log("%s: verdict %s fire_ok %s hold %s gates_off %s (after %.0f s)", what,
c.get("verdict"), c.get("fire_ok"), c.get("hold"), c.get("gates_off"), time.time() - t0)
return c
finally:
g.command("M9")
def _tail_has(fc, needle):
st, body = fc.get("/logs/tail", params={"name": "forgectrl", "lines": GATE_LOG_LINES})
text = body.get("text", "") if st == 200 and isinstance(body, dict) else ""
return needle in text
@test("cooling.gate-off", title="A gate setting trips inside its range and is off at its far end",
subsystem="cooling", kind="auto", mode="grbl", est_min=3,
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"],
steps=["Machine idle, coolant at room temperature (above 8 C). The test writes the coolant "
"ceiling and resume gate and restores them; three short M8/M9 cycles spin the fans."],
description="The coolant ceiling is a plain setting with a wide legal range whose top "
"turns the gate off by value. Set just above its legal minimum it must trip "
"(OVERTEMP, hold, fire blocked) at the next run start; set to its top the "
"engine must skip the gate (verdict OK), report it in gates_off on /status "
"and /cool/status, say so in the settings reply, and log the run-start line; "
"restored, everything reads as before.")
def gate_off(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
before = fc.settings()
orig = {k: before.get(k, "") for k in GATE_KEYS}
ev["orig"] = orig
ctx.log("original: %s", orig)
up = (fc.status().get("coolant") or {}).get("up_c")
ctx.check(up is not None and up > 8.0, "coolant too cold for the trip leg (up_c %s)", up)
c0 = _cool(fc)
ctx.check(c0.get("verdict") == "OK", "engine is not at OK before the test: %s", c0)
ctx.check(c0.get("gates_off") == [], "a gate is already off: %s", c0.get("gates_off"))
g_default = (before.get("gates") or {}).get("cool_temp_max") or {}
ctx.check(g_default.get("gate") == "coolant_max" and g_default.get("off") == "high",
"settings reply does not describe the ceiling as the coolant_max gate, off at its top: %s", g_default)
top = g_default.get("hi")
bottom = g_default.get("lo")
ctx.check(isinstance(top, (int, float)) and isinstance(bottom, (int, float)), "no range in the reply: %s", g_default)
restored = False
try:
with ctx.grbl() as grbl:
st = grbl.status_report()
ctx.check(st["state"].startswith("Idle"), "controller is %s", st["state"])
# Leg 1: a ceiling the coolant is already over. Legal, outside
# the band (warned), and it must trip at the next run start.
_set_gates(ctx, fc, {"cool_temp_max": str(bottom + 1), "cool_temp_resume": str(bottom)})
state, val = _gate_state(fc, "cool_temp_max")
ev["trip_state"] = state
ctx.check(state == "warn", "a ceiling of %s reports state %r, expected warn", val, state)
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OVERTEMP", "trip leg")
ev["trip"] = c
ctx.check(c.get("verdict") == "OVERTEMP", "ceiling %s C with coolant at %.1f C did not trip: %s",
bottom + 1, up, c)
ctx.check(c.get("fire_ok") is False and c.get("hold") is True,
"OVERTEMP without fire blocked and a hold: %s", c)
ctx.check(c.get("gates_off") == [], "a tripped gate is not an off gate: %s", c.get("gates_off"))
# Leg 2: the ceiling at its top. Off by value: no gate, verdict
# back to OK at the next run start, and said out loud.
_set_gates(ctx, fc, {"cool_temp_max": str(top), "cool_temp_resume": orig["cool_temp_resume"]})
state, val = _gate_state(fc, "cool_temp_max")
ev["off_state"] = state
ctx.check(state == "off", "a ceiling of %s reports state %r, expected off", val, state)
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and c.get("gates_off"), "off leg")
ev["off"] = c
ctx.check(c.get("verdict") == "OK", "ceiling at %s did not clear the gate: %s", top, c)
ctx.check(c.get("gates_off") == ["coolant_max"], "/cool/status gates_off %s, expected [coolant_max]",
c.get("gates_off"))
s_off = fc.status().get("gates_off")
ctx.check(s_off == ["coolant_max"], "/status gates_off %s, expected [coolant_max]", s_off)
ctx.check(_tail_has(fc, "gate coolant_max OFF: cool_temp_max = %g" % top),
"the run-start log line for the off gate is missing from the forgectrl log")
# Restore, and prove the restore: the next run start reloads.
_set_gates(ctx, fc, orig)
restored = True
state, val = _gate_state(fc, "cool_temp_max")
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and not c.get("gates_off"),
"restored")
ev["restored"] = c
ctx.check(c.get("verdict") == "OK" and c.get("gates_off") == [],
"engine did not return to OK with no gate off after the restore: %s", c)
ctx.log("restored ceiling %s reports state %s", val, state)
finally:
if not restored:
st, body = fc.post("/settings", params=orig)
ctx.log("restore on failure: POST /settings %s -> %s", orig, st)
after = fc.settings()
ctx.check(all(after.get(k, "") == orig[k] for k in GATE_KEYS),
"settings not restored: %s", {k: after.get(k) for k in GATE_KEYS})
ctx.check(fc.wait_idle(60, abort=ctx.aborted), "machine did not return to idle")
+5 -2
View File
@@ -102,6 +102,7 @@ class FakeForgectrl:
"cam": {"running": False, "clients": 0},
"diag": {"running": False},
"settings": {"controller_mode": "grbl", "lid_lamp_idle": ""},
"logs_tail": {"name": "forgectrl", "text": "", "truncated": False, "exists": True},
}
self.posts = []
self.on_post = None
@@ -122,7 +123,8 @@ class FakeForgectrl:
def do_GET(self):
path = self.path.split("?", 1)[0]
key = {"/mode": "mode", "/status": "status", "/cool/status": "cool", "/cam/status": "cam",
"/diag/status": "diag", "/settings": "settings"}.get(path)
"/diag/status": "diag", "/settings": "settings",
"/logs/tail": "logs_tail"}.get(path)
if key is None:
return self._send(404, {"error": "no " + path})
self._send(200, fake.state[key])
@@ -131,7 +133,7 @@ class FakeForgectrl:
path, _, query = self.path.partition("?")
n = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(n).decode() if n else ""
form = dict(_up.parse_qsl(raw)) if raw else dict(_up.parse_qsl(query))
form = dict(_up.parse_qsl(raw if raw else query, keep_blank_values=True))
fake.posts.append((path, form))
if fake.on_post:
r = fake.on_post(path, form)
@@ -143,6 +145,7 @@ class FakeForgectrl:
fake.state["settings"]["controller_mode"] = form["controller"]
elif path == "/settings":
fake.state["settings"].update(form)
return self._send(200, fake.state["settings"])
self._send(200, {"ok": True})
self._srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
+131 -4
View File
@@ -1,7 +1,8 @@
"""cooling.fans-quiet-after-motion replayed host-side under the real
runner Context against a scripted machine: a fake forgectrl (/status
fans, /cool/status phase), a fake kernel sysfs (the fan duties), and a
fake Grbl port that answers '?' and every command.
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
under the real runner Context against a scripted machine: a fake
forgectrl (/status fans, /cool/status phase and verdict, /settings with
the gates table, /logs/tail), a fake kernel sysfs (the fan duties), and
a fake Grbl port that answers '?' and every command.
The bench case that motivated this: the test started one second after
the engine of a previous test went idle, took the tachs still coasting
@@ -209,3 +210,129 @@ class FansQuietTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class GateOffTests(unittest.TestCase):
"""cooling.gate-off against a scripted engine: the fake forgectrl
re-reads the ceiling at every M8 (as the engine reloads its tunables
at run start), trips OVERTEMP when the coolant is over it, skips the
gate and reports gates_off when the ceiling sits at its top, and
writes the run-start line the test looks for."""
TOP, BOTTOM = 60.0, 5.0
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.grbl = FakeGrbl()
self.saved = cooling.VERDICT_WAIT_S
cooling.VERDICT_WAIT_S = 3
self.fc.state["status"] = dict(self.fc.state["status"],
coolant={"down_c": 22.4, "up_c": 22.3, "pump": True, "tec": False},
gates_off=[])
self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False,
"gates_off": []}
self.fc.state["settings"].update({"cool_temp_max": "", "cool_temp_resume": ""})
self.log_line = True # the engine writes its run-start line
self.report_off = True # the engine reports the off gate
self.trips = True # the engine trips a low ceiling
self._describe()
self.grbl.on_command = self._engine
self.fc.on_post = self._on_post
def tearDown(self):
cooling.VERDICT_WAIT_S = self.saved
self.grbl.close()
self.fc.stop()
# -- the scripted machine --------------------------------------------------
def ceiling(self):
v = self.fc.state["settings"].get("cool_temp_max") or ""
return float(v) if v else 33.0
def _describe(self):
"""/settings carries the gates table the way forgectrl's gates.c
publishes it, classified from the stored value."""
v = self.ceiling()
state = "off" if v >= self.TOP else ("ok" if 25 <= v <= 38 else "warn")
self.fc.state["settings"]["gates"] = {
"cool_temp_max": {"gate": "coolant_max", "def": 33, "lo": self.BOTTOM, "hi": self.TOP,
"band": [25, 38], "off": "high", "value": v, "state": state}}
def _on_post(self, path, form):
"""The real settings reply re-classifies the gates from the new values."""
if path != "/settings":
return None
self.fc.state["settings"].update(form)
self._describe()
return (200, self.fc.state["settings"])
def _engine(self, line):
self._describe()
if line != "M8":
return
v = self.ceiling()
cool = self.fc.state["cool"]
off = v >= self.TOP
if off and self.log_line:
self.fc.state["logs_tail"]["text"] += (
"Aug 21 12:00:00 forgectrl: cool: gate coolant_max OFF: cool_temp_max = 60 "
"(the high end of 5 to 60; recommended 25 to 38, default 33)\n")
gates_off = ["coolant_max"] if off and self.report_off else []
if not off and self.trips and 22.3 > v:
cool.update(verdict="OVERTEMP", fire_ok=False, hold=True, gates_off=gates_off)
else:
cool.update(verdict="OK", fire_ok=True, hold=False, gates_off=gates_off)
self.fc.state["status"]["gates_off"] = gates_off
def run_test(self):
run = Run("test", "cooling.gate-off", "t")
ctx = Context(run, None, helpers.make_test("cooling.gate-off", []))
cooling.gate_off(ctx)
return run
def settings_posts(self):
return [f for p, f in self.fc.posts if p == "/settings"]
# -- cases ------------------------------------------------------------------
def test_trip_then_off_then_restored_passes(self):
run = self.run_test()
self.assertEqual(run.evidence["trip"]["verdict"], "OVERTEMP")
self.assertEqual(run.evidence["off"]["gates_off"], ["coolant_max"])
self.assertEqual(run.evidence["restored"]["verdict"], "OK")
posts = self.settings_posts()
self.assertEqual(posts[0], {"cool_temp_max": "6.0", "cool_temp_resume": "5.0"})
self.assertEqual(posts[1], {"cool_temp_max": "60.0", "cool_temp_resume": ""})
self.assertEqual(posts[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
self.assertEqual(self.grbl.commands.count("M8"), 3)
self.assertEqual(self.grbl.commands.count("M9"), 3)
def test_an_engine_that_does_not_trip_fails_and_restores(self):
self.trips = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("did not trip", str(cm.exception))
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "", "cool_temp_resume": ""})
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
def test_an_engine_that_hides_the_off_gate_fails(self):
self.report_off = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("gates_off", str(cm.exception))
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
def test_a_missing_run_start_log_line_fails(self):
self.log_line = False
with self.assertRaises(Failed) as cm:
self.run_test()
self.assertIn("run-start log line", str(cm.exception))
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "")
def test_a_custom_ceiling_is_restored_verbatim(self):
self.fc.state["settings"].update({"cool_temp_max": "30", "cool_temp_resume": "28"})
self._describe()
run = self.run_test()
self.assertEqual(run.evidence["orig"], {"cool_temp_max": "30", "cool_temp_resume": "28"})
self.assertEqual(self.settings_posts()[-1], {"cool_temp_max": "30", "cool_temp_resume": "28"})
self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "30")