mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user