mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-16 01:29:55 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8d4560ff0 | ||
|
|
00cc009871 | ||
|
|
c47f7d9a86 | ||
|
|
c217e24905 | ||
|
|
b94c990bbc | ||
|
|
8d8ca2a8c6 | ||
|
|
4fbf14195b | ||
|
|
d3fe1d90b9 | ||
|
|
594b6990fd | ||
|
|
57ba3454b4 | ||
|
|
d58ee39086 | ||
|
|
b2c42f0cc3 | ||
|
|
1dd6ad608b | ||
|
|
f0c40e7d4f | ||
|
|
d858a23f45 | ||
|
|
c7b80ab2e8 | ||
|
|
468e92655a | ||
|
|
d898b5659d |
+143
-13
@@ -65,6 +65,21 @@ FIXED_SYSFS = [
|
|||||||
("head/white_led", "0"),
|
("head/white_led", "0"),
|
||||||
("thermal/heater_pwm", "0"),
|
("thermal/heater_pwm", "0"),
|
||||||
("thermal/tec_on", "0"),
|
("thermal/tec_on", "0"),
|
||||||
|
# Purge air runs continuously, as on the factory machine: the engine
|
||||||
|
# turns it on at start and only a listening (the quiet hold) switches
|
||||||
|
# it off, which puts it back on release. A check that switches it off
|
||||||
|
# to measure it and forgets leaves the machine one job away from an
|
||||||
|
# airflow hold mid-cut, judged against the floor that same check just
|
||||||
|
# wrote, and nothing notices until the daemon restarts: the engine's
|
||||||
|
# idle phase never re-applies its own duties. The airflow check did
|
||||||
|
# exactly that, and it reached an operator's first fire.
|
||||||
|
("head/purge_air", "1"),
|
||||||
|
# The lens motor at rest: hold current, half step. The lens checks and
|
||||||
|
# the sheet cards take it to the run current in full or half step and
|
||||||
|
# must hand it back, or the motor sits hot and the next reference
|
||||||
|
# starts from a state nobody chose.
|
||||||
|
("head/z_current", "1"),
|
||||||
|
("head/z_mode", "1"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# The subset the GRBL controller writes at its start: checked and restored
|
# The subset the GRBL controller writes at its start: checked and restored
|
||||||
@@ -72,9 +87,14 @@ FIXED_SYSFS = [
|
|||||||
# every pulse header (step_freq 10 kHz, the run currents), runs at the
|
# every pulse header (step_freq 10 kHz, the run currents), runs at the
|
||||||
# service's own x8 with the module's ramp whatever xy_microsteps says, and
|
# service's own x8 with the module's ramp whatever xy_microsteps says, and
|
||||||
# hands the hold currents back at idle; forcing the GRBL values under it
|
# hands the hold currents back at idle; forcing the GRBL values under it
|
||||||
# would be the baseline configuring another controller's machine.
|
# would be the baseline configuring another controller's machine. The Z
|
||||||
|
# pair belongs with them: the cloud client takes the lens microstep mode
|
||||||
|
# from the pulse header it is playing (ZSmd, gfhardware z_axis
|
||||||
|
# set_mode_from_puls) and the current with it, so a job whose header asks
|
||||||
|
# for full step leaves z_mode 0 behind by doing what it was told.
|
||||||
GRBL_CONTROLLER_SYSFS = ("cnc/motor_lock", "cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
|
GRBL_CONTROLLER_SYSFS = ("cnc/motor_lock", "cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
|
||||||
"cnc/step_freq", "cnc/ramp_rate", "pic/x_step_current", "pic/y_step_current")
|
"cnc/step_freq", "cnc/ramp_rate", "pic/x_step_current", "pic/y_step_current",
|
||||||
|
"head/z_current", "head/z_mode")
|
||||||
|
|
||||||
# Settings the baseline never hands back as bare settings: controller_mode
|
# Settings the baseline never hands back as bare settings: controller_mode
|
||||||
# is the persisted mirror of the live mode (the mode item restores it
|
# is the persisted mirror of the live mode (the mode item restores it
|
||||||
@@ -118,6 +138,13 @@ GRBL_PORT_S = 30 # the Grbl port after the supervisor reports
|
|||||||
|
|
||||||
XY_STEPS_PER_MM = 53.333 # boards/glowforge.h (x8 microstepping)
|
XY_STEPS_PER_MM = 53.333 # boards/glowforge.h (x8 microstepping)
|
||||||
RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most this far
|
RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most this far
|
||||||
|
# The counters count steps; the controller's own returns land within a
|
||||||
|
# tenth of a millimeter of where they started, not on the step. A
|
||||||
|
# difference under this is that quantization, not a leftover: the head is
|
||||||
|
# still put back, so nothing accumulates over a campaign, but the test
|
||||||
|
# that made it does not fail for it. X and Y only - Z is the lens, which
|
||||||
|
# the return never moves, so any Z difference is still a leftover.
|
||||||
|
POSITION_DEADBAND_MM = 0.1
|
||||||
|
|
||||||
# The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset =
|
# The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset =
|
||||||
# 8). The GRBL controller reads it at its start and derives its scale,
|
# 8). The GRBL controller reads it at its start and derives its scale,
|
||||||
@@ -160,11 +187,18 @@ def leds_root():
|
|||||||
return r if r.endswith("/") else r + "/"
|
return r if r.endswith("/") else r + "/"
|
||||||
|
|
||||||
|
|
||||||
def read_led(name):
|
def read_led(name, attr="target"):
|
||||||
|
"""A button LED's commanded level. The trigger fades brightness toward
|
||||||
|
target, so brightness is where the fade has reached and target is what
|
||||||
|
the machine was told: a run that left the button lit left a target, and
|
||||||
|
a run that ended a moment ago may still be fading from one. Falls back
|
||||||
|
to brightness where no target exists."""
|
||||||
|
for a in (attr, "brightness"):
|
||||||
try:
|
try:
|
||||||
with open(leds_root() + name + "/brightness") as f:
|
with open(leds_root() + name + "/" + a) as f:
|
||||||
return f.read().strip()
|
return f.read().strip()
|
||||||
except OSError:
|
except OSError:
|
||||||
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -183,6 +217,19 @@ def read_position():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def position_quantized(was, now):
|
||||||
|
"""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
|
||||||
|
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
|
||||||
|
of a millimeter, which is a step or four, and whether that rounds to
|
||||||
|
the same integer is chance."""
|
||||||
|
if was is None or now is None or now[2] != was[2]:
|
||||||
|
return False
|
||||||
|
return (abs(now[0] - was[0]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM and
|
||||||
|
abs(now[1] - was[1]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM)
|
||||||
|
|
||||||
|
|
||||||
def read_ring_residue():
|
def read_ring_residue():
|
||||||
"""Unplayed bytes queued in the kernel pulse ring: total written minus
|
"""Unplayed bytes queued in the kernel pulse ring: total written minus
|
||||||
processed (the cnc/position byte counters), or None when unreadable.
|
processed (the cnc/position byte counters), or None when unreadable.
|
||||||
@@ -421,6 +468,37 @@ class Baseline:
|
|||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def stand_down(self, why):
|
||||||
|
"""End whatever the machine is still doing, and prove it ended.
|
||||||
|
|
||||||
|
A run that dies mid-job leaves the job alive behind it: the
|
||||||
|
cooling engine armed and holding for a job nothing will finish,
|
||||||
|
the fans at run duty, and the rest of the program still queued in
|
||||||
|
the pulse ring. Waiting does not end any of that - the engine is
|
||||||
|
holding correctly, for a job that is never coming back.
|
||||||
|
|
||||||
|
Stopping the controller ends it. The supervisor brings the
|
||||||
|
controller back, the arm and the hold go with the job, and the
|
||||||
|
ring is empty on the way in. This is what the ring-residue
|
||||||
|
message has always told an operator to do; the hand-back does it
|
||||||
|
instead of saying it.
|
||||||
|
|
||||||
|
Returns the /mode body the machine settled on, or None."""
|
||||||
|
self.log("baseline: standing the machine down (%s): stopping the controller" % why)
|
||||||
|
st, _b = self.fc_post("/controller/stop")
|
||||||
|
if st != 200:
|
||||||
|
self.log("baseline: stand-down: /controller/stop -> %s" % st)
|
||||||
|
deadline = time.time() + 30
|
||||||
|
while time.time() < deadline and not self.abort():
|
||||||
|
body = self.fc_get("/mode")[1] or {}
|
||||||
|
if body.get("controller") != "running":
|
||||||
|
break
|
||||||
|
time.sleep(1.0)
|
||||||
|
st, _b = self.fc_post("/controller/start")
|
||||||
|
if st != 200:
|
||||||
|
self.log("baseline: stand-down: /controller/start -> %s" % st)
|
||||||
|
return self.wait_settled()
|
||||||
|
|
||||||
def cloud_mode(self):
|
def cloud_mode(self):
|
||||||
return self.mode == "cloud"
|
return self.mode == "cloud"
|
||||||
|
|
||||||
@@ -479,7 +557,14 @@ class Baseline:
|
|||||||
before = (mode.get("controller"), mode.get("motion"))
|
before = (mode.get("controller"), mode.get("motion"))
|
||||||
mode = self.wait_settled() or mode
|
mode = self.wait_settled() or mode
|
||||||
if mode.get("controller") == "running" and mode.get("motion") == "verified":
|
if mode.get("controller") == "running" and mode.get("motion") == "verified":
|
||||||
left.append(Leftover("controller", "%s/%s" % before, "running/verified", "waited"))
|
# The supervisor's own work: a takeover ends by starting
|
||||||
|
# forgectrl again, and the respawn runs the liveness probe
|
||||||
|
# and the lens reference before the controller is up and
|
||||||
|
# motion is verified. Waiting for that is right; calling
|
||||||
|
# it a leftover is not, because the run did put it back -
|
||||||
|
# the machine was still doing what it was asked.
|
||||||
|
self.log("controller: %s/%s settled to running/verified on its own; "
|
||||||
|
"the supervisor's own start, not a leftover" % before)
|
||||||
else:
|
else:
|
||||||
left.append(Leftover("controller", "%s/%s" % before, "running/verified",
|
left.append(Leftover("controller", "%s/%s" % before, "running/verified",
|
||||||
"failed: %s/%s" % (mode.get("controller"), mode.get("motion"))))
|
"failed: %s/%s" % (mode.get("controller"), mode.get("motion"))))
|
||||||
@@ -487,12 +572,21 @@ class Baseline:
|
|||||||
st, s = self.fc_get("/status")
|
st, s = self.fc_get("/status")
|
||||||
if st == 200 and isinstance(s, dict):
|
if st == 200 and isinstance(s, dict):
|
||||||
if s.get("state") != "idle":
|
if s.get("state") != "idle":
|
||||||
if s.get("state") == "underrun":
|
# An underrun is the run's: the engine ran out of bytes and
|
||||||
|
# the fault is acknowledged here. Any other state is the
|
||||||
|
# machine finishing what it was given - the ring draining
|
||||||
|
# to the end of a job - and it reaches idle on its own.
|
||||||
|
underrun = s.get("state") == "underrun"
|
||||||
|
if underrun:
|
||||||
try:
|
try:
|
||||||
hw.sysfs_write("cnc/stop", "1") # ack
|
hw.sysfs_write("cnc/stop", "1") # ack
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
w = self._wait("idle", lambda: (self.fc_get("/status")[1] or {}).get("state") == "idle", IDLE_S)
|
w = self._wait("idle", lambda: (self.fc_get("/status")[1] or {}).get("state") == "idle", IDLE_S)
|
||||||
|
if w is not None and not underrun:
|
||||||
|
self.log("state: %s reached idle on its own after %.0f s; the machine "
|
||||||
|
"finishing, not a leftover" % (s.get("state"), w))
|
||||||
|
else:
|
||||||
left.append(Leftover("state", s.get("state"), "idle",
|
left.append(Leftover("state", s.get("state"), "idle",
|
||||||
"waited" if w is not None else "failed: not idle"))
|
"waited" if w is not None else "failed: not idle"))
|
||||||
if s.get("laser_locked") is False:
|
if s.get("laser_locked") is False:
|
||||||
@@ -507,11 +601,31 @@ class Baseline:
|
|||||||
if st == 200 and isinstance(c, dict):
|
if st == 200 and isinstance(c, dict):
|
||||||
if c.get("phase") != "idle" or c.get("armed") or c.get("hold"):
|
if c.get("phase") != "idle" or c.get("armed") or c.get("hold"):
|
||||||
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
|
found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold"))
|
||||||
w = self._wait("cool idle", lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed")
|
idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731
|
||||||
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {}),
|
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {})
|
||||||
COOL_IDLE_S)
|
# Armed, or holding, is the run's doing. A phase alone is
|
||||||
|
# not: the engine clears smoke at run duty after an armed
|
||||||
|
# session and cools down after a hot one, both timed and
|
||||||
|
# both ending on their own. Waiting for that is right;
|
||||||
|
# calling it a leftover is not, because nothing was left -
|
||||||
|
# the machine was still finishing.
|
||||||
|
dirt = bool(c.get("armed") or c.get("hold"))
|
||||||
|
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
||||||
|
if w is not None and not dirt:
|
||||||
|
self.log("cool: %s ended on its own after %.0f s; the engine's own post-job "
|
||||||
|
"work, not a leftover" % (found, w))
|
||||||
|
else:
|
||||||
|
# The engine holds for a job. A run that ended without
|
||||||
|
# ending its job leaves one alive, and no amount of
|
||||||
|
# waiting ends it: stand the machine down and let the
|
||||||
|
# supervisor bring the controller back clean.
|
||||||
|
act = "waited"
|
||||||
|
if w is None:
|
||||||
|
self.stand_down("the cooling engine is still %s" % found)
|
||||||
|
w = self._wait("cool idle", idle, COOL_IDLE_S)
|
||||||
|
act = "restored (stood the machine down)"
|
||||||
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
||||||
"waited" if w is not None else "failed: still %s" % found))
|
act if w is not None else "failed: still %s" % found))
|
||||||
|
|
||||||
def _lamp_side(self, left):
|
def _lamp_side(self, left):
|
||||||
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
|
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
|
||||||
@@ -575,6 +689,9 @@ class Baseline:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
act = "failed: %s" % e
|
act = "failed: %s" % e
|
||||||
left.append(Leftover("leds/" + name, got, "0", act))
|
left.append(Leftover("leds/" + name, got, "0", act))
|
||||||
|
elif (read_led(name, "brightness") or "0") != "0":
|
||||||
|
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")))
|
||||||
|
|
||||||
def _return_head(self, was, now):
|
def _return_head(self, was, now):
|
||||||
"""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
|
||||||
@@ -590,10 +707,18 @@ class Baseline:
|
|||||||
# Never jog on top of stale bytes: a run started now would replay
|
# Never jog on top of stale bytes: a run started now would replay
|
||||||
# whatever the ring still holds before the jog, in a direction and
|
# whatever the ring still holds before the jog, in a direction and
|
||||||
# for a distance nobody asked for. Report and leave the head.
|
# for a distance nobody asked for. Report and leave the head.
|
||||||
|
# Stale bytes in the ring are the last job's, and a jog now would
|
||||||
|
# replay them before its own. The ring empties when the
|
||||||
|
# controller restarts, so the hand-back restarts it rather than
|
||||||
|
# leaving the head where it is and the bytes where they are.
|
||||||
residue = read_ring_residue()
|
residue = read_ring_residue()
|
||||||
if residue:
|
if residue:
|
||||||
return ("unrestorable: %d unplayed bytes queued in the kernel ring - a jog would "
|
self.log("baseline: %d unplayed bytes in the kernel ring before the return" % residue)
|
||||||
"replay them; clear the ring (controller restart) before moving" % residue)
|
self.stand_down("%d unplayed bytes in the kernel ring" % residue)
|
||||||
|
residue = read_ring_residue()
|
||||||
|
if residue:
|
||||||
|
return ("unrestorable: %d unplayed bytes still queued in the kernel ring after a "
|
||||||
|
"controller restart - a jog would replay them" % residue)
|
||||||
# a controller may be inside a respawn backoff (seconds): wait for it
|
# a controller may be inside a respawn backoff (seconds): wait for it
|
||||||
mode = None
|
mode = None
|
||||||
deadline = time.time() + 30
|
deadline = time.time() + 30
|
||||||
@@ -662,7 +787,12 @@ 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():
|
||||||
left.append(Leftover("position", now, was, self._return_head(was, now)))
|
act = self._return_head(was, now)
|
||||||
|
if position_quantized(was, now):
|
||||||
|
self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, "
|
||||||
|
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
|
||||||
|
else:
|
||||||
|
left.append(Leftover("position", now, was, act))
|
||||||
was = captured.get("settings")
|
was = captured.get("settings")
|
||||||
if was:
|
if was:
|
||||||
st, body = self.fc_get("/settings")
|
st, body = self.fc_get("/settings")
|
||||||
|
|||||||
@@ -164,7 +164,12 @@ class Run:
|
|||||||
notice = dict(self.notice) if self.notice else None
|
notice = dict(self.notice) if self.notice else None
|
||||||
return {
|
return {
|
||||||
"kind": self.kind, "id": self.id, "title": self.title,
|
"kind": self.kind, "id": self.id, "title": self.title,
|
||||||
"started": self.started_ts, "elapsed_s": int(time.time() - self.started),
|
# A finished run's clock stops: the page shows the last run
|
||||||
|
# until the next one starts, and a live figure there counts
|
||||||
|
# the time since, not the time it took.
|
||||||
|
"started": self.started_ts,
|
||||||
|
"elapsed_s": (self.finished["duration_s"] if self.finished
|
||||||
|
else int(time.time() - self.started)),
|
||||||
"log": lines, "dropped": self.dropped, "prompt": prompt, "notice": notice,
|
"log": lines, "dropped": self.dropped, "prompt": prompt, "notice": notice,
|
||||||
"finished": self.finished, "aborting": self.aborted.is_set(),
|
"finished": self.finished, "aborting": self.aborted.is_set(),
|
||||||
}
|
}
|
||||||
@@ -424,6 +429,45 @@ class Context:
|
|||||||
self.log("ACT %s %s: done after %.1f s", channel, state, dt)
|
self.log("ACT %s %s: done after %.1f s", channel, state, dt)
|
||||||
return dt
|
return dt
|
||||||
|
|
||||||
|
def press_now(self, text="The button is lit white. Press it now: the laser fires after your press."):
|
||||||
|
"""The arm press, at the moment the machine says it is waiting.
|
||||||
|
|
||||||
|
A check that runs a job opens a `press` prompt of its own when
|
||||||
|
it sees the button lit and the tube still dark, which is the
|
||||||
|
machine's own word that the press it wants is this one. Pressing
|
||||||
|
on that is exact; pressing on a button LED read from here is
|
||||||
|
not, because the button is lit through parts of a check that are
|
||||||
|
not the arm (the lens reference, the program on its way to the
|
||||||
|
controller), and a press then lands before the job waits for it
|
||||||
|
and is lost.
|
||||||
|
|
||||||
|
The fixture presses where the bench took this test's presses (or
|
||||||
|
opted in standing); otherwise the notice goes up for a person,
|
||||||
|
who is looking at the same prompt. Returns True when the fixture
|
||||||
|
pressed."""
|
||||||
|
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
|
||||||
|
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
|
||||||
|
self.evidence.setdefault("actions", []).append(rec)
|
||||||
|
opted_in = fixture is not None and fixture.covers("button") and \
|
||||||
|
(fixture.arm_press or self.run.fixture_takeover)
|
||||||
|
if not opted_in:
|
||||||
|
if self.run.fixture_takeover:
|
||||||
|
rec["fixture_lost"] = True
|
||||||
|
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
|
||||||
|
"gone - asking the operator for the arm press")
|
||||||
|
self.notice(text)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
fixture.act("button", "press")
|
||||||
|
except _fixture.FixtureError as e:
|
||||||
|
rec["fixture_error"] = str(e)
|
||||||
|
self.log("ARM press: fixture failed (%s) - asking the operator", e)
|
||||||
|
self.notice(text)
|
||||||
|
return False
|
||||||
|
rec["by"] = "fixture"
|
||||||
|
self.log("ARM press by the fixture, at the machine's own press prompt")
|
||||||
|
return True
|
||||||
|
|
||||||
def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.",
|
def arm_press(self, text="The button lights white: press it to arm. The machine fires after your press.",
|
||||||
lit_timeout=60):
|
lit_timeout=60):
|
||||||
"""The arm cue of a live test. A person's press by default: a
|
"""The arm cue of a live test. A person's press by default: a
|
||||||
@@ -994,7 +1038,21 @@ class Runner:
|
|||||||
run.log(traceback.format_exc().rstrip())
|
run.log(traceback.format_exc().rstrip())
|
||||||
self.fixture_release(run)
|
self.fixture_release(run)
|
||||||
try:
|
try:
|
||||||
self._baseline_post(run, captured)
|
left = self._baseline_post(run, captured)
|
||||||
|
# A test hands the machine back as it found it. Anything left
|
||||||
|
# behind was recorded here and nothing more, so a check that
|
||||||
|
# measured correctly and walked away with the machine in a
|
||||||
|
# state nobody chose still passed: that is how the airflow
|
||||||
|
# check came to leave the purge fan off, and an operator met
|
||||||
|
# it at their first fire instead of the bench meeting it here.
|
||||||
|
# The pass is now conditional on the machine being whole. A
|
||||||
|
# leftover the baseline put back still fails: the restore is
|
||||||
|
# the bench cleaning up after a defect, not the defect's
|
||||||
|
# absence.
|
||||||
|
if left and result == _campaign.PASS:
|
||||||
|
result = _campaign.FAIL
|
||||||
|
message = ("the machine was not handed back as found: %s"
|
||||||
|
% "; ".join(str(x) for x in left))
|
||||||
except Exception as e: # noqa: BLE001 - never lose the result over the cleanup
|
except Exception as e: # noqa: BLE001 - never lose the result over the cleanup
|
||||||
run.log("baseline: post pass errored: %s: %s" % (type(e).__name__, e))
|
run.log("baseline: post pass errored: %s: %s" % (type(e).__name__, e))
|
||||||
duration = int(time.time() - run.started)
|
duration = int(time.time() - run.started)
|
||||||
|
|||||||
@@ -372,7 +372,13 @@ def mode_switch(ctx):
|
|||||||
gfhome_homing(ctx, ev, g)
|
gfhome_homing(ctx, ev, g)
|
||||||
finally:
|
finally:
|
||||||
if ev["homing_mode"] != "gfcloud":
|
if ev["homing_mode"] != "gfcloud":
|
||||||
st, body = fc.post("/settings", data={"homing_mode": ev["homing_mode"] or "none"})
|
# Back to exactly what the machine had: unset is the empty
|
||||||
|
# string, and clearing a key needs the query-string form.
|
||||||
|
# "none" is a value, and writing it where the machine had
|
||||||
|
# nothing is a leftover the hand-back reports.
|
||||||
|
st, body = (fc.post("/settings", params={"homing_mode": ""})
|
||||||
|
if not ev["homing_mode"]
|
||||||
|
else fc.post("/settings", data={"homing_mode": ev["homing_mode"]}))
|
||||||
ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st)
|
ctx.log("restore homing_mode=%r -> %s", ev["homing_mode"], st)
|
||||||
ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> "
|
ctx.log("PASS: grbl -> cloud (session, hunt with the lid open, lens homed, airflow unjudged) -> "
|
||||||
"grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
|
"grbl (port open, %s), then $H homed in %.1f s", ev["grbl_state"], ev["homing_s"])
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""commission.* - the first-run commissioning: the record and the
|
"""commission.* - the first-run commissioning: 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 mDNS name, and the factory return.
|
boundary, SSH, the cloud switch, the machine's name, and the factory
|
||||||
|
return.
|
||||||
|
|
||||||
The daemon reads the commissioning record (commissioning.json in the
|
The daemon reads the commissioning record (commissioning.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
|
||||||
@@ -11,7 +12,7 @@ never saves a test record over the real one, and a restore is complete
|
|||||||
when the daemon is up again.
|
when the daemon is up again.
|
||||||
|
|
||||||
The layer content of this work (the account replay init script, the
|
The layer content of this work (the account replay init script, the
|
||||||
sshd policy, the console banner, the avahi service) is part of the
|
sshd policy, the hostname and console banner scripts) is part of the
|
||||||
platform identity that every fingerprint carries, not of a component.
|
platform identity that every fingerprint carries, not of a component.
|
||||||
No coverage map names it: a change there makes every test necessary
|
No coverage map names it: a change there makes every test necessary
|
||||||
again.
|
again.
|
||||||
@@ -23,10 +24,9 @@ import http.client
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import select
|
import re
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import struct
|
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
@@ -46,9 +46,6 @@ ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service")
|
|||||||
SAFETY_PHRASE = "I UNDERSTAND"
|
SAFETY_PHRASE = "I UNDERSTAND"
|
||||||
LOGIN_FAILS = 5
|
LOGIN_FAILS = 5
|
||||||
LOGIN_LOCK_S = 30
|
LOGIN_LOCK_S = 30
|
||||||
MDNS_NAME = "forgefirm.local"
|
|
||||||
MDNS_GROUP = "224.0.0.251"
|
|
||||||
MDNS_PORT = 5353
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------- files
|
# ----------------------------------------------------------------- files
|
||||||
@@ -777,7 +774,7 @@ def https_only_writes(ctx):
|
|||||||
# ------------------------------------------------------------------ ssh
|
# ------------------------------------------------------------------ ssh
|
||||||
|
|
||||||
def sshd_policy():
|
def sshd_policy():
|
||||||
"""The three ForgeFIRM keys of the effective sshd policy (sshd -T,
|
"""The ForgeFIRM keys of the effective sshd policy (sshd -T,
|
||||||
lowercase keys), or {"error": ...} when sshd cannot report it."""
|
lowercase keys), or {"error": ...} when sshd cannot report it."""
|
||||||
for exe in ("/usr/sbin/sshd", "sshd"):
|
for exe in ("/usr/sbin/sshd", "sshd"):
|
||||||
rc, out = hw.run([exe, "-T"], timeout=15)
|
rc, out = hw.run([exe, "-T"], timeout=15)
|
||||||
@@ -789,7 +786,7 @@ def sshd_policy():
|
|||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
parts = line.split(None, 1)
|
parts = line.split(None, 1)
|
||||||
if len(parts) == 2 and parts[0] in ("permitrootlogin", "permitemptypasswords",
|
if len(parts) == 2 and parts[0] in ("permitrootlogin", "permitemptypasswords",
|
||||||
"passwordauthentication"):
|
"passwordauthentication", "banner"):
|
||||||
policy[parts[0]] = parts[1].strip()
|
policy[parts[0]] = parts[1].strip()
|
||||||
return policy
|
return policy
|
||||||
return {"error": "no sshd binary"}
|
return {"error": "no sshd binary"}
|
||||||
@@ -814,7 +811,9 @@ def restore_ssh(fc, before):
|
|||||||
"sshd listens on 22, with the effective policy (sshd -T) at PasswordAuthentication "
|
"sshd listens on 22, with the effective policy (sshd -T) at PasswordAuthentication "
|
||||||
"yes, and on a release image PermitRootLogin no and PermitEmptyPasswords no (the "
|
"yes, and on a release image PermitRootLogin no and PermitEmptyPasswords no (the "
|
||||||
"dev image turns root over SSH back on for the bench; the release gate checks "
|
"dev image turns root over SSH back on for the bench; the release gate checks "
|
||||||
"the release image's sshd_config as built). An enable outside 0 and 1 is refused (400). On a release "
|
"the release image's sshd_config as built), and with no pre-authentication "
|
||||||
|
"banner, so the machine says nothing to a client that has not logged in. "
|
||||||
|
"An enable outside 0 and 1 is refused (400). On a release "
|
||||||
"image POST enable=0 removes the flag and stops sshd; on the dev image the boot "
|
"image POST enable=0 removes the flag and stops sshd; on the dev image the boot "
|
||||||
"rule keeps sshd up and the stop is never gated, so the test removes the flag "
|
"rule keeps sshd up and the stop is never gated, so the test removes the flag "
|
||||||
"by hand (as a reboot does, the flag is on tmpfs) and sshd stays. The prior "
|
"by hand (as a reboot does, the flag is on tmpfs) and sshd stays. The prior "
|
||||||
@@ -848,6 +847,13 @@ def ssh_until_reboot(ctx):
|
|||||||
ctx.log("sshd -T: %s", policy)
|
ctx.log("sshd -T: %s", policy)
|
||||||
ctx.check(policy.get("passwordauthentication") == "yes",
|
ctx.check(policy.get("passwordauthentication") == "yes",
|
||||||
"PasswordAuthentication is %r: the account cannot log in", policy.get("passwordauthentication"))
|
"PasswordAuthentication is %r: the account cannot log in", policy.get("passwordauthentication"))
|
||||||
|
|
||||||
|
# No pre-authentication banner: the machine says nothing to a
|
||||||
|
# client that has not logged in. The mark and the version go in
|
||||||
|
# the motd, which a login prints (image.health).
|
||||||
|
ctx.check(policy.get("banner") in (None, "none"),
|
||||||
|
"sshd sends a pre-authentication banner (%r)", policy.get("banner"))
|
||||||
|
|
||||||
if not before["dev_image"]:
|
if not before["dev_image"]:
|
||||||
ctx.check(policy.get("permitrootlogin") == "no" and policy.get("permitemptypasswords") == "no",
|
ctx.check(policy.get("permitrootlogin") == "no" and policy.get("permitemptypasswords") == "no",
|
||||||
"a release image runs sshd with PermitRootLogin %r, PermitEmptyPasswords %r",
|
"a release image runs sshd with PermitRootLogin %r, PermitEmptyPasswords %r",
|
||||||
@@ -1002,165 +1008,89 @@ def factory_return(ctx):
|
|||||||
ctx.log("PASS: the return is refused without confirm=1; the page confirms before it calls")
|
ctx.log("PASS: the return is refused without confirm=1; the page confirms before it calls")
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------- mDNS
|
# --------------------------------------------------- the machine's name
|
||||||
|
|
||||||
def mdns_query(name, qid=None):
|
def mac_suffix():
|
||||||
"""A DNS query packet for the A record of name with the unicast-
|
"""The last four hex digits of the MAC address the hostname is built
|
||||||
response bit set (RFC 6762 5.4), so the responder answers this
|
from: wlan0, or eth0 on a machine with no WiFi."""
|
||||||
socket directly."""
|
for dev in ("wlan0", "eth0"):
|
||||||
qid = random.randrange(1, 65536) if qid is None else qid
|
raw = read_file("/sys/class/net/%s/address" % dev)
|
||||||
labels = b"".join(struct.pack("B", len(p)) + p.encode("ascii") for p in name.strip(".").split("."))
|
mac = (raw or b"").decode("ascii", "replace").strip().replace(":", "").lower()
|
||||||
return struct.pack(">HHHHHH", qid, 0, 1, 0, 0, 0) + labels + b"\x00" + struct.pack(">HH", 1, 0x8001)
|
if mac and mac != "0" * 12:
|
||||||
|
return mac[-4:]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _dns_name(pkt, off):
|
def cmdlines():
|
||||||
"""(name, offset after the name); follows compression pointers."""
|
"""The command line of every process on the machine, one string
|
||||||
parts = []
|
each."""
|
||||||
jumped = False
|
|
||||||
end = off
|
|
||||||
hops = 0
|
|
||||||
while True:
|
|
||||||
if off >= len(pkt):
|
|
||||||
raise ValueError("truncated name")
|
|
||||||
n = pkt[off]
|
|
||||||
if n == 0:
|
|
||||||
off += 1
|
|
||||||
break
|
|
||||||
if n & 0xC0 == 0xC0:
|
|
||||||
if off + 1 >= len(pkt):
|
|
||||||
raise ValueError("truncated pointer")
|
|
||||||
ptr = ((n & 0x3F) << 8) | pkt[off + 1]
|
|
||||||
if not jumped:
|
|
||||||
end = off + 2
|
|
||||||
jumped = True
|
|
||||||
off = ptr
|
|
||||||
hops += 1
|
|
||||||
if hops > 32:
|
|
||||||
raise ValueError("pointer loop")
|
|
||||||
continue
|
|
||||||
off += 1
|
|
||||||
parts.append(pkt[off:off + n].decode("ascii", "replace"))
|
|
||||||
off += n
|
|
||||||
if not jumped:
|
|
||||||
end = off
|
|
||||||
return ".".join(parts), end
|
|
||||||
|
|
||||||
|
|
||||||
def mdns_answers(pkt, qid=None):
|
|
||||||
"""The A records of a DNS response: [(name, address)]. A qid, when
|
|
||||||
given, must match the packet's id; the answer flag (QR) must be set."""
|
|
||||||
if len(pkt) < 12:
|
|
||||||
return []
|
|
||||||
pid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", pkt[:12])
|
|
||||||
if qid is not None and pid != qid:
|
|
||||||
return []
|
|
||||||
if not flags & 0x8000:
|
|
||||||
return []
|
|
||||||
off = 12
|
|
||||||
try:
|
|
||||||
for _ in range(qd):
|
|
||||||
_name, off = _dns_name(pkt, off)
|
|
||||||
off += 4
|
|
||||||
out = []
|
out = []
|
||||||
for _ in range(an + ns + ar):
|
for name in os.listdir("/proc"):
|
||||||
name, off = _dns_name(pkt, off)
|
if not name.isdigit():
|
||||||
if off + 10 > len(pkt):
|
|
||||||
break
|
|
||||||
rtype, rclass, _ttl, rdlen = struct.unpack(">HHIH", pkt[off:off + 10])
|
|
||||||
off += 10
|
|
||||||
rdata = pkt[off:off + rdlen]
|
|
||||||
off += rdlen
|
|
||||||
if rtype == 1 and rdlen == 4:
|
|
||||||
out.append((name.lower(), socket.inet_ntoa(rdata)))
|
|
||||||
return out
|
|
||||||
except ValueError:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def mdns_resolve(ip, name=MDNS_NAME, timeout=3.0, tries=3):
|
|
||||||
"""Ask the LAN for the A record of name over mDNS from the interface
|
|
||||||
that holds ip, and collect the answers: {address}. The query goes to
|
|
||||||
the multicast group; the responder on this machine answers the
|
|
||||||
unicast-response bit directly (a legacy query from a port that is
|
|
||||||
not 5353 is answered the same way), and a listener on 5353 catches
|
|
||||||
a multicast answer too."""
|
|
||||||
found = set()
|
|
||||||
qid = random.randrange(1, 65536)
|
|
||||||
q = mdns_query(name, qid)
|
|
||||||
tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
rx = None
|
|
||||||
try:
|
|
||||||
tx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
tx.bind((ip, 0))
|
|
||||||
tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(ip))
|
|
||||||
tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
|
|
||||||
tx.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1)
|
|
||||||
try:
|
|
||||||
rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
if hasattr(socket, "SO_REUSEPORT"):
|
|
||||||
rx.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
||||||
rx.bind(("", MDNS_PORT))
|
|
||||||
rx.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
|
|
||||||
socket.inet_aton(MDNS_GROUP) + socket.inet_aton(ip))
|
|
||||||
except OSError:
|
|
||||||
rx = None
|
|
||||||
socks = [s for s in (tx, rx) if s is not None]
|
|
||||||
for _ in range(tries):
|
|
||||||
tx.sendto(q, (MDNS_GROUP, MDNS_PORT))
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
ready, _w, _x = select.select(socks, [], [], max(0.05, deadline - time.time()))
|
|
||||||
for s in ready:
|
|
||||||
try:
|
|
||||||
pkt, _peer = s.recvfrom(4096)
|
|
||||||
except OSError:
|
|
||||||
continue
|
continue
|
||||||
# a multicast answer carries id 0; the direct one echoes the query's
|
raw = read_file("/proc/%s/cmdline" % name)
|
||||||
for n, addr in mdns_answers(pkt, qid if s is tx else None):
|
if raw:
|
||||||
if n == name.lower():
|
out.append(raw.decode("utf-8", "replace").replace("\0", " ").strip())
|
||||||
found.add(addr)
|
return out
|
||||||
if found:
|
|
||||||
return found
|
|
||||||
finally:
|
|
||||||
tx.close()
|
|
||||||
if rx is not None:
|
|
||||||
rx.close()
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
@test("commission.mdns-announce", title="The machine answers forgefirm.local", subsystem="commission",
|
@test("commission.machine-name", title="The machine names itself from its MAC address",
|
||||||
kind="auto", est_min=1,
|
subsystem="commission", kind="auto", est_min=1,
|
||||||
requires=["forgectrl.auth"],
|
requires=["forgectrl.auth"],
|
||||||
description="avahi-daemon runs, its configuration names the host forgefirm on the WiFi and "
|
description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex "
|
||||||
"wired links, and the service file advertises the panel on 443 and 80. An mDNS "
|
"digits of its WiFi MAC address, so two machines on one network answer to "
|
||||||
"query for the A record of forgefirm.local, sent to the multicast group from "
|
"different names and no name carries a serial number. The live name, "
|
||||||
"the board's own LAN interface, is answered with the board's LAN address; that "
|
"/etc/hostname (a bind-mounted copy, because the rootfs is read-only) and the "
|
||||||
"is what `ping forgefirm.local` on a workstation resolves. No component "
|
"MAC address agree; the DHCP client sends the name as the hostname option, "
|
||||||
"covers this: the avahi files are layer content, in the platform identity of "
|
"which is what a network with dynamic DNS publishes; the console banner offers "
|
||||||
|
"the machine's addresses and nothing else, with no marker line and no .local "
|
||||||
|
"name; and no mDNS responder is on the image. No component covers this: the "
|
||||||
|
"hostname and banner scripts are layer content, in the platform identity of "
|
||||||
"every fingerprint.")
|
"every fingerprint.")
|
||||||
def mdns_announce(ctx):
|
def machine_name(ctx):
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
pids = hw.pidof("avahi-daemon")
|
|
||||||
ev["avahi_pids"] = pids
|
# 1. the name, the file and the MAC address agree
|
||||||
ctx.check(pids, "avahi-daemon is not running")
|
suffix = mac_suffix()
|
||||||
conf = read_file("/etc/avahi/avahi-daemon.conf") or b""
|
ev["mac_suffix"] = suffix
|
||||||
ctx.check(b"host-name=forgefirm" in conf, "avahi-daemon.conf does not name the host forgefirm")
|
ctx.check(suffix, "no MAC address to build a name from")
|
||||||
ctx.check(b"allow-interfaces=wlan0,eth0" in conf, "avahi-daemon.conf does not restrict the interfaces")
|
live = socket.gethostname()
|
||||||
svc = read_file("/etc/avahi/services/forgefirm.service") or b""
|
on_file = ((read_file("/etc/hostname") or b"").decode("utf-8", "replace")).strip()
|
||||||
ev["service_file_bytes"] = len(svc)
|
ev["hostname"] = live
|
||||||
ctx.check(b"_https._tcp" in svc and b"<port>443</port>" in svc, "the service file lacks HTTPS 443")
|
ev["hostname_file"] = on_file
|
||||||
ctx.check(b"_http._tcp" in svc and b"<port>80</port>" in svc, "the service file lacks HTTP 80")
|
ctx.log("hostname %r (MAC suffix %s)", live, suffix or "none")
|
||||||
|
ctx.check(live == "forgefirm-" + suffix,
|
||||||
|
"the hostname is %r, expected forgefirm-%s", live, suffix)
|
||||||
|
ctx.check(on_file == live, "/etc/hostname holds %r, the live name is %r", on_file, live)
|
||||||
|
|
||||||
|
# 2. the DHCP client sends it (option 12), so dynamic DNS can publish it
|
||||||
|
dhcp = [c for c in cmdlines() if c.split(" ")[0].split("/")[-1] == "udhcpc"]
|
||||||
|
ev["udhcpc"] = dhcp
|
||||||
|
ctx.check(dhcp, "no DHCP client is running")
|
||||||
|
ctx.check(any("-x hostname:" + live in c for c in dhcp),
|
||||||
|
"the DHCP client does not send the machine's name: %s", dhcp)
|
||||||
|
|
||||||
|
# 3. the console banner: the addresses, and nothing that is not the banner
|
||||||
|
issue = (read_file("/etc/issue") or b"").decode("utf-8", "replace")
|
||||||
|
ev["issue"] = issue
|
||||||
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")
|
||||||
t0 = time.time()
|
ctx.check("Control panel:" in issue, "the console banner names no control panel")
|
||||||
found = mdns_resolve(ip)
|
ctx.check("https://%s/" % ip in issue,
|
||||||
ev["answers"] = sorted(found)
|
"the console banner does not carry the LAN address %s", ip)
|
||||||
ev["resolve_s"] = round(time.time() - t0, 2)
|
for junk in ("# end", "# ForgeFIRM addresses", ".local"):
|
||||||
ctx.log("%s -> %s (%.1f s)", MDNS_NAME, sorted(found) or "no answer", ev["resolve_s"])
|
ctx.check(junk not in issue, "the console banner shows %r", junk)
|
||||||
ctx.check(found, "no mDNS answer for %s from the LAN interface (%s)", MDNS_NAME, ip)
|
urls = re.findall(r"https://(\S+)/", issue)
|
||||||
ctx.check(ip in found, "%s resolves to %s, not the LAN address %s", MDNS_NAME, sorted(found), ip)
|
ev["banner_urls"] = urls
|
||||||
|
for u in urls:
|
||||||
|
literal = u.startswith("[") or all(c.isdigit() or c == "." for c in u)
|
||||||
|
ctx.check(literal, "the console banner offers %r, which is not an address", u)
|
||||||
|
|
||||||
|
# 4. no mDNS on the image; the name is the only name the machine has
|
||||||
|
ev["avahi_pids"] = hw.pidof("avahi-daemon")
|
||||||
|
ctx.check(not ev["avahi_pids"], "an mDNS responder is running: %s", ev["avahi_pids"])
|
||||||
|
ctx.check(not os.path.exists("/etc/avahi"), "/etc/avahi is on the image")
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------- the first run
|
# --------------------------------------------------------- the first run
|
||||||
@@ -1458,7 +1388,8 @@ def cert_page(ctx):
|
|||||||
ctx.check(st == 200, "GET %s/cert -> %s, expected 200", base, st)
|
ctx.check(st == 200, "GET %s/cert -> %s, expected 200", base, st)
|
||||||
ctx.check("location" not in hdrs, "GET %s/cert redirected", base)
|
ctx.check("location" not in hdrs, "GET %s/cert redirected", base)
|
||||||
ctx.check(fp in text, "the fingerprint is not on the page from %s", base)
|
ctx.check(fp in text, "the fingerprint is not on the page from %s", base)
|
||||||
ctx.check("forgefirm.local" in text, "forgefirm.local is not among the names on the page")
|
ctx.check(socket.gethostname() in text,
|
||||||
|
"the machine's hostname is not among the names on the page")
|
||||||
st, body, hdrs = request(base, "GET", "/cert.pem")
|
st, body, hdrs = request(base, "GET", "/cert.pem")
|
||||||
ctx.check(st == 200 and body.startswith(b"-----BEGIN CERTIFICATE-----"),
|
ctx.check(st == 200 and body.startswith(b"-----BEGIN CERTIFICATE-----"),
|
||||||
"GET %s/cert.pem -> %s, not a PEM certificate", base, st)
|
"GET %s/cert.pem -> %s, not a PEM certificate", base, st)
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ from ..baseline import read_position
|
|||||||
from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
|
from .commission 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.
|
||||||
|
PRESS_TEXT = "The button is lit white. Press it now: the laser fires after your press."
|
||||||
|
# How long the purge fan's current is given to come up after the airflow
|
||||||
|
# check switches it back on. The bench reference draws its off current
|
||||||
|
# (74) for the first seconds and settles near 630.
|
||||||
|
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/commission.*"),
|
||||||
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
||||||
@@ -76,6 +82,12 @@ def run_check(ctx, wid, on_prompt, timeout_s):
|
|||||||
if value is not None:
|
if value is not None:
|
||||||
st, body = answer(fc, wid, p, value)
|
st, body = answer(fc, wid, p, value)
|
||||||
ctx.check(st == 200, "the answer to %s -> %s %s", p.get("id"), st, body)
|
ctx.check(st == 200, "the answer to %s -> %s %s", p.get("id"), st, body)
|
||||||
|
elif p.get("id") == "press":
|
||||||
|
# The arm press, asked for by the machine itself: the
|
||||||
|
# check opens this prompt when the button is lit and
|
||||||
|
# the tube is still dark. Nothing answers it - the
|
||||||
|
# press does, and the discharge closes it.
|
||||||
|
ctx.press_now(p.get("text") or PRESS_TEXT)
|
||||||
if not d.get("running"):
|
if not d.get("running"):
|
||||||
last = d
|
last = d
|
||||||
break
|
break
|
||||||
@@ -167,7 +179,8 @@ def check_sensors(ctx):
|
|||||||
"for 35 s, the purge is measured on and off, and five settings are written: the "
|
"for 35 s, the purge is measured on and off, and five settings are written: the "
|
||||||
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
|
"four floors at 55 percent of steady and the grace from the slowest spin-up. Each "
|
||||||
"fan must read at least 1000 rpm. The five settings are put back as found at the "
|
"fan must read at least 1000 rpm. The five settings are put back as found at the "
|
||||||
"end and the controller must be running again.")
|
"end, the purge fan is on again and drawing over the floor the check just wrote, "
|
||||||
|
"and the controller must be running again.")
|
||||||
def check_airflow(ctx):
|
def check_airflow(ctx):
|
||||||
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
|
keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm",
|
||||||
"cool_purge_min_current", "cool_fan_grace_s"]
|
"cool_purge_min_current", "cool_fan_grace_s"]
|
||||||
@@ -186,6 +199,32 @@ def check_airflow(ctx):
|
|||||||
for k in keys:
|
for k in keys:
|
||||||
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
|
ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k))
|
||||||
ctx.log("floors: %s", json.dumps(floors))
|
ctx.log("floors: %s", json.dumps(floors))
|
||||||
|
# The check switches purge air off to read its off current. Leaving
|
||||||
|
# it off costs the next job an airflow hold mid-cut, judged against
|
||||||
|
# the very floor written above, and nothing puts it back until the
|
||||||
|
# daemon restarts: the engine's idle phase never re-applies its own
|
||||||
|
# duties. Prove the machine is whole, not just measured.
|
||||||
|
purge_on = ctx.sysfs("head/purge_air")
|
||||||
|
ctx.check(purge_on == "1", "purge air reads %r after the check, expected 1 (on)", purge_on)
|
||||||
|
|
||||||
|
def purge_gate():
|
||||||
|
return ((ctx.forgectrl.get("/cool/status")[1] or {}).get("fan_gates") or {}).get("purge") or {}
|
||||||
|
|
||||||
|
# The check read its off current with the fan off, and the stand-down
|
||||||
|
# commands it back on. The current follows the command, it does not
|
||||||
|
# arrive with it: read in the same second and what comes back is the
|
||||||
|
# off current the check just measured. Wait for the draw the way the
|
||||||
|
# controller below is waited for.
|
||||||
|
took = ctx.wait_for(lambda: (purge_gate().get("reading") or 0) >= (purge_gate().get("floor") or 0),
|
||||||
|
PURGE_SPINUP_S)
|
||||||
|
gate = purge_gate()
|
||||||
|
ctx.evidence["purge_after"] = {"purge_air": purge_on, "gate": gate, "took_s": took}
|
||||||
|
ctx.log("purge after the check: commanded %s, drawing %s against the %s floor (%s)",
|
||||||
|
purge_on, gate.get("reading"), gate.get("floor"),
|
||||||
|
"after %.1f s" % took if took is not None else "never reached the floor")
|
||||||
|
ctx.check(took is not None,
|
||||||
|
"purge draws %s after %d s, under the %s floor the check just wrote: the next job "
|
||||||
|
"would be held", gate.get("reading"), PURGE_SPINUP_S, gate.get("floor"))
|
||||||
ok = ctx.wait_for(lambda: (ctx.forgectrl.get("/mode")[1] or {}).get("controller") == "running", 60)
|
ok = ctx.wait_for(lambda: (ctx.forgectrl.get("/mode")[1] or {}).get("controller") == "running", 60)
|
||||||
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")
|
||||||
|
|
||||||
@@ -228,7 +267,7 @@ def check_cameras(ctx):
|
|||||||
@test("commission.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
@test("commission.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="commission", 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.c"),
|
("forgectrl", "src/lenshome.*"),
|
||||||
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
|
("forgectrl", "src/accel.c"), ("forgectrl", "src/cool.c")],
|
||||||
requires=["forgectrl.auth", "motion.pacing"],
|
requires=["forgectrl.auth", "motion.pacing"],
|
||||||
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
|
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
|
||||||
|
|||||||
@@ -36,19 +36,18 @@ SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
|||||||
|
|
||||||
# The live wizards in the order the sheet burns them: the wizard id, the
|
# The live wizards in the order the sheet burns them: the wizard id, the
|
||||||
# answers to its prompts (a middle pick where a person would look at the
|
# answers to its prompts (a middle pick where a person would look at the
|
||||||
# sheet), the keys its result must carry, and how long the bench actuator
|
# sheet), and the keys its result must carry. The arm press is not timed
|
||||||
# waits for the button to light before the arm press goes to a person.
|
# from here: each card opens a press prompt of its own when the button is
|
||||||
# The flow-load card settles the coolant loop before it lights the button
|
# lit and the tube still dark, and run_check presses on that.
|
||||||
# (60 to 240 s); the others light within seconds of the lens reference.
|
|
||||||
CARDS = [
|
CARDS = [
|
||||||
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"], 120),
|
("sheet.frame", {"frame-arm": "Continue", "frame-ok": "Yes"}, ["mark_s", "mark_feed"]),
|
||||||
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"},
|
("laser.focus", {"focus-arm": "Continue", "focus-pick": "11", "thickness": "Keep"},
|
||||||
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
|
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
|
||||||
"focus_range_mm", "stops"], 120),
|
"focus_range_mm", "stops"]),
|
||||||
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"], 120),
|
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"]),
|
||||||
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"], 120),
|
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"]),
|
||||||
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"], 120),
|
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"]),
|
||||||
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"], 420),
|
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"]),
|
||||||
]
|
]
|
||||||
# Every setting a card writes, restored as found when the run ends.
|
# Every setting a card writes, restored as found when the run ends.
|
||||||
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
|
CARD_SETTINGS = ["lens_hall_edge_z_mm", "lens_stop_below_steps", "lens_stop_above_steps",
|
||||||
@@ -130,12 +129,12 @@ def place(ctx):
|
|||||||
file_card(ctx, "sheet.place")
|
file_card(ctx, "sheet.place")
|
||||||
|
|
||||||
|
|
||||||
def burn(ctx, wid, answers, want, lit_s):
|
def burn(ctx, wid, answers, want):
|
||||||
"""One live card: the preview, the burn after the press with the
|
"""One live card: the preview, the burn after the press with the
|
||||||
prompts answered from `answers`, the witnesses, the keys of the
|
prompts answered from `answers`, the witnesses, the keys of the
|
||||||
result in `want`. The actuator waits `lit_s` for the button to light."""
|
result in `want`. run_check makes the arm press when the card asks
|
||||||
|
for it."""
|
||||||
preview_ok(ctx, wid)
|
preview_ok(ctx, wid)
|
||||||
ctx.arm_press(lit_timeout=lit_s)
|
|
||||||
|
|
||||||
def on_prompt(p):
|
def on_prompt(p):
|
||||||
return answers.get(p.get("id"))
|
return answers.get(p.get("id"))
|
||||||
@@ -161,9 +160,10 @@ def burn(ctx, wid, answers, want, lit_s):
|
|||||||
"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); "
|
||||||
"lid closed. Eye protection on, exhaust on, extinguisher in reach.",
|
"lid closed. Eye protection on, exhaust on, extinguisher in reach.",
|
||||||
"Start the test and press the machine's button once. That press says you are at the "
|
"Start the test and press the machine's button once. That press says you are at the "
|
||||||
"machine, and the bench actuator makes every arm press after it. With no actuator "
|
"machine, and the bench actuator makes every arm press after it, each one when the "
|
||||||
"the start is the Ready answer instead, and you press the button each time it lights "
|
"card itself asks for it. With no actuator the start is the Ready answer instead, "
|
||||||
"white: six times, the frame and the five cards.",
|
"and you press the button each time the page asks: six times, the frame and the "
|
||||||
|
"five cards.",
|
||||||
"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."],
|
||||||
@@ -191,6 +191,6 @@ 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):
|
||||||
for wid, answers, want, lit_s in CARDS:
|
for wid, answers, want in CARDS:
|
||||||
burn(ctx, wid, answers, want, lit_s)
|
burn(ctx, wid, answers, want)
|
||||||
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
|
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ def lan_ip():
|
|||||||
covers=_COVERS_AUTH,
|
covers=_COVERS_AUTH,
|
||||||
description="Every state-changing endpoint refuses an unauthenticated write (the factory "
|
description="Every state-changing endpoint refuses an unauthenticated write (the factory "
|
||||||
"return, the SSH switch and the wizard's own routes included); a non-literal "
|
"return, the SSH switch and the wizard's own routes included); a non-literal "
|
||||||
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused; the "
|
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused, while "
|
||||||
"cooling report channel accepts the loopback peer and refuses a non-loopback "
|
"the machine's own hostname passes and that name with a domain on it does not; "
|
||||||
|
"the cooling report channel accepts the loopback peer and refuses a non-loopback "
|
||||||
"one (over HTTP the write is sent to HTTPS first, 302; over HTTPS the route "
|
"one (over HTTP the write is sent to HTTPS first, 302; over HTTPS the route "
|
||||||
"answers 403 loopback only); the fuse view is two-factor "
|
"answers 403 loopback only); the fuse view is two-factor "
|
||||||
"(token and the physical button) and refused without either; "
|
"(token and the physical button) and refused without either; "
|
||||||
@@ -96,6 +97,17 @@ def auth(ctx):
|
|||||||
ev["host_name"] = st
|
ev["host_name"] = st
|
||||||
ctx.log("GET /status Host=evil.example.net -> %s", st)
|
ctx.log("GET /status Host=evil.example.net -> %s", st)
|
||||||
ctx.check(st == 403, "a DNS-name Host was accepted (%s)", st)
|
ctx.check(st == 403, "a DNS-name Host was accepted (%s)", st)
|
||||||
|
# the machine's own name passes; the same name with a domain on it
|
||||||
|
# does not, because anyone can register one
|
||||||
|
own = socket.gethostname()
|
||||||
|
st, body = fc.get("/status", headers={"Host": own})
|
||||||
|
ev["host_own_name"] = st
|
||||||
|
ctx.log("GET /status Host=%s -> %s", own, st)
|
||||||
|
ctx.check(st == 200, "the machine's own hostname was refused as a Host (%s)", st)
|
||||||
|
st, body = fc.get("/status", headers={"Host": own + ".example.net"})
|
||||||
|
ev["host_own_name_domain"] = st
|
||||||
|
ctx.log("GET /status Host=%s.example.net -> %s", own, st)
|
||||||
|
ctx.check(st == 403, "a domain name built on the machine's name was accepted (%s)", st)
|
||||||
st, body = fc.get("/status", headers={"Origin": "http://evil.example.net"})
|
st, body = fc.get("/status", headers={"Origin": "http://evil.example.net"})
|
||||||
ev["origin_name"] = st
|
ev["origin_name"] = st
|
||||||
ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
|
ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ def _wdog1_wcr():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _plain(line):
|
||||||
|
"""A line without its ANSI color sequences: what a terminal shows,
|
||||||
|
counted in columns rather than in bytes."""
|
||||||
|
return re.sub(r"\x1b\[[0-9;]*m", "", line)
|
||||||
|
|
||||||
|
|
||||||
def _read(path, default=None):
|
def _read(path, default=None):
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||||
@@ -104,7 +110,9 @@ def fds_of(pid):
|
|||||||
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
|
("grblhal-glowforge", "CMakeLists.txt"), ("kernel-module-glowforge", "**"),
|
||||||
("linux-fslc", "**")],
|
("linux-fslc", "**")],
|
||||||
description="The image that is running is the image the manifest describes, with the "
|
description="The image that is running is the image the manifest describes, with the "
|
||||||
"kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
|
"version stamped in /etc/forgefirm-version and under the machine mark in the "
|
||||||
|
"console banner and the motd, "
|
||||||
|
"the kernel options, the module, the pulse ring it maps and the SDMA clocks it holds, "
|
||||||
"the daemon ownership, "
|
"the daemon ownership, "
|
||||||
"the init ordering, the file modes the release depends on, and the mounts: the "
|
"the init ordering, the file modes the release depends on, and the mounts: the "
|
||||||
"rootfs read-only, /data writable, the account files and the banner rendered "
|
"rootfs read-only, /data writable, the account files and the banner rendered "
|
||||||
@@ -113,11 +121,39 @@ def image_health(ctx):
|
|||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
manifest = ctx.runner.manifest
|
manifest = ctx.runner.manifest
|
||||||
|
|
||||||
# 1. version stamp
|
# 1. version stamp: the machine-readable file, and the two a person
|
||||||
|
# reads - the console banner (/etc/issue, which forgefirm-banner
|
||||||
|
# renders from the image's own text) and the motd a login prints.
|
||||||
|
# Each carries the machine mark with the version on the mark's own
|
||||||
|
# last line, right-justified to the mark's last column.
|
||||||
ver = (_read("/etc/forgefirm-version", "") or "").strip()
|
ver = (_read("/etc/forgefirm-version", "") or "").strip()
|
||||||
ev["forgefirm_version"] = ver
|
ev["forgefirm_version"] = ver
|
||||||
ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version)
|
ctx.log("forgefirm-version: %s (manifest: %s)", ver, manifest.version)
|
||||||
ctx.check(ver == manifest.version, "/etc/forgefirm-version %r != manifest %r", ver, manifest.version)
|
ctx.check(ver == manifest.version, "/etc/forgefirm-version %r != manifest %r", ver, manifest.version)
|
||||||
|
for path in ("/etc/issue", "/etc/motd"):
|
||||||
|
text = _read(path, "") or ""
|
||||||
|
ev[path] = text
|
||||||
|
lines = text.splitlines()
|
||||||
|
at = [i for i, line in enumerate(lines) if line.rstrip().endswith(ver)]
|
||||||
|
ctx.check(at, "%s carries no line ending in the version %r", path, ver)
|
||||||
|
if not at:
|
||||||
|
continue
|
||||||
|
i = at[0]
|
||||||
|
ctx.check(lines[i].rstrip() == lines[i], "%s pads the version line: %r", path, lines[i])
|
||||||
|
ctx.check(_plain(lines[i]).strip() != ver,
|
||||||
|
"%s puts the version on a line of its own, not on the mark's last line", path)
|
||||||
|
# Right-justified to the mark's last column: the stamped line is
|
||||||
|
# exactly as wide as the widest line above it. Only the motd is
|
||||||
|
# measured, because /etc/issue holds the mark with its
|
||||||
|
# backslashes doubled for the getty that reads it, so its bytes
|
||||||
|
# are wider than the columns a person sees.
|
||||||
|
if path == "/etc/motd":
|
||||||
|
above = [len(_plain(line)) for line in lines[:i] if line.strip()]
|
||||||
|
width = max(above) if above else 0
|
||||||
|
ev["motd_columns"] = [len(_plain(lines[i])), width]
|
||||||
|
ctx.check(len(_plain(lines[i])) == width,
|
||||||
|
"/etc/motd stamped line is %d columns, the mark above is %d",
|
||||||
|
len(_plain(lines[i])), width)
|
||||||
|
|
||||||
# 2. kernel options
|
# 2. kernel options
|
||||||
cfg = kernel_config()
|
cfg = kernel_config()
|
||||||
@@ -251,7 +287,8 @@ def image_health(ctx):
|
|||||||
|
|
||||||
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
||||||
# the state a read-only rootfs hands off (the read-only-rootfs image
|
# the state a read-only rootfs hands off (the read-only-rootfs image
|
||||||
# feature, forgefirm-users, forgefirm-banner, the sshd host keys). The
|
# feature, forgefirm-users, forgefirm-hostname, forgefirm-banner, the
|
||||||
|
# sshd host keys). The
|
||||||
# dev image alone mounts the factory slots under /factory.
|
# dev image alone mounts the factory slots under /factory.
|
||||||
mounts = {}
|
mounts = {}
|
||||||
for line in (_read("/proc/mounts", "") or "").splitlines():
|
for line in (_read("/proc/mounts", "") or "").splitlines():
|
||||||
@@ -262,7 +299,8 @@ def image_health(ctx):
|
|||||||
def mount_opts(path):
|
def mount_opts(path):
|
||||||
return (mounts.get(path) or {}).get("opts") or []
|
return (mounts.get(path) or {}).get("opts") or []
|
||||||
|
|
||||||
ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd", "/etc/issue") if p in mounts}
|
ev["mounts"] = {p: mounts[p] for p in ("/", "/data", "/var/lib", "/etc/passwd",
|
||||||
|
"/etc/hostname", "/etc/issue") if p in mounts}
|
||||||
ctx.log("/ mounted %s; /data %s; /var/lib %s", ",".join(mount_opts("/")) or "(absent)",
|
ctx.log("/ mounted %s; /data %s; /var/lib %s", ",".join(mount_opts("/")) or "(absent)",
|
||||||
",".join(mount_opts("/data")) or "(absent)", ",".join(mount_opts("/var/lib")) or "(absent)")
|
",".join(mount_opts("/data")) or "(absent)", ",".join(mount_opts("/var/lib")) or "(absent)")
|
||||||
ctx.check("ro" in mount_opts("/"), "the rootfs is not mounted read-only: %s", mounts.get("/"))
|
ctx.check("ro" in mount_opts("/"), "the rootfs is not mounted read-only: %s", mounts.get("/"))
|
||||||
@@ -292,6 +330,7 @@ def image_health(ctx):
|
|||||||
ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f)
|
ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f)
|
||||||
for n in names:
|
for n in names:
|
||||||
ctx.check(n in passwd_names, "record account %r is missing from /etc/passwd", n)
|
ctx.check(n in passwd_names, "record account %r is missing from /etc/passwd", n)
|
||||||
|
ctx.check("/etc/hostname" in mounts, "/etc/hostname is not the bind-mounted name copy")
|
||||||
ctx.check("/etc/issue" in mounts, "/etc/issue is not the bind-mounted banner copy")
|
ctx.check("/etc/issue" in mounts, "/etc/issue is not the bind-mounted banner copy")
|
||||||
if hw.pidof("sshd"):
|
if hw.pidof("sshd"):
|
||||||
key = "/data/forgefirm/ssh/ssh_host_ed25519_key"
|
key = "/data/forgefirm/ssh/ssh_host_ed25519_key"
|
||||||
|
|||||||
@@ -566,11 +566,19 @@ def m5_rapid_dark(ctx):
|
|||||||
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
ctx.check(base, "forgectrl /status or /cool/status unavailable")
|
||||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||||
ctx.ready(ARM_CUE % "20 mm +X")
|
ctx.ready(ARM_CUE % "20 mm +X")
|
||||||
|
# +20 out on the cut, then the two rapids the test is about, then
|
||||||
|
# a third that puts the head back where the job found it: the
|
||||||
|
# first two net to +20 and a job that walks the head 20 mm and
|
||||||
|
# leaves it there is dirt the hand-back has to jog out. The
|
||||||
|
# return is inside the sampling window and after the M5, so it is
|
||||||
|
# one more rapid that must be dark, which is what the test asks
|
||||||
|
# of the other two.
|
||||||
job = ["G91", "G21", "M3", "S400",
|
job = ["G91", "G21", "M3", "S400",
|
||||||
"G1 X20 F600",
|
"G1 X20 F600",
|
||||||
"M5", "G4 P2.5",
|
"M5", "G4 P2.5",
|
||||||
"G0 X-20", "G4 P2.5",
|
"G0 X-20", "G4 P2.5",
|
||||||
"G0 X20", "G4 P2.5",
|
"G0 X20", "G4 P2.5",
|
||||||
|
"G0 X-20", "G4 P0.5",
|
||||||
"G90", "M2"]
|
"G90", "M2"]
|
||||||
ctx.arm_press()
|
ctx.arm_press()
|
||||||
try:
|
try:
|
||||||
@@ -632,6 +640,7 @@ def disarm_in_hold(ctx):
|
|||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||||
prepare(ctx, g)
|
prepare(ctx, g)
|
||||||
|
k0 = kernel_start(ctx)
|
||||||
ctx.ready(ARM_CUE % "40 mm +X")
|
ctx.ready(ARM_CUE % "40 mm +X")
|
||||||
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
|
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
|
||||||
ctx.log("armed; waiting for motion to start (arm + your button press)...")
|
ctx.log("armed; waiting for motion to start (arm + your button press)...")
|
||||||
@@ -674,6 +683,20 @@ def disarm_in_hold(ctx):
|
|||||||
ctx.sleep(1)
|
ctx.sleep(1)
|
||||||
if "Alarm" in g.status_report()["state"]:
|
if "Alarm" in g.status_report()["state"]:
|
||||||
g.command("$X")
|
g.command("$X")
|
||||||
|
# The hold parks the head part-way through the move and the reset
|
||||||
|
# abandons the rest of it, so the job ends wherever the feed hold
|
||||||
|
# caught it - about 11 mm in, on the bench reference. That is the
|
||||||
|
# test's own doing and it goes back: the kernel counters say how
|
||||||
|
# far the head actually went, which is what a jog has to undo.
|
||||||
|
wait_idle(ctx, g, 15)
|
||||||
|
dx, dy = kernel_xy_mm(ctx)
|
||||||
|
back_x, back_y = k0[0] - dx, k0[1] - dy
|
||||||
|
ev["left_at_mm"] = [round(dx - k0[0], 3), round(dy - k0[1], 3)]
|
||||||
|
if abs(back_x) >= 0.02 or abs(back_y) >= 0.02:
|
||||||
|
ctx.log("returning the head: %+.3f/%+.3f mm", back_x, back_y)
|
||||||
|
g.command("$J=G91X%.3fY%.3fF1200" % (back_x, back_y))
|
||||||
|
wait_idle(ctx, g, 30)
|
||||||
|
ev["returned_mm"] = list(kernel_xy_mm(ctx))
|
||||||
ctx.check(disarmed_at is not None, "still armed after 120 s in Hold")
|
ctx.check(disarmed_at is not None, "still armed after 120 s in Hold")
|
||||||
ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold)
|
ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold)
|
||||||
check_button_dark(ctx, ev)
|
check_button_dark(ctx, ev)
|
||||||
|
|||||||
@@ -1285,8 +1285,13 @@ def interlock_cancel_home(ctx):
|
|||||||
def lid_policy_hold(ctx):
|
def lid_policy_hold(ctx):
|
||||||
ev = ctx.evidence
|
ev = ctx.evidence
|
||||||
fc = ctx.forgectrl
|
fc = ctx.forgectrl
|
||||||
was = (fc.settings() or {}).get("lid_policy") or "cancel"
|
# What the machine had, exactly: an unset lid_policy reads as the empty
|
||||||
|
# string and behaves as cancel, and writing the word back where the
|
||||||
|
# machine had nothing is a leftover the hand-back reports. The default
|
||||||
|
# is for reading the value, never for restoring it.
|
||||||
|
was = (fc.settings() or {}).get("lid_policy", "")
|
||||||
ev["lid_policy_before"] = was
|
ev["lid_policy_before"] = was
|
||||||
|
ev["lid_policy_in_force"] = was or "cancel"
|
||||||
st, _b = fc.post("/settings", data={"lid_policy": "hold"})
|
st, _b = fc.post("/settings", data={"lid_policy": "hold"})
|
||||||
ctx.check(st == 200, "could not set lid_policy=hold (%s)", st)
|
ctx.check(st == 200, "could not set lid_policy=hold (%s)", st)
|
||||||
ctx.check(((fc.settings() or {}).get("lid_policy")) == "hold", "lid_policy did not take")
|
ctx.check(((fc.settings() or {}).get("lid_policy")) == "hold", "lid_policy did not take")
|
||||||
@@ -1332,8 +1337,12 @@ def lid_policy_hold(ctx):
|
|||||||
g.command("G90")
|
g.command("G90")
|
||||||
machine_idle(ctx)
|
machine_idle(ctx)
|
||||||
finally:
|
finally:
|
||||||
st, _b = fc.post("/settings", data={"lid_policy": was})
|
# An empty value clears the key, and only the query-string form
|
||||||
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy")
|
# carries one: an empty form field never reaches the request, and
|
||||||
|
# the write is refused with "no known setting in request".
|
||||||
|
st, _b = (fc.post("/settings", params={"lid_policy": ""}) if not was
|
||||||
|
else fc.post("/settings", data={"lid_policy": was}))
|
||||||
|
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy", "")
|
||||||
ctx.log("lid_policy restored to %s", ev["lid_policy_restored"])
|
ctx.log("lid_policy restored to %s", ev["lid_policy_restored"])
|
||||||
ctx.check(ev["lid_policy_restored"] == was, "lid_policy was not restored to %r", was)
|
ctx.check(ev["lid_policy_restored"] == was, "lid_policy was not restored to %r", was)
|
||||||
ctx.log("PASS: lid_policy=hold parked the job in Door and the cycle start finished it (%.3f mm)",
|
ctx.log("PASS: lid_policy=hold parked the job in Door and the cycle start finished it (%.3f mm)",
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ header {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
#themebtn,
|
#themebtn,
|
||||||
|
#logsbtn,
|
||||||
#logoutbtn {
|
#logoutbtn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgba(255, 255, 255, 0.75);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
@@ -249,6 +250,7 @@ header {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
#themebtn:hover,
|
#themebtn:hover,
|
||||||
|
#logsbtn:hover,
|
||||||
#logoutbtn:hover {
|
#logoutbtn:hover {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: rgba(255, 255, 255, 0.6);
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
@@ -692,3 +694,13 @@ footer a {
|
|||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The setup's log-bundle line, under the header: the result of the
|
||||||
|
header's Download logs, or why the machine refused it. */
|
||||||
|
.wz-logs-msg {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 6px auto 0;
|
||||||
|
padding: 0 16px;
|
||||||
|
min-height: 16px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ class BaselineTests(unittest.TestCase):
|
|||||||
# the class interface writes 'target'; the fake mirrors it into brightness
|
# the class interface writes 'target'; the fake mirrors it into brightness
|
||||||
# only when the test asks (see _sync_leds)
|
# only when the test asks (see _sync_leds)
|
||||||
|
|
||||||
|
def _led2(self, name, target, brightness):
|
||||||
|
"""Both attributes of one button LED: the trigger's commanded
|
||||||
|
level and the fade that follows it."""
|
||||||
|
for a, v in (("target", target), ("brightness", brightness)):
|
||||||
|
with open(self.leds + name + "/" + a, "w") as f:
|
||||||
|
f.write(v)
|
||||||
|
|
||||||
def _sync_leds(self):
|
def _sync_leds(self):
|
||||||
for name in baseline.BUTTON_LEDS:
|
for name in baseline.BUTTON_LEDS:
|
||||||
p = self.leds + name + "/target"
|
p = self.leds + name + "/target"
|
||||||
@@ -154,16 +161,32 @@ class BaselineTests(unittest.TestCase):
|
|||||||
b = self.bl()
|
b = self.bl()
|
||||||
cap = b.capture()
|
cap = b.capture()
|
||||||
# the run left 40 unplayed bytes queued in the kernel ring and the
|
# the run left 40 unplayed bytes queued in the kernel ring and the
|
||||||
# head 1000 counts out: the residue is reported, and the return jog
|
# head 1000 counts out: the hand-back stands the machine down to
|
||||||
# is refused (it would replay the residue first)
|
# empty the ring, and only refuses the return jog when the bytes
|
||||||
|
# are still there afterwards (they are here: no daemon to restart
|
||||||
|
# a controller)
|
||||||
self._pos_bytes(1000, 0, 0, 100, 140)
|
self._pos_bytes(1000, 0, 0, 100, 140)
|
||||||
left = b.enforce("post", captured=cap)
|
left = b.enforce("post", captured=cap)
|
||||||
items = {x.item: x for x in left}
|
items = {x.item: x for x in left}
|
||||||
self.assertIn("pulse ring", items)
|
self.assertIn("pulse ring", items)
|
||||||
self.assertEqual(items["pulse ring"].found, "40 unplayed bytes")
|
self.assertEqual(items["pulse ring"].found, "40 unplayed bytes")
|
||||||
self.assertIn("unplayed bytes queued", items["position"].action)
|
self.assertIn("still queued", items["position"].action)
|
||||||
|
self.assertIn("after a controller restart", items["position"].action)
|
||||||
self.assertEqual(baseline.read_ring_residue(), 40)
|
self.assertEqual(baseline.read_ring_residue(), 40)
|
||||||
|
|
||||||
|
def test_the_hand_back_stands_the_machine_down_for_residue(self):
|
||||||
|
"""It acts, it does not report: a run that left bytes in the ring
|
||||||
|
gets a controller restart out of the hand-back, because that is
|
||||||
|
what empties the ring."""
|
||||||
|
b = self.bl()
|
||||||
|
cap = b.capture()
|
||||||
|
called = []
|
||||||
|
b.stand_down = lambda why: called.append(why)
|
||||||
|
self._pos_bytes(1000, 0, 0, 100, 140)
|
||||||
|
b.enforce("post", captured=cap)
|
||||||
|
self.assertEqual(len(called), 1)
|
||||||
|
self.assertIn("unplayed bytes", called[0])
|
||||||
|
|
||||||
def test_clean_ring_reads_zero_residue(self):
|
def test_clean_ring_reads_zero_residue(self):
|
||||||
self.assertEqual(baseline.read_ring_residue(), 0)
|
self.assertEqual(baseline.read_ring_residue(), 0)
|
||||||
|
|
||||||
@@ -354,6 +377,71 @@ if __name__ == "__main__":
|
|||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class TransientNotLeftoverTests(BaselineTests):
|
||||||
|
"""A leftover is what a run left behind, not the machine part-way
|
||||||
|
through its own work. Found on the bench reference, where
|
||||||
|
laser.arm-wait-lid passed every check it makes and failed its
|
||||||
|
hand-back on the post-job smoke clear."""
|
||||||
|
|
||||||
|
def test_a_fading_button_led_is_not_a_leftover(self):
|
||||||
|
# the trigger fades brightness toward target: target 0 with
|
||||||
|
# brightness still high is the fade from a level the machine ended
|
||||||
|
for name in baseline.BUTTON_LEDS:
|
||||||
|
self._led2(name, target="0", brightness="900")
|
||||||
|
left = self.bl().enforce("post", captured=None)
|
||||||
|
self.assertEqual([x for x in left if x.item.startswith("leds/")], [])
|
||||||
|
|
||||||
|
def test_a_lit_button_led_is_a_leftover(self):
|
||||||
|
# a target the run left standing is dirt, whatever the fade reads
|
||||||
|
self._led2(baseline.BUTTON_LEDS[0], target="1014", brightness="0")
|
||||||
|
left = self.bl().enforce("post", captured=None)
|
||||||
|
items = [x for x in left if x.item.startswith("leds/")]
|
||||||
|
self.assertEqual(len(items), 1)
|
||||||
|
self.assertEqual(items[0].found, "1014")
|
||||||
|
|
||||||
|
|
||||||
|
class CloudOwnedSysfsTests(unittest.TestCase):
|
||||||
|
"""In cloud mode the cloud client configures the machine from the
|
||||||
|
pulse header it is playing, the lens included: z_mode comes from
|
||||||
|
ZSmd through gfhardware's set_mode_from_puls. Handing the GRBL values
|
||||||
|
back under it would be the baseline configuring another controller's
|
||||||
|
machine. In GRBL mode the pair is checked as before."""
|
||||||
|
|
||||||
|
def test_the_z_pair_is_the_cloud_clients(self):
|
||||||
|
for attr in ("head/z_current", "head/z_mode"):
|
||||||
|
self.assertIn(attr, baseline.GRBL_CONTROLLER_SYSFS)
|
||||||
|
|
||||||
|
def test_the_z_pair_is_still_checked_in_grbl_mode(self):
|
||||||
|
fixed = dict(baseline.fixed_sysfs())
|
||||||
|
self.assertEqual(fixed.get("head/z_current"), "1")
|
||||||
|
self.assertEqual(fixed.get("head/z_mode"), "1")
|
||||||
|
|
||||||
|
|
||||||
|
class PositionDeadbandTests(unittest.TestCase):
|
||||||
|
"""The counters count steps and a controller's own return lands within
|
||||||
|
a few hundredths of a millimeter, not on the step: a difference that
|
||||||
|
small is quantization, not a leftover. Found on the bench reference,
|
||||||
|
where motion.lid-cancel-home returned the head twice, each within
|
||||||
|
0.04 mm, and failed the hand-back on the four steps left over."""
|
||||||
|
|
||||||
|
def test_a_few_steps_of_xy_are_the_quantization(self):
|
||||||
|
self.assertTrue(baseline.position_quantized([0, 0, 0], [4, 0, 0]))
|
||||||
|
self.assertTrue(baseline.position_quantized([0, 0, 0], [-4, 4, 0]))
|
||||||
|
self.assertTrue(baseline.position_quantized([10, 20, 30], [10, 20, 30]))
|
||||||
|
|
||||||
|
def test_past_the_dead_band_is_a_leftover(self):
|
||||||
|
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
|
||||||
|
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
|
||||||
|
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
|
||||||
|
|
||||||
|
def test_z_is_exact_because_the_return_never_moves_it(self):
|
||||||
|
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
|
||||||
|
|
||||||
|
def test_an_unreadable_reading_is_never_quantization(self):
|
||||||
|
self.assertFalse(baseline.position_quantized(None, [0, 0, 0]))
|
||||||
|
self.assertFalse(baseline.position_quantized([0, 0, 0], None))
|
||||||
|
|
||||||
|
|
||||||
class BaselineModeTests(BaselineTests):
|
class BaselineModeTests(BaselineTests):
|
||||||
"""The baseline against a fake forgectrl: what the mode in force
|
"""The baseline against a fake forgectrl: what the mode in force
|
||||||
owns. Reuses the fake sysfs tree of BaselineTests; only the new
|
owns. Reuses the fake sysfs tree of BaselineTests; only the new
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"""The commission.* suite on the host: the registration (ids, kinds, the
|
"""The commission.* 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
|
||||||
mDNS packet code, 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
|
||||||
against the fake daemon."""
|
against the fake daemon."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
@@ -19,7 +17,7 @@ from forgetest.suite import commission
|
|||||||
IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot",
|
IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot",
|
||||||
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes",
|
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes",
|
||||||
"commission.ssh-until-reboot", "commission.cloud-disabled-surface",
|
"commission.ssh-until-reboot", "commission.cloud-disabled-surface",
|
||||||
"commission.factory-return", "commission.mdns-announce", "commission.first-run-flow",
|
"commission.factory-return", "commission.machine-name", "commission.first-run-flow",
|
||||||
"commission.first-run-page", "commission.what-changed", "commission.record-export",
|
"commission.first-run-page", "commission.what-changed", "commission.record-export",
|
||||||
"commission.mirror")
|
"commission.mirror")
|
||||||
OPERATOR = ("commission.first-run-flow", "commission.first-run-page")
|
OPERATOR = ("commission.first-run-flow", "commission.first-run-page")
|
||||||
@@ -104,8 +102,8 @@ class RegistrationTests(unittest.TestCase):
|
|||||||
self.assertEqual(t.kind, "auto") # the return itself is a bench drill, not a test
|
self.assertEqual(t.kind, "auto") # the return itself is a bench drill, not a test
|
||||||
self.assertFalse(t.hands)
|
self.assertFalse(t.hands)
|
||||||
|
|
||||||
def test_mdns_covers_nothing_by_design(self):
|
def test_the_machine_name_covers_nothing_by_design(self):
|
||||||
self.assertEqual(self.reg["commission.mdns-announce"].covers, ())
|
self.assertEqual(self.reg["commission.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
|
||||||
@@ -184,35 +182,45 @@ class RecordTests(unittest.TestCase):
|
|||||||
os.environ.pop("GF_RUN_DIR", None)
|
os.environ.pop("GF_RUN_DIR", None)
|
||||||
|
|
||||||
|
|
||||||
class MdnsTests(unittest.TestCase):
|
class MachineNameTests(unittest.TestCase):
|
||||||
def test_query_is_a_unicast_response_question(self):
|
def setUp(self):
|
||||||
q = commission.mdns_query("forgefirm.local", qid=0x1234)
|
self.root = tempfile.mkdtemp()
|
||||||
qid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", q[:12])
|
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||||
self.assertEqual((qid, flags, qd, an, ns, ar), (0x1234, 0, 1, 0, 0, 0))
|
|
||||||
self.assertEqual(q[12:], b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 0x8001))
|
|
||||||
|
|
||||||
def _response(self, qid, name_bytes, addr, flags=0x8400, extra=b""):
|
def _net(self, **devs):
|
||||||
q = b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 1)
|
"""A /sys/class/net tree; commission.mac_suffix reads it through
|
||||||
rr = name_bytes + struct.pack(">HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(addr)
|
commission.read_file, which takes an absolute path."""
|
||||||
return struct.pack(">HHHHHH", qid, flags, 1, 1, 0, 0) + q + rr + extra
|
for dev, mac in devs.items():
|
||||||
|
d = os.path.join(self.root, dev)
|
||||||
|
os.makedirs(d)
|
||||||
|
with open(os.path.join(d, "address"), "w") as f:
|
||||||
|
f.write(mac + "\n")
|
||||||
|
real = commission.read_file
|
||||||
|
|
||||||
def test_answers_follow_a_compression_pointer(self):
|
def read(path):
|
||||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
head = "/sys/class/net/"
|
||||||
self.assertEqual(commission.mdns_answers(pkt, 7), [("forgefirm.local", "192.168.1.9")])
|
if path.startswith(head):
|
||||||
|
return real(os.path.join(self.root, path[len(head):]))
|
||||||
|
return real(path)
|
||||||
|
|
||||||
def test_answers_with_the_name_spelled_out(self):
|
commission.read_file = read
|
||||||
pkt = self._response(0, b"\x09forgefirm\x05local\x00", "10.0.0.5")
|
self.addCleanup(setattr, commission, "read_file", real)
|
||||||
self.assertEqual(commission.mdns_answers(pkt), [("forgefirm.local", "10.0.0.5")])
|
|
||||||
|
|
||||||
def test_wrong_id_or_a_query_yields_nothing(self):
|
def test_the_wifi_address_names_the_machine(self):
|
||||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
|
||||||
self.assertEqual(commission.mdns_answers(pkt, 8), [])
|
self.assertEqual(commission.mac_suffix(), "b00a")
|
||||||
self.assertEqual(commission.mdns_answers(self._response(7, b"\xc0\x0c", "1.2.3.4", flags=0), 7), [])
|
|
||||||
self.assertEqual(commission.mdns_answers(b"\x00" * 5), [])
|
|
||||||
|
|
||||||
def test_a_truncated_packet_yields_nothing(self):
|
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
|
||||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
self._net(eth0="00:11:22:33:44:55")
|
||||||
self.assertEqual(commission.mdns_answers(pkt[:20], 7), [])
|
self.assertEqual(commission.mac_suffix(), "4455")
|
||||||
|
|
||||||
|
def test_an_unread_address_is_no_address(self):
|
||||||
|
self._net(wlan0="00:00:00:00:00:00")
|
||||||
|
self.assertEqual(commission.mac_suffix(), "")
|
||||||
|
|
||||||
|
def test_no_interface_is_no_address(self):
|
||||||
|
self._net()
|
||||||
|
self.assertEqual(commission.mac_suffix(), "")
|
||||||
|
|
||||||
|
|
||||||
class SmallHelpersTests(unittest.TestCase):
|
class SmallHelpersTests(unittest.TestCase):
|
||||||
|
|||||||
@@ -586,6 +586,41 @@ class RoutingTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
runner_mod.hw.button_lit = saved
|
runner_mod.hw.button_lit = saved
|
||||||
|
|
||||||
|
def test_the_press_prompt_is_what_the_actuator_presses_on(self):
|
||||||
|
"""The machine's own press prompt, not a button LED read from
|
||||||
|
here: the button is lit through parts of a live check that are
|
||||||
|
not the arm, and a press then lands before the job waits for it
|
||||||
|
and is lost. press_now presses at the moment the check asks, with
|
||||||
|
no LED reading and no waiting thread."""
|
||||||
|
run = Run("test", "r.live", "r.live")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||||
|
self.runner.probe_fixture(force=True)
|
||||||
|
self.stub.arm_press = False
|
||||||
|
run.fixture_takeover = True
|
||||||
|
saved = runner_mod.hw.button_lit
|
||||||
|
runner_mod.hw.button_lit = lambda: False # never consulted
|
||||||
|
try:
|
||||||
|
self.assertTrue(ctx.press_now())
|
||||||
|
self.assertIn(("button", "press"), self.stub.acts)
|
||||||
|
finally:
|
||||||
|
runner_mod.hw.button_lit = saved
|
||||||
|
rec = [r for r in run.evidence["actions"] if r["state"] == "arm"][-1]
|
||||||
|
self.assertEqual(rec["by"], "fixture")
|
||||||
|
|
||||||
|
def test_the_press_prompt_goes_to_the_operator_without_an_actuator(self):
|
||||||
|
"""No actuator, or one that never took this test's presses: the
|
||||||
|
notice goes up for a person, who is looking at the same prompt."""
|
||||||
|
run = Run("test", "r.live", "r.live")
|
||||||
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||||
|
self.runner.probe_fixture(force=True)
|
||||||
|
self.stub.arm_press = False
|
||||||
|
run.fixture_takeover = False
|
||||||
|
seen = []
|
||||||
|
run.set_notice = lambda text: seen.append(text)
|
||||||
|
self.assertFalse(ctx.press_now("Press it now."))
|
||||||
|
self.assertNotIn(("button", "press"), self.stub.acts)
|
||||||
|
self.assertIn("Press it now.", seen)
|
||||||
|
|
||||||
def test_the_arm_press_waits_as_long_as_the_card_says_for_the_light(self):
|
def test_the_arm_press_waits_as_long_as_the_card_says_for_the_light(self):
|
||||||
"""A card that settles the coolant before it lights the button
|
"""A card that settles the coolant before it lights the button
|
||||||
takes minutes; the caller names the wait, and the actuator presses
|
takes minutes; the caller names the wait, and the actuator presses
|
||||||
|
|||||||
@@ -84,6 +84,32 @@ class OrderTests(unittest.TestCase):
|
|||||||
self.assertEqual(sorted(out), ["s.a", "s.b"])
|
self.assertEqual(sorted(out), ["s.a", "s.b"])
|
||||||
|
|
||||||
|
|
||||||
|
class RunClockTests(unittest.TestCase):
|
||||||
|
"""The page shows the last run until the next one starts, so a
|
||||||
|
finished run's elapsed figure must be the time it took, not the time
|
||||||
|
since. Found on the bench reference, where a failed test went on
|
||||||
|
counting past 5000 s and read as still running."""
|
||||||
|
|
||||||
|
def test_a_finished_runs_clock_stops(self):
|
||||||
|
from forgetest.runner import Run
|
||||||
|
run = Run("test", "t.x", "t.x")
|
||||||
|
run.started = time.time() - 30
|
||||||
|
self.assertGreaterEqual(run.snapshot()["elapsed_s"], 30)
|
||||||
|
run.finished = {"result": "FAIL", "message": "", "duration_s": 31}
|
||||||
|
first = run.snapshot()["elapsed_s"]
|
||||||
|
time.sleep(1.1)
|
||||||
|
self.assertEqual(first, 31)
|
||||||
|
self.assertEqual(run.snapshot()["elapsed_s"], 31)
|
||||||
|
|
||||||
|
def test_a_running_runs_clock_ticks(self):
|
||||||
|
from forgetest.runner import Run
|
||||||
|
run = Run("test", "t.x", "t.x")
|
||||||
|
run.started = time.time() - 5
|
||||||
|
first = run.snapshot()["elapsed_s"]
|
||||||
|
time.sleep(1.1)
|
||||||
|
self.assertGreater(run.snapshot()["elapsed_s"], first)
|
||||||
|
|
||||||
|
|
||||||
class QueueTests(unittest.TestCase):
|
class QueueTests(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
|
|||||||
@@ -94,13 +94,28 @@ class TreeManifestTests(unittest.TestCase):
|
|||||||
self.assertNotEqual(self.mod.layer_content(self.layer), base)
|
self.assertNotEqual(self.mod.layer_content(self.layer), base)
|
||||||
|
|
||||||
def test_layer_hash_matches_the_bbclass_rule(self):
|
def test_layer_hash_matches_the_bbclass_rule(self):
|
||||||
# The same skip list as FORGEFIRM_MANIFEST_PIN_SUFFIX + *.md in
|
# The same skip list as *.md plus FORGEFIRM_MANIFEST_PIN_SUFFIX and
|
||||||
# forgefirm-image-manifest.bbclass.
|
# FORGEFIRM_MANIFEST_VERSION_SUFFIX in
|
||||||
|
# forgefirm-image-manifest.bbclass. The two implementations compute
|
||||||
|
# the same identity - one on the build host, one on a workstation -
|
||||||
|
# so a suffix added to either without the other is the failure this
|
||||||
|
# guards.
|
||||||
bbclass = os.path.join(REPO, "meta-forgefirm", "classes", "forgefirm-image-manifest.bbclass")
|
bbclass = os.path.join(REPO, "meta-forgefirm", "classes", "forgefirm-image-manifest.bbclass")
|
||||||
with open(bbclass, encoding="utf-8") as f:
|
with open(bbclass, encoding="utf-8") as f:
|
||||||
text = f.read()
|
text = f.read()
|
||||||
self.assertIn('FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"', text)
|
self.assertIn('FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"', text)
|
||||||
self.assertEqual(self.mod.LAYER_SKIP_SUFFIXES, (".md", "-pin.inc"))
|
self.assertIn('FORGEFIRM_MANIFEST_VERSION_SUFFIX ?= "forgefirm-release.inc"', text)
|
||||||
|
self.assertEqual(self.mod.LAYER_SKIP_SUFFIXES,
|
||||||
|
(".md", "-pin.inc", "forgefirm-release.inc"))
|
||||||
|
|
||||||
|
def test_layer_hash_ignores_the_release_version(self):
|
||||||
|
# The version is metadata: setting the release number must not move
|
||||||
|
# the layer hash, or the bump would invalidate the campaign that
|
||||||
|
# authorizes the release.
|
||||||
|
self.write("forgefirm-release.inc",'FORGEFIRM_RELEASE ?= "0.0.1"\n')
|
||||||
|
base = self.mod.layer_content(self.layer)
|
||||||
|
self.write("forgefirm-release.inc",'FORGEFIRM_RELEASE ?= "0.0.2"\n')
|
||||||
|
self.assertEqual(self.mod.layer_content(self.layer), base)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ overrides:
|
|||||||
meta-freescale-distro:
|
meta-freescale-distro:
|
||||||
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
||||||
meta-openglow:
|
meta-openglow:
|
||||||
commit: b7ad6d9377fbb4215dc4d898837906b06b47e5e9
|
commit: ced2af274ba270fd8fc9a6601bbd3fc6247110a6
|
||||||
|
|||||||
@@ -44,6 +44,13 @@
|
|||||||
FORGEFIRM_MANIFEST_DIR ?= "${sysconfdir}/forgefirm-manifest.d"
|
FORGEFIRM_MANIFEST_DIR ?= "${sysconfdir}/forgefirm-manifest.d"
|
||||||
FORGEFIRM_MANIFEST_CONTENT_LAYERS ?= "meta-forgefirm meta-glowforge-bsp meta-openglow-core"
|
FORGEFIRM_MANIFEST_CONTENT_LAYERS ?= "meta-forgefirm meta-glowforge-bsp meta-openglow-core"
|
||||||
FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"
|
FORGEFIRM_MANIFEST_PIN_SUFFIX ?= "-pin.inc"
|
||||||
|
# The release version file. Like a pin file it is metadata, not platform
|
||||||
|
# content: it carries FORGEFIRM_RELEASE and nothing else, the version
|
||||||
|
# string is already outside the identity hash (below), and the release
|
||||||
|
# gate proves the number against the rootfs stamp, the .fw meta-version
|
||||||
|
# and the tag. Hashing it would make every version bump a platform
|
||||||
|
# change and invalidate the campaign that authorizes the release.
|
||||||
|
FORGEFIRM_MANIFEST_VERSION_SUFFIX ?= "forgefirm-release.inc"
|
||||||
|
|
||||||
do_rootfs[depends] += "virtual/kernel:do_deploy kernel-module-glowforge:do_deploy"
|
do_rootfs[depends] += "virtual/kernel:do_deploy kernel-module-glowforge:do_deploy"
|
||||||
|
|
||||||
@@ -88,7 +95,8 @@ def forgefirm_manifest_layers(d):
|
|||||||
and dirty flag of every layer checkout (informational)."""
|
and dirty flag of every layer checkout (informational)."""
|
||||||
import os, subprocess
|
import os, subprocess
|
||||||
content_layers = (d.getVar('FORGEFIRM_MANIFEST_CONTENT_LAYERS') or '').split()
|
content_layers = (d.getVar('FORGEFIRM_MANIFEST_CONTENT_LAYERS') or '').split()
|
||||||
skip = ('.md',) + tuple((d.getVar('FORGEFIRM_MANIFEST_PIN_SUFFIX') or '').split())
|
skip = (('.md',) + tuple((d.getVar('FORGEFIRM_MANIFEST_PIN_SUFFIX') or '').split())
|
||||||
|
+ tuple((d.getVar('FORGEFIRM_MANIFEST_VERSION_SUFFIX') or '').split()))
|
||||||
identity, build = {}, {}
|
identity, build = {}, {}
|
||||||
for layer in (d.getVar('BBLAYERS') or '').split():
|
for layer in (d.getVar('BBLAYERS') or '').split():
|
||||||
name = os.path.basename(layer.rstrip('/'))
|
name = os.path.basename(layer.rstrip('/'))
|
||||||
|
|||||||
@@ -8,19 +8,19 @@ DISTRO_FEATURES:remove = " \
|
|||||||
3g alsa avahi bluetooth bluez5 ext2 irda nfc nfs pci pcmcia \
|
3g alsa avahi bluetooth bluez5 ext2 irda nfc nfs pci pcmcia \
|
||||||
pulseaudio vulkan wayland x11 zeroconf "
|
pulseaudio vulkan wayland x11 zeroconf "
|
||||||
|
|
||||||
# mDNS: the image installs avahi-daemon by name (forgefirm-image.bb), so
|
# The name the image ships in /etc/hostname. forgefirm-hostname replaces
|
||||||
# the control panel answers at forgefirm.local. The avahi and zeroconf
|
# it at S38 in rcS with forgefirm-<xxxx>, from the machine's MAC address;
|
||||||
# features stay removed above: zeroconf would install
|
# this is what the few seconds before that show, and the fallback on a
|
||||||
# packagegroup-base-zeroconf (the daemon plus libnss-mdns), and avahi
|
# machine whose MAC address cannot be read.
|
||||||
# would switch other recipes' avahi options on. The build is trimmed to
|
hostname:pn-base-files = "forgefirm"
|
||||||
# the daemon: no D-Bus (so no libavahi-client, no avahi-utils, no bus
|
|
||||||
# activation; the daemon reads /etc/avahi/services itself), no GTK or Qt
|
# No mDNS. The panel is reached at the machine's address, which every
|
||||||
# front ends, no libdns_sd compatibility library, no libevent binding.
|
# network resolves; the machine's own name (forgefirm-<xxxx>, from
|
||||||
# Python bindings are off in the recipe itself.
|
# forgefirm-hostname) goes out in the DHCP request, so a network with
|
||||||
PACKAGECONFIG:pn-avahi = ""
|
# dynamic DNS publishes it as well. The avahi and zeroconf features stay
|
||||||
# libnss-mdns is what avahi-daemon recommends: an NSS module so the
|
# removed above: zeroconf would install packagegroup-base-zeroconf (the
|
||||||
# machine itself resolves .local names. Nothing on the machine does.
|
# daemon plus libnss-mdns), and avahi would switch other recipes' avahi
|
||||||
BAD_RECOMMENDATIONS += "libnss-mdns"
|
# options on.
|
||||||
|
|
||||||
# TLS: forgectrl serves HTTPS on 443 with a self-signed certificate
|
# TLS: forgectrl serves HTTPS on 443 with a self-signed certificate
|
||||||
# through libmicrohttpd and ulfius, and links GnuTLS itself. GnuTLS is
|
# through libmicrohttpd and ulfius, and links GnuTLS itself. GnuTLS is
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
# ForgeFIRM avahi-daemon configuration (avahi-daemon.conf(5)).
|
|
||||||
# The machine answers forgefirm.local on the WiFi link (and on eth0 when
|
|
||||||
# the machine has one), over IPv4 and IPv6. It publishes its addresses
|
|
||||||
# and the services in /etc/avahi/services, nothing else: no workstation
|
|
||||||
# record, no host information, no wide-area lookups, no reflector.
|
|
||||||
|
|
||||||
[server]
|
|
||||||
host-name=forgefirm
|
|
||||||
use-ipv4=yes
|
|
||||||
use-ipv6=yes
|
|
||||||
allow-interfaces=wlan0,eth0
|
|
||||||
ratelimit-interval-usec=1000000
|
|
||||||
ratelimit-burst=1000
|
|
||||||
|
|
||||||
[wide-area]
|
|
||||||
enable-wide-area=no
|
|
||||||
|
|
||||||
[publish]
|
|
||||||
publish-hinfo=no
|
|
||||||
publish-workstation=no
|
|
||||||
publish-addresses=yes
|
|
||||||
publish-domain=yes
|
|
||||||
|
|
||||||
[reflector]
|
|
||||||
enable-reflector=no
|
|
||||||
|
|
||||||
[rlimits]
|
|
||||||
rlimit-core=0
|
|
||||||
rlimit-data=8388608
|
|
||||||
rlimit-fsize=0
|
|
||||||
rlimit-nofile=768
|
|
||||||
rlimit-stack=8388608
|
|
||||||
rlimit-nproc=3
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?xml version="1.0" standalone='no'?>
|
|
||||||
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
|
|
||||||
|
|
||||||
<!-- ForgeFIRM control panel: HTTPS on 443 and HTTP on 80, served by
|
|
||||||
forgectrl. %h is the host name (avahi.service(5)). -->
|
|
||||||
|
|
||||||
<service-group>
|
|
||||||
|
|
||||||
<name replace-wildcards="yes">ForgeFIRM on %h</name>
|
|
||||||
|
|
||||||
<service>
|
|
||||||
<type>_https._tcp</type>
|
|
||||||
<port>443</port>
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<service>
|
|
||||||
<type>_http._tcp</type>
|
|
||||||
<port>80</port>
|
|
||||||
</service>
|
|
||||||
|
|
||||||
</service-group>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
FILESEXTRAPATHS:prepend := "${THISDIR}/${BPN}:"
|
|
||||||
|
|
||||||
# mDNS for the control panel: the machine answers forgefirm.local and
|
|
||||||
# advertises the panel on HTTPS 443 and HTTP 80. Only avahi-daemon is
|
|
||||||
# installed (forgefirm-image.bb); the build options that keep it to the
|
|
||||||
# daemon are in conf/distro/forgefirm.conf. The daemon reads the service
|
|
||||||
# file itself: no D-Bus is involved.
|
|
||||||
SRC_URI += " \
|
|
||||||
file://avahi-daemon.conf \
|
|
||||||
file://forgefirm.service \
|
|
||||||
"
|
|
||||||
|
|
||||||
do_install:append() {
|
|
||||||
install -m 0644 ${WORKDIR}/avahi-daemon.conf ${D}${sysconfdir}/avahi/avahi-daemon.conf
|
|
||||||
install -d ${D}${sysconfdir}/avahi/services
|
|
||||||
install -m 0644 ${WORKDIR}/forgefirm.service ${D}${sysconfdir}/avahi/services/forgefirm.service
|
|
||||||
}
|
|
||||||
@@ -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 = "468ee215dd1148b92c979decacecc41aa6a75ccf"
|
SRCREV = "a9e7073b2325c8c9d89d490fe6d067abfac22da5"
|
||||||
PV = "0.1.12"
|
PV = "0.1.17"
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Rewrites the address block of /etc/issue, the serial-console login
|
# Rewrites /etc/issue, the serial-console login banner: the lines the
|
||||||
# banner: the control panel by mDNS name, then one https:// URL per
|
# image build wrote, then one https:// URL per global address of wlan0
|
||||||
# global address of wlan0 (and eth0 when the machine has one). Every
|
# (and eth0 when the machine has one). The address is the only way in
|
||||||
# other line of /etc/issue stays as the image build wrote it. The block
|
# that the banner gives, because it is the one that works on every
|
||||||
# sits between the marker lines "# ForgeFIRM addresses" and "# end" and
|
# network. Called by the init script at boot and by the udhcpc hook on
|
||||||
# is appended when absent. Called by the init script at boot and by the
|
# every lease event. Idempotent: the file is written only when the text
|
||||||
# udhcpc hook on every lease event. Idempotent: the file is written only
|
# changes.
|
||||||
# when the block changes.
|
|
||||||
#
|
#
|
||||||
# The rootfs is read-only: at the first change after boot the file is
|
# The rootfs is read-only: at the first change after boot the file is
|
||||||
# bind-mounted from a copy under /run/forgefirm (tmpfs) and the block is
|
# bind-mounted from a copy under /run/forgefirm (tmpfs) and the banner
|
||||||
# written through the mount. Before that the image's own file shows.
|
# is written through the mount. Before that the image's own file shows.
|
||||||
|
# The image's own text is kept at that moment in a second copy, so the
|
||||||
|
# banner needs no marker line in the file and shows nothing but itself.
|
||||||
|
|
||||||
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
||||||
|
|
||||||
ISSUE=/etc/issue
|
ISSUE=/etc/issue
|
||||||
STATE=/run/forgefirm/issue
|
STATE=/run/forgefirm/issue
|
||||||
MARK_BEGIN='# ForgeFIRM addresses'
|
BASE=/run/forgefirm/issue.base
|
||||||
MARK_END='# end'
|
|
||||||
PANEL='Control panel: https://forgefirm.local/'
|
|
||||||
|
|
||||||
# One URL per global address; an IPv6 address gets its URL brackets.
|
# One URL per global address; an IPv6 address gets its URL brackets.
|
||||||
# Tentative, deprecated and temporary addresses are left out.
|
# Tentative, deprecated and temporary addresses are left out.
|
||||||
@@ -32,45 +31,40 @@ addresses () {
|
|||||||
a = $2
|
a = $2
|
||||||
sub(/\/.*/, "", a)
|
sub(/\/.*/, "", a)
|
||||||
if ($1 == "inet6") a = "[" a "]"
|
if ($1 == "inet6") a = "[" a "]"
|
||||||
print "https://" a "/"
|
print " https://" a "/"
|
||||||
}'
|
}'
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
block () {
|
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
|
||||||
echo "$MARK_BEGIN"
|
# file by its device numbers alone).
|
||||||
echo "$PANEL"
|
is_mounted () {
|
||||||
addrs=$(addresses)
|
awk -v t="$1" '$2 == t { f = 1 } END { exit !f }' /proc/mounts
|
||||||
if [ -n "$addrs" ]; then
|
|
||||||
echo "$addrs"
|
|
||||||
else
|
|
||||||
echo "no network address yet"
|
|
||||||
fi
|
|
||||||
echo "$MARK_END"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[ -f "$ISSUE" ] || exit 0
|
[ -f "$ISSUE" ] || exit 0
|
||||||
|
|
||||||
new=$(block)
|
# The image's own text: the file as it is before the first write of the
|
||||||
old=$(awk -v b="$MARK_BEGIN" -v e="$MARK_END" \
|
# boot. /run is a tmpfs, so the copy is made once per boot, and the
|
||||||
'$0 == b { p = 1 } p { print } $0 == e { p = 0 }' "$ISSUE")
|
# first caller is the one that finds the file untouched.
|
||||||
[ "$new" = "$old" ] && exit 0
|
mkdir -p "${STATE%/*}" || exit 1
|
||||||
|
[ -f "$BASE" ] || cp -p "$ISSUE" "$BASE" || exit 1
|
||||||
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
|
if ! is_mounted "$ISSUE"; then
|
||||||
# file by its device numbers alone).
|
cp -p "$BASE" "$STATE" \
|
||||||
if ! awk -v t="$ISSUE" '$2 == t { f = 1 } END { exit !f }' /proc/mounts; then
|
|
||||||
mkdir -p "${STATE%/*}" \
|
|
||||||
&& cp -p "$ISSUE" "$STATE" \
|
|
||||||
&& mount --bind "$STATE" "$ISSUE" || exit 1
|
&& mount --bind "$STATE" "$ISSUE" || exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
base=$(cat "$BASE")
|
||||||
|
addrs=$(addresses)
|
||||||
|
if [ -n "$addrs" ]; then
|
||||||
|
new=$(printf '%s\n\nControl panel:\n%s\n' "$base" "$addrs")
|
||||||
|
else
|
||||||
|
new=$(printf '%s\n\nControl panel: no network address yet\n' "$base")
|
||||||
|
fi
|
||||||
|
[ "$new" = "$(cat "$ISSUE")" ] && exit 0
|
||||||
|
|
||||||
tmp="$STATE.tmp.$$"
|
tmp="$STATE.tmp.$$"
|
||||||
awk -v b="$MARK_BEGIN" -v e="$MARK_END" -v blk="$new" '
|
printf '%s\n\n' "$new" > "$tmp" || { rm -f "$tmp"; exit 1; }
|
||||||
$0 == b { print blk; seen = 1; skip = 1; next }
|
|
||||||
$0 == e && skip { skip = 0; next }
|
|
||||||
!skip { print }
|
|
||||||
END { if (!seen) print blk }
|
|
||||||
' "$ISSUE" > "$tmp" || { rm -f "$tmp"; exit 1; }
|
|
||||||
cat "$tmp" > "$ISSUE"
|
cat "$tmp" > "$ISSUE"
|
||||||
rm -f "$tmp"
|
rm -f "$tmp"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
|
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
|
||||||
DESCRIPTION = "Keeps an address block in the serial-console login banner \
|
DESCRIPTION = "Keeps an address block in the serial-console login banner \
|
||||||
(/etc/issue): the control panel by mDNS name and by every global address \
|
(/etc/issue): one control panel URL per global address of wlan0 and eth0. \
|
||||||
of wlan0 and eth0. Refreshed at boot and on every DHCP lease event."
|
Refreshed at boot and on every DHCP lease event."
|
||||||
LICENSE = "MIT"
|
LICENSE = "MIT"
|
||||||
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
|
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Sets the machine's hostname to forgefirm-<xxxx>, where xxxx is the
|
||||||
|
# last four hex digits of the wlan0 MAC address (eth0 on a machine with
|
||||||
|
# no WiFi). The name is the same at every boot, two machines on one
|
||||||
|
# network answer to different names, and the name carries no serial
|
||||||
|
# number. The DHCP client sends it as the hostname option
|
||||||
|
# (/etc/network/interfaces), so a network with dynamic DNS resolves it.
|
||||||
|
#
|
||||||
|
# The rootfs is read-only: /etc/hostname shows a copy under
|
||||||
|
# /run/forgefirm (tmpfs), bind-mounted, and the name is written through
|
||||||
|
# the mount. The init script calls this at boot, before the network
|
||||||
|
# starts and before anything reads the name.
|
||||||
|
|
||||||
|
PATH=/sbin:/usr/sbin:/bin:/usr/bin
|
||||||
|
|
||||||
|
FILE=/etc/hostname
|
||||||
|
STATE=/run/forgefirm/hostname
|
||||||
|
PREFIX=forgefirm
|
||||||
|
WAIT_S=10
|
||||||
|
|
||||||
|
# The MAC address of the first interface that has one. An interface
|
||||||
|
# before its driver reads the address reports all zeros.
|
||||||
|
mac () {
|
||||||
|
for dev in wlan0 eth0; do
|
||||||
|
a=$(cat "/sys/class/net/$dev/address" 2>/dev/null) || continue
|
||||||
|
case "$a" in
|
||||||
|
''|00:00:00:00:00:00) continue ;;
|
||||||
|
esac
|
||||||
|
printf '%s\n' "$a"
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
|
||||||
|
# file by its device numbers alone).
|
||||||
|
is_mounted () {
|
||||||
|
awk -v t="$1" '$2 == t { f = 1 } END { exit !f }' /proc/mounts
|
||||||
|
}
|
||||||
|
|
||||||
|
# An interface registers when its driver probes, which udev does earlier
|
||||||
|
# in rcS. The wait is for a slow probe and ends at once in the normal
|
||||||
|
# case.
|
||||||
|
n=0
|
||||||
|
while [ "$n" -lt "$WAIT_S" ] && ! mac >/dev/null; do
|
||||||
|
sleep 1
|
||||||
|
n=$((n + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
suffix=$(mac | tr -d ':' | tr 'A-Z' 'a-z' | cut -c9-12)
|
||||||
|
if [ -n "$suffix" ]; then
|
||||||
|
name="$PREFIX-$suffix"
|
||||||
|
else
|
||||||
|
name="$PREFIX"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "$(hostname)" = "$name" ] || hostname "$name"
|
||||||
|
|
||||||
|
# The file follows the live name, so every reader agrees.
|
||||||
|
[ -f "$FILE" ] || exit 0
|
||||||
|
[ "$(cat "$FILE" 2>/dev/null)" = "$name" ] && exit 0
|
||||||
|
if ! is_mounted "$FILE"; then
|
||||||
|
mkdir -p "${STATE%/*}" \
|
||||||
|
&& cp -p "$FILE" "$STATE" \
|
||||||
|
&& mount --bind "$STATE" "$FILE" || exit 1
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$name" > "$FILE" || exit 1
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
### BEGIN INIT INFO
|
||||||
|
# Provides: forgefirm-hostname
|
||||||
|
# Required-Start: $local_fs
|
||||||
|
# Required-Stop:
|
||||||
|
# Default-Start: S
|
||||||
|
# Default-Stop:
|
||||||
|
# Short-Description: ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address
|
||||||
|
### END INIT INFO
|
||||||
|
|
||||||
|
# Writes the hostname before the network starts. At boot this runs at
|
||||||
|
# S38 in rcS: /run is mounted (mountall, S03), udev has probed the
|
||||||
|
# network drivers (S04), and poky's own hostname.sh (S39) reads the file
|
||||||
|
# this leaves.
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
start|restart|reload|force-reload)
|
||||||
|
/usr/sbin/forgefirm-hostname
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
hostname
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {start|stop|restart|status}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
SUMMARY = "ForgeFIRM hostname: forgefirm-<xxxx> from the MAC address"
|
||||||
|
DESCRIPTION = "Names the machine forgefirm-<xxxx>, where xxxx is the last \
|
||||||
|
four hex digits of the wlan0 MAC address (eth0 on a machine with no WiFi). \
|
||||||
|
The name is the same at every boot, two machines on one network answer to \
|
||||||
|
different names, and the name carries no serial number. The DHCP client \
|
||||||
|
sends it as the hostname option, so a network with dynamic DNS resolves it."
|
||||||
|
LICENSE = "MIT"
|
||||||
|
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
|
||||||
|
|
||||||
|
SRC_URI = " \
|
||||||
|
file://forgefirm-hostname \
|
||||||
|
file://forgefirm-hostname.init \
|
||||||
|
"
|
||||||
|
|
||||||
|
S = "${WORKDIR}"
|
||||||
|
|
||||||
|
inherit update-rc.d
|
||||||
|
|
||||||
|
INITSCRIPT_NAME = "forgefirm-hostname"
|
||||||
|
# 38 in rcS: after udev (S04) has probed the network drivers, before
|
||||||
|
# poky's hostname.sh (S39) reads /etc/hostname and before the network
|
||||||
|
# starts (rc5 S01).
|
||||||
|
INITSCRIPT_PARAMS = "start 38 S ."
|
||||||
|
|
||||||
|
do_install() {
|
||||||
|
install -Dm 0755 ${WORKDIR}/forgefirm-hostname ${D}${sbindir}/forgefirm-hostname
|
||||||
|
install -Dm 0755 ${WORKDIR}/forgefirm-hostname.init ${D}${sysconfdir}/init.d/forgefirm-hostname
|
||||||
|
}
|
||||||
@@ -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 = "9ee624bbc1d1fd929f1f5587b6fc6acaa13a4561"
|
SRCREV = "1f2ea9ca83aa0035260c9db6b21c4fe0873796e0"
|
||||||
PV = "0.1.10"
|
PV = "0.1.11"
|
||||||
|
|||||||
@@ -48,15 +48,12 @@ IMAGE_INSTALL:append = " grblhal-glowforge forgectrl gfhome gfcloud v4l-utils fw
|
|||||||
# forgefirm-users: renders the operator account record
|
# forgefirm-users: renders the operator account record
|
||||||
# (/data/forgefirm/users, written by forgectrl) into the system account
|
# (/data/forgefirm/users, written by forgectrl) into the system account
|
||||||
# files at boot, before sshd, and on reload; also installs the warning an
|
# files at boot, before sshd, and on reload; also installs the warning an
|
||||||
# interactive root shell prints. forgefirm-banner: keeps the control
|
# interactive root shell prints. forgefirm-hostname: names the machine
|
||||||
# panel addresses in the serial-console banner (/etc/issue).
|
# forgefirm-<xxxx> from its MAC address, before the network starts.
|
||||||
|
# forgefirm-banner: keeps the control panel addresses in the
|
||||||
|
# serial-console banner (/etc/issue).
|
||||||
# forgefirm-persist: the boot timestamp and the random seed on /data.
|
# forgefirm-persist: the boot timestamp and the random seed on /data.
|
||||||
# avahi-daemon: mDNS, so the panel answers at https://forgefirm.local/
|
IMAGE_INSTALL:append = " forgefirm-users forgefirm-hostname forgefirm-banner forgefirm-persist"
|
||||||
# and shows up in service browsers. The daemon is installed by name (the
|
|
||||||
# zeroconf distro feature stays off: it would bring libnss-mdns); the
|
|
||||||
# build options and the configuration are in conf/distro/forgefirm.conf
|
|
||||||
# and recipes-connectivity/avahi.
|
|
||||||
IMAGE_INSTALL:append = " forgefirm-users forgefirm-banner forgefirm-persist avahi-daemon"
|
|
||||||
|
|
||||||
# The rootfs mounts read-only on both images; /data (p3) is the writable
|
# The rootfs mounts read-only on both images; /data (p3) is the writable
|
||||||
# partition. read-only-rootfs is poky's feature for it: the root line of
|
# partition. read-only-rootfs is poky's feature for it: the root line of
|
||||||
@@ -67,8 +64,9 @@ IMAGE_INSTALL:append = " forgefirm-users forgefirm-banner forgefirm-persist avah
|
|||||||
# package whose post-install must run on the machine, and the removal of
|
# package whose post-install must run on the machine, and the removal of
|
||||||
# the packages a read-only rootfs cannot use (shadow, base-passwd,
|
# the packages a read-only rootfs cannot use (shadow, base-passwd,
|
||||||
# update-rc.d, update-alternatives; the account files stay). What must
|
# update-rc.d, update-alternatives; the account files stay). What must
|
||||||
# last or change at run time is handled file by file: the account files
|
# last or change at run time is handled file by file: the account files,
|
||||||
# and /etc/issue (forgefirm-users, forgefirm-banner), the sshd host keys
|
# /etc/hostname and /etc/issue (forgefirm-users, forgefirm-hostname,
|
||||||
|
# forgefirm-banner), the sshd host keys
|
||||||
# (recipes-connectivity/openssh), the timestamp and the random seed
|
# (recipes-connectivity/openssh), the timestamp and the random seed
|
||||||
# (forgefirm-persist). The facts are on the docs site,
|
# (forgefirm-persist). The facts are on the docs site,
|
||||||
# technical/forgefirm/image-and-bsp; scripts/release.sh checks the built
|
# technical/forgefirm/image-and-bsp; scripts/release.sh checks the built
|
||||||
@@ -108,19 +106,81 @@ IMAGE_OVERHEAD_FACTOR = "1.0"
|
|||||||
IMAGE_ROOTFS_EXTRA_SPACE = "40960"
|
IMAGE_ROOTFS_EXTRA_SPACE = "40960"
|
||||||
IMAGE_ROOTFS_MAXSIZE = "204800"
|
IMAGE_ROOTFS_MAXSIZE = "204800"
|
||||||
|
|
||||||
# Version stamp: /etc/forgefirm-version (machine-readable), echoed on the
|
# The ForgeFIRM mark and the version stamp:
|
||||||
# serial-console login prompt (/etc/issue) and at SSH login (motd).
|
# /etc/forgefirm-version (machine-readable), and, under the OpenGlow mark
|
||||||
|
# the base image carries (base-files, meta-openglow), the ForgeFIRM mark
|
||||||
|
# with the version on its last line, right-justified to the mark's last
|
||||||
|
# column, in the two files a person reads - the serial-console login
|
||||||
|
# prompt (/etc/issue) and the motd, which every login prints, the network
|
||||||
|
# ones included. The mark names the firmware, so the version stands
|
||||||
|
# alone.
|
||||||
|
#
|
||||||
|
# The mark is written once here and rendered for each reader, because the
|
||||||
|
# two files are read by different programs:
|
||||||
|
# /etc/issue busybox getty parses it, and both a backslash and a
|
||||||
|
# percent sign start an escape (libbb/login.c,
|
||||||
|
# print_login_issue). An unrecognized escape prints the
|
||||||
|
# character and swallows the backslash, so the art goes in
|
||||||
|
# with every backslash doubled.
|
||||||
|
# /etc/motd a login writes it out as it is: nothing is doubled.
|
||||||
|
# Both keep their color: a getty passes an escape character through, and
|
||||||
|
# so does a login writing the motd. Only the ssh client escapes one, and
|
||||||
|
# it does that to a banner alone.
|
||||||
|
# The pre-authentication banner (/etc/issue.net) stays unused: the ssh
|
||||||
|
# client prints a control character in a banner as an octal escape, and
|
||||||
|
# the machine tells a client that has not logged in nothing anyway.
|
||||||
|
#
|
||||||
# Release images carry the release version; the dev image overrides the
|
# Release images carry the release version; the dev image overrides the
|
||||||
# string with the build timestamp (the same DATETIME as the artifact
|
# string with the build timestamp (the same DATETIME as the artifact
|
||||||
# name) plus a dev tag.
|
# name) plus a dev tag.
|
||||||
FORGEFIRM_RELEASE ?= "0.0.1"
|
#
|
||||||
|
# FORGEFIRM_RELEASE lives in its own file, which the manifest leaves out
|
||||||
|
# of the layer content hash: the version is metadata, and a bump must not
|
||||||
|
# read as a platform change and invalidate a campaign
|
||||||
|
# (forgefirm-release.inc).
|
||||||
|
require forgefirm-release.inc
|
||||||
FORGEFIRM_VERSION_STRING ?= "v${FORGEFIRM_RELEASE}"
|
FORGEFIRM_VERSION_STRING ?= "v${FORGEFIRM_RELEASE}"
|
||||||
|
|
||||||
write_forgefirm_version() {
|
write_forgefirm_version() {
|
||||||
echo "${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/forgefirm-version
|
echo "${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/forgefirm-version
|
||||||
echo "ForgeFIRM ${FORGEFIRM_VERSION_STRING}" >> ${IMAGE_ROOTFS}${sysconfdir}/issue
|
|
||||||
|
mark=${WORKDIR}/forgefirm-mark
|
||||||
|
cat > $mark <<'MARK'
|
||||||
|
___ [1;39m___ ___ ___ __ __[0m
|
||||||
|
| __|__ _ _ __ _ ___[1;39m| __|_ _| _ \ \/ |[0m
|
||||||
|
| _/ _ \ '_/ _` / -_) [1;39m_| | || / |\/| |[0m
|
||||||
|
|_|\___/_| \__, \___|[1;39m_| |___|_|_\_| |_|[0m
|
||||||
|
|___/
|
||||||
|
MARK
|
||||||
|
|
||||||
|
# The version rides the mark's own last line, its last character on
|
||||||
|
# the mark's last column: under the FIRM half, in the room the
|
||||||
|
# descender of the Forge half leaves. The mark carries the name, so
|
||||||
|
# the version stands alone. Measured in columns, not in bytes: the
|
||||||
|
# color sequences take no room on the screen, and the doubling below
|
||||||
|
# is undone by the getty that reads it. A version with no room left
|
||||||
|
# keeps one space and runs past the mark rather than being cut.
|
||||||
|
stamped=${WORKDIR}/forgefirm-mark-stamped
|
||||||
|
awk -v v="${FORGEFIRM_VERSION_STRING}" '
|
||||||
|
{ line[NR] = $0
|
||||||
|
bare = $0
|
||||||
|
gsub(/\033\[[0-9;]*m/, "", bare)
|
||||||
|
col[NR] = length(bare)
|
||||||
|
if (col[NR] > w) w = col[NR] }
|
||||||
|
END { for (i = 1; i < NR; i++) print line[i]
|
||||||
|
pad = w - col[NR] - length(v)
|
||||||
|
if (pad < 1) pad = 1
|
||||||
|
gap = ""
|
||||||
|
while (length(gap) < pad) gap = gap " "
|
||||||
|
print line[NR] gap v }' $mark > $stamped
|
||||||
|
|
||||||
|
sed 's|\\|\\\\|g' $stamped >> ${IMAGE_ROOTFS}${sysconfdir}/issue
|
||||||
echo "" >> ${IMAGE_ROOTFS}${sysconfdir}/issue
|
echo "" >> ${IMAGE_ROOTFS}${sysconfdir}/issue
|
||||||
echo "ForgeFIRM ${FORGEFIRM_VERSION_STRING}" > ${IMAGE_ROOTFS}${sysconfdir}/motd
|
|
||||||
|
cat $stamped >> ${IMAGE_ROOTFS}${sysconfdir}/motd
|
||||||
|
echo "" >> ${IMAGE_ROOTFS}${sysconfdir}/motd
|
||||||
|
|
||||||
|
rm -f $mark $stamped
|
||||||
}
|
}
|
||||||
write_forgefirm_version[vardepsexclude] += "DATETIME"
|
write_forgefirm_version[vardepsexclude] += "DATETIME"
|
||||||
# No semicolon after a function name here or below (the vardeps rule in
|
# No semicolon after a function name here or below (the vardeps rule in
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# The release version, and nothing else.
|
||||||
|
#
|
||||||
|
# This file is metadata, not platform content: the image manifest leaves
|
||||||
|
# it out of the layer content hash the way it leaves out the component
|
||||||
|
# pin files (forgefirm-image-manifest.bbclass,
|
||||||
|
# FORGEFIRM_MANIFEST_VERSION_SUFFIX). A version bump therefore changes
|
||||||
|
# the version and invalidates nothing, so the number can be set at the
|
||||||
|
# moment a release is cut rather than before the campaign that authorizes
|
||||||
|
# it. Setting it inside forgefirm-image.bb instead would hash as layer
|
||||||
|
# content and invalidate every acceptance result, which is what it did
|
||||||
|
# until v0.0.1.
|
||||||
|
#
|
||||||
|
# Nothing else belongs here. The version is still proven end to end:
|
||||||
|
# scripts/release.sh reads FORGEFIRM_RELEASE from this file and requires
|
||||||
|
# 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
|
||||||
|
# machine with the manifest's.
|
||||||
|
FORGEFIRM_RELEASE ?= "0.0.2"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
|||||||
|
# ForgeFIRM acceptance - 20260910181308 (dev)
|
||||||
|
|
||||||
|
- Image: `20260910181308 (dev)` (forgefirm-image-dev)
|
||||||
|
- Manifest identity: `168e0307cef8f58f6bb871102f98ee56f9661b852e738d054891c5f072fd33b9`
|
||||||
|
- Catalog: `da9da478f3df1e5ad87319fd9d831ee224a3c88e5d3896162a09b4de14aad6b1`
|
||||||
|
- Campaign: `c-20260910181641-11a2` opened 2026-09-10T18:16:41Z
|
||||||
|
- Exported: 2026-09-10T18:32:07Z
|
||||||
|
- **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-10T18:23:17Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `image.license-bundle` | auto | PASS | 2026-09-10T13:11:39Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-10T18:23:17Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-10T18:23:39Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-10T18:23:50Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-10T18:24:08Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-10T18:24:51Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-10T13:13:35Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `kernel.pic-soc-load` | auto | PASS | 2026-09-10T13:13:39Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `forgectrl.auth` | auto | PASS | 2026-09-10T13:13:40Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-10T13:13:41Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `forgectrl.panel-serves` | auto | PASS | 2026-09-10T13:11:36Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-10T13:14:00Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.override-until-reboot` | auto | PASS | 2026-09-10T13:14:00Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.advisories-rehash` | auto, takeover | PASS | 2026-09-10T13:14:09Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.account-login` | auto, takeover | PASS | 2026-09-10T13:14:59Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.https-only-writes` | auto | PASS | 2026-09-10T13:15:00Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.ssh-until-reboot` | auto | PASS | 2026-09-10T13:15:00Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.cloud-disabled-surface` | auto | PASS | 2026-09-10T13:15:01Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.factory-return` | auto | PASS | 2026-09-10T13:15:04Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.machine-name` | auto | PASS | 2026-09-10T13:15:04Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.first-run-flow` | operator, takeover | PASS | 2026-09-10T13:15:26Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.first-run-page` | operator, takeover | PASS | 2026-09-10T17:25:08Z | `c-20260910171338-9149` | yes |
|
||||||
|
| `commission.cert-page` | auto | PASS | 2026-09-10T13:15:26Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.what-changed` | auto, takeover | PASS | 2026-09-10T13:15:36Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.record-export` | auto | PASS | 2026-09-10T13:16:52Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.mirror` | auto, takeover | PASS | 2026-09-10T13:17:25Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `commission.check-switches` | operator | PASS | 2026-09-10T16:06:37Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `commission.check-sensors` | auto | PASS | 2026-09-10T16:06:50Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `commission.check-airflow` | auto, takeover | PASS | 2026-09-10T16:08:24Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `commission.check-cameras` | operator | PASS | 2026-09-10T16:08:39Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `commission.check-motion` | auto, takeover | PASS | 2026-09-10T16:09:05Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `commission.check-flow-verify` | auto, takeover | PASS | 2026-09-10T16:16:37Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `commission.cloud-header-capture` | operator, takeover | PASS | 2026-09-10T17:26:21Z | `c-20260910171338-9149` | yes |
|
||||||
|
| `commission.sheet` | live, takeover | PASS | 2026-09-10T17:39:28Z | `c-20260910171338-9149` | yes |
|
||||||
|
| `logs.tree-tail-export` | auto | PASS | 2026-09-10T13:16:14Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `logs.routing` | auto | PASS | 2026-09-10T13:27:51Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `logs.level-settings` | auto | PASS | 2026-09-10T13:27:52Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `motion.pacing` | auto | PASS | 2026-09-10T18:25:07Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `motion.jog-roundtrip` | auto | PASS | 2026-09-10T18:25:21Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `motion.microstep-modes` | auto | PASS | 2026-09-10T18:25:46Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `motion.liveness-probe` | auto | PASS | 2026-09-10T13:28:39Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-10T13:28:56Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `motion.cancel-abort` | auto | PASS | 2026-09-10T18:25:53Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `motion.deadman` | auto | PASS | 2026-09-10T18:26:22Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `motion.button-hold-resume` | operator | PASS | 2026-09-10T18:26:33Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `motion.lid-cancel-home` | operator | PASS | 2026-09-10T18:26:49Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-10T18:26:58Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `motion.lid-policy-hold` | operator | PASS | 2026-09-10T18:27:12Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `motion.step-timing-under-load` | auto | PASS | 2026-09-10T18:27:42Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-10T16:10:38Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `cooling.flow-verify` | auto | PASS | 2026-09-10T16:13:21Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-10T16:17:38Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `cooling.gate-off` | auto | PASS | 2026-09-10T16:06:57Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-10T16:19:05Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `cooling.tec-drive` | auto | PASS | 2026-09-10T16:19:13Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-10T16:19:29Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-10T16:19:36Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `cooling.critical-tier` | auto | PASS | 2026-09-10T16:19:44Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-10T16:07:43Z | `c-20260910160456-619b` | yes |
|
||||||
|
| `cooling.flow-under-load` | live | PASS | 2026-09-10T17:41:03Z | `c-20260910171338-9149` | yes |
|
||||||
|
| `laser.power-floor` | auto | PASS | 2026-09-10T18:27:43Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `laser.emission-witness` | live, core | PASS | 2026-09-10T18:17:19Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `laser.m5-rapid-dark` | live | PASS | 2026-09-10T18:18:01Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `laser.disarm-in-hold` | live | PASS | 2026-09-10T18:19:29Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `laser.armed-kill` | live | PASS | 2026-09-10T18:20:12Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `laser.arm-wait-lid` | operator | PASS | 2026-09-10T18:28:02Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-10T18:20:41Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `camera.snapshot` | auto | PASS | 2026-09-10T13:19:21Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `camera.sensor-profile` | auto | PASS | 2026-09-10T14:55:23Z | `c-20260910144945-3072` | yes |
|
||||||
|
| `camera.h264-stream` | auto | PASS | 2026-09-10T14:55:37Z | `c-20260910144945-3072` | yes |
|
||||||
|
| `camera.frame-health` | auto | PASS | 2026-09-10T14:55:50Z | `c-20260910144945-3072` | yes |
|
||||||
|
| `camera.lid-privacy` | operator | PASS | 2026-09-10T14:56:05Z | `c-20260910144945-3072` | yes |
|
||||||
|
| `camera.key-read` | auto | PASS | 2026-09-10T14:56:06Z | `c-20260910144945-3072` | yes |
|
||||||
|
| `update.slots-and-signature` | auto | PASS | 2026-09-10T13:15:03Z | `c-20260910131134-5cb0` | yes |
|
||||||
|
| `cloud.mode-switch` | operator | PASS | 2026-09-10T18:29:38Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `cloud.service-protocol` | operator | PASS | 2026-09-10T18:21:52Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-10T17:19:59Z | `c-20260910171338-9149` | yes |
|
||||||
|
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-10T14:58:32Z | `c-20260910144945-3072` | yes |
|
||||||
|
| `cloud.verdict-hold` | operator | PASS | 2026-09-10T17:18:09Z | `c-20260910171338-9149` | yes |
|
||||||
|
| `cloud.pause-resume` | live | PASS | 2026-09-10T18:23:07Z | `c-20260910181641-11a2` | no |
|
||||||
|
| `cloud.oversize-stream` | live | PASS | 2026-09-10T17:22:39Z | `c-20260910171338-9149` | yes |
|
||||||
|
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-10T17:18:57Z | `c-20260910171338-9149` | yes |
|
||||||
|
|
||||||
|
Artifact sha256: `191f416666d40efd92c77973fa392f38ca3d5ca160533ff83b4475d47b5fa6e3`
|
||||||
@@ -52,8 +52,11 @@ CONTENT_LAYERS = {"meta-forgefirm": ("forgefirm", "meta-forgefirm"),
|
|||||||
"meta-glowforge-bsp": ("meta-openglow", "meta-glowforge-bsp"),
|
"meta-glowforge-bsp": ("meta-openglow", "meta-glowforge-bsp"),
|
||||||
"meta-openglow-core": ("meta-openglow", "meta-openglow-core")}
|
"meta-openglow-core": ("meta-openglow", "meta-openglow-core")}
|
||||||
# Left out of a layer's content, as in forgefirm-image-manifest.bbclass
|
# Left out of a layer's content, as in forgefirm-image-manifest.bbclass
|
||||||
# (FORGEFIRM_MANIFEST_PIN_SUFFIX): documentation and the component pin files.
|
# (FORGEFIRM_MANIFEST_PIN_SUFFIX, FORGEFIRM_MANIFEST_VERSION_SUFFIX):
|
||||||
LAYER_SKIP_SUFFIXES = (".md", "-pin.inc")
|
# documentation, the component pin files, and the release version file.
|
||||||
|
# All three are metadata; hashing them would turn a pin bump or a version
|
||||||
|
# bump into a platform change and invalidate every acceptance result.
|
||||||
|
LAYER_SKIP_SUFFIXES = (".md", "-pin.inc", "forgefirm-release.inc")
|
||||||
|
|
||||||
|
|
||||||
def git(args, cwd=None, input=None):
|
def git(args, cwd=None, input=None):
|
||||||
|
|||||||
+7
-4
@@ -45,7 +45,7 @@
|
|||||||
# forgefirm-source-v<version>.tar.gz, and refuses to pack a bundle in which
|
# forgefirm-source-v<version>.tar.gz, and refuses to pack a bundle in which
|
||||||
# a recipe that needs source has none.
|
# a recipe that needs source has none.
|
||||||
#
|
#
|
||||||
# Version contract: <version> == FORGEFIRM_RELEASE in forgefirm-image.bb
|
# Version contract: <version> == FORGEFIRM_RELEASE in forgefirm-release.inc
|
||||||
# == /etc/forgefirm-version ("v<version>") in the built rootfs == .fw
|
# == /etc/forgefirm-version ("v<version>") in the built rootfs == .fw
|
||||||
# meta-version ("v<version>") == release tag ("v<version>").
|
# meta-version ("v<version>") == release tag ("v<version>").
|
||||||
|
|
||||||
@@ -55,6 +55,9 @@ REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
|||||||
DEPLOY_ROOT="$REPO/build/tmp/deploy"
|
DEPLOY_ROOT="$REPO/build/tmp/deploy"
|
||||||
DEPLOY="$DEPLOY_ROOT/images/glowforge"
|
DEPLOY="$DEPLOY_ROOT/images/glowforge"
|
||||||
IMAGE_BB="$REPO/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb"
|
IMAGE_BB="$REPO/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb"
|
||||||
|
# The release version, in its own file so a bump is not a platform change
|
||||||
|
# (the manifest leaves it out of the layer content hash).
|
||||||
|
RELEASE_INC="$REPO/meta-forgefirm/recipes-forgefirm/images/forgefirm-release.inc"
|
||||||
INSTALLER="$REPO/scripts/install-forgefirm.sh"
|
INSTALLER="$REPO/scripts/install-forgefirm.sh"
|
||||||
WARN_BYTES=$((170 * 1024 * 1024))
|
WARN_BYTES=$((170 * 1024 * 1024))
|
||||||
FAIL_BYTES=$((195 * 1024 * 1024))
|
FAIL_BYTES=$((195 * 1024 * 1024))
|
||||||
@@ -134,7 +137,7 @@ if [ "$MODE" = "dev" ]; then
|
|||||||
EXT4="${EXT4/forgefirm-image-glowforge/forgefirm-image-dev-glowforge}"
|
EXT4="${EXT4/forgefirm-image-glowforge/forgefirm-image-dev-glowforge}"
|
||||||
[ -f "$EXT4" ] || die "dev rootfs not found: $EXT4"
|
[ -f "$EXT4" ] || die "dev rootfs not found: $EXT4"
|
||||||
check_size
|
check_size
|
||||||
REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$IMAGE_BB")
|
REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$RELEASE_INC")
|
||||||
DEVVER="v${REL}-dev-$(date +%Y%m%d%H%M%S)"
|
DEVVER="v${REL}-dev-$(date +%Y%m%d%H%M%S)"
|
||||||
OUT="$DEPLOY/forgefirm-dev.fw"
|
OUT="$DEPLOY/forgefirm-dev.fw"
|
||||||
"$REPO/scripts/mkfw.sh" "$EXT4" "$DEVVER" "$OUT" "$KEY"
|
"$REPO/scripts/mkfw.sh" "$EXT4" "$DEVVER" "$OUT" "$KEY"
|
||||||
@@ -163,9 +166,9 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Version single-source check.
|
# Version single-source check.
|
||||||
BB_REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$IMAGE_BB")
|
BB_REL=$(sed -n 's/^FORGEFIRM_RELEASE ?= "\(.*\)"/\1/p' "$RELEASE_INC")
|
||||||
[ "$BB_REL" = "$VERSION" ] \
|
[ "$BB_REL" = "$VERSION" ] \
|
||||||
|| die "FORGEFIRM_RELEASE in forgefirm-image.bb is '$BB_REL', not '$VERSION'"
|
|| die "FORGEFIRM_RELEASE in forgefirm-release.inc is '$BB_REL', not '$VERSION'"
|
||||||
|
|
||||||
# The beta rule: every release below 0.1.0 is a beta, and 0.1.0 is the
|
# The beta rule: every release below 0.1.0 is a beta, and 0.1.0 is the
|
||||||
# first release that is not. While the README carries the beta banner, a
|
# first release that is not. While the README carries the beta banner, a
|
||||||
|
|||||||
Reference in New Issue
Block a user