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:
@@ -235,6 +235,11 @@ tests:
|
|||||||
2. does that test's `covers` map name the files touched - if not, widen it
|
2. does that test's `covers` map name the files touched - if not, widen it
|
||||||
in the same change.
|
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
|
A behavior change with no catalog consequence needs a sentence of
|
||||||
justification in the commit message. Coverage gaps are defects: under the
|
justification in the commit message. Coverage gaps are defects: under the
|
||||||
domain model an uncovered path lets an inherited PASS stay valid across a
|
domain model an uncovered path lets an inherited PASS stay valid across a
|
||||||
|
|||||||
+16
-3
@@ -48,7 +48,7 @@ hardware-validated.**
|
|||||||
modes (cancel-and-return on a lid or interlock open, button pause/resume),
|
modes (cancel-and-return on a lid or interlock open, button pause/resume),
|
||||||
bench-validated 2026-08-17.
|
bench-validated 2026-08-17.
|
||||||
- **Releases are gated by the acceptance tool** (`forgetest`, dev image only):
|
- **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
|
and a release gate that reads the exported artifact. The full campaign on
|
||||||
dev image `20260821181036` (the first built on the `<recipe>-pin.inc`
|
dev image `20260821181036` (the first built on the `<recipe>-pin.inc`
|
||||||
layout) satisfied 42 of 42 and its export authorizes a release;
|
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
|
YES" for that image's manifest. That authorizes a release; it is not one
|
||||||
until `releases/v<version>/acceptance.json` is committed.
|
until `releases/v<version>/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
|
proven bench drill or a bench-verified check — the always-required core
|
||||||
(`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`,
|
(`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`,
|
||||||
`kernel.fire-line`), `forgectrl.*`, `logs.*`, `update.*`, `motion.*`
|
`kernel.fire-line`), `forgectrl.*`, `logs.*`, `update.*`, `motion.*`
|
||||||
(pacing, jog round-trip, liveness probe, cancel/abort, dead-man, the lid,
|
(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,
|
`laser.*` (emission witness, arm-wait lid, disarm-in-hold, armed kill,
|
||||||
pause/resume/lid-cancel) and `cloud.*`. Tests that share a setup are merged;
|
pause/resume/lid-cancel) and `cloud.*`. Tests that share a setup are merged;
|
||||||
the `auto` tests stay separate for failure isolation.
|
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
|
data format, both checked before a byte reaches the ring) and drops the
|
||||||
rest. Seventeen of the mandatory ones are among the dropped.
|
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
|
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
|
is the emission boundary and no header field touches it, and forgectrl runs
|
||||||
its own coolant ceiling, flow verification, emission witness, liveness gate
|
its own coolant ceiling, flow verification, emission witness, liveness gate
|
||||||
|
|||||||
+37
-12
@@ -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
|
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.
|
start of every run, so a change takes effect on your next job.
|
||||||
|
|
||||||
| Setting | Default | What it controls |
|
| Setting | Default | Legal range | Recommended | What it controls |
|
||||||
|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `cool_flow_rise` | 14.4 °C | Downstream rise that counts as no-flow. Set this from **flow calibrate**. |
|
| `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 % | Heater duty during a check. Raising it separates the bands further at the cost of warming the loop more. |
|
| `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 | Length of a check window. `0` disables flow verification entirely. |
|
| `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 | How often checks repeat during a job. |
|
| `cool_recheck_s` | 150 s | 0 to 3600 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_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 | Run ceiling — above it, hold. |
|
| `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 | Resume gate — below it, continue. |
|
| `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 | Smoke-clear phase at run duty after a job. |
|
| `cool_cooldown_s` | 15 s | 0 to 1800 s | | Smoke-clear phase at run duty after a job. |
|
||||||
| `cool_cooldown_max_s` | 300 s | Cap on the thermal cooldown phase. |
|
| `cool_cooldown_max_s` | 300 s | 0 to 1800 s | | Cap on the thermal cooldown phase. |
|
||||||
|
|
||||||
Two settings are deliberately not on the panel:
|
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.
|
changing it by hand is not recommended until the watch is lamp-aware.
|
||||||
- `GFCOOL_*` environment overrides exist for bench work; they win for the
|
- `GFCOOL_*` environment overrides exist for bench work; they win for the
|
||||||
lifetime of the process and are not a normal operating path.
|
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
|
## 9. Not implemented yet
|
||||||
@@ -348,6 +372,7 @@ Stated plainly so nobody counts on them:
|
|||||||
| Suspicion unresolved past the budget | Escalates to `FAULT`. |
|
| Suspicion unresolved past the budget | Escalates to `FAULT`. |
|
||||||
| Three cleared suspicions in one job | Aggregated "check your coolant" warning. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| Silence while armed, or a program still playing | Motion stopped and the latch locked by the engine itself. |
|
||||||
|
|||||||
+5
-1
@@ -205,7 +205,11 @@ would not allow.
|
|||||||
finish the queue tail so a controlled stop can never leave FIRE driven.
|
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
|
- **Coolant fire gates.** The armed window requires a fresh `fire_ok` verdict
|
||||||
from the cooling engine (flow verification, over-temperature, lid-IR
|
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.** `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
|
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
|
(cycle, hold, tool change, door): a running job parks with a planned
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
"""cooling.* - the cooling engine: flow verification through forgectrl's
|
"""cooling.* - the cooling engine: flow verification through forgectrl's
|
||||||
diagnostics runner (the same check the fire gate runs), and the fan
|
diagnostics runner (the same check the fire gate runs), the fan profile
|
||||||
profile returning to idle after motion."""
|
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
|
import time
|
||||||
|
|
||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
from .. import hw
|
from .. import hw
|
||||||
|
|
||||||
_COOL_COVERS = [("forgectrl", "src/cool.*"), ("forgectrl", "src/diag.*"),
|
_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*")]
|
("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.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, "
|
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)
|
"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},
|
"cam": {"running": False, "clients": 0},
|
||||||
"diag": {"running": False},
|
"diag": {"running": False},
|
||||||
"settings": {"controller_mode": "grbl", "lid_lamp_idle": ""},
|
"settings": {"controller_mode": "grbl", "lid_lamp_idle": ""},
|
||||||
|
"logs_tail": {"name": "forgectrl", "text": "", "truncated": False, "exists": True},
|
||||||
}
|
}
|
||||||
self.posts = []
|
self.posts = []
|
||||||
self.on_post = None
|
self.on_post = None
|
||||||
@@ -122,7 +123,8 @@ class FakeForgectrl:
|
|||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
path = self.path.split("?", 1)[0]
|
path = self.path.split("?", 1)[0]
|
||||||
key = {"/mode": "mode", "/status": "status", "/cool/status": "cool", "/cam/status": "cam",
|
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:
|
if key is None:
|
||||||
return self._send(404, {"error": "no " + path})
|
return self._send(404, {"error": "no " + path})
|
||||||
self._send(200, fake.state[key])
|
self._send(200, fake.state[key])
|
||||||
@@ -131,7 +133,7 @@ class FakeForgectrl:
|
|||||||
path, _, query = self.path.partition("?")
|
path, _, query = self.path.partition("?")
|
||||||
n = int(self.headers.get("Content-Length") or 0)
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
raw = self.rfile.read(n).decode() if n else ""
|
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))
|
fake.posts.append((path, form))
|
||||||
if fake.on_post:
|
if fake.on_post:
|
||||||
r = fake.on_post(path, form)
|
r = fake.on_post(path, form)
|
||||||
@@ -143,6 +145,7 @@ class FakeForgectrl:
|
|||||||
fake.state["settings"]["controller_mode"] = form["controller"]
|
fake.state["settings"]["controller_mode"] = form["controller"]
|
||||||
elif path == "/settings":
|
elif path == "/settings":
|
||||||
fake.state["settings"].update(form)
|
fake.state["settings"].update(form)
|
||||||
|
return self._send(200, fake.state["settings"])
|
||||||
self._send(200, {"ok": True})
|
self._send(200, {"ok": True})
|
||||||
|
|
||||||
self._srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
|
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
|
"""cooling.fans-quiet-after-motion and cooling.gate-off replayed host-side
|
||||||
runner Context against a scripted machine: a fake forgectrl (/status
|
under the real runner Context against a scripted machine: a fake
|
||||||
fans, /cool/status phase), a fake kernel sysfs (the fan duties), and a
|
forgectrl (/status fans, /cool/status phase and verdict, /settings with
|
||||||
fake Grbl port that answers '?' and every command.
|
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 bench case that motivated this: the test started one second after
|
||||||
the engine of a previous test went idle, took the tachs still coasting
|
the engine of a previous test went idle, took the tachs still coasting
|
||||||
@@ -209,3 +210,129 @@ class FansQuietTests(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.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")
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||||
# layer content hash because the component entry already identifies the
|
# layer content hash because the component entry already identifies the
|
||||||
# pinned source (forgefirm-image-manifest.bbclass).
|
# pinned source (forgefirm-image-manifest.bbclass).
|
||||||
SRCREV = "01fb0244657a506c24a55339af6d7f3e86838464"
|
SRCREV = "9e44fdc8b8ac389164f9b79e967ebdc978a476c4"
|
||||||
PV = "0.1.0"
|
PV = "0.1.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user