mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
forgetest: nineteen tests a host test already proves leave the catalog
The catalog is for what needs the machine or an operator. Each test below asserted software rules that a host test in CI already proves, read assertion by assertion next to that host test before it left; the record of that reading is kept with the audit. Safety rules (each approved for deletion by the owner on that reading): - laser.disarm-in-hold: laser_lifecycle_test test_resume_after_grace. - laser.arm-wait-lid: laser_arm_test E-G, laser_lifecycle_test test_lid_open_in_wait. - laser.port-dark: ctlport_test test_port_dark (port jogs, a long jog and its cancel, four injected words, the panel ops: no FIRE tick under an open window with M3 modal). - motion.respawn-gate: super_test A and D. - motion.interlock-cancel-home: the lifecycle loop cases, laser_arm_test G. - motion.lid-policy-hold: laser_lifecycle_test test_lid_policy_hold. - cloud.lid-during-button-wait, cloud.dark-print, cloud.verdict-refuse, cloud.paused-lid-cancel: test_machine_lid_button.py. - cooling.fan-duty-readback: cool_fanwrite_test. - cooling.intake-one-fan: cool_intake_test A-E. - exthost.hold-pause-tier: holdkeep_test, run_test.py, cool_flow_test P, holds_test. Other rules: - homing.cloud-offsets: manual_home_test [own-offsets], test_gfhome_motion; the real session stays in cloud.mode-switch. - exthost.core-range: version_test, install_test. - exthost.operator-destinations: run_test.py, netrules_test, install_test, extpkg_test. - exthost.lifecycle: run_test.py's ext.shutdown read before the stop. - exthost.mcode: the null-sink mcode_test, forgectrl mcode_test, sdk_test, install_test. - motion.soft-limits: z_envelope_test's homed cases, which now assert no step before the alarm, and envelope_test. - cooling_intake.py, extcore.py, extdest.py, extlife.py and extmcode.py go whole; homeoff.py keeps only the camera-home helpers that cloud.mode-switch, setup.check-envelope and their meta-tests use. - The helpers only the removed tests used go with them. - exthost.armed-freeze still runs the cloud suite's dark-print helper, whose latch checks still run; its description says so. It now requires cooling.floor-and-warm-up, the dark print's own prerequisite, in place of the removed cloud.dark-print. - HOST_TESTED: forgectrl src/holds.* (holds_test) and src/mcode.* (mcode_test), whose only catalog home left with these tests. - The meta-tests that replayed the removed tests go: test_cooling_intake_suite.py, the cloud replay cases and their fixture. The one that catches a false PASS stays and runs the dark print as armed-freeze does: a latch open at the button fails. - Text that named a removed test (exthost.motion-job's description and comment, test_baseline's docstring) no longer does. Proof: the coverage lint on a manifest from the recipe pins reports 0 uncovered paths and 0 host-tested row problems over 111 tests (its one failure is setup.check-lens's src/lensfind.* entry, which the forgectrl pin bump clears); pyflakes adds no finding; the gate self-check refuses an empty artifact cleanly; the forgetest host suite passes under Linux (547 tests); the catalog loads with 111 tests after armed-freeze's description edit. Acceptance: the catalog hash moves, so the next image opens a new campaign. Every remaining test in laser.py, motion.py, cloud.py, cooling.py, exthost.py, homeoff.py and bedsize.py moves its fingerprint (their modules' shared text changed); exthost.armed-freeze moves by its requires.
This commit is contained in:
@@ -125,6 +125,12 @@ BEHAVIORAL = [
|
||||
# catalog test exercises on the hardware belongs in that test's covers, not
|
||||
# here.
|
||||
HOST_TESTED = [
|
||||
# An extension package's holds as the engine reads them (forgeext's
|
||||
# holdkeep_test and run_test prove the writer's side).
|
||||
("forgectrl", "src/holds.*", "forgectrl:tests/holds_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
# The M-code relay to the package that answers (the controller's side is
|
||||
# the null-sink mcode_test harness, under grblhal-glowforge's src/**).
|
||||
("forgectrl", "src/mcode.*", "forgectrl:tests/mcode_test.c", "forgectrl:.github/workflows/build.yml"),
|
||||
]
|
||||
|
||||
# This repository's root, when the package runs from a checkout of it; a
|
||||
|
||||
@@ -15,20 +15,15 @@ from . import setup_burns # noqa: F401,E402
|
||||
from . import logs # noqa: F401,E402
|
||||
from . import motion # noqa: F401,E402
|
||||
from . import cooling # noqa: F401,E402
|
||||
from . import cooling_intake # noqa: F401,E402
|
||||
from . import laser # noqa: F401,E402
|
||||
from . import camera # noqa: F401,E402
|
||||
from . import update # noqa: F401,E402
|
||||
from . import cloud # noqa: F401,E402
|
||||
# Last: it stands on the setup record's consent and on the cloud suite's dark print.
|
||||
from . import exthost # noqa: F401,E402
|
||||
from . import extcore # noqa: F401,E402
|
||||
from . import extcall # noqa: F401,E402
|
||||
from . import extdest # noqa: F401,E402
|
||||
from . import extlife # noqa: F401,E402
|
||||
from . import evmore # noqa: F401,E402
|
||||
from . import extcat # noqa: F401,E402
|
||||
from . import extmcode # noqa: F401,E402
|
||||
from . import extwizard # noqa: F401,E402
|
||||
from . import updlock # noqa: F401,E402
|
||||
from . import homeoff # noqa: F401,E402
|
||||
|
||||
@@ -9,7 +9,7 @@ Its own module, so that no other test's fingerprint moves. The check is
|
||||
driven from here inside the travel the machine already knows: a few jogs
|
||||
out from the camera home, never toward an end, so it needs no operator.
|
||||
Measuring the real ends is the operator's own run of the check. The
|
||||
homing is the web-service session homing.cloud-offsets runs.
|
||||
homing is the web-service session cloud.mode-switch runs.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
@@ -1262,123 +1262,7 @@ def lid_interlock_abort_body(ctx, ev, off, job, offset):
|
||||
ev.get("edge_to_stop_ms"))
|
||||
|
||||
|
||||
@test("cloud.lid-during-button-wait", title="Lid open at the cloud button prompt cancels the print",
|
||||
subsystem="cloud", kind="operator", est_min=8,
|
||||
covers=_MACHINE_RUN, requires=[], actions=["lid"],
|
||||
steps=[OFFLINE_STEP, LID_STEP,
|
||||
"The job is longer than the ring holds, so the ring is filled before the button "
|
||||
"lights; that takes a minute. When the button lights white, do NOT press it - open "
|
||||
"the lid, and close it when told. Nothing moves and nothing fires."],
|
||||
description="A cloud print waiting for the button is canceled by the lid: the wait ends "
|
||||
"with the lid named as the reason, the laser latch relocks, the armed window "
|
||||
"closes, no run starts, and the job ends ':cancelled'. The job is longer than "
|
||||
"the ring, so its feeder is alive through the wait with the rest of the print "
|
||||
"in hand: the cancel has to stop that feeder before the park clears the ring, "
|
||||
"and the ring has to stay empty afterward. A feeder left alive would refill "
|
||||
"it and the park, which ignores the lid, would play the print's path.")
|
||||
def lid_during_button_wait(ctx):
|
||||
ev = ctx.evidence
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "wait.puls", seconds=3500)
|
||||
off = Offline().__enter__()
|
||||
try:
|
||||
lid_during_button_wait_body(ctx, ev, off, job, offset)
|
||||
finally:
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
|
||||
def lid_during_button_wait_body(ctx, ev, off, job, offset):
|
||||
off.print_ready(9003, job)
|
||||
got = wait_button_wait(ctx, offset, 180, also=["job is longer than the ring"])
|
||||
ctx.check(got["job is longer than the ring"],
|
||||
"the job fit the ring: this test needs a job the ring cannot hold")
|
||||
ctx.act("lid", "open", text="The button is lit: do NOT press it.", timeout=120)
|
||||
relock = "button wait lid opened - relocking the laser"
|
||||
got = wait_log(ctx, offset, [relock], 60)
|
||||
fin = wait_action_finished(ctx, offset, "print", 60)
|
||||
ev["log"] = {relock: bool(got[relock]), "print finished": message(fin)}
|
||||
ctx.check(got[relock], "the lid did not end the button wait")
|
||||
ctx.check(fin and CANCELLED in fin, "the print did not end ':cancelled': %s",
|
||||
message(fin) or "no finish line")
|
||||
# No run may start between the button wait and the cancel: the print
|
||||
# itself, or a park (the head never moved, there is nothing to park).
|
||||
# The service's moves BEFORE the print are legitimate runs and are
|
||||
# outside this window.
|
||||
lines = log_lines_since(GFCLOUD_LOG, offset)
|
||||
wait_i = next((i for i, ln in enumerate(lines) if "waiting for button" in ln), None)
|
||||
end_i = action_finish_index(lines, "print")
|
||||
if end_i is None or wait_i is None or end_i < wait_i:
|
||||
end_i = len(lines)
|
||||
ran = ([ln for ln in lines[wait_i:end_i] if "starting run" in ln]
|
||||
if wait_i is not None else [])
|
||||
ev["runs_started_after_wait"] = len(ran)
|
||||
ctx.check(not ran, "a run started after the lid-open cancel (%d)", len(ran))
|
||||
st, cs = ctx.forgectrl.get("/cool/status")
|
||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
||||
ev["latch_locked"] = latch_locked()
|
||||
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
|
||||
ctx.check(ev["latch_locked"], "kernel latch not locked after the cancel")
|
||||
ev["button_dark"] = hw.button_lit()
|
||||
ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
|
||||
# The park cleared the ring and nothing refilled it: the feeder is gone,
|
||||
# the device is out of live-feed mode, and the program total stands at
|
||||
# zero and stays there. A feeder left alive shows here as a total that
|
||||
# comes back within its retry period.
|
||||
total = read_program_total()
|
||||
ctx.sleep(2)
|
||||
later = read_program_total()
|
||||
streaming = hw.sysfs_int("cnc/streaming", 0)
|
||||
ev["program_total_after"] = {"first": total, "later": later}
|
||||
ev["streaming_after"] = streaming
|
||||
ctx.check(total == 0 and later == 0,
|
||||
"the ring is not empty after the cancel (program total %s, then %s): the feeder "
|
||||
"outlived the job", total, later)
|
||||
ctx.check(streaming == 0, "cnc/streaming is %s after the cancel", streaming)
|
||||
ctx.act("lid", "close")
|
||||
settle_cloud(ctx, offset)
|
||||
ev["events"] = offline_events(off)
|
||||
ctx.log("PASS: lid open at the button prompt canceled the print; latch locked, armed=false, "
|
||||
"button dark, ring empty with the feed stopped")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the cooling verdict
|
||||
|
||||
HOLD_KEYS = ("cool_temp_min", "cool_temp_start")
|
||||
REFUSE_KEYS = HOLD_KEYS + ("cloud_hold_max_s",)
|
||||
HOLD_REFUSE_S = 60 # the shortest bound the client takes
|
||||
HOLD_REFUSE_LINE = "cooling hold held 60 s - relocking the laser"
|
||||
WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP"
|
||||
|
||||
|
||||
@test("cloud.dark-print", title="A dark cloud print: the latch locked until the run, unlocked "
|
||||
"for it, locked after it",
|
||||
subsystem="cloud", kind="operator", est_min=3,
|
||||
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
||||
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
|
||||
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
||||
"when it lights: the print waits for the engine, runs, and finishes."],
|
||||
description="The cooling-engine contract in cloud mode at its ordinary end, on the engine "
|
||||
"itself: the print arms on the press, waits for the engine's acknowledgment (the "
|
||||
"fans at their floors), and runs. The laser latch is locked at the button and "
|
||||
"through the wait, unlocks only for the run (immediately before it starts), and "
|
||||
"is locked again when the job ends; the print completes. A hold the engine never "
|
||||
"clears is cloud.verdict-refuse; the engine's own warm-up release is "
|
||||
"cooling.floor-and-warm-up.")
|
||||
def dark_print(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "dark.puls", seconds=30)
|
||||
off = Offline().__enter__()
|
||||
try:
|
||||
dark_print_body(ctx, ev, off, job, offset, fc)
|
||||
finally:
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
# ---------------------------------------------------------------- the dark print (exthost.armed-freeze)
|
||||
|
||||
def dark_print_body(ctx, ev, off, job, offset, fc):
|
||||
off.print_ready(9005, job)
|
||||
@@ -1404,91 +1288,6 @@ def dark_print_body(ctx, ev, off, job, offset, fc):
|
||||
ctx.log("PASS: locked at the button, unlocked for the run, locked after it; the print completed")
|
||||
|
||||
|
||||
@test("cloud.verdict-refuse", title="A cloud print held by the engine past the bound is canceled with "
|
||||
"the latch never unlocked",
|
||||
subsystem="cloud", kind="operator", est_min=4,
|
||||
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
||||
("forgectrl", "src/cool.*")],
|
||||
requires=["cloud.dark-print"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate well above "
|
||||
"the coolant and the hold bound to its minimum, and restores both. Press the button "
|
||||
"when it lights: nothing runs; a minute later the print cancels by itself."],
|
||||
description="The other end of the cooling-engine contract before a run: a verdict that "
|
||||
"refuses fire and never clears. The start gate far above the coolant keeps the "
|
||||
"armed session under the warm-up for longer than the hold bound (cloud_hold_max_s "
|
||||
"at its minimum, 60 s). The client waits with the laser latch locked the whole "
|
||||
"time - it is never unlocked before the verdict passes, so there is nothing to "
|
||||
"relock - then cancels the print at the bound: no run, the armed window closed, "
|
||||
"the print ':cancelled'. Dark by construction.")
|
||||
def verdict_refuse(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
before = fc.settings()
|
||||
orig = {k: before.get(k, "") for k in REFUSE_KEYS}
|
||||
ev["orig"] = orig
|
||||
up = (fc.status().get("coolant") or {}).get("up_c")
|
||||
ctx.check(up is not None and 10.0 <= up <= 30.0, "coolant outside the test's range (up_c %s)", up)
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "refuse.puls", seconds=30)
|
||||
off = Offline().__enter__()
|
||||
try:
|
||||
verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig)
|
||||
finally:
|
||||
st, body = fc.post("/settings", params=orig)
|
||||
ctx.log("restore: POST /settings %s -> %s", orig, st)
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
|
||||
def verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig):
|
||||
gate = round(up + 8.0, 1) # eight degrees: the heater cannot get there in a minute
|
||||
st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
|
||||
"cool_temp_min": orig["cool_temp_min"],
|
||||
"cloud_hold_max_s": str(HOLD_REFUSE_S)})
|
||||
ctx.check(st == 200, "POST /settings cool_temp_start=%s cloud_hold_max_s=%s -> %s %s",
|
||||
gate, HOLD_REFUSE_S, st, body)
|
||||
ev["gate"] = gate
|
||||
off.print_ready(9006, job)
|
||||
wait_button_wait(ctx, offset, 120)
|
||||
ev["latch_locked_at_button"] = latch_locked()
|
||||
ctx.check(ev["latch_locked_at_button"], "the latch is unlocked at the button wait")
|
||||
ctx.act("button", "press", text="The button is lit: press it. Nothing runs; the print cancels "
|
||||
"itself a minute later.",
|
||||
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
|
||||
got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30)
|
||||
ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up")
|
||||
t0 = time.time()
|
||||
samples = []
|
||||
ctx.notice("The engine holds the print; the client gives up at the bound. Do nothing.")
|
||||
try:
|
||||
while time.time() - t0 < HOLD_REFUSE_S + 30:
|
||||
ctx.checkpoint()
|
||||
samples.append(latch_locked())
|
||||
if log_has(offset, HOLD_REFUSE_LINE):
|
||||
break
|
||||
ctx.sleep(2)
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
ev["hold_s"] = round(time.time() - t0)
|
||||
ev["latch_samples"] = {"n": len(samples), "unlocked": samples.count(False)}
|
||||
ctx.check(log_has(offset, HOLD_REFUSE_LINE), "the client did not cancel at the hold bound (%d s)",
|
||||
ev["hold_s"])
|
||||
ctx.check(all(samples), "the latch unlocked during the hold (%d of %d samples)",
|
||||
samples.count(False), len(samples))
|
||||
ctx.check(wait_print_running(ctx, offset, 1) is None, "the run started under the refused verdict")
|
||||
fin = wait_action_finished(ctx, offset, "print", 120)
|
||||
ev["print finished"] = message(fin)
|
||||
ctx.check(fin and CANCELLED in fin, "the held print did not end ':cancelled': %s",
|
||||
message(fin) or "no finish line")
|
||||
st, cs = fc.get("/cool/status")
|
||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
||||
ctx.check(not ev["armed_after"], "armed window still open after the cancel")
|
||||
ctx.check(latch_locked(), "the latch is unlocked after the cancel")
|
||||
ev["events"] = offline_events(off)
|
||||
ctx.log("PASS: held %s s with the latch locked throughout, then canceled at the bound", ev["hold_s"])
|
||||
|
||||
|
||||
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
|
||||
subsystem="cloud", kind="live", est_min=8,
|
||||
covers=_CLOUD_ALL,
|
||||
@@ -1722,68 +1521,3 @@ def oversize_stream_body(ctx, ev, off, job, offset, before):
|
||||
"stopped it, parked, relocked and reported ':cancelled'")
|
||||
|
||||
|
||||
@test("cloud.paused-lid-cancel", title="A paused cloud print is canceled by the lid",
|
||||
subsystem="cloud", kind="live", est_min=6,
|
||||
covers=_MACHINE_RUN, requires=["cloud.pause-resume", "cloud.lid-interlock-abort"],
|
||||
actions=["button", "lid"],
|
||||
steps=[OFFLINE_STEP, ARM_STEP, LID_STEP,
|
||||
"The head needs 40 mm of free +X and +Y travel.",
|
||||
"Press the button to start; when asked, press it again (pause), then open the lid and "
|
||||
"leave it open until the head is back; close it when told."],
|
||||
description="A job paused on the button is canceled by the lid, from the state the factory "
|
||||
"ends it in - there is no resume past a lid open. The same tail as every abort: "
|
||||
"the motion stops, the head parks back at the job start, the latch relocks and "
|
||||
"the armed window closes, the button goes dark, and the print ends ':cancelled'. "
|
||||
"(The app's own cancel of a running print is exercised where a print has to be "
|
||||
"ended that way: cloud.oversize-stream.)")
|
||||
def paused_lid_cancel(ctx):
|
||||
ev = ctx.evidence
|
||||
offset = enter_offline(ctx)
|
||||
job = offline_job(ctx, "paused.puls", seconds=60)
|
||||
off = Offline().__enter__()
|
||||
try:
|
||||
paused_lid_cancel_body(ctx, ev, off, job, offset)
|
||||
finally:
|
||||
off.__exit__(None, None, None)
|
||||
offline_cleanup(ctx)
|
||||
|
||||
|
||||
def paused_lid_cancel_body(ctx, ev, off, job, offset):
|
||||
# -- paused on the button, then the lid ---------------------------------
|
||||
offline_start_print(ctx, off, 9005, job, offset)
|
||||
ctx.act("button", "press", text="The head is moving: the press pauses the print.",
|
||||
until=lambda: log_has(offset, PAUSE_LINES[0]), timeout=PRESS_TIMEOUT_S, fail=False)
|
||||
got = wait_log(ctx, offset, list(PAUSE_LINES), 10)
|
||||
ev["paused"] = {k: bool(v) for k, v in got.items()}
|
||||
ctx.check(got["button pressed mid-run; pausing"], "the press did not pause the print")
|
||||
ctx.check(got["paused at"], "the pause did not settle (no 'paused at')")
|
||||
ctx.act("lid", "open", text="The print is paused: leave the lid open until the head has returned "
|
||||
"to the corner.", timeout=60)
|
||||
lid_stop = "lid opened mid-run; stopping motion"
|
||||
got = wait_log(ctx, offset, [lid_stop, "start return home", "return home complete"], 120)
|
||||
fin1 = wait_action_finished(ctx, offset, "print", 60)
|
||||
ev["lid_from_pause"] = {k: message(v) for k, v in got.items()}
|
||||
ev["lid_from_pause"]["print finished"] = message(fin1)
|
||||
for k, v in ev["lid_from_pause"].items():
|
||||
ctx.log(" [print] %s: %s", k, "seen" if v else "MISSING")
|
||||
ctx.check(got[lid_stop], "the lid did not end the paused print")
|
||||
ctx.check(got["return home complete"], "the paused print did not park to completion")
|
||||
ctx.check(fin1 and CANCELLED in fin1, "the print did not end ':cancelled': %s",
|
||||
message(fin1) or "no finish line")
|
||||
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the park")
|
||||
kpos = read_position()
|
||||
ev["counters_after"] = kpos
|
||||
ctx.check(kpos is not None and abs(kpos[0]) <= 3 and abs(kpos[1]) <= 3,
|
||||
"the head did not come back to the job start (kernel counters %s)", kpos)
|
||||
st, cs = ctx.forgectrl.get("/cool/status")
|
||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
||||
ev["latch_locked_after"] = latch_locked()
|
||||
ctx.check(not ev["armed_after"], "armed window still open after the paused print was canceled")
|
||||
ctx.check(ev["latch_locked_after"],
|
||||
"kernel latch not locked after the paused print was canceled")
|
||||
ev["button_dark"] = hw.button_lit()
|
||||
ctx.check(ev["button_dark"] is False, "the button is still lit after the cancel (%s)", ev["button_dark"])
|
||||
ctx.act("lid", "close")
|
||||
settle_cloud(ctx, offset)
|
||||
ev["events"] = offline_events(off)
|
||||
ctx.log("PASS: a paused print canceled by the lid stopped, parked, relocked and reported ':cancelled'")
|
||||
|
||||
@@ -1404,73 +1404,6 @@ def flow_under_load(ctx):
|
||||
"(dT %.1f), window closed %.1f s after M2", raw, share, rise, float(limit), float(dtc), dt)
|
||||
|
||||
|
||||
@test("cooling.fan-duty-readback", title="A fan duty a device lost is read back and put back inside a tick",
|
||||
subsystem="cooling", kind="auto", mode="grbl", est_min=2,
|
||||
covers=_COOL_COVERS + [("kernel-module-glowforge", "src/head*")],
|
||||
requires=["kernel.latch-locked-idle"],
|
||||
description="M8 opens a run session and every fan reads its run duty back from its device. "
|
||||
"The head's air-assist register is then written back to the idle duty behind the "
|
||||
"engine's back (what a head reset or a dropped transaction leaves) and the exhaust "
|
||||
"PWM to zero: within a few ticks each reads its run duty again, the log names each "
|
||||
"loss and its put-back, and the session stays OK with no airflow fault (a fan put "
|
||||
"back gets its spin-up grace). Then M9 and the idle duties.")
|
||||
def fan_duty_readback(ctx):
|
||||
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
run = {"head/air_assist_pwm": 1023, "thermal/exhaust_pwm": 65535, "thermal/intake_pwm": 43278}
|
||||
with ctx.grbl() as g:
|
||||
c = _hold_session(ctx, g, fc, lambda c: c.get("phase") == "run", "readback", 20)
|
||||
try:
|
||||
ctx.check(c.get("phase") == "run", "no run session opened: %s", c)
|
||||
ctx.sleep(2)
|
||||
before = {k: hw.sysfs_int(k) for k in run}
|
||||
ctx.log("duties at run: %s", before)
|
||||
ev["run_duties"] = before
|
||||
ctx.check(all(before.get(k) == v for k, v in run.items()),
|
||||
"not every fan reads its run duty: %s", before)
|
||||
at_loss = _log_offset(FORGECTRL_LOG) # the put-back lines are ones written after this
|
||||
hw.sysfs_write("head/air_assist_pwm", 204)
|
||||
hw.sysfs_write("thermal/exhaust_pwm", 0)
|
||||
lost = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
|
||||
ctx.log("written behind the engine's back: %s", lost)
|
||||
back = {}
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 6:
|
||||
ctx.sleep(0.5)
|
||||
back = {k: hw.sysfs_int(k) for k in ("head/air_assist_pwm", "thermal/exhaust_pwm")}
|
||||
if back.get("head/air_assist_pwm") == 1023 and back.get("thermal/exhaust_pwm") == 65535:
|
||||
break
|
||||
took = round(time.time() - t0, 1)
|
||||
ev["put_back"] = {"duties": back, "after_s": took}
|
||||
ctx.log("put back after %s s: %s", took, back)
|
||||
ctx.check(back.get("head/air_assist_pwm") == 1023, "the air-assist duty was not put back: %s", back)
|
||||
ctx.check(back.get("thermal/exhaust_pwm") == 65535, "the exhaust duty was not put back: %s", back)
|
||||
ctx.sleep(2)
|
||||
line = "head/air_assist_pwm read 204 with 1023 commanded: put back"
|
||||
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_loss, line)), 5, poll=0.25)
|
||||
ctx.check(_log_lines(FORGECTRL_LOG, at_loss, line),
|
||||
"the air-assist loss is not named in the log")
|
||||
line = "thermal/exhaust_pwm read 0 with 65535 commanded: put back"
|
||||
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, at_loss, line)), 5, poll=0.25)
|
||||
ctx.check(_log_lines(FORGECTRL_LOG, at_loss, line),
|
||||
"the exhaust loss is not named in the log")
|
||||
for _ in range(8):
|
||||
ctx.sleep(1)
|
||||
c = _cool(fc)
|
||||
if c.get("verdict") != "OK":
|
||||
break
|
||||
ev["verdict_after"] = {"verdict": c.get("verdict"), "hold": c.get("hold"), "reason": c.get("reason")}
|
||||
ctx.check(c.get("verdict") == "OK" and not c.get("hold"),
|
||||
"the session did not stay OK after the put-back: %s %s", c.get("verdict"), c.get("reason"))
|
||||
finally:
|
||||
g.command("M9")
|
||||
_session_ended(ctx, fc, "readback")
|
||||
idle = {k: hw.sysfs_int(k) for k in run}
|
||||
ev["idle_duties"] = idle
|
||||
ctx.log("duties at idle: %s", idle)
|
||||
|
||||
|
||||
@test("cooling.fail-tier-stop", title="A fail-tier verdict ends the controller: CRASH stops motion, "
|
||||
"locks the laser, and the supervisor restarts the controller",
|
||||
subsystem="cooling", kind="operator", mode="grbl", est_min=4,
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""cooling.intake-one-fan - a machine built with one intake fan
|
||||
(cool_intake_fans 1, which the setup's Intake fans step writes when the
|
||||
operator counts one): the second
|
||||
intake tach is published as not fitted and never judged, and intake 1
|
||||
still holds the intake floor. On a machine with two intake fans the
|
||||
setting alone proves both halves: intake 2 turns and is not judged, and
|
||||
an intake floor no fan can meet trips with intake 2 still out of it."""
|
||||
from ..catalog import test
|
||||
from .cooling import _COOL_COVERS, _after_session, _cool, _fan_gate, _run_session, _set_gates
|
||||
|
||||
INTAKE_KEYS = ("cool_intake_fans", "cool_tach_intake_min_rpm", "cool_fan_grace_s")
|
||||
INTAKE_GRACE_S = "8" # past the intakes' 7 s to 90 percent, so only the leg's floor trips
|
||||
INTAKE_TRIP_WAIT_S = 20 # grace + three ticks, with slack for the 1 Hz pipeline
|
||||
|
||||
|
||||
@test("cooling.intake-one-fan", title="With one intake fan set, intake 2 is not judged and intake 1 still is",
|
||||
subsystem="cooling", kind="auto", mode="grbl", est_min=3,
|
||||
covers=_COOL_COVERS + [("forgectrl", "src/main.c")], requires=["cooling.fan-gate-trips"],
|
||||
steps=["Machine idle, fans quiet. The test sets one intake fan, the intake floor and the spin-up "
|
||||
"grace and restores all three; three short M8/M9 cycles spin the fans."],
|
||||
description="cool_intake_fans 1 must take intake 2 out of the airflow gates and leave intake 1 in "
|
||||
"them. With it set, a session at the cut fan profile must publish intake_2 as not fitted "
|
||||
"with no floor while it reads its real speed, run OK, and judge intake_1 ok at the intake "
|
||||
"floor; an intake floor at its legal maximum must then trip AIRFLOW (hold, fire blocked) "
|
||||
"with intake_1 TRIPPED and intake_2 still not fitted, and the fault must end with the "
|
||||
"session; restored, the next session judges intake_2 again and every fan reads at or "
|
||||
"above its floor.")
|
||||
def intake_one_fan(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
before = fc.settings()
|
||||
orig = {k: before.get(k, "") for k in INTAKE_KEYS}
|
||||
ev["orig"] = orig
|
||||
ctx.log("original: %s", orig)
|
||||
inl = (before.get("gates") or {}).get("cool_tach_intake_min_rpm") or {}
|
||||
ctx.check(inl.get("gate") == "intake" and inl.get("off") == "low",
|
||||
"settings reply does not describe the intake floor as the intake gate, off at zero: %s", inl)
|
||||
ctx.check(orig["cool_intake_fans"] != "1",
|
||||
"cool_intake_fans is already 1: the test starts from a machine with both intakes judged")
|
||||
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: one intake fan. Intake 2 still turns on this machine; it
|
||||
# is measured and published not fitted, and nothing judges it.
|
||||
_set_gates(ctx, fc, {"cool_intake_fans": "1", "cool_fan_grace_s": INTAKE_GRACE_S})
|
||||
c = _run_session(ctx, grbl, fc,
|
||||
lambda c: c.get("verdict") == "OK" and _fan_gate(c, "intake_1").get("state") == "ok",
|
||||
"one intake fan leg", wait=INTAKE_TRIP_WAIT_S + 10)
|
||||
g1, g2 = _fan_gate(c, "intake_1"), _fan_gate(c, "intake_2")
|
||||
ev["one_fan"] = {"cool": c, "intake_1": g1, "intake_2": g2}
|
||||
ctx.check(g2.get("state") == "not fitted" and (g2.get("floor") or 0) == 0,
|
||||
"intake_2 is not published not fitted with no floor: %s", g2)
|
||||
ctx.check((g2.get("reading") or 0) > 0,
|
||||
"intake_2 reads %s: the test needs a machine whose second intake turns", g2.get("reading"))
|
||||
ctx.check(c.get("verdict") == "OK", "the one intake fan session is not OK: %s", c)
|
||||
ctx.check(g1.get("state") == "ok" and (g1.get("floor") or 0) > 0,
|
||||
"intake_1 is not judged ok at the intake floor: %s", g1)
|
||||
|
||||
# Leg 2: the intake floor at its legal maximum. Intake 1 is the
|
||||
# one intake left in the gates; intake 2 stays out of them.
|
||||
_set_gates(ctx, fc, {"cool_tach_intake_min_rpm": str(inl.get("hi"))})
|
||||
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "AIRFLOW", "intake 1 trip leg",
|
||||
wait=INTAKE_TRIP_WAIT_S)
|
||||
g1, g2 = _fan_gate(c, "intake_1"), _fan_gate(c, "intake_2")
|
||||
ev["intake_1_trip"] = {"cool": c, "intake_1": g1, "intake_2": g2}
|
||||
ctx.check(c.get("verdict") == "AIRFLOW", "an intake floor of %s rpm did not trip: %s", inl.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(g1.get("state") == "TRIPPED", "intake_1 does not read TRIPPED: %s", g1)
|
||||
ctx.check(g2.get("state") == "not fitted", "intake_2 was judged with one intake fan set: %s", g2)
|
||||
ctx.check("intake_1" in (c.get("reason") or ""), "the reason does not name intake_1: %r", c.get("reason"))
|
||||
c = _after_session(ctx, fc)
|
||||
ev["intake_1_trip_after"] = c
|
||||
ctx.check(c.get("verdict") == "OK" and c.get("hold") is False,
|
||||
"the fan fault outlived its run session: %s", c)
|
||||
|
||||
# Restore, and prove it: intake 2 judged again, every fan at or
|
||||
# above its floor. The restored grace is the shipped one.
|
||||
_set_gates(ctx, fc, orig)
|
||||
restored = True
|
||||
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=INTAKE_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 restore: %s", bad)
|
||||
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 INTAKE_KEYS),
|
||||
"settings not restored: %s", {k: after.get(k) for k in INTAKE_KEYS})
|
||||
ctx.check(fc.wait_idle(60, abort=ctx.aborted), "machine did not return to idle")
|
||||
@@ -1,117 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""What the extension host makes of the firmware's own version file.
|
||||
|
||||
A campaign only ever runs on a dev image - the release image does not
|
||||
ship this suite - and a dev image stamps ${DATETIME} (dev), which is no
|
||||
version at all. So the branch where a core range is actually judged
|
||||
cannot be reached from here by any machine this suite runs on, and it is
|
||||
not this module's to prove: that is forgeext's version_test, over the
|
||||
file as each image build writes it. What is left here is the half a
|
||||
machine can show, and it is the half that ties the daemon to the image:
|
||||
that the host reads the image's own file, finds a build stamp, and
|
||||
judges nothing rather than guessing.
|
||||
|
||||
Its own module rather than a test in exthost.py: a test's fingerprint
|
||||
holds the shared text of the module it lives in, and the blank lines
|
||||
between tests are shared text, so a test added to exthost.py moves the
|
||||
fingerprint of every other test there and costs a campaign a re-run of
|
||||
each - exthost.panel-install among them, which is an operator drill with
|
||||
the button held. The helpers come from exthost, and importing them puts
|
||||
that module's shared text in this test's fingerprint and not the other
|
||||
way round.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from ..catalog import test
|
||||
from .exthost import FWUP, _forgeext, _read, _write
|
||||
|
||||
|
||||
@test("exthost.core-range", title="The host reads the image's own version file and judges by it",
|
||||
subsystem="exthost", kind="auto", est_min=2,
|
||||
covers=[("forgeext", "src/install.*"), ("forgeext", "src/manifest.*"), ("forgeext", "src/main.c")],
|
||||
description="The firmware's version reaches the host from /etc/forgefirm-version, which the image "
|
||||
"build writes. This test asks the host what it made of that file rather than reading the "
|
||||
"file and believing it. On the dev image a campaign runs on, the first word is a build "
|
||||
"stamp and no version: inspect answers core_checked false, and a package asking for a "
|
||||
"minimum far above or a maximum far below is accepted all the same, because a range "
|
||||
"cannot be judged against a stamp and the host does not guess one. A dev image that "
|
||||
"began stamping something version-shaped would fail here, which is the regression this "
|
||||
"test is for; the judging branch belongs to forgeext's version_test, since no image "
|
||||
"carrying this suite can reach it. Nothing is installed, nothing runs, and nothing "
|
||||
"moves: inspect changes nothing and the work directory goes at the end.")
|
||||
def core_range(ctx):
|
||||
import io
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
ev = ctx.evidence
|
||||
|
||||
stamp = (_read("/etc/forgefirm-version", "").split() or [""])[0]
|
||||
ctx.check(stamp, "/etc/forgefirm-version says nothing: the image did not stamp itself")
|
||||
version = stamp[1:] if stamp[:1] == "v" and stamp[1:2].isdigit() else stamp
|
||||
looks_like_a_version = bool(re.match(r"^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$", version))
|
||||
ctx.log("/etc/forgefirm-version: %r -> %r", stamp, version)
|
||||
ev["firmware_version"] = {"file": stamp, "version": version, "looks_like_a_version": looks_like_a_version}
|
||||
|
||||
# The suite ships on the dev image alone, so this is the only shape a
|
||||
# campaign can meet. If it ever is not, the image changed and the
|
||||
# judging path needs a proof that is not here.
|
||||
ctx.check(not looks_like_a_version,
|
||||
"this image stamped %r, which is a version: a campaign cannot run on a release image, so "
|
||||
"either the dev image's stamp changed or this is not the image this suite belongs on", stamp)
|
||||
|
||||
work = tempfile.mkdtemp(prefix="forgetest-core-")
|
||||
try:
|
||||
def packed(core):
|
||||
"""A data package that asks for nothing, with the core range given."""
|
||||
m = {"manifest": 1, "id": "org.forgetest.corerange", "name": "forgetest core range",
|
||||
"version": "1.0.0", "author": "forgetest", "license": "MIT", "api": "0.1",
|
||||
"runtime": "data", "capabilities": []}
|
||||
if core:
|
||||
m["core"] = core
|
||||
payload = os.path.join(work, "payload.tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
data = json.dumps(m).encode()
|
||||
info = tarfile.TarInfo("manifest.json")
|
||||
info.size, info.mode = len(data), 0o644
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, "fwup.conf")
|
||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "core range"\n'
|
||||
'meta-version = "1.0.0"\nmeta-platform = "forgefirm-ext"\n'
|
||||
'file-resource payload.tar.gz {\n host-path = "%s"\n}\n' % payload)
|
||||
out = os.path.join(work, "corerange.ffx")
|
||||
if os.path.exists(out):
|
||||
os.remove(out)
|
||||
subprocess.run([FWUP, "-c", "-f", conf, "-o", out], check=True, capture_output=True,
|
||||
timeout=60, cwd=work)
|
||||
return out
|
||||
|
||||
r = _forgeext("inspect", packed(None))
|
||||
ctx.check(r.get("ok") is True, "a package with no core range was refused: %s", r.get("error"))
|
||||
ctx.check(r.get("core_checked") is False,
|
||||
"the host judged a range against the stamp %r: core_checked %s", stamp, r.get("core_checked"))
|
||||
ev["no_range"] = r
|
||||
|
||||
# A stamp is no version, so neither end of a range is judged and
|
||||
# neither refuses the package. The host says so rather than
|
||||
# refusing on a comparison it cannot make.
|
||||
ev["ranges"] = []
|
||||
for core in ({"min": "99.0.0"}, {"max": "0.0.1"}, {"min": "0.0.1", "max": "0.0.2"}):
|
||||
r = _forgeext("inspect", packed(core))
|
||||
ctx.check(r.get("ok") is True and r.get("core_checked") is False,
|
||||
"the host judged %s against the stamp anyway: %s", core, r)
|
||||
ev["ranges"].append({"core": core, "inspect": r})
|
||||
|
||||
ctx.check("org.forgetest.corerange" not in
|
||||
[x.get("id") for x in (_forgeext("list").get("packages") or [])],
|
||||
"inspect installed the package: it must change nothing")
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
@@ -1,237 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The destinations the operator names for a package that asks for them.
|
||||
|
||||
Its own module rather than a test in exthost.py, for the reason extcore.py
|
||||
gives. The package here is the reference package's id and key with a
|
||||
service of its own, so exthost's put-back takes it away like the others.
|
||||
|
||||
What it dials is the machine's own DNS resolver on TCP port 53: an address
|
||||
every machine on a network has, that is not the machine, and that answers.
|
||||
Named by address, it gives the service no resolver of its own, so the
|
||||
only way through to it is the one the operator named.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
|
||||
from ..catalog import test
|
||||
from .exthost import (EXT_ROOT, FWUP, NFT, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _read, _svc, _tree,
|
||||
_until, _write)
|
||||
from .forgectrl import lan_ip
|
||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||
|
||||
# The service: at every start, where it may connect and what one dial does.
|
||||
DEST_SERVICE = r'''
|
||||
import errno, json, os, socket, sys, time
|
||||
data, target, port = os.environ["FFX_DATA"], sys.argv[1], int(sys.argv[2])
|
||||
|
||||
|
||||
def api(method, path):
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(5)
|
||||
try:
|
||||
s.connect(os.environ["FFX_API"])
|
||||
s.sendall(("%s %s HTTP/1.1\r\nHost: forgeext\r\n\r\n" % (method, path)).encode())
|
||||
buf = b""
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf += c
|
||||
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def dial(p):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(4)
|
||||
try:
|
||||
s.connect((target, p))
|
||||
return "connected"
|
||||
except socket.timeout:
|
||||
return "timeout"
|
||||
except OSError as e:
|
||||
return errno.errorcode.get(e.errno, str(e.errno))
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
report = {"pid": os.getpid(), "destinations": (api("GET", "/v0/self") or {}).get("destinations"),
|
||||
"dial": dial(port), "dial_other_port": dial(port + 1)}
|
||||
with open(os.path.join(data, "report.json.new"), "w") as f:
|
||||
json.dump(report, f)
|
||||
os.rename(os.path.join(data, "report.json.new"), os.path.join(data, "report.json"))
|
||||
print("destinations service up: %s" % report, flush=True)
|
||||
while True:
|
||||
time.sleep(60)
|
||||
'''
|
||||
|
||||
|
||||
def _resolver():
|
||||
"""The first IPv4 nameserver of /etc/resolv.conf, or None."""
|
||||
for line in _read("/etc/resolv.conf").splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0] == "nameserver" and parts[1].count(".") == 3:
|
||||
return parts[1]
|
||||
return None
|
||||
|
||||
|
||||
def _pack_dest(work, target, port):
|
||||
"""The reference package's id and key, asking for the operator's destinations: (archive, public key)."""
|
||||
import io
|
||||
import tarfile
|
||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest destinations", "version": "1.0.0",
|
||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||
"service": {"exec": "bin/dest.py", "args": [target, str(port)]},
|
||||
"capabilities": ["net.outbound.operator"]}
|
||||
payload = os.path.join(work, "payload.tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/dest.py", DEST_SERVICE, 0o755)):
|
||||
info = tarfile.TarInfo(name)
|
||||
data = text.encode()
|
||||
info.size, info.mode = len(data), mode
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, "fwup.conf")
|
||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||
% (REF_ID, payload))
|
||||
key = os.path.join(work, REF_KEY)
|
||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "dest.ffx")
|
||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||
return signed, key + ".pub"
|
||||
|
||||
|
||||
@test("exthost.operator-destinations", title="The operator names where a package may connect, and takes it away",
|
||||
subsystem="exthost", kind="auto", hardware="api", est_min=4,
|
||||
covers=[("forgeext", "src/install.*"), ("forgeext", "src/state.*"), ("forgeext", "src/run.*"),
|
||||
("forgeext", "src/super.*"), ("forgeext", "src/api.*"), ("forgeext", "src/caps.*"),
|
||||
("forgeext", "src/manifest.*"), ("forgeext", "src/netrules.*"), ("forgeext", "src/main.c"),
|
||||
("forgectrl", "src/extpkg.*"), ("forgectrl", "src/main.c")],
|
||||
requires=["exthost.service"],
|
||||
description="A package that asks for net.outbound.operator may reach the places the operator names for it "
|
||||
"and nothing else. Installed, it has none: its own GET /v0/self names no destination and its "
|
||||
"dial to the machine's DNS resolver on TCP port 53 is refused inside the sandbox (EACCES). "
|
||||
"The operator names that resolver through POST /ext/dest; the host starts the service again "
|
||||
"(said in its log, and no crash), its answer names the destination, its dial connects, the "
|
||||
"next port up is still refused, and the account's chain in the rule table carries the "
|
||||
"address. The machine's own LAN address and loopback are refused in words, a destination "
|
||||
"out of form is 400, and taking away one that was never named is refused. Taken away, the "
|
||||
"service starts again with no destination, the dial is refused again, and the chain no longer "
|
||||
"carries the address. The package, the key, the setting, and the setup record are put back as "
|
||||
"found.")
|
||||
def operator_destinations(ctx):
|
||||
import shutil
|
||||
import tempfile
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
target = _resolver()
|
||||
ctx.check(target, "/etc/resolv.conf names no IPv4 resolver: there is nothing to dial")
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(4)
|
||||
try:
|
||||
s.connect((target, 53))
|
||||
reachable = True
|
||||
except OSError as e:
|
||||
reachable = str(e)
|
||||
finally:
|
||||
s.close()
|
||||
ev["resolver"] = {"address": target, "tcp_53_from_root": reachable}
|
||||
ctx.check(reachable is True, "the resolver %s does not take a TCP connection on port 53 even from outside the "
|
||||
"sandbox (%s): this network cannot show a way out opening", target, reachable)
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||
prior = fc.settings().get("ext_enabled") or ""
|
||||
raw = read_file(record_path())
|
||||
found_tree = _tree(EXT_ROOT)
|
||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||
etag = hdrs.get("etag")
|
||||
work = tempfile.mkdtemp(prefix="forgetest-dest.")
|
||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||
report = os.path.join(EXT_ROOT, "data", REF_ID, "report.json")
|
||||
where = "%s:53" % target
|
||||
|
||||
def seen():
|
||||
try:
|
||||
with open(report) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
def fresh(than):
|
||||
r = seen()
|
||||
return r if r and r.get("pid") != than and _svc(REF_ID).get("state") == "running" else None
|
||||
|
||||
def chain_has(addr):
|
||||
"""Does the package's own chain let it through to addr on TCP 53? No chain is no."""
|
||||
acct = _svc(REF_ID).get("account") or ""
|
||||
if not acct.startswith("ffx"):
|
||||
return False
|
||||
out = subprocess.run([NFT, "list", "chain", "inet", "ffx", "u%d" % (800 + int(acct[3:]))], capture_output=True,
|
||||
text=True, timeout=30).stdout
|
||||
return ("daddr %s tcp dport 53 accept" % addr) in out
|
||||
|
||||
try:
|
||||
archive, pub = _pack_dest(work, target, 53)
|
||||
shutil.copy(pub, owner_key)
|
||||
os.chmod(owner_key, 0o644)
|
||||
r = _forgeext("install", archive, "--consent-community")
|
||||
ctx.check(r.get("ok") is True, "the install of a package asking for the operator's destinations -> %s",
|
||||
r.get("error"))
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||
first = _until(ctx, lambda: fresh(None), 60)
|
||||
ctx.check(first, "the service did not come up: %s", _svc(REF_ID))
|
||||
ev["none_named"] = first
|
||||
ctx.log("none named: %s", first)
|
||||
ctx.check(first.get("destinations") == [] and first.get("dial") == "EACCES",
|
||||
"with nothing named it may reach nothing, and the sandbox refuses the dial: %s", first)
|
||||
ctx.check(not chain_has(target), "the rule table carries %s before the operator named it", target)
|
||||
|
||||
# Refusals, in the host's words or the relay's.
|
||||
refusals = []
|
||||
for dest, want, words in (("%s:80" % lan_ip(), 409, "is this machine"), ("127.0.0.1:80", 409, "is this machine"),
|
||||
("--root", 400, "host:port"), ("%s" % target, 409, "host:port")):
|
||||
st_, why = fc.post("/ext/dest", data={"id": REF_ID, "action": "add", "dest": dest})
|
||||
refusals.append([dest, st_, why])
|
||||
ctx.check(st_ == want and words in json.dumps(why), "add %s -> %s %s, wanted %s %s", dest, st_, why, want, words)
|
||||
st_, why = fc.post("/ext/dest", data={"id": REF_ID, "action": "remove", "dest": where})
|
||||
refusals.append(["remove " + where, st_, why])
|
||||
ctx.check(st_ == 409 and "is not one of" in json.dumps(why), "taking away one never named -> %s %s", st_, why)
|
||||
ev["refusals"] = refusals
|
||||
|
||||
# Named: started again with its way out.
|
||||
st_, doc = fc.post("/ext/dest", data={"id": REF_ID, "action": "add", "dest": where})
|
||||
mine = [p for p in (doc or {}).get("packages", []) if p.get("id") == REF_ID] if isinstance(doc, dict) else []
|
||||
ctx.check(st_ == 200 and mine and mine[0].get("destinations") == [where], "POST /ext/dest add %s -> %s %s",
|
||||
where, st_, doc if st_ != 200 else mine)
|
||||
named = _until(ctx, lambda: fresh(first.get("pid")), 60)
|
||||
ev["named"] = named
|
||||
ctx.log("named: %s", named)
|
||||
ctx.check(named and named.get("destinations") == [where] and named.get("dial") == "connected"
|
||||
and named.get("dial_other_port") == "EACCES",
|
||||
"named, it is started again, names it, reaches it, and still not the next port: %s", named)
|
||||
ctx.check(chain_has(target), "the rule table does not carry %s once named", target)
|
||||
svc = _svc(REF_ID)
|
||||
ctx.check(svc.get("state") == "running" and "quarantined" not in (svc.get("reason") or ""),
|
||||
"the restart was no crash: %s", svc)
|
||||
|
||||
# Taken away: started again with none.
|
||||
st_, doc = fc.post("/ext/dest", data={"id": REF_ID, "action": "remove", "dest": where})
|
||||
ctx.check(st_ == 200, "POST /ext/dest remove %s -> %s %s", where, st_, doc)
|
||||
gone = _until(ctx, lambda: fresh(named.get("pid") if named else None), 60)
|
||||
ev["taken_away"] = gone
|
||||
ctx.check(gone and gone.get("destinations") == [] and gone.get("dial") == "EACCES",
|
||||
"taken away, it starts again and reaches nothing: %s", gone)
|
||||
ctx.check(not chain_has(target), "the rule table still carries %s after it was taken away", target)
|
||||
finally:
|
||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||
@@ -1214,13 +1214,14 @@ def _frozen(id_):
|
||||
covers=[("forgeext", "src/super.*"), ("forgeext", "src/run.*"), ("forgeext", "src/machine.*"),
|
||||
("forgeext", "src/cgroup.*"), ("forgectrl", "src/cool.*"), ("forgectrl", "src/cam.*"),
|
||||
("forgectrl", "src/main.c")],
|
||||
requires=["exthost.service", "cloud.dark-print"], actions=["button"],
|
||||
requires=["exthost.service", "cooling.floor-and-warm-up"], actions=["button"],
|
||||
steps=[OFFLINE_STEP,
|
||||
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
||||
"when it lights."],
|
||||
description="The reference package runs, its heartbeat advancing twice a second, and a dark "
|
||||
"cloud print (cloud.dark-print's own job and checks) opens a real armed window over "
|
||||
"it. Sampled five times a second from before the button to after the end: the "
|
||||
"cloud print opens a real armed window over it: the cloud suite's dark-print "
|
||||
"helper, whose latch checks still run (locked at the button, unlocked for the run, "
|
||||
"locked after it), and the print completes. Sampled five times a second from before the button to after the end: the "
|
||||
"engine's armed flag, the group's frozen state as the kernel reports it, and the "
|
||||
"heartbeat. The window is open for at least 10 s. From 2 s after it opens to its "
|
||||
"close the group reads frozen in every sample and the heartbeat does not move, the "
|
||||
@@ -1350,195 +1351,9 @@ def armed_freeze(ctx):
|
||||
|
||||
# ------------------------------------------------------------- the hold
|
||||
|
||||
HOLDS_DIR = "/run/forgefirm/holds"
|
||||
REQUIRED_HOLDS = EXT_ROOT + "/required-holds"
|
||||
|
||||
|
||||
@test("exthost.hold-pause-tier", title="A package's hold withholds fire, and the operator's exits end it",
|
||||
subsystem="exthost", kind="auto", hardware="takeover", est_min=7,
|
||||
covers=[("forgeext", "src/holdkeep.*"), ("forgeext", "src/run.*"), ("forgeext", "src/install.*"),
|
||||
("forgeext", "src/state.*"), ("forgeext", "src/main.c"), ("forgectrl", "src/holds.*"),
|
||||
("forgectrl", "src/cool.*")],
|
||||
requires=["exthost.service"],
|
||||
description="The reference package is installed with the hold grant and its hold is marked required "
|
||||
"(the package is then named under required-holds for forgectrl). Until its service has "
|
||||
"run healthy (60 s) the required hold stands in the host's words; from then on the host "
|
||||
"keeps its hold file fresh and clear and the engine's verdict is OK. The package then raises "
|
||||
"its hold itself over its API socket (POST /v0/hold): EXT with the package's own words, "
|
||||
"words the form does not take refused with the hold unchanged, and cleared again. The test then "
|
||||
"makes the service end at every start: the host raises the hold in its own words and GET "
|
||||
"/cool/status reads verdict EXT, fire_ok false, hold true, with the reason naming the "
|
||||
"package. Safe mode ends the hold within two ticks and leaving it brings the hold back; "
|
||||
"marked advisory the hold is dropped and the verdict is OK; marked required again, with "
|
||||
"the host suspended the file goes stale and the reason becomes that the host is not "
|
||||
"answering, and resumed it is the package's again; ext_enabled=0 ends it. Nothing moves "
|
||||
"and nothing fires: the verdict is read at idle. Everything is put back as "
|
||||
"exthost.service puts it back, and the required holds are empty at the end.")
|
||||
def hold_pause_tier(ctx):
|
||||
import tempfile
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(len(_host_pids()) == 1, "the extension host is not one running process: %s", _host_pids())
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||
prior = fc.settings().get("ext_enabled") or ""
|
||||
raw = read_file(record_path())
|
||||
ctx.check(raw, "no setup record at %s", record_path())
|
||||
ctx.check(not os.path.exists(SAFE_FILE), "%s exists: the machine is in safe mode", SAFE_FILE)
|
||||
found_tree = _tree(EXT_ROOT)
|
||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||
etag = hdrs.get("etag")
|
||||
ctx.check(st == 200 and etag, "GET /advisories/extensions -> %s", st)
|
||||
|
||||
def cool():
|
||||
st_, c = fc.get("/cool/status")
|
||||
return c if st_ == 200 and isinstance(c, dict) else {}
|
||||
|
||||
def verdict_is(name, seconds, reason=None):
|
||||
def ok():
|
||||
c = cool()
|
||||
return c if c.get("verdict") == name and (reason is None or reason in (c.get("reason") or "")) else None
|
||||
return _until(ctx, ok, seconds, poll=0.25)
|
||||
|
||||
def hold_file():
|
||||
try:
|
||||
h = json.loads(_read("%s/%s.json" % (HOLDS_DIR, REF_ID)))
|
||||
except ValueError:
|
||||
return None
|
||||
h["age"] = time.monotonic() - h["ts_mono"]
|
||||
return h
|
||||
|
||||
start = cool()
|
||||
ev["verdict_at_start"] = {k: start.get(k) for k in ("verdict", "fire_ok", "hold", "reason")}
|
||||
ctx.check(start.get("verdict") == "OK", "the engine's own verdict %r stands above a hold: the test needs OK",
|
||||
start.get("verdict"))
|
||||
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
|
||||
stop_path = os.path.join(EXT_ROOT, "data", REF_ID, "stop")
|
||||
why_not_running = REF_ID + ": the extension is not running"
|
||||
try:
|
||||
archive, pub = _pack_reference(work, lan_ip(), more_caps=["hold"])
|
||||
with open(pub, "rb") as f:
|
||||
write_file(os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub"), f.read())
|
||||
os.chmod(os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub"), 0o644)
|
||||
r = _forgeext("install", archive, "--consent-community")
|
||||
ev["install_without_the_grant"] = r.get("error")
|
||||
ctx.check(r.get("ok") is False and "hold" in (r.get("error") or ""), "a hold was installed without the operator's grant: %s", r)
|
||||
r = _forgeext("install", archive, "--consent-community", "--grant", "hold")
|
||||
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
|
||||
ctx.check(not os.listdir(REQUIRED_HOLDS), "a granted hold is required before the operator marks it: %s",
|
||||
os.listdir(REQUIRED_HOLDS))
|
||||
r = _forgeext("hold", REF_ID, "required")
|
||||
ctx.check(r.get("ok") is True and os.listdir(REQUIRED_HOLDS) == [REF_ID], "marked required -> %s, named %s",
|
||||
r.get("error"), os.listdir(REQUIRED_HOLDS))
|
||||
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
ctx.check(st == 200, "ext_enabled=1 over the advisory -> %s %r", st, reply)
|
||||
beat_path = os.path.join(EXT_ROOT, "data", REF_ID, "beat")
|
||||
seen = set()
|
||||
ctx.check(_until(ctx, lambda: seen.add(_read(beat_path)) or len(seen) >= 3, 120, poll=0.2),
|
||||
"the reference service's heartbeat does not advance: %s", _svc(REF_ID) or _host_status())
|
||||
h = hold_file()
|
||||
ev["running"] = {"hold_file": h, "verdict": cool().get("verdict"), "status_hold": _svc(REF_ID).get("hold")}
|
||||
ctx.check(h and h["required"] is True and h["age"] < 1.5, "a running package's required hold is not fresh: %s", h)
|
||||
# a required hold stands until its package has run healthy (60 s): one that ends at every
|
||||
# start reads as running for a moment each time, and must not flicker clear
|
||||
ctx.check(cool().get("verdict") == "EXT" and "only just started" in (cool().get("reason") or ""),
|
||||
"a required hold is clear before its package has run healthy: %s", cool())
|
||||
ctx.check(verdict_is("OK", 100), "with the package running healthy the verdict is %s (%s)", cool().get("verdict"),
|
||||
cool().get("reason"))
|
||||
h = hold_file()
|
||||
ctx.check(h and h["raised"] is False and h["age"] < 1.5, "healthy: the hold file is not fresh and clear: %s", h)
|
||||
ctx.check(_svc(REF_ID).get("hold") == "required", "the status file does not say the hold is required: %s", _svc(REF_ID))
|
||||
|
||||
# the package's own word, over its API socket: raised in its words, and cleared
|
||||
def tell(words):
|
||||
said = os.path.join(EXT_ROOT, "data", REF_ID, "said")
|
||||
if os.path.exists(said):
|
||||
os.remove(said)
|
||||
_write(os.path.join(EXT_ROOT, "data", REF_ID, "say"), json.dumps(words))
|
||||
ctx.check(_until(ctx, lambda: os.path.exists(said), 10), "the service did not pass %s on", words)
|
||||
return json.loads(_read(said))
|
||||
|
||||
r = tell({"raised": True, "reason": "no badge presented"})
|
||||
ctx.check(r[0] == 200 and r[1] == {"raised": True, "reason": "no badge presented"}, "POST /v0/hold raise -> %s", r)
|
||||
t0 = time.time()
|
||||
c = verdict_is("EXT", 6, REF_ID + ": no badge presented")
|
||||
ev["raised_by_the_package"] = {"after_s": round(time.time() - t0, 1), "cool": {k: (c or cool()).get(k) for k in ("verdict", "fire_ok", "hold", "reason")}}
|
||||
ctx.log("the package raised its hold: %s", ev["raised_by_the_package"])
|
||||
ctx.check(c and c.get("fire_ok") is False and c.get("hold") is True, "the package's own hold does not withhold fire: %s", c or cool())
|
||||
r = tell({"raised": True, "reason": "a \"quote\""})
|
||||
ctx.check(r[0] == 400 and verdict_is("EXT", 3, REF_ID + ": no badge presented"), "words the form does not take -> %s, and the "
|
||||
"hold now reads %s", r, cool().get("reason"))
|
||||
r = tell({"raised": False})
|
||||
ctx.check(r[0] == 200 and r[1].get("raised") is False and verdict_is("OK", 6), "POST /v0/hold clear -> %s, verdict %s", r,
|
||||
cool().get("verdict"))
|
||||
|
||||
# the package can no longer speak for itself
|
||||
_write(stop_path, "")
|
||||
os.kill(_svc(REF_ID)["pid"], signal.SIGKILL)
|
||||
t0 = time.time()
|
||||
c = verdict_is("EXT", 40, why_not_running)
|
||||
ev["held"] = {"after_s": round(time.time() - t0, 1), "cool": {k: (c or {}).get(k) for k in ("verdict", "fire_ok", "hold", "reason")},
|
||||
"hold_file": hold_file()}
|
||||
ctx.log("the service ends at every start: %s", ev["held"])
|
||||
ctx.check(c and c.get("fire_ok") is False and c.get("hold") is True,
|
||||
"the hold does not withhold fire: %s", c or cool())
|
||||
# it reads as running for a moment at every start on its way to quarantine: never clear
|
||||
looks = []
|
||||
for _ in range(24):
|
||||
ctx.sleep(0.5)
|
||||
c = cool()
|
||||
looks.append((c.get("verdict"), c.get("fire_ok")))
|
||||
ev["looks_over_the_crash_loop"] = sorted(set(looks), key=str)
|
||||
ctx.check(all(v == "EXT" and ok is False for v, ok in looks),
|
||||
"the required hold flickered clear while its package kept ending: %s", ev["looks_over_the_crash_loop"])
|
||||
|
||||
# the operator's exits
|
||||
_write(SAFE_FILE, "")
|
||||
t0 = time.time()
|
||||
ctx.check(verdict_is("OK", 4), "safe mode did not end the hold within four seconds: %s", cool())
|
||||
ev["safe_mode_released_s"] = round(time.time() - t0, 1)
|
||||
os.remove(SAFE_FILE)
|
||||
ctx.check(verdict_is("EXT", 30, why_not_running), "out of safe mode the hold did not come back: %s", cool())
|
||||
|
||||
r = _forgeext("hold", REF_ID, "advisory")
|
||||
ctx.check(r.get("ok") is True and not os.listdir(REQUIRED_HOLDS), "marked advisory -> %s, still named %s",
|
||||
r.get("error"), os.listdir(REQUIRED_HOLDS))
|
||||
ctx.check(verdict_is("OK", 6), "an advisory hold of a package that is not running was not dropped: %s", cool())
|
||||
r = _forgeext("hold", REF_ID, "required")
|
||||
ctx.check(r.get("ok") is True and verdict_is("EXT", 6, why_not_running), "marked required again: %s", cool())
|
||||
|
||||
# the host itself gone quiet: the file goes stale, and that is what stands
|
||||
host = _host_pids()[0]
|
||||
os.kill(host, signal.SIGSTOP)
|
||||
try:
|
||||
c = verdict_is("EXT", 8, REF_ID + ": the extension host is not answering")
|
||||
ev["host_suspended"] = {k: (c or cool()).get(k) for k in ("verdict", "fire_ok", "reason")}
|
||||
ctx.check(c and c.get("fire_ok") is False, "with the host suspended the required hold does not stand on its "
|
||||
"stale file: %s", c or cool())
|
||||
finally:
|
||||
os.kill(host, signal.SIGCONT)
|
||||
ctx.check(verdict_is("EXT", 8, why_not_running), "the host resumed: the hold is not the package's again: %s", cool())
|
||||
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
|
||||
ctx.check(st == 200, "ext_enabled=0 -> %s %r", st, reply)
|
||||
t0 = time.time()
|
||||
ctx.check(verdict_is("OK", 4), "ext_enabled=0 did not end the hold within four seconds: %s", cool())
|
||||
ev["ext_off_released_s"] = round(time.time() - t0, 1)
|
||||
ctx.log("PASS: held in %s s; safe mode released it in %s s, extensions off in %s s", ev["held"]["after_s"],
|
||||
ev["safe_mode_released_s"], ev["ext_off_released_s"])
|
||||
finally:
|
||||
for pid in _host_pids():
|
||||
try:
|
||||
os.kill(pid, signal.SIGCONT)
|
||||
except OSError:
|
||||
pass
|
||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||
ctx.check(not os.listdir(REQUIRED_HOLDS), "a required hold is still named at the end: %s", os.listdir(REQUIRED_HOLDS))
|
||||
ctx.check(_until(ctx, lambda: cool().get("verdict") == "OK", 6), "the verdict at the end is %s", cool().get("verdict"))
|
||||
|
||||
|
||||
# ------------------------------------------------- the operator's door
|
||||
|
||||
@test("exthost.events", title="The machine's events reach a package, over one subscription",
|
||||
@@ -1932,8 +1747,8 @@ def ui_delivery(ctx):
|
||||
"package, under the machine lease, and the machine's own job record names it as the "
|
||||
"owner and counts the program's lines. A dark program commands no laser, so no armed "
|
||||
"window opens and there is nothing for a press to arm: the arm gates are in force and "
|
||||
"are never reached, and a job that fires is cloud.dark-print's and "
|
||||
"laser.emission-witness's. Every line is sent and acknowledged, and the runner ends the "
|
||||
"are never reached, and a job that fires is laser.emission-witness's. "
|
||||
"Every line is sent and acknowledged, and the runner ends the "
|
||||
"job as one that ended without a discharge - which is what a program that commands no "
|
||||
"laser is, and is the outcome this test wants. The job's own emission witnesses are all "
|
||||
"zero. The head accelerometer sees the motion and the "
|
||||
@@ -2003,7 +1818,7 @@ def motion_job(ctx):
|
||||
# it as this package. A dark program commands no laser, so no
|
||||
# armed window opens and there is nothing for a press to arm -
|
||||
# the arm gates are in force and simply never reached. A job
|
||||
# that fires is cloud.dark-print's and laser.emission-witness's.
|
||||
# that fires is laser.emission-witness's.
|
||||
def job_rec():
|
||||
st_, j_ = fc.get("/job")
|
||||
return j_ if isinstance(j_, dict) else {}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The host's own events: a service is told before it is stopped.
|
||||
|
||||
Its own module, for the reason extcore.py gives. The package here is the
|
||||
reference package's id and key with a service of its own, so exthost's
|
||||
put-back takes it away like the others.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until, _write)
|
||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||
|
||||
# The service: it follows the host's event feed and keeps every host event it reads, with the
|
||||
# monotonic time it read it, in its data directory.
|
||||
LIFE_SERVICE = r'''
|
||||
import json, os, socket, time
|
||||
data = os.environ["FFX_DATA"]
|
||||
|
||||
|
||||
def api(body):
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(40)
|
||||
try:
|
||||
s.connect(os.environ["FFX_API"])
|
||||
payload = json.dumps(body).encode()
|
||||
s.sendall(b"POST /v0/events HTTP/1.1\r\nHost: forgeext\r\nContent-Type: application/json\r\n"
|
||||
b"Content-Length: %d\r\n\r\n" % len(payload) + payload)
|
||||
buf = b""
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf += c
|
||||
return json.loads(buf.partition(b"\r\n\r\n")[2] or b"null")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def keep(name, doc):
|
||||
with open(os.path.join(data, name + ".new"), "w") as f:
|
||||
json.dump(doc, f)
|
||||
os.rename(os.path.join(data, name + ".new"), os.path.join(data, name))
|
||||
|
||||
|
||||
place = api({})["next"]
|
||||
keep("ready.json", {"place": place, "t": time.monotonic()})
|
||||
got = []
|
||||
while True:
|
||||
ans = api({"since": place, "wait": 20})
|
||||
for e in ans.get("events") or []:
|
||||
if e.get("event", "").startswith("ext."):
|
||||
got.append({"event": e["event"], "data": e.get("data"), "t": time.monotonic()})
|
||||
keep("host-events.json", got)
|
||||
place = ans.get("next", place)
|
||||
'''
|
||||
|
||||
|
||||
def _pack_life(work):
|
||||
import io
|
||||
import tarfile
|
||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest lifecycle", "version": "1.0.0",
|
||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||
"service": {"exec": "bin/life.py"}, "capabilities": ["events"]}
|
||||
payload = os.path.join(work, "payload.tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/life.py", LIFE_SERVICE, 0o755)):
|
||||
info = tarfile.TarInfo(name)
|
||||
data = text.encode()
|
||||
info.size, info.mode = len(data), mode
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, "fwup.conf")
|
||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||
% (REF_ID, payload))
|
||||
key = os.path.join(work, REF_KEY)
|
||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "life.ffx")
|
||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||
return signed, key + ".pub"
|
||||
|
||||
|
||||
@test("exthost.lifecycle", title="A package is told, in the feed it reads, before the host stops it",
|
||||
subsystem="exthost", kind="auto", hardware="api", est_min=3,
|
||||
covers=[("forgeext", "src/run.*"), ("forgeext", "src/machine.*"), ("forgeext", "src/evfeed.*"),
|
||||
("forgeext", "src/api.*")],
|
||||
requires=["exthost.service"],
|
||||
description="A package that holds events reads the host's own events beside the machine's. With the "
|
||||
"package running and following its feed, extensions are turned off: the host puts "
|
||||
"ext.shutdown into the feed with the reason (\"extensions are off (ext_enabled)\"), and "
|
||||
"stops the service a second later, so that the package has read it - the event is in its "
|
||||
"data directory, read before its process ended. Holds do not wait for that second: they "
|
||||
"follow extensions off at once, as exthost.hold-pause-tier holds. ext.will_freeze and "
|
||||
"ext.thawed, which an armed window brings, are the automation package's host test's "
|
||||
"(automation_test.py, where a service with job_time.run reads both). The package, the "
|
||||
"key, the setting, and the setup record are put back as found.")
|
||||
def lifecycle(ctx):
|
||||
import shutil
|
||||
import tempfile
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(timeout=30, abort=ctx.aborted), "machine not idle: settings are locked")
|
||||
prior = fc.settings().get("ext_enabled") or ""
|
||||
raw = read_file(record_path())
|
||||
found_tree = _tree(EXT_ROOT)
|
||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||
etag = hdrs.get("etag")
|
||||
work = tempfile.mkdtemp(prefix="forgetest-life.")
|
||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||
data = os.path.join(EXT_ROOT, "data", REF_ID)
|
||||
|
||||
def doc(name):
|
||||
try:
|
||||
with open(os.path.join(data, name)) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
try:
|
||||
archive, pub = _pack_life(work)
|
||||
shutil.copy(pub, owner_key)
|
||||
os.chmod(owner_key, 0o644)
|
||||
r = _forgeext("install", archive, "--consent-community")
|
||||
ctx.check(r.get("ok") is True, "the install -> %s", r.get("error"))
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||
ready = _until(ctx, lambda: _svc(REF_ID).get("state") == "running" and doc("ready.json"), 60)
|
||||
ctx.check(ready, "the service did not come up and follow its feed: %s", _svc(REF_ID))
|
||||
time.sleep(1.0)
|
||||
pid = _svc(REF_ID).get("pid")
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "0"})
|
||||
ctx.check(st == 200, "ext_enabled=0 -> %s %r", st, reply)
|
||||
stopped = _until(ctx, lambda: _svc(REF_ID).get("state") != "running" and time.monotonic(), 20)
|
||||
ctx.check(stopped, "the service was not stopped: %s", _svc(REF_ID))
|
||||
ctx.check(not os.path.exists("/proc/%s" % pid), "its process %s outlived the stop", pid)
|
||||
got = doc("host-events.json") or []
|
||||
ev["host_events"] = got
|
||||
ctx.log("what it read: %s", got)
|
||||
ctx.check(len(got) >= 1 and got[0].get("event") == "ext.shutdown"
|
||||
and (got[0].get("data") or {}).get("reason") == "extensions are off (ext_enabled)",
|
||||
"it read ext.shutdown, with the reason, before it was stopped: %s", got)
|
||||
finally:
|
||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||
@@ -1,238 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""An M-code a package answers, on the machine.
|
||||
|
||||
Its own module, for the reason extcore.py gives. The package is the
|
||||
reference package's id and key with a service of its own that answers
|
||||
M160 on its call socket, so exthost's put-back takes it away like the
|
||||
others. The jobs are dark: they move and never command the laser.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from ..catalog import test
|
||||
from .exthost import (EXT_ROOT, FWUP, REF_ID, REF_KEY, _as_found, _forgeext, _put_back, _svc, _tree, _until, _write)
|
||||
from .motion import _job, _job_post, _job_wait, _words, kernel_start, kernel_xy_mm, machine_idle
|
||||
from .setup import SAFETY_PHRASE, read_file, record_path, request
|
||||
|
||||
# The service: it answers M160 on the call socket the host hands it (fd 4), and keeps every call it was
|
||||
# asked, with the monotonic time it was asked, in its data directory. P1 takes 2 s and is done; any
|
||||
# other P is refused.
|
||||
MC_SERVICE = r'''
|
||||
import json, os, socket, time
|
||||
data = os.environ["FFX_DATA"]
|
||||
lst = socket.socket(fileno=int(os.environ["FFX_CALL_FD"]))
|
||||
calls = []
|
||||
while True:
|
||||
c, _ = lst.accept()
|
||||
try:
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
k = c.recv(4096)
|
||||
if not k:
|
||||
break
|
||||
buf += k
|
||||
head, _, body = buf.partition(b"\r\n\r\n")
|
||||
n = 0
|
||||
for line in head.split(b"\r\n")[1:]:
|
||||
k, _, v = line.partition(b":")
|
||||
if k.strip().lower() == b"content-length":
|
||||
n = int(v)
|
||||
while len(body) < n:
|
||||
k = c.recv(4096)
|
||||
if not k:
|
||||
break
|
||||
body += k
|
||||
req = json.loads(body[:n] or b"{}")
|
||||
path = head.split(b" ")[1].decode()
|
||||
t0 = time.monotonic()
|
||||
words = req.get("words", {})
|
||||
if path == "/mcode" and words.get("P") == 1:
|
||||
time.sleep(2.0)
|
||||
status, ans = 200, {"message": "forgetest P1"}
|
||||
elif path == "/mcode":
|
||||
status, ans = 409, {"error": "the stand-in refused"}
|
||||
else:
|
||||
status, ans = 404, {"error": "no such call"}
|
||||
calls.append({"path": path, "req": req, "t": t0, "status": status})
|
||||
with open(os.path.join(data, "calls.json.new"), "w") as f:
|
||||
json.dump(calls, f)
|
||||
os.rename(os.path.join(data, "calls.json.new"), os.path.join(data, "calls.json"))
|
||||
out = json.dumps(ans).encode()
|
||||
c.sendall(b"HTTP/1.1 %d X\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"
|
||||
% (status, len(out)) + out)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
c.close()
|
||||
'''
|
||||
|
||||
|
||||
def _pack_mcode(work):
|
||||
import io
|
||||
import tarfile
|
||||
manifest = {"manifest": 1, "id": REF_ID, "name": "forgetest M-code", "version": "1.0.0",
|
||||
"author": "forgetest", "license": "MIT", "api": "0.1", "runtime": "python",
|
||||
"service": {"exec": "bin/mcode.py"}, "capabilities": ["mcode:160", "job_time.run"]}
|
||||
payload = os.path.join(work, "payload.tar.gz")
|
||||
with tarfile.open(payload, "w:gz") as t:
|
||||
for name, text, mode in (("manifest.json", json.dumps(manifest), 0o644), ("bin/mcode.py", MC_SERVICE, 0o755)):
|
||||
info = tarfile.TarInfo(name)
|
||||
data = text.encode()
|
||||
info.size, info.mode = len(data), mode
|
||||
t.addfile(info, io.BytesIO(data))
|
||||
conf = os.path.join(work, "fwup.conf")
|
||||
_write(conf, 'meta-product = "ForgeFIRM extension"\nmeta-description = "%s"\nmeta-version = "1.0.0"\n'
|
||||
'meta-platform = "forgefirm-ext"\nfile-resource payload.tar.gz {\n host-path = "%s"\n}\n'
|
||||
% (REF_ID, payload))
|
||||
key = os.path.join(work, REF_KEY)
|
||||
raw, signed = os.path.join(work, "raw.ffx"), os.path.join(work, "mcode.ffx")
|
||||
for cmd in ([FWUP, "-g", "-o", key], [FWUP, "-c", "-f", conf, "-o", raw],
|
||||
[FWUP, "-S", "-s", key + ".priv", "-i", raw, "-o", signed]):
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60, cwd=work)
|
||||
return signed, key + ".pub"
|
||||
|
||||
|
||||
def _port_state(fc):
|
||||
st, body = fc.get("/motion/state")
|
||||
return body if st == 200 and isinstance(body, dict) else {}
|
||||
|
||||
|
||||
@test("exthost.mcode", title="A job waits at an M-code a package answers, dark and still",
|
||||
subsystem="exthost", kind="auto", mode="grbl", est_min=4,
|
||||
covers=[("grblhal-glowforge", "src/glowforge_mcode.*"), ("grblhal-glowforge", "src/ctlport.*"),
|
||||
("forgectrl", "src/mcode.*"), ("forgectrl", "src/extpkg.*"), ("forgectrl", "src/grblport.*"),
|
||||
("forgeext", "src/main.c"), ("forgeext", "src/run.*"), ("forgeext", "src/caps.*"),
|
||||
("forgeext", "src/manifest.*"), ("forgeext", "src/call.*")],
|
||||
requires=["exthost.service", "motion.job"],
|
||||
steps=["Bed clear; the head needs 10 mm of free travel toward +X. Nobody touches the gantry."],
|
||||
description="A package that asks for mcode:160 and job_time.run, the one granted, is installed and "
|
||||
"turned on; its service answers POST /mcode on its call socket. The host's status names it "
|
||||
"as the one that answers M160. A dark job (out 5 mm, M160 P1, back) plays through POST "
|
||||
"/job: at the M-code the controller's port state names M160 with P 1, the head stands "
|
||||
"still across the wait (the kernel's counters and the port's position unchanged over "
|
||||
"1 s), and the service answers after 2 s; the job then goes on and ends done, every line "
|
||||
"acknowledged, the head back where it began, the service asked once with the code and "
|
||||
"its words, no discharge. A job that names M161, which nothing answers, fails at that "
|
||||
"line with nothing moved. A job at M160 P2, which the service refuses, is held: the "
|
||||
"port's state goes to Hold with the head still, and the job is aborted from there. "
|
||||
"Everything is put back as exthost.service puts it back; the refusals the controller "
|
||||
"makes of a table or an answer out of form, the timeout, the reset, and the barrier's "
|
||||
"darkness under an open armed window are the driver's mcode_test harness's.")
|
||||
def mcode(ctx):
|
||||
import tempfile
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
machine_idle(ctx)
|
||||
prior = fc.settings().get("ext_enabled") or ""
|
||||
raw = read_file(record_path())
|
||||
found_tree = _tree(EXT_ROOT)
|
||||
dir_mode = os.stat(os.path.dirname(EXT_ROOT)).st_mode & 0o7777
|
||||
st, body, hdrs = request(fc.base, "GET", "/advisories/extensions", headers={"Host": fc.host_header()})
|
||||
etag = hdrs.get("etag")
|
||||
work = tempfile.mkdtemp(prefix="forgetest-ffx.")
|
||||
owner_key = os.path.join(EXT_ROOT, "keys", REF_KEY + ".pub")
|
||||
calls_file = os.path.join(EXT_ROOT, "data", REF_ID, "calls.json")
|
||||
|
||||
def calls():
|
||||
try:
|
||||
return json.loads(read_file(calls_file) or "[]")
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
try:
|
||||
archive, pub = _pack_mcode(work)
|
||||
import shutil
|
||||
shutil.copy(pub, owner_key)
|
||||
os.chmod(owner_key, 0o644)
|
||||
r = _forgeext("install", archive, "--consent-community", "--grant", "job_time.run")
|
||||
ctx.check(r.get("ok") is True, "the install with job_time.run granted -> %s", r.get("error"))
|
||||
st, reply = fc.post("/settings", data={"ext_enabled": "1", "advisory": etag, "phrase": SAFETY_PHRASE})
|
||||
ctx.check(st == 200, "ext_enabled=1 -> %s %r", st, reply)
|
||||
x = _until(ctx, lambda: _svc(REF_ID) if _svc(REF_ID).get("state") == "running" else None, 90, poll=0.5)
|
||||
ctx.check(x, "the service is not running: %s", _svc(REF_ID))
|
||||
|
||||
def listed():
|
||||
try:
|
||||
doc = json.loads(read_file("/run/forgefirm/ext/status.json") or "{}")
|
||||
except ValueError:
|
||||
return None
|
||||
return doc.get("mcodes") if doc.get("mcodes") == [{"code": 160, "id": REF_ID}] else None
|
||||
ev["status_mcodes"] = _until(ctx, listed, 30, poll=0.5)
|
||||
ctx.check(ev["status_mcodes"], "the host's status does not name the package as the one that answers M160")
|
||||
ctx.sleep(3.0) # the relay tells the controller within its 2 s
|
||||
|
||||
# A dark job that waits at M160 P1 for 2 s.
|
||||
x0, _y0 = kernel_start(ctx)
|
||||
st, body = _job_post(fc, "(forgetest exthost.mcode)\nG21\nG91\nG1 X5 F1200\nM160 P1\nG1 X-5 F1200\nG90\n")
|
||||
ctx.check(st == 200 and isinstance(body, dict) and body.get("state") == "running",
|
||||
"POST /job -> %s %s", st, _words(body)[:200])
|
||||
waiting = _until(ctx, lambda: _port_state(fc) if isinstance(_port_state(fc).get("mcode"), dict) else None,
|
||||
20, poll=0.1)
|
||||
ctx.check(waiting and waiting["mcode"].get("code") == 160 and waiting["mcode"].get("words") == {"P": 1},
|
||||
"the port's state does not name M160 P1 while the job waits: %s", waiting)
|
||||
k1 = kernel_xy_mm(ctx)
|
||||
ctx.sleep(1.0)
|
||||
k2 = kernel_xy_mm(ctx)
|
||||
still = _port_state(fc)
|
||||
ev["wait"] = {"port": waiting, "kernel": [k1, k2], "port_after_1s": still}
|
||||
ctx.log("the job waits at M160: port %s, kernel %s -> %s", waiting, k1, k2)
|
||||
ctx.check(abs(k2[0] - k1[0]) < 0.001 and abs(k2[1] - k1[1]) < 0.001,
|
||||
"the head moved while the job waited: %s -> %s", k1, k2)
|
||||
ctx.check(still.get("mpos") == waiting.get("mpos") and still.get("state") == "Idle",
|
||||
"the port's position or state moved while the job waited: %s -> %s", waiting, still)
|
||||
ctx.check(abs(k1[0] - x0 - 5.0) < 0.05, "the job did not wait at the M-code 5 mm out: %.3f", k1[0] - x0)
|
||||
rec, far = _job_wait(ctx, fc, 40, x0)
|
||||
x1 = kernel_xy_mm(ctx)[0]
|
||||
got = calls()
|
||||
ev["job"] = {"record": rec, "farthest_mm": round(far, 3), "end_mm": round(x1 - x0, 3), "calls": got}
|
||||
ctx.log("the job at M160 P1: %s", ev["job"])
|
||||
ctx.check(rec["state"] == "done" and rec["reason"] == "", "the job did not end well: %s", rec)
|
||||
ctx.check(rec["sent"] == rec["acked"] == rec["lines"] + 1, "lines %s, sent %s, acked %s",
|
||||
rec["lines"], rec["sent"], rec["acked"])
|
||||
ctx.check(rec["lit"] is False and rec["emission"]["laser_on_samples"] == 0, "a dark job's witnesses: %s",
|
||||
rec["emission"])
|
||||
ctx.check(abs(x1 - x0) < 0.1, "the job did not end where it began: %.3f mm off", x1 - x0)
|
||||
ctx.check(len(got) == 1 and got[0]["path"] == "/mcode" and got[0]["req"] == {"code": 160, "words": {"P": 1}}
|
||||
and got[0]["status"] == 200, "the service was not asked M160 P1 once: %s", got)
|
||||
|
||||
# M161: nothing answers it, and the job stops where it is parsed.
|
||||
x0 = kernel_xy_mm(ctx)[0]
|
||||
st, body = _job_post(fc, "G21\nG91\nM161\nG1 X5 F1200\nG1 X-5 F1200\nG90\n")
|
||||
rec, far = _job_wait(ctx, fc, 20, x0)
|
||||
ev["unanswered"] = {"record": rec, "farthest_mm": round(far, 3)}
|
||||
ctx.log("the job at M161: %s", ev["unanswered"])
|
||||
ctx.check(rec["state"] == "failed" and "answered error:20" in (rec.get("reason") or ""),
|
||||
"a job at M161 did not fail with error:20: %s", rec)
|
||||
ctx.check(far < 0.01, "a job at an M-code nothing answers moved the head %.3f mm", far)
|
||||
|
||||
# M160 P2: the service refuses it, and the job is held there.
|
||||
x0 = kernel_xy_mm(ctx)[0]
|
||||
st, body = _job_post(fc, "G21\nG91\nM160 P2\nG1 X5 F1200\nG1 X-5 F1200\nG90\n")
|
||||
held = _until(ctx, lambda: _port_state(fc) if _port_state(fc).get("state") == "Hold" else None, 30, poll=0.2)
|
||||
ev["refused"] = {"port": held, "calls": calls()}
|
||||
ctx.log("the job at M160 P2: %s", ev["refused"])
|
||||
ctx.check(held and held.get("mcode") is None, "a job at a refused M160 is not held: %s", _port_state(fc))
|
||||
ctx.sleep(1.0)
|
||||
ctx.check(abs(kernel_xy_mm(ctx)[0] - x0) < 0.01, "the held job moved the head")
|
||||
st, body = fc.post("/job/abort")
|
||||
ctx.check(st == 200, "POST /job/abort -> %s %s", st, _words(body)[:160])
|
||||
rec, _far = _job_wait(ctx, fc, 20, x0)
|
||||
ctx.check(rec["state"] == "failed", "the held job did not end aborted: %s", rec)
|
||||
if _port_state(fc).get("state") == "Alarm":
|
||||
st, body = _job_post(fc, "G21\n", unlock="1")
|
||||
_job_wait(ctx, fc, 20, x0)
|
||||
ctx.check(_port_state(fc).get("state") == "Idle", "the controller is not idle after the abort: %s",
|
||||
_port_state(fc))
|
||||
finally:
|
||||
# A check that failed mid-job leaves the job running; it holds the settings until it ends.
|
||||
if _job(fc).get("state") == "running":
|
||||
fc.post("/job/abort")
|
||||
_job_wait(ctx, fc, 20)
|
||||
_put_back(ctx, fc, work, prior, etag, raw, dir_mode)
|
||||
_as_found(ctx, fc, prior, raw, dir_mode, found_tree)
|
||||
@@ -3,13 +3,12 @@
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""A camera home's offset pair, on the machine.
|
||||
"""The helpers for a test that lets the web service move the head.
|
||||
|
||||
Its own module, so that no other test's fingerprint moves. The homing is
|
||||
the web-service session cloud.mode-switch runs. The helpers for a test
|
||||
that lets the service move the head live here too: the motions ran whole,
|
||||
and the head goes back to where the test found it, by the travel the
|
||||
service's motions logged. No counter reading can say where that was: every
|
||||
The homing is the web-service session cloud.mode-switch runs. These judge
|
||||
that its motions ran whole, and take the head back to where the test found
|
||||
it by the travel the service's motions logged. No counter reading can say
|
||||
where that was: every
|
||||
service motion zeroes the step counters at its start, and a home and every
|
||||
controller start zero them again.
|
||||
"""
|
||||
@@ -18,13 +17,11 @@ import re
|
||||
import sys
|
||||
|
||||
from ..baseline import XY_STEPS_PER_MM, counter_frame, counter_steps_per_mm, read_position
|
||||
from ..catalog import test
|
||||
from ..runner import Failed
|
||||
from . import cloud as logs # the log paths, read at each call
|
||||
from .cloud import _HOMING_PATH, gfhome_homing, log_lines_since, log_size
|
||||
from .motion import _drop_reference, clean_slate, machine_idle, wait_idle, wait_state
|
||||
from .cloud import log_lines_since, log_size
|
||||
from .motion import _drop_reference, clean_slate, machine_idle, wait_idle
|
||||
|
||||
HOME_X, HOME_Y = 4.5, -3.25
|
||||
RETURN_MAX_MM = 600.0 # no hand-back jogs farther on an axis: the bed is smaller
|
||||
# The client's own record of a motion's end: the kernel counters, which
|
||||
# the motion zeroed at its start (the "(actual/expected)" line of the same
|
||||
@@ -169,73 +166,3 @@ def _jog_back(ctx, rec, travel, since):
|
||||
_drop_reference(ctx, ctx.forgectrl)
|
||||
ctx.counters_rezeroed()
|
||||
ctx.log("head returned to where the test found it (%.3f/%.3f mm)", -dx, -dy)
|
||||
|
||||
|
||||
@test("homing.cloud-offsets", title="A camera home declares its offset pair, and the envelope reaches back to a "
|
||||
"negative one",
|
||||
subsystem="homing", kind="auto", mode="grbl", est_min=7,
|
||||
covers=_HOMING_PATH + [("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||
("grblhal-glowforge", "src/glowforge_cooling.*")],
|
||||
requires=["forgectrl.auth", "motion.pacing"],
|
||||
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. The head "
|
||||
"travels to the camera home and back to where it was."],
|
||||
description="With cloud mode on, homing_mode = gfcloud and the camera-home offsets set to (4.5, -3.25), "
|
||||
"$H runs the web-service homing session. Every service motion of the session runs whole: "
|
||||
"the runner reports to the cooling engine in the controller's place, and one stopped short "
|
||||
"(the engine's stop, a fault) leaves the head short of the home and the position declared "
|
||||
"there false. When the session ends, the controller declares the position (4.5, -3.25): a "
|
||||
"camera-home offset may be negative, and the declared position is snapped to the step "
|
||||
"grid. The work envelope reaches back to the home on the negative axis, since the head "
|
||||
"stands there: a jog 2 mm out in +Y and 2 mm back ends at the home, accepted, and the same "
|
||||
"in X. The homing mode and the offsets are put back as found, the camera home is dropped, "
|
||||
"and the head is jogged back to where the test found it by the travel the session's "
|
||||
"motions logged; a travel that cannot be known fails the test and moves nothing. That an "
|
||||
"offset past the axis travel is refused, and the envelope's edge on the grid, are the "
|
||||
"driver's unit and harness cases.")
|
||||
def cloud_offsets(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
s0 = fc.settings() or {}
|
||||
ctx.check(s0.get("cloud_enabled") == "1", "cloud mode is off: a camera home needs it on")
|
||||
found = {k: s0.get(k) or "" for k in ("homing_mode", "gfcloud_home_x", "gfcloud_home_y")}
|
||||
ev["found"] = found
|
||||
session_at = None
|
||||
try:
|
||||
for k, v in (("homing_mode", "gfcloud"), ("gfcloud_home_x", str(HOME_X)), ("gfcloud_home_y", str(HOME_Y))):
|
||||
st, body = fc.post("/settings", data={k: v})
|
||||
ctx.check(st == 200, "%s=%s -> %s %s", k, v, st, body)
|
||||
with ctx.grbl() as g:
|
||||
session_at = session_mark()
|
||||
gfhome_homing(ctx, ev, g)
|
||||
judge_whole_motions(ctx, ev, session_at)
|
||||
mpos = g.status_report().get("MPos") or (None, None, None)
|
||||
ev["declared"] = mpos
|
||||
ctx.log("the camera home declared %s", mpos)
|
||||
ctx.check(mpos[0] is not None and abs(mpos[0] - HOME_X) < 0.02 and abs(mpos[1] - HOME_Y) < 0.02,
|
||||
"the camera home did not declare (%.2f, %.2f): %s", HOME_X, HOME_Y, mpos)
|
||||
jogs = {}
|
||||
for axis, home in (("Y", HOME_Y), ("X", HOME_X)):
|
||||
out = g.command("$J=G91 G21 %s2 F1000" % axis, timeout=10)
|
||||
wait_state(ctx, g, "Idle", 20)
|
||||
back = g.command("$J=G91 G21 %s-2 F1000" % axis, timeout=10)
|
||||
wait_state(ctx, g, "Idle", 20)
|
||||
at = g.status_report().get("MPos")
|
||||
jogs[axis] = {"out": out[-1:], "back": back[-1:], "at": at}
|
||||
ctx.check(out[-1:] == ["ok"] and back[-1:] == ["ok"],
|
||||
"a jog out and back to the home in %s was refused: %s", axis, jogs[axis])
|
||||
i = "XY".index(axis)
|
||||
ctx.check(at and abs(at[i] - home) < 0.02, "the jog back in %s did not end at the home: %s", axis, at)
|
||||
ev["jogs"] = jogs
|
||||
ctx.log("out and back to the home: %s", jogs)
|
||||
finally:
|
||||
# The settings are refused for a moment after the suite's Grbl client closes; each is put back when taken.
|
||||
for k, v in found.items():
|
||||
def put():
|
||||
st_, body_ = (fc.post("/settings", params={k: ""}) if v == "" else fc.post("/settings", data={k: v}))
|
||||
if st_ != 200:
|
||||
ctx.log("restore %s=%r -> %s %s", k, v, st_, body_)
|
||||
return st_ == 200 or None
|
||||
took = ctx.wait_for(put, 20, poll=0.5)
|
||||
ctx.log("restore %s=%r -> %s", k, v, "taken" if took is not None else "not taken")
|
||||
if session_at is not None:
|
||||
camera_home_return(ctx, ev, session_at)
|
||||
|
||||
@@ -30,7 +30,7 @@ from ..catalog import test
|
||||
from .. import hw
|
||||
from ..runner import Failed
|
||||
from .motion import (kernel_xy_mm, kernel_start, check_kernel_returned, wait_state,
|
||||
wait_state_text, wait_left_state, wait_idle, drain_text, Watch, machine_idle,
|
||||
wait_state_text, wait_left_state, drain_text, Watch, machine_idle,
|
||||
_job, _job_post, _job_wait, _lease_holder, _port_state, _words)
|
||||
|
||||
_LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "**"),
|
||||
@@ -652,86 +652,6 @@ def m5_rapid_dark(ctx):
|
||||
peak, hv_tail)
|
||||
|
||||
|
||||
@test("laser.disarm-in-hold", title="Disarm grace counts down in Hold", subsystem="laser",
|
||||
kind="live", mode="grbl", est_min=4,
|
||||
covers=_LASER_COVERS,
|
||||
requires=["laser.emission-witness"], actions=["button"],
|
||||
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white. Nothing else: the test holds the "
|
||||
"job itself and waits about a minute for the disarm."],
|
||||
description="Arm and start a +X move at S400/F300, feed-hold it after ~2 s of motion, and "
|
||||
"hold: the disarm grace must count down while held and close the armed window "
|
||||
"(armed -> false) without the job resuming, and the button goes dark with it.")
|
||||
def disarm_in_hold(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
k0 = kernel_start(ctx)
|
||||
ctx.ready(ARM_CUE % "40 mm +X")
|
||||
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
|
||||
ctx.log("armed; waiting for motion to start (arm + your button press)...")
|
||||
w = Watch(g)
|
||||
# The press waits for the button to light: pressed before the job
|
||||
# reaches its arm wait, it is lost, and the move never starts.
|
||||
ctx.arm_press()
|
||||
ctx.wait_for(w.in_state("Run"), 180)
|
||||
ctx.clear_notice()
|
||||
st = w.last["state"] if w.last else None
|
||||
ctx.check(st and st.startswith("Run"), "motion never started (state=%s) - arm refused or no press", st)
|
||||
ctx.log("moving under laser: %s; feed-hold in 2 s", st)
|
||||
ctx.sleep(2)
|
||||
g.realtime(ord("!"))
|
||||
t1 = time.time()
|
||||
while time.time() - t1 < 5:
|
||||
st = g.status_report()["state"]
|
||||
if st.startswith("Hold"):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
ev["held_state"] = st
|
||||
ctx.log("feed-held mid-move: %s; watching the disarm grace count down IN HOLD", st)
|
||||
ctx.check(st.startswith("Hold"), "feed hold did not park (state %s)", st)
|
||||
t0 = time.time()
|
||||
disarmed_at = None
|
||||
left_hold = None
|
||||
while time.time() - t0 < 120:
|
||||
ctx.checkpoint()
|
||||
s = sample(ctx)
|
||||
held = g.status_report()["state"].startswith("Hold")
|
||||
if s and not s["armed"]:
|
||||
disarmed_at = time.time() - t0
|
||||
break
|
||||
if not held and left_hold is None:
|
||||
left_hold = g.status_report()["state"]
|
||||
ctx.log("note: left Hold (state=%s) before disarm", left_hold)
|
||||
time.sleep(1)
|
||||
ev["disarmed_after_s"] = round(disarmed_at, 1) if disarmed_at is not None else None
|
||||
ev["left_hold"] = left_hold
|
||||
# recover: laser off, abort out of hold
|
||||
g.command("M5", timeout=1)
|
||||
g.realtime(0x18)
|
||||
ctx.sleep(1)
|
||||
if "Alarm" in g.status_report()["state"]:
|
||||
g.command("$X")
|
||||
# The hold parks the head part-way through the move and the reset
|
||||
# abandons the rest of it, so the job ends wherever the feed hold
|
||||
# caught it - about 11 mm in, on the bench reference. That is the
|
||||
# test's own doing and it goes back: the kernel counters say how
|
||||
# far the head actually went, which is what a jog has to undo.
|
||||
wait_idle(ctx, g, 15)
|
||||
dx, dy = kernel_xy_mm(ctx)
|
||||
back_x, back_y = k0[0] - dx, k0[1] - dy
|
||||
ev["left_at_mm"] = [round(dx - k0[0], 3), round(dy - k0[1], 3)]
|
||||
if abs(back_x) >= 0.02 or abs(back_y) >= 0.02:
|
||||
ctx.log("returning the head: %+.3f/%+.3f mm", back_x, back_y)
|
||||
g.command("$J=G91X%.3fY%.3fF1200" % (back_x, back_y))
|
||||
wait_idle(ctx, g, 30)
|
||||
ev["returned_mm"] = list(kernel_xy_mm(ctx))
|
||||
ctx.check(disarmed_at is not None, "still armed after 120 s in Hold")
|
||||
ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold)
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.log("PASS: disarmed in Hold after %.1f s, button dark", disarmed_at)
|
||||
|
||||
|
||||
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=6,
|
||||
covers=_LASER_COVERS + [("forgectrl", "src/main.c")],
|
||||
@@ -1090,78 +1010,6 @@ def verdict_cut(ctx):
|
||||
DISARMED_MSG = "laser disarmed - latch locked"
|
||||
|
||||
|
||||
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
|
||||
subsystem="laser", kind="operator", mode="grbl", est_min=3,
|
||||
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_laser.c"),
|
||||
("grblhal-glowforge", "src/glowforge_switches.c"),
|
||||
("grblhal-glowforge", "src/glowforge_switch_map.h")],
|
||||
requires=["kernel.latch-locked-idle", "motion.jog-roundtrip"], actions=["lid"],
|
||||
steps=["Lid closed; nothing under the head needs to be in place - the machine will not fire.",
|
||||
"When the white button lights, do NOT press it: open the lid instead, then close it "
|
||||
"when told."],
|
||||
description="Start a laser job so the controller unlocks the latch and lights the button, "
|
||||
"then open the lid while it waits. The wait must abort with the lid named as the "
|
||||
"reason, canceled with a soft reset (no alarm - nothing to unlock), the armed "
|
||||
"window closed (armed -> false), the kernel latch back to locked, and no emission; "
|
||||
"the controller ends Idle. No press is given, so nothing can fire.")
|
||||
def arm_wait_lid(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
base = sample(ctx)
|
||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||
ctx.check(not base["armed"], "armed window already open before the job")
|
||||
stream(g, ["G91", "G21", "M4", "S400", "G1 X5 F600"])
|
||||
# The prompt: the latch is unlocked and the button lit from here on.
|
||||
t0 = time.time()
|
||||
text = ""
|
||||
while time.time() - t0 < 30 and "press the button" not in text:
|
||||
ctx.checkpoint()
|
||||
text += g.drain()
|
||||
time.sleep(0.1)
|
||||
ctx.check("press the button" in text, "no arm prompt within 30 s (job did not reach the arm)")
|
||||
s = sample(ctx)
|
||||
ev["armed_during_wait"] = s["armed"] if s else None
|
||||
ctx.log("arm prompt seen; armed=%s; asking the operator to open the lid", ev["armed_during_wait"])
|
||||
ctx.act("lid", "open", text="The button is lit white: do NOT press it.", timeout=60)
|
||||
t1 = time.time()
|
||||
while time.time() - t1 < 15:
|
||||
ctx.checkpoint()
|
||||
text += g.drain()
|
||||
if "job canceled" in text and "help]" in text:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
|
||||
ctx.log("controller: %s", ev["messages"])
|
||||
ctx.check("lid opened during arm - job canceled" in text,
|
||||
"the lid open was not reported as canceling the arm")
|
||||
ctx.check("help]" in text, "no reset banner after the lid-open cancel (it must be a clean cancel)")
|
||||
ctx.check("ALARM" not in text, "an alarm was raised on the lid-open cancel")
|
||||
# Armed window closed and the kernel latch locked.
|
||||
t2 = time.time()
|
||||
s = None
|
||||
while time.time() - t2 < 10:
|
||||
s = sample(ctx)
|
||||
if s and not s["armed"]:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
ev["armed_after"] = s["armed"] if s else None
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
locked = ilk is not None and bool(ilk & (1 << 3))
|
||||
ev["latch_locked"] = locked
|
||||
ev["emission"] = s["emission"] if s else None
|
||||
ctx.check(s and not s["armed"], "the armed window stayed open after the lid-open cancel")
|
||||
ctx.check(locked, "kernel latch not locked after the lid-open cancel (interlock_circuit=%s)", ilk)
|
||||
ctx.check(not ev["emission"], "emission_samples nonzero (%s) - nothing may have fired", ev["emission"])
|
||||
check_button_dark(ctx, ev)
|
||||
ctx.act("lid", "close")
|
||||
ctx.sleep(1)
|
||||
st = g.status_report()["state"]
|
||||
ev["state_after"] = st
|
||||
ctx.check(st.startswith("Idle"), "controller is %s after the cancel, expected Idle (no unlock needed)", st)
|
||||
ctx.log("PASS: lid open during the arm wait canceled the job (clean reset, no alarm), armed=false, latch locked")
|
||||
|
||||
|
||||
@test("laser.pause-resume-lid-cancel", title="One live cut: the button pauses and resumes it, the lid "
|
||||
"cancels it and sends the head home",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=7,
|
||||
@@ -1327,101 +1175,6 @@ def pause_resume_lid_cancel(ctx):
|
||||
"%.3f mm), button latch SET", zero_at, drift)
|
||||
|
||||
|
||||
def _port_jog_sampled(ctx, fc, params, samples, timeout=30.0):
|
||||
"""One jog through the controller port, sampled at 8 Hz until the port
|
||||
says it is over. Returns the HTTP status and body of the jog request."""
|
||||
st, body = fc.post("/motion/jog", params=params)
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
ctx.checkpoint()
|
||||
smp = sample(ctx)
|
||||
if smp:
|
||||
samples.append(smp)
|
||||
try:
|
||||
ps = fc.get("/motion/state")[1]
|
||||
except hw.HwError:
|
||||
ps = None
|
||||
if st != 200 or (isinstance(ps, dict) and ps.get("state") == "Idle" and not ps.get("port_jog")):
|
||||
break
|
||||
time.sleep(0.125)
|
||||
return st, body
|
||||
|
||||
|
||||
@test("laser.port-dark", title="A panel jog under an open armed window ships dark",
|
||||
subsystem="laser", kind="live", mode="grbl", est_min=3,
|
||||
covers=_LASER_COVERS + [("forgectrl", "src/grblport.*")],
|
||||
requires=["laser.emission-witness", "motion.port-jog"], actions=["button"],
|
||||
steps=["Scrap under the head with 20 mm of free +X travel; lid closed; exhaust on.",
|
||||
"Press the physical button when it lights white (the arm)."],
|
||||
description="A 20 mm line at M3 S400/F600 with no M5 and no program end after it, so the "
|
||||
"armed window stays open with M3 modal and S above zero: the state in which an "
|
||||
"injected G1 would fire. The line itself must be witnessed lit, or the case "
|
||||
"proves nothing. Then POST /motion/jog takes the head back over the line and out "
|
||||
"again, through the controller port. The window must read armed before and "
|
||||
"after the jogs, and through both the kernel's LASER_ON sample count stays 0, "
|
||||
"the HV current stays at its idle reading, and the head's beam detector does "
|
||||
"not rise over its level before the jogs. The client then ends the job (M5, "
|
||||
"M2) and the window closes.")
|
||||
def port_dark(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
base = sample(ctx)
|
||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
ctx.ready(ARM_CUE % "20 mm +X")
|
||||
ctx.arm_press()
|
||||
jog_samples = []
|
||||
try:
|
||||
# No M5 and no M2: the window stays open, M3 S400 stays modal.
|
||||
lit = run_and_sample(ctx, g, ["G91", "G21", "M3", "S400", "G1 X20 F600"])
|
||||
peak = max((s["emission"] or 0 for s in lit), default=0)
|
||||
ctx.check(peak > 0, "no emission witnessed on the G1: the dark jogs would prove nothing")
|
||||
|
||||
# The cut is over and dark again; the window is still open.
|
||||
ok = ctx.wait_for(lambda: (sample(ctx) or {}).get("emission") == 0, 10, poll=0.125)
|
||||
ctx.check(ok is not None, "emission_samples did not return to 0 after the G1")
|
||||
before = sample(ctx)
|
||||
ctx.check(before and before["armed"], "the armed window closed before the jogs: %s",
|
||||
before and before["phase"])
|
||||
ctx.sleep(0.6) # past the port's sender-quiet interval
|
||||
|
||||
for params in ({"x": "-20", "feed": "600"}, {"x": "20", "feed": "2400"},
|
||||
{"x": "-20", "feed": "2400"}):
|
||||
st, body = _port_jog_sampled(ctx, fc, params, jog_samples)
|
||||
ctx.check(st == 200, "POST /motion/jog %s under the open window -> %s %s", params, st, body)
|
||||
ctx.sleep(0.6)
|
||||
after = sample(ctx)
|
||||
ctx.check(after and after["armed"],
|
||||
"the armed window closed during the jogs: the dark samples were not under it")
|
||||
finally:
|
||||
ctx.clear_notice()
|
||||
r = g.command("M5")
|
||||
r += g.command("M2")
|
||||
ctx.check(not any(x.startswith("error") for x in r), "the client could not end the job: %s", r)
|
||||
ev["disarm_s"] = wait_disarm(ctx, 30)
|
||||
ctx.check(ev["disarm_s"] is not None, "the armed window did not close after the program end")
|
||||
|
||||
lit_in_jog = [s for s in jog_samples if s["emission"]]
|
||||
hv_max = max((s["hv"] for s in jog_samples if s["hv"] is not None), default=0)
|
||||
beams = [s["beam"] for s in jog_samples if s.get("beam") is not None]
|
||||
beam_rise = (max(beams) - before["beam"]) if beams and before.get("beam") is not None else None
|
||||
ev.update({"emission_peak_g1": peak, "jog_samples": len(jog_samples),
|
||||
"jog_emission_samples": len(lit_in_jog), "jog_hv_max": hv_max,
|
||||
"jog_beam_rise": beam_rise, "trail": trail(jog_samples)})
|
||||
ctx.log("G1 emission peak %s; %d samples across three port jogs: %d with emission, HV max %s, "
|
||||
"beam detector rise %s", peak, len(jog_samples), len(lit_in_jog), hv_max, beam_rise)
|
||||
ctx.check(len(jog_samples) >= 16, "only %d samples across the jogs", len(jog_samples))
|
||||
ctx.check(not lit_in_jog, "a port jog fired under the open window: %d samples with emission, "
|
||||
"first emission_samples %s", len(lit_in_jog), lit_in_jog[0]["emission"] if lit_in_jog else None)
|
||||
ctx.check(hv_max <= HV_DARK_MAX, "HV current %s during a port jog (idle reads ~0)", hv_max)
|
||||
ctx.check(beam_rise is not None and beam_rise < BEAM_DELTA_MIN,
|
||||
"the head's beam detector rose %s during a port jog", beam_rise)
|
||||
ctx.log("PASS: lit on the G1 (peak %s), and three port jogs under the same open window with M3 S400 "
|
||||
"modal shipped dark by all three witnesses", peak)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the recorder, as a job of the runner's
|
||||
|
||||
def _laser_keys(fc):
|
||||
|
||||
@@ -1146,192 +1146,6 @@ def homing_kill(ctx, wait_running):
|
||||
ctx.log("restore homing_mode=%r -> %s", hm, st)
|
||||
|
||||
|
||||
@test("motion.respawn-gate", title="A respawn waits for the lid the way a first spawn does",
|
||||
subsystem="motion", kind="operator", mode="grbl", est_min=2,
|
||||
covers=_MOTION_COVERS, requires=["motion.deadman", "motion.gate-waits-for-lid"], actions=["lid"],
|
||||
steps=["Bed clear. Open the lid when told and close it when told; nothing moves."],
|
||||
description="The enclosure check runs before every controller spawn, respawns included. "
|
||||
"With the lid open, the controller is killed: the supervisor safes the machine "
|
||||
"(latch locked), reports waiting with why naming the lid, and starts no controller "
|
||||
"while the lid stays open. When the lid closes the controller comes back verified, "
|
||||
"without a second motion probe (the probe is once per broker hold).")
|
||||
def respawn_gate(ctx):
|
||||
import os as _os
|
||||
import signal as _signal
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the start")
|
||||
st, m0 = fc.get("/mode")
|
||||
ctx.check(st == 200 and isinstance(m0, dict) and m0.get("controller") == "running", "controller not running: %s", m0)
|
||||
pid0 = m0.get("pid")
|
||||
off = _log_offset(FORGECTRL_LOG)
|
||||
ctx.act("lid", "open")
|
||||
try:
|
||||
ctx.sleep(1.0)
|
||||
_os.kill(pid0, _signal.SIGKILL)
|
||||
t_kill = time.time()
|
||||
ctx.log("SIGKILL sent to controller pid %d with the lid open", pid0)
|
||||
waited = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("controller") == "waiting", 15)
|
||||
st, m1 = fc.get("/mode")
|
||||
ev["after_kill"] = {"waiting_s": waited, "mode": m1}
|
||||
ctx.log("mode %.1f s after the kill: %s", time.time() - t_kill, m1)
|
||||
ctx.check(waited is not None, "the supervisor did not report waiting for the lid: %s", m1)
|
||||
ctx.check("lid" in (m1.get("why") or ""), "why does not name the lid: %r", m1.get("why"))
|
||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||
ctx.check(ilk is not None and ilk & (1 << 3), "latch not locked after the kill")
|
||||
ctx.sleep(4.0)
|
||||
st, m2 = fc.get("/mode")
|
||||
ctx.check(isinstance(m2, dict) and m2.get("controller") == "waiting" and not m2.get("pid"),
|
||||
"the wait did not hold with the lid open: %s", m2)
|
||||
finally:
|
||||
ctx.act("lid", "close")
|
||||
t_close = time.time()
|
||||
came = ctx.wait_for(lambda: ((fc.get("/mode")[1] or {}).get("controller") == "running"
|
||||
and (fc.get("/mode")[1] or {}).get("pid") != pid0), 60)
|
||||
st, m3 = fc.get("/mode")
|
||||
ev["after_close"] = {"running_s": came, "mode": m3}
|
||||
ctx.log("mode %.1f s after the lid closed: %s", time.time() - t_close, m3)
|
||||
ctx.check(came is not None and m3.get("motion") == "verified",
|
||||
"the controller did not come back verified after the lid closed: %s", m3)
|
||||
lines = _probe_lines(FORGECTRL_LOG, off)
|
||||
ev["probe_lines"] = lines[-2:]
|
||||
ctx.check(not lines, "the motion probe ran again for the respawn: %s", lines[:1])
|
||||
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle at the end")
|
||||
ctx.log("PASS: the respawn waited %.1f s for the lid, then came back verified in %.1f s with no probe",
|
||||
t_close - t_kill, came)
|
||||
|
||||
|
||||
@test("motion.soft-limits", title="After a home the bed is the X/Y envelope",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=5,
|
||||
covers=_MOTION_COVERS, requires=["motion.jog-roundtrip", "cloud.mode-switch"],
|
||||
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. The test "
|
||||
"homes the machine through the web service (about a minute), sends moves the controller "
|
||||
"must refuse, and hands the head back where it found it."],
|
||||
description="The machine has no limit switches, so the core's $20 cannot be turned on and "
|
||||
"the X/Y soft limits are the driver's: off while the position is not trusted, on "
|
||||
"after a home, when the envelope is the bed from the home corner: the far edges the "
|
||||
"bed check measured (envelope_x_mm, envelope_y_mm, held to 50 mm up to the travel "
|
||||
"plus 30 mm), or $130 by $131 on a machine that was never measured. "
|
||||
"With homing_mode = gfcloud and the camera-home offsets unset for the run, $H runs "
|
||||
"the web-service homing session, every motion of it run whole. Homed, a program "
|
||||
"move 5 mm past the far edge in X or in Y, or past the near edge, raises ALARM:2 "
|
||||
"before any motion (the kernel counters do not move), a jog past the far edge is "
|
||||
"refused with error 15, and a move inside the bed runs. A move or jog that is not "
|
||||
"refused is stopped before the test fails. The Z envelope is the lens window, as "
|
||||
"before. The settings are put back as found, the camera home is dropped, and the "
|
||||
"head is jogged back to where the test found it by the travel the session's "
|
||||
"motions logged.")
|
||||
def soft_limits(ctx):
|
||||
# The camera-home helpers, imported here: homeoff imports this module.
|
||||
from .cloud import gfhome_homing
|
||||
from .homeoff import camera_home_return, judge_whole_motions, session_mark
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
s0 = fc.settings() or {}
|
||||
ctx.check(s0.get("cloud_enabled") == "1", "cloud mode is off: a camera home needs it on")
|
||||
found = {k: s0.get(k) or "" for k in ("homing_mode", "gfcloud_home_x", "gfcloud_home_y")}
|
||||
ev["found"] = found
|
||||
session_at = None
|
||||
|
||||
def put(k, v):
|
||||
# The controller refuses a setting for a moment after a Grbl client
|
||||
# closes: each is written when it is taken. An empty value unsets it.
|
||||
def write():
|
||||
st_, body_ = fc.post("/settings", params={k: ""}) if v == "" else fc.post("/settings", data={k: v})
|
||||
if st_ != 200:
|
||||
ctx.log("%s=%r -> %s %s", k, v, st_, body_)
|
||||
return st_ == 200 or None
|
||||
return ctx.wait_for(write, 20, poll=0.5) is not None
|
||||
|
||||
try:
|
||||
# A camera home at the origin: the envelope is the bed from the home corner.
|
||||
for k, v in (("homing_mode", "gfcloud"), ("gfcloud_home_x", ""), ("gfcloud_home_y", "")):
|
||||
ctx.check(put(k, v), "%s=%r was not taken", k, v)
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
session_at = session_mark()
|
||||
gfhome_homing(ctx, ev, g)
|
||||
judge_whole_motions(ctx, ev, session_at)
|
||||
ctx.check(fc.status().get("homed"), "the machine is not homed")
|
||||
x_travel = float(grbl_setting(g, "$130"))
|
||||
y_travel = float(grbl_setting(g, "$131"))
|
||||
ev["travel"] = {"x": x_travel, "y": y_travel}
|
||||
|
||||
# The far edges the home armed, as the driver takes them
|
||||
# (gfhome_clamp_envelope_mm): the measured key held to 50 mm up
|
||||
# to the travel plus 30 mm, or the travel when it is unset.
|
||||
def far_edge(key, travel):
|
||||
try:
|
||||
mm = float(s0.get(key) or 0)
|
||||
except ValueError:
|
||||
mm = 0.0
|
||||
if not mm > 0:
|
||||
return travel
|
||||
return min(max(mm, 50.0), travel + 30.0)
|
||||
|
||||
x_edge = far_edge("envelope_x_mm", x_travel)
|
||||
y_edge = far_edge("envelope_y_mm", y_travel)
|
||||
ev["envelope"] = {"x": x_edge, "y": y_edge}
|
||||
k0 = kernel_xy_mm(ctx)
|
||||
|
||||
def refused(cmd):
|
||||
lines = g.command(cmd, timeout=2)
|
||||
ctx.sleep(0.5)
|
||||
text = "\n".join(lines) + g.drain()
|
||||
k1 = kernel_xy_mm(ctx)
|
||||
moved = max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1]))
|
||||
st = g.status_report()["state"]
|
||||
rec = {"cmd": cmd, "reply": lines, "alarm": "ALARM:2" in text, "moved_mm": round(moved, 3),
|
||||
"state": st}
|
||||
ctx.log("%s", rec)
|
||||
# Stopped first: a move that was not refused must not run on
|
||||
# to its target while the test fails.
|
||||
g.realtime(0x18)
|
||||
ctx.sleep(1.5)
|
||||
g.drain()
|
||||
ctx.check(rec["alarm"], "%s was not refused with ALARM:2: %s", cmd, lines)
|
||||
ctx.check(moved < 0.05, "%s moved the kernel %.3f mm before the alarm", cmd, moved)
|
||||
unlock = g.command("$X")
|
||||
ctx.check(unlock and unlock[-1] == "ok", "$X after the soft-limit alarm: %s", unlock)
|
||||
st = g.status_report()["state"]
|
||||
ctx.check(st.startswith("Idle"), "controller is %s after the recovery", st)
|
||||
return rec
|
||||
|
||||
ev["refused"] = [refused("G90 G1 X%.1f F600" % (x_edge + 5)),
|
||||
refused("G90 G1 Y%.1f F600" % (y_edge + 5)),
|
||||
refused("G90 G1 X-1 F600")]
|
||||
# Homed still: the soft-limit alarm and its reset keep the reference.
|
||||
ctx.check(fc.status().get("homed"), "the soft-limit alarm and the reset un-homed the machine")
|
||||
jog = g.command("$J=G91X%.1fF1200" % (x_edge + 5))
|
||||
ev["jog"] = jog
|
||||
if not any(l.startswith("error:15") for l in jog):
|
||||
g.realtime(0x85) # the jog cancel, before the test fails
|
||||
ctx.sleep(1.5)
|
||||
g.drain()
|
||||
ctx.check(any(l.startswith("error:15") for l in jog), "a jog past the bed was not refused with error 15: %s",
|
||||
jog)
|
||||
# The core answers the line after an error with that error again
|
||||
# until an empty line clears it (the sender's acknowledgment).
|
||||
g.command("")
|
||||
g.command("G90 G1 X10 Y10 F600", timeout=0.5)
|
||||
peak, states, st = wait_idle(ctx, g, 20)
|
||||
ev["inside"] = states
|
||||
ctx.check("TIMEOUT" not in states, "a move inside the bed did not run")
|
||||
g.command("G90 G1 X0 Y0 F600", timeout=0.5)
|
||||
wait_idle(ctx, g, 20)
|
||||
k1 = kernel_start(ctx) # at rest: grbl's Idle leads the kernel's tail
|
||||
ev["kernel_back_mm"] = [round(k1[0] - k0[0], 3), round(k1[1] - k0[1], 3)]
|
||||
ctx.check(max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1])) < 0.1, "the head did not come back to the corner: %s",
|
||||
ev["kernel_back_mm"])
|
||||
finally:
|
||||
for k, v in found.items():
|
||||
ctx.log("restore %s=%r -> %s", k, v, "taken" if put(k, v) else "not taken")
|
||||
if session_at is not None:
|
||||
camera_home_return(ctx, ev, session_at)
|
||||
ctx.log("PASS: X max, Y max and X min refused with ALARM:2 and no motion, the jog refused with "
|
||||
"error 15, a move inside the bed ran")
|
||||
|
||||
|
||||
# ------------------------------------------------- lid / button (the factory's)
|
||||
|
||||
_LID_COVERS = _MOTION_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
|
||||
@@ -1597,144 +1411,6 @@ def lid_cancel_home(ctx):
|
||||
"reset without alarm, head returned to the start both times", drift, hold_drift)
|
||||
|
||||
|
||||
@test("motion.interlock-cancel-home", title="The interlock loop cancels a job like the lid and returns to "
|
||||
"the job start",
|
||||
subsystem="motion", kind="operator", mode="grbl", est_min=4,
|
||||
covers=_LID_COVERS, requires=["motion.lid-cancel-home"], actions=["interlock"],
|
||||
steps=["Bed clear; the head needs 60 mm of free +X travel. No laser is involved.",
|
||||
"Be able to open the remote-interlock loop: unplug the Pro's interlock plug, or pull the "
|
||||
"jumper at J8 on a Basic/Plus.",
|
||||
"On Ready the head starts a 12 s move: open the interlock while it moves and leave it "
|
||||
"open until the head has come back, then restore it."],
|
||||
description="The remote-interlock loop is the lid's equal in the cancel policy: opening it mid-job "
|
||||
"cancels the job with 'interlock open' named as the reason - the lid's own message would "
|
||||
"be wrong here - and sends the head back to the job start with the loop still open. "
|
||||
"(An open lid not stopping the park is the lid test's park, which runs with the lid open "
|
||||
"throughout; in GRBL mode the park is a rapid too short to open a lid during, so the "
|
||||
"mid-park lid edge is the cloud test's.)")
|
||||
def interlock_cancel_home(ctx):
|
||||
ev = ctx.evidence
|
||||
policy = (ctx.forgectrl.settings() or {}).get("lid_policy") or "cancel"
|
||||
ev["lid_policy"] = policy
|
||||
ctx.check(policy == "cancel", "lid_policy is %r; this test needs cancel", policy)
|
||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
||||
ctx.check(sw.get("interlock_ok"), "the interlock loop already reads open - close it before this test")
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
k0 = kernel_start(ctx)
|
||||
start = g.status_report()["MPos"]
|
||||
ev["start"] = start
|
||||
ev["kernel_start"] = k0
|
||||
ctx.ready("On Ready the head starts a 12 s move along +X. Open the interlock loop while it "
|
||||
"moves and leave it open until the head has come back on its own.")
|
||||
g.command("M5")
|
||||
g.command("G91")
|
||||
start_move(ctx, g, "G1X60F300") # a 12 s move
|
||||
g.drain()
|
||||
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
|
||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
||||
ev["interlock_ok_after_pull"] = sw.get("interlock_ok")
|
||||
ctx.check(sw.get("interlock_ok") is False,
|
||||
"the interlock still reads closed - the loop was not opened (switches: %s)", sw)
|
||||
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "interlock open", "interlock")
|
||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
||||
ev["switches_at_return"] = {"lid": sw.get("lid"), "interlock_ok": sw.get("interlock_ok")}
|
||||
ctx.log("at the end of the park: %s", ev["switches_at_return"])
|
||||
ctx.check(sw.get("interlock_ok") is False,
|
||||
"the interlock was closed again before the park finished - the park ran with the loop "
|
||||
"restored, not open")
|
||||
ctx.act("interlock", "close")
|
||||
ctx.sleep(1)
|
||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
||||
ev["restored"] = {"lid": sw.get("lid"), "interlock_ok": sw.get("interlock_ok")}
|
||||
ctx.check(sw.get("interlock_ok"), "the interlock loop is still open - restore it before continuing")
|
||||
r = g.command("$J=G91X5F1200")
|
||||
ctx.check(not any(x.startswith("error") for x in r), "jog refused after the cancel: %s", r)
|
||||
wait_idle(ctx, g, 15)
|
||||
g.command("$J=G91X-5F1200")
|
||||
wait_idle(ctx, g, 15)
|
||||
g.command("G90")
|
||||
machine_idle(ctx)
|
||||
ctx.log("PASS: interlock open canceled the job with its own reason and the head returned to the "
|
||||
"start (drift %.3f mm) with the loop still open", drift)
|
||||
|
||||
|
||||
@test("motion.lid-policy-hold", title="lid_policy=hold parks the job in Door and a cycle start resumes it",
|
||||
subsystem="motion", kind="operator", mode="grbl", est_min=4,
|
||||
covers=_LID_COVERS + [("forgectrl", "src/settings.*")], requires=["motion.lid-cancel-home"],
|
||||
actions=["lid"],
|
||||
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
|
||||
"On Ready the head starts an 8 s move: open the lid while it moves (the job parks in "
|
||||
"Door), then close it when told; the job finishes after that."],
|
||||
description="The other lid policy, kept for senders that expect stock grblHAL: with "
|
||||
"lid_policy=hold a lid open parks the job in the door state and holds it there - "
|
||||
"no cancel, no return home - and once the lid is closed a cycle start finishes the "
|
||||
"move with its position intact. The setting is restored to cancel at the end.")
|
||||
def lid_policy_hold(ctx):
|
||||
ev = ctx.evidence
|
||||
fc = ctx.forgectrl
|
||||
# What the machine had, exactly: an unset lid_policy reads as the empty
|
||||
# string and behaves as cancel, and writing the word back where the
|
||||
# machine had nothing is a leftover the hand-back reports. The default
|
||||
# is for reading the value, never for restoring it.
|
||||
was = (fc.settings() or {}).get("lid_policy", "")
|
||||
ev["lid_policy_before"] = was
|
||||
ev["lid_policy_in_force"] = was or "cancel"
|
||||
st, _b = fc.post("/settings", data={"lid_policy": "hold"})
|
||||
ctx.check(st == 200, "could not set lid_policy=hold (%s)", st)
|
||||
ctx.check(((fc.settings() or {}).get("lid_policy")) == "hold", "lid_policy did not take")
|
||||
try:
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
start = g.status_report()["MPos"]
|
||||
ctx.ready("On Ready the head starts an 8 s move along +X. Open the lid while it moves; "
|
||||
"the job parks in the door state and waits.")
|
||||
g.command("M5")
|
||||
g.command("G91")
|
||||
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||
g.drain()
|
||||
ctx.act("lid", "open", timeout=12)
|
||||
st, text = wait_state_text(ctx, g, "Door", 8)
|
||||
ev["door_state"] = st["state"] if st else g.status_report()["state"]
|
||||
ctx.check(st is not None, "the lid did not park the job in Door (state %s)", ev["door_state"])
|
||||
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:")]
|
||||
ctx.check("job canceled" not in text, "the job was canceled under lid_policy=hold: %s", ev["messages"])
|
||||
ctx.check("returned to the job start" not in text,
|
||||
"the head returned to the job start under lid_policy=hold")
|
||||
held = g.status_report()["MPos"]
|
||||
ev["parked_at"] = held
|
||||
ctx.log("parked in %s at %s", ev["door_state"], held)
|
||||
ctx.act("lid", "close")
|
||||
ctx.sleep(1)
|
||||
ev["state_after_close"] = g.status_report()["state"]
|
||||
ctx.log("after the lid closed: %s (a cycle start is needed)", ev["state_after_close"])
|
||||
g.realtime(0x7E) # ~ cycle start
|
||||
peak, states, st = wait_idle(ctx, g, 30)
|
||||
ev["states_after_resume"] = states
|
||||
ctx.check("TIMEOUT" not in states, "the job did not finish after the resume: %s", states)
|
||||
final = st["MPos"]
|
||||
moved = final[0] - start[0]
|
||||
ev["moved_mm"] = round(moved, 3)
|
||||
ctx.log("finished: moved %.3f mm of 40 (states %s)", moved, states)
|
||||
ctx.check(abs(moved - 40.0) <= 0.05,
|
||||
"the resumed job did not finish its move (%.3f mm of 40)", moved)
|
||||
g.command("$J=G91X-40F1200")
|
||||
wait_idle(ctx, g, 30)
|
||||
g.command("G90")
|
||||
machine_idle(ctx)
|
||||
finally:
|
||||
# An empty value clears the key, and only the query-string form
|
||||
# carries one: an empty form field never reaches the request, and
|
||||
# the write is refused with "no known setting in request".
|
||||
st, _b = (fc.post("/settings", params={"lid_policy": ""}) if not was
|
||||
else fc.post("/settings", data={"lid_policy": was}))
|
||||
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy", "")
|
||||
ctx.log("lid_policy restored to %s", ev["lid_policy_restored"])
|
||||
ctx.check(ev["lid_policy_restored"] == was, "lid_policy was not restored to %r", was)
|
||||
ctx.log("PASS: lid_policy=hold parked the job in Door and the cycle start finished it (%.3f mm)",
|
||||
ev["moved_mm"])
|
||||
|
||||
|
||||
GRBLHAL_LOG = "/data/log/forgefirm/grblhal/grblhal.log"
|
||||
SCHED_FIFO = 1
|
||||
|
||||
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
2026-08-17T09:44:00.417337+00:00 gfcloud[1927] INFO cnc:set_step_freq 28160
|
||||
2026-08-17T09:44:00.418627+00:00 gfcloud[1927] INFO cnc:set_x_decay 1
|
||||
2026-08-17T09:44:00.419858+00:00 gfcloud[1927] INFO cnc:set_x_mode 8
|
||||
2026-08-17T09:44:00.421083+00:00 gfcloud[1927] INFO cnc:set_x_current 135
|
||||
2026-08-17T09:44:00.422535+00:00 gfcloud[1927] INFO cnc:set_y_decay 1
|
||||
2026-08-17T09:44:00.423794+00:00 gfcloud[1927] INFO cnc:set_y_mode 8
|
||||
2026-08-17T09:44:00.425014+00:00 gfcloud[1927] INFO cnc:set_y_current 22
|
||||
2026-08-17T09:44:00.432591+00:00 gfcloud[1927] INFO machine:_run_loop starting run
|
||||
2026-08-17T09:44:00.434005+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:44:00.438277+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.RUNNING
|
||||
2026-08-17T09:44:01.785230+00:00 gfcloud[1927] INFO machine:_run_loop current state: MachineState.IDLE
|
||||
2026-08-17T09:44:01.797728+00:00 gfcloud[1927] INFO machine:_run_loop finished run
|
||||
2026-08-17T09:44:01.800661+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:01.808504+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (actual/expected): X (-10313/-10313), Y (-649/-649), Z (0/0)
|
||||
2026-08-17T09:44:01.809326+00:00 gfcloud[1927] INFO machine:_motion_locked motion bytes actual:34952, expected: 34952
|
||||
2026-08-17T09:44:01.810066+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:44:01.811352+00:00 gfcloud[1927] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:44:01.812874+00:00 gfcloud[1927] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:44:01.820631+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (-10313, -649, 0)
|
||||
2026-08-17T09:44:01.822062+00:00 gfcloud[1927] INFO machine:_motion end motion
|
||||
2026-08-17T09:44:01.822829+00:00 gfcloud[1927] INFO basemachine:_finish_action motion [1576550614]: finished with event ":completed"
|
||||
2026-08-17T09:44:01.824200+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:07.993647+00:00 gfcloud[1927] INFO gfuiservice:run service action request: print (ready)
|
||||
2026-08-17T09:44:07.998655+00:00 gfcloud[1927] INFO machine:_motion start motion
|
||||
2026-08-17T09:44:07.999643+00:00 gfcloud[1927] INFO gfuiservice:run print (ready) service action print
|
||||
2026-08-17T09:44:14.199666+00:00 gfcloud[1927] INFO cnc:laser_latch 0
|
||||
2026-08-17T09:44:14.208291+00:00 gfcloud[1927] INFO machine:_button_wait waiting for button
|
||||
2026-08-17T09:44:21.270846+00:00 gfcloud[1927] INFO machine:_switch_event lid opened
|
||||
2026-08-17T09:44:21.275703+00:00 gfcloud[1927] WARNING machine:_button_wait button wait lid opened - relocking the laser
|
||||
2026-08-17T09:44:21.278615+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:21.288305+00:00 gfcloud[1927] INFO machine:_return_home start return home
|
||||
2026-08-17T09:44:21.291373+00:00 gfcloud[1927] INFO machine:_return_home return home: already at the job start
|
||||
2026-08-17T09:44:21.292501+00:00 gfcloud[1927] INFO machine:_motion_locked start cool down
|
||||
2026-08-17T09:44:21.305578+00:00 gfcloud[1927] INFO machine:_motion_locked start idle
|
||||
2026-08-17T09:44:21.307322+00:00 gfcloud[1927] INFO cnc:set_x_current 33
|
||||
2026-08-17T09:44:21.309185+00:00 gfcloud[1927] INFO cnc:set_y_current 5
|
||||
2026-08-17T09:44:21.316340+00:00 gfcloud[1927] INFO machine:_motion_locked end positions (0, 0, 0)
|
||||
2026-08-17T09:44:21.318139+00:00 gfcloud[1927] INFO machine:_motion end motion
|
||||
2026-08-17T09:44:21.318860+00:00 gfcloud[1927] INFO basemachine:_finish_action print [1576550621]: finished with event ":cancelled"
|
||||
2026-08-17T09:44:21.320214+00:00 gfcloud[1927] INFO cnc:laser_latch 1
|
||||
2026-08-17T09:44:34.058320+00:00 gfcloud[1927] INFO machine:_switch_event lid closed
|
||||
@@ -531,9 +531,9 @@ class ExtensionFreeTests(BaselineTests):
|
||||
|
||||
class TransientNotLeftoverTests(BaselineTests):
|
||||
"""A leftover is what a run left behind, not the machine part-way
|
||||
through its own work. Found on the bench reference, where
|
||||
laser.arm-wait-lid passed every check it makes and failed its
|
||||
hand-back on the post-job smoke clear."""
|
||||
through its own work. Found on the bench reference, where a laser
|
||||
test passed every check it makes and failed its hand-back on the
|
||||
post-job smoke clear."""
|
||||
|
||||
def test_a_fading_button_led_is_not_a_leftover(self):
|
||||
# the trigger fades brightness toward target: target 0 with
|
||||
|
||||
@@ -69,6 +69,19 @@ def cut(lines, marker, count=1):
|
||||
raise KeyError(marker)
|
||||
|
||||
|
||||
def dark_print(ctx):
|
||||
"""The dark print as exthost.armed-freeze runs it: the cloud suite's
|
||||
dark_print_body between the offline service's setup and cleanup."""
|
||||
offset = cloud.enter_offline(ctx)
|
||||
job = cloud.offline_job(ctx, "dark.puls", seconds=30)
|
||||
off = cloud.Offline().__enter__()
|
||||
try:
|
||||
cloud.dark_print_body(ctx, ctx.evidence, off, job, offset, ctx.forgectrl)
|
||||
finally:
|
||||
off.__exit__(None, None, None)
|
||||
cloud.offline_cleanup(ctx)
|
||||
|
||||
|
||||
class Script:
|
||||
"""Answers every prompt with its first option; a hook per prompt or
|
||||
notice substring drives the fake machine and the log replay (a
|
||||
@@ -1040,30 +1053,7 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.assertFails(cloud.lid_interlock_abort, "not edge-driven",
|
||||
hooks=self.abort_hooks(pre, lid_tail, ilk_stop, ilk_park, []))
|
||||
|
||||
def test_lid_during_button_wait_on_the_bench_excerpt(self):
|
||||
self.in_offline()
|
||||
lines = fixture("buttonwait")
|
||||
pre, rest = cut(lines, "waiting for button")
|
||||
# The bench excerpt is a print that fit the ring; the test now wants
|
||||
# one that did not, so the feeder is alive through the wait.
|
||||
long_job = ("2026-08-17T09:44:14.100000+00:00 gfcloud[1927] INFO machine:_feed_and_run "
|
||||
"job is longer than the ring: 33554432 bytes enqueued, feeding the rest as it plays")
|
||||
pre, rest = pre + [long_job, rest[0]], rest[1:]
|
||||
self.offline_print_hooks(pre)
|
||||
hooks = {"do NOT press it": lambda: (self.lid(False), self.append(rest, delay=0.05)),
|
||||
"Close the lid.": lambda: self.lid(True)}
|
||||
run = self.run_test(cloud.lid_during_button_wait, hooks=hooks, test_id="cloud.lid-during-button-wait")
|
||||
ev = run.evidence
|
||||
self.assertEqual([m["status"] for m in FakeOffline.sent], ["ready"])
|
||||
self.assertEqual(ev["runs_started_after_wait"], 0)
|
||||
self.assertIn(":cancelled", ev["log"]["print finished"])
|
||||
self.assertTrue(ev["latch_locked"])
|
||||
self.assertEqual(ev["program_total_after"], {"first": 0, "later": 0})
|
||||
self.assertEqual(ev["streaming_after"], 0)
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertTrue(any("PASS: lid open at the button prompt" in l for l in run.lines))
|
||||
|
||||
# -- the cooling verdict: locked until the run, a hold that never clears --
|
||||
# -- the dark print exthost.armed-freeze runs: locked at the button --------
|
||||
def dark_parts(self):
|
||||
"""(the print prologue through the button prompt, the run and the
|
||||
print's end with no pause in it)."""
|
||||
@@ -1074,170 +1064,13 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
run = [l for l in run if "paus" not in l and "resum" not in l]
|
||||
return pre, run
|
||||
|
||||
def test_dark_print_on_the_bench_excerpt(self):
|
||||
self.in_offline()
|
||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
|
||||
pre, run = self.dark_parts()
|
||||
self.offline_print_hooks(pre)
|
||||
|
||||
def press():
|
||||
# the run: the client unlocks the latch right before it, and
|
||||
# relocks when the job ends
|
||||
self._attr("cnc/interlock_circuit", "37")
|
||||
self.append(run[:1], delay=0.05)
|
||||
|
||||
def ended():
|
||||
self._attr("cnc/interlock_circuit", "45")
|
||||
self.append(run[1:], delay=0.0)
|
||||
threading.Timer(2.0, ended).start()
|
||||
hooks = {"press it. The print runs dark": press}
|
||||
run_ = self.run_test(cloud.dark_print, hooks=hooks, test_id="cloud.dark-print")
|
||||
ev = run_.evidence
|
||||
self.assertTrue(ev["latch_locked_at_button"])
|
||||
self.assertFalse(ev["latch_locked_in_run"])
|
||||
self.assertTrue(ev["latch_locked_after"])
|
||||
self.assertIn(":completed", ev["print finished"])
|
||||
self.assertTrue(any("PASS: locked at the button" in l for l in run_.lines))
|
||||
|
||||
def test_dark_print_fails_when_the_latch_is_open_at_the_button(self):
|
||||
self.in_offline()
|
||||
pre, run = self.dark_parts()
|
||||
self._attr("cnc/interlock_circuit", "37") # unlocked before the press
|
||||
self.offline_print_hooks(pre)
|
||||
hooks = {"press it. The print runs dark": lambda: self.append(run, delay=0.05)}
|
||||
self.assertFails(cloud.dark_print, "the latch is unlocked at the button wait", hooks=hooks)
|
||||
|
||||
def test_a_print_turned_away_before_the_button_fails_at_once_with_the_reason(self):
|
||||
"""The bench reference's own lines, 2026-09-20: the coolant read 27.4
|
||||
against the client's start ceiling of 27, and the test then sat out
|
||||
its two minutes and said only that the button wait never came."""
|
||||
self.in_offline()
|
||||
stamp = "2026-09-20T21:28:16.83+00:00 gfcloud[782] INFO "
|
||||
refused = [stamp + "gfuiservice:run service action request: print (ready)",
|
||||
stamp + "machine:_motion start motion",
|
||||
stamp + "machine:_safe_to_move machine temp is too high, temp: 27.4",
|
||||
stamp + "machine:_motion end motion",
|
||||
stamp + 'basemachine:_finish_action print [9005]: finished with event ":cancelled"']
|
||||
self.offline_print_hooks(refused)
|
||||
t0 = time.time()
|
||||
self.assertFails(cloud.dark_print, "turned the print away before the button wait: "
|
||||
"INFO machine:_safe_to_move machine temp is too high, temp: 27.4")
|
||||
self.assertLess(time.time() - t0, 30, "the refusal was not seen until the wait timed out")
|
||||
|
||||
def test_verdict_refuse_on_the_bench_excerpt(self):
|
||||
self.in_offline()
|
||||
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
||||
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16",
|
||||
"cloud_hold_max_s": ""})
|
||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
|
||||
pre, run = self.dark_parts()
|
||||
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] %s machine:_verdict_wait "
|
||||
wait = [stamp % (100000, "INFO") + "waiting on the cooling engine: WARMUP (WARM-UP: coolant "
|
||||
"21.5 C under the 29.5 C start gate - heater on, hold)"]
|
||||
refuse = [stamp % (200000, "WARNING") + "cooling hold held 60 s - relocking the laser",
|
||||
"2026-08-17T09:44:15.000000+00:00 gfcloud[1927] INFO basemachine:_finish_action print "
|
||||
"[9006]: finished with event \":cancelled\""]
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/settings" and form.get("cool_temp_start") not in (None, "0", "16"):
|
||||
self.fc.state["cool"].update({"verdict": "WARMUP", "hold": True, "fire_ok": False,
|
||||
"armed": True, "phase": "warm-up"})
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
self.offline_print_hooks(pre)
|
||||
|
||||
def press():
|
||||
self.append(wait, delay=0.05)
|
||||
|
||||
def refused():
|
||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True,
|
||||
"armed": False, "phase": "idle"})
|
||||
self.append(refuse, delay=0.0)
|
||||
threading.Timer(2.5, refused).start()
|
||||
hooks = {"press it. Nothing runs": press}
|
||||
run_ = self.run_test(cloud.verdict_refuse, hooks=hooks, test_id="cloud.verdict-refuse")
|
||||
ev = run_.evidence
|
||||
self.assertEqual(ev["gate"], 29.5)
|
||||
self.assertTrue(ev["latch_locked_at_button"])
|
||||
self.assertEqual(ev["latch_samples"]["unlocked"], 0)
|
||||
self.assertGreaterEqual(ev["latch_samples"]["n"], 1)
|
||||
self.assertIn(":cancelled", ev["print finished"])
|
||||
posted = [f for p_, f in self.fc.posts if p_ == "/settings"]
|
||||
self.assertEqual(posted[0]["cool_temp_start"], "29.5")
|
||||
self.assertEqual(posted[0]["cloud_hold_max_s"], "60")
|
||||
self.assertEqual(posted[-1], {"cool_temp_min": "5", "cool_temp_start": "16", "cloud_hold_max_s": ""})
|
||||
self.assertTrue(any("PASS: held" in l for l in run_.lines))
|
||||
|
||||
def test_verdict_refuse_fails_when_the_latch_unlocks_during_the_hold(self):
|
||||
self.in_offline()
|
||||
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
||||
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"})
|
||||
self.fc.state["cool"].update({"verdict": "WARMUP", "hold": True, "fire_ok": False, "armed": True})
|
||||
pre, run = self.dark_parts()
|
||||
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] %s machine:_verdict_wait "
|
||||
wait = [stamp % (100000, "INFO") + "waiting on the cooling engine: WARMUP (no reason given)"]
|
||||
refuse = [stamp % (200000, "WARNING") + "cooling hold held 60 s - relocking the laser",
|
||||
"2026-08-17T09:44:15.000000+00:00 gfcloud[1927] INFO basemachine:_finish_action print "
|
||||
"[9006]: finished with event \":cancelled\""]
|
||||
self.offline_print_hooks(pre)
|
||||
|
||||
def press():
|
||||
self.append(wait, delay=0.05)
|
||||
self._attr("cnc/interlock_circuit", "37") # unlocked under the hold
|
||||
threading.Timer(2.5, lambda: self.append(refuse, delay=0.0)).start()
|
||||
hooks = {"press it. Nothing runs": press}
|
||||
self.assertFails(cloud.verdict_refuse, "the latch unlocked during the hold", hooks=hooks)
|
||||
|
||||
# -- a paused print canceled by the lid, a running one by the app --------
|
||||
def cancel_parts(self):
|
||||
"""(print prologue, the pause lines, the lid stop + park + cancel,
|
||||
the same tail with the app's cancel as the trigger - what
|
||||
cloud.oversize-stream's ending looks like)."""
|
||||
lines = fixture("lidabort")
|
||||
pre, rest = cut(lines, "waiting for button")
|
||||
run_pre, rest = cut(rest, "machine:_run_loop starting run")
|
||||
pre, rest = pre + run_pre + [rest[0]], rest[1:]
|
||||
paused = ["2026-08-17T09:42:25.500000+00:00 gfcloud[1522] INFO machine:_run_loop "
|
||||
"button pressed mid-run; pausing",
|
||||
"2026-08-17T09:42:26.100000+00:00 gfcloud[1522] INFO machine:_run_loop "
|
||||
"paused at Position(x=41.2, y=17.0, z=0.0)"]
|
||||
app_cancel = [l.replace("lid opened mid-run; stopping motion",
|
||||
"action canceled mid-run; stopping motion") for l in rest]
|
||||
return pre, paused, rest, app_cancel
|
||||
|
||||
def test_paused_lid_cancel_on_the_bench_excerpt(self):
|
||||
self.in_offline()
|
||||
pre, paused, lid_tail, _app = self.cancel_parts()
|
||||
self.offline_print_hooks(pre)
|
||||
run = self.run_test(cloud.paused_lid_cancel,
|
||||
hooks={"the press pauses the print": lambda: self.append(paused, delay=0.05),
|
||||
"The print is paused: leave the lid open": lambda: (
|
||||
self.lid(False), self.append(lid_tail, delay=0.05)),
|
||||
"Close the lid.": lambda: self.lid(True)},
|
||||
test_id="cloud.paused-lid-cancel")
|
||||
ev = run.evidence
|
||||
self.assertEqual(len(FakeOffline.sent), 1) # one print, handed over the socket
|
||||
self.assertEqual(ev["paused"], {"button pressed mid-run; pausing": True, "paused at": True})
|
||||
self.assertIn(":cancelled", ev["lid_from_pause"]["print finished"])
|
||||
self.assertTrue(ev["lid_from_pause"]["return home complete"])
|
||||
self.assertEqual(ev["counters_after"], [0, 0, 3])
|
||||
self.assertTrue(ev["latch_locked_after"])
|
||||
self.assertFalse(ev["armed_after"])
|
||||
self.assertFalse(ev["button_dark"])
|
||||
self.assertEqual(self.fc.posts, [])
|
||||
self.assertTrue(any("PASS: a paused print canceled by the lid" in l for l in run.lines), run.lines)
|
||||
|
||||
def test_paused_lid_cancel_fails_when_the_paused_print_resumes_instead(self):
|
||||
# a lid that resumed (or was ignored) leaves the print ':completed'
|
||||
self.in_offline()
|
||||
pre, paused, lid_tail, _app = self.cancel_parts()
|
||||
lid_tail = [l.replace(':cancelled"', ':completed"') for l in lid_tail]
|
||||
self.offline_print_hooks(pre)
|
||||
self.assertFails(
|
||||
cloud.paused_lid_cancel, "the print did not end ':cancelled'",
|
||||
hooks={"the press pauses the print": lambda: self.append(paused, delay=0.05),
|
||||
"The print is paused: leave the lid open": lambda: (
|
||||
self.lid(False), self.append(lid_tail, delay=0.05))})
|
||||
self.assertFails(dark_print, "the latch is unlocked at the button wait", hooks=hooks)
|
||||
|
||||
def test_print_finish_is_the_prints_not_another_actions(self):
|
||||
# a motion that completes before the print must not satisfy the print's finish
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""cooling.intake-one-fan replayed host-side under the real runner
|
||||
Context against a scripted engine: the fake reads cool_intake_fans and
|
||||
the floors at every M8 (the engine reloads at run start), publishes
|
||||
intake_2 not fitted with no floor when one intake fan is set, holds every
|
||||
fan in grace for the configured seconds, then judges the bench readings
|
||||
against the floors. The negative engines are the two ways the change
|
||||
could go wrong: the setting ignored (intake 2 still judged) and the
|
||||
setting taking intake 1 out with it."""
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest.runner import Context, Failed, Run
|
||||
from forgetest.suite import cooling, cooling_intake
|
||||
from test_cooling_suite import FakeGrbl
|
||||
|
||||
|
||||
class IntakeOneFanTests(unittest.TestCase):
|
||||
READINGS = {"exhaust": 11640, "intake_1": 4157, "intake_2": 4158, "air_assist": 11050, "purge": 625}
|
||||
FLOORS = {"exhaust": ("cool_tach_exhaust_min_rpm", 6400.0, 20000.0),
|
||||
"intake_1": ("cool_tach_intake_min_rpm", 2290.0, 20000.0),
|
||||
"intake_2": ("cool_tach_intake_min_rpm", 2290.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_intake.INTAKE_TRIP_WAIT_S)
|
||||
cooling.VERDICT_WAIT_S = 4
|
||||
cooling.SESSION_END_WAIT_S = 3
|
||||
cooling_intake.INTAKE_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.fc.state["settings"].setdefault("cool_intake_fans", "")
|
||||
self.honors_setting = True # the engine takes intake 2 out with one intake fan
|
||||
self.drops_both = False # the engine takes intake 1 out with it
|
||||
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_intake.INTAKE_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.9, default * 1.1], "off": "low", "value": v,
|
||||
"state": "off" if v <= 0 else "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"
|
||||
cool.update(verdict="OK", fire_ok=True, hold=False, reason="")
|
||||
for g in cool.get("fan_gates", {}).values():
|
||||
if g["state"] != "not fitted":
|
||||
g["state"] = "idle"
|
||||
threading.Thread(target=end, daemon=True).start()
|
||||
return
|
||||
if line != "M8":
|
||||
return
|
||||
cool["phase"] = "run"
|
||||
one = self.fc.state["settings"].get("cool_intake_fans") == "1" and self.honors_setting
|
||||
out = {"intake_2"} | ({"intake_1"} if self.drops_both else set()) if one else set()
|
||||
floors = {fan: 0.0 if fan in out else self.setting(key, default)
|
||||
for fan, (key, default, _h) in self.FLOORS.items()}
|
||||
cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=[],
|
||||
fan_gates={f: {"reading": self.READINGS[f], "floor": floors[f],
|
||||
"state": "not fitted" if f in out else "grace"} for f in floors})
|
||||
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 f not in out:
|
||||
cool["fan_gates"][f]["state"] = "ok" if self.READINGS[f] >= floors[f] else "under"
|
||||
time.sleep(0.3)
|
||||
if cool["phase"] != "run":
|
||||
return
|
||||
for f in floors:
|
||||
if f not in out and floors[f] > self.READINGS[f]:
|
||||
cool["fan_gates"][f]["state"] = "TRIPPED"
|
||||
if cool["verdict"] != "AIRFLOW":
|
||||
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]))
|
||||
threading.Thread(target=judge, daemon=True).start()
|
||||
|
||||
def run_test(self):
|
||||
run = Run("test", "cooling.intake-one-fan", "t")
|
||||
ctx = Context(run, None, helpers.make_test("cooling.intake-one-fan", []))
|
||||
cooling_intake.intake_one_fan(ctx)
|
||||
return run
|
||||
|
||||
def settings_posts(self):
|
||||
return [f for p, f in self.fc.posts if p == "/settings"]
|
||||
|
||||
def restored(self):
|
||||
return {"cool_intake_fans": "", "cool_tach_intake_min_rpm": "", "cool_fan_grace_s": ""}
|
||||
|
||||
def test_one_fan_then_intake_1_trip_then_restore_pass(self):
|
||||
run = self.run_test()
|
||||
ev = run.evidence
|
||||
self.assertEqual(ev["one_fan"]["intake_2"]["state"], "not fitted")
|
||||
self.assertEqual(ev["one_fan"]["intake_1"]["state"], "ok")
|
||||
self.assertEqual(ev["intake_1_trip"]["intake_1"]["state"], "TRIPPED")
|
||||
self.assertEqual(ev["intake_1_trip"]["intake_2"]["state"], "not fitted")
|
||||
self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values()))
|
||||
posts = self.settings_posts()
|
||||
self.assertEqual(posts[0], {"cool_intake_fans": "1", "cool_fan_grace_s": "8"})
|
||||
self.assertEqual(posts[1], {"cool_tach_intake_min_rpm": "20000.0"})
|
||||
self.assertEqual(posts[-1], self.restored())
|
||||
self.assertEqual(self.grbl.commands.count("M8"), 3)
|
||||
self.assertEqual(self.grbl.commands.count("M9"), 3)
|
||||
|
||||
def test_an_engine_that_ignores_the_setting_fails_and_restores(self):
|
||||
self.honors_setting = False
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("intake_2 is not published not fitted", str(cm.exception))
|
||||
self.assertEqual(self.settings_posts()[-1], self.restored())
|
||||
self.assertEqual(self.fc.state["cool"]["verdict"], "OK")
|
||||
|
||||
def test_an_engine_that_drops_intake_1_too_fails_and_restores(self):
|
||||
self.drops_both = True
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("intake_1 is not judged ok at the intake floor", str(cm.exception))
|
||||
self.assertEqual(self.settings_posts()[-1], self.restored())
|
||||
|
||||
def test_a_machine_already_at_one_intake_fan_is_refused_untouched(self):
|
||||
self.fc.state["settings"]["cool_intake_fans"] = "1"
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.run_test()
|
||||
self.assertIn("already 1", str(cm.exception))
|
||||
self.assertEqual(self.settings_posts(), [])
|
||||
self.assertEqual(self.grbl.commands.count("M8"), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user