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:
@@ -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