mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-16 01:29:55 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a9f655916 | ||
|
|
52a91385cd | ||
|
|
b17778f538 | ||
|
|
d5551d7e8a | ||
|
|
41417f820b | ||
|
|
db6015dc81 | ||
|
|
0c87927388 | ||
|
|
ea8a94a7d1 | ||
|
|
79c03641bd | ||
|
|
ea6ac54ab3 | ||
|
|
f080d5d9cb | ||
|
|
d465625a98 | ||
|
|
7089a25721 | ||
|
|
d65a7aa6aa | ||
|
|
70e945a949 | ||
|
|
2f2a4160af | ||
|
|
ea458850f0 | ||
|
|
e9d5f3c842 | ||
|
|
7c0412075c | ||
|
|
d8b8adaef4 | ||
|
|
c0d4302d73 | ||
|
|
884d014d2c | ||
|
|
a9f0ad0d51 | ||
|
|
2dde10cf43 | ||
|
|
f41464cc58 | ||
|
|
20c6256527 | ||
|
|
b7a595ced7 | ||
|
|
5c93e49c8c | ||
|
|
650483c085 | ||
|
|
7b8f72b632 |
@@ -0,0 +1,4 @@
|
|||||||
|
# Every text file is stored and checked out with LF; a patch keeps its bytes.
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.patch -text
|
||||||
|
*.diff -text
|
||||||
@@ -45,6 +45,8 @@ contributor is welcome to read it too: it is the same set of rules.
|
|||||||
chain of others.
|
chain of others.
|
||||||
- Never exercise a gate or a limit through the `GFCOOL_*` environment
|
- Never exercise a gate or a limit through the `GFCOOL_*` environment
|
||||||
overrides in a test. Use the settings API, and restore it in the teardown.
|
overrides in a test. Use the settings API, and restore it in the teardown.
|
||||||
|
- A silence-while-armed injection and a crash during `$H` are catalog
|
||||||
|
drills, not one-off bench scripts.
|
||||||
|
|
||||||
### CI couplings that set the push order
|
### CI couplings that set the push order
|
||||||
|
|
||||||
@@ -186,6 +188,9 @@ does.
|
|||||||
test with the fix, in the same commit, never after.
|
test with the fix, in the same commit, never after.
|
||||||
- Position counters, homing anchors, and a homed flag are not proof of
|
- Position counters, homing anchors, and a homed flag are not proof of
|
||||||
physical motion. The head accelerometer is, and so are the operator's eyes.
|
physical motion. The head accelerometer is, and so are the operator's eyes.
|
||||||
|
- Unreadable is fail-closed. A failed read of a safety input, a state file,
|
||||||
|
or a verdict is never treated as "still running" or "keep waiting" without
|
||||||
|
a bound.
|
||||||
|
|
||||||
### Proof before done
|
### Proof before done
|
||||||
|
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ case "$1" in
|
|||||||
start)
|
start)
|
||||||
echo "Starting forgetest"
|
echo "Starting forgetest"
|
||||||
mkdir -p "$DATA"
|
mkdir -p "$DATA"
|
||||||
# The bench seed: the commissioning override lifts the wizard part of
|
# The bench seed: the setup override lifts the wizard part of
|
||||||
# the controller gate until the next reboot (/run is tmpfs), so a
|
# the controller gate until the next reboot (/run is tmpfs), so a
|
||||||
# campaign runs on a machine whose required wizards are not complete.
|
# campaign runs on a machine whose required wizards are not complete.
|
||||||
# Nothing lifts the agreements or the account: the bench accepts them
|
# Nothing lifts the agreements or the account: the bench accepts them
|
||||||
# once, and that stays in /data.
|
# once, and that stays in /data.
|
||||||
mkdir -p /run/forgefirm
|
mkdir -p /run/forgefirm
|
||||||
: > /run/forgefirm/commissioning-override
|
: > /run/forgefirm/setup-override
|
||||||
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
|
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- -c \
|
||||||
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
|
"exec /usr/bin/python3 -m forgetest >> $LOG 2>&1"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -217,17 +217,25 @@ def read_position():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def position_quantized(was, now):
|
def counter_steps_per_mm():
|
||||||
|
"""The scale of the X/Y position counters: the kernel counts the
|
||||||
|
microsteps of the mode it runs (cnc/x_mode), so x16 and x32 count two
|
||||||
|
and four times the x8 steps for the same millimeter. The x8 scale when
|
||||||
|
the attribute is unreadable."""
|
||||||
|
return XY_STEPS_PER_MM_OF.get(hw.sysfs_int("cnc/x_mode"), XY_STEPS_PER_MM)
|
||||||
|
|
||||||
|
|
||||||
|
def position_quantized(was, now, spm=XY_STEPS_PER_MM):
|
||||||
"""True when two step-counter readings differ by no more than the
|
"""True when two step-counter readings differ by no more than the
|
||||||
dead band on X and Y and not at all on Z: the step quantization of a
|
dead band on X and Y and not at all on Z: the step quantization of a
|
||||||
move that landed where it meant to, rather than a leftover. A cancel
|
move that landed where it meant to, rather than a leftover. A cancel
|
||||||
that returns the head to the job start lands within a few hundredths
|
that returns the head to the job start lands within a few hundredths
|
||||||
of a millimeter, which is a step or four, and whether that rounds to
|
of a millimeter, which is a step or four, and whether that rounds to
|
||||||
the same integer is chance."""
|
the same integer is chance. spm is the counters' scale (the mode's)."""
|
||||||
if was is None or now is None or now[2] != was[2]:
|
if was is None or now is None or now[2] != was[2]:
|
||||||
return False
|
return False
|
||||||
return (abs(now[0] - was[0]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM and
|
return (abs(now[0] - was[0]) / spm <= POSITION_DEADBAND_MM and
|
||||||
abs(now[1] - was[1]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM)
|
abs(now[1] - was[1]) / spm <= POSITION_DEADBAND_MM)
|
||||||
|
|
||||||
|
|
||||||
def read_ring_residue():
|
def read_ring_residue():
|
||||||
@@ -321,7 +329,7 @@ class Baseline:
|
|||||||
def wait_settled(self, timeout=SETTLE_S, unreachable_s=10):
|
def wait_settled(self, timeout=SETTLE_S, unreachable_s=10):
|
||||||
"""Block until forgectrl reports a settled supervisor: motion
|
"""Block until forgectrl reports a settled supervisor: motion
|
||||||
verified (the probe passed), motion-fault (the ladder exhausted),
|
verified (the probe passed), motion-fault (the ladder exhausted),
|
||||||
standby (the manual stop lever), or gated (the commissioning gate
|
standby (the manual stop lever), or gated (the setup gate
|
||||||
is closed: no controller spawns until it opens). Gives up after
|
is closed: no controller spawns until it opens). Gives up after
|
||||||
unreachable_s without an answer. Returns the last /mode body (None
|
unreachable_s without an answer. Returns the last /mode body (None
|
||||||
if unreachable)."""
|
if unreachable)."""
|
||||||
@@ -450,6 +458,9 @@ class Baseline:
|
|||||||
preserved state to hand back (post) - None compares nothing."""
|
preserved state to hand back (post) - None compares nothing."""
|
||||||
left = []
|
left = []
|
||||||
self.mode = None
|
self.mode = None
|
||||||
|
# the counters' scale is the mode the run counted under, read
|
||||||
|
# before the kernel side puts the settings' mode back
|
||||||
|
self._counter_spm = counter_steps_per_mm()
|
||||||
self._forgectrl_side(left, captured)
|
self._forgectrl_side(left, captured)
|
||||||
self._kernel_side(left)
|
self._kernel_side(left)
|
||||||
self._lamp_side(left)
|
self._lamp_side(left)
|
||||||
@@ -693,13 +704,14 @@ class Baseline:
|
|||||||
self.log("leds/%s: target 0, brightness %s; the fade from a level the machine "
|
self.log("leds/%s: target 0, brightness %s; the fade from a level the machine "
|
||||||
"itself ended, not a leftover" % (name, read_led(name, "brightness")))
|
"itself ended, not a leftover" % (name, read_led(name, "brightness")))
|
||||||
|
|
||||||
def _return_head(self, was, now):
|
def _return_head(self, was, now, spm=XY_STEPS_PER_MM):
|
||||||
"""Jog the head back along its own path by the kernel-measured X/Y
|
"""Jog the head back along its own path by the kernel-measured X/Y
|
||||||
delta (Z is never touched), through the GRBL controller. Bounded:
|
delta (Z is never touched), through the GRBL controller. spm is
|
||||||
beyond RETURN_MAX_MM per axis, or without a running GRBL
|
the counters' scale (the mode's). Bounded: beyond RETURN_MAX_MM
|
||||||
controller, the counters are reported and left."""
|
per axis, or without a running GRBL controller, the counters are
|
||||||
dx = (now[0] - was[0]) / XY_STEPS_PER_MM
|
reported and left."""
|
||||||
dy = (now[1] - was[1]) / XY_STEPS_PER_MM
|
dx = (now[0] - was[0]) / spm
|
||||||
|
dy = (now[1] - was[1]) / spm
|
||||||
if abs(dx) < 0.02 and abs(dy) < 0.02:
|
if abs(dx) < 0.02 and abs(dy) < 0.02:
|
||||||
return "unrestorable (Z only)" if now[2] != was[2] else "restored"
|
return "unrestorable (Z only)" if now[2] != was[2] else "restored"
|
||||||
if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM:
|
if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM:
|
||||||
@@ -787,8 +799,9 @@ class Baseline:
|
|||||||
was = captured.get("position")
|
was = captured.get("position")
|
||||||
now = read_position()
|
now = read_position()
|
||||||
if was is not None and now is not None and now != was and not self.cloud_mode():
|
if was is not None and now is not None and now != was and not self.cloud_mode():
|
||||||
act = self._return_head(was, now)
|
spm = self._counter_spm
|
||||||
if position_quantized(was, now):
|
act = self._return_head(was, now, spm)
|
||||||
|
if position_quantized(was, now, spm):
|
||||||
self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, "
|
self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, "
|
||||||
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
|
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -247,8 +247,11 @@ TOOLS = [
|
|||||||
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
|
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
|
||||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
"desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the "
|
"desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the "
|
||||||
"lens stands, and neither a $20 nor a $132 write frees it. A CI harness (the grblHAL repo): needs "
|
"lens stands, and neither a $20 nor a $132 write frees it. A referenced lens (forgectrl's marker "
|
||||||
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
"and the lens settings) opens it to the window the settings hold, the fallback or the focus "
|
||||||
|
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
|
||||||
|
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
|
||||||
|
"bench-page tool."},
|
||||||
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
|
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
|
||||||
"safety": "dry", "where": "board", "ported": True,
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
|
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ order. Each module registers its tests with @catalog.test."""
|
|||||||
from . import image # noqa: F401,E402
|
from . import image # noqa: F401,E402
|
||||||
from . import kernel # noqa: F401,E402
|
from . import kernel # noqa: F401,E402
|
||||||
from . import forgectrl # noqa: F401,E402
|
from . import forgectrl # noqa: F401,E402
|
||||||
from . import commission # noqa: F401,E402
|
from . import setup # noqa: F401,E402
|
||||||
from . import commission_dark # noqa: F401,E402
|
from . import setup_dark # noqa: F401,E402
|
||||||
from . import commission_sheet # noqa: F401,E402
|
from . import setup_sheet # noqa: F401,E402
|
||||||
from . import logs # noqa: F401,E402
|
from . import logs # noqa: F401,E402
|
||||||
from . import motion # noqa: F401,E402
|
from . import motion # noqa: F401,E402
|
||||||
from . import cooling # noqa: F401,E402
|
from . import cooling # noqa: F401,E402
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ def lid_privacy(ctx):
|
|||||||
"authorizes a write; a rotation from the panel route stops the old key and "
|
"authorizes a write; a rotation from the panel route stops the old key and "
|
||||||
"the new one reads. The setting is put back after.")
|
"the new one reads. The setting is put back after.")
|
||||||
def key_read(ctx):
|
def key_read(ctx):
|
||||||
from .commission import request
|
from .setup import request
|
||||||
from .forgectrl import lan_ip
|
from .forgectrl import lan_ip
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ FORGECTRL_LOG = "/data/log/forgefirm/forgectrl/forgectrl.log"
|
|||||||
LIMITS_MARK = "job limits from the header: "
|
LIMITS_MARK = "job limits from the header: "
|
||||||
EFFECTIVE_MARK = "effective limits: coolant ceiling "
|
EFFECTIVE_MARK = "effective limits: coolant ceiling "
|
||||||
SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
|
SESSION_MARKS = ("authenticate_machine SUCCESS", "ws_connect ESTABLISHED")
|
||||||
|
# The service's connect-time hunt, as the client logs its request: a few
|
||||||
|
# seconds after the controller starts, right behind the session.
|
||||||
|
HUNT_REQUEST = "service action request: hunt"
|
||||||
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
|
RETURN_MAX_MM = 600.0 # the head comes back from the home corner across the bed
|
||||||
|
|
||||||
|
|
||||||
@@ -145,7 +148,7 @@ def return_head(ctx, feed=2400):
|
|||||||
"head not back at the start after the return jog: %s", pos)
|
"head not back at the start after the return jog: %s", pos)
|
||||||
|
|
||||||
|
|
||||||
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
|
def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90, poll=1.0):
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
last = None
|
last = None
|
||||||
while time.time() - t0 < timeout:
|
while time.time() - t0 < timeout:
|
||||||
@@ -157,7 +160,7 @@ def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
|
|||||||
return m
|
return m
|
||||||
if m.get("controller") == "motion-fault":
|
if m.get("controller") == "motion-fault":
|
||||||
break
|
break
|
||||||
time.sleep(1)
|
time.sleep(poll)
|
||||||
return last
|
return last
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +230,11 @@ def gfhome_homing(ctx, ev, g):
|
|||||||
# homed: the counters are re-anchored at the corner, where the head stays
|
# homed: the counters are re-anchored at the corner, where the head stays
|
||||||
ctx.counters_rezeroed()
|
ctx.counters_rezeroed()
|
||||||
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing")
|
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing")
|
||||||
|
# The driver answers $H when the session ends; that ok (and the
|
||||||
|
# session's messages) sit in the buffer behind the status reports and
|
||||||
|
# would pass for the reply to the caller's next command.
|
||||||
|
ack = " | ".join(ln.strip() for ln in g.drain().splitlines() if ln.strip())
|
||||||
|
ctx.log("$H acknowledged: %s", ack[:160] or "(nothing buffered)")
|
||||||
|
|
||||||
|
|
||||||
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt "
|
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt "
|
||||||
@@ -235,17 +243,22 @@ def gfhome_homing(ctx, ev, g):
|
|||||||
covers=_HOMING_PATH + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"),
|
covers=_HOMING_PATH + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*"),
|
||||||
("grblhal-glowforge", "src/**")],
|
("grblhal-glowforge", "src/**")],
|
||||||
requires=["forgectrl.auth", "motion.pacing"], actions=["lid"],
|
requires=["forgectrl.auth", "motion.pacing"], actions=["lid"],
|
||||||
steps=["Bed clear; cloud credentials configured; the machine on the network. The test "
|
steps=["Bed clear, lid closed; cloud credentials configured; the machine on the network. "
|
||||||
"turns cloud mode and the gfcloud homing on itself when they are off, and puts "
|
"The test turns cloud mode and the gfcloud homing on itself when they are off, and "
|
||||||
"the settings back at the end.",
|
"puts the settings back at the end.",
|
||||||
"Open the lid when told and leave it open through the cloud client's connect and its "
|
"Open the lid the moment you are told, with a hand ready on it: the cloud client's "
|
||||||
"hunt; close it when told. Nothing else: the switch back and the $H homing run on "
|
"first hunt begins a few seconds after its controller starts and must find the lid "
|
||||||
"their own, and the head ends parked at the home corner."],
|
"open. Leave it open through the connect and the hunt; close it when told. Nothing "
|
||||||
|
"else: the switch back and the $H homing run on their own, and the head ends parked "
|
||||||
|
"at the home corner."],
|
||||||
description="One round trip with the two service-driven motions on it. POST /mode switches "
|
description="One round trip with the two service-driven motions on it. POST /mode switches "
|
||||||
"to the cloud controller: gfcloud comes up under supervision, authenticates and "
|
"to the cloud controller with the lid closed (no controller starts with the "
|
||||||
|
"enclosure open): gfcloud comes up under supervision, authenticates and "
|
||||||
"establishes its service session (its own log lines are the evidence; the "
|
"establishes its service session (its own log lines are the evidence; the "
|
||||||
"connect-time firmware probe is recorded when configured). Its connect-time hunt "
|
"connect-time firmware probe is recorded when configured). The lid is opened the "
|
||||||
"runs with the lid OPEN, as the factory's does - it completes, nothing before its "
|
"moment the controller is up, before the client requests its connect-time hunt "
|
||||||
|
"(a hunt requested before the lid was open fails the test), so the hunt runs "
|
||||||
|
"with the lid OPEN, as the factory's does - it completes, nothing before its "
|
||||||
"end is refused for the lid, the lens homes - and with the extraction fans off it "
|
"end is refused for the lid, the lens homes - and with the extraction fans off it "
|
||||||
"is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row "
|
"is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row "
|
||||||
"reads unjudged); the camera service survives the switch. The lid closed, the "
|
"reads unjudged); the camera service survives the switch. The lid closed, the "
|
||||||
@@ -270,17 +283,32 @@ def mode_switch(ctx):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
lamp0 = hw.sysfs_read("pic/lid_led")
|
lamp0 = hw.sysfs_read("pic/lid_led")
|
||||||
|
ctx.check(ctx.switch("lid") is True, "close the lid first: no controller starts with it open")
|
||||||
|
|
||||||
ctx.act("lid", "open", text="Leave it open: the cloud client connects and hunts with the lid open.")
|
# The switch is made with the enclosure closed (the supervisor holds
|
||||||
|
# every spawn until it is). The lid opens the moment the controller is
|
||||||
|
# up: the client requests its connect-time hunt a few seconds after
|
||||||
|
# its start, right behind its session, and the hunt must find the lid
|
||||||
|
# open. The poll is tight so the window is not spent waiting.
|
||||||
log_offset = log_size(GFCLOUD_LOG)
|
log_offset = log_size(GFCLOUD_LOG)
|
||||||
st, body = fc.post("/mode", data={"controller": "cloud"})
|
st, body = fc.post("/mode", data={"controller": "cloud"})
|
||||||
ctx.log("POST /mode controller=cloud -> %s %s", st, body)
|
ctx.log("POST /mode controller=cloud -> %s %s", st, body)
|
||||||
ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body)
|
ctx.check(st == 200, "mode switch to cloud refused: %s %s", st, body)
|
||||||
m = wait_mode(ctx, fc, "cloud", timeout=90)
|
m = wait_mode(ctx, fc, "cloud", timeout=90, poll=0.2)
|
||||||
ev["mode_cloud"] = m
|
ev["mode_cloud"] = m
|
||||||
ctx.log("mode after switch: %s", m)
|
ctx.log("mode after switch: %s", m)
|
||||||
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
|
ctx.check(m and m.get("mode") == "cloud" and m.get("controller") == "running",
|
||||||
"cloud controller did not come up: %s", m)
|
"cloud controller did not come up: %s", m)
|
||||||
|
ctx.act("lid", "open", text="Now, at once: the cloud client's first hunt begins a few seconds "
|
||||||
|
"after its controller starts and must find the lid open. Leave it open through the "
|
||||||
|
"connect and the hunt.")
|
||||||
|
# The order is the proof: the hunt's request is not in the log yet
|
||||||
|
# when the lid reads open.
|
||||||
|
ev["hunt_before_lid_open"] = log_has(log_offset, HUNT_REQUEST)
|
||||||
|
ctx.check(not ev["hunt_before_lid_open"],
|
||||||
|
"the client requested its hunt before the lid was open (the hunt follows the "
|
||||||
|
"controller's start by a few seconds): the lid opened too late, so a hunt with the "
|
||||||
|
"lid open is not proven")
|
||||||
# the client's own session lines are the evidence of a live cloud session
|
# the client's own session lines are the evidence of a live cloud session
|
||||||
session = wait_session(ctx, log_offset)
|
session = wait_session(ctx, log_offset)
|
||||||
probe = None
|
probe = None
|
||||||
@@ -1295,42 +1323,96 @@ def lid_during_button_wait_body(ctx, ev, off, job, offset):
|
|||||||
# ---------------------------------------------------------------- the cooling verdict
|
# ---------------------------------------------------------------- the cooling verdict
|
||||||
|
|
||||||
HOLD_KEYS = ("cool_temp_min", "cool_temp_start")
|
HOLD_KEYS = ("cool_temp_min", "cool_temp_start")
|
||||||
WARMUP_ABOVE_C = 1.0 # the start gate this far above the loop: a few heater minutes
|
REFUSE_KEYS = HOLD_KEYS + ("cloud_hold_max_s",)
|
||||||
WARMUP_RELEASE_S = 720 # the flow heater warms the bulk 0.4 to 0.8 C a minute
|
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"
|
WARMUP_WAIT_LINE = "waiting on the cooling engine: WARMUP"
|
||||||
WARMUP_RELEASE_LINE = "cooling verdict clean after WARMUP; starting the run"
|
|
||||||
|
|
||||||
|
|
||||||
@test("cloud.verdict-hold", title="A cloud print armed under the warm-up gate waits for the engine",
|
@test("cloud.dark-print", title="A dark cloud print: the latch locked until the run, unlocked "
|
||||||
subsystem="cloud", kind="operator", est_min=14,
|
"for it, locked after it",
|
||||||
|
subsystem="cloud", kind="operator", est_min=3,
|
||||||
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
covers=_MACHINE_RUN + [("python3-gfhardware", "gfhardware/coolsvc.py"),
|
||||||
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
|
("forgectrl", "src/cool.*"), ("forgectrl", "src/main.c")],
|
||||||
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
|
requires=["cloud.lid-during-button-wait", "cooling.floor-and-warm-up"], actions=["button"],
|
||||||
steps=[OFFLINE_STEP,
|
steps=[OFFLINE_STEP,
|
||||||
"Coolant at room temperature (10 to 30 C). The test sets the warm-up gate just above "
|
"Bed clear (the job is dark: a 30 s square at S0, nothing fires). Press the button "
|
||||||
"the coolant and restores it. Press the button when it lights: the print then waits "
|
"when it lights: the print waits for the engine, runs, and finishes."],
|
||||||
"for the warm-up (a few minutes with the loop heater on), runs dark, and finishes."],
|
description="The cooling-engine contract in cloud mode at its ordinary end, on the engine "
|
||||||
description="The cooling-engine contract in cloud mode, on the engine itself: the start gate "
|
"itself: the print arms on the press, waits for the engine's acknowledgment (the "
|
||||||
"set just above the coolant makes the armed session open under the warm-up "
|
"fans at their floors), and runs. The laser latch is locked at the button and "
|
||||||
"(WARMUP: fire blocked, hold), and the client waits it out after the button "
|
"through the wait, unlocks only for the run (immediately before it starts), and "
|
||||||
"instead of canceling - nothing runs, the latch stays unlocked for the armed "
|
"is locked again when the job ends; the print completes. A hold the engine never "
|
||||||
"window - until the engine releases into run, and the release starts the print, "
|
"clears is cloud.verdict-refuse; the engine's own warm-up release is "
|
||||||
"which completes. The hold mid-run (the same laser-off pause as the button's, "
|
"cooling.floor-and-warm-up.")
|
||||||
"resumed on the engine's resume_ok) and the bound on a hold are host-tested: the "
|
def dark_print(ctx):
|
||||||
"gates apply at session open, so no setting can produce a hold mid-run here.")
|
ev = ctx.evidence
|
||||||
def verdict_hold(ctx):
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def dark_print_body(ctx, ev, off, job, offset, fc):
|
||||||
|
off.print_ready(9005, job)
|
||||||
|
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||||
|
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
||||||
|
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. The print runs dark and finishes.",
|
||||||
|
until=lambda: wait_print_running(ctx, offset, 0.1) is not None, timeout=PRESS_TIMEOUT_S)
|
||||||
|
run = wait_print_running(ctx, offset, 60)
|
||||||
|
ctx.check(run is not None, "the print did not start after the press")
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
ev["latch_locked_in_run"] = latch_locked()
|
||||||
|
ctx.check(ev["latch_locked_in_run"] is False, "the latch is locked during the run")
|
||||||
|
fin = wait_action_finished(ctx, offset, "print", 180)
|
||||||
|
ev["print finished"] = message(fin)
|
||||||
|
ctx.check(fin and COMPLETED in fin, "the print did not complete: %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 print")
|
||||||
|
ev["latch_locked_after"] = latch_locked()
|
||||||
|
ctx.check(ev["latch_locked_after"], "the latch is unlocked after the print")
|
||||||
|
ev["events"] = offline_events(off)
|
||||||
|
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
|
ev = ctx.evidence
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
before = fc.settings()
|
before = fc.settings()
|
||||||
orig = {k: before.get(k, "") for k in HOLD_KEYS}
|
orig = {k: before.get(k, "") for k in REFUSE_KEYS}
|
||||||
ev["orig"] = orig
|
ev["orig"] = orig
|
||||||
up = (fc.status().get("coolant") or {}).get("up_c")
|
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)
|
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)
|
offset = enter_offline(ctx)
|
||||||
job = offline_job(ctx, "hold.puls", seconds=30)
|
job = offline_job(ctx, "refuse.puls", seconds=30)
|
||||||
off = Offline().__enter__()
|
off = Offline().__enter__()
|
||||||
try:
|
try:
|
||||||
verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig)
|
verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig)
|
||||||
finally:
|
finally:
|
||||||
st, body = fc.post("/settings", params=orig)
|
st, body = fc.post("/settings", params=orig)
|
||||||
ctx.log("restore: POST /settings %s -> %s", orig, st)
|
ctx.log("restore: POST /settings %s -> %s", orig, st)
|
||||||
@@ -1338,46 +1420,53 @@ def verdict_hold(ctx):
|
|||||||
offline_cleanup(ctx)
|
offline_cleanup(ctx)
|
||||||
|
|
||||||
|
|
||||||
def verdict_hold_body(ctx, ev, off, job, offset, fc, up, orig):
|
def verdict_refuse_body(ctx, ev, off, job, offset, fc, up, orig):
|
||||||
gate = round(up + WARMUP_ABOVE_C, 1)
|
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),
|
st, body = fc.post("/settings", params={"cool_temp_start": str(gate),
|
||||||
"cool_temp_min": orig["cool_temp_min"]})
|
"cool_temp_min": orig["cool_temp_min"],
|
||||||
ctx.check(st == 200, "POST /settings cool_temp_start=%s -> %s %s", gate, st, body)
|
"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
|
ev["gate"] = gate
|
||||||
off.print_ready(9005, job)
|
off.print_ready(9006, job)
|
||||||
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
got = wait_log(ctx, offset, ["waiting for button"], 120)
|
||||||
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
ctx.check(got["waiting for button"], "the print never reached the button wait")
|
||||||
ctx.act("button", "press", text="The button is lit: press it. The print then waits for the warm-up.",
|
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)
|
until=lambda: log_has(offset, WARMUP_WAIT_LINE), timeout=PRESS_TIMEOUT_S)
|
||||||
got = wait_log(ctx, offset, [WARMUP_WAIT_LINE], 30)
|
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")
|
ctx.check(got[WARMUP_WAIT_LINE], "the armed print did not wait on the warm-up")
|
||||||
st, c = fc.get("/cool/status")
|
|
||||||
ev["warmup"] = c
|
|
||||||
ctx.check(isinstance(c, dict) and c.get("verdict") == "WARMUP" and c.get("hold") is True
|
|
||||||
and c.get("fire_ok") is False,
|
|
||||||
"the engine is not holding the armed session on the warm-up: %s", c)
|
|
||||||
ctx.check(wait_print_running(ctx, offset, 2) is None, "the run started under the warm-up hold")
|
|
||||||
ctx.notice("Warm-up: the loop heater brings the coolant to the gate; a few minutes. Do nothing.")
|
|
||||||
try:
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
run = wait_print_running(ctx, offset, WARMUP_RELEASE_S)
|
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:
|
finally:
|
||||||
ctx.clear_notice()
|
ctx.clear_notice()
|
||||||
ev["release_s"] = round(time.time() - t0)
|
ev["hold_s"] = round(time.time() - t0)
|
||||||
got = wait_log(ctx, offset, [WARMUP_RELEASE_LINE], 5)
|
ev["latch_samples"] = {"n": len(samples), "unlocked": samples.count(False)}
|
||||||
ev["log"] = {WARMUP_WAIT_LINE: True, WARMUP_RELEASE_LINE: bool(got[WARMUP_RELEASE_LINE])}
|
ctx.check(log_has(offset, HOLD_REFUSE_LINE), "the client did not cancel at the hold bound (%d s)",
|
||||||
ctx.check(got[WARMUP_RELEASE_LINE], "the warm-up did not release into the run within %d s",
|
ev["hold_s"])
|
||||||
WARMUP_RELEASE_S)
|
ctx.check(all(samples), "the latch unlocked during the hold (%d of %d samples)",
|
||||||
ctx.check(run is not None, "the print did not start after the warm-up")
|
samples.count(False), len(samples))
|
||||||
fin = wait_action_finished(ctx, offset, "print", 180)
|
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)
|
ev["print finished"] = message(fin)
|
||||||
ctx.check(fin and COMPLETED in fin, "the print did not complete after the warm-up: %s",
|
ctx.check(fin and CANCELLED in fin, "the held print did not end ':cancelled': %s",
|
||||||
message(fin) or "no finish line")
|
message(fin) or "no finish line")
|
||||||
st, cs = fc.get("/cool/status")
|
st, cs = fc.get("/cool/status")
|
||||||
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
ev["armed_after"] = cs.get("armed") if isinstance(cs, dict) else None
|
||||||
ctx.check(not ev["armed_after"], "armed window still open after the print")
|
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)
|
ev["events"] = offline_events(off)
|
||||||
ctx.log("PASS: the armed print waited %s s on the warm-up, then ran and completed", ev["release_s"])
|
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)",
|
@test("cloud.pause-resume", title="Button pauses and resumes a cloud print (factory backtrack + lead)",
|
||||||
|
|||||||
@@ -746,7 +746,7 @@ FIRE_WAIT_S = 25 # settings re-read at run start, two-tick breach, 1
|
|||||||
|
|
||||||
@test("cooling.fire-watch-tiers", title="The lid-IR fire watch pauses at its alert and fails at its critical",
|
@test("cooling.fire-watch-tiers", title="The lid-IR fire watch pauses at its alert and fails at its critical",
|
||||||
subsystem="cooling", kind="auto", mode="grbl", est_min=6,
|
subsystem="cooling", kind="auto", mode="grbl", est_min=6,
|
||||||
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"],
|
covers=_COOL_COVERS + [("forgectrl", "src/super.*")], requires=["kernel.latch-locked-idle"],
|
||||||
steps=["Machine idle, lid closed, lid lamp at its resting level. The test moves the flame "
|
steps=["Machine idle, lid closed, lid lamp at its resting level. The test moves the flame "
|
||||||
"thresholds under the lamp's own reading and restores them; three M8/M9 sessions, "
|
"thresholds under the lamp's own reading and restores them; three M8/M9 sessions, "
|
||||||
"no fire."],
|
"no fire."],
|
||||||
@@ -759,6 +759,7 @@ FIRE_WAIT_S = 25 # settings re-read at run start, two-tick breach, 1
|
|||||||
"read as the four flame gates off and the watch reads watch. Restored, the "
|
"read as the four flame gates off and the watch reads watch. Restored, the "
|
||||||
"watch reads armed at OK.")
|
"watch reads armed at OK.")
|
||||||
def fire_watch_tiers(ctx):
|
def fire_watch_tiers(ctx):
|
||||||
|
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
before = fc.settings()
|
before = fc.settings()
|
||||||
@@ -810,7 +811,16 @@ def fire_watch_tiers(ctx):
|
|||||||
ctx.check(c.get("verdict") == "OK", "the alert survived into a fresh session: %s", c)
|
ctx.check(c.get("verdict") == "OK", "the alert survived into a fresh session: %s", c)
|
||||||
|
|
||||||
# Leg 2: the fail tier. The q1 critical under the lamp reading
|
# Leg 2: the fail tier. The q1 critical under the lamp reading
|
||||||
# stops the session and latches FIRE until it ends.
|
# latches FIRE and locks the laser; the fail tier then stops
|
||||||
|
# the controller through the supervisor and starts it again,
|
||||||
|
# so no run start can relight what was locked and the sender
|
||||||
|
# sees the job end. The Grbl socket dies with the restart -
|
||||||
|
# FIRE is read from the engine, the latch from sysfs, the stop
|
||||||
|
# from the supervisor's log, and a fresh session is opened for
|
||||||
|
# the legs that follow.
|
||||||
|
st, m0 = fc.get("/mode")
|
||||||
|
pid0 = m0.get("pid") if isinstance(m0, dict) else None
|
||||||
|
off = _log_offset(FORGECTRL_LOG)
|
||||||
_set_gates(ctx, fc, {"cool_fire_q1_critical": str(max(2, q1 - 5)),
|
_set_gates(ctx, fc, {"cool_fire_q1_critical": str(max(2, q1 - 5)),
|
||||||
"cool_fire_q1_alert": str(max(1, q1 - 10))})
|
"cool_fire_q1_alert": str(max(1, q1 - 10))})
|
||||||
grbl.command("M8")
|
grbl.command("M8")
|
||||||
@@ -821,19 +831,38 @@ def fire_watch_tiers(ctx):
|
|||||||
c = _cool(fc)
|
c = _cool(fc)
|
||||||
if c.get("verdict") == "FIRE":
|
if c.get("verdict") == "FIRE":
|
||||||
break
|
break
|
||||||
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
ev["critical"] = c
|
ev["critical"] = c
|
||||||
ctx.log("critical leg: verdict %s fire_watch %s", c.get("verdict"), c.get("fire_watch"))
|
ev["interlock_circuit"] = ilk
|
||||||
|
ctx.log("critical leg: verdict %s fire_watch %s interlock_circuit %s",
|
||||||
|
c.get("verdict"), c.get("fire_watch"), ilk)
|
||||||
ctx.check(c.get("verdict") == "FIRE", "q1 critical under the lamp did not latch FIRE: %s", c)
|
ctx.check(c.get("verdict") == "FIRE", "q1 critical under the lamp did not latch FIRE: %s", c)
|
||||||
ctx.check(c.get("fire_watch") == "ALARM", "fire_watch %r during FIRE, expected ALARM", c.get("fire_watch"))
|
ctx.check(c.get("fire_watch") == "ALARM", "fire_watch %r during FIRE, expected ALARM", c.get("fire_watch"))
|
||||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
|
||||||
ev["interlock_circuit"] = ilk
|
|
||||||
ctx.check(ilk is not None and (ilk & 8), "the laser latch is not locked under FIRE (interlock_circuit=%s)", ilk)
|
ctx.check(ilk is not None and (ilk & 8), "the laser latch is not locked under FIRE (interlock_circuit=%s)", ilk)
|
||||||
ctx.check(_tail_wait(ctx, fc, "LID IR FIRE SIGNAL (quartiles "),
|
ctx.check(_tail_wait(ctx, fc, "LID IR FIRE SIGNAL (quartiles "),
|
||||||
"the FIRE line is missing from the forgectrl log")
|
"the FIRE line is missing from the forgectrl log")
|
||||||
|
# the fail tier stops the controller and starts it again; the
|
||||||
|
# Grbl socket dies with it. Wait out the restart, prove the
|
||||||
|
# supervisor logged the stop, restore the thresholds, and open
|
||||||
|
# a fresh session on the new controller.
|
||||||
|
grbl.close()
|
||||||
|
m1 = None
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < 60:
|
||||||
|
st, m1 = fc.get("/mode")
|
||||||
|
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
|
||||||
|
break
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
ev["restart"] = {"pid_before": pid0, "mode_after": m1}
|
||||||
|
ctx.log("after FIRE: %s", ev["restart"])
|
||||||
|
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid0,
|
||||||
|
"the controller was not stopped and started again after FIRE: %s", m1)
|
||||||
|
ctx.check(_log_lines(FORGECTRL_LOG, off, "lid IR fire signal - the controller is stopped"),
|
||||||
|
"the supervisor did not log the fail-tier stop after FIRE")
|
||||||
_set_gates(ctx, fc, {"cool_fire_q1_critical": orig["cool_fire_q1_critical"],
|
_set_gates(ctx, fc, {"cool_fire_q1_critical": orig["cool_fire_q1_critical"],
|
||||||
"cool_fire_q1_alert": orig["cool_fire_q1_alert"]})
|
"cool_fire_q1_alert": orig["cool_fire_q1_alert"]})
|
||||||
grbl.command("M9")
|
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the FIRE restart")
|
||||||
_session_ended(ctx, fc, "critical leg")
|
grbl.connect() # a fresh session on the restarted controller
|
||||||
|
|
||||||
# Leg 3: all four at zero: the watch off, said so, session OK.
|
# Leg 3: all four at zero: the watch off, said so, session OK.
|
||||||
_set_gates(ctx, fc, {k: "0" for k in FIRE_KEYS})
|
_set_gates(ctx, fc, {k: "0" for k in FIRE_KEYS})
|
||||||
@@ -891,7 +920,7 @@ CRASH_GATES = ["crash_abort", "crash_x_alert", "crash_y_alert"]
|
|||||||
"read as the three crash gates off by value (gates_off names them) with the "
|
"read as the three crash gates off by value (gates_off names them) with the "
|
||||||
"session OK; a threshold past the register range is refused; restored, the "
|
"session OK; a threshold past the register range is refused; restored, the "
|
||||||
"gates read ok. The tiers themselves (BUMP, CRASH) trip only on a physical "
|
"gates read ok. The tiers themselves (BUMP, CRASH) trip only on a physical "
|
||||||
"knock inside an armed window and are commissioned at the bench, not here.")
|
"knock inside an armed window and are proven at the bench, not here.")
|
||||||
def crash_watch_plumbing(ctx):
|
def crash_watch_plumbing(ctx):
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
@@ -1329,3 +1358,150 @@ def flow_under_load(ctx):
|
|||||||
ctx.check(dt is not None, "the window did not close after M2")
|
ctx.check(dt is not None, "the window did not close after M2")
|
||||||
ctx.log("PASS: lit through the window, raw rise %.1f C, laser %.1f C off, judged %.1f C against %.1f "
|
ctx.log("PASS: lit through the window, raw rise %.1f C, laser %.1f C off, judged %.1f C against %.1f "
|
||||||
"(dT %.1f), window closed %.1f s after M2", raw, share, rise, float(limit), float(dtc), dt)
|
"(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):
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
ctx.check(_tail_has(fc, "head/air_assist_pwm read 204 with 1023 commanded: put back"),
|
||||||
|
"the air-assist loss is not named in the log")
|
||||||
|
ctx.check(_tail_has(fc, "thermal/exhaust_pwm read 0 with 65535 commanded: put back"),
|
||||||
|
"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,
|
||||||
|
covers=_COOL_COVERS + [("forgectrl", "src/super.*")],
|
||||||
|
requires=["cooling.crash-watch-plumbing", "motion.deadman"], actions=["button"],
|
||||||
|
steps=["Bed clear, lid closed, 40 mm of free +X travel. Nothing fires: the job is dark (S0).",
|
||||||
|
"Press the physical button when it lights white, once. The head moves and stops on "
|
||||||
|
"its own; the controller restarts."],
|
||||||
|
description="The engine's fail tiers (a lid IR fire signal, a head crash signal) do not "
|
||||||
|
"leave the job to the controller: after the kernel writes (motion stopped, the "
|
||||||
|
"latch locked) the engine ends the controller through the supervisor, which "
|
||||||
|
"starts it again, so no run start can relight what was locked and the sender "
|
||||||
|
"sees the job end. Provable without emission or a physical crash: the crash "
|
||||||
|
"watch's thresholds at their lowest (1) make the head's own move trip the abort "
|
||||||
|
"generator inside an armed, dark (S0) job. Expected: the engine logs the crash "
|
||||||
|
"signal, the supervisor logs the stop and starts a new controller (new pid), the "
|
||||||
|
"latch is locked, the kernel idle, no emission, and the thresholds are put back.")
|
||||||
|
def fail_tier_stop(ctx):
|
||||||
|
from .laser import prepare, stream, sample
|
||||||
|
from .motion import FORGECTRL_LOG, _log_lines, _log_offset
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
before = fc.settings()
|
||||||
|
orig = {k: before.get(k, "") for k in CRASH_KEYS}
|
||||||
|
ev["orig"] = orig
|
||||||
|
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")
|
||||||
|
_set_gates(ctx, fc, {k: "1" for k in CRASH_KEYS})
|
||||||
|
off = _log_offset(FORGECTRL_LOG)
|
||||||
|
t_arm = None
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
prepare(ctx, g)
|
||||||
|
ctx.ready("DARK JOB (S0, nothing fires). Bed clear with 40 mm of free +X travel, lid closed. "
|
||||||
|
"The head moves 20 mm and the controller restarts on its own.")
|
||||||
|
stream(g, ["G91", "G21", "M3 S0", "G4 P1", "G1 X20 F1200", "G1 X-20 F1200", "M5", "G90", "M2"])
|
||||||
|
ctx.arm_press()
|
||||||
|
t_arm = ctx.wait_for(lambda: bool((sample(ctx) or {}).get("armed")), 240)
|
||||||
|
ctx.clear_notice()
|
||||||
|
ctx.check(t_arm is not None, "the window never opened (arm refused, or no press)")
|
||||||
|
t0 = time.time()
|
||||||
|
tripped = ctx.wait_for(lambda: _cool(fc).get("verdict") == "CRASH"
|
||||||
|
or (fc.get("/mode")[1] or {}).get("pid") not in (None, pid0), 30)
|
||||||
|
ctx.log("the crash tier tripped after %s s", tripped)
|
||||||
|
except (hw.HwError, OSError) as e:
|
||||||
|
ctx.log("the Grbl connection ended with the restart: %s", e)
|
||||||
|
t_kill = time.time()
|
||||||
|
m1 = None
|
||||||
|
while time.time() - t_kill < 60:
|
||||||
|
st, m1 = fc.get("/mode")
|
||||||
|
if isinstance(m1, dict) and m1.get("controller") == "running" and m1.get("pid") != pid0:
|
||||||
|
break
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
# the daemon's lines reach the log file through syslog, a moment
|
||||||
|
# after the events they name
|
||||||
|
ctx.wait_for(lambda: bool(_log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL"))
|
||||||
|
and bool(_log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")), 15)
|
||||||
|
engine = _log_lines(FORGECTRL_LOG, off, "HEAD CRASH SIGNAL")
|
||||||
|
super_ = _log_lines(FORGECTRL_LOG, off, "head crash signal - the controller is stopped")
|
||||||
|
smp = sample(ctx)
|
||||||
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
|
ev["result"] = {"mode_after": m1, "engine_lines": [ln[-160:] for ln in engine[-2:]],
|
||||||
|
"super_lines": [ln[-160:] for ln in super_[-2:]],
|
||||||
|
"emission": smp and smp["emission"], "kernel": smp and smp["kstate"],
|
||||||
|
"latch_locked": ilk is not None and bool(ilk & (1 << 3))}
|
||||||
|
ctx.log("after the crash tier: %s", ev["result"])
|
||||||
|
ctx.check(engine, "the engine did not log the crash signal")
|
||||||
|
ctx.check(super_, "the supervisor did not log the fail-tier stop")
|
||||||
|
ctx.check(m1 and m1.get("pid") != pid0, "the controller was not stopped and started again: %s", m1)
|
||||||
|
ctx.check(ev["result"]["latch_locked"], "latch not locked after the crash tier")
|
||||||
|
ctx.check(not (smp and smp["emission"]), "emission during a dark job: %s", smp and smp["emission"])
|
||||||
|
ctx.check(fc.wait_idle(30, abort=ctx.aborted), "machine not idle after the restart")
|
||||||
|
finally:
|
||||||
|
fc.wait_idle(30, abort=ctx.aborted)
|
||||||
|
st, body = fc.post("/settings", params=orig)
|
||||||
|
ctx.log("restore thresholds: POST /settings %s -> %s", orig, st)
|
||||||
|
after = fc.settings()
|
||||||
|
ctx.check(all(after.get(k, "") == orig[k] for k in CRASH_KEYS),
|
||||||
|
"thresholds not restored: %s", {k: after.get(k) for k in CRASH_KEYS})
|
||||||
|
ctx.log("PASS: the crash tier stopped the job, locked the latch, and the supervisor restarted "
|
||||||
|
"the controller (pid %s -> %s)", pid0, m1 and m1.get("pid"))
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ def auth(ctx):
|
|||||||
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
|
("/mode", {"controller": "grbl"}), ("/settings", {"ui_units": "mm"}),
|
||||||
("/diag/flow-verify", None), ("/diag/abort", None),
|
("/diag/flow-verify", None), ("/diag/abort", None),
|
||||||
("/update/apply", None), ("/boot", {"target": "a"}),
|
("/update/apply", None), ("/boot", {"target": "a"}),
|
||||||
|
("/update/check", None), ("/update/dismiss", {"version": "v0.0.0"}),
|
||||||
("/system/reboot", None), ("/restore/factory", None),
|
("/system/reboot", None), ("/restore/factory", None),
|
||||||
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
|
("/restore/factory-return", {"confirm": "1"}), ("/system/ssh", {"enable": "1"}),
|
||||||
("/wiz/advisories/accept", None), ("/wiz/account", None),
|
("/wiz/advisories/accept", None), ("/wiz/account", None),
|
||||||
@@ -151,7 +152,7 @@ def auth(ctx):
|
|||||||
# HTTPS the route itself refuses the peer (403 loopback only). Neither
|
# HTTPS the route itself refuses the peer (403 loopback only). Neither
|
||||||
# request follows the redirect, and the self-signed certificate is
|
# request follows the redirect, and the self-signed certificate is
|
||||||
# not verified.
|
# not verified.
|
||||||
from .commission import request, decode
|
from .setup import request, decode
|
||||||
ip = lan_ip()
|
ip = lan_ip()
|
||||||
ev["lan_ip"] = ip
|
ev["lan_ip"] = ip
|
||||||
ctx.check(ip, "cannot determine the board's LAN address")
|
ctx.check(ip, "cannot determine the board's LAN address")
|
||||||
|
|||||||
@@ -252,6 +252,26 @@ def kill_trail(ctx, t0, seconds=5.0):
|
|||||||
return trail
|
return trail
|
||||||
|
|
||||||
|
|
||||||
|
def kill_fast_trail(t0, seconds=1.5):
|
||||||
|
"""The latch and the kernel state read from sysfs every few
|
||||||
|
milliseconds after a kill: the supervisor's own reaction, which the
|
||||||
|
HTTP samples of kill_trail cannot resolve. A controller death is a
|
||||||
|
signal to the supervisor, not a poll, so the relock lands within
|
||||||
|
milliseconds; the kernel leaves running once its stop ramp is done."""
|
||||||
|
locked_at = stopped_at = None
|
||||||
|
end = t0 + seconds
|
||||||
|
while time.time() < end and (locked_at is None or stopped_at is None):
|
||||||
|
now = time.time() - t0
|
||||||
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
|
if locked_at is None and ilk is not None and ilk & IL_LASER_LATCH:
|
||||||
|
locked_at = round(now, 3)
|
||||||
|
st = hw.sysfs_read("cnc/state")
|
||||||
|
if stopped_at is None and st is not None and st != "running":
|
||||||
|
stopped_at = round(now, 3)
|
||||||
|
time.sleep(0.005)
|
||||||
|
return {"latch_locked_at_s": locked_at, "kernel_stopped_at_s": stopped_at}
|
||||||
|
|
||||||
|
|
||||||
def judge_kill(ctx, trail, what):
|
def judge_kill(ctx, trail, what):
|
||||||
"""(first zero, tail stayed zero, kernel stopped running) from a trail."""
|
"""(first zero, tail stayed zero, kernel stopped running) from a trail."""
|
||||||
for t in trail:
|
for t in trail:
|
||||||
@@ -715,7 +735,9 @@ def disarm_in_hold(ctx):
|
|||||||
"SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, "
|
"SIGTERM, so emission drops within 2.5 s and stays 0, the kernel is not running, "
|
||||||
"and the restart is a separate operator-judged step. Unexpected: mid-burn SIGKILL "
|
"and the restart is a separate operator-judged step. Unexpected: mid-burn SIGKILL "
|
||||||
"of the controller - the supervisor's exit safing must end the fire tail inside "
|
"of the controller - the supervisor's exit safing must end the fire tail inside "
|
||||||
"the ring's in-flight window, leave the latch locked, and respawn the controller.")
|
"the ring's in-flight window, leave the latch locked, and respawn the controller. "
|
||||||
|
"The death is a signal to the supervisor, not a poll: the latch relocks within "
|
||||||
|
"300 ms of the kill and the kernel is out of running within a second.")
|
||||||
def armed_kill(ctx):
|
def armed_kill(ctx):
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
@@ -769,13 +791,20 @@ def armed_kill(ctx):
|
|||||||
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
|
ctx.log("emission live (%s) - SIGKILL controller pid %s NOW", smp["emission"], pid)
|
||||||
t_kill = time.time()
|
t_kill = time.time()
|
||||||
_os.kill(pid, _signal.SIGKILL)
|
_os.kill(pid, _signal.SIGKILL)
|
||||||
|
fast = kill_fast_trail(t_kill)
|
||||||
trail = kill_trail(ctx, t_kill)
|
trail = kill_trail(ctx, t_kill)
|
||||||
|
ctx.log("after the kill (sysfs): %s", fast)
|
||||||
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "kill")
|
zero_at, tail_zero, not_running = judge_kill(ctx, trail, "kill")
|
||||||
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
locked = ilk is not None and bool(ilk & (1 << 3))
|
locked = ilk is not None and bool(ilk & (1 << 3))
|
||||||
ev["sigkill"] = {"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero,
|
ev["sigkill"] = {"pid": pid, "zero_at_s": zero_at, "tail_zero": tail_zero,
|
||||||
"kernel_not_running": not_running, "latch_locked": locked, "trail": trail}
|
"kernel_not_running": not_running, "latch_locked": locked, "trail": trail,
|
||||||
|
"fast": fast}
|
||||||
ctx.log("latch locked after the kill: %s", locked)
|
ctx.log("latch locked after the kill: %s", locked)
|
||||||
|
ctx.check(fast["latch_locked_at_s"] is not None and fast["latch_locked_at_s"] < 0.3,
|
||||||
|
"the latch was not locked within 300 ms of the kill: %s", fast)
|
||||||
|
ctx.check(fast["kernel_stopped_at_s"] is not None and fast["kernel_stopped_at_s"] < 1.0,
|
||||||
|
"the kernel was still running 1 s after the kill: %s", fast)
|
||||||
ctx.check(zero_at is not None and zero_at < 2.5,
|
ctx.check(zero_at is not None and zero_at < 2.5,
|
||||||
"emission did not drop within 2.5 s of the kill (first 0 at %s)", zero_at)
|
"emission did not drop within 2.5 s of the kill (first 0 at %s)", zero_at)
|
||||||
ctx.check(tail_zero, "emission returned after the kill")
|
ctx.check(tail_zero, "emission returned after the kill")
|
||||||
@@ -792,10 +821,187 @@ def armed_kill(ctx):
|
|||||||
ctx.log("/mode after the kill: %s", m1)
|
ctx.log("/mode after the kill: %s", m1)
|
||||||
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
|
ctx.check(m1 and m1.get("controller") == "running" and m1.get("pid") != pid,
|
||||||
"supervisor did not respawn the controller: %s", m1)
|
"supervisor did not respawn the controller: %s", m1)
|
||||||
|
# The respawned controller starts locked and stays locked: a latch it
|
||||||
|
# did not unlock is never relit by a run start, and no arm has asked.
|
||||||
|
ctx.check(wait_grbl_port(ctx), "the respawned controller never accepted a Grbl connection")
|
||||||
|
ctx.sleep(2)
|
||||||
|
ilk = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
|
ev["latch_locked_after_respawn"] = ilk is not None and bool(ilk & IL_LASER_LATCH)
|
||||||
|
ctx.check(ev["latch_locked_after_respawn"],
|
||||||
|
"the respawned controller unlocked the latch with no arm (interlock_circuit=%s)", ilk)
|
||||||
ev["beam_at_fire_kill"] = smp.get("beam")
|
ev["beam_at_fire_kill"] = smp.get("beam")
|
||||||
check_button_dark(ctx, ev)
|
check_button_dark(ctx, ev)
|
||||||
ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller "
|
ctx.log("PASS: expected stop 0 at +%s s and SIGKILL 0 at +%s s, latch locked, controller "
|
||||||
"respawned, button dark", ev["expected"]["zero_at_s"], zero_at)
|
"respawned with the latch still locked, button dark", ev["expected"]["zero_at_s"], zero_at)
|
||||||
|
|
||||||
|
|
||||||
|
@test("laser.verdict-cut", title="A blocked verdict mid-cut locks the latch and holds; the clean "
|
||||||
|
"verdict resumes with no press",
|
||||||
|
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 (the arm). Nothing else: the test "
|
||||||
|
"takes the cooling verdict away itself and gives it back."],
|
||||||
|
description="A 40 mm line at constant power (M3 S400/F300, 8 s). About 1.5 s in, the test "
|
||||||
|
"pauses the machine-services daemon for 3.5 s, so the cooling verdict the "
|
||||||
|
"controller reads goes stale: the controller's pause tier. The controller must "
|
||||||
|
"hold the job (grbl Hold) under the still-open armed window with the stream "
|
||||||
|
"masked dark, so the kernel's LASER_ON sample count reads 0 before the cut "
|
||||||
|
"resumes, and it must not write the latch: a lock sets the hardware button "
|
||||||
|
"latch, which only a press clears, so both bit 3 (the SoC lock) and bit 2 "
|
||||||
|
"(the button latch) of interlock_circuit stay clear through the whole run. "
|
||||||
|
"When the daemon returns (the verdict fresh, clean, resume_ok), the controller "
|
||||||
|
"must resume the cut lit from where it stopped, with no button press and no "
|
||||||
|
"prompt; the M2 program end then disarms as usual. The daemon's own dead-man "
|
||||||
|
"(5 s of silence) is not reached: the controller keeps reporting through the "
|
||||||
|
"pause. The beam detector witnesses the cut on both sides of the hold.")
|
||||||
|
def verdict_cut(ctx):
|
||||||
|
import os as _os
|
||||||
|
import signal as _signal
|
||||||
|
ev = ctx.evidence
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
# The daemon is frozen long enough for the verdict the controller
|
||||||
|
# caches to expire (VERDICT_MAX_AGE 2.0 s), so the driver's pause tier
|
||||||
|
# holds the job - but not so long that the engine's report dead-man
|
||||||
|
# trips on resume. That dead-man locks the laser latch when the
|
||||||
|
# controller's report is older than REPORT_TIMEOUT_S (5.0 s) while
|
||||||
|
# armed, which is exactly the "latch stayed locked" this test refuses.
|
||||||
|
# The last report is up to 1 s old when the freeze starts, so the
|
||||||
|
# freeze must stay under 4.0 s; 3.0 s clears the stale floor with room
|
||||||
|
# to see the hold and stays a full second under the dead-man.
|
||||||
|
PAUSE_S = 3.0
|
||||||
|
pids = hw.pidof("forgectrl")
|
||||||
|
ctx.check(pids, "no forgectrl process found to pause")
|
||||||
|
ev["daemon_pids"] = pids
|
||||||
|
state_path = "/run/forgefirm/grbl.state"
|
||||||
|
|
||||||
|
def grbl_state_file():
|
||||||
|
try:
|
||||||
|
with open(state_path) as f:
|
||||||
|
return f.read()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def resume_daemon():
|
||||||
|
for p in pids:
|
||||||
|
try:
|
||||||
|
_os.kill(p, _signal.SIGCONT)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
trail = []
|
||||||
|
text = ""
|
||||||
|
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||||
|
prepare(ctx, g)
|
||||||
|
k0 = kernel_start(ctx)
|
||||||
|
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 % "40 mm +X")
|
||||||
|
job = ["G91", "G21", "M3", "S400", "G1 X40 F300", "M5", "G0 X-40", "G90", "M2"]
|
||||||
|
smp = arm_and_fire(ctx, g, room="40 mm +X", job=job)
|
||||||
|
beams = [(smp.get("beam"), smp.get("beam_d"))]
|
||||||
|
ctx.log("emission live (%s); pausing the daemon in 1 s", smp["emission"])
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
g.drain()
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
for p in pids:
|
||||||
|
_os.kill(p, _signal.SIGSTOP)
|
||||||
|
ctx.log("daemon paused (SIGSTOP %s) for %.1f s: the verdict goes stale", pids, PAUSE_S)
|
||||||
|
# No HTTP while the daemon is paused: the controller's socket,
|
||||||
|
# sysfs and the controller's state file are the witnesses.
|
||||||
|
while time.time() - t0 < PAUSE_S:
|
||||||
|
st = g.status_report()["state"]
|
||||||
|
text += g.drain()
|
||||||
|
il = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
|
trail.append({"t": round(time.time() - t0, 2), "gstate": st, "il": il,
|
||||||
|
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
|
||||||
|
"armed": '"armed":true' in grbl_state_file()})
|
||||||
|
time.sleep(0.12)
|
||||||
|
finally:
|
||||||
|
resume_daemon()
|
||||||
|
ctx.log("daemon resumed (SIGCONT) at +%.2f s", time.time() - t0)
|
||||||
|
# Through the resume and to the end of the cut, sampling the same
|
||||||
|
# witnesses (the daemon's own once it answers again).
|
||||||
|
resumed_at = None
|
||||||
|
end = time.time() + 30
|
||||||
|
while time.time() < end:
|
||||||
|
ctx.checkpoint()
|
||||||
|
st = g.status_report()["state"]
|
||||||
|
text += g.drain()
|
||||||
|
il = hw.sysfs_int("cnc/interlock_circuit")
|
||||||
|
row = {"t": round(time.time() - t0, 2), "gstate": st, "il": il,
|
||||||
|
"emission": hw.sysfs_int("cnc/laser_on_sampled"),
|
||||||
|
"armed": '"armed":true' in grbl_state_file()}
|
||||||
|
if time.time() - t0 > PAUSE_S + 1.0:
|
||||||
|
s = sample(ctx)
|
||||||
|
if s:
|
||||||
|
row["beam"] = s.get("beam")
|
||||||
|
beams.append((s.get("beam"), s.get("beam_d")))
|
||||||
|
trail.append(row)
|
||||||
|
if resumed_at is None and st.startswith("Run"):
|
||||||
|
resumed_at = time.time() - t0
|
||||||
|
if st.startswith("Idle") and resumed_at is not None and time.time() - t0 > resumed_at + 3.0:
|
||||||
|
break
|
||||||
|
time.sleep(0.12)
|
||||||
|
for r in trail:
|
||||||
|
ctx.log(" %s", r)
|
||||||
|
ev["trail"] = trail
|
||||||
|
ev["messages"] = [ln for ln in text.splitlines() if ln.startswith("[MSG:") or ln.startswith("ALARM")]
|
||||||
|
ctx.log("controller: %s", ev["messages"])
|
||||||
|
dt = wait_disarm(ctx, 75)
|
||||||
|
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
||||||
|
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after the job")
|
||||||
|
check_kernel_returned(ctx, ev, k0)
|
||||||
|
|
||||||
|
def locked(r):
|
||||||
|
return r["il"] is not None and bool(r["il"] & IL_LASER_LATCH)
|
||||||
|
|
||||||
|
def button_latched(r):
|
||||||
|
return r["il"] is not None and bool(r["il"] & IL_BUTTON_LATCH)
|
||||||
|
|
||||||
|
held = [r for r in trail if r["gstate"].startswith("Hold")]
|
||||||
|
ev.update({"held_at_s": held[0]["t"] if held else None,
|
||||||
|
"resumed_at_s": round(resumed_at, 2) if resumed_at else None,
|
||||||
|
"latch_locked_samples": sum(1 for r in trail if locked(r)),
|
||||||
|
"button_latch_set_samples": sum(1 for r in trail if button_latched(r))})
|
||||||
|
ctx.check(held and held[0]["t"] < PAUSE_S, "the stale verdict did not hold the job during the "
|
||||||
|
"pause (first Hold at %s)", ev["held_at_s"])
|
||||||
|
ctx.check("fire masked, job held" in text, "the controller did not report the pause tier")
|
||||||
|
ctx.check(ev["latch_locked_samples"] == 0,
|
||||||
|
"the pause tier wrote the latch (locked in %d samples): a lock sets the hardware "
|
||||||
|
"button latch and the resume would need a press", ev["latch_locked_samples"])
|
||||||
|
ctx.check(ev["button_latch_set_samples"] == 0,
|
||||||
|
"the hardware button latch read SET in %d samples: the resume needed a press",
|
||||||
|
ev["button_latch_set_samples"])
|
||||||
|
ctx.check(all(r["armed"] for r in trail if r["t"] < (resumed_at or PAUSE_S + 5)),
|
||||||
|
"the armed window closed during the pause")
|
||||||
|
ctx.check("press the button" not in text, "the resume asked for a button press")
|
||||||
|
ctx.check(DISARMED_MSG not in text.split("resuming")[0], "the pause tier disarmed the job")
|
||||||
|
# Dark from the hold until the resume: the gate masked the stream.
|
||||||
|
span = [r for r in trail if held and r["t"] >= held[0]["t"]
|
||||||
|
and (resumed_at is None or r["t"] < resumed_at)]
|
||||||
|
zero = next((r for r in span if r["emission"] == 0), None)
|
||||||
|
ev["emission_zero_after_hold_s"] = round(zero["t"] - held[0]["t"], 2) if zero else None
|
||||||
|
ctx.check(zero is not None and zero["t"] - held[0]["t"] <= 2.5,
|
||||||
|
"emission did not read 0 within 2.5 s of the hold (before the resume)")
|
||||||
|
ctx.check(resumed_at is not None, "the clean verdict did not resume the cut")
|
||||||
|
ctx.check("resuming" in text, "the controller did not report the resume")
|
||||||
|
after = [r for r in trail if resumed_at and r["t"] >= resumed_at]
|
||||||
|
ctx.check(any(r["emission"] for r in after), "the resumed cut ran dark (no emission after the resume)")
|
||||||
|
ctx.check("ALARM" not in text, "an alarm was raised on the pause tier")
|
||||||
|
ctx.check(dt is not None and dt < 10.0, "the M2 job did not disarm promptly at Idle (%s s)", dt)
|
||||||
|
beam_witness(ctx, ev, [{"beam": b, "beam_d": d} for b, d in beams], base)
|
||||||
|
judge_beam(ctx, ev["beam"], "the cut")
|
||||||
|
check_button_dark(ctx, ev)
|
||||||
|
ctx.log("PASS: held at +%s s with the latch untouched, emission 0 %s s after the hold, resumed "
|
||||||
|
"lit at +%s s with no press, disarmed %.1f s after Idle", ev["held_at_s"],
|
||||||
|
ev["emission_zero_after_hold_s"], ev["resumed_at_s"], dt)
|
||||||
|
|
||||||
|
|
||||||
|
DISARMED_MSG = "laser disarmed - latch locked"
|
||||||
|
|
||||||
|
|
||||||
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
|
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
|
||||||
|
|||||||
@@ -136,9 +136,68 @@ def clean_slate(ctx, g):
|
|||||||
ctx.log("unlock: %s", g.command("$X"))
|
ctx.log("unlock: %s", g.command("$X"))
|
||||||
st = g.status_report()
|
st = g.status_report()
|
||||||
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
|
ctx.check(st["state"].startswith("Idle"), "controller is %s, expected Idle", st["state"])
|
||||||
|
clear_error(ctx, g)
|
||||||
return st
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def clear_error(ctx, g):
|
||||||
|
"""Clear a latched line error before the test's first move.
|
||||||
|
|
||||||
|
Soft limits are armed after a home (the driver's, since the bed has
|
||||||
|
no switches), so a jog past the bed is refused with error:15 - and
|
||||||
|
grblHAL then answers error:15 to every following G-code line, across
|
||||||
|
a fresh connection, until a blank line acknowledges it ($ commands
|
||||||
|
and the ? report are unaffected, so a sender that queries on connect
|
||||||
|
self-clears; a bare G-code stream does not). A prior test's or the
|
||||||
|
baseline hand-back's rejected jog would otherwise make this test's
|
||||||
|
first move fail with a stale error unrelated to the move. A blank
|
||||||
|
line clears it and returns ok. Harmless and idempotent at Idle."""
|
||||||
|
r = g.command("")
|
||||||
|
ctx.check(r and r[-1] == "ok" and not any(x.startswith("error") for x in r),
|
||||||
|
"the controller answered %s to a blank line at the start - not a clean parser", r)
|
||||||
|
|
||||||
|
|
||||||
|
MOVE_START_S = 2.0 # Run is there within 100 ms; a loaded board gets this long
|
||||||
|
|
||||||
|
|
||||||
|
def start_move(ctx, g, line):
|
||||||
|
"""Start a travel job with `line` and see the controller in Run. The
|
||||||
|
wait is bounded, not a fixed sleep. When Run never comes, the failure
|
||||||
|
names what happened instead: the reply (a refused block answers
|
||||||
|
error), the state the controller sits in (Hold, Door, Alarm, or an
|
||||||
|
Idle that never took the block), every line it said meanwhile, and
|
||||||
|
the machine as forgectrl sees it - the evidence a bare "did not
|
||||||
|
start" leaves out. Recorded in the evidence either way."""
|
||||||
|
reply = g.command(line, timeout=1.0)
|
||||||
|
end = time.time() + MOVE_START_S
|
||||||
|
t0 = time.time()
|
||||||
|
said = ""
|
||||||
|
st = None
|
||||||
|
while time.time() < end:
|
||||||
|
ctx.checkpoint()
|
||||||
|
st = g.status_report()
|
||||||
|
if st["state"].startswith("Run"):
|
||||||
|
break
|
||||||
|
said += g.drain()
|
||||||
|
time.sleep(0.1)
|
||||||
|
rec = {"line": line, "reply": reply, "state": st["state"] if st else None,
|
||||||
|
"after_s": round(time.time() - t0, 2)}
|
||||||
|
ctx.evidence.setdefault("move_start", []).append(rec)
|
||||||
|
if st is not None and st["state"].startswith("Run"):
|
||||||
|
return st
|
||||||
|
msgs = [ln.strip() for ln in said.splitlines() if ln.strip()]
|
||||||
|
rec["said"] = msgs
|
||||||
|
s = ctx.forgectrl.status() or {}
|
||||||
|
gr = ((s.get("grbl") or {}).get("report") or {})
|
||||||
|
rec["forgectrl"] = {"state": s.get("state"), "switches": s.get("switches"),
|
||||||
|
"laser_locked": s.get("laser_locked"), "grbl": gr.get("state"),
|
||||||
|
"alarm": gr.get("alarm"), "sender": (gr.get("sender") or {}).get("connected")}
|
||||||
|
ctx.check(False, "the move did not start: %s answered %s; controller %s %.1f s later, said %s; "
|
||||||
|
"forgectrl sees kernel %s, grbl %s (alarm %s), switches %s, laser locked %s",
|
||||||
|
line, reply, st["state"] if st else "no report", rec["after_s"], msgs or "nothing",
|
||||||
|
s.get("state"), gr.get("state"), gr.get("alarm"), s.get("switches"), s.get("laser_locked"))
|
||||||
|
|
||||||
|
|
||||||
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
|
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
|
||||||
subsystem="motion", kind="auto", mode="grbl", est_min=1,
|
subsystem="motion", kind="auto", mode="grbl", est_min=1,
|
||||||
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
|
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
|
||||||
@@ -753,21 +812,30 @@ def _return_x(ctx, delta_mm):
|
|||||||
machine_idle(ctx)
|
machine_idle(ctx)
|
||||||
|
|
||||||
|
|
||||||
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move",
|
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move, "
|
||||||
subsystem="motion", kind="auto", mode="grbl", est_min=4,
|
"a kill during the web-service homing",
|
||||||
|
subsystem="motion", kind="auto", mode="grbl", est_min=6,
|
||||||
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
|
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
|
||||||
requires=["motion.cancel-abort", "kernel.k1-k2"],
|
requires=["motion.cancel-abort", "kernel.k1-k2"],
|
||||||
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail."],
|
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail. "
|
||||||
|
"With cloud mode enabled the test also homes through the web service and kills the "
|
||||||
|
"controller mid-homing (about a minute); the head ends wherever the homing was."],
|
||||||
description="SIGKILL of the controller mid-move: the supervisor reaps it, safes (cnc/stop, "
|
description="SIGKILL of the controller mid-move: the supervisor reaps it, safes (cnc/stop, "
|
||||||
"latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a "
|
"latch relocked - it never unlocked), and respawns within seconds. SIGSTOP (a "
|
||||||
"hang) mid-move: the ring drains into a kernel underrun (fast halt, latch "
|
"hang) mid-move: the ring drains into a kernel underrun (fast halt, latch "
|
||||||
"locked); the process resumed from the hang recovers on a soft reset and an unlock "
|
"locked); the process resumed from the hang recovers on a soft reset and an unlock "
|
||||||
"(the alarm is critical, so $X alone is refused; the reset acknowledges the "
|
"(the alarm is critical, so $X alone is refused; the reset acknowledges the "
|
||||||
"fault to the stream and clears the stale ring) and moves again without a "
|
"fault to the stream and clears the stale ring) and moves again without a "
|
||||||
"restart. forgectrl "
|
"restart. A short SIGSTOP (100 ms, inside the kernel's 200 ms queue) mid-move: "
|
||||||
"restart mid-move: the busy controller finishes the move unmanaged and the new "
|
"no underrun, but the step producer comes back later than its lead and its "
|
||||||
"daemon retakes supervision at idle. After each drill the head is jogged back "
|
"late events clamp; unarmed that is a warning in the controller's log and the "
|
||||||
"by the kernel-measured distance.")
|
"move completes with every step (the armed case faults, proven on the host). "
|
||||||
|
"forgectrl restart mid-move: the busy controller finishes the move unmanaged "
|
||||||
|
"and the new daemon retakes supervision at idle. After each drill the head is "
|
||||||
|
"jogged back by the kernel-measured distance. SIGKILL during $H (the web-service "
|
||||||
|
"homing, with cloud mode enabled): the homing runner the controller left behind "
|
||||||
|
"is ended before the respawn, the kernel is idle when the new controller starts, "
|
||||||
|
"and the pulse device has one controller on it.")
|
||||||
def deadman(ctx):
|
def deadman(ctx):
|
||||||
import os as _os
|
import os as _os
|
||||||
import signal as _signal
|
import signal as _signal
|
||||||
@@ -897,6 +965,47 @@ def deadman(ctx):
|
|||||||
x1 = _kernel_x_mm(ctx)
|
x1 = _kernel_x_mm(ctx)
|
||||||
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||||
|
|
||||||
|
# ---- 2b. a short hang (inside the kernel queue): late events clamp, a warning, the move completes
|
||||||
|
m2 = wait_running(10)
|
||||||
|
pid2 = m2["pid"]
|
||||||
|
x0 = _kernel_x_mm(ctx)
|
||||||
|
underruns0 = hw.sysfs_int("cnc/underruns", 0)
|
||||||
|
log0 = _log_offset(GRBLHAL_LOG)
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
clean_slate(ctx, g)
|
||||||
|
g.command("G91")
|
||||||
|
g.command("G1X30F300", timeout=0.5) # ~6 s of motion
|
||||||
|
ctx.sleep(1.0)
|
||||||
|
_os.kill(pid2, _signal.SIGSTOP)
|
||||||
|
time.sleep(0.1)
|
||||||
|
_os.kill(pid2, _signal.SIGCONT)
|
||||||
|
ctx.log("SIGSTOP 100 ms sent to controller pid %d mid-move", pid2)
|
||||||
|
peak, states, st = wait_idle(ctx, g, 30)
|
||||||
|
g.command("G90")
|
||||||
|
machine_idle(ctx) # the kernel's tail plays out behind grbl's Idle
|
||||||
|
warned = None
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < 5 and not warned:
|
||||||
|
lines = _log_lines(GRBLHAL_LOG, log0, "late events clamped")
|
||||||
|
warned = lines[-1] if lines else None
|
||||||
|
if not warned:
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
x1 = _kernel_x_mm(ctx)
|
||||||
|
ev["short_stall"] = {"states": states, "underruns": hw.sysfs_int("cnc/underruns", 0) - underruns0,
|
||||||
|
"warning": warned, "kernel_dx_mm": round((x1 - x0), 3) if (x0 is not None and x1 is not None) else None,
|
||||||
|
"state_after": (st or {}).get("state")}
|
||||||
|
ctx.log("short stall: states %s, underruns +%s, warning %r, kernel dx %s mm", states,
|
||||||
|
ev["short_stall"]["underruns"], warned, ev["short_stall"]["kernel_dx_mm"])
|
||||||
|
ctx.check("TIMEOUT" not in states and str((st or {}).get("state", "")).startswith("Idle"),
|
||||||
|
"the move did not complete after the short stall (states %s)", states)
|
||||||
|
ctx.check(ev["short_stall"]["underruns"] == 0, "the short stall drained the ring (underruns +%s)",
|
||||||
|
ev["short_stall"]["underruns"])
|
||||||
|
ctx.check(warned, "the controller did not warn about the clamped events after the short stall")
|
||||||
|
ctx.check(ev["short_stall"]["kernel_dx_mm"] is not None and abs(ev["short_stall"]["kernel_dx_mm"] - 30.0) < 0.3,
|
||||||
|
"the kernel counted %s mm for a 30 mm move after the short stall", ev["short_stall"]["kernel_dx_mm"])
|
||||||
|
ctx.check(latch_locked(), "latch unlocked after the short stall")
|
||||||
|
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||||
|
|
||||||
# ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle
|
# ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle
|
||||||
m2 = wait_running(10)
|
m2 = wait_running(10)
|
||||||
pid2 = m2["pid"]
|
pid2 = m2["pid"]
|
||||||
@@ -934,8 +1043,226 @@ def deadman(ctx):
|
|||||||
x1 = _kernel_x_mm(ctx)
|
x1 = _kernel_x_mm(ctx)
|
||||||
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||||
machine_idle(ctx)
|
machine_idle(ctx)
|
||||||
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, restart retook supervision (pid %s)",
|
|
||||||
respawn_s, halt_s, m3.get("pid"))
|
# ---- 4. SIGKILL during $H: the homing runner is ended, the kernel idle, one writer
|
||||||
|
homing_kill(ctx, wait_running)
|
||||||
|
|
||||||
|
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, short stall warned and completed, "
|
||||||
|
"restart retook supervision (pid %s), the kill during $H ended the runner (%s)",
|
||||||
|
respawn_s, halt_s, m3.get("pid"), ev.get("homing_kill", {}).get("runner_gone_s", "skipped"))
|
||||||
|
|
||||||
|
|
||||||
|
def _pulse_holders():
|
||||||
|
"""The processes holding the pulse device open, by name."""
|
||||||
|
out = []
|
||||||
|
for pid in os.listdir("/proc"):
|
||||||
|
if not pid.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
for fd in os.listdir("/proc/%s/fd" % pid):
|
||||||
|
if os.readlink("/proc/%s/fd/%s" % (pid, fd)) == "/dev/glowforge":
|
||||||
|
with open("/proc/%s/comm" % pid) as f:
|
||||||
|
out.append(f.read().strip())
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def homing_kill(ctx, wait_running):
|
||||||
|
"""SIGKILL the GRBL controller while gfhome (the web-service homing
|
||||||
|
runner, its own process group on the inherited pulse fd) is moving
|
||||||
|
the head. The supervisor must end the runner before it starts another
|
||||||
|
controller, or two processes write the ring. Skipped when cloud mode
|
||||||
|
is not enabled on the machine (the runner needs the service)."""
|
||||||
|
import os as _os
|
||||||
|
import signal as _signal
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
before = fc.settings()
|
||||||
|
if str(before.get("cloud_enabled", "")).lower() not in ("1", "true", "yes", "on"):
|
||||||
|
ctx.log("kill during $H: skipped, cloud mode is not enabled on this machine")
|
||||||
|
return
|
||||||
|
hm = before.get("homing_mode", "")
|
||||||
|
ev["homing_kill"] = {"homing_mode": hm}
|
||||||
|
if hm != "gfcloud":
|
||||||
|
st, body = fc.post("/settings", data={"homing_mode": "gfcloud"})
|
||||||
|
ctx.check(st == 200, "homing_mode=gfcloud -> %s %s", st, body)
|
||||||
|
try:
|
||||||
|
m4 = wait_running(10)
|
||||||
|
ctx.check(m4, "controller not running before the homing drill")
|
||||||
|
pid4 = m4["pid"]
|
||||||
|
off = _log_offset(FORGECTRL_LOG)
|
||||||
|
runner_up = None
|
||||||
|
try:
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
clean_slate(ctx, g)
|
||||||
|
g.send_raw(b"$H\n")
|
||||||
|
runner_up = ctx.wait_for(lambda: bool(hw.pidof("gfhome.py")), 30)
|
||||||
|
ctx.check(runner_up is not None, "the homing runner never started for $H")
|
||||||
|
ctx.sleep(3.0) # into the session: the runner drives the head
|
||||||
|
ctx.log("SIGKILL sent to controller pid %d during $H (runner pid %s)",
|
||||||
|
pid4, hw.pidof("gfhome.py"))
|
||||||
|
_os.kill(pid4, _signal.SIGKILL)
|
||||||
|
except (hw.HwError, OSError) as e:
|
||||||
|
ctx.log("the Grbl connection ended with the kill: %s", e)
|
||||||
|
t_kill = time.time()
|
||||||
|
gone_s = ctx.wait_for(lambda: not hw.pidof("gfhome.py"), 15)
|
||||||
|
ev["homing_kill"]["runner_gone_s"] = gone_s
|
||||||
|
ctx.check(gone_s is not None, "the homing runner outlived its controller")
|
||||||
|
m5 = None
|
||||||
|
while time.time() - t_kill < 60:
|
||||||
|
st, m5 = fc.get("/mode")
|
||||||
|
if isinstance(m5, dict) and m5.get("controller") == "running" and m5.get("pid") != pid4:
|
||||||
|
break
|
||||||
|
ctx.sleep(0.5)
|
||||||
|
kstate = hw.sysfs_read("cnc/state")
|
||||||
|
holders = _pulse_holders()
|
||||||
|
lines = _log_lines(FORGECTRL_LOG, off, "homing runner")
|
||||||
|
halted = _log_lines(FORGECTRL_LOG, off, "halting it")
|
||||||
|
ev["homing_kill"].update({"respawn_s": round(time.time() - t_kill, 1), "mode_after": m5,
|
||||||
|
"kernel_state_at_respawn": kstate, "pulse_holders": holders,
|
||||||
|
"super_lines": [ln[-160:] for ln in lines[-3:]]})
|
||||||
|
ctx.log("kill during $H: runner gone in %s s, respawned as %s, kernel %s, pulse held by %s",
|
||||||
|
gone_s, m5 and m5.get("pid"), kstate, holders)
|
||||||
|
ctx.check(m5 and m5.get("pid") != pid4, "no respawn after the kill during $H: %s", m5)
|
||||||
|
ctx.check(lines, "the supervisor did not log the runner's end")
|
||||||
|
ctx.check(not halted, "the kernel had to be halted for the respawn: %s", halted[-1:])
|
||||||
|
ctx.check(kstate in ("idle", "disabled"), "the kernel was %s when the new controller started", kstate)
|
||||||
|
ctx.check("gfhome.py" not in holders and holders.count("grblHAL_glowfor") <= 1,
|
||||||
|
"more than one controller on the pulse device: %s", holders)
|
||||||
|
ctx.check(fc.wait_idle(15, abort=ctx.aborted), "machine not idle after the drill")
|
||||||
|
finally:
|
||||||
|
if hm != "gfcloud":
|
||||||
|
st, body = (fc.post("/settings", params={"homing_mode": ""}) if not hm
|
||||||
|
else fc.post("/settings", data={"homing_mode": hm}))
|
||||||
|
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=3,
|
||||||
|
covers=_MOTION_COVERS, requires=["motion.jog-roundtrip", "cloud.mode-switch"],
|
||||||
|
steps=["Bed clear, lid closed. The test homes the machine through the web service if it is "
|
||||||
|
"not homed (about a minute), then sends moves the controller must refuse."],
|
||||||
|
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 ($130 by $131 from the home corner). "
|
||||||
|
"Homed, a program move 5 mm past X max or Y max, or past the near edge, raises "
|
||||||
|
"ALARM:2 before any motion (the kernel counters do not move), a jog past the bed "
|
||||||
|
"is refused with error 15, and a move inside the bed runs. The Z envelope is the "
|
||||||
|
"lens window, as before.")
|
||||||
|
def soft_limits(ctx):
|
||||||
|
from .cloud import gfhome_homing
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
with ctx.grbl() as g:
|
||||||
|
clean_slate(ctx, g)
|
||||||
|
if not fc.status().get("homed"):
|
||||||
|
gfhome_homing(ctx, ev, g)
|
||||||
|
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}
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
g.realtime(0x18)
|
||||||
|
ctx.sleep(1.5)
|
||||||
|
g.drain()
|
||||||
|
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_travel + 5)),
|
||||||
|
refused("G90 G1 Y%.1f F600" % (y_travel + 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_travel + 5))
|
||||||
|
ev["jog"] = jog
|
||||||
|
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"])
|
||||||
|
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 / button (the factory's)
|
||||||
@@ -1066,9 +1393,7 @@ def button_hold_resume(ctx):
|
|||||||
"moves; the job holds and the test sees it.")
|
"moves; the job holds and the test sees it.")
|
||||||
g.command("M5")
|
g.command("M5")
|
||||||
g.command("G91")
|
g.command("G91")
|
||||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain() # the message window opens here
|
g.drain() # the message window opens here
|
||||||
w = Watch(g)
|
w = Watch(g)
|
||||||
ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
|
ctx.act("button", "press", until=w.in_state("Hold"), timeout=12, fail=False)
|
||||||
@@ -1144,9 +1469,7 @@ def lid_cancel_home(ctx):
|
|||||||
"leave it open until the head has come back on its own.")
|
"leave it open until the head has come back on its own.")
|
||||||
g.command("M5")
|
g.command("M5")
|
||||||
g.command("G91")
|
g.command("G91")
|
||||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain() # the message window opens here
|
g.drain() # the message window opens here
|
||||||
ctx.act("lid", "open", text="Leave it open until the head has come back.", timeout=12)
|
ctx.act("lid", "open", text="Leave it open until the head has come back.", timeout=12)
|
||||||
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "lid opened", "running")
|
drift = expect_cancel_and_return(ctx, g, ev, start, k0, "lid opened", "running")
|
||||||
@@ -1239,9 +1562,7 @@ def interlock_cancel_home(ctx):
|
|||||||
"moves and leave it open until the head has come back on its own.")
|
"moves and leave it open until the head has come back on its own.")
|
||||||
g.command("M5")
|
g.command("M5")
|
||||||
g.command("G91")
|
g.command("G91")
|
||||||
g.command("G1X60F300", timeout=0.5) # a 12 s move
|
start_move(ctx, g, "G1X60F300") # a 12 s move
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain()
|
g.drain()
|
||||||
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
|
ctx.act("interlock", "open", text="Leave it open until the head has come back.", timeout=16)
|
||||||
sw = (ctx.forgectrl.status().get("switches") or {})
|
sw = (ctx.forgectrl.status().get("switches") or {})
|
||||||
@@ -1303,9 +1624,7 @@ def lid_policy_hold(ctx):
|
|||||||
"the job parks in the door state and waits.")
|
"the job parks in the door state and waits.")
|
||||||
g.command("M5")
|
g.command("M5")
|
||||||
g.command("G91")
|
g.command("G91")
|
||||||
g.command("G1X40F300", timeout=0.5) # an 8 s move
|
start_move(ctx, g, "G1X40F300") # an 8 s move
|
||||||
ctx.sleep(0.5)
|
|
||||||
ctx.check(g.status_report()["state"].startswith("Run"), "the move did not start")
|
|
||||||
g.drain()
|
g.drain()
|
||||||
ctx.act("lid", "open", timeout=12)
|
ctx.act("lid", "open", timeout=12)
|
||||||
st, text = wait_state_text(ctx, g, "Door", 8)
|
st, text = wait_state_text(ctx, g, "Door", 8)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""commission.* - the first-run commissioning: the record and the
|
"""setup.* - the first-run setup: the record and the
|
||||||
controller gate, the advisories, the account and its login, the HTTPS
|
controller gate, the advisories, the account and its login, the HTTPS
|
||||||
boundary, SSH, the cloud switch, the machine's name, and the factory
|
boundary, SSH, the cloud switch, the machine's name, and the factory
|
||||||
return.
|
return.
|
||||||
|
|
||||||
The daemon reads the commissioning record (commissioning.json in the
|
The daemon reads the setup record (setup.json in the
|
||||||
data directory) and the account record (users) once, at its start, and
|
data directory) and the account record (users) once, at its start, and
|
||||||
keeps them in memory; only its own routes write them. A test that needs
|
keeps them in memory; only its own routes write them. A test that needs
|
||||||
a different record installs the file under a forgectrl restart (a
|
a different record installs the file under a forgectrl restart (a
|
||||||
@@ -34,13 +34,13 @@ from ..catalog import test
|
|||||||
from .. import hw
|
from .. import hw
|
||||||
from .forgectrl import lan_ip
|
from .forgectrl import lan_ip
|
||||||
|
|
||||||
RECORD = "commissioning.json"
|
RECORD = "setup.json"
|
||||||
USERS = "users"
|
USERS = "users"
|
||||||
OVERRIDE = "commissioning-override"
|
OVERRIDE = "setup-override"
|
||||||
SSH_FLAG = "ssh-enabled"
|
SSH_FLAG = "ssh-enabled"
|
||||||
TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests)
|
TLS_BASE = "https://127.0.0.1" # FORGECTRL_TLS_URL overrides (host tests)
|
||||||
|
|
||||||
# The image's required wizards, all at version 1 (commission.c).
|
# The image's required wizards, all at version 1 (setup.c).
|
||||||
REQUIRED_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
|
REQUIRED_WIZARDS = ("advisories", "account", "preferences", "machine", "cloud")
|
||||||
ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service")
|
ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service")
|
||||||
SAFETY_PHRASE = "I UNDERSTAND"
|
SAFETY_PHRASE = "I UNDERSTAND"
|
||||||
@@ -176,8 +176,8 @@ def record_without_wizards(record):
|
|||||||
|
|
||||||
|
|
||||||
def complete_record(status, base=None, account_name="bench"):
|
def complete_record(status, base=None, account_name="bench"):
|
||||||
"""A complete commissioning record for a machine that must count as
|
"""A complete setup record for a machine that must count as
|
||||||
commissioned: every advisory at the hash GET /wiz reports, the
|
set up: every advisory at the hash GET /wiz reports, the
|
||||||
press recorded, an account (the one of `base` when it has one),
|
press recorded, an account (the one of `base` when it has one),
|
||||||
every wizard of the catalog at its version, and the run complete.
|
every wizard of the catalog at its version, and the run complete.
|
||||||
`status` is a GET /wiz body; `base` is an existing record to keep
|
`status` is a GET /wiz body; `base` is an existing record to keep
|
||||||
@@ -352,12 +352,12 @@ def gate_is_open():
|
|||||||
|
|
||||||
# -------------------------------------------------------------- the gate
|
# -------------------------------------------------------------- the gate
|
||||||
|
|
||||||
_GATE_COVERS = [("forgectrl", "src/commission.*"), ("forgectrl", "src/super.c"),
|
_GATE_COVERS = [("forgectrl", "src/setup.*"), ("forgectrl", "src/super.c"),
|
||||||
("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")]
|
("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")]
|
||||||
|
|
||||||
|
|
||||||
@test("commission.gate-blocks-controllers", title="The commissioning gate spawns no controller",
|
@test("setup.gate-blocks-controllers", title="The setup gate spawns no controller",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", mode="grbl", est_min=5,
|
subsystem="setup", kind="auto", hardware="takeover", mode="grbl", est_min=5,
|
||||||
covers=_GATE_COVERS, requires=["forgectrl.auth"], precheck=gate_is_open,
|
covers=_GATE_COVERS, requires=["forgectrl.auth"], precheck=gate_is_open,
|
||||||
description="With the override removed and a record that lacks every required wizard "
|
description="With the override removed and a record that lacks every required wizard "
|
||||||
"(installed under a forgectrl restart, the consent and the account kept), "
|
"(installed under a forgectrl restart, the consent and the account kept), "
|
||||||
@@ -374,11 +374,11 @@ def gate_blocks_controllers(ctx):
|
|||||||
ctx.log("before: gate %s, override %s, required %s", before.get("gate"), before.get("override"),
|
ctx.log("before: gate %s, override %s, required %s", before.get("gate"), before.get("override"),
|
||||||
before.get("required"))
|
before.get("required"))
|
||||||
raw = read_file(record_path())
|
raw = read_file(record_path())
|
||||||
ctx.check(raw, "no commissioning record at %s", record_path())
|
ctx.check(raw, "no setup record at %s", record_path())
|
||||||
try:
|
try:
|
||||||
record = json.loads(raw.decode("utf-8"))
|
record = json.loads(raw.decode("utf-8"))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
ctx.fail("the commissioning record is not JSON: %s", e)
|
ctx.fail("the setup record is not JSON: %s", e)
|
||||||
lacking = json.dumps(record_without_wizards(record), indent=1, sort_keys=True).encode() + b"\n"
|
lacking = json.dumps(record_without_wizards(record), indent=1, sort_keys=True).encode() + b"\n"
|
||||||
had_override = override_present()
|
had_override = override_present()
|
||||||
ev["override_before"] = had_override
|
ev["override_before"] = had_override
|
||||||
@@ -391,7 +391,7 @@ def gate_blocks_controllers(ctx):
|
|||||||
ctx.log("/mode after the restart: %s (after %s s)", m, took)
|
ctx.log("/mode after the restart: %s (after %s s)", m, took)
|
||||||
ctx.check(m.get("controller") == "gated" and m.get("gated") is True,
|
ctx.check(m.get("controller") == "gated" and m.get("gated") is True,
|
||||||
"the supervisor did not report the gate: %s", m)
|
"the supervisor did not report the gate: %s", m)
|
||||||
ctx.check("commissioning required" in (m.get("why") or ""),
|
ctx.check("setup required" in (m.get("why") or ""),
|
||||||
"the reason does not name the missing wizards: %r", m.get("why"))
|
"the reason does not name the missing wizards: %r", m.get("why"))
|
||||||
w = wiz(fc)
|
w = wiz(fc)
|
||||||
ev["gated_wiz"] = wiz_summary(w)
|
ev["gated_wiz"] = wiz_summary(w)
|
||||||
@@ -439,11 +439,11 @@ def gate_blocks_controllers(ctx):
|
|||||||
ctx.log("restored: gate %s, controller %s", after.get("gate"), m.get("controller"))
|
ctx.log("restored: gate %s, controller %s", after.get("gate"), m.get("controller"))
|
||||||
|
|
||||||
|
|
||||||
@test("commission.override-until-reboot", title="The override stands until the next reboot",
|
@test("setup.override-until-reboot", title="The override stands until the next reboot",
|
||||||
subsystem="commission", kind="auto", est_min=1,
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
covers=_GATE_COVERS, requires=["forgectrl.auth"],
|
covers=_GATE_COVERS, requires=["forgectrl.auth"],
|
||||||
description="The bench seed: the dev image's forgetest init script writes "
|
description="The bench seed: the dev image's forgetest init script writes "
|
||||||
"/run/forgefirm/commissioning-override at boot. With it present, GET /wiz "
|
"/run/forgefirm/setup-override at boot. With it present, GET /wiz "
|
||||||
"reports the override and an open gate, the reason names the wizards it lifts "
|
"reports the override and an open gate, the reason names the wizards it lifts "
|
||||||
"when any is required, and GET /mode reports the controller running and not "
|
"when any is required, and GET /mode reports the controller running and not "
|
||||||
"gated. The file lives on tmpfs (/proc/mounts), so a reboot removes it by "
|
"gated. The file lives on tmpfs (/proc/mounts), so a reboot removes it by "
|
||||||
@@ -486,9 +486,9 @@ def override_until_reboot(ctx):
|
|||||||
|
|
||||||
# ------------------------------------------------------------ advisories
|
# ------------------------------------------------------------ advisories
|
||||||
|
|
||||||
@test("commission.advisories-rehash", title="An advisory is accepted at its current hash only",
|
@test("setup.advisories-rehash", title="An advisory is accepted at its current hash only",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||||
covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/commission.*"),
|
covers=[("forgectrl", "src/advisories.*"), ("forgectrl", "src/setup.*"),
|
||||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/sha256.*"), ("forgectrl", "src/main.c"),
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/sha256.*"), ("forgectrl", "src/main.c"),
|
||||||
("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake")],
|
("forgectrl", "src/ui/md.js"), ("forgectrl", "src/ui/embed_docs.cmake")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
@@ -504,7 +504,7 @@ def advisories_rehash(ctx):
|
|||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
raw = read_file(record_path())
|
raw = read_file(record_path())
|
||||||
ctx.check(raw, "no commissioning record at %s", record_path())
|
ctx.check(raw, "no setup record at %s", record_path())
|
||||||
before = wiz(fc)
|
before = wiz(fc)
|
||||||
ev["before"] = wiz_summary(before)
|
ev["before"] = wiz_summary(before)
|
||||||
docs = {d.get("id"): d for d in before.get("documents") or []}
|
docs = {d.get("id"): d for d in before.get("documents") or []}
|
||||||
@@ -579,7 +579,7 @@ def advisories_rehash(ctx):
|
|||||||
|
|
||||||
# -------------------------------------------------------------- the login
|
# -------------------------------------------------------------- the login
|
||||||
|
|
||||||
@test("commission.account-login", title="The panel login over HTTPS", subsystem="commission",
|
@test("setup.account-login", title="The panel login over HTTPS", subsystem="setup",
|
||||||
kind="auto", est_min=2,
|
kind="auto", est_min=2,
|
||||||
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/session.*"), ("forgectrl", "src/users.*"),
|
||||||
("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"), ("forgectrl", "src/main.c"),
|
||||||
@@ -702,7 +702,7 @@ def login_checks(ctx, name, pw):
|
|||||||
|
|
||||||
# ------------------------------------------------------- HTTPS boundary
|
# ------------------------------------------------------- HTTPS boundary
|
||||||
|
|
||||||
@test("commission.https-only-writes", title="Writes from the LAN go to HTTPS", subsystem="commission",
|
@test("setup.https-only-writes", title="Writes from the LAN go to HTTPS", subsystem="setup",
|
||||||
kind="auto", est_min=1,
|
kind="auto", est_min=1,
|
||||||
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"),
|
covers=[("forgectrl", "src/auth.*"), ("forgectrl", "src/tls.*"), ("forgectrl", "src/peer.*"),
|
||||||
("forgectrl", "src/main.c")],
|
("forgectrl", "src/main.c")],
|
||||||
@@ -802,8 +802,8 @@ def restore_ssh(fc, before):
|
|||||||
write_file(flag, b"" if before.get("enabled") else None)
|
write_file(flag, b"" if before.get("enabled") else None)
|
||||||
|
|
||||||
|
|
||||||
@test("commission.ssh-until-reboot", title="SSH is switched from the panel until the next reboot",
|
@test("setup.ssh-until-reboot", title="SSH is switched from the panel until the next reboot",
|
||||||
subsystem="commission", kind="auto", est_min=1,
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")],
|
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/paths.h")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
description="GET /system/ssh reports enabled, running, and dev_image, and agrees with the "
|
description="GET /system/ssh reports enabled, running, and dev_image, and agrees with the "
|
||||||
@@ -888,8 +888,8 @@ def ssh_until_reboot(ctx):
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- cloud
|
# ---------------------------------------------------------------- cloud
|
||||||
|
|
||||||
@test("commission.cloud-disabled-surface", title="Nothing points at the cloud while it is off",
|
@test("setup.cloud-disabled-surface", title="Nothing points at the cloud while it is off",
|
||||||
subsystem="commission", kind="auto", mode="grbl", est_min=1,
|
subsystem="setup", kind="auto", mode="grbl", est_min=1,
|
||||||
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/settings.*"),
|
covers=[("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/settings.*"),
|
||||||
("forgectrl", "src/wiz.c"), ("forgectrl", "src/hooks.h")],
|
("forgectrl", "src/wiz.c"), ("forgectrl", "src/hooks.h")],
|
||||||
requires=["forgectrl.settings-bounds"],
|
requires=["forgectrl.settings-bounds"],
|
||||||
@@ -971,8 +971,8 @@ def cloud_disabled_surface(ctx):
|
|||||||
|
|
||||||
# ---------------------------------------------------------- the operator
|
# ---------------------------------------------------------- the operator
|
||||||
|
|
||||||
@test("commission.factory-return", title="The return to the factory firmware is guarded",
|
@test("setup.factory-return", title="The return to the factory firmware is guarded",
|
||||||
subsystem="commission", kind="auto", est_min=1,
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.js")],
|
covers=[("forgectrl", "src/update.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.js")],
|
||||||
requires=["update.slots-and-signature"],
|
requires=["update.slots-and-signature"],
|
||||||
description="The return itself reboots the machine as a Glowforge, so it never runs from the "
|
description="The return itself reboots the machine as a Glowforge, so it never runs from the "
|
||||||
@@ -1034,8 +1034,8 @@ def cmdlines():
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@test("commission.machine-name", title="The machine names itself from its MAC address",
|
@test("setup.machine-name", title="The machine names itself from its MAC address",
|
||||||
subsystem="commission", kind="auto", est_min=1,
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex "
|
description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex "
|
||||||
"digits of its WiFi MAC address, so two machines on one network answer to "
|
"digits of its WiFi MAC address, so two machines on one network answer to "
|
||||||
@@ -1213,12 +1213,12 @@ def finished_first_run(ctx):
|
|||||||
"the controller did not come back after the setup: %s", m)
|
"the controller did not come back after the setup: %s", m)
|
||||||
|
|
||||||
|
|
||||||
_FIRST_RUN_REQUIRES = ["commission.gate-blocks-controllers", "commission.advisories-rehash"]
|
_FIRST_RUN_REQUIRES = ["setup.gate-blocks-controllers", "setup.advisories-rehash"]
|
||||||
|
|
||||||
|
|
||||||
@test("commission.first-run-flow", title="The first run, driven as the page drives it", subsystem="commission",
|
@test("setup.first-run-flow", title="The first run, driven as the page drives it", subsystem="setup",
|
||||||
kind="operator", hardware="takeover", est_min=3,
|
kind="operator", hardware="takeover", est_min=3,
|
||||||
covers=[("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"),
|
covers=[("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
|
||||||
("forgectrl", "src/session.*"), ("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"),
|
("forgectrl", "src/session.*"), ("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"),
|
||||||
("forgectrl", "src/sheetid.*"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/hooks.h"),
|
("forgectrl", "src/sheetid.*"), ("forgectrl", "src/advisories.*"), ("forgectrl", "src/hooks.h"),
|
||||||
("forgectrl", "src/main.c")],
|
("forgectrl", "src/main.c")],
|
||||||
@@ -1309,11 +1309,11 @@ def first_run_flow(ctx):
|
|||||||
ctx.log("the run completed: gate open, the controller back")
|
ctx.log("the run completed: gate open, the controller back")
|
||||||
|
|
||||||
|
|
||||||
@test("commission.first-run-page", title="The first-run page, walked once", subsystem="commission",
|
@test("setup.first-run-page", title="The first-run page, walked once", subsystem="setup",
|
||||||
kind="operator", hardware="takeover", est_min=6,
|
kind="operator", hardware="takeover", est_min=6,
|
||||||
covers=[("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/forms.js"), ("forgectrl", "src/ui/md.js"),
|
covers=[("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/forms.js"), ("forgectrl", "src/ui/md.js"),
|
||||||
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/embed_docs.cmake"), ("forgectrl", "src/tls.*")],
|
("forgectrl", "src/ui/login.*"), ("forgectrl", "src/ui/embed_docs.cmake"), ("forgectrl", "src/tls.*")],
|
||||||
requires=_FIRST_RUN_REQUIRES + ["commission.first-run-flow"],
|
requires=_FIRST_RUN_REQUIRES + ["setup.first-run-flow"],
|
||||||
actions=["button"], hands=["workstation"],
|
actions=["button"], hands=["workstation"],
|
||||||
steps=["From the workstation open https://<address>/ and accept the self-signed certificate.",
|
steps=["From the workstation open https://<address>/ and accept the self-signed certificate.",
|
||||||
"Click through the setup: scroll each document to its end and accept it (type I "
|
"Click through the setup: scroll each document to its end and accept it (type I "
|
||||||
@@ -1328,7 +1328,7 @@ def first_run_flow(ctx):
|
|||||||
"clicks through (the documents accepted, the press step with the LEDs "
|
"clicks through (the documents accepted, the press step with the LEDs "
|
||||||
"breathing teal, the account created, the run complete, the gate open, the "
|
"breathing teal, the account created, the run complete, the gate open, the "
|
||||||
"controller back). The operator confirms the panel opened. Everything the run "
|
"controller back). The operator confirms the panel opened. Everything the run "
|
||||||
"wrote is restored as commission.first-run-flow restores it.")
|
"wrote is restored as setup.first-run-flow restores it.")
|
||||||
def first_run_page(ctx):
|
def first_run_page(ctx):
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
@@ -1364,8 +1364,8 @@ def first_run_page(ctx):
|
|||||||
ctx.confirm("Did the control panel open after Finish?")
|
ctx.confirm("Did the control panel open after Finish?")
|
||||||
|
|
||||||
|
|
||||||
@test("commission.cert-page", title="The certificate is checkable before the warning",
|
@test("setup.cert-page", title="The certificate is checkable before the warning",
|
||||||
subsystem="commission", kind="auto", est_min=1,
|
subsystem="setup", kind="auto", est_min=1,
|
||||||
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c"),
|
covers=[("forgectrl", "src/tls.*"), ("forgectrl", "src/main.c"),
|
||||||
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.js")],
|
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.js")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
@@ -1423,9 +1423,9 @@ def flag_of(w, level, wid):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@test("commission.what-changed", title="A replaced part asks for its checks again",
|
@test("setup.what-changed", title="A replaced part asks for its checks again",
|
||||||
subsystem="commission", kind="auto", est_min=2, hardware="takeover",
|
subsystem="setup", kind="auto", est_min=2, hardware="takeover",
|
||||||
covers=[("forgectrl", "src/commission.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"),
|
covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/wiz.*"), ("forgectrl", "src/main.c"),
|
||||||
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html"),
|
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html"),
|
||||||
("forgectrl", "src/ui/help.js")],
|
("forgectrl", "src/ui/help.js")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
@@ -1493,9 +1493,9 @@ def what_changed(ctx):
|
|||||||
ctx.check(bool(mode(fc).get("gated")) == gated_before, "the gate does not read as before")
|
ctx.check(bool(mode(fc).get("gated")) == gated_before, "the gate does not read as before")
|
||||||
|
|
||||||
|
|
||||||
@test("commission.record-export", title="The record: JSON, the printable page, and the log bundle",
|
@test("setup.record-export", title="The record: JSON, the printable page, and the log bundle",
|
||||||
subsystem="commission", kind="auto", est_min=2,
|
subsystem="setup", kind="auto", est_min=2,
|
||||||
covers=[("forgectrl", "src/commission.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"),
|
covers=[("forgectrl", "src/setup.*"), ("forgectrl", "src/recordhtml.*"), ("forgectrl", "src/wiz.*"),
|
||||||
("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
|
("forgectrl", "src/logs.*"), ("forgectrl", "src/main.c"), ("forgectrl", "src/ui/index.html"),
|
||||||
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/wizard.html")],
|
("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/wizard.html")],
|
||||||
requires=["forgectrl.auth", "logs.tree-tail-export"],
|
requires=["forgectrl.auth", "logs.tree-tail-export"],
|
||||||
@@ -1504,7 +1504,7 @@ def what_changed(ctx):
|
|||||||
"named after the sheet id; GET /wiz/record.html is a page with no script that "
|
"named after the sheet id; GET /wiz/record.html is a page with no script that "
|
||||||
"carries the sheet id and every completed wizard; without the token and without "
|
"carries the sheet id and every completed wizard; without the token and without "
|
||||||
"a login both are refused (403); and the sanitized log export carries "
|
"a login both are refused (403); and the sanitized log export carries "
|
||||||
"system/commissioning.json, parseable, with the same sheet id and no panel token.")
|
"system/setup.json, parseable, with the same sheet id and no panel token.")
|
||||||
def record_export(ctx):
|
def record_export(ctx):
|
||||||
import gzip
|
import gzip
|
||||||
import io
|
import io
|
||||||
@@ -1544,7 +1544,7 @@ def record_export(ctx):
|
|||||||
ctx.check(st == 200 and "text/html" in (hdrs.get("content-type") or ""), "GET /wiz/record.html -> %s %s",
|
ctx.check(st == 200 and "text/html" in (hdrs.get("content-type") or ""), "GET /wiz/record.html -> %s %s",
|
||||||
st, hdrs.get("content-type"))
|
st, hdrs.get("content-type"))
|
||||||
ctx.check("<script" not in page.lower(), "the printable page carries a script")
|
ctx.check("<script" not in page.lower(), "the printable page carries a script")
|
||||||
ctx.check(sid in page and "commissioning record" in page, "the page lacks the sheet id or its title")
|
ctx.check(sid in page and "setup record" in page, "the page lacks the sheet id or its title")
|
||||||
titles = {z["id"]: z["title"] for z in w.get("wizards") or []}
|
titles = {z["id"]: z["title"] for z in w.get("wizards") or []}
|
||||||
missing = [d for d in done if titles.get(d) and ("<h3>" + titles[d]) not in page]
|
missing = [d for d in done if titles.get(d) and ("<h3>" + titles[d]) not in page]
|
||||||
ev["missing_on_page"] = missing
|
ev["missing_on_page"] = missing
|
||||||
@@ -1565,11 +1565,11 @@ def record_export(ctx):
|
|||||||
try:
|
try:
|
||||||
tf = tarfile.open(fileobj=io.BytesIO(gzip.decompress(data)))
|
tf = tarfile.open(fileobj=io.BytesIO(gzip.decompress(data)))
|
||||||
for m in tf.getmembers():
|
for m in tf.getmembers():
|
||||||
if m.isfile() and m.name.endswith("system/commissioning.json"):
|
if m.isfile() and m.name.endswith("system/setup.json"):
|
||||||
member = tf.extractfile(m).read()
|
member = tf.extractfile(m).read()
|
||||||
except (OSError, tarfile.TarError, EOFError) as e:
|
except (OSError, tarfile.TarError, EOFError) as e:
|
||||||
ctx.fail("export is not a readable tar.gz: %s", e)
|
ctx.fail("export is not a readable tar.gz: %s", e)
|
||||||
ctx.check(member is not None, "the bundle lacks system/commissioning.json")
|
ctx.check(member is not None, "the bundle lacks system/setup.json")
|
||||||
if member is not None:
|
if member is not None:
|
||||||
try:
|
try:
|
||||||
inside = json.loads(member.decode("utf-8"))
|
inside = json.loads(member.decode("utf-8"))
|
||||||
@@ -1587,12 +1587,12 @@ def record_export(ctx):
|
|||||||
ctx.log("record %d wizards; page %d bytes; bundle carries the record", len(done), ev["page_bytes"])
|
ctx.log("record %d wizards; page %d bytes; bundle carries the record", len(done), ev["page_bytes"])
|
||||||
|
|
||||||
|
|
||||||
@test("commission.mirror", title="One browser drives a check; a second one follows",
|
@test("setup.mirror", title="One browser drives a check; a second one follows",
|
||||||
subsystem="commission", kind="auto", est_min=2, hardware="takeover",
|
subsystem="setup", kind="auto", est_min=2, hardware="takeover",
|
||||||
covers=[("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), ("forgectrl", "src/wiz.*"),
|
covers=[("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"), ("forgectrl", "src/wiz.*"),
|
||||||
("forgectrl", "src/session.*"), ("forgectrl", "src/ui/wizard.js"),
|
("forgectrl", "src/session.*"), ("forgectrl", "src/ui/wizard.js"),
|
||||||
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.css")],
|
("forgectrl", "src/ui/wizard.html"), ("forgectrl", "src/ui/wizard.css")],
|
||||||
requires=["forgectrl.auth", "commission.account-login", "commission.check-sensors"],
|
requires=["forgectrl.auth", "setup.account-login", "setup.check-sensors"],
|
||||||
description="With a temporary account (made and removed as the login test does) and two "
|
description="With a temporary account (made and removed as the login test does) and two "
|
||||||
"login sessions: the first session starts the sensors check; GET /wiz/dark "
|
"login sessions: the first session starts the sensors check; GET /wiz/dark "
|
||||||
"reports the run as owned and its own to the first session, not to the second, "
|
"reports the run as owned and its own to the first session, not to the second, "
|
||||||
+34
-24
@@ -1,4 +1,4 @@
|
|||||||
"""commission.check-* - the setup's checks (the dark wizards), driven the
|
"""setup.check-* - the setup's checks (the dark wizards), driven the
|
||||||
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
|
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
|
||||||
the prompts answered from here (the bench fixture works the lid and the
|
the prompts answered from here (the bench fixture works the lid and the
|
||||||
button; a confirmation is answered yes once the snapshot exists), the
|
button; a confirmation is answered yes once the snapshot exists), the
|
||||||
@@ -12,7 +12,7 @@ import time
|
|||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
from .. import hw
|
from .. import hw
|
||||||
from ..baseline import read_position
|
from ..baseline import read_position
|
||||||
from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
|
from .setup import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
|
||||||
|
|
||||||
POLL_S = 1.0
|
POLL_S = 1.0
|
||||||
# What the machine's own press prompt says, used when it sends no text.
|
# What the machine's own press prompt says, used when it sends no text.
|
||||||
@@ -22,7 +22,7 @@ PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your
|
|||||||
# (74) for the first seconds and settles near 630.
|
# (74) for the first seconds and settles near 630.
|
||||||
PURGE_SPINUP_S = 30
|
PURGE_SPINUP_S = 30
|
||||||
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
|
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
|
||||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
||||||
|
|
||||||
|
|
||||||
@@ -113,8 +113,8 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
|||||||
return last
|
return last
|
||||||
|
|
||||||
|
|
||||||
@test("commission.check-switches", title="The switches check follows the lid and the button",
|
@test("setup.check-switches", title="The switches check follows the lid and the button",
|
||||||
subsystem="commission", kind="operator", est_min=3,
|
subsystem="setup", kind="operator", est_min=3,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
||||||
requires=["forgectrl.auth"], actions=["lid", "button"],
|
requires=["forgectrl.auth"], actions=["lid", "button"],
|
||||||
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
||||||
@@ -148,30 +148,40 @@ def check_switches(ctx):
|
|||||||
ctx.log("switches: %s", json.dumps(r))
|
ctx.log("switches: %s", json.dumps(r))
|
||||||
|
|
||||||
|
|
||||||
@test("commission.check-sensors", title="The sensors check reads a plausible machine at rest",
|
@test("setup.check-sensors", title="The sensors check reads a plausible machine at rest",
|
||||||
subsystem="commission", kind="auto", est_min=2,
|
subsystem="setup", kind="auto", est_min=2,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
|
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
|
||||||
("forgectrl", "src/cool.c")],
|
("forgectrl", "src/cool.c")],
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
description="POST /wiz/sensors/start; ten seconds of readings, then the room-temperature "
|
description="POST /wiz/sensors/start; ten seconds of readings and no question asked. The "
|
||||||
"prompt, answered Skip so no offset is written. The result carries both coolant "
|
"result carries both coolant temperatures, the chassis and SoC, the lid IR "
|
||||||
"temperatures, the chassis and SoC, the lid IR maxima, the accelerometer event "
|
"maxima, the accelerometer event count, the supply power-good, the HV current, "
|
||||||
"count, the supply power-good, the HV current, and the idle fan speeds; the "
|
"and the idle fan speeds; the record carries sensors at version 1 and the "
|
||||||
"record carries sensors at version 1 and cool_temp_offset_c reads as before.")
|
"settings read as before (the check writes none).")
|
||||||
def check_sensors(ctx):
|
def check_sensors(ctx):
|
||||||
with Restore(ctx, ["cool_temp_offset_c"]):
|
fc = ctx.forgectrl
|
||||||
last = run_check(ctx, "sensors", lambda p: "Skip" if p.get("id") == "room-temp" else None, 120)
|
ev = ctx.evidence
|
||||||
|
before = fc.settings() or {}
|
||||||
|
ev["settings_before"] = dict(before)
|
||||||
|
|
||||||
|
def on_prompt(p):
|
||||||
|
ctx.fail("the sensors check asked a question: %s", p)
|
||||||
|
last = run_check(ctx, "sensors", on_prompt, 120)
|
||||||
r = last.get("result") or {}
|
r = last.get("result") or {}
|
||||||
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
||||||
"exhaust_rpm_idle", "intake_rpm_idle"):
|
"exhaust_rpm_idle", "intake_rpm_idle"):
|
||||||
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
||||||
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
|
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
|
||||||
|
after = fc.settings() or {}
|
||||||
|
changed = {k: (before.get(k), after.get(k)) for k in set(before) | set(after)
|
||||||
|
if before.get(k) != after.get(k)}
|
||||||
|
ctx.check(not changed, "the sensors check wrote a setting: %s", changed)
|
||||||
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
|
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
|
||||||
r.get("lid_ir_max"), r.get("hv_current_max"))
|
r.get("lid_ir_max"), r.get("hv_current_max"))
|
||||||
|
|
||||||
|
|
||||||
@test("commission.check-airflow", title="The airflow check measures the fans and sets the floors",
|
@test("setup.check-airflow", title="The airflow check measures the fans and sets the floors",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=3,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
||||||
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
||||||
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
||||||
@@ -229,8 +239,8 @@ def check_airflow(ctx):
|
|||||||
ctx.check(ok is not None, "the controller did not come back after the check")
|
ctx.check(ok is not None, "the controller did not come back after the check")
|
||||||
|
|
||||||
|
|
||||||
@test("commission.check-cameras", title="The cameras check captures both cameras",
|
@test("setup.check-cameras", title="The cameras check captures both cameras",
|
||||||
subsystem="commission", kind="operator", est_min=2,
|
subsystem="setup", kind="operator", est_min=2,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
|
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
|
||||||
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
|
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
|
||||||
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
|
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
|
||||||
@@ -264,8 +274,8 @@ def check_cameras(ctx):
|
|||||||
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
|
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
|
||||||
|
|
||||||
|
|
||||||
@test("commission.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
@test("setup.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.c"),
|
||||||
("forgectrl", "src/lenshome.*"),
|
("forgectrl", "src/lenshome.*"),
|
||||||
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
|
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
|
||||||
@@ -305,8 +315,8 @@ def check_motion(ctx):
|
|||||||
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
|
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
|
||||||
|
|
||||||
|
|
||||||
@test("commission.check-flow-verify", title="The flow check runs as a setup check",
|
@test("setup.check-flow-verify", title="The flow check runs as a setup check",
|
||||||
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
subsystem="setup", kind="auto", hardware="takeover", est_min=5,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
||||||
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
||||||
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
||||||
@@ -323,8 +333,8 @@ def check_flow_verify(ctx):
|
|||||||
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
|
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
|
||||||
|
|
||||||
|
|
||||||
@test("commission.cloud-header-capture", title="The cloud header check takes one print's envelope",
|
@test("setup.cloud-header-capture", title="The cloud header check takes one print's envelope",
|
||||||
subsystem="commission", kind="operator", hardware="takeover", mode="grbl", est_min=8,
|
subsystem="setup", kind="operator", hardware="takeover", mode="grbl", est_min=8,
|
||||||
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
|
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
|
||||||
("python3-gfhardware", "gfhardware/machine.py"),
|
("python3-gfhardware", "gfhardware/machine.py"),
|
||||||
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
|
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
|
||||||
+93
-12
@@ -1,4 +1,4 @@
|
|||||||
"""commission.sheet - the setup's sheet (the live wizards) in one run,
|
"""setup.sheet - the setup's sheet (the live wizards) in one run,
|
||||||
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
|
driven the way the page drives them: POST /wiz/<id>/start, GET /wiz/dark
|
||||||
polled, the prompts answered from here, each burn armed by a press, the
|
polled, the prompts answered from here, each burn armed by a press, the
|
||||||
result judged, and every setting a wizard wrote put back as found. One
|
result judged, and every setting a wizard wrote put back as found. One
|
||||||
@@ -18,13 +18,13 @@ restored. The page is where the real numbers go in.
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from ..catalog import test
|
from ..catalog import test
|
||||||
from .commission_dark import run_check, Restore
|
from .setup_dark import run_check, Restore
|
||||||
|
|
||||||
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
||||||
("forgectrl", "src/sheet.*"),
|
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
|
||||||
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
||||||
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
||||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"),
|
||||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*"),
|
||||||
("forgectrl", "src/super.c"),
|
("forgectrl", "src/super.c"),
|
||||||
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
("forgectrl", "src/status.c"), ("forgectrl", "src/cool.c"),
|
||||||
@@ -71,6 +71,22 @@ def preview_ok(ctx, wid):
|
|||||||
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
||||||
"no program for %s (%s)", wid, st)
|
"no program for %s (%s)", wid, st)
|
||||||
ctx.evidence["program_lines"] = body.count(b"\n")
|
ctx.evidence["program_lines"] = body.count(b"\n")
|
||||||
|
# Every Z the program commands is a height the head reaches in the
|
||||||
|
# window the machine holds now: the focus card's own numbers once it
|
||||||
|
# has them, and the conservative fallback on a head whose travel is
|
||||||
|
# not known yet. The controller refuses a Z outside it with ALARM:2,
|
||||||
|
# and it refuses it while the body is still burning, so the card dies
|
||||||
|
# where the parser is and not where the bad line is.
|
||||||
|
lens = (fc.status() or {}).get("lens") or {}
|
||||||
|
lo, hi = lens.get("reach_min"), lens.get("reach_max")
|
||||||
|
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
|
||||||
|
ctx.evidence.setdefault("program_z", {})[wid] = {
|
||||||
|
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
|
||||||
|
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
|
||||||
|
if lo is not None and hi is not None:
|
||||||
|
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
|
||||||
|
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
|
||||||
|
wid, lo, hi, out)
|
||||||
|
|
||||||
|
|
||||||
def emission_ok(ctx, last):
|
def emission_ok(ctx, last):
|
||||||
@@ -89,6 +105,61 @@ def file_card(ctx, wid):
|
|||||||
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
||||||
|
|
||||||
|
|
||||||
|
# The lens settings: the head's numbers, which a fresh machine does not
|
||||||
|
# have. Cleared before the cards so the run is a fresh machine's.
|
||||||
|
LENS_SETTINGS = CARD_SETTINGS[:3]
|
||||||
|
FALLBACK_WINDOW = (10, 12)
|
||||||
|
|
||||||
|
|
||||||
|
def fresh_lens(ctx):
|
||||||
|
"""The lens as a fresh machine has it: the head's numbers unknown, so
|
||||||
|
the frame runs in the fallback window and the focus card has to find
|
||||||
|
the stops and write them before its ladder burns. The machine's own
|
||||||
|
numbers come back with the restore at the end of the run."""
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
for k in LENS_SETTINGS:
|
||||||
|
st, _ = fc.post("/settings", params={k: ""})
|
||||||
|
ctx.check(st == 200, "clearing %s -> %s", k, st)
|
||||||
|
lens = (fc.status() or {}).get("lens") or {}
|
||||||
|
ctx.evidence["lens_fresh"] = lens
|
||||||
|
ctx.check(lens.get("stops_found") is False and (lens.get("below"), lens.get("above")) == FALLBACK_WINDOW,
|
||||||
|
"the lens window is not the fallback after the clear: %s", lens)
|
||||||
|
|
||||||
|
|
||||||
|
def focus_window_ok(ctx):
|
||||||
|
"""The window the focus card burned its ladder in is the one it wrote
|
||||||
|
before its controller started, and the one the machine holds now: the
|
||||||
|
result's window is the stops it found (or the fallback), the settings
|
||||||
|
hold that window, every ladder height lies in its reach, and the
|
||||||
|
program served now agrees with /status."""
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
r = ((ctx.evidence.get("cards") or {}).get("laser.focus") or {}).get("result") or {}
|
||||||
|
w, stops = r.get("window") or {}, r.get("stops") or {}
|
||||||
|
s = fc.settings() or {}
|
||||||
|
held = (str(s.get("lens_stop_below_steps")), str(s.get("lens_stop_above_steps")))
|
||||||
|
ctx.check(held == (str(w.get("below")), str(w.get("above"))),
|
||||||
|
"the settings hold the window %s, the ladder ran in %s/%s", held, w.get("below"), w.get("above"))
|
||||||
|
if stops.get("found"):
|
||||||
|
ctx.check((w.get("below"), w.get("above")) == (stops.get("below"), stops.get("above")),
|
||||||
|
"the ladder's window %s is not the stops found %s", w, stops)
|
||||||
|
else:
|
||||||
|
ctx.check((w.get("below"), w.get("above")) == FALLBACK_WINDOW,
|
||||||
|
"stops not found (%s), but the ladder's window is %s", stops.get("why"), w)
|
||||||
|
zs = r.get("ladder_z") or []
|
||||||
|
lo, hi = w.get("reach_min"), w.get("reach_max")
|
||||||
|
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
|
||||||
|
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
|
||||||
|
ev = ctx.evidence
|
||||||
|
before = dict(ev.get("program_z") or {}).get("laser.focus")
|
||||||
|
preview_ok(ctx, "laser.focus")
|
||||||
|
ev["focus_window"] = {"window": w, "stops": stops, "ladder_z": zs,
|
||||||
|
"settings": {k: s.get(k) for k in LENS_SETTINGS},
|
||||||
|
"program_z_before": before,
|
||||||
|
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
|
||||||
|
"preview_bytes": ev.pop("preview_bytes", None),
|
||||||
|
"program_lines": ev.pop("program_lines", None)}
|
||||||
|
|
||||||
|
|
||||||
def thickness_answer(ctx):
|
def thickness_answer(ctx):
|
||||||
"""The thickness the placement is answered with, in the machine's
|
"""The thickness the placement is answered with, in the machine's
|
||||||
units (the number prompts read ui_units), and the millimeters the
|
units (the number prompts read ui_units), and the millimeters the
|
||||||
@@ -151,10 +222,10 @@ def burn(ctx, wid, answers, want):
|
|||||||
file_card(ctx, wid)
|
file_card(ctx, wid)
|
||||||
|
|
||||||
|
|
||||||
@test("commission.sheet", title="The commissioning sheet: the placement, the frame, and the five cards",
|
@test("setup.sheet", title="The setup sheet: the placement, the frame, and the five cards",
|
||||||
subsystem="commission", kind="live", hardware="takeover", mode="grbl", est_min=35,
|
subsystem="setup", kind="live", hardware="takeover", mode="grbl", est_min=35,
|
||||||
covers=SHEET_COVERS,
|
covers=SHEET_COVERS,
|
||||||
requires=["forgectrl.auth", "commission.check-motion", "laser.emission-witness", "cooling.flow-verify"],
|
requires=["forgectrl.auth", "setup.check-motion", "laser.emission-witness", "cooling.flow-verify"],
|
||||||
actions=["button"], hands=["scrap"],
|
actions=["button"], hands=["scrap"],
|
||||||
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
|
steps=["A piece of wood at least 200 x 150 mm (8 x 6 in) on the bed, pushed as far left as "
|
||||||
"it goes with its top edge at the top of the cut area (the head's home corner); "
|
"it goes with its top edge at the top of the cut area (the head's home corner); "
|
||||||
@@ -167,15 +238,22 @@ def burn(ctx, wid, answers, want):
|
|||||||
"Nothing else: the test answers every prompt itself and puts back every setting the "
|
"Nothing else: the test answers every prompt itself and puts back every setting the "
|
||||||
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
|
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
|
||||||
"tails."],
|
"tails."],
|
||||||
description="One run over the sheet's seven wizards on one piece of wood. "
|
description="One run over the sheet's seven wizards on one piece of wood, as a fresh "
|
||||||
|
"machine runs them: the three lens settings are cleared first, so the frame "
|
||||||
|
"runs in the fallback window and the focus card has to find the stops and write "
|
||||||
|
"them before its ladder burns. "
|
||||||
"POST /wiz/sheet.place/start references the lens, takes the controller in "
|
"POST /wiz/sheet.place/start references the lens, takes the controller in "
|
||||||
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
|
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
|
||||||
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
|
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
|
||||||
"0.125 in). Then, each after the press and each with its preview "
|
"0.125 in). Then, each after the press and each with its preview "
|
||||||
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode: the frame "
|
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the "
|
||||||
|
"program commands inside the reach /status reports: the frame "
|
||||||
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
|
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
|
||||||
"lens homed on its bottom stop, the hall edge counted, twelve lines down the "
|
"lens on its hall edge, the stops found by the head accelerometer and written "
|
||||||
"travel; the pick is line 11, the thickness kept); the floor card (twelve "
|
"as the window before the controller starts, twelve lines from the top of the "
|
||||||
|
"window to the bottom; the pick is line 11, the thickness kept; after it the "
|
||||||
|
"settings, the result's window, and the served program must hold the window "
|
||||||
|
"the ladder ran in); the floor card (twelve "
|
||||||
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
|
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
|
||||||
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
|
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
|
||||||
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
|
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
|
||||||
@@ -191,6 +269,9 @@ def sheet(ctx):
|
|||||||
"each. The bench presses when the button lights.")
|
"each. The bench presses when the button lights.")
|
||||||
place(ctx)
|
place(ctx)
|
||||||
with Restore(ctx, CARD_SETTINGS):
|
with Restore(ctx, CARD_SETTINGS):
|
||||||
|
fresh_lens(ctx)
|
||||||
for wid, answers, want in CARDS:
|
for wid, answers, want in CARDS:
|
||||||
burn(ctx, wid, answers, want)
|
burn(ctx, wid, answers, want)
|
||||||
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
|
if wid == "laser.focus":
|
||||||
|
focus_window_ok(ctx)
|
||||||
|
ctx.log("PASS: the sheet's seven wizards ran on one piece as a fresh machine's; settings restored")
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""update.* - the A/B slot inventory and the firmware verification path."""
|
"""update.* - the A/B slot inventory and the firmware verification path."""
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import shutil
|
import shutil
|
||||||
@@ -8,6 +9,7 @@ from ..catalog import test
|
|||||||
from .. import hw
|
from .. import hw
|
||||||
|
|
||||||
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
|
_UPDATE_COVERS = [("forgectrl", "src/update.c"), ("forgectrl", "src/update.h"),
|
||||||
|
("forgectrl", "src/relcheck.c"), ("forgectrl", "src/relcheck.h"),
|
||||||
("ffboot", "**")]
|
("ffboot", "**")]
|
||||||
|
|
||||||
|
|
||||||
@@ -117,3 +119,76 @@ def slots_and_signature(ctx):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
shutil.rmtree(work, ignore_errors=True)
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
_VERSION_RX = re.compile(r"^v?\d+\.\d+\.\d+")
|
||||||
|
|
||||||
|
|
||||||
|
@test("update.release-check", title="Release check answers from the releases API; the alert dismissal is per release",
|
||||||
|
subsystem="update", kind="auto", est_min=1,
|
||||||
|
covers=_UPDATE_COVERS + [("forgectrl", "src/ui/panel.js"), ("forgectrl", "src/ui/index.html")],
|
||||||
|
requires=["forgectrl.auth"],
|
||||||
|
description="GET /update/release answers the kept answer of the daily check (available, version, "
|
||||||
|
"current, new, checked, dismissed). POST /update/check asks the releases API now and "
|
||||||
|
"answers the same shape; a machine with no route to the API answers 502, which the drill "
|
||||||
|
"records and steps over. A published release is a v<semver> tag with the firmware "
|
||||||
|
"file's size and its notes, and `new` follows the version order: a development build "
|
||||||
|
"sees every release as newer. POST /update/dismiss marks that version dismissed, an "
|
||||||
|
"empty version undoes it, and a version that is not a tag is refused. The dismissal "
|
||||||
|
"the machine had before the drill is put back.")
|
||||||
|
def release_check(ctx):
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
ev = ctx.evidence
|
||||||
|
keys = ("available", "version", "current", "new", "checked", "dismissed", "detail", "bytes")
|
||||||
|
|
||||||
|
st, before = fc.get("/update/release")
|
||||||
|
ctx.log("GET /update/release -> %s %s", st, {k: before.get(k) for k in keys} if isinstance(before, dict) else before)
|
||||||
|
ctx.check(st == 200 and isinstance(before, dict) and "available" in before and "checked" in before,
|
||||||
|
"GET /update/release -> %s", st)
|
||||||
|
ev["before"] = {k: before.get(k) for k in keys}
|
||||||
|
prior = before.get("version", "") if before.get("dismissed") else ""
|
||||||
|
|
||||||
|
st, now = fc.post("/update/check")
|
||||||
|
ctx.log("POST /update/check -> %s %s", st, {k: now.get(k) for k in keys} if isinstance(now, dict) else now)
|
||||||
|
if st == 502:
|
||||||
|
ctx.log("the machine has no route to the releases API; the kept answer stands")
|
||||||
|
now = before
|
||||||
|
else:
|
||||||
|
ctx.check(st == 200 and isinstance(now, dict) and "available" in now, "POST /update/check -> %s", st)
|
||||||
|
ctx.check(isinstance(now.get("checked"), int) and now["checked"] >= before.get("checked", 0),
|
||||||
|
"the check did not move `checked`: %s", now.get("checked"))
|
||||||
|
ev["after_check"] = {k: now.get(k) for k in keys}
|
||||||
|
|
||||||
|
if now.get("available"):
|
||||||
|
ctx.check(_VERSION_RX.match(str(now.get("version", ""))) is not None,
|
||||||
|
"the release version is not a v<semver> tag: %r", now.get("version"))
|
||||||
|
ctx.check(isinstance(now.get("bytes"), int) and now["bytes"] > 0,
|
||||||
|
"the firmware file's size is missing: %r", now.get("bytes"))
|
||||||
|
ctx.check(isinstance(now.get("notes"), str), "the notes are not a string")
|
||||||
|
ctx.check(isinstance(now.get("new"), bool), "`new` is not a bool")
|
||||||
|
if not _VERSION_RX.match(str(now.get("current", ""))):
|
||||||
|
ctx.check(now.get("new") is True,
|
||||||
|
"a development build (%r) must see release %s as newer", now.get("current"), now["version"])
|
||||||
|
else:
|
||||||
|
ctx.log("no release available: %s", now.get("detail"))
|
||||||
|
ctx.check(isinstance(now.get("detail"), str) and now["detail"], "an unavailable release names no reason")
|
||||||
|
|
||||||
|
try:
|
||||||
|
st, body = fc.post("/update/dismiss", params={"version": "v0.0.1; rm -rf /"})
|
||||||
|
ctx.log("POST /update/dismiss version=<not a tag> -> %s %s", st, body)
|
||||||
|
ctx.check(st == 400, "a version that is not a tag was not refused: %s", st)
|
||||||
|
|
||||||
|
tag = now["version"] if now.get("available") else "v0.0.0"
|
||||||
|
st, body = fc.post("/update/dismiss", params={"version": tag})
|
||||||
|
ctx.log("POST /update/dismiss version=%s -> %s %s", tag, st,
|
||||||
|
{k: body.get(k) for k in keys} if isinstance(body, dict) else body)
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict), "POST /update/dismiss -> %s", st)
|
||||||
|
if now.get("available"):
|
||||||
|
ctx.check(body.get("dismissed") is True, "the dismissal of %s was not recorded: %r", tag, body.get("dismissed"))
|
||||||
|
st, body = fc.post("/update/dismiss", params={"version": ""})
|
||||||
|
ctx.log("POST /update/dismiss version= -> %s", st)
|
||||||
|
ctx.check(st == 200 and isinstance(body, dict) and body.get("dismissed") is False,
|
||||||
|
"the empty version did not undo the dismissal: %s %r", st, body.get("dismissed") if isinstance(body, dict) else body)
|
||||||
|
finally:
|
||||||
|
st, body = fc.post("/update/dismiss", params={"version": prior})
|
||||||
|
ctx.log("dismissal put back to %r -> %s", prior, st)
|
||||||
|
|||||||
@@ -206,6 +206,100 @@
|
|||||||
border-left: 4px solid var(--og-red);
|
border-left: 4px solid var(--og-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- The release dialog -------------------------------------------- */
|
||||||
|
#rel-view h3 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--og-dim);
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
}
|
||||||
|
.relnotes {
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--og-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.relnotes h1,
|
||||||
|
.relnotes h2,
|
||||||
|
.relnotes h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
}
|
||||||
|
.relnotes h1:first-child,
|
||||||
|
.relnotes h2:first-child,
|
||||||
|
.relnotes h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.relnotes p,
|
||||||
|
.relnotes ul {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.relnotes ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.relnotes code {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.relnotes table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
}
|
||||||
|
.relnotes th,
|
||||||
|
.relnotes td {
|
||||||
|
border: 1px solid var(--og-line);
|
||||||
|
padding: 2px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.relwarn {
|
||||||
|
background: var(--og-warn-bg);
|
||||||
|
border: 1px solid var(--og-warn);
|
||||||
|
color: var(--og-warn-txt);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.relsteps {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-left: 22px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.relsteps li {
|
||||||
|
color: var(--og-dim);
|
||||||
|
}
|
||||||
|
.relsteps li.cur {
|
||||||
|
color: var(--og-txt);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.relsteps li.done {
|
||||||
|
color: var(--og-ok);
|
||||||
|
}
|
||||||
|
.relsteps li.done::after {
|
||||||
|
content: ' \2713';
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
--bs-progress-bg: var(--og-line-soft);
|
||||||
|
--bs-progress-bar-bg: var(--og-action);
|
||||||
|
--bs-progress-height: 10px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
#rel-phase {
|
||||||
|
min-height: 1.4em;
|
||||||
|
font-size: 12.5px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
#rel-err {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Chrome: header, tabs, footer ---------------------------------- */
|
/* ---- Chrome: header, tabs, footer ---------------------------------- */
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -126,6 +126,21 @@ class BaselineTests(unittest.TestCase):
|
|||||||
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
|
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
|
||||||
self.assertEqual(items["position"].found, [1000, 0, 0])
|
self.assertEqual(items["position"].found, [1000, 0, 0])
|
||||||
|
|
||||||
|
def test_the_counters_are_read_at_the_modes_scale(self):
|
||||||
|
# 6400 steps is 30 mm at x32 (a return within the bound; on the
|
||||||
|
# host it stops at the missing controller) and 120 mm at x8
|
||||||
|
# (beyond the bound). Read at the x8 scale, an x32 machine's
|
||||||
|
# displaced head is never jogged back.
|
||||||
|
b = self.bl()
|
||||||
|
cap = b.capture()
|
||||||
|
self._attr("cnc/x_mode", "32")
|
||||||
|
self._pos(6400, 0, 0)
|
||||||
|
items = {x.item: x for x in b.enforce("post", captured=cap)}
|
||||||
|
self.assertEqual(items["position"].action, "unrestorable: no running GRBL controller")
|
||||||
|
self._attr("cnc/x_mode", "8")
|
||||||
|
items = {x.item: x for x in b.enforce("post", captured=cap)}
|
||||||
|
self.assertEqual(items["position"].action, "unrestorable: 120.0/0.0 mm exceeds 100 mm")
|
||||||
|
|
||||||
def test_a_held_controller_is_reset_before_the_return_jog(self):
|
def test_a_held_controller_is_reset_before_the_return_jog(self):
|
||||||
# a pause test that failed while held leaves the controller in
|
# a pause test that failed while held leaves the controller in
|
||||||
# Hold, which refuses a jog: the baseline resets out of it first
|
# Hold, which refuses a jog: the baseline resets out of it first
|
||||||
@@ -434,6 +449,13 @@ class PositionDeadbandTests(unittest.TestCase):
|
|||||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
|
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
|
||||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
|
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
|
||||||
|
|
||||||
|
def test_the_dead_band_scales_with_the_counters(self):
|
||||||
|
# the same step count is a quarter of the distance at x32
|
||||||
|
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
|
||||||
|
x32 = baseline.XY_STEPS_PER_MM_OF[32]
|
||||||
|
self.assertTrue(baseline.position_quantized([0, 0, 0], [over, 0, 0], x32))
|
||||||
|
self.assertFalse(baseline.position_quantized([0, 0, 0], [4 * over, 0, 0], x32))
|
||||||
|
|
||||||
def test_z_is_exact_because_the_return_never_moves_it(self):
|
def test_z_is_exact_because_the_return_never_moves_it(self):
|
||||||
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
|
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
|
||||||
|
|
||||||
|
|||||||
@@ -558,12 +558,16 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
|
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
|
||||||
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
|
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
|
||||||
|
|
||||||
def mode_switch_setup(self, hunt_lines=None, home_complete=True):
|
def mode_switch_setup(self, hunt_lines=None, home_complete=True, lid_late=False):
|
||||||
"""The fakes a mode-switch run needs: grbl to answer $H, the lid
|
"""The fakes a mode-switch run needs: grbl to answer $H, the lid
|
||||||
lamp attr, homing_mode = gfcloud, the service lines landing on
|
lamp attr, homing_mode = gfcloud, the service lines landing on
|
||||||
the switch to cloud (the hunt reads as a run to the cooling
|
the switch to cloud - the client's start at once, its session and
|
||||||
engine while it lasts), the re-hunt on the lid close, and gfhome
|
hunt once the lid reads open, as on the bench where the lid opens
|
||||||
finishing the homing after $H."""
|
behind the controller's start and ahead of the hunt (the hunt
|
||||||
|
reads as a run to the cooling engine while it lasts) - the re-hunt
|
||||||
|
on the lid close, and gfhome finishing the homing after $H. With
|
||||||
|
lid_late the hunt is requested before the lid opens: the race the
|
||||||
|
test must call."""
|
||||||
self.grbl = helpers.FakeGrbl().start()
|
self.grbl = helpers.FakeGrbl().start()
|
||||||
os.makedirs(self.sysfs + "pic", exist_ok=True)
|
os.makedirs(self.sysfs + "pic", exist_ok=True)
|
||||||
self._attr("pic/lid_led", "236")
|
self._attr("pic/lid_led", "236")
|
||||||
@@ -575,22 +579,35 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
hunt_part = hunt_lines(hunt_part)
|
hunt_part = hunt_lines(hunt_part)
|
||||||
fc = self.fc
|
fc = self.fc
|
||||||
|
|
||||||
|
def hunt():
|
||||||
|
self.append(hunt_part, delay=0.0)
|
||||||
|
time.sleep(4.0) # the hunt outlasts the session wait on the bench
|
||||||
|
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
|
||||||
|
|
||||||
def on_post(path, form):
|
def on_post(path, form):
|
||||||
if path == "/mode" and form.get("controller") == "cloud":
|
if path == "/mode" and form.get("controller") == "cloud":
|
||||||
fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE)
|
fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE)
|
||||||
|
|
||||||
def land():
|
def land():
|
||||||
self.append(pre, delay=0.0)
|
self.append(pre, delay=0.0)
|
||||||
|
if lid_late:
|
||||||
|
return
|
||||||
|
t0 = time.time()
|
||||||
|
while fc.state["status"]["switches"]["lid"] and time.time() - t0 < 5:
|
||||||
|
time.sleep(0.02)
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
self.append(hunt_part, delay=0.0)
|
hunt()
|
||||||
time.sleep(4.0) # the hunt outlasts the session wait on the bench
|
|
||||||
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
|
|
||||||
threading.Thread(target=land, daemon=True).start()
|
threading.Thread(target=land, daemon=True).start()
|
||||||
elif path == "/mode" and form.get("controller") == "grbl":
|
elif path == "/mode" and form.get("controller") == "grbl":
|
||||||
self.grbl.state = "Idle"
|
self.grbl.state = "Idle"
|
||||||
return None
|
return None
|
||||||
self.fc.on_post = on_post
|
self.fc.on_post = on_post
|
||||||
|
|
||||||
|
def lid_opened():
|
||||||
|
if lid_late:
|
||||||
|
self.append(hunt_part, delay=0.0)
|
||||||
|
self.lid(False)
|
||||||
|
|
||||||
def lid_closed():
|
def lid_closed():
|
||||||
self.lid(True)
|
self.lid(True)
|
||||||
self.append(close_part, delay=0.2)
|
self.append(close_part, delay=0.2)
|
||||||
@@ -605,7 +622,7 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete "
|
f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete "
|
||||||
b"(service quiet 8s, 3 motion windows)\n")
|
b"(service quiet 8s, 3 motion windows)\n")
|
||||||
self.home_hook = homing
|
self.home_hook = homing
|
||||||
return {"Open the lid.": lambda: self.lid(False), "Close the lid.": lid_closed}
|
return {"Open the lid.": lid_opened, "Close the lid.": lid_closed}
|
||||||
|
|
||||||
def wait_home_command(self):
|
def wait_home_command(self):
|
||||||
while "$H" not in self.grbl.sent:
|
while "$H" not in self.grbl.sent:
|
||||||
@@ -633,6 +650,29 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.script.asked, [])
|
self.assertEqual(self.script.asked, [])
|
||||||
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
|
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
|
||||||
|
|
||||||
|
def test_mode_switch_opens_the_lid_behind_the_controller_and_ahead_of_the_hunt(self):
|
||||||
|
# No controller starts with the enclosure open: the switch is made
|
||||||
|
# lid closed, the lid opens once the controller is up, and the
|
||||||
|
# hunt's request lands after that - the order the test records.
|
||||||
|
hooks = self.mode_switch_setup()
|
||||||
|
threading.Thread(target=self.wait_home_command, daemon=True).start()
|
||||||
|
run = self.run_test(cloud.mode_switch, hooks=hooks, test_id="cloud.mode-switch")
|
||||||
|
self.assertFalse(run.evidence["hunt_before_lid_open"])
|
||||||
|
acts = [(a["channel"], a["state"]) for a in run.evidence["actions"]]
|
||||||
|
self.assertEqual(acts, [("lid", "open"), ("lid", "close")])
|
||||||
|
i_post = next(i for i, l in enumerate(run.lines) if "POST /mode controller=cloud" in l)
|
||||||
|
i_open = next(i for i, l in enumerate(run.lines) if "ACT lid open" in l)
|
||||||
|
self.assertLess(i_post, i_open)
|
||||||
|
|
||||||
|
def test_mode_switch_refuses_to_start_with_the_lid_open(self):
|
||||||
|
hooks = self.mode_switch_setup()
|
||||||
|
self.lid(False)
|
||||||
|
self.assertFails(cloud.mode_switch, "close the lid first", hooks=hooks)
|
||||||
|
|
||||||
|
def test_mode_switch_fails_when_the_hunt_was_requested_before_the_lid_opened(self):
|
||||||
|
hooks = self.mode_switch_setup(lid_late=True)
|
||||||
|
self.assertFails(cloud.mode_switch, "before the lid was open", hooks=hooks)
|
||||||
|
|
||||||
def test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self):
|
def test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self):
|
||||||
def refused(hunt_part):
|
def refused(hunt_part):
|
||||||
i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)
|
i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)
|
||||||
@@ -914,22 +954,63 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.fc.posts, [])
|
self.assertEqual(self.fc.posts, [])
|
||||||
self.assertTrue(any("PASS: lid open at the button prompt" in l for l in run.lines))
|
self.assertTrue(any("PASS: lid open at the button prompt" in l for l in run.lines))
|
||||||
|
|
||||||
# -- the cooling verdict: the armed print waits on the warm-up ------------
|
# -- the cooling verdict: locked until the run, a hold that never clears --
|
||||||
def test_verdict_hold_on_the_bench_excerpt(self):
|
def dark_parts(self):
|
||||||
self.in_offline()
|
"""(the print prologue through the button prompt, the run and the
|
||||||
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
print's end with no pause in it)."""
|
||||||
self.fc.state["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"})
|
|
||||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True, "armed": False})
|
|
||||||
lines = fixture("pause")
|
lines = fixture("pause")
|
||||||
pre, rest = cut(lines, "waiting for button")
|
pre, rest = cut(lines, "waiting for button")
|
||||||
pre = pre + [rest[0]]
|
pre = pre + [rest[0]]
|
||||||
# the run and the print's end, without the pause in the middle
|
|
||||||
_, run = cut(rest, "machine:_run_loop starting run")
|
_, run = cut(rest, "machine:_run_loop starting run")
|
||||||
run = [l for l in run if "paus" not in l and "resum" not in l]
|
run = [l for l in run if "paus" not in l and "resum" not in l]
|
||||||
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] INFO machine:_verdict_wait "
|
return pre, run
|
||||||
wait = [stamp % 100000 + "waiting on the cooling engine: WARMUP (WARM-UP: coolant 21.5 C under "
|
|
||||||
"the 22.5 C start gate - heater on, hold)"]
|
def test_dark_print_on_the_bench_excerpt(self):
|
||||||
release = [stamp % 200000 + "cooling verdict clean after WARMUP; starting the run"]
|
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_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):
|
def on_post(path, form):
|
||||||
if path == "/settings" and form.get("cool_temp_start") not in (None, "0", "16"):
|
if path == "/settings" and form.get("cool_temp_start") not in (None, "0", "16"):
|
||||||
@@ -942,37 +1023,44 @@ class CloudSuiteTests(unittest.TestCase):
|
|||||||
def press():
|
def press():
|
||||||
self.append(wait, delay=0.05)
|
self.append(wait, delay=0.05)
|
||||||
|
|
||||||
def released():
|
def refused():
|
||||||
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True,
|
self.fc.state["cool"].update({"verdict": "OK", "hold": False, "fire_ok": True,
|
||||||
"armed": False, "phase": "run"})
|
"armed": False, "phase": "idle"})
|
||||||
self.append(release + run, delay=0.0)
|
self.append(refuse, delay=0.0)
|
||||||
threading.Timer(3.0, released).start()
|
threading.Timer(2.5, refused).start()
|
||||||
hooks = {"press it. The print then waits": press}
|
hooks = {"press it. Nothing runs": press}
|
||||||
run_ = self.run_test(cloud.verdict_hold, hooks=hooks, test_id="cloud.verdict-hold")
|
run_ = self.run_test(cloud.verdict_refuse, hooks=hooks, test_id="cloud.verdict-refuse")
|
||||||
ev = run_.evidence
|
ev = run_.evidence
|
||||||
self.assertEqual(ev["gate"], 22.5)
|
self.assertEqual(ev["gate"], 29.5)
|
||||||
self.assertEqual(ev["warmup"]["verdict"], "WARMUP")
|
self.assertTrue(ev["latch_locked_at_button"])
|
||||||
self.assertTrue(ev["log"]["cooling verdict clean after WARMUP; starting the run"])
|
self.assertEqual(ev["latch_samples"]["unlocked"], 0)
|
||||||
self.assertIn(":completed", ev["print finished"])
|
self.assertGreaterEqual(ev["latch_samples"]["n"], 1)
|
||||||
posted = [f for p, f in self.fc.posts if p == "/settings"]
|
self.assertIn(":cancelled", ev["print finished"])
|
||||||
self.assertEqual(posted[0]["cool_temp_start"], "22.5")
|
posted = [f for p_, f in self.fc.posts if p_ == "/settings"]
|
||||||
self.assertEqual(posted[-1], {"cool_temp_min": "5", "cool_temp_start": "16"})
|
self.assertEqual(posted[0]["cool_temp_start"], "29.5")
|
||||||
self.assertTrue(any("PASS: the armed print waited" in l for l in run_.lines))
|
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_hold_fails_when_the_print_runs_under_the_hold(self):
|
def test_verdict_refuse_fails_when_the_latch_unlocks_during_the_hold(self):
|
||||||
self.in_offline()
|
self.in_offline()
|
||||||
self.fc.state["status"]["coolant"] = {"up_c": 21.5, "down_c": 21.4}
|
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["settings"].update({"cool_temp_min": "5", "cool_temp_start": "16"})
|
||||||
self.fc.state["cool"].update({"verdict": "WARMUP", "hold": True, "fire_ok": False, "armed": True})
|
self.fc.state["cool"].update({"verdict": "WARMUP", "hold": True, "fire_ok": False, "armed": True})
|
||||||
lines = fixture("pause")
|
pre, run = self.dark_parts()
|
||||||
pre, rest = cut(lines, "waiting for button")
|
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] %s machine:_verdict_wait "
|
||||||
pre = pre + [rest[0]]
|
wait = [stamp % (100000, "INFO") + "waiting on the cooling engine: WARMUP (no reason given)"]
|
||||||
_, run = cut(rest, "machine:_run_loop starting run")
|
refuse = [stamp % (200000, "WARNING") + "cooling hold held 60 s - relocking the laser",
|
||||||
stamp = "2026-08-17T09:44:14.%06d+00:00 gfcloud[1927] INFO machine:_verdict_wait "
|
"2026-08-17T09:44:15.000000+00:00 gfcloud[1927] INFO basemachine:_finish_action print "
|
||||||
wait = [stamp % 100000 + "waiting on the cooling engine: WARMUP (no reason given)"]
|
"[9006]: finished with event \":cancelled\""]
|
||||||
self.offline_print_hooks(pre)
|
self.offline_print_hooks(pre)
|
||||||
hooks = {"press it. The print then waits": lambda: self.append(wait + run[:1], delay=0.05)}
|
|
||||||
self.assertFails(cloud.verdict_hold, "the run started under the warm-up hold", hooks=hooks)
|
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 --------
|
# -- a paused print canceled by the lid, a running one by the app --------
|
||||||
def cancel_parts(self):
|
def cancel_parts(self):
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""The setup's checks in the catalog: registered as automatic tests with
|
"""The setup's checks in the catalog: registered as automatic tests with
|
||||||
no precheck, the takeover ones marked so, every covers map naming the
|
no precheck, the takeover ones marked so, every covers map naming the
|
||||||
check runner; and the check driver itself against the fake daemon: a
|
check runner; and the check driver itself against the fake daemon: a
|
||||||
start, a prompt answered, the result taken, the record read at version
|
start, the run polled to its end with no question asked, the result
|
||||||
1, and the settings a check may write put back as found."""
|
taken, the record read at version 1, and the settings read as before."""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
@@ -12,8 +12,8 @@ import helpers # noqa: E402
|
|||||||
from forgetest import catalog # noqa: E402
|
from forgetest import catalog # noqa: E402
|
||||||
from forgetest.runner import Context, Run # noqa: E402
|
from forgetest.runner import Context, Run # noqa: E402
|
||||||
|
|
||||||
IDS = ["commission.check-switches", "commission.check-sensors", "commission.check-airflow",
|
IDS = ["setup.check-switches", "setup.check-sensors", "setup.check-airflow",
|
||||||
"commission.check-cameras", "commission.check-motion", "commission.check-flow-verify"]
|
"setup.check-cameras", "setup.check-motion", "setup.check-flow-verify"]
|
||||||
|
|
||||||
|
|
||||||
class RegistrationTests(unittest.TestCase):
|
class RegistrationTests(unittest.TestCase):
|
||||||
@@ -25,11 +25,11 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
# bench fixture performs; the rest need no hands at all.
|
# bench fixture performs; the rest need no hands at all.
|
||||||
for tid in IDS:
|
for tid in IDS:
|
||||||
self.assertIn(tid, self.reg)
|
self.assertIn(tid, self.reg)
|
||||||
for tid in ("commission.check-switches", "commission.check-cameras"):
|
for tid in ("setup.check-switches", "setup.check-cameras"):
|
||||||
self.assertEqual(self.reg[tid].kind, "operator", tid)
|
self.assertEqual(self.reg[tid].kind, "operator", tid)
|
||||||
self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid)
|
self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid)
|
||||||
for tid in ("commission.check-sensors", "commission.check-airflow", "commission.check-motion",
|
for tid in ("setup.check-sensors", "setup.check-airflow", "setup.check-motion",
|
||||||
"commission.check-flow-verify"):
|
"setup.check-flow-verify"):
|
||||||
self.assertEqual(self.reg[tid].kind, "auto", tid)
|
self.assertEqual(self.reg[tid].kind, "auto", tid)
|
||||||
|
|
||||||
def test_no_check_asks_the_operator_to_prepare_anything(self):
|
def test_no_check_asks_the_operator_to_prepare_anything(self):
|
||||||
@@ -37,7 +37,7 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
|
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
|
||||||
|
|
||||||
def test_the_checks_that_take_the_machine_say_so(self):
|
def test_the_checks_that_take_the_machine_say_so(self):
|
||||||
for tid in ("commission.check-airflow", "commission.check-motion", "commission.check-flow-verify"):
|
for tid in ("setup.check-airflow", "setup.check-motion", "setup.check-flow-verify"):
|
||||||
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||||
|
|
||||||
def test_every_check_covers_the_runner_and_the_page(self):
|
def test_every_check_covers_the_runner_and_the_page(self):
|
||||||
@@ -62,9 +62,17 @@ class SensorsCheckTests(unittest.TestCase):
|
|||||||
"shots": {"lid": False, "head": False}}
|
"shots": {"lid": False, "head": False}}
|
||||||
self.versions = {}
|
self.versions = {}
|
||||||
self.answers = []
|
self.answers = []
|
||||||
|
self.polls = 0
|
||||||
|
|
||||||
def on_get(path, q):
|
def on_get(path, q):
|
||||||
if path == "/wiz/dark":
|
if path == "/wiz/dark":
|
||||||
|
# One poll sees the sampling; the next sees the result.
|
||||||
|
if self.dark["running"]:
|
||||||
|
self.polls += 1
|
||||||
|
if self.polls >= 2:
|
||||||
|
self.dark.update({"running": False, "prompt": None, "progress": 100,
|
||||||
|
"result": dict(self.RESULT)})
|
||||||
|
self.versions["sensors"] = 1
|
||||||
return 200, dict(self.dark)
|
return 200, dict(self.dark)
|
||||||
if path == "/wiz":
|
if path == "/wiz":
|
||||||
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
|
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
|
||||||
@@ -74,19 +82,11 @@ class SensorsCheckTests(unittest.TestCase):
|
|||||||
s = self.fake.state["settings"]
|
s = self.fake.state["settings"]
|
||||||
if path == "/wiz/sensors/start":
|
if path == "/wiz/sensors/start":
|
||||||
self.dark.update({"id": "sensors", "running": True, "phase": "sampling",
|
self.dark.update({"id": "sensors", "running": True, "phase": "sampling",
|
||||||
"prompt": {"seq": 7, "id": "room-temp", "kind": "number",
|
"prompt": None})
|
||||||
"text": "Optional: the room temperature.",
|
|
||||||
"options": ["Set", "Skip"]}})
|
|
||||||
return 200, {"started": True, "id": "sensors"}
|
return 200, {"started": True, "id": "sensors"}
|
||||||
if path == "/wiz/sensors/answer":
|
if path == "/wiz/sensors/answer":
|
||||||
self.answers.append(dict(form))
|
self.answers.append(dict(form))
|
||||||
if form.get("seq") != "7":
|
|
||||||
return 409, {"error": "no such prompt is open"}
|
return 409, {"error": "no such prompt is open"}
|
||||||
if form.get("value") == "Skip":
|
|
||||||
self.dark.update({"running": False, "prompt": None, "progress": 100,
|
|
||||||
"result": dict(self.RESULT)})
|
|
||||||
self.versions["sensors"] = 1
|
|
||||||
return 200, {"ok": True}
|
|
||||||
if path == "/settings":
|
if path == "/settings":
|
||||||
for k, v in form.items():
|
for k, v in form.items():
|
||||||
if v == "":
|
if v == "":
|
||||||
@@ -101,15 +101,35 @@ class SensorsCheckTests(unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.fake.stop()
|
self.fake.stop()
|
||||||
|
|
||||||
def test_start_prompt_answer_result_and_restore(self):
|
def test_start_no_question_result_and_settings_untouched(self):
|
||||||
t = catalog.load_suite()["commission.check-sensors"]
|
t = catalog.load_suite()["setup.check-sensors"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
t.fn(Context(run, None, t))
|
t.fn(Context(run, None, t))
|
||||||
self.assertIn(("/wiz/sensors/start", {}), [(p, f) for p, f in self.fake.posts])
|
self.assertIn(("/wiz/sensors/start", {}), [(p, f) for p, f in self.fake.posts])
|
||||||
self.assertEqual(self.answers, [{"seq": "7", "value": "Skip"}])
|
self.assertEqual(self.answers, [])
|
||||||
self.assertEqual(run.evidence["result"]["laser_pgood"], 1)
|
self.assertEqual(run.evidence["result"]["laser_pgood"], 1)
|
||||||
self.assertEqual(run.evidence["settings_before"], {"cool_temp_offset_c": "0.5"})
|
self.assertEqual(run.evidence["settings_before"].get("cool_temp_offset_c"), "0.5")
|
||||||
self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5")
|
self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5")
|
||||||
|
self.assertNotIn("/settings", [p for p, _ in self.fake.posts])
|
||||||
|
|
||||||
|
def test_a_question_fails_the_check(self):
|
||||||
|
t = catalog.load_suite()["setup.check-sensors"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
real_get = self.fake.on_get
|
||||||
|
|
||||||
|
def on_get(path, q):
|
||||||
|
if path == "/wiz/dark" and self.dark["running"]:
|
||||||
|
d = dict(self.dark)
|
||||||
|
d["prompt"] = {"seq": 3, "id": "room-temp", "kind": "number", "text": "?",
|
||||||
|
"options": ["Set", "Skip"]}
|
||||||
|
return 200, d
|
||||||
|
return real_get(path, q)
|
||||||
|
self.fake.on_get = on_get
|
||||||
|
with self.assertRaises(Exception) as cm:
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
self.assertIn("asked a question", str(cm.exception))
|
||||||
|
self.assertEqual(self.answers, [])
|
||||||
|
self.assertIn(("/wiz/sensors/abort", {}), [(p, f) for p, f in self.fake.posts])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -14,10 +14,47 @@ sys.path.insert(0, os.path.dirname(__file__))
|
|||||||
import helpers # noqa: E402
|
import helpers # noqa: E402
|
||||||
from forgetest import catalog # noqa: E402
|
from forgetest import catalog # noqa: E402
|
||||||
from forgetest.runner import Context, Run # noqa: E402
|
from forgetest.runner import Context, Run # noqa: E402
|
||||||
from forgetest.suite import commission_sheet # noqa: E402
|
from forgetest.suite import setup_sheet # noqa: E402
|
||||||
|
|
||||||
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
|
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
|
||||||
|
# The focus card's program is read again after it ran: the served ladder
|
||||||
|
# must then lie in the window the card wrote.
|
||||||
|
PREVIEWS = ["sheet.frame", "laser.focus", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner",
|
||||||
|
"cooling.flow-load"]
|
||||||
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
|
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
|
||||||
|
SPM = 2.922 # the lens screw, half-steps per mm ($102)
|
||||||
|
EDGE_DEFAULT = 3.35
|
||||||
|
|
||||||
|
|
||||||
|
def lens_block(s):
|
||||||
|
"""The /status lens block as the daemon builds it from the settings: a
|
||||||
|
stop count from 1 to 40 stands, anything else is the fallback."""
|
||||||
|
def count(key, fallback):
|
||||||
|
try:
|
||||||
|
n = float(s.get(key, ""))
|
||||||
|
except ValueError:
|
||||||
|
return fallback, False
|
||||||
|
return (int(n), True) if 1 <= n <= 40 else (fallback, False)
|
||||||
|
try:
|
||||||
|
edge = float(s.get("lens_hall_edge_z_mm", ""))
|
||||||
|
except ValueError:
|
||||||
|
edge = EDGE_DEFAULT
|
||||||
|
below, a = count("lens_stop_below_steps", 10)
|
||||||
|
above, b = count("lens_stop_above_steps", 12)
|
||||||
|
return {"edge_z": round(edge, 2), "below": below, "above": above, "stops_found": a and b,
|
||||||
|
"reach_min": round(edge - below / SPM, 2), "reach_max": round(edge + above / SPM, 2)}
|
||||||
|
|
||||||
|
|
||||||
|
def ladder_z(edge, below, above):
|
||||||
|
"""The focus ladder the daemon builds: twelve whole half-steps from
|
||||||
|
the top of the window to the bottom."""
|
||||||
|
return [round(edge + round(above - (above + below) * i / 11) / SPM, 2) for i in range(12)]
|
||||||
|
|
||||||
|
|
||||||
|
# The window the focus card finds and writes before its controller starts.
|
||||||
|
STOPS = {"lens_stop_below_steps": "14", "lens_stop_above_steps": "20"}
|
||||||
|
FOCUS_WINDOW = {"below": 14, "above": 20, "reach_min": round(EDGE_DEFAULT - 14 / SPM, 2),
|
||||||
|
"reach_max": round(EDGE_DEFAULT + 20 / SPM, 2)}
|
||||||
|
|
||||||
# Each wizard as the daemon runs it: its prompts in order (id, kind,
|
# Each wizard as the daemon runs it: its prompts in order (id, kind,
|
||||||
# options) and the result it ends with. A "jog" prompt repeats until the
|
# options) and the result it ends with. A "jog" prompt repeats until the
|
||||||
@@ -37,7 +74,9 @@ SCRIPT = {
|
|||||||
"steps_per_mm": 2.922, "max_height_mm": 13.81,
|
"steps_per_mm": 2.922, "max_height_mm": 13.81,
|
||||||
"focus_range_mm": {"min": 2.17, "max": 13.81},
|
"focus_range_mm": {"min": 2.17, "max": 13.81},
|
||||||
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
|
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
|
||||||
"contact_above": 22, "why": ""}, "emission": EMISSION}),
|
"contact_above": 22, "why": ""},
|
||||||
|
"window": dict(FOCUS_WINDOW), "ladder_z": ladder_z(EDGE_DEFAULT, 14, 20),
|
||||||
|
"emission": EMISSION}),
|
||||||
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
|
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
|
||||||
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
|
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
|
||||||
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
|
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
|
||||||
@@ -50,10 +89,11 @@ SCRIPT = {
|
|||||||
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
|
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
|
||||||
"k_cw": 2.78e-5, "emission": EMISSION}),
|
"k_cw": 2.78e-5, "emission": EMISSION}),
|
||||||
}
|
}
|
||||||
# What each card writes when it ends.
|
# What a card writes when it starts (the focus card's window, before its
|
||||||
|
# controller starts) and when it ends.
|
||||||
|
START_WRITES = {"laser.focus": dict(STOPS)}
|
||||||
WRITES = {
|
WRITES = {
|
||||||
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14",
|
"laser.focus": {"lens_hall_edge_z_mm": "6.96"},
|
||||||
"lens_stop_above_steps": "20"},
|
|
||||||
"laser.floor": {"laser_floor_density": "10"},
|
"laser.floor": {"laser_floor_density": "10"},
|
||||||
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
|
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
|
||||||
"laser.corner": {"laser_corner_gamma": "1.5"},
|
"laser.corner": {"laser_corner_gamma": "1.5"},
|
||||||
@@ -67,7 +107,7 @@ FOUND = {"lens_hall_edge_z_mm": "3.35", "lens_stop_below_steps": "10", "lens_sto
|
|||||||
|
|
||||||
class RegistrationTests(unittest.TestCase):
|
class RegistrationTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.t = catalog.load_suite()["commission.sheet"]
|
self.t = catalog.load_suite()["setup.sheet"]
|
||||||
|
|
||||||
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
|
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
|
||||||
t = self.t
|
t = self.t
|
||||||
@@ -78,12 +118,12 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
self.assertTrue(t.hands)
|
self.assertTrue(t.hands)
|
||||||
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
|
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
|
||||||
self.assertIsNone(getattr(t, "precheck", None))
|
self.assertIsNone(getattr(t, "precheck", None))
|
||||||
for want in ("commission.check-motion", "laser.emission-witness", "cooling.flow-verify"):
|
for want in ("setup.check-motion", "laser.emission-witness", "cooling.flow-verify"):
|
||||||
self.assertIn(want, t.requires)
|
self.assertIn(want, t.requires)
|
||||||
|
|
||||||
def test_no_other_sheet_test_remains(self):
|
def test_no_other_sheet_test_remains(self):
|
||||||
ids = [i for i in catalog.load_suite() if i.startswith("commission.sheet")]
|
ids = [i for i in catalog.load_suite() if i.startswith("setup.sheet")]
|
||||||
self.assertEqual(ids, ["commission.sheet"])
|
self.assertEqual(ids, ["setup.sheet"])
|
||||||
|
|
||||||
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
|
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
|
||||||
covers = set(self.t.covers)
|
covers = set(self.t.covers)
|
||||||
@@ -97,7 +137,7 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
text = " ".join(self.t.steps).lower()
|
text = " ".join(self.t.steps).lower()
|
||||||
self.assertIn("press the machine's button once", text)
|
self.assertIn("press the machine's button once", text)
|
||||||
self.assertNotIn("when the page", text)
|
self.assertNotIn("when the page", text)
|
||||||
self.assertEqual(len(commission_sheet.CARDS), 6)
|
self.assertEqual(len(setup_sheet.CARDS), 6)
|
||||||
|
|
||||||
|
|
||||||
class ScriptedDaemon:
|
class ScriptedDaemon:
|
||||||
@@ -115,9 +155,11 @@ class ScriptedDaemon:
|
|||||||
self.versions = {}
|
self.versions = {}
|
||||||
self.answers = {} # wid -> [values]
|
self.answers = {} # wid -> [values]
|
||||||
self.order = [] # the wizards as started
|
self.order = [] # the wizards as started
|
||||||
|
self.settings_at_start = {} # wid -> the settings as the wizard found them
|
||||||
self.previews = []
|
self.previews = []
|
||||||
self.queue = []
|
self.queue = []
|
||||||
self.seq = 0
|
self.seq = 0
|
||||||
|
self.stray_z = None # a Z the served program commands beyond the reach, when set
|
||||||
fake.on_get = self.on_get
|
fake.on_get = self.on_get
|
||||||
fake.on_post = self.on_post
|
fake.on_post = self.on_post
|
||||||
|
|
||||||
@@ -130,6 +172,10 @@ class ScriptedDaemon:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def on_get(self, path, q):
|
def on_get(self, path, q):
|
||||||
|
s = self.fake.state["settings"]
|
||||||
|
if path == "/status":
|
||||||
|
# the lens block follows the settings, as the daemon's does
|
||||||
|
return 200, dict(self.fake.state["status"], lens=lens_block(s))
|
||||||
if path == "/wiz/dark":
|
if path == "/wiz/dark":
|
||||||
return 200, dict(self.dark)
|
return 200, dict(self.dark)
|
||||||
if path == "/wiz":
|
if path == "/wiz":
|
||||||
@@ -140,8 +186,18 @@ class ScriptedDaemon:
|
|||||||
return 404, {"error": "no such card"}
|
return 404, {"error": "no such card"}
|
||||||
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
|
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
|
||||||
if path == "/wiz/sheet.gcode":
|
if path == "/wiz/sheet.gcode":
|
||||||
# the frame and the text are M4 at the mark dose, as the daemon serves them
|
# the frame and the text are M4 at the mark dose, as the daemon
|
||||||
return 200, b"; mock\nM4 S400\nG0 X60 Y42\nG1 X120 Y42 F3000\nM5\n", "text/plain"
|
# serves them; the head's Z is the edge, the tail goes back to
|
||||||
|
# it, and the focus card's ladder spans the window the settings
|
||||||
|
# hold now
|
||||||
|
lens = lens_block(s)
|
||||||
|
lines = ["; mock", "G0 Z%.2f" % lens["edge_z"], "M4 S400", "G0 X60 Y42", "G1 X120 Y42 F3000"]
|
||||||
|
if q.get("card") == "laser.focus":
|
||||||
|
lines += ["G0 Z%.2f" % z for z in ladder_z(lens["edge_z"], lens["below"], lens["above"])]
|
||||||
|
if self.stray_z is not None:
|
||||||
|
lines.append("G0 Z%.2f" % self.stray_z)
|
||||||
|
lines += ["M5", "G0 Z%.2f" % lens["edge_z"], "G0 X0 Y0", "M2"]
|
||||||
|
return 200, ("\n".join(lines) + "\n").encode(), "text/plain"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def on_post(self, path, form):
|
def on_post(self, path, form):
|
||||||
@@ -151,6 +207,8 @@ class ScriptedDaemon:
|
|||||||
if wid not in SCRIPT:
|
if wid not in SCRIPT:
|
||||||
return 404, {"error": "no such wizard"}
|
return 404, {"error": "no such wizard"}
|
||||||
self.order.append(wid)
|
self.order.append(wid)
|
||||||
|
self.settings_at_start[wid] = dict(s)
|
||||||
|
s.update(START_WRITES.get(wid, {}))
|
||||||
self.queue = list(SCRIPT[wid][0])
|
self.queue = list(SCRIPT[wid][0])
|
||||||
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
|
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
|
||||||
self.open_next()
|
self.open_next()
|
||||||
@@ -188,7 +246,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
self.fake.stop()
|
self.fake.stop()
|
||||||
|
|
||||||
def test_the_whole_sheet_on_one_piece(self):
|
def test_the_whole_sheet_on_one_piece(self):
|
||||||
t = catalog.load_suite()["commission.sheet"]
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
run.unattended = True # the ready gate passes; the arm cue is a notice
|
run.unattended = True # the ready gate passes; the arm cue is a notice
|
||||||
t.fn(Context(run, None, t))
|
t.fn(Context(run, None, t))
|
||||||
@@ -199,7 +257,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
|
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
|
||||||
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
|
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
|
||||||
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
|
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
|
||||||
self.assertEqual(d.previews, LIVE)
|
self.assertEqual(d.previews, PREVIEWS)
|
||||||
cards = run.evidence["cards"]
|
cards = run.evidence["cards"]
|
||||||
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
|
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
|
||||||
for wid in LIVE:
|
for wid in LIVE:
|
||||||
@@ -208,6 +266,24 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
self.assertNotIn("emission", run.evidence)
|
self.assertNotIn("emission", run.evidence)
|
||||||
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
|
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
|
||||||
self.assertEqual(run.evidence["settings_before"], FOUND)
|
self.assertEqual(run.evidence["settings_before"], FOUND)
|
||||||
|
# A fresh machine's run: the lens settings were gone before the
|
||||||
|
# frame, the frame's program ran in the fallback window, the focus
|
||||||
|
# card's window was in the settings when the floor card started,
|
||||||
|
# and the window check filed what it compared.
|
||||||
|
for k in setup_sheet.LENS_SETTINGS:
|
||||||
|
self.assertNotIn(k, d.settings_at_start["sheet.frame"], k)
|
||||||
|
self.assertEqual(run.evidence["lens_fresh"]["stops_found"], False)
|
||||||
|
self.assertEqual(run.evidence["program_z"]["sheet.frame"]["reach"], [-0.07, 7.46])
|
||||||
|
floor_start = d.settings_at_start["laser.floor"]
|
||||||
|
self.assertEqual({k: floor_start[k] for k in setup_sheet.LENS_SETTINGS},
|
||||||
|
{"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14", "lens_stop_above_steps": "20"})
|
||||||
|
fw = run.evidence["focus_window"]
|
||||||
|
self.assertEqual(fw["window"], FOCUS_WINDOW)
|
||||||
|
self.assertEqual(fw["settings"]["lens_stop_below_steps"], "14")
|
||||||
|
self.assertEqual(len(fw["ladder_z"]), 12)
|
||||||
|
self.assertEqual(fw["program_z_before"]["reach"], [-0.07, 7.46]) # the fallback ladder
|
||||||
|
self.assertEqual(fw["program_z_after"]["reach"], [2.17, 13.8]) # the written window at the new edge
|
||||||
|
self.assertEqual(len(fw["program_z_after"]["z"]), 14) # head, twelve lines, tail
|
||||||
for k, v in FOUND.items():
|
for k, v in FOUND.items():
|
||||||
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
|
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
|
||||||
|
|
||||||
@@ -218,7 +294,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
place_prompts, place_result = SCRIPT["sheet.place"]
|
place_prompts, place_result = SCRIPT["sheet.place"]
|
||||||
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=3.175))
|
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=3.175))
|
||||||
try:
|
try:
|
||||||
t = catalog.load_suite()["commission.sheet"]
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
run.unattended = True
|
run.unattended = True
|
||||||
t.fn(Context(run, None, t))
|
t.fn(Context(run, None, t))
|
||||||
@@ -234,7 +310,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
place_prompts, place_result = SCRIPT["sheet.place"]
|
place_prompts, place_result = SCRIPT["sheet.place"]
|
||||||
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=0.0))
|
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=0.0))
|
||||||
try:
|
try:
|
||||||
t = catalog.load_suite()["commission.sheet"]
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
run.unattended = True
|
run.unattended = True
|
||||||
with self.assertRaises(Failed) as cm:
|
with self.assertRaises(Failed) as cm:
|
||||||
@@ -250,7 +326,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
SCRIPT_DARK["emission"] = {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0}
|
SCRIPT_DARK["emission"] = {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0}
|
||||||
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], SCRIPT_DARK)
|
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], SCRIPT_DARK)
|
||||||
try:
|
try:
|
||||||
t = catalog.load_suite()["commission.sheet"]
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
run.unattended = True
|
run.unattended = True
|
||||||
with self.assertRaises(Failed):
|
with self.assertRaises(Failed):
|
||||||
@@ -261,6 +337,44 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
|
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
|
||||||
|
|
||||||
|
def test_a_focus_window_the_settings_do_not_hold_fails_the_run(self):
|
||||||
|
# The defect the bench found: the focus card burned its ladder over
|
||||||
|
# the stops it found while the controller's Z limit stood on the
|
||||||
|
# settings, which did not hold them. A daemon whose focus result
|
||||||
|
# names a window the settings do not hold fails the run before the
|
||||||
|
# floor card, and the settings still go back.
|
||||||
|
from forgetest.runner import Failed
|
||||||
|
saved = dict(START_WRITES["laser.focus"])
|
||||||
|
START_WRITES["laser.focus"] = {}
|
||||||
|
try:
|
||||||
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
run.unattended = True
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
self.assertIn("the settings hold", str(cm.exception))
|
||||||
|
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus"])
|
||||||
|
for k, v in FOUND.items():
|
||||||
|
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||||
|
finally:
|
||||||
|
START_WRITES["laser.focus"] = saved
|
||||||
|
|
||||||
|
def test_a_program_z_beyond_the_reach_fails_before_the_burn(self):
|
||||||
|
# A served program that commands a Z the lens does not reach is
|
||||||
|
# refused before the card starts: nothing burns.
|
||||||
|
from forgetest.runner import Failed
|
||||||
|
self.daemon.stray_z = 10.6
|
||||||
|
t = catalog.load_suite()["setup.sheet"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
run.unattended = True
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
self.assertIn("outside the lens window", str(cm.exception))
|
||||||
|
self.assertIn("10.6", str(cm.exception))
|
||||||
|
self.assertEqual(self.daemon.order, ["sheet.place"])
|
||||||
|
for k, v in FOUND.items():
|
||||||
|
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""The commission.* suite on the host: the registration (ids, kinds, the
|
"""The setup.* suite on the host: the registration (ids, kinds, the
|
||||||
takeover tests, the operator tests' hands), the record builders, the
|
takeover tests, the operator tests' hands), the record builders, the
|
||||||
machine's name, the cookie parsing, the LED cue, the settle rule for a
|
machine's name, the cookie parsing, the LED cue, the settle rule for a
|
||||||
gated supervisor, and the cloud-off surface test driven end to end
|
gated supervisor, and the cloud-off surface test driven end to end
|
||||||
@@ -12,15 +12,15 @@ import unittest
|
|||||||
import helpers
|
import helpers
|
||||||
from forgetest import baseline, catalog
|
from forgetest import baseline, catalog
|
||||||
from forgetest.runner import Context, Run
|
from forgetest.runner import Context, Run
|
||||||
from forgetest.suite import commission
|
from forgetest.suite import setup
|
||||||
|
|
||||||
IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot",
|
IDS = ("setup.gate-blocks-controllers", "setup.override-until-reboot",
|
||||||
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes",
|
"setup.advisories-rehash", "setup.account-login", "setup.https-only-writes",
|
||||||
"commission.ssh-until-reboot", "commission.cloud-disabled-surface",
|
"setup.ssh-until-reboot", "setup.cloud-disabled-surface",
|
||||||
"commission.factory-return", "commission.machine-name", "commission.first-run-flow",
|
"setup.factory-return", "setup.machine-name", "setup.first-run-flow",
|
||||||
"commission.first-run-page", "commission.what-changed", "commission.record-export",
|
"setup.first-run-page", "setup.what-changed", "setup.record-export",
|
||||||
"commission.mirror")
|
"setup.mirror")
|
||||||
OPERATOR = ("commission.first-run-flow", "commission.first-run-page")
|
OPERATOR = ("setup.first-run-flow", "setup.first-run-page")
|
||||||
|
|
||||||
|
|
||||||
class RegistrationTests(unittest.TestCase):
|
class RegistrationTests(unittest.TestCase):
|
||||||
@@ -35,49 +35,49 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
for tid in IDS:
|
for tid in IDS:
|
||||||
if tid not in OPERATOR:
|
if tid not in OPERATOR:
|
||||||
self.assertEqual(kinds[tid], "auto", tid)
|
self.assertEqual(kinds[tid], "auto", tid)
|
||||||
self.assertNotIn("commission.root-ssh-refused", self.reg) # folded into ssh-until-reboot
|
self.assertNotIn("setup.root-ssh-refused", self.reg) # folded into ssh-until-reboot
|
||||||
self.assertNotIn("commission.wizard-first-run", self.reg) # split into the flow and the page
|
self.assertNotIn("setup.wizard-first-run", self.reg) # split into the flow and the page
|
||||||
|
|
||||||
def test_the_record_swapping_tests_are_takeovers(self):
|
def test_the_record_swapping_tests_are_takeovers(self):
|
||||||
for tid in ("commission.gate-blocks-controllers", "commission.advisories-rehash",
|
for tid in ("setup.gate-blocks-controllers", "setup.advisories-rehash",
|
||||||
"commission.first-run-flow", "commission.first-run-page",
|
"setup.first-run-flow", "setup.first-run-page",
|
||||||
"commission.what-changed", "commission.mirror"):
|
"setup.what-changed", "setup.mirror"):
|
||||||
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||||
self.assertEqual(self.reg["commission.record-export"].hardware, "api")
|
self.assertEqual(self.reg["setup.record-export"].hardware, "api")
|
||||||
|
|
||||||
def test_the_lifecycle_tests_cover_their_files(self):
|
def test_the_lifecycle_tests_cover_their_files(self):
|
||||||
changed = set(self.reg["commission.what-changed"].covers)
|
changed = set(self.reg["setup.what-changed"].covers)
|
||||||
export = set(self.reg["commission.record-export"].covers)
|
export = set(self.reg["setup.record-export"].covers)
|
||||||
mirror = set(self.reg["commission.mirror"].covers)
|
mirror = set(self.reg["setup.mirror"].covers)
|
||||||
self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
|
self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
|
||||||
self.assertIn(("forgectrl", "src/recordhtml.*"), export)
|
self.assertIn(("forgectrl", "src/recordhtml.*"), export)
|
||||||
self.assertIn(("forgectrl", "src/logs.*"), export)
|
self.assertIn(("forgectrl", "src/logs.*"), export)
|
||||||
self.assertIn(("forgectrl", "src/wizdark.*"), mirror)
|
self.assertIn(("forgectrl", "src/wizdark.*"), mirror)
|
||||||
self.assertIn("commission.check-sensors", self.reg["commission.mirror"].requires)
|
self.assertIn("setup.check-sensors", self.reg["setup.mirror"].requires)
|
||||||
|
|
||||||
def test_flag_of_reads_the_status_document(self):
|
def test_flag_of_reads_the_status_document(self):
|
||||||
w = {"required": ["airflow", {"id": "laser.floor", "reason": "the tube was replaced"}],
|
w = {"required": ["airflow", {"id": "laser.floor", "reason": "the tube was replaced"}],
|
||||||
"recommended": [{"id": "laser.focus", "reason": "the tray was replaced"}]}
|
"recommended": [{"id": "laser.focus", "reason": "the tray was replaced"}]}
|
||||||
self.assertEqual(commission.flag_of(w, "required", "laser.floor"), "the tube was replaced")
|
self.assertEqual(setup.flag_of(w, "required", "laser.floor"), "the tube was replaced")
|
||||||
self.assertEqual(commission.flag_of(w, "recommended", "laser.focus"), "the tray was replaced")
|
self.assertEqual(setup.flag_of(w, "recommended", "laser.focus"), "the tray was replaced")
|
||||||
self.assertIsNone(commission.flag_of(w, "required", "airflow")) # a plain id is the table, not a flag
|
self.assertIsNone(setup.flag_of(w, "required", "airflow")) # a plain id is the table, not a flag
|
||||||
self.assertIsNone(commission.flag_of(w, "required", "laser.focus"))
|
self.assertIsNone(setup.flag_of(w, "required", "laser.focus"))
|
||||||
self.assertIsNone(commission.flag_of({}, "recommended", "x"))
|
self.assertIsNone(setup.flag_of({}, "recommended", "x"))
|
||||||
|
|
||||||
def test_the_first_run_flow_runs_unattended_with_the_fixture_and_the_page_walk_does_not(self):
|
def test_the_first_run_flow_runs_unattended_with_the_fixture_and_the_page_walk_does_not(self):
|
||||||
flow = self.reg["commission.first-run-flow"]
|
flow = self.reg["setup.first-run-flow"]
|
||||||
self.assertEqual(list(flow.actions), ["button"])
|
self.assertEqual(list(flow.actions), ["button"])
|
||||||
self.assertFalse(flow.hands)
|
self.assertFalse(flow.hands)
|
||||||
self.assertTrue(flow.fixture_runnable(("button", "lid", "interlock")))
|
self.assertTrue(flow.fixture_runnable(("button", "lid", "interlock")))
|
||||||
page = self.reg["commission.first-run-page"]
|
page = self.reg["setup.first-run-page"]
|
||||||
self.assertTrue(page.hands)
|
self.assertTrue(page.hands)
|
||||||
self.assertFalse(page.fixture_runnable(("button", "lid", "interlock")))
|
self.assertFalse(page.fixture_runnable(("button", "lid", "interlock")))
|
||||||
self.assertIn("commission.first-run-flow", page.requires)
|
self.assertIn("setup.first-run-flow", page.requires)
|
||||||
|
|
||||||
def test_the_first_run_split_keeps_the_backend_and_the_page_apart(self):
|
def test_the_first_run_split_keeps_the_backend_and_the_page_apart(self):
|
||||||
flow = set(self.reg["commission.first-run-flow"].covers)
|
flow = set(self.reg["setup.first-run-flow"].covers)
|
||||||
page = set(self.reg["commission.first-run-page"].covers)
|
page = set(self.reg["setup.first-run-page"].covers)
|
||||||
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"),
|
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/setup.*"), ("forgectrl", "src/users.*"),
|
||||||
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
|
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
|
||||||
self.assertIn(want, flow, want)
|
self.assertIn(want, flow, want)
|
||||||
self.assertNotIn(want, page, want)
|
self.assertNotIn(want, page, want)
|
||||||
@@ -85,17 +85,17 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
self.assertIn(want, page, want)
|
self.assertIn(want, page, want)
|
||||||
|
|
||||||
def test_the_only_attended_tests_need_a_workstation_or_the_sheet(self):
|
def test_the_only_attended_tests_need_a_workstation_or_the_sheet(self):
|
||||||
# With the bench actuator up, three commissioning tests keep a
|
# With the bench actuator up, three setup tests keep a
|
||||||
# person: the page walk, the print from the Glowforge app, and
|
# person: the page walk, the print from the Glowforge app, and
|
||||||
# the sheet. Everything else runs from the queue.
|
# the sheet. Everything else runs from the queue.
|
||||||
attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("commission.")
|
attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("setup.")
|
||||||
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
|
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
|
||||||
self.assertEqual(attended, ["commission.cloud-header-capture", "commission.first-run-page",
|
self.assertEqual(attended, ["setup.cloud-header-capture", "setup.first-run-page",
|
||||||
"commission.sheet"])
|
"setup.sheet"])
|
||||||
|
|
||||||
def test_the_factory_return_never_runs_the_return(self):
|
def test_the_factory_return_never_runs_the_return(self):
|
||||||
import inspect
|
import inspect
|
||||||
t = self.reg["commission.factory-return"]
|
t = self.reg["setup.factory-return"]
|
||||||
src = inspect.getsource(t.fn)
|
src = inspect.getsource(t.fn)
|
||||||
self.assertNotIn('"confirm": "1"', src) # the only argument that starts the return
|
self.assertNotIn('"confirm": "1"', src) # the only argument that starts the return
|
||||||
self.assertIn('"confirm": "0"', src)
|
self.assertIn('"confirm": "0"', src)
|
||||||
@@ -103,15 +103,15 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
self.assertFalse(t.hands)
|
self.assertFalse(t.hands)
|
||||||
|
|
||||||
def test_the_machine_name_covers_nothing_by_design(self):
|
def test_the_machine_name_covers_nothing_by_design(self):
|
||||||
self.assertEqual(self.reg["commission.machine-name"].covers, ())
|
self.assertEqual(self.reg["setup.machine-name"].covers, ())
|
||||||
|
|
||||||
def test_the_login_test_makes_its_own_account(self):
|
def test_the_login_test_makes_its_own_account(self):
|
||||||
# No bench credentials, no precheck: the test installs a temporary
|
# No bench credentials, no precheck: the test installs a temporary
|
||||||
# account under a takeover and restores the real one.
|
# account under a takeover and restores the real one.
|
||||||
t = self.reg["commission.account-login"]
|
t = self.reg["setup.account-login"]
|
||||||
self.assertIsNone(getattr(t, "precheck", None))
|
self.assertIsNone(getattr(t, "precheck", None))
|
||||||
self.assertEqual(t.hardware, "takeover")
|
self.assertEqual(t.hardware, "takeover")
|
||||||
with open(commission.__file__) as f:
|
with open(setup.__file__) as f:
|
||||||
src = f.read()
|
src = f.read()
|
||||||
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
|
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
|
||||||
self.assertNotIn(k, src)
|
self.assertNotIn(k, src)
|
||||||
@@ -124,8 +124,8 @@ class RecordTests(unittest.TestCase):
|
|||||||
"wizards": [{"id": "advisories", "version": 1}, {"id": "account", "version": 1},
|
"wizards": [{"id": "advisories", "version": 1}, {"id": "account", "version": 1},
|
||||||
{"id": "machine", "version": 2}]}
|
{"id": "machine", "version": 2}]}
|
||||||
|
|
||||||
def test_complete_record_counts_as_commissioned(self):
|
def test_complete_record_counts_as_set_up(self):
|
||||||
rec = commission.complete_record(self.STATUS)
|
rec = setup.complete_record(self.STATUS)
|
||||||
self.assertEqual(rec["schema"], 1)
|
self.assertEqual(rec["schema"], 1)
|
||||||
self.assertEqual(rec["advisories"]["safety-and-risk"]["hash"], "a" * 64)
|
self.assertEqual(rec["advisories"]["safety-and-risk"]["hash"], "a" * 64)
|
||||||
self.assertEqual(rec["advisories"]["safety-and-risk"]["method"], "typed")
|
self.assertEqual(rec["advisories"]["safety-and-risk"]["method"], "typed")
|
||||||
@@ -139,16 +139,16 @@ class RecordTests(unittest.TestCase):
|
|||||||
def test_complete_record_keeps_the_base_account_and_machine(self):
|
def test_complete_record_keeps_the_base_account_and_machine(self):
|
||||||
base = {"account": {"name": "owner", "uid": 1000, "created": "x"}, "machine": {"model": "pro"},
|
base = {"account": {"name": "owner", "uid": 1000, "created": "x"}, "machine": {"model": "pro"},
|
||||||
"sheet_id": "KKKKK-LLLLL", "created": "then"}
|
"sheet_id": "KKKKK-LLLLL", "created": "then"}
|
||||||
rec = commission.complete_record(self.STATUS, base)
|
rec = setup.complete_record(self.STATUS, base)
|
||||||
self.assertEqual(rec["account"]["name"], "owner")
|
self.assertEqual(rec["account"]["name"], "owner")
|
||||||
self.assertEqual(rec["machine"], {"model": "pro"})
|
self.assertEqual(rec["machine"], {"model": "pro"})
|
||||||
self.assertEqual(rec["sheet_id"], "KKKKK-LLLLL")
|
self.assertEqual(rec["sheet_id"], "KKKKK-LLLLL")
|
||||||
self.assertEqual(rec["created"], "then")
|
self.assertEqual(rec["created"], "then")
|
||||||
|
|
||||||
def test_without_wizards_keeps_consent_and_account(self):
|
def test_without_wizards_keeps_consent_and_account(self):
|
||||||
rec = commission.complete_record(self.STATUS)
|
rec = setup.complete_record(self.STATUS)
|
||||||
rec["flags"] = {"machine": {"level": "required", "reason": "x"}}
|
rec["flags"] = {"machine": {"level": "required", "reason": "x"}}
|
||||||
out = commission.record_without_wizards(rec)
|
out = setup.record_without_wizards(rec)
|
||||||
self.assertEqual(out["wizards"], {})
|
self.assertEqual(out["wizards"], {})
|
||||||
self.assertEqual(out["flags"], {})
|
self.assertEqual(out["flags"], {})
|
||||||
self.assertEqual(out["advisories"], rec["advisories"])
|
self.assertEqual(out["advisories"], rec["advisories"])
|
||||||
@@ -158,14 +158,14 @@ class RecordTests(unittest.TestCase):
|
|||||||
def test_write_file_and_remove(self):
|
def test_write_file_and_remove(self):
|
||||||
tmp = tempfile.mkdtemp(prefix="forgetest-comm-")
|
tmp = tempfile.mkdtemp(prefix="forgetest-comm-")
|
||||||
try:
|
try:
|
||||||
p = os.path.join(tmp, "sub", "commissioning.json")
|
p = os.path.join(tmp, "sub", "setup.json")
|
||||||
commission.write_file(p, b"{}\n")
|
setup.write_file(p, b"{}\n")
|
||||||
self.assertEqual(commission.read_file(p), b"{}\n")
|
self.assertEqual(setup.read_file(p), b"{}\n")
|
||||||
if os.name == "posix": # a mode means nothing on a Windows host
|
if os.name == "posix": # a mode means nothing on a Windows host
|
||||||
self.assertEqual(oct(os.stat(p).st_mode & 0o777), oct(0o600))
|
self.assertEqual(oct(os.stat(p).st_mode & 0o777), oct(0o600))
|
||||||
commission.write_file(p, None)
|
setup.write_file(p, None)
|
||||||
self.assertIsNone(commission.read_file(p))
|
self.assertIsNone(setup.read_file(p))
|
||||||
commission.write_file(p, None) # a second remove is silent
|
setup.write_file(p, None) # a second remove is silent
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmp, ignore_errors=True)
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
@@ -173,10 +173,10 @@ class RecordTests(unittest.TestCase):
|
|||||||
os.environ["FORGECTRL_DATA_DIR"] = "/tmp/ffdata"
|
os.environ["FORGECTRL_DATA_DIR"] = "/tmp/ffdata"
|
||||||
os.environ["GF_RUN_DIR"] = "/tmp/ffrun"
|
os.environ["GF_RUN_DIR"] = "/tmp/ffrun"
|
||||||
try:
|
try:
|
||||||
self.assertEqual(commission.record_path(), "/tmp/ffdata/commissioning.json")
|
self.assertEqual(setup.record_path(), "/tmp/ffdata/setup.json")
|
||||||
self.assertEqual(commission.users_path(), "/tmp/ffdata/users")
|
self.assertEqual(setup.users_path(), "/tmp/ffdata/users")
|
||||||
self.assertEqual(commission.override_path(), "/tmp/ffrun/commissioning-override")
|
self.assertEqual(setup.override_path(), "/tmp/ffrun/setup-override")
|
||||||
self.assertEqual(commission.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
|
self.assertEqual(setup.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
|
||||||
finally:
|
finally:
|
||||||
os.environ.pop("FORGECTRL_DATA_DIR", None)
|
os.environ.pop("FORGECTRL_DATA_DIR", None)
|
||||||
os.environ.pop("GF_RUN_DIR", None)
|
os.environ.pop("GF_RUN_DIR", None)
|
||||||
@@ -188,14 +188,14 @@ class MachineNameTests(unittest.TestCase):
|
|||||||
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||||
|
|
||||||
def _net(self, **devs):
|
def _net(self, **devs):
|
||||||
"""A /sys/class/net tree; commission.mac_suffix reads it through
|
"""A /sys/class/net tree; setup.mac_suffix reads it through
|
||||||
commission.read_file, which takes an absolute path."""
|
setup.read_file, which takes an absolute path."""
|
||||||
for dev, mac in devs.items():
|
for dev, mac in devs.items():
|
||||||
d = os.path.join(self.root, dev)
|
d = os.path.join(self.root, dev)
|
||||||
os.makedirs(d)
|
os.makedirs(d)
|
||||||
with open(os.path.join(d, "address"), "w") as f:
|
with open(os.path.join(d, "address"), "w") as f:
|
||||||
f.write(mac + "\n")
|
f.write(mac + "\n")
|
||||||
real = commission.read_file
|
real = setup.read_file
|
||||||
|
|
||||||
def read(path):
|
def read(path):
|
||||||
head = "/sys/class/net/"
|
head = "/sys/class/net/"
|
||||||
@@ -203,52 +203,52 @@ class MachineNameTests(unittest.TestCase):
|
|||||||
return real(os.path.join(self.root, path[len(head):]))
|
return real(os.path.join(self.root, path[len(head):]))
|
||||||
return real(path)
|
return real(path)
|
||||||
|
|
||||||
commission.read_file = read
|
setup.read_file = read
|
||||||
self.addCleanup(setattr, commission, "read_file", real)
|
self.addCleanup(setattr, setup, "read_file", real)
|
||||||
|
|
||||||
def test_the_wifi_address_names_the_machine(self):
|
def test_the_wifi_address_names_the_machine(self):
|
||||||
self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
|
self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
|
||||||
self.assertEqual(commission.mac_suffix(), "b00a")
|
self.assertEqual(setup.mac_suffix(), "b00a")
|
||||||
|
|
||||||
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
|
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
|
||||||
self._net(eth0="00:11:22:33:44:55")
|
self._net(eth0="00:11:22:33:44:55")
|
||||||
self.assertEqual(commission.mac_suffix(), "4455")
|
self.assertEqual(setup.mac_suffix(), "4455")
|
||||||
|
|
||||||
def test_an_unread_address_is_no_address(self):
|
def test_an_unread_address_is_no_address(self):
|
||||||
self._net(wlan0="00:00:00:00:00:00")
|
self._net(wlan0="00:00:00:00:00:00")
|
||||||
self.assertEqual(commission.mac_suffix(), "")
|
self.assertEqual(setup.mac_suffix(), "")
|
||||||
|
|
||||||
def test_no_interface_is_no_address(self):
|
def test_no_interface_is_no_address(self):
|
||||||
self._net()
|
self._net()
|
||||||
self.assertEqual(commission.mac_suffix(), "")
|
self.assertEqual(setup.mac_suffix(), "")
|
||||||
|
|
||||||
|
|
||||||
class SmallHelpersTests(unittest.TestCase):
|
class SmallHelpersTests(unittest.TestCase):
|
||||||
def test_cookie_parsing(self):
|
def test_cookie_parsing(self):
|
||||||
sid = "ab" * 32
|
sid = "ab" * 32
|
||||||
value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid
|
value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid
|
||||||
self.assertEqual(commission.session_from_cookie(value), sid)
|
self.assertEqual(setup.session_from_cookie(value), sid)
|
||||||
self.assertEqual(commission.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
|
self.assertEqual(setup.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
|
||||||
self.assertIsNone(commission.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
|
self.assertIsNone(setup.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
|
||||||
self.assertIsNone(commission.session_from_cookie(""))
|
self.assertIsNone(setup.session_from_cookie(""))
|
||||||
|
|
||||||
def test_the_teal_breathe_cue(self):
|
def test_the_teal_breathe_cue(self):
|
||||||
self.assertTrue(commission.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
|
self.assertTrue(setup.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
|
||||||
"pulse_off": [0, 1400, 1400]}))
|
"pulse_off": [0, 1400, 1400]}))
|
||||||
self.assertFalse(commission.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0],
|
self.assertFalse(setup.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0],
|
||||||
"pulse_off": [0, 0, 0]})) # solid green
|
"pulse_off": [0, 0, 0]})) # solid green
|
||||||
self.assertFalse(commission.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800],
|
self.assertFalse(setup.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800],
|
||||||
"pulse_off": [1800] * 3})) # white
|
"pulse_off": [1800] * 3})) # white
|
||||||
self.assertFalse(commission.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
|
self.assertFalse(setup.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
|
||||||
self.assertFalse(commission.breathes_teal({}))
|
self.assertFalse(setup.breathes_teal({}))
|
||||||
|
|
||||||
def test_decode(self):
|
def test_decode(self):
|
||||||
self.assertEqual(commission.decode(b'{"a": 1}'), {"a": 1})
|
self.assertEqual(setup.decode(b'{"a": 1}'), {"a": 1})
|
||||||
self.assertEqual(commission.decode(b"cloud mode is not enabled"), "cloud mode is not enabled")
|
self.assertEqual(setup.decode(b"cloud mode is not enabled"), "cloud mode is not enabled")
|
||||||
|
|
||||||
|
|
||||||
class GatedSettleTests(unittest.TestCase):
|
class GatedSettleTests(unittest.TestCase):
|
||||||
"""A supervisor that reports the commissioning gate closed is settled:
|
"""A supervisor that reports the setup gate closed is settled:
|
||||||
it spawns nothing until the gate opens, so a takeover that installs
|
it spawns nothing until the gate opens, so a takeover that installs
|
||||||
a gating record must not wait the whole settle timeout for it."""
|
a gating record must not wait the whole settle timeout for it."""
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ class GatedSettleTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_gated_returns_at_once(self):
|
def test_gated_returns_at_once(self):
|
||||||
self.fake.state["mode"] = {"mode": "grbl", "controller": "gated", "pid": 0, "motion": "unverified",
|
self.fake.state["mode"] = {"mode": "grbl", "controller": "gated", "pid": 0, "motion": "unverified",
|
||||||
"gated": True, "why": "commissioning required: machine"}
|
"gated": True, "why": "setup required: machine"}
|
||||||
lines = []
|
lines = []
|
||||||
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
|
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
|
||||||
self.assertEqual(body["controller"], "gated")
|
self.assertEqual(body["controller"], "gated")
|
||||||
@@ -284,7 +284,7 @@ class FirstRunSeedTests(unittest.TestCase):
|
|||||||
"advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"},
|
"advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"},
|
||||||
"account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True},
|
"account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True},
|
||||||
"wizards": {"advisories": {"version": 1}, "switches": {"version": 1, "result": {"lid": True}}}}
|
"wizards": {"advisories": {"version": 1}, "switches": {"version": 1, "result": {"lid": True}}}}
|
||||||
rec = commission.first_run_record(self.STATUS, base)
|
rec = setup.first_run_record(self.STATUS, base)
|
||||||
self.assertNotIn("completed", rec)
|
self.assertNotIn("completed", rec)
|
||||||
self.assertNotIn("acceptance", rec)
|
self.assertNotIn("acceptance", rec)
|
||||||
self.assertNotIn("account", rec)
|
self.assertNotIn("account", rec)
|
||||||
@@ -296,7 +296,7 @@ class FirstRunSeedTests(unittest.TestCase):
|
|||||||
self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version
|
self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version
|
||||||
|
|
||||||
def test_without_a_base_record(self):
|
def test_without_a_base_record(self):
|
||||||
rec = commission.first_run_record(self.STATUS, None)
|
rec = setup.first_run_record(self.STATUS, None)
|
||||||
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
|
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
|
||||||
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
|
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ class SshdPolicyTests(unittest.TestCase):
|
|||||||
real = hw.run
|
real = hw.run
|
||||||
hw.run = fake_run
|
hw.run = fake_run
|
||||||
try:
|
try:
|
||||||
self.assertEqual(commission.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
|
self.assertEqual(setup.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
|
||||||
"permitemptypasswords": "no"})
|
"permitemptypasswords": "no"})
|
||||||
finally:
|
finally:
|
||||||
hw.run = real
|
hw.run = real
|
||||||
@@ -323,7 +323,7 @@ class SshdPolicyTests(unittest.TestCase):
|
|||||||
real = hw.run
|
real = hw.run
|
||||||
hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available")
|
hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available")
|
||||||
try:
|
try:
|
||||||
self.assertEqual(commission.sshd_policy(), {"error": "sshd: no hostkeys available"})
|
self.assertEqual(setup.sshd_policy(), {"error": "sshd: no hostkeys available"})
|
||||||
finally:
|
finally:
|
||||||
hw.run = real
|
hw.run = real
|
||||||
|
|
||||||
@@ -375,7 +375,7 @@ class CloudDisabledSurfaceTests(unittest.TestCase):
|
|||||||
self.fake.stop()
|
self.fake.stop()
|
||||||
|
|
||||||
def run_test(self):
|
def run_test(self):
|
||||||
t = catalog.load_suite()["commission.cloud-disabled-surface"]
|
t = catalog.load_suite()["setup.cloud-disabled-surface"]
|
||||||
run = Run("test", t.id, t.title)
|
run = Run("test", t.id, t.title)
|
||||||
t.fn(Context(run, None, t))
|
t.fn(Context(run, None, t))
|
||||||
return run
|
return run
|
||||||
@@ -11,4 +11,4 @@ overrides:
|
|||||||
meta-freescale-distro:
|
meta-freescale-distro:
|
||||||
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
||||||
meta-openglow:
|
meta-openglow:
|
||||||
commit: ced2af274ba270fd8fc9a6601bbd3fc6247110a6
|
commit: dd8f74c2d58e998913e77e28450e8dca5e164a48
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||||
# layer content hash because the component entry already identifies the
|
# layer content hash because the component entry already identifies the
|
||||||
# pinned source (forgefirm-image-manifest.bbclass).
|
# pinned source (forgefirm-image-manifest.bbclass).
|
||||||
SRCREV = "a9e7073b2325c8c9d89d490fe6d067abfac22da5"
|
SRCREV = "da3eddcc0f4324dc6ec8a0042ab7db0f1b16fd4f"
|
||||||
PV = "0.1.17"
|
PV = "0.1.25"
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
# because the component entry already identifies the pinned source
|
# because the component entry already identifies the pinned source
|
||||||
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
# (forgefirm-image-manifest.bbclass). The python3-gfhardware recipe in
|
||||||
# meta-glowforge-bsp pins the same repository; move both together.
|
# meta-glowforge-bsp pins the same repository; move both together.
|
||||||
SRCREV = "56f134a1e6393a2096c16e8226027fbbd38d79c7"
|
SRCREV = "5ca279a1f6008ddf6e19a17c6d6375972d27ed7c"
|
||||||
|
|
||||||
# Bump PV with every SRCREV move: the hash-derived package version is not
|
# Bump PV with every SRCREV move: the hash-derived package version is not
|
||||||
# monotonic on its own and buildhistory QA fails the build when it sorts
|
# monotonic on its own and buildhistory QA fails the build when it sorts
|
||||||
# backwards.
|
# backwards.
|
||||||
PV = "0.1.28+git"
|
PV = "0.1.29+git"
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# changes; keep only SRCREV and PV here - the image manifest leaves
|
# changes; keep only SRCREV and PV here - the image manifest leaves
|
||||||
# *-pin.inc out of the layer content hash because the component entry
|
# *-pin.inc out of the layer content hash because the component entry
|
||||||
# already identifies the pinned source (forgefirm-image-manifest.bbclass).
|
# already identifies the pinned source (forgefirm-image-manifest.bbclass).
|
||||||
SRCREV = "1f2ea9ca83aa0035260c9db6b21c4fe0873796e0"
|
SRCREV = "ecebe9c8eb8dc220e928f8039a0f922327e42c6f"
|
||||||
PV = "0.1.11"
|
PV = "0.1.17"
|
||||||
|
|||||||
@@ -15,4 +15,4 @@
|
|||||||
# it to equal the rootfs stamp, the .fw meta-version and the release tag,
|
# it to equal the rootfs stamp, the .fw meta-version and the release tag,
|
||||||
# and the acceptance test image.health compares the stamp on the running
|
# and the acceptance test image.health compares the stamp on the running
|
||||||
# machine with the manifest's.
|
# machine with the manifest's.
|
||||||
FORGEFIRM_RELEASE ?= "0.0.2"
|
FORGEFIRM_RELEASE ?= "0.0.5"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
|||||||
|
# ForgeFIRM acceptance - 20260911172215 (dev)
|
||||||
|
|
||||||
|
- Image: `20260911172215 (dev)` (forgefirm-image-dev)
|
||||||
|
- Manifest identity: `fb24c3f4508ae54592f8d0b98ed0b2a07a933274980d8c1e4e5a967618ef9bd8`
|
||||||
|
- Catalog: `ceabc830345fc4cac4832de691c0571e93a5c4e155666ce91601c844c40942fc`
|
||||||
|
- Campaign: `c-20260911182256-078f` opened 2026-09-11T18:22:56Z
|
||||||
|
- Exported: 2026-09-11T18:54:20Z
|
||||||
|
- **Release authorized: YES**
|
||||||
|
- Tests: 83 total, 83 satisfied (57 inherited), 0 required
|
||||||
|
|
||||||
|
| Test | Kind | Result | Run at | Campaign | Inherited |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `image.health` | auto, core | PASS | 2026-09-11T18:22:57Z | `c-20260911182256-078f` | no |
|
||||||
|
| `image.license-bundle` | auto | PASS | 2026-09-11T17:29:26Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-11T18:22:58Z | `c-20260911182256-078f` | no |
|
||||||
|
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-11T18:23:19Z | `c-20260911182256-078f` | no |
|
||||||
|
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-11T18:23:29Z | `c-20260911182256-078f` | no |
|
||||||
|
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-11T18:23:48Z | `c-20260911182256-078f` | no |
|
||||||
|
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-11T18:24:32Z | `c-20260911182256-078f` | no |
|
||||||
|
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-11T17:31:23Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `kernel.pic-soc-load` | auto | PASS | 2026-09-11T17:31:26Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `forgectrl.auth` | auto | PASS | 2026-09-11T17:31:27Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-11T17:31:27Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `forgectrl.panel-serves` | auto | PASS | 2026-09-11T17:29:23Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-11T17:31:46Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.override-until-reboot` | auto | PASS | 2026-09-11T17:31:46Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.advisories-rehash` | auto, takeover | PASS | 2026-09-11T17:31:55Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.account-login` | auto, takeover | PASS | 2026-09-11T17:32:44Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.https-only-writes` | auto | PASS | 2026-09-11T17:32:45Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.ssh-until-reboot` | auto | PASS | 2026-09-11T17:32:46Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.cloud-disabled-surface` | auto | PASS | 2026-09-11T17:32:47Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.factory-return` | auto | PASS | 2026-09-11T17:32:49Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.machine-name` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.first-run-flow` | operator, takeover | PASS | 2026-09-11T17:54:32Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.first-run-page` | operator, takeover | PASS | 2026-09-11T17:55:49Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.cert-page` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.what-changed` | auto, takeover | PASS | 2026-09-11T17:32:59Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.record-export` | auto | PASS | 2026-09-11T17:34:22Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.mirror` | auto, takeover | PASS | 2026-09-11T17:34:54Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.check-switches` | operator | PASS | 2026-09-11T17:56:07Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.check-sensors` | auto | PASS | 2026-09-11T17:34:34Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.check-airflow` | auto, takeover | PASS | 2026-09-11T17:36:26Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.check-cameras` | operator | PASS | 2026-09-11T17:56:22Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.check-motion` | auto, takeover | PASS | 2026-09-11T17:37:06Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.check-flow-verify` | auto, takeover | PASS | 2026-09-11T17:44:42Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.cloud-header-capture` | operator, takeover | PASS | 2026-09-11T17:59:41Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.sheet` | live, takeover | PASS | 2026-09-11T18:17:29Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `logs.tree-tail-export` | auto | PASS | 2026-09-11T17:33:40Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `logs.routing` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `logs.level-settings` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `motion.pacing` | auto | PASS | 2026-09-11T17:36:41Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `motion.jog-roundtrip` | auto | PASS | 2026-09-11T17:45:00Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `motion.microstep-modes` | auto | PASS | 2026-09-11T17:45:25Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `motion.liveness-probe` | auto | PASS | 2026-09-11T17:45:33Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-11T18:24:48Z | `c-20260911182256-078f` | no |
|
||||||
|
| `motion.cancel-abort` | auto | PASS | 2026-09-11T17:45:40Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `motion.deadman` | auto | PASS | 2026-09-11T17:46:09Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `motion.button-hold-resume` | operator | PASS | 2026-09-11T18:24:59Z | `c-20260911182256-078f` | no |
|
||||||
|
| `motion.lid-cancel-home` | operator | PASS | 2026-09-11T18:25:15Z | `c-20260911182256-078f` | no |
|
||||||
|
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-11T18:25:24Z | `c-20260911182256-078f` | no |
|
||||||
|
| `motion.lid-policy-hold` | operator | PASS | 2026-09-11T18:25:37Z | `c-20260911182256-078f` | no |
|
||||||
|
| `motion.step-timing-under-load` | auto | PASS | 2026-09-11T17:46:40Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-11T17:38:38Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `cooling.flow-verify` | auto | PASS | 2026-09-11T17:41:29Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-11T17:47:35Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `cooling.gate-off` | auto | PASS | 2026-09-11T17:35:02Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-11T17:49:02Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `cooling.tec-drive` | auto | PASS | 2026-09-11T17:49:10Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-11T17:49:25Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-11T17:49:32Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `cooling.critical-tier` | auto | PASS | 2026-09-11T17:49:41Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-11T17:35:45Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `cooling.flow-under-load` | live | PASS | 2026-09-11T18:41:47Z | `c-20260911182256-078f` | no |
|
||||||
|
| `laser.power-floor` | auto | PASS | 2026-09-11T17:49:42Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `laser.emission-witness` | live, core | PASS | 2026-09-11T18:40:15Z | `c-20260911182256-078f` | no |
|
||||||
|
| `laser.m5-rapid-dark` | live | PASS | 2026-09-11T18:42:25Z | `c-20260911182256-078f` | no |
|
||||||
|
| `laser.disarm-in-hold` | live | PASS | 2026-09-11T18:43:51Z | `c-20260911182256-078f` | no |
|
||||||
|
| `laser.armed-kill` | live | PASS | 2026-09-11T18:44:43Z | `c-20260911182256-078f` | no |
|
||||||
|
| `laser.arm-wait-lid` | operator | PASS | 2026-09-11T18:25:57Z | `c-20260911182256-078f` | no |
|
||||||
|
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-11T18:45:12Z | `c-20260911182256-078f` | no |
|
||||||
|
| `camera.snapshot` | auto | PASS | 2026-09-11T17:49:57Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `camera.sensor-profile` | auto | PASS | 2026-09-11T17:50:10Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `camera.h264-stream` | auto | PASS | 2026-09-11T17:50:23Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `camera.frame-health` | auto | PASS | 2026-09-11T17:50:36Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `camera.lid-privacy` | operator | PASS | 2026-09-11T18:26:12Z | `c-20260911182256-078f` | no |
|
||||||
|
| `camera.key-read` | auto | PASS | 2026-09-11T17:50:37Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `update.slots-and-signature` | auto | PASS | 2026-09-11T17:32:49Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `cloud.mode-switch` | operator | PASS | 2026-09-11T17:58:28Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `cloud.service-protocol` | operator | PASS | 2026-09-11T18:46:13Z | `c-20260911182256-078f` | no |
|
||||||
|
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-11T18:47:21Z | `c-20260911182256-078f` | no |
|
||||||
|
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-11T18:26:51Z | `c-20260911182256-078f` | no |
|
||||||
|
| `cloud.verdict-hold` | operator | PASS | 2026-09-11T18:38:58Z | `c-20260911182256-078f` | no |
|
||||||
|
| `cloud.pause-resume` | live | PASS | 2026-09-11T18:49:33Z | `c-20260911182256-078f` | no |
|
||||||
|
| `cloud.oversize-stream` | live | PASS | 2026-09-11T18:52:19Z | `c-20260911182256-078f` | no |
|
||||||
|
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-11T18:52:48Z | `c-20260911182256-078f` | no |
|
||||||
|
|
||||||
|
Artifact sha256: `d7bb5d900f9237711a17ae207df90025605340c2eb2eaf34f9ed508cc3a899a6`
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
|||||||
|
# ForgeFIRM acceptance - 20260912180956 (dev)
|
||||||
|
|
||||||
|
- Image: `20260912180956 (dev)` (forgefirm-image-dev)
|
||||||
|
- Manifest identity: `8bb5b6f39ca4e0a5afca9b620aa38980c4ede8cdfda240b24bbc01fd6c28fa05`
|
||||||
|
- Catalog: `f906de649b7533e3713309f141afacbc06420f96824936a60b70febd71bb2df7`
|
||||||
|
- Campaign: `c-20260912182427-e1ee` opened 2026-09-12T18:24:27Z
|
||||||
|
- Exported: 2026-09-12T19:21:35Z
|
||||||
|
- **Release authorized: YES**
|
||||||
|
- Tests: 85 total, 85 satisfied (50 inherited), 0 required
|
||||||
|
|
||||||
|
| Test | Kind | Result | Run at | Campaign | Inherited |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `image.health` | auto, core | PASS | 2026-09-12T18:24:28Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `image.license-bundle` | auto | PASS | 2026-09-12T16:56:02Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-12T18:24:29Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-12T18:24:50Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-12T18:25:02Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-12T18:25:21Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-12T18:26:04Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-11T17:31:23Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `kernel.pic-soc-load` | auto | PASS | 2026-09-11T17:31:26Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `forgectrl.auth` | auto | PASS | 2026-09-12T16:58:12Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-12T16:58:19Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `forgectrl.panel-serves` | auto | PASS | 2026-09-12T16:55:54Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `commission.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-12T16:58:44Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `commission.override-until-reboot` | auto | PASS | 2026-09-12T16:58:44Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `commission.advisories-rehash` | auto, takeover | PASS | 2026-09-12T16:58:53Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `commission.account-login` | auto, takeover | PASS | 2026-09-12T16:59:43Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `commission.https-only-writes` | auto | PASS | 2026-09-12T16:59:44Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `commission.ssh-until-reboot` | auto | PASS | 2026-09-12T16:59:46Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `commission.cloud-disabled-surface` | auto | PASS | 2026-09-12T16:59:46Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `commission.factory-return` | auto | PASS | 2026-09-12T16:59:49Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `commission.machine-name` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.first-run-flow` | operator, takeover | PASS | 2026-09-12T17:10:18Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `commission.first-run-page` | operator, takeover | PASS | 2026-09-11T17:55:49Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.cert-page` | auto | PASS | 2026-09-12T16:59:50Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `commission.what-changed` | auto, takeover | PASS | 2026-09-12T16:59:59Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `commission.record-export` | auto | PASS | 2026-09-12T17:01:26Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `commission.mirror` | auto, takeover | PASS | 2026-09-11T17:34:54Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `commission.check-switches` | operator | PASS | 2026-09-12T17:10:23Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `commission.check-sensors` | auto | PASS | 2026-09-12T18:26:17Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `commission.check-airflow` | auto, takeover | PASS | 2026-09-12T18:27:51Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `commission.check-cameras` | operator | PASS | 2026-09-12T17:10:54Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `commission.check-motion` | auto, takeover | PASS | 2026-09-12T18:28:17Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `commission.check-flow-verify` | auto, takeover | PASS | 2026-09-12T17:16:41Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `commission.cloud-header-capture` | operator, takeover | PASS | 2026-09-12T17:29:14Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `commission.sheet` | live, takeover | PASS | 2026-09-12T19:02:59Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `logs.tree-tail-export` | auto | PASS | 2026-09-12T17:00:43Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `logs.routing` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `logs.level-settings` | auto | PASS | 2026-09-12T17:16:42Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `motion.pacing` | auto | PASS | 2026-09-11T17:36:41Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `motion.jog-roundtrip` | auto | PASS | 2026-09-11T17:45:00Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `motion.microstep-modes` | auto | PASS | 2026-09-12T17:17:07Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `motion.liveness-probe` | auto | PASS | 2026-09-11T17:45:33Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-12T17:17:23Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `motion.cancel-abort` | auto | PASS | 2026-09-11T17:45:40Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `motion.deadman` | auto | PASS | 2026-09-12T17:17:53Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `motion.button-hold-resume` | operator | PASS | 2026-09-11T18:24:59Z | `c-20260911182256-078f` | yes |
|
||||||
|
| `motion.lid-cancel-home` | operator | PASS | 2026-09-11T18:25:15Z | `c-20260911182256-078f` | yes |
|
||||||
|
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-11T18:25:24Z | `c-20260911182256-078f` | yes |
|
||||||
|
| `motion.lid-policy-hold` | operator | PASS | 2026-09-11T18:25:37Z | `c-20260911182256-078f` | yes |
|
||||||
|
| `motion.step-timing-under-load` | auto | PASS | 2026-09-11T17:46:40Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-12T18:29:50Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cooling.flow-verify` | auto | PASS | 2026-09-12T18:32:33Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-12T18:33:39Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cooling.gate-off` | auto | PASS | 2026-09-12T18:26:26Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-12T18:35:06Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cooling.tec-drive` | auto | PASS | 2026-09-12T18:35:15Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-12T18:35:31Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-12T18:35:38Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cooling.critical-tier` | auto | PASS | 2026-09-12T18:35:47Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-12T18:27:10Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cooling.flow-under-load` | live | PASS | 2026-09-12T19:05:38Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cooling.fan-duty-readback` | auto | PASS | 2026-09-12T18:36:02Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `laser.power-floor` | auto | PASS | 2026-09-12T17:20:57Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `laser.emission-witness` | live, core | PASS | 2026-09-12T18:50:46Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `laser.m5-rapid-dark` | live | PASS | 2026-09-12T19:06:30Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `laser.disarm-in-hold` | live | PASS | 2026-09-12T19:08:04Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `laser.armed-kill` | live | PASS | 2026-09-12T19:08:58Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `laser.arm-wait-lid` | operator | PASS | 2026-09-12T18:36:21Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-12T19:09:43Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `camera.snapshot` | auto | PASS | 2026-09-12T17:10:39Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `camera.sensor-profile` | auto | PASS | 2026-09-12T17:21:13Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `camera.h264-stream` | auto | PASS | 2026-09-12T17:21:27Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `camera.frame-health` | auto | PASS | 2026-09-12T17:21:40Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `camera.lid-privacy` | operator | PASS | 2026-09-12T17:21:56Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `camera.key-read` | auto | PASS | 2026-09-12T17:21:57Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `update.slots-and-signature` | auto | PASS | 2026-09-12T16:59:48Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `update.release-check` | auto | PASS | 2026-09-12T17:21:58Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `cloud.mode-switch` | operator | PASS | 2026-09-12T18:38:00Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cloud.service-protocol` | operator | PASS | 2026-09-12T19:10:50Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-12T19:11:59Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-12T17:24:21Z | `c-20260912170819-8dec` | yes |
|
||||||
|
| `cloud.verdict-hold` | operator | PASS | 2026-09-12T18:40:33Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cloud.pause-resume` | live | PASS | 2026-09-12T19:14:09Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cloud.oversize-stream` | live | PASS | 2026-09-12T19:16:55Z | `c-20260912182427-e1ee` | no |
|
||||||
|
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-12T19:17:30Z | `c-20260912182427-e1ee` | no |
|
||||||
|
|
||||||
|
Artifact sha256: `9650b1ed9862eefab808ca32cdaa8f6b9b6debd8ee640adde9d18dcdfc03ba25`
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ForgeFIRM v0.0.4 is the first public beta release.
|
||||||
|
|
||||||
|
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
|
||||||
|
|
||||||
|
Every release below 0.1.0 is a beta release. Installation, usage, and safety are documented at https://docs.forgefirm.org/.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
|||||||
|
# ForgeFIRM acceptance - 20260915225440 (dev)
|
||||||
|
|
||||||
|
- Image: `20260915225440 (dev)` (forgefirm-image-dev)
|
||||||
|
- Manifest identity: `371bf2b8e0779a641b844b48e18b7fbe1a13adee0f6bf8706d1a720d3e000d22`
|
||||||
|
- Catalog: `7929ef2ce875fd4cccec3f794f7d49ceadd46bdd5068a3cab75b021947198525`
|
||||||
|
- Campaign: `c-20260915231006-07cc` opened 2026-09-15T23:10:06Z
|
||||||
|
- Exported: 2026-09-15T23:59:10Z
|
||||||
|
- **Release authorized: YES**
|
||||||
|
- Tests: 90 total, 90 satisfied (53 inherited), 0 required
|
||||||
|
|
||||||
|
| Test | Kind | Result | Run at | Campaign | Inherited |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `image.health` | auto, core | PASS | 2026-09-15T23:10:06Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `image.license-bundle` | auto | PASS | 2026-09-15T18:50:17Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-15T23:10:07Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-15T23:10:28Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-15T23:10:38Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-15T23:10:56Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-15T23:11:39Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-11T17:31:23Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `kernel.pic-soc-load` | auto | PASS | 2026-09-11T17:31:26Z | `c-20260911172921-9426` | yes |
|
||||||
|
| `forgectrl.auth` | auto | PASS | 2026-09-15T18:51:51Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-15T18:51:52Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `forgectrl.panel-serves` | auto | PASS | 2026-09-15T23:11:41Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `setup.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-15T18:52:10Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.override-until-reboot` | auto | PASS | 2026-09-15T18:52:11Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.advisories-rehash` | auto, takeover | PASS | 2026-09-15T18:52:19Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.account-login` | auto, takeover | PASS | 2026-09-15T18:53:08Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.https-only-writes` | auto | PASS | 2026-09-15T18:53:09Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.ssh-until-reboot` | auto | PASS | 2026-09-15T18:53:10Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.cloud-disabled-surface` | auto | PASS | 2026-09-15T18:53:11Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.factory-return` | auto | PASS | 2026-09-15T18:53:12Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.machine-name` | auto | PASS | 2026-09-15T18:53:13Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.first-run-flow` | operator, takeover | PASS | 2026-09-15T18:53:33Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.first-run-page` | operator, takeover | PASS | 2026-09-15T23:26:39Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `setup.cert-page` | auto | PASS | 2026-09-15T18:53:34Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.what-changed` | auto, takeover | PASS | 2026-09-15T18:53:44Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.record-export` | auto | PASS | 2026-09-15T18:55:14Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.mirror` | auto, takeover | PASS | 2026-09-15T18:55:44Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.check-switches` | operator | PASS | 2026-09-15T18:55:49Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.check-sensors` | auto | PASS | 2026-09-15T18:55:25Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.check-airflow` | auto, takeover | PASS | 2026-09-15T18:57:23Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.check-cameras` | operator | PASS | 2026-09-15T18:57:51Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.check-motion` | auto, takeover | PASS | 2026-09-15T18:58:27Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.check-flow-verify` | auto, takeover | PASS | 2026-09-15T19:05:54Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `setup.cloud-header-capture` | operator, takeover | PASS | 2026-09-15T23:28:02Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `setup.sheet` | live, takeover | PASS | 2026-09-15T23:40:57Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `logs.tree-tail-export` | auto | PASS | 2026-09-15T18:54:29Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `logs.routing` | auto | PASS | 2026-09-15T19:05:59Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `logs.level-settings` | auto | PASS | 2026-09-15T19:06:00Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `motion.pacing` | auto | PASS | 2026-09-15T23:11:57Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `motion.jog-roundtrip` | auto | PASS | 2026-09-15T23:12:10Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `motion.microstep-modes` | auto | PASS | 2026-09-15T23:12:36Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `motion.liveness-probe` | auto | PASS | 2026-09-15T20:59:39Z | `c-20260915205702-a7b9` | yes |
|
||||||
|
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-15T20:59:55Z | `c-20260915205702-a7b9` | yes |
|
||||||
|
| `motion.cancel-abort` | auto | PASS | 2026-09-15T23:12:43Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `motion.deadman` | auto | PASS | 2026-09-15T23:13:29Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `motion.respawn-gate` | operator | PASS | 2026-09-15T23:13:38Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `motion.soft-limits` | auto | PASS | 2026-09-15T23:15:35Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `motion.button-hold-resume` | operator | PASS | 2026-09-15T23:15:46Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `motion.lid-cancel-home` | operator | PASS | 2026-09-15T23:16:02Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-15T23:16:10Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `motion.lid-policy-hold` | operator | PASS | 2026-09-15T23:16:24Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `motion.step-timing-under-load` | auto | PASS | 2026-09-15T23:16:54Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-15T19:00:00Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `cooling.flow-verify` | auto | PASS | 2026-09-15T19:02:43Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-15T21:04:26Z | `c-20260915205702-a7b9` | yes |
|
||||||
|
| `cooling.gate-off` | auto | PASS | 2026-09-15T18:55:57Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-15T21:05:53Z | `c-20260915205702-a7b9` | yes |
|
||||||
|
| `cooling.tec-drive` | auto | PASS | 2026-09-15T21:06:01Z | `c-20260915205702-a7b9` | yes |
|
||||||
|
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-15T21:06:17Z | `c-20260915205702-a7b9` | yes |
|
||||||
|
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-15T21:06:25Z | `c-20260915205702-a7b9` | yes |
|
||||||
|
| `cooling.critical-tier` | auto | PASS | 2026-09-15T21:06:34Z | `c-20260915205702-a7b9` | yes |
|
||||||
|
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-15T18:56:43Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `cooling.flow-under-load` | live | PASS | 2026-09-15T23:45:14Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `cooling.fan-duty-readback` | auto | PASS | 2026-09-15T21:06:50Z | `c-20260915205702-a7b9` | yes |
|
||||||
|
| `cooling.fail-tier-stop` | operator | PASS | 2026-09-15T21:19:25Z | `c-20260915211732-5800` | yes |
|
||||||
|
| `laser.power-floor` | auto | PASS | 2026-09-15T23:16:56Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `laser.emission-witness` | live, core | PASS | 2026-09-15T23:28:39Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `laser.m5-rapid-dark` | live | PASS | 2026-09-15T23:45:54Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `laser.disarm-in-hold` | live | PASS | 2026-09-15T23:47:56Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `laser.armed-kill` | live | PASS | 2026-09-15T23:48:48Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `laser.verdict-cut` | live | PASS | 2026-09-15T23:49:28Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `laser.arm-wait-lid` | operator | PASS | 2026-09-15T23:16:59Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-15T23:49:58Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `camera.snapshot` | auto | PASS | 2026-09-15T18:57:38Z | `c-20260915185011-0c8a` | yes |
|
||||||
|
| `camera.sensor-profile` | auto | PASS | 2026-09-15T21:19:42Z | `c-20260915211732-5800` | yes |
|
||||||
|
| `camera.h264-stream` | auto | PASS | 2026-09-15T21:19:55Z | `c-20260915211732-5800` | yes |
|
||||||
|
| `camera.frame-health` | auto | PASS | 2026-09-15T21:20:07Z | `c-20260915211732-5800` | yes |
|
||||||
|
| `camera.lid-privacy` | operator | PASS | 2026-09-15T21:20:21Z | `c-20260915211732-5800` | yes |
|
||||||
|
| `camera.key-read` | auto | PASS | 2026-09-15T21:20:22Z | `c-20260915211732-5800` | yes |
|
||||||
|
| `update.slots-and-signature` | auto | PASS | 2026-09-12T16:59:48Z | `c-20260912165539-ac51` | yes |
|
||||||
|
| `update.release-check` | auto | PASS | 2026-09-15T21:20:23Z | `c-20260915211732-5800` | yes |
|
||||||
|
| `cloud.mode-switch` | operator | PASS | 2026-09-15T23:15:16Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `cloud.service-protocol` | operator | PASS | 2026-09-15T23:51:07Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-15T23:52:16Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-15T21:21:03Z | `c-20260915211732-5800` | yes |
|
||||||
|
| `cloud.dark-print` | operator | PASS | 2026-09-15T21:21:58Z | `c-20260915211732-5800` | yes |
|
||||||
|
| `cloud.verdict-refuse` | operator | PASS | 2026-09-15T21:23:20Z | `c-20260915211732-5800` | yes |
|
||||||
|
| `cloud.pause-resume` | live | PASS | 2026-09-15T23:54:31Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `cloud.oversize-stream` | live | PASS | 2026-09-15T23:57:18Z | `c-20260915231006-07cc` | no |
|
||||||
|
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-15T23:57:46Z | `c-20260915231006-07cc` | no |
|
||||||
|
|
||||||
|
Artifact sha256: `35e54b5db01fb23eb9091302e86d158852f9b4eaddca2a127a1bc957efb2ea0c`
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ForgeFIRM v0.0.5 is a beta release.
|
||||||
|
|
||||||
|
It follows a review of the Grbl control path, tightening the laser emission and motion gates, the stream and protocol handling, and the machine-services supervisor. The connect banner, the soft-limit envelope after a home, and the cooling fail tiers all behave more predictably, and a fresh controller or a new sender starts from a clean state.
|
||||||
|
|
||||||
|
Expect bugs. When you find one, open an issue in this repository or report it on the community forum at https://community.openglow.org so it can be fixed.
|
||||||
|
|
||||||
|
Every release below 0.1.0 is a beta release. Installation, usage, and safety are documented at https://docs.forgefirm.org/.
|
||||||
@@ -39,6 +39,20 @@ reported messages:
|
|||||||
not to where it was paused (the core restarts a held cycle through
|
not to where it was paused (the core restarts a held cycle through
|
||||||
Idle); a job abandoned in a hold and reset ends there, so the next
|
Idle); a job abandoned in a hold and reset ends there, so the next
|
||||||
job's start is captured afresh where it begins
|
job's start is captured afresh where it begins
|
||||||
|
11. the cooling verdict's pause tier (OVERTEMP) holds the job under the
|
||||||
|
open window without writing the latch (a lock sets the hardware
|
||||||
|
button latch, which only a press clears); the clean verdict then
|
||||||
|
resumes it with no new button press: no prompt, no second arm
|
||||||
|
12. the verdict's fail tier (AIRFLOW) ends the job: disarmed, reset with
|
||||||
|
ALARM:3, and a ~ under the clean verdict that follows resumes nothing
|
||||||
|
13. a sender change during a re-arm cancels it: the press that follows
|
||||||
|
resumes nothing, the job stays held, and the new sender's own ~
|
||||||
|
prompts afresh
|
||||||
|
14. a jog does not hold the armed window open: with the spindle off the
|
||||||
|
grace counts down through jogs, and the window closes on time
|
||||||
|
15. a press counts only after the button has been seen up: a press that
|
||||||
|
began before the wait is not consent (the harness's own presses
|
||||||
|
follow a fresh prompt for the same reason)
|
||||||
|
|
||||||
The disarm grace is shortened via a temp config (GFHOME_CONF), the
|
The disarm grace is shortened via a temp config (GFHOME_CONF), the
|
||||||
cooling verdict is published hermetically (GF_VERDICT_FILE), the same
|
cooling verdict is published hermetically (GF_VERDICT_FILE), the same
|
||||||
@@ -141,23 +155,29 @@ def wait_idle(sock, log):
|
|||||||
fail("controller never returned to Idle")
|
fail("controller never returned to Idle")
|
||||||
|
|
||||||
|
|
||||||
def publish_verdicts(path, stop, fire_ok, armed_ack=True, ack_after_s=0.0):
|
def publish_verdicts(path, stop, verdict):
|
||||||
"""Stand in for the cooling engine. "armed" is the engine's
|
"""Stand in for the cooling engine. `verdict` is the session's live
|
||||||
acknowledgment that it has taken the controller's armed window and
|
dict: fire_ok, hold, resume_ok, name, armed_ack, ack_after_s. "armed"
|
||||||
applied the run airflow; the controller refuses to fire on a verdict
|
is the engine's acknowledgment that it has taken the controller's
|
||||||
that lacks it, so an engine that never acknowledges (armed_ack
|
armed window and applied the run airflow; the controller refuses to
|
||||||
False) must produce a refused arm and no emission. ack_after_s
|
fire on a verdict that lacks it, so an engine that never
|
||||||
withholds the acknowledgment for that long first, which is the real
|
acknowledges (armed_ack False) must produce a refused arm and no
|
||||||
engine's case: it answers on its next tick, and the controller has
|
emission. ack_after_s withholds the acknowledgment for that long
|
||||||
to see the refreshed verdict to get past the arm."""
|
first, which is the real engine's case: it answers on its next
|
||||||
|
tick, and the controller has to see the refreshed verdict to get
|
||||||
|
past the arm."""
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
while not stop.is_set():
|
while not stop.is_set():
|
||||||
acked = armed_ack and time.monotonic() - t0 >= ack_after_s
|
v = dict(verdict)
|
||||||
body = ('{"ts_mono":%.3f,"fire_ok":%s,"hold":false,'
|
acked = v["armed_ack"] and time.monotonic() - t0 >= v["ack_after_s"]
|
||||||
'"resume_ok":true,"armed":%s,"reason":""}'
|
body = ('{"ts_mono":%.3f,"fire_ok":%s,"verdict":"%s","hold":%s,'
|
||||||
|
'"resume_ok":%s,"armed":%s,"reason":"%s"}'
|
||||||
% (time.clock_gettime(time.CLOCK_MONOTONIC),
|
% (time.clock_gettime(time.CLOCK_MONOTONIC),
|
||||||
"true" if fire_ok else "false",
|
"true" if v["fire_ok"] else "false", v["name"],
|
||||||
"true" if acked else "false"))
|
"true" if v["hold"] else "false",
|
||||||
|
"true" if v["resume_ok"] else "false",
|
||||||
|
"true" if acked else "false",
|
||||||
|
"" if v["fire_ok"] else "harness: %s" % v["name"]))
|
||||||
tmp = path + ".tmp"
|
tmp = path + ".tmp"
|
||||||
with open(tmp, "w") as f:
|
with open(tmp, "w") as f:
|
||||||
f.write(body)
|
f.write(body)
|
||||||
@@ -165,6 +185,20 @@ def publish_verdicts(path, stop, fire_ok, armed_ack=True, ack_after_s=0.0):
|
|||||||
stop.wait(0.5)
|
stop.wait(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_new(log, needle, n_before, timeout, sock=None):
|
||||||
|
"""Wait until needle appears in the log more times than n_before: a
|
||||||
|
fresh occurrence, never a stale match from an earlier job."""
|
||||||
|
end = time.time() + timeout
|
||||||
|
while time.time() < end:
|
||||||
|
if "".join(log).count(needle) > n_before:
|
||||||
|
return True
|
||||||
|
if sock is not None:
|
||||||
|
read_avail(sock, log, 0.2)
|
||||||
|
else:
|
||||||
|
time.sleep(0.1)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class Session:
|
class Session:
|
||||||
"""One controller process with the lifecycle overrides applied."""
|
"""One controller process with the lifecycle overrides applied."""
|
||||||
|
|
||||||
@@ -189,8 +223,10 @@ class Session:
|
|||||||
self.set_switches(switches)
|
self.set_switches(switches)
|
||||||
env["GF_SWITCH_FILE"] = self.switch_file
|
env["GF_SWITCH_FILE"] = self.switch_file
|
||||||
self.stop = threading.Event()
|
self.stop = threading.Event()
|
||||||
|
self.verdict = {"fire_ok": fire_ok, "hold": False, "resume_ok": True, "name": "OK",
|
||||||
|
"armed_ack": armed_ack, "ack_after_s": ack_after_s}
|
||||||
self.pub = threading.Thread(target=publish_verdicts,
|
self.pub = threading.Thread(target=publish_verdicts,
|
||||||
args=(verdict, self.stop, fire_ok, armed_ack, ack_after_s),
|
args=(verdict, self.stop, self.verdict),
|
||||||
daemon=True)
|
daemon=True)
|
||||||
self.pub.start()
|
self.pub.start()
|
||||||
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
|
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
|
||||||
@@ -220,6 +256,16 @@ class Session:
|
|||||||
f.write("%d\n" % word)
|
f.write("%d\n" % word)
|
||||||
os.replace(tmp, self.switch_file)
|
os.replace(tmp, self.switch_file)
|
||||||
|
|
||||||
|
def set_verdict(self, name="OK", fire_ok=True, hold=False, resume_ok=True):
|
||||||
|
"""The engine's next verdicts: a pause tier (OVERTEMP: fire
|
||||||
|
blocked, hold, no resume), a fail tier (AIRFLOW: the same under a
|
||||||
|
name the client ends the job on), or clean."""
|
||||||
|
self.verdict.update({"name": name, "fire_ok": fire_ok, "hold": hold,
|
||||||
|
"resume_ok": resume_ok})
|
||||||
|
|
||||||
|
def count(self, needle):
|
||||||
|
return "".join(self.log).count(needle)
|
||||||
|
|
||||||
def send_raw(self, line):
|
def send_raw(self, line):
|
||||||
"""Send a line without waiting for ok/error (the arm wait blocks
|
"""Send a line without waiting for ok/error (the arm wait blocks
|
||||||
the gcode stream, so the ok only comes once the button is pressed)."""
|
the gcode stream, so the ok only comes once the button is pressed)."""
|
||||||
@@ -867,13 +913,17 @@ def test_lid_open_in_wait():
|
|||||||
|
|
||||||
def start_armed_move(s, tag, gcode="G1 X30 F60"):
|
def start_armed_move(s, tag, gcode="G1 X30 F60"):
|
||||||
"""Arm through the button and get a long move under way; returns once
|
"""Arm through the button and get a long move under way; returns once
|
||||||
the controller reports Run."""
|
the controller reports Run. The prompt and the armed message are
|
||||||
|
waited for as fresh occurrences: a press that lands before the
|
||||||
|
wait has begun is not consent (rule 15), so the press must follow
|
||||||
|
THIS job's prompt, not an earlier job's."""
|
||||||
|
prompts, armed = s.count(PROMPT), s.count(ARMED)
|
||||||
s.send_raw("M4 S100")
|
s.send_raw("M4 S100")
|
||||||
s.send_raw(gcode)
|
s.send_raw(gcode)
|
||||||
if not wait_for(s.log, PROMPT, 5, s.sock):
|
if not wait_for_new(s.log, PROMPT, prompts, 5, s.sock):
|
||||||
fail("[%s] no button prompt" % tag)
|
fail("[%s] no button prompt" % tag)
|
||||||
s.press_button()
|
s.press_button()
|
||||||
if not wait_for(s.log, ARMED, 5, s.sock):
|
if not wait_for_new(s.log, ARMED, armed, 5, s.sock):
|
||||||
fail("[%s] the button press did not arm" % tag)
|
fail("[%s] the button press did not arm" % tag)
|
||||||
if not s.wait_state("Run", 5):
|
if not s.wait_state("Run", 5):
|
||||||
fail("[%s] the job never reported Run" % tag)
|
fail("[%s] the job never reported Run" % tag)
|
||||||
@@ -1045,9 +1095,158 @@ def test_lid_policy_hold():
|
|||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verdict_pause_resumes_without_press():
|
||||||
|
"""Rule 11: the pause tier holds under the open window without a
|
||||||
|
latch write; the clean verdict resumes with no press. The window is
|
||||||
|
proven still open by the absence of any prompt and of a second arm."""
|
||||||
|
s = Session("verdict-pause", disarm_s=60, switches=SW_CLOSED)
|
||||||
|
try:
|
||||||
|
start_armed_move(s, "verdict-pause", gcode="G1 X30 F120") # 15 s of motion
|
||||||
|
time.sleep(0.5)
|
||||||
|
s.set_verdict("OVERTEMP", fire_ok=False, hold=True, resume_ok=False)
|
||||||
|
if not s.wait_state("Hold", 5):
|
||||||
|
fail("[verdict-pause] the pause tier did not hold the job (state %s)" % s.state())
|
||||||
|
if not wait_for(s.log, "fire masked, job held", 3, s.sock):
|
||||||
|
fail("[verdict-pause] the pause tier did not report the masked hold")
|
||||||
|
if "latch locked" in "".join(s.log):
|
||||||
|
fail("[verdict-pause] the pause tier wrote the latch")
|
||||||
|
if DISARMED in "".join(s.log):
|
||||||
|
fail("[verdict-pause] the pause tier closed the armed window")
|
||||||
|
time.sleep(1.5)
|
||||||
|
s.set_verdict("OK", fire_ok=True, hold=False, resume_ok=True)
|
||||||
|
if not s.wait_state("Run", 5):
|
||||||
|
fail("[verdict-pause] the clean verdict did not resume the job (state %s)" % s.state())
|
||||||
|
if not wait_for(s.log, "resuming", 2, s.sock):
|
||||||
|
fail("[verdict-pause] the resume was not reported")
|
||||||
|
if RESUME_PROMPT in "".join(s.log) or s.count(PROMPT) != 1:
|
||||||
|
fail("[verdict-pause] the resume asked for a button press")
|
||||||
|
if s.armed_count() != 1:
|
||||||
|
fail("[verdict-pause] the resume armed again (armed messages: %d)" % s.armed_count())
|
||||||
|
if "ALARM" in "".join(s.log):
|
||||||
|
fail("[verdict-pause] the pause tier raised an alarm")
|
||||||
|
s.sock.sendall(b"\x18")
|
||||||
|
print("PASS [verdict-pause]: OVERTEMP held under the open window with no latch write; "
|
||||||
|
"the clean verdict resumed with no press")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verdict_fail_tier_ends_job():
|
||||||
|
"""Rule 12: the fail tier disarms, resets the job with ALARM:3, and
|
||||||
|
nothing resumes it."""
|
||||||
|
s = Session("verdict-fail", disarm_s=60, switches=SW_CLOSED)
|
||||||
|
try:
|
||||||
|
start_armed_move(s, "verdict-fail", gcode="G1 X30 F120")
|
||||||
|
time.sleep(0.5)
|
||||||
|
s.set_verdict("AIRFLOW", fire_ok=False, hold=True, resume_ok=False)
|
||||||
|
if not s.wait_state("Alarm", 5):
|
||||||
|
fail("[verdict-fail] the fail tier did not end the job in Alarm (state %s)" % s.state())
|
||||||
|
if not wait_for(s.log, DISARMED, 3, s.sock):
|
||||||
|
fail("[verdict-fail] the fail tier did not close the armed window")
|
||||||
|
text = "".join(s.log)
|
||||||
|
if "ALARM:3" not in text:
|
||||||
|
fail("[verdict-fail] no ALARM:3 on the fail tier")
|
||||||
|
if "AIRFLOW" not in text:
|
||||||
|
fail("[verdict-fail] the fail tier did not name the verdict")
|
||||||
|
s.set_verdict("OK", fire_ok=True, hold=False, resume_ok=True)
|
||||||
|
time.sleep(1.5)
|
||||||
|
s.sock.sendall(b"~")
|
||||||
|
read_avail(s.sock, s.log, 1.0)
|
||||||
|
if not s.state().startswith("Alarm"):
|
||||||
|
fail("[verdict-fail] a ~ after the fail tier resumed something (state %s)" % s.state())
|
||||||
|
if "resuming" in "".join(s.log):
|
||||||
|
fail("[verdict-fail] the client resumed after the fail tier")
|
||||||
|
send_line(s.sock, "$X", s.log)
|
||||||
|
if not s.wait_state("Idle", 3):
|
||||||
|
fail("[verdict-fail] $X did not unlock the alarm")
|
||||||
|
print("PASS [verdict-fail]: AIRFLOW disarmed, reset the job with ALARM:3, and nothing "
|
||||||
|
"resumed it")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sender_change_during_rearm_cancels():
|
||||||
|
"""Rule 13: a sender change while a re-arm waits for the press
|
||||||
|
cancels the re-arm; the press resumes nothing and the job stays
|
||||||
|
held; the new sender's ~ prompts afresh."""
|
||||||
|
s = Session("rearm-sender-change", disarm_s=60, switches=SW_CLOSED)
|
||||||
|
try:
|
||||||
|
start_armed_move(s, "rearm-sender-change", gcode="G1 X30 F120")
|
||||||
|
time.sleep(1.0)
|
||||||
|
s.sock.close() # mid-move: held, disarmed
|
||||||
|
time.sleep(0.5)
|
||||||
|
s.sock = s.connect()
|
||||||
|
if s.wait_state("Hold", 5) is None:
|
||||||
|
fail("[rearm-sender-change] the job was not held on the sender change")
|
||||||
|
s.sock.sendall(b"~")
|
||||||
|
if not wait_for(s.log, RESUME_PROMPT, 5, s.sock):
|
||||||
|
fail("[rearm-sender-change] the resume did not prompt for the button")
|
||||||
|
# A second sender change, inside the re-arm wait. The cancel is
|
||||||
|
# reported at the disconnect, to nobody (output with no client is
|
||||||
|
# discarded), so the evidence is what the press does next: nothing.
|
||||||
|
s.sock.close()
|
||||||
|
time.sleep(0.5)
|
||||||
|
s.sock = s.connect()
|
||||||
|
st = read_state(s, '"connected":true')
|
||||||
|
if '"arming":false' not in st:
|
||||||
|
fail("[rearm-sender-change] the re-arm wait survived the sender change: %r" % st)
|
||||||
|
# A press now is the machine's resume button for the new sender:
|
||||||
|
# it resumes nothing and arms nothing, it opens a fresh re-arm
|
||||||
|
# prompt of its own (the displaced consent is gone), and only a
|
||||||
|
# second press, against that prompt, re-arms the held job.
|
||||||
|
before = s.armed_count()
|
||||||
|
prompts = s.count(RESUME_PROMPT)
|
||||||
|
s.press_button()
|
||||||
|
read_avail(s.sock, s.log, 1.0)
|
||||||
|
if s.armed_count() != before or not s.state().startswith("Hold"):
|
||||||
|
fail("[rearm-sender-change] a press after the canceled re-arm resumed the job "
|
||||||
|
"(armed %d -> %d, state %s)" % (before, s.armed_count(), s.state()))
|
||||||
|
if not wait_for_new(s.log, RESUME_PROMPT, prompts, 3, s.sock):
|
||||||
|
fail("[rearm-sender-change] the press after the cancel did not open a fresh re-arm prompt")
|
||||||
|
s.press_button()
|
||||||
|
end = time.time() + 10
|
||||||
|
while time.time() < end and s.armed_count() != before + 1:
|
||||||
|
read_avail(s.sock, s.log, 0.2)
|
||||||
|
if s.armed_count() != before + 1:
|
||||||
|
fail("[rearm-sender-change] the second press did not re-arm the held job")
|
||||||
|
s.sock.sendall(b"\x18")
|
||||||
|
print("PASS [rearm-sender-change]: the sender change canceled the re-arm; the next press "
|
||||||
|
"resumed nothing and prompted afresh, and the press after it re-armed")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_jog_does_not_extend_window():
|
||||||
|
"""Rule 14: with the spindle off, jogs do not reset the disarm grace:
|
||||||
|
the window closes on time through them."""
|
||||||
|
s = Session("jog-grace", disarm_s=2)
|
||||||
|
try:
|
||||||
|
send_line(s.sock, "M4 S100", s.log)
|
||||||
|
send_line(s.sock, "G1 X1 F600", s.log)
|
||||||
|
if not wait_for(s.log, ARMED, 5, s.sock):
|
||||||
|
fail("[jog-grace] job did not arm")
|
||||||
|
send_line(s.sock, "M5", s.log)
|
||||||
|
wait_idle(s.sock, s.log)
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < 5.0 and DISARMED not in "".join(s.log):
|
||||||
|
send_line(s.sock, "$J=G91X1F1200", s.log)
|
||||||
|
read_avail(s.sock, s.log, 0.3)
|
||||||
|
dt = time.time() - t0
|
||||||
|
if DISARMED not in "".join(s.log):
|
||||||
|
fail("[jog-grace] the window stayed open through %.1f s of jogging (grace 2 s)" % dt)
|
||||||
|
wait_idle(s.sock, s.log)
|
||||||
|
print("PASS [jog-grace]: the window closed after %.1f s of jogging with the spindle off" % dt)
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if not os.path.isfile(BIN):
|
if not os.path.isfile(BIN):
|
||||||
fail("controller binary not found at %s" % BIN)
|
fail("controller binary not found at %s" % BIN)
|
||||||
|
test_verdict_pause_resumes_without_press()
|
||||||
|
test_verdict_fail_tier_ends_job()
|
||||||
|
test_sender_change_during_rearm_cancels()
|
||||||
|
test_jog_does_not_extend_window()
|
||||||
test_job_window()
|
test_job_window()
|
||||||
test_status_files()
|
test_status_files()
|
||||||
test_laser_keys_reload()
|
test_laser_keys_reload()
|
||||||
|
|||||||
@@ -79,12 +79,45 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
|||||||
the default gamma of 2 than at gamma 1, and the cruise middle
|
the default gamma of 2 than at gamma 1, and the cruise middle
|
||||||
renders the same - the exponent shapes only the velocity-scaled
|
renders the same - the exponent shapes only the velocity-scaled
|
||||||
rolloff, never the programmed level
|
rolloff, never the programmed level
|
||||||
24. a hold verdict is held again after a resume: with the engine's
|
24. the verdict's pause tier: with the engine's verdict at a pause
|
||||||
verdict at its fail tier (hold, fire blocked, no resume) the client
|
(OVERTEMP: hold, fire blocked, no resume) the client holds the job
|
||||||
holds the job; a ~ under that verdict, which is what a button press
|
under the open window; the deceleration into that first hold runs
|
||||||
or a sender does, moves the head for at most one client poll, dark,
|
lit to the stop, as a feed hold's does (the segments already planned
|
||||||
before the client holds it again and says so; the clean verdict then
|
at speed would otherwise play dark and leave a gap in the cut), and
|
||||||
resumes the hold the client took, and the rest of the line cuts lit
|
the stream engine's per-tick gate masks the stream from the stop on;
|
||||||
|
a ~ under that verdict, which is what a button press or a sender
|
||||||
|
does, moves the head dark for at most one client poll before the
|
||||||
|
client holds it again and says so; the clean verdict then resumes
|
||||||
|
the hold the client took with no new press, and the rest of the line
|
||||||
|
cuts lit. The latch is never written by a pause (a lock sets the
|
||||||
|
hardware button latch, which only a press clears): the latch
|
||||||
|
sideband (GFSINK_LATCH_LOG) carries the unlock at the arm and the
|
||||||
|
lock at the program end and nothing between
|
||||||
|
25. the verdict's fail tier: a verdict named AIRFLOW (fire blocked, hold,
|
||||||
|
no resume) mid-M3 ends the job: the window closes, the latch locks,
|
||||||
|
the job is reset with ALARM:3, the stream ends dark well short of the
|
||||||
|
line, and a ~ under the clean verdict that follows resumes nothing;
|
||||||
|
the sideband ends on the lock and carries no unlock after it
|
||||||
|
26. a sender change mid-M3 closes the window and holds the job, and the
|
||||||
|
deceleration into that hold ships dark: the gate follows the window
|
||||||
|
on every tick, so a fire state the core never updates cannot outlive
|
||||||
|
the consent it rode on
|
||||||
|
27. a verdict that goes stale (the engine stops publishing mid-cut)
|
||||||
|
holds the job the moment the client's cache expires, on its own
|
||||||
|
clock between two of its file reads, lit to the stop like rule 24:
|
||||||
|
no dark cut runs out while the client waits for its next read; the
|
||||||
|
engine's return resumes the cut lit
|
||||||
|
28. a scheduling stall of the step producer (GFSINK_STALL_MS: the
|
||||||
|
producer is kept off the CPU longer than its lead) maps events
|
||||||
|
behind the ship cursor. Inside an armed window that is a fault:
|
||||||
|
ALARM:17, the window closed, the stream ended dark, and no step
|
||||||
|
burst shipped (the clamped events are never compressed onto later
|
||||||
|
bytes). Unarmed it is a warning in the log and the move completes
|
||||||
|
with every step in the stream, compressed
|
||||||
|
29. a stall of the kernel write (GFSINK_WRITE_STALL_MS: the sink holds
|
||||||
|
the write the way a full ring holds it) stalls nothing but the
|
||||||
|
shipper: the producer keeps mapping at wall pace behind it, so the
|
||||||
|
job completes lit with no clamp, no burst, and a dark end
|
||||||
|
|
||||||
The analog sessions select the reference mode through the config; on
|
The analog sessions select the reference mode through the config; on
|
||||||
hardware the controller ignores it (density is the only product model -
|
hardware the controller ignores it (density is the only product model -
|
||||||
@@ -401,28 +434,37 @@ def wait_state(sock, log, prefix, timeout=5.0):
|
|||||||
fail("controller never reached %s" % prefix)
|
fail("controller never reached %s" % prefix)
|
||||||
|
|
||||||
|
|
||||||
# The published verdict is clean unless a session sets this: then it is
|
# The published verdict is clean unless a session sets a mode: "hold" is
|
||||||
# the engine's fail tier (hold, fire blocked, no resume), what an airflow
|
# the engine's pause tier (OVERTEMP: hold, fire blocked, no resume) and
|
||||||
# fault publishes. A ("verdict", "hold") step sets it, ("verdict",
|
# "fail" its fail tier (AIRFLOW: the same flags under a name the client
|
||||||
|
# ends the job on). A ("verdict", <mode>) step sets it, ("verdict",
|
||||||
# "clean") clears it.
|
# "clean") clears it.
|
||||||
VERDICT_HOLD = threading.Event()
|
VERDICT_MODE = {"mode": "clean"}
|
||||||
|
VERDICT_NAMES = {"hold": "OVERTEMP", "fail": "AIRFLOW"}
|
||||||
|
|
||||||
|
|
||||||
def publish_verdicts(path, stop):
|
def publish_verdicts(path, stop):
|
||||||
"""Publish a fresh cooling verdict every 0.5 s (the arm flow refuses
|
"""Publish a fresh cooling verdict every 0.5 s (the arm flow refuses
|
||||||
without one; freshness window is 2 s), clean unless VERDICT_HOLD is
|
without one; freshness window is 2 s), clean unless VERDICT_MODE
|
||||||
set. Same-host monotonic clock, atomic rename so the reader never
|
says otherwise. Same-host monotonic clock, atomic rename so the
|
||||||
sees a torn file. "armed" is the engine's acknowledgment that it has
|
reader never sees a torn file. "armed" is the engine's
|
||||||
taken the controller's armed window; the arm waits for it, so a
|
acknowledgment that it has taken the controller's armed window; the
|
||||||
stand-in engine that means to let jobs run must assert it."""
|
arm waits for it, so a stand-in engine that means to let jobs run
|
||||||
|
must assert it."""
|
||||||
while not stop.is_set():
|
while not stop.is_set():
|
||||||
hold = VERDICT_HOLD.is_set()
|
mode = VERDICT_MODE["mode"]
|
||||||
body = ('{"ts_mono":%.3f,"fire_ok":%s,"hold":%s,'
|
if mode == "stale":
|
||||||
|
stop.wait(0.5) # the engine has stopped publishing
|
||||||
|
continue
|
||||||
|
blocked = mode != "clean"
|
||||||
|
body = ('{"ts_mono":%.3f,"fire_ok":%s,"verdict":"%s","hold":%s,'
|
||||||
'"resume_ok":%s,"armed":true,"reason":"%s"}'
|
'"resume_ok":%s,"armed":true,"reason":"%s"}'
|
||||||
% (time.clock_gettime(time.CLOCK_MONOTONIC),
|
% (time.clock_gettime(time.CLOCK_MONOTONIC),
|
||||||
"false" if hold else "true", "true" if hold else "false",
|
"false" if blocked else "true",
|
||||||
"false" if hold else "true",
|
VERDICT_NAMES.get(mode, "OK"),
|
||||||
"harness: airflow fault" if hold else ""))
|
"true" if blocked else "false",
|
||||||
|
"false" if blocked else "true",
|
||||||
|
("harness: %s" % VERDICT_NAMES[mode]) if blocked else ""))
|
||||||
tmp = path + ".tmp"
|
tmp = path + ".tmp"
|
||||||
with open(tmp, "w") as f:
|
with open(tmp, "w") as f:
|
||||||
f.write(body)
|
f.write(body)
|
||||||
@@ -431,19 +473,23 @@ def publish_verdicts(path, stop):
|
|||||||
|
|
||||||
|
|
||||||
def run_session(name, steps, conf=None, workdir=None, keep=False,
|
def run_session(name, steps, conf=None, workdir=None, keep=False,
|
||||||
arm_required=True):
|
arm_required=True, env_extra=None):
|
||||||
"""Launch the controller, run the job steps, return the dump bytes.
|
"""Launch the controller, run the job steps, return the dump bytes.
|
||||||
|
|
||||||
Pass workdir + keep to chain launches over one settings file: the
|
Pass workdir + keep to chain launches over one settings file: the
|
||||||
core precomputes the spindle PWM mapping once, when the spindle is
|
core precomputes the spindle PWM mapping once, when the spindle is
|
||||||
enabled, so a $35 written at runtime only takes effect on the next
|
enabled, so a $35 written at runtime only takes effect on the next
|
||||||
controller start."""
|
controller start. env_extra adds to the controller's environment
|
||||||
|
(the host stall knobs). The controller's log (stderr) is kept in
|
||||||
|
run_session.err."""
|
||||||
if workdir is None:
|
if workdir is None:
|
||||||
workdir = tempfile.mkdtemp(prefix="laser-test-")
|
workdir = tempfile.mkdtemp(prefix="laser-test-")
|
||||||
dump = os.path.join(workdir, "stream.bin")
|
dump = os.path.join(workdir, "stream.bin")
|
||||||
verdict = os.path.join(workdir, "cooling.state")
|
verdict = os.path.join(workdir, "cooling.state")
|
||||||
|
latch_log = os.path.join(workdir, "latch.log")
|
||||||
env = dict(os.environ, GFSINK_DUMP=dump, GF_VERDICT_FILE=verdict,
|
env = dict(os.environ, GFSINK_DUMP=dump, GF_VERDICT_FILE=verdict,
|
||||||
FFLOG_STDERR="1")
|
GFSINK_LATCH_LOG=latch_log, FFLOG_STDERR="1")
|
||||||
|
env.update(env_extra or {})
|
||||||
# The lens reference the daemon leaves before a controller starts:
|
# The lens reference the daemon leaves before a controller starts:
|
||||||
# forgectrl sweeps the carriage onto the hall edge and marks it, and
|
# forgectrl sweeps the carriage onto the hall edge and marks it, and
|
||||||
# the controller opens the Z envelope on that mark. Without one Z
|
# the controller opens the Z envelope on that mark. Without one Z
|
||||||
@@ -462,7 +508,7 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
|
|||||||
env["GFHOME_CONF"] = conf_path
|
env["GFHOME_CONF"] = conf_path
|
||||||
|
|
||||||
stop = threading.Event()
|
stop = threading.Event()
|
||||||
VERDICT_HOLD.clear()
|
VERDICT_MODE["mode"] = "clean"
|
||||||
pub = threading.Thread(target=publish_verdicts, args=(verdict, stop), daemon=True)
|
pub = threading.Thread(target=publish_verdicts, args=(verdict, stop), daemon=True)
|
||||||
pub.start()
|
pub.start()
|
||||||
|
|
||||||
@@ -494,12 +540,18 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
|
|||||||
elif isinstance(step, tuple) and step[0] == "rt":
|
elif isinstance(step, tuple) and step[0] == "rt":
|
||||||
sock.sendall(step[1]) # a realtime character: no ok follows
|
sock.sendall(step[1]) # a realtime character: no ok follows
|
||||||
elif isinstance(step, tuple) and step[0] == "wait_state":
|
elif isinstance(step, tuple) and step[0] == "wait_state":
|
||||||
wait_state(sock, log, step[1])
|
wait_state(sock, log, step[1], step[2] if len(step) > 2 else 5.0)
|
||||||
elif isinstance(step, tuple) and step[0] == "verdict":
|
elif isinstance(step, tuple) and step[0] == "verdict":
|
||||||
if step[1] == "hold":
|
VERDICT_MODE["mode"] = step[1]
|
||||||
VERDICT_HOLD.set()
|
elif isinstance(step, tuple) and step[0] == "expect_text":
|
||||||
else:
|
if not wait_text(sock, log, step[1], step[2] if len(step) > 2 else 5.0):
|
||||||
VERDICT_HOLD.clear()
|
fail("[%s] the controller never said %r" % (name, step[1]))
|
||||||
|
elif isinstance(step, tuple) and step[0] == "reconnect":
|
||||||
|
# A sender change: the socket closes and a new one connects.
|
||||||
|
sock.close()
|
||||||
|
time.sleep(step[1] if len(step) > 1 else 0.3)
|
||||||
|
sock = socket.create_connection(("127.0.0.1", PORT), timeout=1)
|
||||||
|
read_avail(sock, log, 0.5)
|
||||||
else:
|
else:
|
||||||
send_line(sock, step, log)
|
send_line(sock, step, log)
|
||||||
|
|
||||||
@@ -520,11 +572,19 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
|
|||||||
proc.wait(5)
|
proc.wait(5)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
|
try:
|
||||||
|
run_session.err = (proc.stderr.read() or b"").decode(errors="replace")
|
||||||
|
except (OSError, ValueError):
|
||||||
|
run_session.err = ""
|
||||||
stop.set()
|
stop.set()
|
||||||
pub.join(2)
|
pub.join(2)
|
||||||
VERDICT_HOLD.clear()
|
VERDICT_MODE["mode"] = "clean"
|
||||||
|
|
||||||
data = open(dump, "rb").read()
|
data = open(dump, "rb").read()
|
||||||
|
try:
|
||||||
|
run_session.latch = open(latch_log).read().split()
|
||||||
|
except OSError:
|
||||||
|
run_session.latch = []
|
||||||
if not data and arm_required:
|
if not data and arm_required:
|
||||||
fail("[%s] empty stream dump" % name)
|
fail("[%s] empty stream dump" % name)
|
||||||
if not keep:
|
if not keep:
|
||||||
@@ -532,6 +592,89 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def wait_text(sock, log, needle, timeout):
|
||||||
|
"""Drain the socket until needle appears in the accumulated log."""
|
||||||
|
end = time.time() + timeout
|
||||||
|
while time.time() < end:
|
||||||
|
if needle in "".join(log):
|
||||||
|
return True
|
||||||
|
read_avail(sock, log, 0.2)
|
||||||
|
return needle in "".join(log)
|
||||||
|
|
||||||
|
|
||||||
|
def latch_transitions(lines):
|
||||||
|
"""The sideband's lock/unlock lines with repeats collapsed: the
|
||||||
|
ownership sequence as transitions."""
|
||||||
|
out = []
|
||||||
|
for ln in lines:
|
||||||
|
if ln in ("lock", "unlock") and (not out or out[-1] != ln):
|
||||||
|
out.append(ln)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def holds_in(ticks, min_run=2000):
|
||||||
|
"""The stationary stretches (no X/Y/Z step for min_run ticks) inside
|
||||||
|
the motion, as (start, end) tick spans."""
|
||||||
|
step = [1 if t & 0x25 else 0 for t in ticks]
|
||||||
|
first = step.index(1)
|
||||||
|
last = len(step) - 1 - step[::-1].index(1)
|
||||||
|
holds, run = [], 0
|
||||||
|
for i in range(first, last + 1):
|
||||||
|
if step[i]:
|
||||||
|
if run >= min_run:
|
||||||
|
holds.append((i - run, i))
|
||||||
|
run = 0
|
||||||
|
else:
|
||||||
|
run += 1
|
||||||
|
return holds
|
||||||
|
|
||||||
|
|
||||||
|
# The deceleration into a hold from F3000 (50 mm/s) at the board's
|
||||||
|
# 700 mm/s^2: 71 ms, about 2000 ticks. A gate that closes with the
|
||||||
|
# hold darkens all of it but the producer's lead (10 ms, ~280 ticks);
|
||||||
|
# a fire state that outlives the gate lights it to the last step.
|
||||||
|
DECEL_TICKS = int(50.0 / 700.0 * MACHINE_TICK_HZ)
|
||||||
|
DECEL_DARK_MIN = DECEL_TICKS - 600
|
||||||
|
|
||||||
|
|
||||||
|
def dark_lead(ticks, hold):
|
||||||
|
"""Ticks between the last FIRE tick before `hold` and its stop."""
|
||||||
|
h0 = hold[0]
|
||||||
|
last = next((i for i in range(h0 - 1, -1, -1) if ticks[i] & 0x10), None)
|
||||||
|
return h0 - last if last is not None else h0
|
||||||
|
|
||||||
|
|
||||||
|
def check_decel_dark(name, ticks, hold, what):
|
||||||
|
"""No FIRE tick in the deceleration into `hold` beyond the lead: the
|
||||||
|
gate closed with the cause (a closed window, a lost sender)."""
|
||||||
|
lead = dark_lead(ticks, hold)
|
||||||
|
if lead < DECEL_DARK_MIN:
|
||||||
|
fail("[%s] the deceleration into the hold ran lit: the last FIRE tick is %d "
|
||||||
|
"ticks before the stationary stretch, expected at least %d (%s)"
|
||||||
|
% (name, lead, DECEL_DARK_MIN, what))
|
||||||
|
return lead
|
||||||
|
|
||||||
|
|
||||||
|
# A lit deceleration ends within the producer's lead (10 ms) plus one
|
||||||
|
# shipper period (10 ms) of the stop: the gate closes when the core
|
||||||
|
# reports the hold complete, and the bytes produced ahead of the cursor
|
||||||
|
# by then ship dark. 1000 ticks is 35 ms, under 0.2 mm at the end of a
|
||||||
|
# ramp from 50 mm/s; a gate that closed before the head stopped shows
|
||||||
|
# as the whole deceleration (~2000 ticks) or more.
|
||||||
|
DECEL_LIT_MAX = 1000
|
||||||
|
|
||||||
|
|
||||||
|
def check_decel_lit(name, ticks, hold, what):
|
||||||
|
"""FIRE ran to the stop: the pause tier keeps the beam through the
|
||||||
|
deceleration it planned, so no dark motion precedes the hold."""
|
||||||
|
lead = dark_lead(ticks, hold)
|
||||||
|
if lead > DECEL_LIT_MAX:
|
||||||
|
fail("[%s] %d ticks (%.0f ms) of dark motion before the stop, expected at most %d: "
|
||||||
|
"the gate closed before the head stopped (%s)"
|
||||||
|
% (name, lead, lead * 1e3 / MACHINE_TICK_HZ, DECEL_LIT_MAX, what))
|
||||||
|
return lead
|
||||||
|
|
||||||
|
|
||||||
def tick_bytes(data):
|
def tick_bytes(data):
|
||||||
"""The stream with power bytes stripped (tick bytes only)."""
|
"""The stream with power bytes stripped (tick bytes only)."""
|
||||||
return bytes(b for b in data if not b & 0x80)
|
return bytes(b for b in data if not b & 0x80)
|
||||||
@@ -1092,42 +1235,45 @@ def main():
|
|||||||
"(%d ticks), lit from the first step out (%d fire ticks)"
|
"(%d ticks), lit from the first step out (%d fire ticks)"
|
||||||
% (mode, decel, dlen, accel))
|
% (mode, decel, dlen, accel))
|
||||||
|
|
||||||
# --- rule 24: a hold verdict is held again after a resume -----------
|
# --- rule 24: the verdict's pause tier -------------------------------
|
||||||
# One long line at 50 mm/s. Mid-move the engine's verdict goes to its
|
# One long line at 50 mm/s. Mid-move the engine's verdict goes to its
|
||||||
# fail tier (hold, fire blocked, no resume: an airflow fault) and the
|
# pause tier (OVERTEMP: hold, fire blocked, no resume): the client
|
||||||
# client takes the feed hold. A ~ then resumes the job under the
|
# takes the feed hold, and the stream's gate darkens the deceleration
|
||||||
# standing verdict, which is what a button press or a sender does;
|
# into it; the latch is not written. A ~ then resumes the job under
|
||||||
# the client must hold it again within its poll, saying so, and the
|
# the standing verdict, which is what a button press or a sender
|
||||||
# stretch it moved in between ships dark. The clean verdict then
|
# does; the client must hold it again within its poll, saying so,
|
||||||
# resumes the hold the client took, and the rest of the line cuts lit.
|
# and the stretch it moved in between ships dark. The clean verdict
|
||||||
|
# then resumes the hold the client took, and the rest of the line
|
||||||
|
# cuts lit; M2 closes the window and locks.
|
||||||
TICK_HZ = 28160
|
TICK_HZ = 28160
|
||||||
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
|
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
|
||||||
("verdict", "hold"), ("wait_state", "Hold:0"), ("sleep", 0.5),
|
("verdict", "hold"), ("wait_state", "Hold:0"), ("sleep", 0.5),
|
||||||
("rt", b"~"), ("sleep", 1.2), ("wait_state", "Hold:0"),
|
("rt", b"~"), ("sleep", 1.2), ("wait_state", "Hold:0"),
|
||||||
("sleep", 0.5), ("verdict", "clean"), WAIT_IDLE, "M5"]
|
("sleep", 0.5), ("verdict", "clean"), WAIT_IDLE, "M5", "M2",
|
||||||
|
("expect_text", "laser disarmed")]
|
||||||
data = run_session("verdict-rehold", steps, conf=DENSITY_CONF_FLOORED)
|
data = run_session("verdict-rehold", steps, conf=DENSITY_CONF_FLOORED)
|
||||||
text = run_session.text
|
text = run_session.text
|
||||||
if "held again" not in text:
|
if "held again" not in text:
|
||||||
fail("[verdict-rehold] the client did not say it held the job again")
|
fail("[verdict-rehold] the client did not say it held the job again")
|
||||||
if "resuming" not in text:
|
if "resuming" not in text:
|
||||||
fail("[verdict-rehold] the client did not resume its own hold once the verdict cleared")
|
fail("[verdict-rehold] the client did not resume its own hold once the verdict cleared")
|
||||||
|
if "fire masked, job held" not in text:
|
||||||
|
fail("[verdict-rehold] the pause tier did not report the masked hold")
|
||||||
|
if "latch locked" in text.split("Pgm End")[0]:
|
||||||
|
fail("[verdict-rehold] the pause tier locked the latch")
|
||||||
|
if "ALARM" in text:
|
||||||
|
fail("[verdict-rehold] the pause tier raised an alarm")
|
||||||
ticks = tick_bytes(data)
|
ticks = tick_bytes(data)
|
||||||
step = [1 if t & 0x05 else 0 for t in ticks]
|
|
||||||
fire = [1 if t & 0x10 else 0 for t in ticks]
|
fire = [1 if t & 0x10 else 0 for t in ticks]
|
||||||
first = step.index(1)
|
step = [1 if t & 0x05 else 0 for t in ticks]
|
||||||
last = len(step) - 1 - step[::-1].index(1)
|
last = len(step) - 1 - step[::-1].index(1)
|
||||||
holds, run = [], 0 # the stationary stretches inside the motion
|
holds = holds_in(ticks)
|
||||||
for i in range(first, last + 1):
|
|
||||||
if step[i]:
|
|
||||||
if run >= 2000:
|
|
||||||
holds.append((i - run, i))
|
|
||||||
run = 0
|
|
||||||
else:
|
|
||||||
run += 1
|
|
||||||
if len(holds) != 2:
|
if len(holds) != 2:
|
||||||
fail("[verdict-rehold] expected two holds in the stream, found %d: %s"
|
fail("[verdict-rehold] expected two holds in the stream, found %d: %s"
|
||||||
% (len(holds), holds))
|
% (len(holds), holds))
|
||||||
(_h1s, h1e), (h2s, h2e) = holds
|
(_h1s, h1e), (h2s, h2e) = holds
|
||||||
|
lit_lead = check_decel_lit("verdict-rehold", ticks, holds[0],
|
||||||
|
"a pause keeps the beam through its first deceleration")
|
||||||
between = sum(fire[h1e:h2s])
|
between = sum(fire[h1e:h2s])
|
||||||
if between:
|
if between:
|
||||||
fail("[verdict-rehold] FIRE while resumed under the hold verdict: %d fire ticks "
|
fail("[verdict-rehold] FIRE while resumed under the hold verdict: %d fire ticks "
|
||||||
@@ -1139,9 +1285,184 @@ def main():
|
|||||||
if lit_after < 50:
|
if lit_after < 50:
|
||||||
fail("[verdict-rehold] the resume after the clean verdict ran dark (%d fire ticks)"
|
fail("[verdict-rehold] the resume after the clean verdict ran dark (%d fire ticks)"
|
||||||
% lit_after)
|
% lit_after)
|
||||||
print("PASS [verdict-rehold]: held, resumed dark for %d ticks (%.2f s), held again, "
|
seq = latch_transitions(run_session.latch)
|
||||||
"lit after the clear (%d fire ticks)"
|
if seq != ["unlock", "lock"]:
|
||||||
% (h2s - h1e, (h2s - h1e) / float(TICK_HZ), lit_after))
|
fail("[verdict-rehold] latch ownership sequence %s, expected unlock (arm) and lock "
|
||||||
|
"(program end) only: a pause must not write the latch" % seq)
|
||||||
|
print("PASS [verdict-rehold]: held lit to %d ticks before the stop, resumed dark for "
|
||||||
|
"%d ticks (%.2f s), held again, lit after the clear (%d fire ticks), latch %s"
|
||||||
|
% (lit_lead, h2s - h1e, (h2s - h1e) / float(TICK_HZ), lit_after, seq))
|
||||||
|
|
||||||
|
# --- rule 25: the verdict's fail tier --------------------------------
|
||||||
|
# The same line; mid-move the verdict goes to AIRFLOW. The job ends:
|
||||||
|
# window closed, latch locked, ALARM:3, the stream short and dark at
|
||||||
|
# its end, and the clean verdict that follows resumes nothing.
|
||||||
|
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
|
||||||
|
("verdict", "fail"), ("wait_state", "Alarm", 5.0),
|
||||||
|
("expect_text", "laser disarmed"), ("verdict", "clean"), ("sleep", 1.0),
|
||||||
|
("rt", b"~"), ("sleep", 0.5), ("wait_state", "Alarm", 2.0), "$X"]
|
||||||
|
data = run_session("verdict-fail", steps, conf=DENSITY_CONF_FLOORED)
|
||||||
|
text = run_session.text
|
||||||
|
if "ALARM:3" not in text:
|
||||||
|
fail("[verdict-fail] the fail tier did not end the job with ALARM:3")
|
||||||
|
if "AIRFLOW" not in text:
|
||||||
|
fail("[verdict-fail] the fail tier did not name the verdict")
|
||||||
|
if "resuming" in text or "held again" in text:
|
||||||
|
fail("[verdict-fail] the fail tier was treated as a pause")
|
||||||
|
check_termination("verdict-fail", data)
|
||||||
|
line_ticks = 150.0 / 50.0 * TICK_HZ
|
||||||
|
lit = count_fire(data)
|
||||||
|
if lit > line_ticks * 0.5:
|
||||||
|
fail("[verdict-fail] %d fire ticks: the job ran on past the fail-tier verdict "
|
||||||
|
"(the whole line is %d)" % (lit, line_ticks))
|
||||||
|
seq = latch_transitions(run_session.latch)
|
||||||
|
if seq != ["unlock", "lock"]:
|
||||||
|
fail("[verdict-fail] latch ownership sequence %s, expected unlock (arm), lock "
|
||||||
|
"(the fail tier), and nothing after" % seq)
|
||||||
|
print("PASS [verdict-fail]: AIRFLOW mid-cut ended the job with ALARM:3, %d of %d ticks "
|
||||||
|
"lit, ends dark, latch %s, ~ resumed nothing" % (lit, line_ticks, seq))
|
||||||
|
|
||||||
|
# --- rule 26: a sender change mid-M3 darkens the hold's decel ---------
|
||||||
|
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
|
||||||
|
("reconnect", 0.3), ("wait_state", "Hold:0", 5.0), ("sleep", 0.5),
|
||||||
|
("rt", b"\x18"), ("sleep", 0.5)]
|
||||||
|
data = run_session("sender-drop", steps, conf=DENSITY_CONF_FLOORED)
|
||||||
|
ticks = tick_bytes(data)
|
||||||
|
# No cycle follows the hold here, so the stream ends on the hold's
|
||||||
|
# deceleration: the stop is the tick after the last step.
|
||||||
|
step = [1 if t & 0x25 else 0 for t in ticks]
|
||||||
|
if 1 not in step:
|
||||||
|
fail("[sender-drop] no motion in the stream")
|
||||||
|
stop = len(step) - step[::-1].index(1)
|
||||||
|
if count_fire(data) < 1000:
|
||||||
|
fail("[sender-drop] the cut before the sender change ran dark (%d fire ticks)"
|
||||||
|
% count_fire(data))
|
||||||
|
dark = check_decel_dark("sender-drop", ticks, (stop, stop),
|
||||||
|
"the gate must follow the window closed on the sender change")
|
||||||
|
seq = latch_transitions(run_session.latch)
|
||||||
|
if seq[:2] != ["unlock", "lock"]:
|
||||||
|
fail("[sender-drop] latch ownership sequence %s, expected unlock (arm), lock "
|
||||||
|
"(the sender change)" % seq)
|
||||||
|
print("PASS [sender-drop]: the sender change held the job with the decel dark from %d "
|
||||||
|
"ticks before the stop, latch %s" % (dark, seq))
|
||||||
|
|
||||||
|
# --- rule 27: a stale verdict holds the moment the cache expires -----
|
||||||
|
# The engine stops publishing mid-cut. The client reads the file
|
||||||
|
# every 500 ms and its cache expires on its own clock between two
|
||||||
|
# reads: the hold must land at the expiry, not at the next read, and
|
||||||
|
# the deceleration runs lit to the stop like rule 24's, so no dark
|
||||||
|
# cut runs out in between. The engine's return resumes the cut lit.
|
||||||
|
steps = ["G90", "G21", "M3 S500", "G1 X150 F3000", ("sleep", 0.7),
|
||||||
|
("verdict", "stale"), ("wait_state", "Hold:0", 6.0), ("sleep", 0.5),
|
||||||
|
("verdict", "clean"), WAIT_IDLE, "M5", "M2", ("expect_text", "laser disarmed")]
|
||||||
|
data = run_session("verdict-stale", steps, conf=DENSITY_CONF_FLOORED)
|
||||||
|
text = run_session.text
|
||||||
|
if "cooling service lost" not in text:
|
||||||
|
fail("[verdict-stale] the client did not report the engine gone")
|
||||||
|
if "resuming" not in text or "restored" not in text:
|
||||||
|
fail("[verdict-stale] the client did not resume once the engine returned")
|
||||||
|
ticks = tick_bytes(data)
|
||||||
|
holds = holds_in(ticks)
|
||||||
|
if len(holds) != 1:
|
||||||
|
fail("[verdict-stale] expected one hold in the stream, found %d: %s" % (len(holds), holds))
|
||||||
|
lit_lead = check_decel_lit("verdict-stale", ticks, holds[0],
|
||||||
|
"the hold must land at the cache's expiry, lit to the stop")
|
||||||
|
fire = [1 if t & 0x10 else 0 for t in ticks]
|
||||||
|
step = [1 if t & 0x05 else 0 for t in ticks]
|
||||||
|
last = len(step) - 1 - step[::-1].index(1)
|
||||||
|
lit_after = sum(fire[holds[0][1]:last + 1])
|
||||||
|
if lit_after < 50:
|
||||||
|
fail("[verdict-stale] the resume after the engine's return ran dark (%d fire ticks)" % lit_after)
|
||||||
|
seq = latch_transitions(run_session.latch)
|
||||||
|
if seq != ["unlock", "lock"]:
|
||||||
|
fail("[verdict-stale] latch ownership sequence %s, expected unlock (arm) and lock "
|
||||||
|
"(program end) only" % seq)
|
||||||
|
print("PASS [verdict-stale]: the expired cache held the job lit to %d ticks (%.0f ms) "
|
||||||
|
"before the stop, lit after the return (%d fire ticks), latch %s"
|
||||||
|
% (lit_lead, lit_lead * 1e3 / MACHINE_TICK_HZ, lit_after, seq))
|
||||||
|
|
||||||
|
# --- rules 28 and 29: the stalls -------------------------------------
|
||||||
|
# The step density the job plans: F3000 at 53.333 steps/mm is 2667
|
||||||
|
# steps/s, 9.5 per 100 ticks. A clamp compresses the late events onto
|
||||||
|
# consecutive bytes, which is what the window count catches.
|
||||||
|
STALL_JOB = ["G90", "G21", "G1 X150 F3000"]
|
||||||
|
ARMED_STALL_JOB = ["G90", "G21", "M3 S500", "G1 X150 F3000"]
|
||||||
|
PLANNED_PER_100 = 3000.0 / 60.0 * STEPS_PER_MM / MACHINE_TICK_HZ * 100.0
|
||||||
|
BURST_LIMIT = int(PLANNED_PER_100 * 1.3) + 1
|
||||||
|
|
||||||
|
def max_steps_per_window(ticks, win=100):
|
||||||
|
worst = 0
|
||||||
|
for i in range(0, max(1, len(ticks) - win), win // 2):
|
||||||
|
worst = max(worst, sum(1 for t in ticks[i:i + win] if t & 0x01))
|
||||||
|
return worst
|
||||||
|
|
||||||
|
# 28a: the armed stall is a fault. The job ends in ALARM:17; a reset
|
||||||
|
# and an unlock bring the controller back so the session can end.
|
||||||
|
steps = ARMED_STALL_JOB + [("wait_state", "Alarm", 6.0), ("expect_text", "laser disarmed", 3.0),
|
||||||
|
("rt", b"\x18"), ("sleep", 1.0), "$X"]
|
||||||
|
data = run_session("stall-armed", steps, conf=DENSITY_CONF_FLOORED,
|
||||||
|
env_extra={"GFSINK_STALL_MS": "300"})
|
||||||
|
text, err = run_session.text, run_session.err
|
||||||
|
if "ALARM:17" not in text:
|
||||||
|
fail("[stall-armed] a producer stall inside the armed window did not fault (no ALARM:17)")
|
||||||
|
if "late events while the laser is armed" not in err:
|
||||||
|
fail("[stall-armed] the fault was not named in the log")
|
||||||
|
ticks = tick_bytes(data)
|
||||||
|
burst = max_steps_per_window(ticks)
|
||||||
|
if burst > BURST_LIMIT:
|
||||||
|
fail("[stall-armed] %d steps in a 100-tick window (planned %.1f): the clamped events "
|
||||||
|
"were shipped as a burst" % (burst, PLANNED_PER_100))
|
||||||
|
check_termination("stall-armed", data)
|
||||||
|
seq = latch_transitions(run_session.latch)
|
||||||
|
if seq != ["unlock", "lock"]:
|
||||||
|
fail("[stall-armed] latch ownership sequence %s, expected unlock (arm), lock (the fault)" % seq)
|
||||||
|
print("PASS [stall-armed]: a 300 ms producer stall while armed faulted with ALARM:17, "
|
||||||
|
"%d steps per 100 ticks at most (planned %.1f), ends dark, latch %s"
|
||||||
|
% (burst, PLANNED_PER_100, seq))
|
||||||
|
|
||||||
|
# 28b: unarmed, the same stall is a warning and the move completes with
|
||||||
|
# every step, compressed.
|
||||||
|
data = run_session("stall-unarmed", STALL_JOB, conf=DENSITY_CONF_FLOORED,
|
||||||
|
arm_required=False, env_extra={"GFSINK_STALL_MS": "300"})
|
||||||
|
text, err = run_session.text, run_session.err
|
||||||
|
if "ALARM" in text:
|
||||||
|
fail("[stall-unarmed] an unarmed stall raised an alarm")
|
||||||
|
if "late events clamped" not in err:
|
||||||
|
fail("[stall-unarmed] the clamp was not warned about in the log")
|
||||||
|
ticks = tick_bytes(data)
|
||||||
|
total = sum(1 for t in ticks if t & 0x01)
|
||||||
|
want = round(150 * STEPS_PER_MM)
|
||||||
|
if abs(total - want) > 2:
|
||||||
|
fail("[stall-unarmed] %d X steps in the stream, expected %d: steps were lost" % (total, want))
|
||||||
|
burst = max_steps_per_window(ticks)
|
||||||
|
if burst <= BURST_LIMIT:
|
||||||
|
fail("[stall-unarmed] no step burst in the stream (%d per 100 ticks): the stall knob "
|
||||||
|
"did not stall the producer" % burst)
|
||||||
|
print("PASS [stall-unarmed]: the same stall unarmed was warned, the move completed with "
|
||||||
|
"all %d steps, the clamp visible as %d steps per 100 ticks" % (total, burst))
|
||||||
|
|
||||||
|
# 29: a write stall stalls only the shipper. The producer keeps its
|
||||||
|
# pace behind it, so nothing clamps and nothing bursts.
|
||||||
|
data = run_session("write-stall", ARMED_STALL_JOB + [WAIT_IDLE, "M5"], conf=DENSITY_CONF_FLOORED,
|
||||||
|
env_extra={"GFSINK_WRITE_STALL_MS": "300"})
|
||||||
|
text, err = run_session.text, run_session.err
|
||||||
|
if "ALARM" in text:
|
||||||
|
fail("[write-stall] a write stall raised an alarm")
|
||||||
|
if "clamped" in err:
|
||||||
|
fail("[write-stall] a write stall clamped producer events: the back-off held the lock")
|
||||||
|
ticks = tick_bytes(data)
|
||||||
|
total = sum(1 for t in ticks if t & 0x01)
|
||||||
|
if abs(total - want) > 2:
|
||||||
|
fail("[write-stall] %d X steps in the stream, expected %d" % (total, want))
|
||||||
|
burst = max_steps_per_window(ticks)
|
||||||
|
if burst > BURST_LIMIT:
|
||||||
|
fail("[write-stall] %d steps in a 100-tick window (planned %.1f): a burst" % (burst, PLANNED_PER_100))
|
||||||
|
lit = count_fire(data)
|
||||||
|
if lit < 0.8 * want:
|
||||||
|
fail("[write-stall] the cut ran dark through the stall (%d fire ticks)" % lit)
|
||||||
|
check_termination("write-stall", data)
|
||||||
|
print("PASS [write-stall]: a 300 ms write stall left the producer on pace: all %d steps, "
|
||||||
|
"%d per 100 ticks at most, %d fire ticks, ends dark" % (total, burst, lit))
|
||||||
|
|
||||||
# --- rule 22: a jog never fires, whatever the modal spindle says ----
|
# --- rule 22: a jog never fires, whatever the modal spindle says ----
|
||||||
# The arm flow runs on the M3 (window open), the modal spindle is on
|
# The arm flow runs on the M3 (window open), the modal spindle is on
|
||||||
|
|||||||
@@ -25,6 +25,30 @@ no root) and drives it over TCP:
|
|||||||
does not free Z either
|
does not free Z either
|
||||||
6. X and Y are still free after both writes
|
6. X and Y are still free after both writes
|
||||||
|
|
||||||
|
A referenced lens opens the envelope to the window the shared settings
|
||||||
|
hold, the same keys the daemon's wizards build their Z from. forgectrl
|
||||||
|
leaves the lens on its hall edge and a marker in the state directory
|
||||||
|
before the controller starts; the driver takes it as it loads its
|
||||||
|
settings and places the edge on its step grid:
|
||||||
|
|
||||||
|
7. the marker with the edge alone: Z sits at the edge's grid step, the
|
||||||
|
fallback window (10 below, 12 above) runs to its ends, and two
|
||||||
|
half-steps past either end alarms
|
||||||
|
8. the marker with the focus card's stops (14 below, 20 above): the
|
||||||
|
wider window runs to its ends, two half-steps past either alarms
|
||||||
|
9. a stop count out of range (41) falls back on its side alone
|
||||||
|
|
||||||
|
The X and Y soft limits are the driver's as well: off while the
|
||||||
|
position is not trusted (the core's $20 cannot be turned on with $22
|
||||||
|
off), on after a home, when the envelope is the bed. A scripted home
|
||||||
|
(homing_mode = gfcloud with a runner that exits 0, the host hook) sets
|
||||||
|
them:
|
||||||
|
|
||||||
|
10. after the home a program move past X or Y max raises ALARM:2
|
||||||
|
before any motion, a jog past it is refused with error 15, and a
|
||||||
|
move inside the bed runs; a stream fault or a controller start
|
||||||
|
drops them again with the reference
|
||||||
|
|
||||||
The binary keeps its settings in EEPROM.DAT in the working directory, so
|
The binary keeps its settings in EEPROM.DAT in the working directory, so
|
||||||
each run starts from defaults in a temporary directory and leaves
|
each run starts from defaults in a temporary directory and leaves
|
||||||
nothing behind.
|
nothing behind.
|
||||||
@@ -53,13 +77,24 @@ def fail(msg):
|
|||||||
|
|
||||||
|
|
||||||
class Session:
|
class Session:
|
||||||
"""One null-sink controller process with its own settings store."""
|
"""One null-sink controller process with its own settings store.
|
||||||
|
`conf` (key: value) is the shared config the driver reads; with
|
||||||
|
`referenced` the lens marker forgectrl leaves is in the state
|
||||||
|
directory, so the driver takes the lens reference at its start."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, conf=None, referenced=False):
|
||||||
self.workdir = tempfile.mkdtemp(prefix="z-envelope-")
|
self.workdir = tempfile.mkdtemp(prefix="z-envelope-")
|
||||||
env = dict(os.environ, GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
|
env = dict(os.environ, GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
|
||||||
for key in ("GFSINK", "GF_SWITCH_FILE", "GF_VERDICT_FILE"):
|
for key in ("GFSINK", "GF_SWITCH_FILE", "GF_VERDICT_FILE"):
|
||||||
env.pop(key, None)
|
env.pop(key, None)
|
||||||
|
conf_path = os.path.join(self.workdir, "forgefirm.conf")
|
||||||
|
with open(conf_path, "w") as f:
|
||||||
|
for k, v in (conf or {}).items():
|
||||||
|
f.write("%s = %s\n" % (k, v))
|
||||||
|
env["GFHOME_CONF"] = conf_path
|
||||||
|
if referenced:
|
||||||
|
with open(os.path.join(self.workdir, "lens.home"), "w") as f:
|
||||||
|
f.write("edge 0 3 3\n")
|
||||||
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
|
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
|
||||||
cwd=self.workdir, env=env,
|
cwd=self.workdir, env=env,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
@@ -113,6 +148,23 @@ class Session:
|
|||||||
m = re.search(r"<(\w+)", self.send("?"))
|
m = re.search(r"<(\w+)", self.send("?"))
|
||||||
return m.group(1) if m else "?"
|
return m.group(1) if m else "?"
|
||||||
|
|
||||||
|
def mpos_z(self):
|
||||||
|
"""The reported machine Z (no work offset is set in a session)."""
|
||||||
|
m = re.search(r"[MW]Pos:(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)", self.send("?"))
|
||||||
|
if not m:
|
||||||
|
fail("no position in the status report")
|
||||||
|
return float(m.group(3))
|
||||||
|
|
||||||
|
def wait_idle(self, timeout=15.0):
|
||||||
|
"""A move that ran plays out before the next line is judged: the
|
||||||
|
soft limit on a moving machine holds first and alarms after."""
|
||||||
|
end = time.time() + timeout
|
||||||
|
while time.time() < end:
|
||||||
|
if self.state() == "Idle":
|
||||||
|
return
|
||||||
|
time.sleep(0.1)
|
||||||
|
fail("the controller did not return to Idle")
|
||||||
|
|
||||||
def move(self, gcode):
|
def move(self, gcode):
|
||||||
"""Run one move. Returns True when the soft limit blocked it.
|
"""Run one move. Returns True when the soft limit blocked it.
|
||||||
An alarm needs a reset and an unlock before the next move."""
|
An alarm needs a reset and an unlock before the next move."""
|
||||||
@@ -160,6 +212,105 @@ def free_in_xy(s, when):
|
|||||||
check(ran, "X and Y free %s" % when, "an X or Y move was blocked %s" % when)
|
check(ran, "X and Y free %s" % when, "an X or Y move was blocked %s" % when)
|
||||||
|
|
||||||
|
|
||||||
|
def window_holds(s, steps, below, above, what):
|
||||||
|
"""The envelope of a referenced lens whose edge sits on grid step
|
||||||
|
`steps`: the ends of the reach (`below` and `above` half-steps from
|
||||||
|
the edge) run, two half-steps past either end alarms. The controller
|
||||||
|
keeps a half-step of slack beyond the reach, so one past is not
|
||||||
|
judged here; two is outside on every head."""
|
||||||
|
spm = float(s.setting("$102"))
|
||||||
|
top, bottom = (steps + above) / spm, (steps - below) / spm
|
||||||
|
s.wait_idle()
|
||||||
|
check(not s.move("G0 Z%.3f" % top), "%s: the top of the reach, Z %.3f, runs" % (what, top),
|
||||||
|
"%s: Z %.3f, the top of the reach, was blocked" % (what, top))
|
||||||
|
s.wait_idle()
|
||||||
|
check(not s.move("G0 Z%.3f" % bottom), "%s: the bottom of the reach, Z %.3f, runs" % (what, bottom),
|
||||||
|
"%s: Z %.3f, the bottom of the reach, was blocked" % (what, bottom))
|
||||||
|
over, under = (steps + above + 2) / spm, (steps - below - 2) / spm
|
||||||
|
s.wait_idle()
|
||||||
|
check(s.move("G0 Z%.3f" % over), "%s: two half-steps over the top, Z %.3f, alarms" % (what, over),
|
||||||
|
"%s: Z %.3f, two half-steps over the top, ran" % (what, over))
|
||||||
|
s.wait_idle()
|
||||||
|
check(s.move("G0 Z%.3f" % under), "%s: two half-steps under the bottom, Z %.3f, alarms" % (what, under),
|
||||||
|
"%s: Z %.3f, two half-steps under the bottom, ran" % (what, under))
|
||||||
|
|
||||||
|
|
||||||
|
def referenced_cases():
|
||||||
|
"""The lens referenced by forgectrl's marker: the envelope is the
|
||||||
|
window the shared settings hold, as the daemon's wizards read it."""
|
||||||
|
edge = 3.35
|
||||||
|
conf = {"lens_hall_edge_z_mm": "%.2f" % edge}
|
||||||
|
s = Session(conf=conf, referenced=True)
|
||||||
|
try:
|
||||||
|
spm = float(s.setting("$102"))
|
||||||
|
steps = int(round(edge * spm))
|
||||||
|
z = s.mpos_z()
|
||||||
|
check(abs(z - steps / spm) < 0.002,
|
||||||
|
"referenced: Z sits at the edge's grid step, %.3f" % z,
|
||||||
|
"referenced: Z reads %.3f, not the edge's grid step %.3f" % (z, steps / spm))
|
||||||
|
window_holds(s, steps, 10, 12, "the fallback window")
|
||||||
|
free_in_xy(s, "with the lens referenced")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
s = Session(conf=dict(conf, lens_stop_below_steps="14", lens_stop_above_steps="20"), referenced=True)
|
||||||
|
try:
|
||||||
|
window_holds(s, steps, 14, 20, "the focus card's window")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
s = Session(conf=dict(conf, lens_stop_below_steps="41", lens_stop_above_steps="20"), referenced=True)
|
||||||
|
try:
|
||||||
|
window_holds(s, steps, 10, 20, "a below count out of range")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
|
def homed_cases():
|
||||||
|
"""After a scripted gfcloud home the bed is the X/Y envelope."""
|
||||||
|
conf = {"lens_hall_edge_z_mm": "3.35", "homing_mode": "gfcloud", "gfcloud_home_cmd": "true"}
|
||||||
|
s = Session(conf=conf, referenced=True)
|
||||||
|
try:
|
||||||
|
free_in_xy(s, "before the home")
|
||||||
|
x_travel = float(s.setting("$130"))
|
||||||
|
y_travel = float(s.setting("$131"))
|
||||||
|
reply = s.send("$H", 5.0)
|
||||||
|
if "ok" not in reply:
|
||||||
|
fail("the scripted home was refused: %r" % reply)
|
||||||
|
s.wait_idle()
|
||||||
|
st = s.send("?")
|
||||||
|
m = re.search(r"MPos:(-?[\d.]+),(-?[\d.]+)", st)
|
||||||
|
check(m and abs(float(m.group(1))) < 0.01 and abs(float(m.group(2))) < 0.01,
|
||||||
|
"homed: X and Y sit at the origin", "homed: X/Y are not at the origin: %r" % st)
|
||||||
|
check(s.move("G90 G0 X%.1f" % (x_travel + 5)), "homed: X past the bed alarms",
|
||||||
|
"homed: a move past X max ran")
|
||||||
|
s.wait_idle()
|
||||||
|
check(s.move("G90 G0 Y%.1f" % (y_travel + 5)), "homed: Y past the bed alarms",
|
||||||
|
"homed: a move past Y max ran")
|
||||||
|
s.wait_idle()
|
||||||
|
check(s.move("G90 G0 X-1"), "homed: X past the near edge alarms",
|
||||||
|
"homed: a move past X min ran")
|
||||||
|
s.wait_idle()
|
||||||
|
reply = s.send("$J=G91X%.1fF1200" % (x_travel + 5), 1.5)
|
||||||
|
check("error:15" in reply, "homed: a jog past the bed is refused with error 15",
|
||||||
|
"homed: a jog past the bed was not refused with error 15: %r" % reply)
|
||||||
|
# The core answers the line after an error with that error again
|
||||||
|
# until an empty line (or a $ command) clears it: the sender's
|
||||||
|
# acknowledgment at compatibility level 0.
|
||||||
|
s.send("", 1.0)
|
||||||
|
check(not s.move("G90 G0 X10 Y10"), "homed: a move inside the bed runs",
|
||||||
|
"homed: a move inside the bed was blocked")
|
||||||
|
s.wait_idle()
|
||||||
|
# A settings write does not drop them: the driver re-applies the
|
||||||
|
# mask after the core clears it.
|
||||||
|
s.write_setting("$20=0")
|
||||||
|
check(s.move("G90 G0 X%.1f" % (x_travel + 5)), "homed: X past the bed still alarms after a $20 write",
|
||||||
|
"homed: a $20 write freed X")
|
||||||
|
s.wait_idle()
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if not os.path.isfile(BIN):
|
if not os.path.isfile(BIN):
|
||||||
fail("no controller binary at %s" % BIN)
|
fail("no controller binary at %s" % BIN)
|
||||||
@@ -189,6 +340,8 @@ def main():
|
|||||||
free_in_xy(s, "after the settings writes")
|
free_in_xy(s, "after the settings writes")
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
s.close()
|
||||||
|
referenced_cases()
|
||||||
|
homed_cases()
|
||||||
print("PASS z_envelope_test")
|
print("PASS z_envelope_test")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -365,6 +365,19 @@ else
|
|||||||
DOCS_TAG_CMD="git -C $DOCS_DIR push origin v$VERSION"
|
DOCS_TAG_CMD="git -C $DOCS_DIR push origin v$VERSION"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The release notes: releases/v<version>/notes.md when the release carries
|
||||||
|
# one (staged beside the assets, not uploaded as one), GitHub's generated
|
||||||
|
# notes otherwise.
|
||||||
|
NOTES_OPT="--generate-notes"
|
||||||
|
if [ -f "$REPO/releases/v$VERSION/notes.md" ]; then
|
||||||
|
cp "$REPO/releases/v$VERSION/notes.md" "$STAGE/notes.md"
|
||||||
|
NOTES_OPT="--notes-file notes.md"
|
||||||
|
echo "notes: releases/v$VERSION/notes.md"
|
||||||
|
else
|
||||||
|
rm -f "$STAGE/notes.md"
|
||||||
|
echo "notes: generated by GitHub"
|
||||||
|
fi
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
||||||
== release v$VERSION staged ==
|
== release v$VERSION staged ==
|
||||||
@@ -377,7 +390,7 @@ Pre-publish checklist (docs.forgefirm.org, Developers, "Release flow"):
|
|||||||
Publish (from a directory with an authenticated gh):
|
Publish (from a directory with an authenticated gh):
|
||||||
cd "$STAGE"
|
cd "$STAGE"
|
||||||
gh release create "v$VERSION" --repo openglow-org/forgefirm \\
|
gh release create "v$VERSION" --repo openglow-org/forgefirm \\
|
||||||
--title "ForgeFIRM v$VERSION" --generate-notes $PRERELEASE \\
|
--title "ForgeFIRM v$VERSION" $NOTES_OPT $PRERELEASE \\
|
||||||
$ASSETS
|
$ASSETS
|
||||||
|
|
||||||
Push the documentation tag with it:
|
Push the documentation tag with it:
|
||||||
@@ -387,7 +400,7 @@ EOF
|
|||||||
if [ "$PUBLISH" = "1" ]; then
|
if [ "$PUBLISH" = "1" ]; then
|
||||||
command -v gh >/dev/null || die "--publish requested but gh is not on PATH"
|
command -v gh >/dev/null || die "--publish requested but gh is not on PATH"
|
||||||
( cd "$STAGE" && gh release create "v$VERSION" --repo openglow-org/forgefirm \
|
( cd "$STAGE" && gh release create "v$VERSION" --repo openglow-org/forgefirm \
|
||||||
--title "ForgeFIRM v$VERSION" --generate-notes $PRERELEASE \
|
--title "ForgeFIRM v$VERSION" $NOTES_OPT $PRERELEASE \
|
||||||
$ASSETS ) \
|
$ASSETS ) \
|
||||||
|| die "gh release create failed"
|
|| die "gh release create failed"
|
||||||
echo "== published v$VERSION =="
|
echo "== published v$VERSION =="
|
||||||
|
|||||||
Reference in New Issue
Block a user