From bf3483e994f598900aa0af58cb10c1fed99b8d38 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Fri, 21 Aug 2026 17:06:49 -0400 Subject: [PATCH] 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. --- docs/ACCEPTANCE.md | 5 + docs/BRINGUP.md | 19 ++- docs/COOLING.md | 49 ++++-- docs/SAFETY.md | 6 +- forgetest/forgetest/suite/cooling.py | 147 +++++++++++++++++- forgetest/tests/helpers.py | 7 +- forgetest/tests/test_cooling_suite.py | 135 +++++++++++++++- .../forgectrl/forgectrl-pin.inc | 2 +- 8 files changed, 344 insertions(+), 26 deletions(-) diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 151d239..ed73012 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -235,6 +235,11 @@ tests: 2. does that test's `covers` map name the files touched - if not, widen it in the same change. +A gate or a limit is exercised through the settings API (a value a healthy +machine cannot meet, re-read by the engine at the next run start, restored +by the test's own teardown), never through `GFCOOL_*` environment overrides, +which need a daemon restart and stay bench-only. + A behavior change with no catalog consequence needs a sentence of justification in the commit message. Coverage gaps are defects: under the domain model an uncovered path lets an inherited PASS stay valid across a diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 916a3ad..77513f9 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -48,7 +48,7 @@ hardware-validated.** modes (cancel-and-return on a lid or interlock open, button pause/resume), bench-validated 2026-08-17. - **Releases are gated by the acceptance tool** (`forgetest`, dev image only): - a 42-test catalog, domain-scoped inheritance, an always-required safety core, + a 43-test catalog, domain-scoped inheritance, an always-required safety core, and a release gate that reads the exported artifact. The full campaign on dev image `20260821181036` (the first built on the `-pin.inc` layout) satisfied 42 of 42 and its export authorizes a release; @@ -579,12 +579,13 @@ under the domain model from the day's earlier dev images) and the export reads " YES" for that image's manifest. That authorizes a release; it is not one until `releases/v/acceptance.json` is committed. -- **Catalog: 42 tests** in `forgetest/forgetest/suite/`, every one a port of a +- **Catalog: 43 tests** in `forgetest/forgetest/suite/`, every one a port of a proven bench drill or a bench-verified check — the always-required core (`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`, `kernel.fire-line`), `forgectrl.*`, `logs.*`, `update.*`, `motion.*` (pacing, jog round-trip, liveness probe, cancel/abort, dead-man, the lid, - interlock and button parity tests), `cooling.*`, `camera.snapshot`, + interlock and button parity tests), `cooling.*` (flow verification, fans + quiet after motion, a gate setting tripping and off by value), `camera.snapshot`, `laser.*` (emission witness, arm-wait lid, disarm-in-hold, armed kill, pause/resume/lid-cancel) and `cloud.*`. Tests that share a setup are merged; the `auto` tests stay separate for failure isolation. @@ -1314,6 +1315,18 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`. data format, both checked before a byte reaches the ring) and drops the rest. Seventeen of the mandatory ones are among the dropped. + **Landed first, the pattern every gate ships on:** a gate is a plain + setting with a wide legal range, a recommended band, and an off end (a + ceiling at its maximum, a window of zero) that turns the gate off by + value, with no separate switch; the panel warns outside the band and + while any gate is off, the engine logs each gate setting at every run + start, `/status` and `/cool/status` carry `gates_off`, and an off gate + keeps measuring. Applied to the coolant ceiling, its resume gate, the + flow window and the flow rise (`forgectrl/src/gates.c`, `SERVICES.md` + "Gate settings", `COOLING.md` §8a, `cooling.gate-off` in the catalog). + The fan gates, the pass-through of header limits, the coolant critical + tier and the watch-only board temperatures follow on it. + 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 its own coolant ceiling, flow verification, emission witness, liveness gate diff --git a/docs/COOLING.md b/docs/COOLING.md index 922d4c1..b7aec40 100644 --- a/docs/COOLING.md +++ b/docs/COOLING.md @@ -300,25 +300,49 @@ All of these live in the panel's Machine tab, are validated on entry, and can only be changed while the machine is idle. The engine re-reads them at the start of every run, so a change takes effect on your next job. -| Setting | Default | What it controls | -|---|---|---| -| `cool_flow_rise` | 14.4 °C | Downstream rise that counts as no-flow. Set this from **flow calibrate**. | -| `cool_flow_heater_pct` | 40 % | Heater duty during a check. Raising it separates the bands further at the cost of warming the loop more. | -| `cool_flow_check_s` | 50 s | Length of a check window. `0` disables flow verification entirely. | -| `cool_recheck_s` | 150 s | How often checks repeat during a job. | -| `cool_confirm_max_s` | 480 s | How long a suspicion may stay unresolved before it escalates to a fault. | -| `cool_temp_max` | 33 °C | Run ceiling — above it, hold. | -| `cool_temp_resume` | 31 °C | Resume gate — below it, continue. | -| `cool_cooldown_s` | 15 s | Smoke-clear phase at run duty after a job. | -| `cool_cooldown_max_s` | 300 s | Cap on the thermal cooldown phase. | +| Setting | Default | Legal range | Recommended | What it controls | +|---|---|---|---|---| +| `cool_flow_rise` | 14.4 °C | 1 to 40 °C | 8 to 16 °C | Downstream rise that counts as no-flow. Set this from **flow calibrate**; above the band the check can never fault. | +| `cool_flow_heater_pct` | 40 % | 0 to 100 % | | Heater duty during a check. Raising it separates the bands further at the cost of warming the loop more. | +| `cool_flow_check_s` | 50 s | 0 to 300 s | 30 to 120 s | Length of a check window. `0` turns flow verification off (§8a). | +| `cool_recheck_s` | 150 s | 0 to 3600 s | | How often checks repeat during a job. | +| `cool_confirm_max_s` | 480 s | 60 to 3600 s | | How long a suspicion may stay unresolved before it escalates to a fault. | +| `cool_temp_max` | 33 °C | 5 to 60 °C | 25 to 38 °C | Run ceiling: above it, hold. `60` turns the gate off (§8a). | +| `cool_temp_resume` | 31 °C | 5 to 59 °C | 20 to 36 °C | Resume gate: below it, continue. Always kept below the ceiling. | +| `cool_cooldown_s` | 15 s | 0 to 1800 s | | Smoke-clear phase at run duty after a job. | +| `cool_cooldown_max_s` | 300 s | 0 to 1800 s | | Cap on the thermal cooldown phase. | Two settings are deliberately not on the panel: -- `cool_fire_ir_delta` — the lid-IR fire gate (§7). It is `0`, watch-only, and +- `cool_fire_ir_delta`, the lid-IR fire gate (§7). It is `0`, watch-only, and changing it by hand is not recommended until the watch is lamp-aware. - `GFCOOL_*` environment overrides exist for bench work; they win for the lifetime of the process and are not a normal operating path. +### 8a. Turning a gate off + +The gates are settings, and the far end of a gate setting's range is the off +switch: a coolant ceiling of 60 °C never trips, and a check window of 0 s runs +no flow verification at all. There is no other switch, and no list of names to +get wrong. The ranges are wide on purpose: the shipped defaults and the +recommended bands come from one bench machine, and a machine whose loop or +sensors read differently changes the number rather than waiting for new +firmware. + +A gate that is off is not a gate that is forgotten. The panel flags any value +outside its recommended band beside the field and says "this gate is OFF" at +the far end; the Status tab shows a standing banner while any gate is off; the +engine logs one line per gate setting at every run start, and with the ceiling +off it still logs the first reading in a job that would have tripped the +default. `/status` and `/cool/status` carry the off gates as `gates_off`. +Nothing about it reaches the cloud service. + +What no setting can reach: the hardware safety chain, the laser latch, the +emission witness, the lid-IR fire watch, the controller-silence dead-man, and +the motion-liveness gate. A machine with every thermal gate off still stops +firing the moment its controller goes quiet; what it no longer does is hold a +job for a stopped pump or an overheating loop. The banner says so. + --- ## 9. Not implemented yet @@ -348,6 +372,7 @@ Stated plainly so nobody counts on them: | Suspicion unresolved past the budget | Escalates to `FAULT`. | | Three cleared suspicions in one job | Aggregated "check your coolant" warning. | | Upstream coolant above 33 °C | `OVERTEMP`: hold + forced cooling; auto-resume under 31 °C. | +| A gate setting at its off end (ceiling 60 °C, check window 0 s) | No verdict from that gate; a run-start log line, `gates_off` in `/status`, and a standing panel banner. | | Job ends | 15 s smoke clear at run duty, then reduced airflow until the loop is under the resume gate. | | Controller stops reporting | Fire blocked at once, stand-down through cooldown. | | Silence while armed, or a program still playing | Motion stopped and the latch locked by the engine itself. | diff --git a/docs/SAFETY.md b/docs/SAFETY.md index d0f3b18..77ced22 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -205,7 +205,11 @@ would not allow. finish the queue tail so a controlled stop can never leave FIRE driven. - **Coolant fire gates.** The armed window requires a fresh `fire_ok` verdict from the cooling engine (flow verification, over-temperature, lid-IR - emission witness); a stale or failed verdict relocks in-process. + emission witness); a stale or failed verdict relocks in-process. The + thermal gates are settings with a wide range whose far end turns the gate + off by value (`COOLING.md` §8a), loudly; the fresh-report rule, the + emission witness, the dead-man and the latch are not settings and stay in + force whatever the gates are set to. - **Safety door.** `doors` (lid) and `interlock` (loop open) are the core's safety-door signal, shown to the core only while it is in a job-time state (cycle, hold, tool change, door): a running job parks with a planned diff --git a/forgetest/forgetest/suite/cooling.py b/forgetest/forgetest/suite/cooling.py index 8354faa..fbeb4e3 100644 --- a/forgetest/forgetest/suite/cooling.py +++ b/forgetest/forgetest/suite/cooling.py @@ -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") diff --git a/forgetest/tests/helpers.py b/forgetest/tests/helpers.py index 40a379a..ae13cbc 100644 --- a/forgetest/tests/helpers.py +++ b/forgetest/tests/helpers.py @@ -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) diff --git a/forgetest/tests/test_cooling_suite.py b/forgetest/tests/test_cooling_suite.py index 1ec105e..050286c 100644 --- a/forgetest/tests/test_cooling_suite.py +++ b/forgetest/tests/test_cooling_suite.py @@ -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") diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc index 7f7b264..b082da2 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 = "01fb0244657a506c24a55339af6d7f3e86838464" +SRCREV = "9e44fdc8b8ac389164f9b79e967ebdc978a476c4" PV = "0.1.0"