diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index db7540f..3fdb9cb 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 43-test catalog, domain-scoped inheritance, an always-required safety core, + a 44-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,13 +579,14 @@ 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: 43 tests** in `forgetest/forgetest/suite/`, every one a port of a +- **Catalog: 44 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.*` (flow verification, fans - quiet after motion, a gate setting tripping and off by value), `camera.snapshot`, + quiet after motion, a gate setting tripping and off by value, a fan under + its floor), `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. @@ -1331,11 +1332,20 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`. `/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 + is the live consumer and the fan floors follow (`cloud.pause-resume` checks both log lines on a real print; bench PASS 2026-08-21 on dev image `20260821220926`, the service's hunt windows - ignored as looser and the print's 33 C window matched). The fan - gates, the coolant critical tier and the watch-only board temperatures + ignored as looser and the print's 33 C window matched). **The airflow + gates are in** (`forgectrl/src/airflow.c`): every fan held to a floor + while the run profile is applied (exhaust, intakes and air assist by + tachometer, purge by current), a spin-up grace, three ticks under the + floor, a fault for the rest of the session (`AIRFLOW`, no resume), a + header window only ever raising a floor; `cooling.fan-gate-trips` in + the catalog. The shipped floors are provisional, 55 percent of one + run-duty snapshot (exhaust 3700, intake 1800, air assist 6000 rpm, + purge current 300, grace 15 s) until `fan_floor_measure.py` has been + run on the bench and the numbers in `forgectrl/src/gates.c` set from + it. The coolant critical tier and the watch-only board temperatures follow. Nothing here can put energy where it was not commanded: the hardware chain diff --git a/docs/COOLING.md b/docs/COOLING.md index 0d8c228..1c8f932 100644 --- a/docs/COOLING.md +++ b/docs/COOLING.md @@ -135,6 +135,37 @@ per-layer Air Assist), OR'd with the armed window. In **cloud mode** the job's own header carries the duties and the client passes them through, so a print gets the fan profile the service designed for it and a lens hunt stays quiet. +### 3a. Airflow gates: a fan that is not moving the air + +Commanding a fan and getting airflow are two different things, and the +machine can tell them apart: the exhaust, the two intakes and the air assist +carry tachometers, and the purge-air fan in the head reports its current. +While the run profile is applied, the engine holds every one of them to a +floor. + +- **The floors** are settings (§8): `cool_tach_exhaust_min_rpm`, + `cool_tach_intake_min_rpm` (either intake), `cool_tach_air_assist_min_rpm` + and `cool_purge_min_current`, each a fraction of what the fan reaches at + run duty on the bench machine. A cloud job's header can raise a tach floor + for that job, never lower it (§2). +- **A spin-up grace** (`cool_fan_grace_s`) runs from the moment the run + profile is written; nothing counts inside it, because the big exhaust fan + takes seconds to reach speed. +- **Three seconds under the floor trip the gate**, and a single reading at + or above it in between clears the count, so a tach reading that wanders + does not end a job. +- **A trip is a fault, not a pause.** The verdict goes `AIRFLOW`, fire is + blocked, the job holds, and there is no resume for the rest of that run + session: a fan that has stopped moving air is not a condition to cut + through. The fans stay at run duty (a stalled extraction fan needs every + other fan around it running), and the reason names the fan, the reading + and the floor. The next job starts the gates fresh. +- **A floor of zero is that gate off** (§8a). It still measures: the first + reading in a job that would have tripped the shipped default is logged. + +`/cool/status` carries each fan's reading, floor and state (`grace`, `ok`, +`under`, `TRIPPED`, `off`, or `idle` outside a run) as `fan_gates`. + --- ## 4. Coolant flow verification @@ -324,6 +355,11 @@ start of every run, so a change takes effect on your next job. | `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. | +| `cool_tach_exhaust_min_rpm` | 3700 rpm | 0 to 20000 | 2500 to 5000 | Exhaust fan floor at run duty (§3a). `0` turns the gate off. | +| `cool_tach_intake_min_rpm` | 1800 rpm | 0 to 20000 | 1200 to 2500 | Intake fan floor, either intake (§3a). `0` turns the gate off. | +| `cool_tach_air_assist_min_rpm` | 6000 rpm | 0 to 30000 | 4000 to 8000 | Air-assist fan floor (§3a). `0` turns the gate off. | +| `cool_purge_min_current` | 300 raw | 0 to 1023 | 150 to 500 | Purge-air fan current floor (the fan has no tachometer; about 1 off, about 630 on). `0` turns the gate off. | +| `cool_fan_grace_s` | 15 s | 0 to 120 s | 5 to 30 s | Spin-up window after the run profile is written, during which no floor counts. | Two settings are deliberately not on the panel: @@ -335,8 +371,8 @@ Two settings are deliberately not on the panel: ### 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 +switch: a coolant ceiling of 60 °C never trips, a check window of 0 s runs +no flow verification at all, and a fan floor of 0 never trips. 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 @@ -371,6 +407,10 @@ Stated plainly so nobody counts on them: cannot be detected (the output has no readback), so this will become a user setting plus a simple hysteresis around the factory's setpoints. - **A fire watch that acts** (§7). +- **Fan floors measured on more than one machine.** The shipped floors are + a fraction of one bench machine's run-duty speeds; a machine whose fans + read differently sets its own (§8), and a floor of zero turns that gate + off while it does. --- @@ -385,6 +425,9 @@ 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 fan under its floor inside the spin-up grace | Nothing yet: the gate reads `grace`. | +| A fan under its floor for three seconds after the grace | `AIRFLOW`: fire blocked, hold, no resume this job; fans held at run duty; the next job starts the gates fresh. | +| Purge-air current absent at run duty | `AIRFLOW`, the same way. | | 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. | diff --git a/docs/SAFETY.md b/docs/SAFETY.md index 77ced22..2190d8c 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -204,8 +204,9 @@ would not allow. and the next emission needs a fresh press. The relock waits for the kernel to 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. The + from the cooling engine (flow verification, over-temperature, the airflow + floors on every fan, lid-IR 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 diff --git a/forgetest/forgetest/suite/cooling.py b/forgetest/forgetest/suite/cooling.py index aed880e..cdf678a 100644 --- a/forgetest/forgetest/suite/cooling.py +++ b/forgetest/forgetest/suite/cooling.py @@ -1,16 +1,18 @@ """cooling.* - the cooling engine: flow verification through forgectrl's 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).""" +returning to idle after motion, 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), and the airflow gates (a fan under its +floor past the spin-up grace is a fault for the rest of the run).""" import time from ..catalog import test from .. import hw _COOL_COVERS = [("forgectrl", "src/cool.*"), ("forgectrl", "src/diag.*"), - ("forgectrl", "src/gates.*"), ("forgectrl", "src/settings.*"), + ("forgectrl", "src/gates.*"), ("forgectrl", "src/airflow.*"), + ("forgectrl", "src/settings.*"), ("forgectrl", "src/status.*"), ("forgectrl", "src/ui/**"), ("grblhal-glowforge", "src/glowforge_cooling.*"), ("kernel-module-glowforge", "src/thermal*"), @@ -215,7 +217,7 @@ def _session_ended(ctx, fc, what): return False -def _run_session(ctx, g, fc, until, what): +def _run_session(ctx, g, fc, until, what, wait=None): """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 and wait for the session to end, so the next M8 is a new one.""" @@ -224,7 +226,7 @@ def _run_session(ctx, g, fc, until, what): try: t0 = time.time() c = {} - while time.time() - t0 < VERDICT_WAIT_S: + while time.time() - t0 < (wait or VERDICT_WAIT_S): ctx.sleep(1) c = _cool(fc) if until(c): @@ -337,3 +339,111 @@ def gate_off(ctx): 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") + + +FAN_KEYS = ("cool_tach_exhaust_min_rpm", "cool_purge_min_current", "cool_fan_grace_s") +FAN_GRACE_S = "2" # the shortest grace the test can wait out with margin +FAN_TRIP_WAIT_S = 20 # grace + three ticks, with slack for the 1 Hz pipeline + + +def _fan_gate(c, name): + return ((c.get("fan_gates") or {}).get(name) or {}) + + +@test("cooling.fan-gate-trips", title="A fan under its floor past the grace is a fault; a floor of zero is off", + subsystem="cooling", kind="auto", mode="grbl", est_min=4, + covers=_COOL_COVERS, requires=["cooling.gate-off"], + steps=["Machine idle, fans quiet. The test writes the exhaust floor, the purge current floor and " + "the spin-up grace and restores them; four short M8/M9 cycles spin the fans."], + description="The airflow gates run while the cut fan profile is applied. An exhaust floor no fan " + "can meet must trip AIRFLOW after the grace plus three ticks (hold, fire blocked, no " + "resume while the session lasts); a purge current floor at the ADC rail must trip the " + "same way; a floor of zero must read off in gates_off and trip nothing; restored, the " + "next session runs OK with every fan reading at or above its floor.") +def fan_gate_trips(ctx): + fc = ctx.forgectrl + ev = ctx.evidence + before = fc.settings() + orig = {k: before.get(k, "") for k in FAN_KEYS} + ev["orig"] = orig + ctx.log("original: %s", orig) + gates = before.get("gates") or {} + exh = gates.get("cool_tach_exhaust_min_rpm") or {} + prg = gates.get("cool_purge_min_current") or {} + ctx.check(exh.get("gate") == "exhaust" and exh.get("off") == "low", + "settings reply does not describe the exhaust floor as the exhaust gate, off at zero: %s", exh) + ctx.check(prg.get("gate") == "purge" and prg.get("off") == "low", + "settings reply does not describe the purge floor as the purge gate, off at zero: %s", prg) + c0 = _cool(fc) + ctx.check(c0.get("verdict") == "OK" and c0.get("gates_off") == [], + "engine is not at OK with every gate on before the test: %s", c0) + + restored = False + with ctx.grbl() as grbl: + st = grbl.status_report() + ctx.check(st["state"].startswith("Idle"), "controller is %s", st["state"]) + try: + # Leg 1: an exhaust floor at the legal maximum. No fan reaches it, + # so after the grace and three ticks the gate trips a fault. + _set_gates(ctx, fc, {"cool_tach_exhaust_min_rpm": str(exh.get("hi")), "cool_fan_grace_s": FAN_GRACE_S}) + c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "AIRFLOW", "exhaust trip leg", + wait=FAN_TRIP_WAIT_S) + ev["exhaust_trip"] = {"cool": c, "gate": _fan_gate(c, "exhaust")} + ctx.check(c.get("verdict") == "AIRFLOW", "an exhaust floor of %s rpm did not trip: %s", exh.get("hi"), c) + ctx.check(c.get("fire_ok") is False and c.get("hold") is True, + "AIRFLOW without fire blocked and a hold: %s", c) + ctx.check(c.get("resume_ok") is not True, "a fan fault offered a resume: %s", c) + ctx.check(_fan_gate(c, "exhaust").get("state") == "TRIPPED", + "the exhaust gate does not read TRIPPED: %s", c.get("fan_gates")) + ctx.check("exhaust" in (c.get("reason") or ""), "the reason does not name the fan: %r", c.get("reason")) + + # Leg 2: the purge fan by current, floor at the ADC rail. + _set_gates(ctx, fc, {"cool_tach_exhaust_min_rpm": orig["cool_tach_exhaust_min_rpm"], + "cool_purge_min_current": str(prg.get("hi"))}) + c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "AIRFLOW", "purge trip leg", + wait=FAN_TRIP_WAIT_S) + ev["purge_trip"] = {"cool": c, "gate": _fan_gate(c, "purge")} + ctx.check(c.get("verdict") == "AIRFLOW" and _fan_gate(c, "purge").get("state") == "TRIPPED", + "a purge current floor of %s did not trip: %s", prg.get("hi"), c) + + # Leg 3: the exhaust floor at zero is the gate off: nothing trips, + # gates_off says so, and the other fans are judged on their own. + _set_gates(ctx, fc, {"cool_tach_exhaust_min_rpm": "0", + "cool_purge_min_current": orig["cool_purge_min_current"]}) + c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and "exhaust" in (c.get("gates_off") or []), + "exhaust off leg") + ev["exhaust_off"] = {"cool": c, "gate": _fan_gate(c, "exhaust")} + ctx.check(c.get("verdict") == "OK", "the exhaust floor at zero did not clear the gate: %s", c) + ctx.check("exhaust" in (c.get("gates_off") or []), "gates_off %s lacks exhaust", c.get("gates_off")) + ctx.check(_fan_gate(c, "exhaust").get("state") == "off", + "the exhaust gate does not read off: %s", c.get("fan_gates")) + + # Restore, and prove it: every fan at or above its floor, nothing off. + _set_gates(ctx, fc, orig) + restored = True + # The restored grace is the shipped one, so this leg waits it out. + c = _run_session(ctx, grbl, fc, + lambda c: c.get("verdict") == "OK" and not c.get("gates_off") + and all(g.get("state") == "ok" for g in (c.get("fan_gates") or {}).values()), + "restored", wait=FAN_TRIP_WAIT_S + 20) + ev["restored"] = c.get("fan_gates") + ctx.check(c.get("verdict") == "OK" and c.get("gates_off") == [], + "engine did not return to OK with every gate on after the restore: %s", c) + bad = {k: g for k, g in (c.get("fan_gates") or {}).items() if g.get("state") != "ok"} + ctx.check(not bad, "fans not at or above their floors after the grace: %s", bad) + ctx.log("fans at run duty: %s", {k: "%s/%s" % (g.get("reading"), g.get("floor")) + for k, g in (c.get("fan_gates") or {}).items()}) + finally: + if not restored: + st, body = fc.post("/settings", params=orig) + ctx.log("restore on failure: POST /settings %s -> %s", orig, st) + try: + c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and not c.get("gates_off"), + "restore on failure") + ctx.log("restore on failure: engine %s gates_off %s", c.get("verdict"), c.get("gates_off")) + except Exception as e: # the original failure is the one to report + ctx.log("restore on failure: run session did not complete (%s)", e) + after = fc.settings() + ctx.check(all(after.get(k, "") == orig[k] for k in FAN_KEYS), + "settings not restored: %s", {k: after.get(k) for k in FAN_KEYS}) + ctx.check(fc.wait_idle(60, abort=ctx.aborted), "machine did not return to idle") diff --git a/forgetest/tests/test_cooling_suite.py b/forgetest/tests/test_cooling_suite.py index 256232e..cca1c9d 100644 --- a/forgetest/tests/test_cooling_suite.py +++ b/forgetest/tests/test_cooling_suite.py @@ -374,3 +374,148 @@ class GateOffTests(unittest.TestCase): 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") + + +class FanGateTests(unittest.TestCase): + """cooling.fan-gate-trips against a scripted engine: the fake reads the + floors at every M8 (the engine reloads at run start), holds every fan + in grace for the configured seconds, then judges the bench readings + (exhaust 6753, intakes 3212/3328, air assist 10997 rpm, purge 628) + against the floors: a floor a reading cannot meet trips AIRFLOW three + ticks after the grace, a floor of zero reads off.""" + + READINGS = {"exhaust": 6753, "intake_1": 3212, "intake_2": 3328, "air_assist": 10997, "purge": 628} + FLOORS = {"exhaust": ("cool_tach_exhaust_min_rpm", 3700.0, 20000.0), + "intake_1": ("cool_tach_intake_min_rpm", 1800.0, 20000.0), + "intake_2": ("cool_tach_intake_min_rpm", 1800.0, 20000.0), + "air_assist": ("cool_tach_air_assist_min_rpm", 6000.0, 30000.0), + "purge": ("cool_purge_min_current", 300.0, 1023.0)} + GATE_OF = {"exhaust": "exhaust", "intake_1": "intake", "intake_2": "intake", + "air_assist": "air_assist", "purge": "purge"} + + def setUp(self): + self.fc = helpers.FakeForgectrl().start() + self.grbl = FakeGrbl() + self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling.FAN_TRIP_WAIT_S) + cooling.VERDICT_WAIT_S = 4 + cooling.SESSION_END_WAIT_S = 3 + cooling.FAN_TRIP_WAIT_S = 4 + self.fc.state["status"] = dict(self.fc.state["status"], gates_off=[]) + self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False, + "gates_off": [], "fan_gates": {}} + for key, _d, _h in self.FLOORS.values(): + self.fc.state["settings"].setdefault(key, "") + self.fc.state["settings"].setdefault("cool_fan_grace_s", "") + self.trips = True # the engine trips an unmeetable floor + self.reports_off = True # the engine reports a zero floor as off + self.grace_scale = 0.1 # seconds of fake grace per configured second + self._describe() + self.grbl.on_command = self._engine + self.fc.on_post = self._on_post + + def tearDown(self): + cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling.FAN_TRIP_WAIT_S = self.saved + self.grbl.close() + self.fc.stop() + + def setting(self, key, default): + v = self.fc.state["settings"].get(key) or "" + return float(v) if v else default + + def _describe(self): + gates = {} + for fan, (key, default, hi) in self.FLOORS.items(): + v = self.setting(key, default) + gates[key] = {"gate": self.GATE_OF[fan], "def": default, "lo": 0.0, "hi": hi, + "band": [default * 0.7, default * 1.4], "off": "low", "value": v, + "state": "off" if v <= 0 else "ok"} + g = self.setting("cool_fan_grace_s", 15.0) + gates["cool_fan_grace_s"] = {"gate": None, "def": 15.0, "lo": 0.0, "hi": 120.0, "band": [5, 30], + "off": "none", "value": g, "state": "ok"} + self.fc.state["settings"]["gates"] = gates + + def _on_post(self, path, form): + 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() + cool = self.fc.state["cool"] + if line == "M9": + def end(): + time.sleep(0.3) + cool["phase"] = "idle" + threading.Thread(target=end, daemon=True).start() + return + if line != "M8": + return + cool["phase"] = "run" + floors = {fan: self.setting(key, default) for fan, (key, default, _h) in self.FLOORS.items()} + off = sorted({self.GATE_OF[f] for f, v in floors.items() if v <= 0}) if self.reports_off else [] + cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=off, + fan_gates={f: {"reading": self.READINGS[f], "floor": floors[f], + "state": "off" if floors[f] <= 0 else "grace"} for f in floors}) + self.fc.state["status"]["gates_off"] = off + grace = self.setting("cool_fan_grace_s", 15.0) * self.grace_scale + + def judge(): + time.sleep(grace) + if cool["phase"] != "run": + return + for f in floors: + if floors[f] > 0: + cool["fan_gates"][f]["state"] = "ok" if self.READINGS[f] >= floors[f] else "under" + time.sleep(0.3) + if cool["phase"] != "run" or not self.trips: + return + for f in floors: + if 0 < floors[f] > self.READINGS[f]: + cool["fan_gates"][f]["state"] = "TRIPPED" + cool.update(verdict="AIRFLOW", fire_ok=False, hold=True, resume_ok=False, + reason="AIRFLOW: %s %d under the %d floor for 3 s - hold, no resume this job" + % (f, self.READINGS[f], floors[f])) + break + threading.Thread(target=judge, daemon=True).start() + + def run_test(self): + run = Run("test", "cooling.fan-gate-trips", "t") + ctx = Context(run, None, helpers.make_test("cooling.fan-gate-trips", [])) + cooling.fan_gate_trips(ctx) + return run + + def settings_posts(self): + return [f for p, f in self.fc.posts if p == "/settings"] + + def test_trip_purge_off_and_restore_pass(self): + run = self.run_test() + ev = run.evidence + self.assertEqual(ev["exhaust_trip"]["cool"]["verdict"], "AIRFLOW") + self.assertEqual(ev["exhaust_trip"]["gate"]["state"], "TRIPPED") + self.assertEqual(ev["purge_trip"]["gate"]["state"], "TRIPPED") + self.assertEqual(ev["exhaust_off"]["cool"]["gates_off"], ["exhaust"]) + self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values())) + posts = self.settings_posts() + self.assertEqual(posts[0], {"cool_tach_exhaust_min_rpm": "20000.0", "cool_fan_grace_s": "2"}) + self.assertEqual(posts[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "", + "cool_fan_grace_s": ""}) + self.assertEqual(self.grbl.commands.count("M8"), 4) + self.assertEqual(self.grbl.commands.count("M9"), 4) + + 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_tach_exhaust_min_rpm": "", "cool_purge_min_current": "", + "cool_fan_grace_s": ""}) + self.assertEqual(self.fc.state["cool"]["verdict"], "OK") + + def test_an_engine_that_hides_an_off_floor_fails(self): + self.reports_off = False + with self.assertRaises(Failed) as cm: + self.run_test() + self.assertIn("lacks exhaust", str(cm.exception)) + self.assertEqual(self.fc.state["settings"]["cool_tach_exhaust_min_rpm"], "") diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc index 460889c..240c95b 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 = "e0b41b33ee13c29d0ff772b15a919b2b24bfa24b" +SRCREV = "5a31d66e15f299daff540d0ca49e67b893fa841d" 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 c644d4e..e571fc2 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 = "c34faa1402d09c888ab8f7eccdf26dd8ec4be405" +SRCREV = "81027ffc3617c092e0fa23005d599229ebe57d61" # 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.14+git" +PV = "0.1.15+git"