mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-28 01:01:12 -07:00
One name for every machine was wrong: an operator with two of them on a network had one forgefirm.local, and mDNS does not work on many networks at all. The machine now calls itself forgefirm-<xxxx>, from the last four hex digits of its WiFi MAC address, and sends that name with its DHCP request, so a network with dynamic DNS publishes it and a router lists the machine by name. The name is the same at every boot, two machines take different names, and no serial number leaves the machine. forgefirm-hostname (new): reads the wlan0 MAC address (eth0 on a machine with no WiFi) at S38 in rcS, after udev has probed the network drivers and before poky's hostname.sh reads the file and before the network starts. The rootfs is read-only, so the name is written through a bind-mounted copy under /run/forgefirm. A bounded wait covers a slow probe. hostname:pn-base-files is "forgefirm": the name before S38, and the fallback when no MAC address can be read. avahi is deleted - the bbappend, the daemon configuration, the service file, the image install and the distro block. The address is the way in that works on every network, and the DHCP name covers the rest. forgefirm-banner: the marker lines are gone. "# ForgeFIRM addresses" and "# end" delimited the address block inside /etc/issue, and getty prints every line of that file, so both markers were on the console. The script now keeps the image's own text in a second copy under /run/forgefirm, captured once per boot before the first write, and renders the whole banner from it. The block is the addresses alone: no mDNS name. forgefirm-image.bb: the ForgeFIRM mark, under the OpenGlow one the base image carries, with the version on the mark's own last line, right-justified to the mark's last column. The mark is written once and rendered per reader, because /etc/issue is parsed by busybox getty (a backslash or a percent sign starts an escape, so the art goes in with every backslash doubled) while /etc/motd is written out as it is. Widths are measured in columns, not bytes: the color sequences take no room on the screen. /etc/issue.net stays unused - the machine tells a client that has not logged in nothing. Acceptance: commission.mdns-announce is replaced by commission.machine-name, which checks the name against the MAC address, the bind-mounted /etc/hostname, the DHCP client's hostname option, the banner's addresses, and that no mDNS responder is on the image; it covers nothing by design, like the test it replaces. forgectrl.auth gains the own-name Host check and its refusal with a domain on it. image.health checks the /etc/hostname mount and the version on the mark's last line in both files. commission.ssh-until-reboot asserts there is no pre-authentication banner. commission_dark's lens coverage widens to src/lenshome.* so src/lenshome.h is covered; the lint is clean at 83 tests. Pins: forgectrl 0.1.14 (9e5330f, the hostname certificate and the Host rule), meta-openglow ced2af2 (the DHCP hostname option and the motd mark) in the kas lock. Proven on the bench reference, hot-deployed and rebooted (image 20260910000208 dev): hostname forgefirm-b00a from MAC 2c:6b:7d:0d:b0:0a, live and in the bind-mounted file; the DHCP client running with -x hostname:forgefirm-b00a; the console banner and the motd carrying both marks with the version aligned to the mark's last column, no marker line and no .local name; forgectrl regenerating its certificate for the new name. Host tests: 357 forgetest unit tests, forgectrl clean under -Werror, tls_test and sanitize_test.
346 lines
20 KiB
Python
346 lines
20 KiB
Python
"""commission.check-* - the setup's checks (the dark wizards), driven the
|
|
way the page drives them: POST /wiz/<id>/start, GET /wiz/dark polled,
|
|
the prompts answered from here (the bench fixture works the lid and the
|
|
button; a confirmation is answered yes once the snapshot exists), the
|
|
result judged, and every setting a check wrote put back as found. Each
|
|
check re-runs on the real record: a completed check completes again at
|
|
the same version, so the record reads as before.
|
|
"""
|
|
import json
|
|
import os
|
|
import time
|
|
from ..catalog import test
|
|
from .. import hw
|
|
from ..baseline import read_position
|
|
from .commission import wiz, Restore # noqa: F401 - Restore is re-exported for the sheet
|
|
|
|
POLL_S = 1.0
|
|
DARK_COVERS = [("forgectrl", "src/wizdark.*"), ("forgectrl", "src/wizcalc.*"),
|
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
|
("forgectrl", "src/main.c"), ("forgectrl", "src/ui/wizard.*")]
|
|
|
|
|
|
def dark(fc):
|
|
st, body = fc.get("/wiz/dark")
|
|
if st != 200 or not isinstance(body, dict):
|
|
raise hw.HwError("GET /wiz/dark -> %s" % st)
|
|
return body
|
|
|
|
|
|
def start(ctx, fc, wid):
|
|
ev = ctx.evidence
|
|
st, body = fc.post("/wiz/%s/start" % wid)
|
|
if st == 409 and dark(fc).get("id") == wid and dark(fc).get("running"):
|
|
# The same check left running by an earlier run of this test (an
|
|
# abort mid-wait): this run owns the machine, so it ends that one
|
|
# and starts its own.
|
|
ctx.log("the %s check is still running from an earlier run: aborting it", wid)
|
|
fc.post("/wiz/%s/abort" % wid)
|
|
ctx.wait_for(lambda: not dark(fc).get("running"), 30)
|
|
st, body = fc.post("/wiz/%s/start" % wid)
|
|
ctx.log("POST /wiz/%s/start -> %s %s", wid, st, body if isinstance(body, dict) else "")
|
|
ctx.check(st == 200, "the %s check did not start (%s %s)", wid, st, body)
|
|
ev["started"] = st == 200
|
|
|
|
|
|
def answer(fc, wid, prompt, value):
|
|
return fc.post("/wiz/%s/answer" % wid, data={"seq": str(prompt["seq"]), "value": value})
|
|
|
|
|
|
def run_check(ctx, wid, on_prompt, timeout_s):
|
|
"""Start `wid` and follow it to its end. on_prompt(prompt) is called
|
|
once per prompt (by sequence number) and returns the value to answer
|
|
with, or None when it handled the prompt another way (a fixture
|
|
action). Returns the final status document."""
|
|
fc = ctx.forgectrl
|
|
ev = ctx.evidence
|
|
start(ctx, fc, wid)
|
|
t0 = time.time()
|
|
seen = set()
|
|
phases = []
|
|
last = None
|
|
try:
|
|
while time.time() - t0 < timeout_s:
|
|
ctx.checkpoint()
|
|
d = dark(fc)
|
|
if d.get("id") != wid:
|
|
raise hw.HwError("another check took the slot: %s" % d.get("id"))
|
|
if d.get("phase") and (not phases or phases[-1] != d["phase"]):
|
|
phases.append(d["phase"])
|
|
ctx.log("%s: %s", wid, d["phase"])
|
|
p = d.get("prompt")
|
|
if p and p.get("seq") not in seen:
|
|
seen.add(p["seq"])
|
|
ctx.log("prompt %s (%s): %s", p.get("id"), p.get("kind"), p.get("text"))
|
|
value = on_prompt(p)
|
|
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)
|
|
if not d.get("running"):
|
|
last = d
|
|
break
|
|
time.sleep(POLL_S)
|
|
except BaseException:
|
|
# An abort or a failure on the way out leaves no check running
|
|
# behind this test: the next run must be able to start its own.
|
|
fc.post("/wiz/%s/abort" % wid)
|
|
raise
|
|
ev["elapsed_s"] = round(time.time() - t0, 1)
|
|
ev["phases"] = phases[-12:]
|
|
ctx.check(last is not None, "the %s check did not end within %d s", wid, timeout_s)
|
|
if last is None:
|
|
fc.post("/wiz/%s/abort" % wid)
|
|
return {}
|
|
ev["log"] = last.get("log", [])[-12:]
|
|
ev["error"] = last.get("error")
|
|
ev["result"] = last.get("result")
|
|
ctx.check(not last.get("error"), "the %s check failed: %s", wid, last.get("error"))
|
|
ctx.check(isinstance(last.get("result"), dict), "the %s check ended without a result", wid)
|
|
w = wiz(fc)
|
|
ctx.check((w.get("versions") or {}).get(wid) == 1, "the record does not carry %s at version 1", wid)
|
|
return last
|
|
|
|
|
|
@test("commission.check-switches", title="The switches check follows the lid and the button",
|
|
subsystem="commission", kind="operator", est_min=3,
|
|
covers=DARK_COVERS + [("forgectrl", "src/status.c")],
|
|
requires=["forgectrl.auth"], actions=["lid", "button"],
|
|
description="POST /wiz/switches/start; the check asks for the lid to open and close and for "
|
|
"a press, each as a 'wait' prompt that the switch edge itself answers; the bench "
|
|
"fixture works the lid and the button. On a Basic or Plus the interlock loop reads "
|
|
"satisfied without a prompt. The result says lid, button, head present, and "
|
|
"whether the HV enable readback followed the lid; the record carries switches at "
|
|
"version 1.")
|
|
def check_switches(ctx):
|
|
def on_prompt(p):
|
|
pid = p.get("id", "")
|
|
if pid in ("lid-open", "lid-close-first", "lid-close"):
|
|
ctx.act("lid", "open" if pid == "lid-open" else "close",
|
|
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
|
|
text=p.get("text", ""))
|
|
elif pid == "button-press":
|
|
ctx.act("button", "press",
|
|
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("id") != "button-press",
|
|
text=p.get("text", ""))
|
|
elif pid == "button-release":
|
|
pass # the fixture's press releases on its own
|
|
elif pid.startswith("interlock-"):
|
|
ctx.act("interlock", "open" if pid == "interlock-open" else "close",
|
|
until=lambda: (dark(ctx.forgectrl).get("prompt") or {}).get("seq") != p["seq"],
|
|
text=p.get("text", ""))
|
|
return None
|
|
last = run_check(ctx, "switches", on_prompt, 600)
|
|
r = last.get("result") or {}
|
|
ctx.check(r.get("lid") is True and r.get("button") is True, "lid or button not proven: %s", r)
|
|
ctx.check(r.get("head_present") is True, "the head did not read present: %s", r)
|
|
ctx.log("switches: %s", json.dumps(r))
|
|
|
|
|
|
@test("commission.check-sensors", title="The sensors check reads a plausible machine at rest",
|
|
subsystem="commission", kind="auto", est_min=2,
|
|
covers=DARK_COVERS + [("forgectrl", "src/status.c"), ("forgectrl", "src/accel.c"),
|
|
("forgectrl", "src/cool.c")],
|
|
requires=["forgectrl.auth"],
|
|
description="POST /wiz/sensors/start; ten seconds of readings, then the room-temperature "
|
|
"prompt, answered Skip so no offset is written. The result carries both coolant "
|
|
"temperatures, the chassis and SoC, the lid IR maxima, the accelerometer event "
|
|
"count, the supply power-good, the HV current, and the idle fan speeds; the "
|
|
"record carries sensors at version 1 and cool_temp_offset_c reads as before.")
|
|
def check_sensors(ctx):
|
|
with Restore(ctx, ["cool_temp_offset_c"]):
|
|
last = run_check(ctx, "sensors", lambda p: "Skip" if p.get("id") == "room-temp" else None, 120)
|
|
r = last.get("result") or {}
|
|
for k in ("coolant_down_c", "coolant_up_c", "lid_ir_max", "laser_pgood", "hv_current_max",
|
|
"exhaust_rpm_idle", "intake_rpm_idle"):
|
|
ctx.check(k in r, "the result lacks %s: %s", k, r)
|
|
ctx.check(r.get("laser_pgood") == 1, "power-good read %s", r.get("laser_pgood"))
|
|
ctx.log("sensors: coolant %s/%s C, IR %s, HV %s", r.get("coolant_down_c"), r.get("coolant_up_c"),
|
|
r.get("lid_ir_max"), r.get("hv_current_max"))
|
|
|
|
|
|
@test("commission.check-airflow", title="The airflow check measures the fans and sets the floors",
|
|
subsystem="commission", kind="auto", hardware="takeover", est_min=3,
|
|
covers=DARK_COVERS + [("forgectrl", "src/cool.c"), ("forgectrl", "src/airflow.*"),
|
|
("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")],
|
|
requires=["forgectrl.auth", "cooling.fan-gate-trips"],
|
|
description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile "
|
|
"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, 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"]
|
|
with Restore(ctx, keys):
|
|
last = run_check(ctx, "airflow", lambda p: None, 240)
|
|
r = last.get("result") or {}
|
|
fans = r.get("fans") or {}
|
|
for name in ("exhaust", "intake 1", "intake 2", "air assist"):
|
|
f = fans.get(name) or {}
|
|
ctx.check(f.get("ok") is True, "%s: %s", name, f)
|
|
ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s"))
|
|
floors = r.get("floors") or {}
|
|
for k in keys:
|
|
ctx.check(k in floors, "no floor for %s: %s", k, floors)
|
|
s = ctx.forgectrl.settings() or {}
|
|
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)
|
|
gate = ((ctx.forgectrl.get("/cool/status")[1] or {}).get("fan_gates") or {}).get("purge") or {}
|
|
ctx.evidence["purge_after"] = {"purge_air": purge_on, "gate": gate}
|
|
ctx.log("purge after the check: commanded %s, drawing %s against the %s floor",
|
|
purge_on, gate.get("reading"), gate.get("floor"))
|
|
ctx.check((gate.get("reading") or 0) >= (gate.get("floor") or 0),
|
|
"purge draws %s, under the %s floor the check just wrote: the next job would be held",
|
|
gate.get("reading"), 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")
|
|
|
|
|
|
@test("commission.check-cameras", title="The cameras check captures both cameras",
|
|
subsystem="commission", kind="operator", est_min=2,
|
|
covers=DARK_COVERS + [("forgectrl", "src/cam.c")],
|
|
requires=["forgectrl.auth", "camera.snapshot"], actions=["lid"],
|
|
description="POST /wiz/cameras/start with the lid closed: a lid snapshot, the question, a "
|
|
"head snapshot, the question. Each question is answered yes once GET /wiz/shot "
|
|
"serves the snapshot as image/jpeg. The result names the sensor and says both "
|
|
"views were accepted.")
|
|
def check_cameras(ctx):
|
|
fc = ctx.forgectrl
|
|
ev = ctx.evidence
|
|
ctx.act("lid", "close")
|
|
shots = {}
|
|
|
|
def on_prompt(p):
|
|
pid = p.get("id", "")
|
|
if pid in ("lid-view", "head-view"):
|
|
cam = "lid" if pid == "lid-view" else "head"
|
|
st, body = fc.get("/wiz/shot", params={"cam": cam}, raw=True)
|
|
shots[cam] = {"status": st, "bytes": len(body) if body else 0,
|
|
"jpeg": bool(body) and body[:2] == b"\xff\xd8"}
|
|
ctx.check(st == 200 and shots[cam]["jpeg"], "no %s snapshot to judge: %s", cam, shots[cam])
|
|
return "Yes"
|
|
if pid == "lid-close":
|
|
ctx.act("lid", "close", until=lambda: (dark(fc).get("prompt") or {}).get("seq") != p["seq"])
|
|
return None
|
|
last = run_check(ctx, "cameras", on_prompt, 180)
|
|
ev["shots"] = shots
|
|
r = last.get("result") or {}
|
|
ctx.check(r.get("lid_ok") is True and r.get("head_ok") is True, "a view was not accepted: %s", r)
|
|
ctx.check(bool(r.get("sensor")) and r.get("sensor") != "unknown", "no sensor named: %s", r)
|
|
ctx.log("cameras: %s, lid %d bytes, head %d bytes", r.get("sensor"),
|
|
shots.get("lid", {}).get("bytes", 0), shots.get("head", {}).get("bytes", 0))
|
|
|
|
|
|
@test("commission.check-motion", title="The motion check proves the rail, the lens reference, and the jogs",
|
|
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
|
covers=DARK_COVERS + [("forgectrl", "src/super.c"), ("forgectrl", "src/liveness.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 "
|
|
"lens takes its hall-edge reference behind it (a lens that cannot reach its "
|
|
"edge is a motion fault, and no controller starts), the lens "
|
|
"finds the hall reference in five agreeing passes, the controller comes back in "
|
|
"loopback posture, and the head jogs 50 mm each way on X and Y with the "
|
|
"accelerometer as the witness. The one prompt (the jogs are about to move) is "
|
|
"answered Continue. The result carries the probe line, the passes, and a "
|
|
"witnessed reading for each jog; the controller must be back in its normal "
|
|
"posture, and the kernel position counters must read what they read before: "
|
|
"every move played to its end (the probe's return leg under the cooling "
|
|
"engine's dead-man, the last jog under the controller stop).")
|
|
def check_motion(ctx):
|
|
fc = ctx.forgectrl
|
|
before = read_position()
|
|
ctx.notice("The head moves 50 mm each way on X and on Y. Keep the bed clear.")
|
|
last = run_check(ctx, "motion", lambda p: "Continue" if p.get("id") == "jogs" else None, 420)
|
|
ctx.clear_notice()
|
|
r = last.get("result") or {}
|
|
ctx.check(r.get("z_referenced") is True, "the lens was not referenced: %s", r)
|
|
ctx.check(len(r.get("z_passes") or []) == 5, "not five reference passes: %s", r.get("z_passes"))
|
|
moves = r.get("moves") or {}
|
|
for name in ("+X", "-X", "+Y", "-Y"):
|
|
m = moves.get(name) or {}
|
|
ctx.check(m.get("witnessed") is True, "the %s jog was not witnessed: %s", name, m)
|
|
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("local") is False
|
|
and (fc.get("/mode")[1] or {}).get("controller") == "running", 90)
|
|
ctx.check(ok is not None, "the controller is not back in its normal posture")
|
|
after = read_position()
|
|
if before is not None and after is not None:
|
|
ctx.check(all(abs(a - b) <= 2 for a, b in zip(after[:2], before[:2])),
|
|
"the check lost motion: position %s before, %s after (steps)", before, after)
|
|
ctx.log("motion: probe '%s', passes %s, rest %s, moves %s", r.get("probe"), r.get("z_passes"),
|
|
r.get("rest"), {k: (v.get("p2p_lp_x"), v.get("p2p_lp_y")) for k, v in moves.items()})
|
|
|
|
|
|
@test("commission.check-flow-verify", title="The flow check runs as a setup check",
|
|
subsystem="commission", kind="auto", hardware="takeover", est_min=5,
|
|
covers=DARK_COVERS + [("forgectrl", "src/diag.c")],
|
|
requires=["forgectrl.auth", "cooling.aa-offset-calibrate", "cooling.flow-verify"],
|
|
description="POST /wiz/cooling.flow-verify/start drives the flow-verify diagnostic through the "
|
|
"check runner: the diagnostic's phases show as the check's, its result becomes the "
|
|
"check's result with pass true, and the record carries cooling.flow-verify at "
|
|
"version 1. A thin margin recommends the calibration in the record's flags. The "
|
|
"air-assist offset calibration is named first among the prerequisites so a queue "
|
|
"keeps it ahead of every heater tool this check pulls forward.")
|
|
def check_flow_verify(ctx):
|
|
last = run_check(ctx, "cooling.flow-verify", lambda p: None, 600)
|
|
r = last.get("result") or {}
|
|
ctx.check(r.get("pass") is True, "the flow check did not pass: %s", r)
|
|
ctx.log("flow check: threshold %s, flow %s, no-flow %s, thin %s", r.get("threshold"),
|
|
r.get("flow_rise"), r.get("noflow_rise"), r.get("thin_margin"))
|
|
|
|
|
|
@test("commission.cloud-header-capture", title="The cloud header check takes one print's envelope",
|
|
subsystem="commission", kind="operator", hardware="takeover", mode="grbl", est_min=8,
|
|
covers=DARK_COVERS + [("forgectrl", "src/super.c"),
|
|
("python3-gfhardware", "gfhardware/machine.py"),
|
|
("python3-gfhardware", "forgefirm-app/gfcloud.py"),
|
|
("python3-gfhardware", "forgefirm-app/ffmachine.py")],
|
|
requires=["forgectrl.auth", "cloud.mode-switch"], hands=["workstation"],
|
|
steps=["The Glowforge app open on the workstation, signed in to this machine. The test "
|
|
"turns cloud mode on itself and puts it back.",
|
|
"Once the app shows the machine online, place any small design on the bed image "
|
|
"and press Print. That is the only action: the check takes the print's header and "
|
|
"cancels the print before it arms. Do not press the machine's button.",
|
|
"The machine returns to GRBL mode on its own."],
|
|
description="POST /wiz/cloud.header/start with cloud mode on: the wizard writes the "
|
|
"client's one-start capture marker, starts cloud mode in the wizard's posture, "
|
|
"asks for a print, and takes the header the client writes to the run directory "
|
|
"before it cancels the print. The result carries the tag count, the "
|
|
"calibration-bearing tags, the limits, and the machine's own numbers; the log "
|
|
"has the tagged block; the machine is back in GRBL mode with the marker gone.")
|
|
def cloud_header_capture(ctx):
|
|
fc = ctx.forgectrl
|
|
ev = ctx.evidence
|
|
with Restore(ctx, ["cloud_enabled", "homing_mode", "controller_mode"]):
|
|
s = fc.settings() or {}
|
|
if s.get("cloud_enabled") != "1":
|
|
st, body = fc.post("/settings", data={"cloud_enabled": "1"})
|
|
ctx.check(st == 200, "cloud_enabled=1 -> %s %s", st, body)
|
|
ctx.log("cloud mode turned on for the test")
|
|
ctx.notice("Print any small design from the Glowforge app once it shows the machine "
|
|
"online. Do not press the machine's button.")
|
|
last = run_check(ctx, "cloud.header",
|
|
lambda p: "Continue" if p.get("id") == "print" else None, 900)
|
|
ctx.clear_notice()
|
|
r = last.get("result") or {}
|
|
ctx.check(isinstance(r.get("tag_count"), int) and r["tag_count"] > 100,
|
|
"the header carried %s tags", r.get("tag_count"))
|
|
kept = r.get("calibration_tags") or {}
|
|
ctx.check(len(kept) > 10, "only %d calibration-bearing tags kept", len(kept))
|
|
ev["kept"] = sorted(kept)[:40]
|
|
ctx.check(not os.path.exists("/run/gfcloud-capture"), "the capture marker is still down")
|
|
ok = ctx.wait_for(lambda: (fc.get("/mode")[1] or {}).get("mode") == "grbl"
|
|
and (fc.get("/mode")[1] or {}).get("controller") == "running", 120)
|
|
ctx.check(ok is not None, "the machine did not return to GRBL mode")
|
|
ctx.log("header: %d tags, %d kept; job %s", r.get("tag_count", 0), len(kept), r.get("job_id"))
|