cooling.fan-gate-trips: a fan under its floor is a fault; pin the airflow gates

The acceptance catalog gains the test behind forgectrl 5a31d66: an
exhaust floor at the legal maximum must trip AIRFLOW after the grace and
three ticks (hold, fire blocked, no resume, the exhaust gate TRIPPED and
named in the reason); a purge current floor at the ADC rail must trip
the same way; an exhaust floor of zero must read off in gates_off and
trip nothing; restored, the next session must run OK with every fan at
or above its floor. Three host cases against a scripted engine that
models the grace and the readings. The covers map names src/airflow.*.

COOLING.md gains 3a (the airflow gates) and the five settings with their
ranges and bands; SERVICES.md the gate rule, the rows, the AIRFLOW
verdict and fan_gates; SAFETY.md names the floors among the verdict's
inputs; BRINGUP item 19 records the gates as in with provisional floors
and the catalog at 44. Pins: forgectrl 5a31d66, forgefirm-app 81027ff
(0.1.15+git); fetch-verified.
This commit is contained in:
ScottW514
2026-08-21 19:00:06 -04:00
parent a8a02596cd
commit 0bddc9eeab
7 changed files with 329 additions and 20 deletions
+16 -6
View File
@@ -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 `<recipe>-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<version>/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
+45 -2
View File
@@ -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. |
+3 -2
View File
@@ -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
+117 -7
View File
@@ -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")
+145
View File
@@ -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"], "")
@@ -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"
@@ -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"