mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7a595ced7 | ||
|
|
5c93e49c8c | ||
|
|
650483c085 | ||
|
|
7b8f72b632 | ||
|
|
e8d4560ff0 | ||
|
|
00cc009871 | ||
|
|
c47f7d9a86 | ||
|
|
c217e24905 | ||
|
|
b94c990bbc | ||
|
|
8d8ca2a8c6 | ||
|
|
4fbf14195b | ||
|
|
d3fe1d90b9 | ||
|
|
594b6990fd | ||
|
|
57ba3454b4 | ||
|
|
d58ee39086 | ||
|
|
b2c42f0cc3 | ||
|
|
1dd6ad608b | ||
|
|
f0c40e7d4f | ||
|
|
d858a23f45 | ||
|
|
c7b80ab2e8 | ||
|
|
468e92655a | ||
|
|
d898b5659d |
+150
-20
@@ -65,6 +65,21 @@ FIXED_SYSFS = [
|
||||
("head/white_led", "0"),
|
||||
("thermal/heater_pwm", "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
|
||||
@@ -72,9 +87,14 @@ FIXED_SYSFS = [
|
||||
# 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
|
||||
# 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",
|
||||
"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
|
||||
# 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)
|
||||
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 =
|
||||
# 8). The GRBL controller reads it at its start and derives its scale,
|
||||
@@ -160,12 +187,19 @@ def leds_root():
|
||||
return r if r.endswith("/") else r + "/"
|
||||
|
||||
|
||||
def read_led(name):
|
||||
try:
|
||||
with open(leds_root() + name + "/brightness") as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
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:
|
||||
with open(leds_root() + name + "/" + a) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def write_led(name, value):
|
||||
@@ -183,6 +217,19 @@ def read_position():
|
||||
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():
|
||||
"""Unplayed bytes queued in the kernel pulse ring: total written minus
|
||||
processed (the cnc/position byte counters), or None when unreadable.
|
||||
@@ -421,6 +468,37 @@ class Baseline:
|
||||
time.sleep(1.0)
|
||||
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):
|
||||
return self.mode == "cloud"
|
||||
|
||||
@@ -479,7 +557,14 @@ class Baseline:
|
||||
before = (mode.get("controller"), mode.get("motion"))
|
||||
mode = self.wait_settled() or mode
|
||||
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:
|
||||
left.append(Leftover("controller", "%s/%s" % before, "running/verified",
|
||||
"failed: %s/%s" % (mode.get("controller"), mode.get("motion"))))
|
||||
@@ -487,14 +572,23 @@ class Baseline:
|
||||
st, s = self.fc_get("/status")
|
||||
if st == 200 and isinstance(s, dict):
|
||||
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:
|
||||
hw.sysfs_write("cnc/stop", "1") # ack
|
||||
except OSError:
|
||||
pass
|
||||
w = self._wait("idle", lambda: (self.fc_get("/status")[1] or {}).get("state") == "idle", IDLE_S)
|
||||
left.append(Leftover("state", s.get("state"), "idle",
|
||||
"waited" if w is not None else "failed: not idle"))
|
||||
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",
|
||||
"waited" if w is not None else "failed: not idle"))
|
||||
if s.get("laser_locked") is False:
|
||||
try:
|
||||
hw.sysfs_write("cnc/laser_latch", "1")
|
||||
@@ -507,11 +601,31 @@ class Baseline:
|
||||
if st == 200 and isinstance(c, dict):
|
||||
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"))
|
||||
w = self._wait("cool idle", lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed")
|
||||
and not x.get("hold"))(self.fc_get("/cool/status")[1] or {}),
|
||||
COOL_IDLE_S)
|
||||
left.append(Leftover("cool", found, "idle/unarmed/no hold",
|
||||
"waited" if w is not None else "failed: still %s" % found))
|
||||
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 {})
|
||||
# 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",
|
||||
act if w is not None else "failed: still %s" % found))
|
||||
|
||||
def _lamp_side(self, left):
|
||||
"""The lid lamp at forgectrl's idle level (the lid_lamp_idle setting).
|
||||
@@ -575,6 +689,9 @@ class Baseline:
|
||||
except OSError as e:
|
||||
act = "failed: %s" % e
|
||||
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):
|
||||
"""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
|
||||
# whatever the ring still holds before the jog, in a direction and
|
||||
# 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()
|
||||
if residue:
|
||||
return ("unrestorable: %d unplayed bytes queued in the kernel ring - a jog would "
|
||||
"replay them; clear the ring (controller restart) before moving" % residue)
|
||||
self.log("baseline: %d unplayed bytes in the kernel ring before the return" % 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
|
||||
mode = None
|
||||
deadline = time.time() + 30
|
||||
@@ -662,7 +787,12 @@ class Baseline:
|
||||
was = captured.get("position")
|
||||
now = read_position()
|
||||
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")
|
||||
if was:
|
||||
st, body = self.fc_get("/settings")
|
||||
|
||||
@@ -247,8 +247,11 @@ TOOLS = [
|
||||
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the "
|
||||
"lens stands, and neither a $20 nor a $132 write frees it. A CI harness (the grblHAL repo): needs "
|
||||
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
"lens stands, and neither a $20 nor a $132 write frees it. A referenced lens (forgectrl's marker "
|
||||
"and the lens settings) opens it to the window the settings hold, the fallback or the focus "
|
||||
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
|
||||
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
|
||||
"bench-page tool."},
|
||||
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
|
||||
|
||||
@@ -164,7 +164,12 @@ class Run:
|
||||
notice = dict(self.notice) if self.notice else None
|
||||
return {
|
||||
"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,
|
||||
"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)
|
||||
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.",
|
||||
lit_timeout=60):
|
||||
"""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())
|
||||
self.fixture_release(run)
|
||||
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
|
||||
run.log("baseline: post pass errored: %s: %s" % (type(e).__name__, e))
|
||||
duration = int(time.time() - run.started)
|
||||
|
||||
@@ -372,7 +372,13 @@ def mode_switch(ctx):
|
||||
gfhome_homing(ctx, ev, g)
|
||||
finally:
|
||||
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("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"])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""commission.* - the first-run commissioning: the record and the
|
||||
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
|
||||
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.
|
||||
|
||||
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.
|
||||
No coverage map names it: a change there makes every test necessary
|
||||
again.
|
||||
@@ -23,10 +24,9 @@ import http.client
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import select
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
@@ -46,9 +46,6 @@ ADVISORY_DOCS = ("safety-and-risk", "licenses", "privacy", "cloud-service")
|
||||
SAFETY_PHRASE = "I UNDERSTAND"
|
||||
LOGIN_FAILS = 5
|
||||
LOGIN_LOCK_S = 30
|
||||
MDNS_NAME = "forgefirm.local"
|
||||
MDNS_GROUP = "224.0.0.251"
|
||||
MDNS_PORT = 5353
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- files
|
||||
@@ -777,7 +774,7 @@ def https_only_writes(ctx):
|
||||
# ------------------------------------------------------------------ ssh
|
||||
|
||||
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."""
|
||||
for exe in ("/usr/sbin/sshd", "sshd"):
|
||||
rc, out = hw.run([exe, "-T"], timeout=15)
|
||||
@@ -789,7 +786,7 @@ def sshd_policy():
|
||||
for line in out.splitlines():
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) == 2 and parts[0] in ("permitrootlogin", "permitemptypasswords",
|
||||
"passwordauthentication"):
|
||||
"passwordauthentication", "banner"):
|
||||
policy[parts[0]] = parts[1].strip()
|
||||
return policy
|
||||
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 "
|
||||
"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 "
|
||||
"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 "
|
||||
"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 "
|
||||
@@ -848,6 +847,13 @@ def ssh_until_reboot(ctx):
|
||||
ctx.log("sshd -T: %s", policy)
|
||||
ctx.check(policy.get("passwordauthentication") == "yes",
|
||||
"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"]:
|
||||
ctx.check(policy.get("permitrootlogin") == "no" and policy.get("permitemptypasswords") == "no",
|
||||
"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")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- mDNS
|
||||
# --------------------------------------------------- the machine's name
|
||||
|
||||
def mdns_query(name, qid=None):
|
||||
"""A DNS query packet for the A record of name with the unicast-
|
||||
response bit set (RFC 6762 5.4), so the responder answers this
|
||||
socket directly."""
|
||||
qid = random.randrange(1, 65536) if qid is None else qid
|
||||
labels = b"".join(struct.pack("B", len(p)) + p.encode("ascii") for p in name.strip(".").split("."))
|
||||
return struct.pack(">HHHHHH", qid, 0, 1, 0, 0, 0) + labels + b"\x00" + struct.pack(">HH", 1, 0x8001)
|
||||
def mac_suffix():
|
||||
"""The last four hex digits of the MAC address the hostname is built
|
||||
from: wlan0, or eth0 on a machine with no WiFi."""
|
||||
for dev in ("wlan0", "eth0"):
|
||||
raw = read_file("/sys/class/net/%s/address" % dev)
|
||||
mac = (raw or b"").decode("ascii", "replace").strip().replace(":", "").lower()
|
||||
if mac and mac != "0" * 12:
|
||||
return mac[-4:]
|
||||
return ""
|
||||
|
||||
|
||||
def _dns_name(pkt, off):
|
||||
"""(name, offset after the name); follows compression pointers."""
|
||||
parts = []
|
||||
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")
|
||||
def cmdlines():
|
||||
"""The command line of every process on the machine, one string
|
||||
each."""
|
||||
out = []
|
||||
for name in os.listdir("/proc"):
|
||||
if not name.isdigit():
|
||||
continue
|
||||
off += 1
|
||||
parts.append(pkt[off:off + n].decode("ascii", "replace"))
|
||||
off += n
|
||||
if not jumped:
|
||||
end = off
|
||||
return ".".join(parts), end
|
||||
raw = read_file("/proc/%s/cmdline" % name)
|
||||
if raw:
|
||||
out.append(raw.decode("utf-8", "replace").replace("\0", " ").strip())
|
||||
return out
|
||||
|
||||
|
||||
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 = []
|
||||
for _ in range(an + ns + ar):
|
||||
name, off = _dns_name(pkt, off)
|
||||
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
|
||||
# a multicast answer carries id 0; the direct one echoes the query's
|
||||
for n, addr in mdns_answers(pkt, qid if s is tx else None):
|
||||
if n == name.lower():
|
||||
found.add(addr)
|
||||
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",
|
||||
kind="auto", est_min=1,
|
||||
@test("commission.machine-name", title="The machine names itself from its MAC address",
|
||||
subsystem="commission", kind="auto", est_min=1,
|
||||
requires=["forgectrl.auth"],
|
||||
description="avahi-daemon runs, its configuration names the host forgefirm on the WiFi and "
|
||||
"wired links, and the service file advertises the panel on 443 and 80. An mDNS "
|
||||
"query for the A record of forgefirm.local, sent to the multicast group from "
|
||||
"the board's own LAN interface, is answered with the board's LAN address; that "
|
||||
"is what `ping forgefirm.local` on a workstation resolves. No component "
|
||||
"covers this: the avahi files are layer content, in the platform identity of "
|
||||
description="The machine calls itself forgefirm-<xxxx>, where xxxx is the last four hex "
|
||||
"digits of its WiFi MAC address, so two machines on one network answer to "
|
||||
"different names and no name carries a serial number. The live name, "
|
||||
"/etc/hostname (a bind-mounted copy, because the rootfs is read-only) and the "
|
||||
"MAC address agree; the DHCP client sends the name as the hostname option, "
|
||||
"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.")
|
||||
def mdns_announce(ctx):
|
||||
def machine_name(ctx):
|
||||
ev = ctx.evidence
|
||||
pids = hw.pidof("avahi-daemon")
|
||||
ev["avahi_pids"] = pids
|
||||
ctx.check(pids, "avahi-daemon is not running")
|
||||
conf = read_file("/etc/avahi/avahi-daemon.conf") or b""
|
||||
ctx.check(b"host-name=forgefirm" in conf, "avahi-daemon.conf does not name the host forgefirm")
|
||||
ctx.check(b"allow-interfaces=wlan0,eth0" in conf, "avahi-daemon.conf does not restrict the interfaces")
|
||||
svc = read_file("/etc/avahi/services/forgefirm.service") or b""
|
||||
ev["service_file_bytes"] = len(svc)
|
||||
ctx.check(b"_https._tcp" in svc and b"<port>443</port>" in svc, "the service file lacks HTTPS 443")
|
||||
ctx.check(b"_http._tcp" in svc and b"<port>80</port>" in svc, "the service file lacks HTTP 80")
|
||||
|
||||
# 1. the name, the file and the MAC address agree
|
||||
suffix = mac_suffix()
|
||||
ev["mac_suffix"] = suffix
|
||||
ctx.check(suffix, "no MAC address to build a name from")
|
||||
live = socket.gethostname()
|
||||
on_file = ((read_file("/etc/hostname") or b"").decode("utf-8", "replace")).strip()
|
||||
ev["hostname"] = live
|
||||
ev["hostname_file"] = on_file
|
||||
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()
|
||||
ev["lan_ip"] = ip
|
||||
ctx.check(ip, "cannot determine the board's LAN address")
|
||||
t0 = time.time()
|
||||
found = mdns_resolve(ip)
|
||||
ev["answers"] = sorted(found)
|
||||
ev["resolve_s"] = round(time.time() - t0, 2)
|
||||
ctx.log("%s -> %s (%.1f s)", MDNS_NAME, sorted(found) or "no answer", ev["resolve_s"])
|
||||
ctx.check(found, "no mDNS answer for %s from the LAN interface (%s)", MDNS_NAME, ip)
|
||||
ctx.check(ip in found, "%s resolves to %s, not the LAN address %s", MDNS_NAME, sorted(found), ip)
|
||||
ctx.check("Control panel:" in issue, "the console banner names no control panel")
|
||||
ctx.check("https://%s/" % ip in issue,
|
||||
"the console banner does not carry the LAN address %s", ip)
|
||||
for junk in ("# end", "# ForgeFIRM addresses", ".local"):
|
||||
ctx.check(junk not in issue, "the console banner shows %r", junk)
|
||||
urls = re.findall(r"https://(\S+)/", issue)
|
||||
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
|
||||
@@ -1458,7 +1388,8 @@ def cert_page(ctx):
|
||||
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(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")
|
||||
ctx.check(st == 200 and body.startswith(b"-----BEGIN CERTIFICATE-----"),
|
||||
"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
|
||||
|
||||
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.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
||||
("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:
|
||||
st, body = answer(fc, wid, p, value)
|
||||
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"):
|
||||
last = d
|
||||
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 "
|
||||
"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 "
|
||||
"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):
|
||||
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"]
|
||||
@@ -186,6 +199,32 @@ def check_airflow(ctx):
|
||||
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.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)
|
||||
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",
|
||||
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
||||
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")],
|
||||
requires=["forgectrl.auth", "motion.pacing"],
|
||||
description="POST /wiz/motion/start: the controller stops, the liveness probe runs and the "
|
||||
|
||||
@@ -21,7 +21,7 @@ from ..catalog import test
|
||||
from .commission_dark import run_check, Restore
|
||||
|
||||
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
||||
("forgectrl", "src/sheet.*"),
|
||||
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
|
||||
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
||||
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
||||
@@ -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
|
||||
# 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
|
||||
# waits for the button to light before the arm press goes to a person.
|
||||
# The flow-load card settles the coolant loop before it lights the button
|
||||
# (60 to 240 s); the others light within seconds of the lens reference.
|
||||
# sheet), and the keys its result must carry. The arm press is not timed
|
||||
# from here: each card opens a press prompt of its own when the button is
|
||||
# lit and the tube still dark, and run_check presses on that.
|
||||
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"},
|
||||
["pick", "thickness_mm", "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm",
|
||||
"focus_range_mm", "stops"], 120),
|
||||
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"], 120),
|
||||
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"], 120),
|
||||
("laser.corner", {"corner-arm": "Continue", "corner-pick": "1.50"}, ["gamma"], 120),
|
||||
("cooling.flow-load", {"load-arm": "Continue"}, ["lit_s", "dose_raw_s", "peak_c", "k_density", "k_cw"], 420),
|
||||
"focus_range_mm", "stops"]),
|
||||
("laser.floor", {"floor-arm": "Continue", "floor-pick": "8"}, ["faintest_density", "floor_density"]),
|
||||
("laser.dose-curve", {"dose-arm": "Continue"}, ["points", "curve"]),
|
||||
("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"]),
|
||||
]
|
||||
# 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",
|
||||
@@ -72,6 +71,22 @@ def preview_ok(ctx, wid):
|
||||
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
||||
"no program for %s (%s)", wid, st)
|
||||
ctx.evidence["program_lines"] = body.count(b"\n")
|
||||
# Every Z the program commands is a height the head reaches in the
|
||||
# window the machine holds now: the focus card's own numbers once it
|
||||
# has them, and the conservative fallback on a head whose travel is
|
||||
# not known yet. The controller refuses a Z outside it with ALARM:2,
|
||||
# and it refuses it while the body is still burning, so the card dies
|
||||
# where the parser is and not where the bad line is.
|
||||
lens = (fc.status() or {}).get("lens") or {}
|
||||
lo, hi = lens.get("reach_min"), lens.get("reach_max")
|
||||
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
|
||||
ctx.evidence.setdefault("program_z", {})[wid] = {
|
||||
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
|
||||
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
|
||||
if lo is not None and hi is not None:
|
||||
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
|
||||
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
|
||||
wid, lo, hi, out)
|
||||
|
||||
|
||||
def emission_ok(ctx, last):
|
||||
@@ -90,6 +105,61 @@ def file_card(ctx, wid):
|
||||
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
||||
|
||||
|
||||
# The lens settings: the head's numbers, which a fresh machine does not
|
||||
# have. Cleared before the cards so the run is a fresh machine's.
|
||||
LENS_SETTINGS = CARD_SETTINGS[:3]
|
||||
FALLBACK_WINDOW = (10, 12)
|
||||
|
||||
|
||||
def fresh_lens(ctx):
|
||||
"""The lens as a fresh machine has it: the head's numbers unknown, so
|
||||
the frame runs in the fallback window and the focus card has to find
|
||||
the stops and write them before its ladder burns. The machine's own
|
||||
numbers come back with the restore at the end of the run."""
|
||||
fc = ctx.forgectrl
|
||||
for k in LENS_SETTINGS:
|
||||
st, _ = fc.post("/settings", params={k: ""})
|
||||
ctx.check(st == 200, "clearing %s -> %s", k, st)
|
||||
lens = (fc.status() or {}).get("lens") or {}
|
||||
ctx.evidence["lens_fresh"] = lens
|
||||
ctx.check(lens.get("stops_found") is False and (lens.get("below"), lens.get("above")) == FALLBACK_WINDOW,
|
||||
"the lens window is not the fallback after the clear: %s", lens)
|
||||
|
||||
|
||||
def focus_window_ok(ctx):
|
||||
"""The window the focus card burned its ladder in is the one it wrote
|
||||
before its controller started, and the one the machine holds now: the
|
||||
result's window is the stops it found (or the fallback), the settings
|
||||
hold that window, every ladder height lies in its reach, and the
|
||||
program served now agrees with /status."""
|
||||
fc = ctx.forgectrl
|
||||
r = ((ctx.evidence.get("cards") or {}).get("laser.focus") or {}).get("result") or {}
|
||||
w, stops = r.get("window") or {}, r.get("stops") or {}
|
||||
s = fc.settings() or {}
|
||||
held = (str(s.get("lens_stop_below_steps")), str(s.get("lens_stop_above_steps")))
|
||||
ctx.check(held == (str(w.get("below")), str(w.get("above"))),
|
||||
"the settings hold the window %s, the ladder ran in %s/%s", held, w.get("below"), w.get("above"))
|
||||
if stops.get("found"):
|
||||
ctx.check((w.get("below"), w.get("above")) == (stops.get("below"), stops.get("above")),
|
||||
"the ladder's window %s is not the stops found %s", w, stops)
|
||||
else:
|
||||
ctx.check((w.get("below"), w.get("above")) == FALLBACK_WINDOW,
|
||||
"stops not found (%s), but the ladder's window is %s", stops.get("why"), w)
|
||||
zs = r.get("ladder_z") or []
|
||||
lo, hi = w.get("reach_min"), w.get("reach_max")
|
||||
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
|
||||
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
|
||||
ev = ctx.evidence
|
||||
before = dict(ev.get("program_z") or {}).get("laser.focus")
|
||||
preview_ok(ctx, "laser.focus")
|
||||
ev["focus_window"] = {"window": w, "stops": stops, "ladder_z": zs,
|
||||
"settings": {k: s.get(k) for k in LENS_SETTINGS},
|
||||
"program_z_before": before,
|
||||
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
|
||||
"preview_bytes": ev.pop("preview_bytes", None),
|
||||
"program_lines": ev.pop("program_lines", None)}
|
||||
|
||||
|
||||
def thickness_answer(ctx):
|
||||
"""The thickness the placement is answered with, in the machine's
|
||||
units (the number prompts read ui_units), and the millimeters the
|
||||
@@ -130,12 +200,12 @@ def place(ctx):
|
||||
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
|
||||
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)
|
||||
ctx.arm_press(lit_timeout=lit_s)
|
||||
|
||||
def on_prompt(p):
|
||||
return answers.get(p.get("id"))
|
||||
@@ -161,21 +231,29 @@ 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); "
|
||||
"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 "
|
||||
"machine, and the bench actuator makes every arm press after it. With no actuator "
|
||||
"the start is the Ready answer instead, and you press the button each time it lights "
|
||||
"white: six times, the frame and the five cards.",
|
||||
"machine, and the bench actuator makes every arm press after it, each one when the "
|
||||
"card itself asks for it. With no actuator the start is the Ready answer instead, "
|
||||
"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 "
|
||||
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
|
||||
"tails."],
|
||||
description="One run over the sheet's seven wizards on one piece of wood. "
|
||||
description="One run over the sheet's seven wizards on one piece of wood, as a fresh "
|
||||
"machine runs them: the three lens settings are cleared first, so the frame "
|
||||
"runs in the fallback window and the focus card has to find the stops and write "
|
||||
"them before its ladder burns. "
|
||||
"POST /wiz/sheet.place/start references the lens, takes the controller in "
|
||||
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
|
||||
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
|
||||
"0.125 in). Then, each after the press and each with its preview "
|
||||
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode: the frame "
|
||||
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the "
|
||||
"program commands inside the reach /status reports: the frame "
|
||||
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
|
||||
"lens homed on its bottom stop, the hall edge counted, twelve lines down the "
|
||||
"travel; the pick is line 11, the thickness kept); the floor card (twelve "
|
||||
"lens on its hall edge, the stops found by the head accelerometer and written "
|
||||
"as the window before the controller starts, twelve lines from the top of the "
|
||||
"window to the bottom; the pick is line 11, the thickness kept; after it the "
|
||||
"settings, the result's window, and the served program must hold the window "
|
||||
"the ladder ran in); the floor card (twelve "
|
||||
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
|
||||
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
|
||||
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
|
||||
@@ -191,6 +269,9 @@ def sheet(ctx):
|
||||
"each. The bench presses when the button lights.")
|
||||
place(ctx)
|
||||
with Restore(ctx, CARD_SETTINGS):
|
||||
for wid, answers, want, lit_s in CARDS:
|
||||
burn(ctx, wid, answers, want, lit_s)
|
||||
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
|
||||
fresh_lens(ctx)
|
||||
for wid, answers, want in CARDS:
|
||||
burn(ctx, wid, answers, want)
|
||||
if wid == "laser.focus":
|
||||
focus_window_ok(ctx)
|
||||
ctx.log("PASS: the sheet's seven wizards ran on one piece as a fresh machine's; settings restored")
|
||||
|
||||
@@ -35,8 +35,9 @@ def lan_ip():
|
||||
covers=_COVERS_AUTH,
|
||||
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 "
|
||||
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused; the "
|
||||
"cooling report channel accepts the loopback peer and refuses a non-loopback "
|
||||
"Host, a non-literal Origin and a cross-site Sec-Fetch-Site are refused, while "
|
||||
"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 "
|
||||
"answers 403 loopback only); the fuse view is two-factor "
|
||||
"(token and the physical button) and refused without either; "
|
||||
@@ -96,6 +97,17 @@ def auth(ctx):
|
||||
ev["host_name"] = st
|
||||
ctx.log("GET /status Host=evil.example.net -> %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"})
|
||||
ev["origin_name"] = st
|
||||
ctx.log("GET /status Origin=http://evil.example.net -> %s", st)
|
||||
|
||||
@@ -28,6 +28,12 @@ def _wdog1_wcr():
|
||||
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):
|
||||
try:
|
||||
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", "**"),
|
||||
("linux-fslc", "**")],
|
||||
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 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 "
|
||||
@@ -113,11 +121,39 @@ def image_health(ctx):
|
||||
ev = ctx.evidence
|
||||
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()
|
||||
ev["forgefirm_version"] = ver
|
||||
ctx.log("forgefirm-version: %s (manifest: %s)", 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
|
||||
cfg = kernel_config()
|
||||
@@ -251,7 +287,8 @@ def image_health(ctx):
|
||||
|
||||
# 8. the mounts: the rootfs read-only, /data the writable partition,
|
||||
# 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.
|
||||
mounts = {}
|
||||
for line in (_read("/proc/mounts", "") or "").splitlines():
|
||||
@@ -262,7 +299,8 @@ def image_health(ctx):
|
||||
def mount_opts(path):
|
||||
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)",
|
||||
",".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("/"))
|
||||
@@ -292,6 +330,7 @@ def image_health(ctx):
|
||||
ctx.check(f in mounts, "%s is not the tmpfs render of the account record", f)
|
||||
for n in names:
|
||||
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")
|
||||
if hw.pidof("sshd"):
|
||||
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(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
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",
|
||||
"G1 X20 F600",
|
||||
"M5", "G4 P2.5",
|
||||
"G0 X-20", "G4 P2.5",
|
||||
"G0 X20", "G4 P2.5",
|
||||
"G0 X-20", "G4 P0.5",
|
||||
"G90", "M2"]
|
||||
ctx.arm_press()
|
||||
try:
|
||||
@@ -632,6 +640,7 @@ def disarm_in_hold(ctx):
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g, LiveJob(ctx, g):
|
||||
prepare(ctx, g)
|
||||
k0 = kernel_start(ctx)
|
||||
ctx.ready(ARM_CUE % "40 mm +X")
|
||||
stream(g, ["G91", "G21", "M4", "S400", "G1 X40 F300"])
|
||||
ctx.log("armed; waiting for motion to start (arm + your button press)...")
|
||||
@@ -674,6 +683,20 @@ def disarm_in_hold(ctx):
|
||||
ctx.sleep(1)
|
||||
if "Alarm" in g.status_report()["state"]:
|
||||
g.command("$X")
|
||||
# The hold parks the head part-way through the move and the reset
|
||||
# abandons the rest of it, so the job ends wherever the feed hold
|
||||
# caught it - about 11 mm in, on the bench reference. That is the
|
||||
# test's own doing and it goes back: the kernel counters say how
|
||||
# far the head actually went, which is what a jog has to undo.
|
||||
wait_idle(ctx, g, 15)
|
||||
dx, dy = kernel_xy_mm(ctx)
|
||||
back_x, back_y = k0[0] - dx, k0[1] - dy
|
||||
ev["left_at_mm"] = [round(dx - k0[0], 3), round(dy - k0[1], 3)]
|
||||
if abs(back_x) >= 0.02 or abs(back_y) >= 0.02:
|
||||
ctx.log("returning the head: %+.3f/%+.3f mm", back_x, back_y)
|
||||
g.command("$J=G91X%.3fY%.3fF1200" % (back_x, back_y))
|
||||
wait_idle(ctx, g, 30)
|
||||
ev["returned_mm"] = list(kernel_xy_mm(ctx))
|
||||
ctx.check(disarmed_at is not None, "still armed after 120 s in Hold")
|
||||
ctx.check(left_hold is None, "the job left Hold (%s) before the disarm", left_hold)
|
||||
check_button_dark(ctx, ev)
|
||||
|
||||
@@ -1285,8 +1285,13 @@ def interlock_cancel_home(ctx):
|
||||
def lid_policy_hold(ctx):
|
||||
ev = ctx.evidence
|
||||
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_in_force"] = was or "cancel"
|
||||
st, _b = fc.post("/settings", data={"lid_policy": "hold"})
|
||||
ctx.check(st == 200, "could not set lid_policy=hold (%s)", st)
|
||||
ctx.check(((fc.settings() or {}).get("lid_policy")) == "hold", "lid_policy did not take")
|
||||
@@ -1332,8 +1337,12 @@ def lid_policy_hold(ctx):
|
||||
g.command("G90")
|
||||
machine_idle(ctx)
|
||||
finally:
|
||||
st, _b = fc.post("/settings", data={"lid_policy": was})
|
||||
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy")
|
||||
# An empty value clears the key, and only the query-string form
|
||||
# carries one: an empty form field never reaches the request, and
|
||||
# the write is refused with "no known setting in request".
|
||||
st, _b = (fc.post("/settings", params={"lid_policy": ""}) if not was
|
||||
else fc.post("/settings", data={"lid_policy": was}))
|
||||
ev["lid_policy_restored"] = (fc.settings() or {}).get("lid_policy", "")
|
||||
ctx.log("lid_policy restored to %s", ev["lid_policy_restored"])
|
||||
ctx.check(ev["lid_policy_restored"] == was, "lid_policy was not restored to %r", was)
|
||||
ctx.log("PASS: lid_policy=hold parked the job in Door and the cycle start finished it (%.3f mm)",
|
||||
|
||||
@@ -238,6 +238,7 @@ header {
|
||||
margin-left: auto;
|
||||
}
|
||||
#themebtn,
|
||||
#logsbtn,
|
||||
#logoutbtn {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
@@ -249,6 +250,7 @@ header {
|
||||
white-space: nowrap;
|
||||
}
|
||||
#themebtn:hover,
|
||||
#logsbtn:hover,
|
||||
#logoutbtn:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
@@ -692,3 +694,13 @@ footer a {
|
||||
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
|
||||
# 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):
|
||||
for name in baseline.BUTTON_LEDS:
|
||||
p = self.leds + name + "/target"
|
||||
@@ -154,16 +161,32 @@ class BaselineTests(unittest.TestCase):
|
||||
b = self.bl()
|
||||
cap = b.capture()
|
||||
# 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
|
||||
# is refused (it would replay the residue first)
|
||||
# head 1000 counts out: the hand-back stands the machine down to
|
||||
# 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)
|
||||
left = b.enforce("post", captured=cap)
|
||||
items = {x.item: x for x in left}
|
||||
self.assertIn("pulse ring", items)
|
||||
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)
|
||||
|
||||
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):
|
||||
self.assertEqual(baseline.read_ring_residue(), 0)
|
||||
|
||||
@@ -354,6 +377,71 @@ if __name__ == "__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):
|
||||
"""The baseline against a fake forgectrl: what the mode in force
|
||||
owns. Reuses the fake sysfs tree of BaselineTests; only the new
|
||||
|
||||
@@ -17,7 +17,44 @@ from forgetest.runner import Context, Run # noqa: E402
|
||||
from forgetest.suite import commission_sheet # noqa: E402
|
||||
|
||||
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
|
||||
# The focus card's program is read again after it ran: the served ladder
|
||||
# must then lie in the window the card wrote.
|
||||
PREVIEWS = ["sheet.frame", "laser.focus", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner",
|
||||
"cooling.flow-load"]
|
||||
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
|
||||
SPM = 2.922 # the lens screw, half-steps per mm ($102)
|
||||
EDGE_DEFAULT = 3.35
|
||||
|
||||
|
||||
def lens_block(s):
|
||||
"""The /status lens block as the daemon builds it from the settings: a
|
||||
stop count from 1 to 40 stands, anything else is the fallback."""
|
||||
def count(key, fallback):
|
||||
try:
|
||||
n = float(s.get(key, ""))
|
||||
except ValueError:
|
||||
return fallback, False
|
||||
return (int(n), True) if 1 <= n <= 40 else (fallback, False)
|
||||
try:
|
||||
edge = float(s.get("lens_hall_edge_z_mm", ""))
|
||||
except ValueError:
|
||||
edge = EDGE_DEFAULT
|
||||
below, a = count("lens_stop_below_steps", 10)
|
||||
above, b = count("lens_stop_above_steps", 12)
|
||||
return {"edge_z": round(edge, 2), "below": below, "above": above, "stops_found": a and b,
|
||||
"reach_min": round(edge - below / SPM, 2), "reach_max": round(edge + above / SPM, 2)}
|
||||
|
||||
|
||||
def ladder_z(edge, below, above):
|
||||
"""The focus ladder the daemon builds: twelve whole half-steps from
|
||||
the top of the window to the bottom."""
|
||||
return [round(edge + round(above - (above + below) * i / 11) / SPM, 2) for i in range(12)]
|
||||
|
||||
|
||||
# The window the focus card finds and writes before its controller starts.
|
||||
STOPS = {"lens_stop_below_steps": "14", "lens_stop_above_steps": "20"}
|
||||
FOCUS_WINDOW = {"below": 14, "above": 20, "reach_min": round(EDGE_DEFAULT - 14 / SPM, 2),
|
||||
"reach_max": round(EDGE_DEFAULT + 20 / SPM, 2)}
|
||||
|
||||
# Each wizard as the daemon runs it: its prompts in order (id, kind,
|
||||
# options) and the result it ends with. A "jog" prompt repeats until the
|
||||
@@ -37,7 +74,9 @@ SCRIPT = {
|
||||
"steps_per_mm": 2.922, "max_height_mm": 13.81,
|
||||
"focus_range_mm": {"min": 2.17, "max": 13.81},
|
||||
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
|
||||
"contact_above": 22, "why": ""}, "emission": EMISSION}),
|
||||
"contact_above": 22, "why": ""},
|
||||
"window": dict(FOCUS_WINDOW), "ladder_z": ladder_z(EDGE_DEFAULT, 14, 20),
|
||||
"emission": EMISSION}),
|
||||
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
|
||||
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
|
||||
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
|
||||
@@ -50,10 +89,11 @@ SCRIPT = {
|
||||
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
|
||||
"k_cw": 2.78e-5, "emission": EMISSION}),
|
||||
}
|
||||
# What each card writes when it ends.
|
||||
# What a card writes when it starts (the focus card's window, before its
|
||||
# controller starts) and when it ends.
|
||||
START_WRITES = {"laser.focus": dict(STOPS)}
|
||||
WRITES = {
|
||||
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14",
|
||||
"lens_stop_above_steps": "20"},
|
||||
"laser.focus": {"lens_hall_edge_z_mm": "6.96"},
|
||||
"laser.floor": {"laser_floor_density": "10"},
|
||||
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
|
||||
"laser.corner": {"laser_corner_gamma": "1.5"},
|
||||
@@ -115,9 +155,11 @@ class ScriptedDaemon:
|
||||
self.versions = {}
|
||||
self.answers = {} # wid -> [values]
|
||||
self.order = [] # the wizards as started
|
||||
self.settings_at_start = {} # wid -> the settings as the wizard found them
|
||||
self.previews = []
|
||||
self.queue = []
|
||||
self.seq = 0
|
||||
self.stray_z = None # a Z the served program commands beyond the reach, when set
|
||||
fake.on_get = self.on_get
|
||||
fake.on_post = self.on_post
|
||||
|
||||
@@ -130,6 +172,10 @@ class ScriptedDaemon:
|
||||
return True
|
||||
|
||||
def on_get(self, path, q):
|
||||
s = self.fake.state["settings"]
|
||||
if path == "/status":
|
||||
# the lens block follows the settings, as the daemon's does
|
||||
return 200, dict(self.fake.state["status"], lens=lens_block(s))
|
||||
if path == "/wiz/dark":
|
||||
return 200, dict(self.dark)
|
||||
if path == "/wiz":
|
||||
@@ -140,8 +186,18 @@ class ScriptedDaemon:
|
||||
return 404, {"error": "no such card"}
|
||||
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
|
||||
if path == "/wiz/sheet.gcode":
|
||||
# the frame and the text are M4 at the mark dose, as the daemon serves them
|
||||
return 200, b"; mock\nM4 S400\nG0 X60 Y42\nG1 X120 Y42 F3000\nM5\n", "text/plain"
|
||||
# the frame and the text are M4 at the mark dose, as the daemon
|
||||
# serves them; the head's Z is the edge, the tail goes back to
|
||||
# it, and the focus card's ladder spans the window the settings
|
||||
# hold now
|
||||
lens = lens_block(s)
|
||||
lines = ["; mock", "G0 Z%.2f" % lens["edge_z"], "M4 S400", "G0 X60 Y42", "G1 X120 Y42 F3000"]
|
||||
if q.get("card") == "laser.focus":
|
||||
lines += ["G0 Z%.2f" % z for z in ladder_z(lens["edge_z"], lens["below"], lens["above"])]
|
||||
if self.stray_z is not None:
|
||||
lines.append("G0 Z%.2f" % self.stray_z)
|
||||
lines += ["M5", "G0 Z%.2f" % lens["edge_z"], "G0 X0 Y0", "M2"]
|
||||
return 200, ("\n".join(lines) + "\n").encode(), "text/plain"
|
||||
return None
|
||||
|
||||
def on_post(self, path, form):
|
||||
@@ -151,6 +207,8 @@ class ScriptedDaemon:
|
||||
if wid not in SCRIPT:
|
||||
return 404, {"error": "no such wizard"}
|
||||
self.order.append(wid)
|
||||
self.settings_at_start[wid] = dict(s)
|
||||
s.update(START_WRITES.get(wid, {}))
|
||||
self.queue = list(SCRIPT[wid][0])
|
||||
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
|
||||
self.open_next()
|
||||
@@ -199,7 +257,7 @@ class SheetRunTests(unittest.TestCase):
|
||||
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
|
||||
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
|
||||
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
|
||||
self.assertEqual(d.previews, LIVE)
|
||||
self.assertEqual(d.previews, PREVIEWS)
|
||||
cards = run.evidence["cards"]
|
||||
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
|
||||
for wid in LIVE:
|
||||
@@ -208,6 +266,24 @@ class SheetRunTests(unittest.TestCase):
|
||||
self.assertNotIn("emission", run.evidence)
|
||||
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
|
||||
self.assertEqual(run.evidence["settings_before"], FOUND)
|
||||
# A fresh machine's run: the lens settings were gone before the
|
||||
# frame, the frame's program ran in the fallback window, the focus
|
||||
# card's window was in the settings when the floor card started,
|
||||
# and the window check filed what it compared.
|
||||
for k in commission_sheet.LENS_SETTINGS:
|
||||
self.assertNotIn(k, d.settings_at_start["sheet.frame"], k)
|
||||
self.assertEqual(run.evidence["lens_fresh"]["stops_found"], False)
|
||||
self.assertEqual(run.evidence["program_z"]["sheet.frame"]["reach"], [-0.07, 7.46])
|
||||
floor_start = d.settings_at_start["laser.floor"]
|
||||
self.assertEqual({k: floor_start[k] for k in commission_sheet.LENS_SETTINGS},
|
||||
{"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14", "lens_stop_above_steps": "20"})
|
||||
fw = run.evidence["focus_window"]
|
||||
self.assertEqual(fw["window"], FOCUS_WINDOW)
|
||||
self.assertEqual(fw["settings"]["lens_stop_below_steps"], "14")
|
||||
self.assertEqual(len(fw["ladder_z"]), 12)
|
||||
self.assertEqual(fw["program_z_before"]["reach"], [-0.07, 7.46]) # the fallback ladder
|
||||
self.assertEqual(fw["program_z_after"]["reach"], [2.17, 13.8]) # the written window at the new edge
|
||||
self.assertEqual(len(fw["program_z_after"]["z"]), 14) # head, twelve lines, tail
|
||||
for k, v in FOUND.items():
|
||||
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
|
||||
|
||||
@@ -261,6 +337,44 @@ class SheetRunTests(unittest.TestCase):
|
||||
finally:
|
||||
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
|
||||
|
||||
def test_a_focus_window_the_settings_do_not_hold_fails_the_run(self):
|
||||
# The defect the bench found: the focus card burned its ladder over
|
||||
# the stops it found while the controller's Z limit stood on the
|
||||
# settings, which did not hold them. A daemon whose focus result
|
||||
# names a window the settings do not hold fails the run before the
|
||||
# floor card, and the settings still go back.
|
||||
from forgetest.runner import Failed
|
||||
saved = dict(START_WRITES["laser.focus"])
|
||||
START_WRITES["laser.focus"] = {}
|
||||
try:
|
||||
t = catalog.load_suite()["commission.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True
|
||||
with self.assertRaises(Failed) as cm:
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertIn("the settings hold", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus"])
|
||||
for k, v in FOUND.items():
|
||||
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||
finally:
|
||||
START_WRITES["laser.focus"] = saved
|
||||
|
||||
def test_a_program_z_beyond_the_reach_fails_before_the_burn(self):
|
||||
# A served program that commands a Z the lens does not reach is
|
||||
# refused before the card starts: nothing burns.
|
||||
from forgetest.runner import Failed
|
||||
self.daemon.stray_z = 10.6
|
||||
t = catalog.load_suite()["commission.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True
|
||||
with self.assertRaises(Failed) as cm:
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertIn("outside the lens window", str(cm.exception))
|
||||
self.assertIn("10.6", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, ["sheet.place"])
|
||||
for k, v in FOUND.items():
|
||||
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""The commission.* suite on the host: the registration (ids, kinds, 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
|
||||
against the fake daemon."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
@@ -19,7 +17,7 @@ from forgetest.suite import commission
|
||||
IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot",
|
||||
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes",
|
||||
"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.mirror")
|
||||
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.assertFalse(t.hands)
|
||||
|
||||
def test_mdns_covers_nothing_by_design(self):
|
||||
self.assertEqual(self.reg["commission.mdns-announce"].covers, ())
|
||||
def test_the_machine_name_covers_nothing_by_design(self):
|
||||
self.assertEqual(self.reg["commission.machine-name"].covers, ())
|
||||
|
||||
def test_the_login_test_makes_its_own_account(self):
|
||||
# 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)
|
||||
|
||||
|
||||
class MdnsTests(unittest.TestCase):
|
||||
def test_query_is_a_unicast_response_question(self):
|
||||
q = commission.mdns_query("forgefirm.local", qid=0x1234)
|
||||
qid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", q[:12])
|
||||
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))
|
||||
class MachineNameTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||
|
||||
def _response(self, qid, name_bytes, addr, flags=0x8400, extra=b""):
|
||||
q = b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 1)
|
||||
rr = name_bytes + struct.pack(">HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(addr)
|
||||
return struct.pack(">HHHHHH", qid, flags, 1, 1, 0, 0) + q + rr + extra
|
||||
def _net(self, **devs):
|
||||
"""A /sys/class/net tree; commission.mac_suffix reads it through
|
||||
commission.read_file, which takes an absolute path."""
|
||||
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):
|
||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
||||
self.assertEqual(commission.mdns_answers(pkt, 7), [("forgefirm.local", "192.168.1.9")])
|
||||
def read(path):
|
||||
head = "/sys/class/net/"
|
||||
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):
|
||||
pkt = self._response(0, b"\x09forgefirm\x05local\x00", "10.0.0.5")
|
||||
self.assertEqual(commission.mdns_answers(pkt), [("forgefirm.local", "10.0.0.5")])
|
||||
commission.read_file = read
|
||||
self.addCleanup(setattr, commission, "read_file", real)
|
||||
|
||||
def test_wrong_id_or_a_query_yields_nothing(self):
|
||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
||||
self.assertEqual(commission.mdns_answers(pkt, 8), [])
|
||||
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_the_wifi_address_names_the_machine(self):
|
||||
self._net(wlan0="2C:6B:7D:0D:B0:0A", eth0="00:11:22:33:44:55")
|
||||
self.assertEqual(commission.mac_suffix(), "b00a")
|
||||
|
||||
def test_a_truncated_packet_yields_nothing(self):
|
||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
||||
self.assertEqual(commission.mdns_answers(pkt[:20], 7), [])
|
||||
def test_a_machine_with_no_wifi_falls_back_to_the_wired_address(self):
|
||||
self._net(eth0="00:11:22:33:44:55")
|
||||
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):
|
||||
|
||||
@@ -586,6 +586,41 @@ class RoutingTests(unittest.TestCase):
|
||||
finally:
|
||||
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):
|
||||
"""A card that settles the coolant before it lights the button
|
||||
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"])
|
||||
|
||||
|
||||
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):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
|
||||
@@ -94,13 +94,28 @@ class TreeManifestTests(unittest.TestCase):
|
||||
self.assertNotEqual(self.mod.layer_content(self.layer), base)
|
||||
|
||||
def test_layer_hash_matches_the_bbclass_rule(self):
|
||||
# The same skip list as FORGEFIRM_MANIFEST_PIN_SUFFIX + *.md in
|
||||
# forgefirm-image-manifest.bbclass.
|
||||
# The same skip list as *.md plus FORGEFIRM_MANIFEST_PIN_SUFFIX and
|
||||
# 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")
|
||||
with open(bbclass, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
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__":
|
||||
|
||||
@@ -11,4 +11,4 @@ overrides:
|
||||
meta-freescale-distro:
|
||||
commit: b9d6a5d9931922558046d230c1f5f4ef6ee72345
|
||||
meta-openglow:
|
||||
commit: b7ad6d9377fbb4215dc4d898837906b06b47e5e9
|
||||
commit: ced2af274ba270fd8fc9a6601bbd3fc6247110a6
|
||||
|
||||
@@ -44,6 +44,13 @@
|
||||
FORGEFIRM_MANIFEST_DIR ?= "${sysconfdir}/forgefirm-manifest.d"
|
||||
FORGEFIRM_MANIFEST_CONTENT_LAYERS ?= "meta-forgefirm meta-glowforge-bsp meta-openglow-core"
|
||||
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"
|
||||
|
||||
@@ -88,7 +95,8 @@ def forgefirm_manifest_layers(d):
|
||||
and dirty flag of every layer checkout (informational)."""
|
||||
import os, subprocess
|
||||
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 = {}, {}
|
||||
for layer in (d.getVar('BBLAYERS') or '').split():
|
||||
name = os.path.basename(layer.rstrip('/'))
|
||||
|
||||
@@ -8,19 +8,19 @@ DISTRO_FEATURES:remove = " \
|
||||
3g alsa avahi bluetooth bluez5 ext2 irda nfc nfs pci pcmcia \
|
||||
pulseaudio vulkan wayland x11 zeroconf "
|
||||
|
||||
# mDNS: the image installs avahi-daemon by name (forgefirm-image.bb), so
|
||||
# the control panel answers at forgefirm.local. The avahi and zeroconf
|
||||
# features stay removed above: zeroconf would install
|
||||
# packagegroup-base-zeroconf (the daemon plus libnss-mdns), and avahi
|
||||
# would switch other recipes' avahi options on. The build is trimmed to
|
||||
# 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
|
||||
# front ends, no libdns_sd compatibility library, no libevent binding.
|
||||
# Python bindings are off in the recipe itself.
|
||||
PACKAGECONFIG:pn-avahi = ""
|
||||
# libnss-mdns is what avahi-daemon recommends: an NSS module so the
|
||||
# machine itself resolves .local names. Nothing on the machine does.
|
||||
BAD_RECOMMENDATIONS += "libnss-mdns"
|
||||
# The name the image ships in /etc/hostname. forgefirm-hostname replaces
|
||||
# it at S38 in rcS with forgefirm-<xxxx>, from the machine's MAC address;
|
||||
# this is what the few seconds before that show, and the fallback on a
|
||||
# machine whose MAC address cannot be read.
|
||||
hostname:pn-base-files = "forgefirm"
|
||||
|
||||
# No mDNS. The panel is reached at the machine's address, which every
|
||||
# network resolves; the machine's own name (forgefirm-<xxxx>, from
|
||||
# forgefirm-hostname) goes out in the DHCP request, so a network with
|
||||
# dynamic DNS publishes it as well. The avahi and zeroconf features stay
|
||||
# removed above: zeroconf would install packagegroup-base-zeroconf (the
|
||||
# daemon plus libnss-mdns), and avahi would switch other recipes' avahi
|
||||
# options on.
|
||||
|
||||
# TLS: forgectrl serves HTTPS on 443 with a self-signed certificate
|
||||
# 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
|
||||
# layer content hash because the component entry already identifies the
|
||||
# pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "468ee215dd1148b92c979decacecc41aa6a75ccf"
|
||||
PV = "0.1.12"
|
||||
SRCREV = "864b8daea94b833ac05eda80170c54f550d54293"
|
||||
PV = "0.1.20"
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
#!/bin/sh
|
||||
# Rewrites the address block of /etc/issue, the serial-console login
|
||||
# banner: the control panel by mDNS name, then one https:// URL per
|
||||
# global address of wlan0 (and eth0 when the machine has one). Every
|
||||
# other line of /etc/issue stays as the image build wrote it. The block
|
||||
# sits between the marker lines "# ForgeFIRM addresses" and "# end" and
|
||||
# is appended when absent. Called by the init script at boot and by the
|
||||
# udhcpc hook on every lease event. Idempotent: the file is written only
|
||||
# when the block changes.
|
||||
# Rewrites /etc/issue, the serial-console login banner: the lines the
|
||||
# image build wrote, then one https:// URL per global address of wlan0
|
||||
# (and eth0 when the machine has one). The address is the only way in
|
||||
# that the banner gives, because it is the one that works on every
|
||||
# network. Called by the init script at boot and by the udhcpc hook on
|
||||
# every lease event. Idempotent: the file is written only when the text
|
||||
# changes.
|
||||
#
|
||||
# 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
|
||||
# written through the mount. Before that the image's own file shows.
|
||||
# bind-mounted from a copy under /run/forgefirm (tmpfs) and the banner
|
||||
# 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
|
||||
|
||||
ISSUE=/etc/issue
|
||||
STATE=/run/forgefirm/issue
|
||||
MARK_BEGIN='# ForgeFIRM addresses'
|
||||
MARK_END='# end'
|
||||
PANEL='Control panel: https://forgefirm.local/'
|
||||
BASE=/run/forgefirm/issue.base
|
||||
|
||||
# One URL per global address; an IPv6 address gets its URL brackets.
|
||||
# Tentative, deprecated and temporary addresses are left out.
|
||||
@@ -32,45 +31,40 @@ addresses () {
|
||||
a = $2
|
||||
sub(/\/.*/, "", a)
|
||||
if ($1 == "inet6") a = "[" a "]"
|
||||
print "https://" a "/"
|
||||
print " https://" a "/"
|
||||
}'
|
||||
done
|
||||
}
|
||||
|
||||
block () {
|
||||
echo "$MARK_BEGIN"
|
||||
echo "$PANEL"
|
||||
addrs=$(addresses)
|
||||
if [ -n "$addrs" ]; then
|
||||
echo "$addrs"
|
||||
else
|
||||
echo "no network address yet"
|
||||
fi
|
||||
echo "$MARK_END"
|
||||
# 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
|
||||
}
|
||||
|
||||
[ -f "$ISSUE" ] || exit 0
|
||||
|
||||
new=$(block)
|
||||
old=$(awk -v b="$MARK_BEGIN" -v e="$MARK_END" \
|
||||
'$0 == b { p = 1 } p { print } $0 == e { p = 0 }' "$ISSUE")
|
||||
[ "$new" = "$old" ] && exit 0
|
||||
|
||||
# A mount at the file, read from /proc/mounts (mountpoint(1) judges a
|
||||
# file by its device numbers alone).
|
||||
if ! awk -v t="$ISSUE" '$2 == t { f = 1 } END { exit !f }' /proc/mounts; then
|
||||
mkdir -p "${STATE%/*}" \
|
||||
&& cp -p "$ISSUE" "$STATE" \
|
||||
# The image's own text: the file as it is before the first write of the
|
||||
# boot. /run is a tmpfs, so the copy is made once per boot, and the
|
||||
# first caller is the one that finds the file untouched.
|
||||
mkdir -p "${STATE%/*}" || exit 1
|
||||
[ -f "$BASE" ] || cp -p "$ISSUE" "$BASE" || exit 1
|
||||
if ! is_mounted "$ISSUE"; then
|
||||
cp -p "$BASE" "$STATE" \
|
||||
&& mount --bind "$STATE" "$ISSUE" || exit 1
|
||||
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.$$"
|
||||
awk -v b="$MARK_BEGIN" -v e="$MARK_END" -v blk="$new" '
|
||||
$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; }
|
||||
printf '%s\n\n' "$new" > "$tmp" || { rm -f "$tmp"; exit 1; }
|
||||
cat "$tmp" > "$ISSUE"
|
||||
rm -f "$tmp"
|
||||
exit 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
SUMMARY = "ForgeFIRM console banner: the control panel addresses in /etc/issue"
|
||||
DESCRIPTION = "Keeps an address block in the serial-console login banner \
|
||||
(/etc/issue): the control panel by mDNS name and by every global address \
|
||||
of wlan0 and eth0. Refreshed at boot and on every DHCP lease event."
|
||||
(/etc/issue): one control panel URL per global address of wlan0 and eth0. \
|
||||
Refreshed at boot and on every DHCP lease event."
|
||||
LICENSE = "MIT"
|
||||
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
|
||||
# *-pin.inc out of the layer content hash because the component entry
|
||||
# already identifies the pinned source (forgefirm-image-manifest.bbclass).
|
||||
SRCREV = "9ee624bbc1d1fd929f1f5587b6fc6acaa13a4561"
|
||||
PV = "0.1.10"
|
||||
SRCREV = "1f2ea9ca83aa0035260c9db6b21c4fe0873796e0"
|
||||
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
|
||||
# (/data/forgefirm/users, written by forgectrl) into the system account
|
||||
# files at boot, before sshd, and on reload; also installs the warning an
|
||||
# interactive root shell prints. forgefirm-banner: keeps the control
|
||||
# panel addresses in the serial-console banner (/etc/issue).
|
||||
# interactive root shell prints. forgefirm-hostname: names the machine
|
||||
# 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.
|
||||
# avahi-daemon: mDNS, so the panel answers at https://forgefirm.local/
|
||||
# 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"
|
||||
IMAGE_INSTALL:append = " forgefirm-users forgefirm-hostname forgefirm-banner forgefirm-persist"
|
||||
|
||||
# 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
|
||||
@@ -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
|
||||
# the packages a read-only rootfs cannot use (shadow, base-passwd,
|
||||
# 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
|
||||
# and /etc/issue (forgefirm-users, forgefirm-banner), the sshd host keys
|
||||
# last or change at run time is handled file by file: the account files,
|
||||
# /etc/hostname and /etc/issue (forgefirm-users, forgefirm-hostname,
|
||||
# forgefirm-banner), the sshd host keys
|
||||
# (recipes-connectivity/openssh), the timestamp and the random seed
|
||||
# (forgefirm-persist). The facts are on the docs site,
|
||||
# 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_MAXSIZE = "204800"
|
||||
|
||||
# Version stamp: /etc/forgefirm-version (machine-readable), echoed on the
|
||||
# serial-console login prompt (/etc/issue) and at SSH login (motd).
|
||||
# The ForgeFIRM mark and the version stamp:
|
||||
# /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
|
||||
# string with the build timestamp (the same DATETIME as the artifact
|
||||
# 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}"
|
||||
|
||||
write_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 "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"
|
||||
# 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.3"
|
||||
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`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
# ForgeFIRM acceptance - 20260911172215 (dev)
|
||||
|
||||
- Image: `20260911172215 (dev)` (forgefirm-image-dev)
|
||||
- Manifest identity: `fb24c3f4508ae54592f8d0b98ed0b2a07a933274980d8c1e4e5a967618ef9bd8`
|
||||
- Catalog: `ceabc830345fc4cac4832de691c0571e93a5c4e155666ce91601c844c40942fc`
|
||||
- Campaign: `c-20260911182256-078f` opened 2026-09-11T18:22:56Z
|
||||
- Exported: 2026-09-11T18:54:20Z
|
||||
- **Release authorized: YES**
|
||||
- Tests: 83 total, 83 satisfied (57 inherited), 0 required
|
||||
|
||||
| Test | Kind | Result | Run at | Campaign | Inherited |
|
||||
|---|---|---|---|---|---|
|
||||
| `image.health` | auto, core | PASS | 2026-09-11T18:22:57Z | `c-20260911182256-078f` | no |
|
||||
| `image.license-bundle` | auto | PASS | 2026-09-11T17:29:26Z | `c-20260911172921-9426` | yes |
|
||||
| `kernel.latch-locked-idle` | auto, core | PASS | 2026-09-11T18:22:58Z | `c-20260911182256-078f` | no |
|
||||
| `kernel.k1-k2` | auto, core, takeover | PASS | 2026-09-11T18:23:19Z | `c-20260911182256-078f` | no |
|
||||
| `kernel.deadman-close` | auto, core, takeover | PASS | 2026-09-11T18:23:29Z | `c-20260911182256-078f` | no |
|
||||
| `kernel.backtrack-bounds` | auto, core, takeover | PASS | 2026-09-11T18:23:48Z | `c-20260911182256-078f` | no |
|
||||
| `kernel.fire-line` | auto, core, takeover | PASS | 2026-09-11T18:24:32Z | `c-20260911182256-078f` | no |
|
||||
| `kernel.resume-lead` | auto, takeover | PASS | 2026-09-11T17:31:23Z | `c-20260911172921-9426` | yes |
|
||||
| `kernel.pic-soc-load` | auto | PASS | 2026-09-11T17:31:26Z | `c-20260911172921-9426` | yes |
|
||||
| `forgectrl.auth` | auto | PASS | 2026-09-11T17:31:27Z | `c-20260911172921-9426` | yes |
|
||||
| `forgectrl.settings-bounds` | auto | PASS | 2026-09-11T17:31:27Z | `c-20260911172921-9426` | yes |
|
||||
| `forgectrl.panel-serves` | auto | PASS | 2026-09-11T17:29:23Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.gate-blocks-controllers` | auto, takeover | PASS | 2026-09-11T17:31:46Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.override-until-reboot` | auto | PASS | 2026-09-11T17:31:46Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.advisories-rehash` | auto, takeover | PASS | 2026-09-11T17:31:55Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.account-login` | auto, takeover | PASS | 2026-09-11T17:32:44Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.https-only-writes` | auto | PASS | 2026-09-11T17:32:45Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.ssh-until-reboot` | auto | PASS | 2026-09-11T17:32:46Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.cloud-disabled-surface` | auto | PASS | 2026-09-11T17:32:47Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.factory-return` | auto | PASS | 2026-09-11T17:32:49Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.machine-name` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.first-run-flow` | operator, takeover | PASS | 2026-09-11T17:54:32Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.first-run-page` | operator, takeover | PASS | 2026-09-11T17:55:49Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.cert-page` | auto | PASS | 2026-09-11T17:32:50Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.what-changed` | auto, takeover | PASS | 2026-09-11T17:32:59Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.record-export` | auto | PASS | 2026-09-11T17:34:22Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.mirror` | auto, takeover | PASS | 2026-09-11T17:34:54Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.check-switches` | operator | PASS | 2026-09-11T17:56:07Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.check-sensors` | auto | PASS | 2026-09-11T17:34:34Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.check-airflow` | auto, takeover | PASS | 2026-09-11T17:36:26Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.check-cameras` | operator | PASS | 2026-09-11T17:56:22Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.check-motion` | auto, takeover | PASS | 2026-09-11T17:37:06Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.check-flow-verify` | auto, takeover | PASS | 2026-09-11T17:44:42Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.cloud-header-capture` | operator, takeover | PASS | 2026-09-11T17:59:41Z | `c-20260911172921-9426` | yes |
|
||||
| `commission.sheet` | live, takeover | PASS | 2026-09-11T18:17:29Z | `c-20260911172921-9426` | yes |
|
||||
| `logs.tree-tail-export` | auto | PASS | 2026-09-11T17:33:40Z | `c-20260911172921-9426` | yes |
|
||||
| `logs.routing` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
|
||||
| `logs.level-settings` | auto | PASS | 2026-09-11T17:44:46Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.pacing` | auto | PASS | 2026-09-11T17:36:41Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.jog-roundtrip` | auto | PASS | 2026-09-11T17:45:00Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.microstep-modes` | auto | PASS | 2026-09-11T17:45:25Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.liveness-probe` | auto | PASS | 2026-09-11T17:45:33Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.gate-waits-for-lid` | operator | PASS | 2026-09-11T18:24:48Z | `c-20260911182256-078f` | no |
|
||||
| `motion.cancel-abort` | auto | PASS | 2026-09-11T17:45:40Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.deadman` | auto | PASS | 2026-09-11T17:46:09Z | `c-20260911172921-9426` | yes |
|
||||
| `motion.button-hold-resume` | operator | PASS | 2026-09-11T18:24:59Z | `c-20260911182256-078f` | no |
|
||||
| `motion.lid-cancel-home` | operator | PASS | 2026-09-11T18:25:15Z | `c-20260911182256-078f` | no |
|
||||
| `motion.interlock-cancel-home` | operator | PASS | 2026-09-11T18:25:24Z | `c-20260911182256-078f` | no |
|
||||
| `motion.lid-policy-hold` | operator | PASS | 2026-09-11T18:25:37Z | `c-20260911182256-078f` | no |
|
||||
| `motion.step-timing-under-load` | auto | PASS | 2026-09-11T17:46:40Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.aa-offset-calibrate` | auto | PASS | 2026-09-11T17:38:38Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.flow-verify` | auto | PASS | 2026-09-11T17:41:29Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.fans-quiet-after-motion` | auto | PASS | 2026-09-11T17:47:35Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.gate-off` | auto | PASS | 2026-09-11T17:35:02Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.floor-and-warm-up` | auto | PASS | 2026-09-11T17:49:02Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.tec-drive` | auto | PASS | 2026-09-11T17:49:10Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.fire-watch-tiers` | auto | PASS | 2026-09-11T17:49:25Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.crash-watch-plumbing` | auto | PASS | 2026-09-11T17:49:32Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.critical-tier` | auto | PASS | 2026-09-11T17:49:41Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.fan-gate-trips` | auto | PASS | 2026-09-11T17:35:45Z | `c-20260911172921-9426` | yes |
|
||||
| `cooling.flow-under-load` | live | PASS | 2026-09-11T18:41:47Z | `c-20260911182256-078f` | no |
|
||||
| `laser.power-floor` | auto | PASS | 2026-09-11T17:49:42Z | `c-20260911172921-9426` | yes |
|
||||
| `laser.emission-witness` | live, core | PASS | 2026-09-11T18:40:15Z | `c-20260911182256-078f` | no |
|
||||
| `laser.m5-rapid-dark` | live | PASS | 2026-09-11T18:42:25Z | `c-20260911182256-078f` | no |
|
||||
| `laser.disarm-in-hold` | live | PASS | 2026-09-11T18:43:51Z | `c-20260911182256-078f` | no |
|
||||
| `laser.armed-kill` | live | PASS | 2026-09-11T18:44:43Z | `c-20260911182256-078f` | no |
|
||||
| `laser.arm-wait-lid` | operator | PASS | 2026-09-11T18:25:57Z | `c-20260911182256-078f` | no |
|
||||
| `laser.pause-resume-lid-cancel` | live | PASS | 2026-09-11T18:45:12Z | `c-20260911182256-078f` | no |
|
||||
| `camera.snapshot` | auto | PASS | 2026-09-11T17:49:57Z | `c-20260911172921-9426` | yes |
|
||||
| `camera.sensor-profile` | auto | PASS | 2026-09-11T17:50:10Z | `c-20260911172921-9426` | yes |
|
||||
| `camera.h264-stream` | auto | PASS | 2026-09-11T17:50:23Z | `c-20260911172921-9426` | yes |
|
||||
| `camera.frame-health` | auto | PASS | 2026-09-11T17:50:36Z | `c-20260911172921-9426` | yes |
|
||||
| `camera.lid-privacy` | operator | PASS | 2026-09-11T18:26:12Z | `c-20260911182256-078f` | no |
|
||||
| `camera.key-read` | auto | PASS | 2026-09-11T17:50:37Z | `c-20260911172921-9426` | yes |
|
||||
| `update.slots-and-signature` | auto | PASS | 2026-09-11T17:32:49Z | `c-20260911172921-9426` | yes |
|
||||
| `cloud.mode-switch` | operator | PASS | 2026-09-11T17:58:28Z | `c-20260911172921-9426` | yes |
|
||||
| `cloud.service-protocol` | operator | PASS | 2026-09-11T18:46:13Z | `c-20260911182256-078f` | no |
|
||||
| `cloud.lid-interlock-abort` | live | PASS | 2026-09-11T18:47:21Z | `c-20260911182256-078f` | no |
|
||||
| `cloud.lid-during-button-wait` | operator | PASS | 2026-09-11T18:26:51Z | `c-20260911182256-078f` | no |
|
||||
| `cloud.verdict-hold` | operator | PASS | 2026-09-11T18:38:58Z | `c-20260911182256-078f` | no |
|
||||
| `cloud.pause-resume` | live | PASS | 2026-09-11T18:49:33Z | `c-20260911182256-078f` | no |
|
||||
| `cloud.oversize-stream` | live | PASS | 2026-09-11T18:52:19Z | `c-20260911182256-078f` | no |
|
||||
| `cloud.paused-lid-cancel` | live | PASS | 2026-09-11T18:52:48Z | `c-20260911182256-078f` | no |
|
||||
|
||||
Artifact sha256: `d7bb5d900f9237711a17ae207df90025605340c2eb2eaf34f9ed508cc3a899a6`
|
||||
@@ -25,6 +25,19 @@ no root) and drives it over TCP:
|
||||
does not free Z either
|
||||
6. X and Y are still free after both writes
|
||||
|
||||
A referenced lens opens the envelope to the window the shared settings
|
||||
hold, the same keys the daemon's wizards build their Z from. forgectrl
|
||||
leaves the lens on its hall edge and a marker in the state directory
|
||||
before the controller starts; the driver takes it as it loads its
|
||||
settings and places the edge on its step grid:
|
||||
|
||||
7. the marker with the edge alone: Z sits at the edge's grid step, the
|
||||
fallback window (10 below, 12 above) runs to its ends, and two
|
||||
half-steps past either end alarms
|
||||
8. the marker with the focus card's stops (14 below, 20 above): the
|
||||
wider window runs to its ends, two half-steps past either alarms
|
||||
9. a stop count out of range (41) falls back on its side alone
|
||||
|
||||
The binary keeps its settings in EEPROM.DAT in the working directory, so
|
||||
each run starts from defaults in a temporary directory and leaves
|
||||
nothing behind.
|
||||
@@ -53,13 +66,24 @@ def fail(msg):
|
||||
|
||||
|
||||
class Session:
|
||||
"""One null-sink controller process with its own settings store."""
|
||||
"""One null-sink controller process with its own settings store.
|
||||
`conf` (key: value) is the shared config the driver reads; with
|
||||
`referenced` the lens marker forgectrl leaves is in the state
|
||||
directory, so the driver takes the lens reference at its start."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, conf=None, referenced=False):
|
||||
self.workdir = tempfile.mkdtemp(prefix="z-envelope-")
|
||||
env = dict(os.environ, GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
|
||||
for key in ("GFSINK", "GF_SWITCH_FILE", "GF_VERDICT_FILE"):
|
||||
env.pop(key, None)
|
||||
conf_path = os.path.join(self.workdir, "forgefirm.conf")
|
||||
with open(conf_path, "w") as f:
|
||||
for k, v in (conf or {}).items():
|
||||
f.write("%s = %s\n" % (k, v))
|
||||
env["GFHOME_CONF"] = conf_path
|
||||
if referenced:
|
||||
with open(os.path.join(self.workdir, "lens.home"), "w") as f:
|
||||
f.write("edge 0 3 3\n")
|
||||
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
|
||||
cwd=self.workdir, env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
@@ -113,6 +137,23 @@ class Session:
|
||||
m = re.search(r"<(\w+)", self.send("?"))
|
||||
return m.group(1) if m else "?"
|
||||
|
||||
def mpos_z(self):
|
||||
"""The reported machine Z (no work offset is set in a session)."""
|
||||
m = re.search(r"[MW]Pos:(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)", self.send("?"))
|
||||
if not m:
|
||||
fail("no position in the status report")
|
||||
return float(m.group(3))
|
||||
|
||||
def wait_idle(self, timeout=15.0):
|
||||
"""A move that ran plays out before the next line is judged: the
|
||||
soft limit on a moving machine holds first and alarms after."""
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if self.state() == "Idle":
|
||||
return
|
||||
time.sleep(0.1)
|
||||
fail("the controller did not return to Idle")
|
||||
|
||||
def move(self, gcode):
|
||||
"""Run one move. Returns True when the soft limit blocked it.
|
||||
An alarm needs a reset and an unlock before the next move."""
|
||||
@@ -160,6 +201,60 @@ def free_in_xy(s, when):
|
||||
check(ran, "X and Y free %s" % when, "an X or Y move was blocked %s" % when)
|
||||
|
||||
|
||||
def window_holds(s, steps, below, above, what):
|
||||
"""The envelope of a referenced lens whose edge sits on grid step
|
||||
`steps`: the ends of the reach (`below` and `above` half-steps from
|
||||
the edge) run, two half-steps past either end alarms. The controller
|
||||
keeps a half-step of slack beyond the reach, so one past is not
|
||||
judged here; two is outside on every head."""
|
||||
spm = float(s.setting("$102"))
|
||||
top, bottom = (steps + above) / spm, (steps - below) / spm
|
||||
s.wait_idle()
|
||||
check(not s.move("G0 Z%.3f" % top), "%s: the top of the reach, Z %.3f, runs" % (what, top),
|
||||
"%s: Z %.3f, the top of the reach, was blocked" % (what, top))
|
||||
s.wait_idle()
|
||||
check(not s.move("G0 Z%.3f" % bottom), "%s: the bottom of the reach, Z %.3f, runs" % (what, bottom),
|
||||
"%s: Z %.3f, the bottom of the reach, was blocked" % (what, bottom))
|
||||
over, under = (steps + above + 2) / spm, (steps - below - 2) / spm
|
||||
s.wait_idle()
|
||||
check(s.move("G0 Z%.3f" % over), "%s: two half-steps over the top, Z %.3f, alarms" % (what, over),
|
||||
"%s: Z %.3f, two half-steps over the top, ran" % (what, over))
|
||||
s.wait_idle()
|
||||
check(s.move("G0 Z%.3f" % under), "%s: two half-steps under the bottom, Z %.3f, alarms" % (what, under),
|
||||
"%s: Z %.3f, two half-steps under the bottom, ran" % (what, under))
|
||||
|
||||
|
||||
def referenced_cases():
|
||||
"""The lens referenced by forgectrl's marker: the envelope is the
|
||||
window the shared settings hold, as the daemon's wizards read it."""
|
||||
edge = 3.35
|
||||
conf = {"lens_hall_edge_z_mm": "%.2f" % edge}
|
||||
s = Session(conf=conf, referenced=True)
|
||||
try:
|
||||
spm = float(s.setting("$102"))
|
||||
steps = int(round(edge * spm))
|
||||
z = s.mpos_z()
|
||||
check(abs(z - steps / spm) < 0.002,
|
||||
"referenced: Z sits at the edge's grid step, %.3f" % z,
|
||||
"referenced: Z reads %.3f, not the edge's grid step %.3f" % (z, steps / spm))
|
||||
window_holds(s, steps, 10, 12, "the fallback window")
|
||||
free_in_xy(s, "with the lens referenced")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
s = Session(conf=dict(conf, lens_stop_below_steps="14", lens_stop_above_steps="20"), referenced=True)
|
||||
try:
|
||||
window_holds(s, steps, 14, 20, "the focus card's window")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
s = Session(conf=dict(conf, lens_stop_below_steps="41", lens_stop_above_steps="20"), referenced=True)
|
||||
try:
|
||||
window_holds(s, steps, 10, 20, "a below count out of range")
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isfile(BIN):
|
||||
fail("no controller binary at %s" % BIN)
|
||||
@@ -189,6 +284,7 @@ def main():
|
||||
free_in_xy(s, "after the settings writes")
|
||||
finally:
|
||||
s.close()
|
||||
referenced_cases()
|
||||
print("PASS z_envelope_test")
|
||||
|
||||
|
||||
|
||||
@@ -52,8 +52,11 @@ CONTENT_LAYERS = {"meta-forgefirm": ("forgefirm", "meta-forgefirm"),
|
||||
"meta-glowforge-bsp": ("meta-openglow", "meta-glowforge-bsp"),
|
||||
"meta-openglow-core": ("meta-openglow", "meta-openglow-core")}
|
||||
# Left out of a layer's content, as in forgefirm-image-manifest.bbclass
|
||||
# (FORGEFIRM_MANIFEST_PIN_SUFFIX): documentation and the component pin files.
|
||||
LAYER_SKIP_SUFFIXES = (".md", "-pin.inc")
|
||||
# (FORGEFIRM_MANIFEST_PIN_SUFFIX, FORGEFIRM_MANIFEST_VERSION_SUFFIX):
|
||||
# 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):
|
||||
|
||||
+7
-4
@@ -45,7 +45,7 @@
|
||||
# forgefirm-source-v<version>.tar.gz, and refuses to pack a bundle in which
|
||||
# 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
|
||||
# meta-version ("v<version>") == release tag ("v<version>").
|
||||
|
||||
@@ -55,6 +55,9 @@ REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DEPLOY_ROOT="$REPO/build/tmp/deploy"
|
||||
DEPLOY="$DEPLOY_ROOT/images/glowforge"
|
||||
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"
|
||||
WARN_BYTES=$((170 * 1024 * 1024))
|
||||
FAIL_BYTES=$((195 * 1024 * 1024))
|
||||
@@ -134,7 +137,7 @@ if [ "$MODE" = "dev" ]; then
|
||||
EXT4="${EXT4/forgefirm-image-glowforge/forgefirm-image-dev-glowforge}"
|
||||
[ -f "$EXT4" ] || die "dev rootfs not found: $EXT4"
|
||||
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)"
|
||||
OUT="$DEPLOY/forgefirm-dev.fw"
|
||||
"$REPO/scripts/mkfw.sh" "$EXT4" "$DEVVER" "$OUT" "$KEY"
|
||||
@@ -163,9 +166,9 @@ else
|
||||
fi
|
||||
|
||||
# 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" ] \
|
||||
|| 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
|
||||
# first release that is not. While the README carries the beta banner, a
|
||||
|
||||
Reference in New Issue
Block a user