mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-29 01:21:16 -07:00
tray_test.py and motion.tray: the crumb tray's mode on the null-sink controller and on the machine
- scripts/bench/tray_test.py, run by the driver's CI: M103 P1 and P0 move Z and its envelope by tray_offset_mm on the lens step grid, tell the sender, write and remove the marker beside the shared config, and ship no step; a missing P, P2, P0.5, P-1, and an axis word are errors that change nothing; the switch behind a move waits for the move; the port's tray op with a sender connected, its count exact, refused out of form, during a program, and at a package's M-code; the mode across a soft reset and a killed controller, a start over the marker taking the lens reference in the tray-out frame; the offset's range; an unreferenced lens; a camera home in both modes handing the runner the same park. The kernel-idle half of the barrier needs the pulse device: a null sink is always idle. - scripts/bench/ctlport_test.py: the dark case carries tray out and in under an open armed window. - forgetest: motion.tray in its own module, suite/tray.py, so no other test's fingerprint moves. It switches the mode both ways through the panel's route and through M103 from a Grbl client and judges each switch: /status's mode, Z, and reach moved by the offset's grid, the port's state, the marker, the reference and its source unchanged, the client told, the kernel's counters unmoved. A jog to the Z the lens read before a switch is refused in the other mode and taken back in the mode found; a setup card started with the tray out is refused in the operator's words; the panel page carries the switch. The mode goes back as found in a finally. It does not restart the controller, which would drop the X and Y reference: persistence is tray_test.py's. - The bench registry and README list tray_test.py. Proof: tray_test.py ALL PASS against the driver with the mode, and six driver copies each missing one part of it fail. The driver's whole CI harness list passes. The forgetest unit suite: 500 tests OK. The coverage lint over the working trees: nothing uncovered, 126 tests. On the bench reference, with the binaries hot-deployed on the fx18 image: motion.tray PASS, and FAIL against the driver whose anchor lost its source word on the second switch. Acceptance. motion.tray is new; the suite and bench files are in no other test's fingerprint.
This commit is contained in:
@@ -304,6 +304,17 @@ TOOLS = [
|
||||
"do the same with the sender's count exact. Imports its sender and port client from "
|
||||
"ctlport_test.py. 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": "tray-test", "title": "Crumb tray mode harness", "script": "tray_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "The crumb tray's mode on the null-sink controller: M103 P1 and P0 move Z and its envelope by "
|
||||
"tray_offset_mm on the lens step grid, tell the sender, write and remove the marker beside the "
|
||||
"shared config, and ship no step; a missing P, a P other than 0 or 1 and an axis word are "
|
||||
"errors; the switch waits for the move before it; the port's tray op, and its refusals during "
|
||||
"a program and at a package's M-code; the mode survives a soft reset and a restart, and a start "
|
||||
"over the marker references Z in the tray-out frame; the offset's range; an unreferenced lens; "
|
||||
"and a camera home in both modes hands the runner the same park. Imports its sender and port "
|
||||
"client from ctlport_test.py. 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"])],
|
||||
|
||||
@@ -33,3 +33,4 @@ from . import updlock # noqa: F401,E402
|
||||
from . import homeoff # noqa: F401,E402
|
||||
from . import bedsize # noqa: F401,E402
|
||||
from . import extsender # noqa: F401,E402
|
||||
from . import tray # noqa: F401,E402
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""The crumb tray's mode, on the machine.
|
||||
|
||||
Its own module, so that no other test's fingerprint moves. With the tray
|
||||
out, the GRBL controller's Z is the focal height above the floor of the cut
|
||||
area: its Z position and its Z range move up by tray_offset_mm on the lens
|
||||
step grid, and nothing moves. The mode persists as the controller's marker
|
||||
in the data directory. That it survives a controller restart is the
|
||||
driver's harness case (tray_test.py): a restart here would drop the X and
|
||||
Y reference, and the machine would not be handed back as it was found.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from ..catalog import test
|
||||
from .. import hw
|
||||
from .motion import (_kernel_position, _port_state, _words, clean_slate, drain_text, machine_idle)
|
||||
|
||||
TRAY_MARKER = "/data/forgefirm/tray.out"
|
||||
LENS_SPM = 2.922 # $102: the lens screw's half-steps per mm
|
||||
SETUP_CARD = "sheet.place" # the first setup card: it asks before it moves anything
|
||||
|
||||
|
||||
def _grid_mm(offset_mm):
|
||||
"""The offset on the lens step grid, the controller's way (lroundf)."""
|
||||
return math.floor(offset_mm * LENS_SPM + 0.5) / LENS_SPM
|
||||
|
||||
|
||||
def _lens(fc):
|
||||
return fc.status().get("lens") or {}
|
||||
|
||||
|
||||
def _judge(ctx, fc, ev, key, mode, found, shift):
|
||||
"""The machine reads the tray `mode`, with Z and the reach moved by
|
||||
`shift` mm from what the test found."""
|
||||
s = fc.status()
|
||||
lens, pos = s.get("lens") or {}, s.get("pos") or {}
|
||||
port = _port_state(fc)
|
||||
rec = ev[key] = {"lens": lens, "pos_z": pos.get("z"), "port_tray": port.get("tray"),
|
||||
"port_z": port.get("mpos", [None] * 3)[2], "marker": os.path.exists(TRAY_MARKER),
|
||||
"home": [s.get("homed_axes"), s.get("home_source")]}
|
||||
ctx.log("%s: %s", key, rec)
|
||||
ctx.check(lens.get("tray") == mode and port.get("tray") == mode,
|
||||
"%s: the status says tray %s and the port %s, not %s", key, lens.get("tray"), port.get("tray"), mode)
|
||||
ctx.check(rec["marker"] == (mode == "out"), "%s: the marker %s is %s with the tray %s", key, TRAY_MARKER,
|
||||
"present" if rec["marker"] else "absent", mode)
|
||||
for k in ("reach_min", "reach_max"):
|
||||
ctx.check(lens.get(k) is not None and abs(lens[k] - (found["lens"][k] + shift)) < 0.011,
|
||||
"%s: %s reads %s, not %.2f", key, k, lens.get(k), found["lens"][k] + shift)
|
||||
ctx.check(pos.get("z") is not None and abs(pos["z"] - (found["pos_z"] + shift)) < 0.006,
|
||||
"%s: the status Z reads %s, not %.2f", key, pos.get("z"), found["pos_z"] + shift)
|
||||
ctx.check(rec["port_z"] is not None and abs(rec["port_z"] - (found["port_z"] + shift)) < 0.002,
|
||||
"%s: the controller's Z reads %s, not %.3f", key, rec["port_z"], found["port_z"] + shift)
|
||||
ctx.check(rec["home"] == found["home"], "%s: the reference changed across the switch: %s, not %s",
|
||||
key, rec["home"], found["home"])
|
||||
|
||||
|
||||
def _tray_route(fc, mode):
|
||||
st, body = fc.post("/motion/tray", params={"tray": mode})
|
||||
return st, _words(body)
|
||||
|
||||
|
||||
def _card_refused(ctx, fc, ev):
|
||||
"""A setup card is refused while the tray is out. The machine is idle a
|
||||
moment after the suite's Grbl client closes; until then the refusal is
|
||||
the idle rule's. A card that started anyway is ended at once: the first
|
||||
one asks before it moves anything."""
|
||||
path = "/wiz/%s/start" % SETUP_CARD
|
||||
deadline = time.time() + 20
|
||||
while True:
|
||||
st, body = fc.post(path)
|
||||
words = _words(body)
|
||||
if st == 200:
|
||||
fc.post("/wiz/%s/abort" % SETUP_CARD)
|
||||
ctx.wait_for(lambda: not (fc.get("/wiz/dark")[1] or {}).get("running"), 20, poll=0.5)
|
||||
if st != 409 or "not idle" not in words or time.time() > deadline:
|
||||
break
|
||||
ctx.sleep(0.5)
|
||||
ev["setup_card"] = [st, words]
|
||||
ctx.log("a setup card started with the tray out -> %s %s", st, words)
|
||||
ctx.check(st == 409 and "crumb tray" in words, "a setup card started with the tray out -> %s %s", st, words)
|
||||
|
||||
|
||||
@test("motion.tray", title="The crumb tray: tray out moves Z and its range up by the offset, and nothing moves",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=2,
|
||||
covers=[("grblhal-glowforge", "src/glowforge_tray.*"), ("grblhal-glowforge", "src/glowforge_homing.*"),
|
||||
("grblhal-glowforge", "src/ctlport.*"), ("grblhal-glowforge", "src/glowforge_io.*"),
|
||||
("forgectrl", "src/tray.*"), ("forgectrl", "src/grblport.*"), ("forgectrl", "src/status.*"),
|
||||
("forgectrl", "src/main.c"), ("forgectrl", "src/wizdark.*"), ("forgectrl", "src/ui/panel.js"),
|
||||
("forgectrl", "src/ui/index.html")],
|
||||
requires=["motion.port-jog"],
|
||||
steps=["Nothing moves; the crumb tray can stay where it is."],
|
||||
description="The tray mode is switched to the other one and back, through the panel's route and "
|
||||
"through M103 from a Grbl client, both ways. Each switch changes the numbers and "
|
||||
"moves nothing: /status says the tray, its Z and the lens reach move by the offset on "
|
||||
"the lens step grid (1.35 in is 100 half-steps, 34.22 mm), the reference and what set it "
|
||||
"stand, the controller port's state "
|
||||
"agrees, the marker in the data directory follows, the client is told [MSG:Tray in] or "
|
||||
"[MSG:Tray out] and its next line draws ok, and the kernel's position and byte counters "
|
||||
"do not move. In the other mode, a jog to the Z the lens read before the switch is "
|
||||
"refused (error:15): the two Z ranges never overlap; back in the mode found, the same "
|
||||
"jog is taken. A setup card started with the tray out is refused in the operator's "
|
||||
"words, with nothing sent. The page "
|
||||
"the panel serves carries the switch. The tray mode is put back as found in a finally. "
|
||||
"The controller is not restarted: that would drop the X and Y reference. That the mode "
|
||||
"survives a restart, the errors of M103, and its wait for the move before it are the "
|
||||
"driver's harness cases.")
|
||||
def tray(ctx):
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
s0 = fc.status()
|
||||
found_mode = (s0.get("lens") or {}).get("tray")
|
||||
ctx.check(found_mode in ("in", "out"), "the status carries no tray mode: %s", s0.get("lens"))
|
||||
other = "out" if found_mode == "in" else "in"
|
||||
p_found = "P1" if found_mode == "out" else "P0"
|
||||
p_other = "P1" if other == "out" else "P0"
|
||||
shift = _grid_mm((s0.get("lens") or {}).get("tray_offset_mm") or 34.29) * (1 if other == "out" else -1)
|
||||
machine_idle(ctx)
|
||||
found = {"lens": s0.get("lens"), "pos_z": (s0.get("pos") or {}).get("z"),
|
||||
"port_z": _port_state(fc)["mpos"][2], "kernel": _kernel_position(),
|
||||
"home": [s0.get("homed_axes"), s0.get("home_source")]}
|
||||
ev["found"] = {"tray": found_mode, "shift_mm": round(shift, 4), **found}
|
||||
ctx.log("found the tray %s; switching to %s moves Z %+.3f mm", found_mode, other, shift)
|
||||
ctx.check(found["pos_z"] is not None, "the status carries no Z")
|
||||
|
||||
st, page = fc.get("/", raw=True)
|
||||
text = page.decode("utf-8", "replace") if isinstance(page, bytes) else str(page)
|
||||
for want in ('id="traybtn"', "function trayToggle", "/motion/tray?tray="):
|
||||
ctx.check(st == 200 and want in text, "the panel page carries no %s", want)
|
||||
|
||||
switched = False
|
||||
try:
|
||||
if found_mode == "out":
|
||||
_card_refused(ctx, fc, ev)
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
ctx.sleep(0.6) # past the port's sender-quiet interval
|
||||
|
||||
# Through the panel's route.
|
||||
st, words = _tray_route(fc, other)
|
||||
ctx.check(st == 200, "POST /motion/tray tray=%s -> %s %s", other, st, words)
|
||||
switched = True
|
||||
said = drain_text(g, 0.5)
|
||||
ctx.check("[MSG:Tray %s]" % other in said, "the client was not told the tray is %s: %r", other, said)
|
||||
_judge(ctx, fc, ev, "route_" + other, other, found, shift)
|
||||
r = g.command("G4 P0")
|
||||
ctx.check(r[-1:] == ["ok"], "the client's line after the switch drew %s", r)
|
||||
|
||||
# The Z the lens read before the switch is outside this mode's range.
|
||||
jog = "$J=G90 G21 Z%.3f F200" % found["port_z"]
|
||||
r = g.command(jog)
|
||||
ev["jog_" + other] = [jog, r]
|
||||
ctx.check(r[-1:] == ["error:15"], "with the tray %s, %s drew %s, not error:15", other, jog, r)
|
||||
g.command("") # the empty line that acknowledges the error
|
||||
|
||||
# Back through M103, from the client.
|
||||
r = g.command("M103 " + p_found)
|
||||
ev["m103_" + found_mode] = r
|
||||
ctx.check(r[-1:] == ["ok"] and "[MSG:Tray %s]" % found_mode in r,
|
||||
"M103 %s drew %s", p_found, r)
|
||||
switched = False
|
||||
_judge(ctx, fc, ev, "m103_" + found_mode, found_mode, found, 0.0)
|
||||
r = g.command(jog)
|
||||
ev["jog_" + found_mode] = [jog, r]
|
||||
ctx.check(r[-1:] == ["ok"], "with the tray %s again, %s drew %s", found_mode, jog, r)
|
||||
machine_idle(ctx)
|
||||
|
||||
# And the other way: M103 out, the route back.
|
||||
r = g.command("M103 " + p_other)
|
||||
ev["m103_" + other] = r
|
||||
ctx.check(r[-1:] == ["ok"] and "[MSG:Tray %s]" % other in r, "M103 %s drew %s", p_other, r)
|
||||
switched = True
|
||||
_judge(ctx, fc, ev, "m103_" + other, other, found, shift)
|
||||
|
||||
if other == "out":
|
||||
_card_refused(ctx, fc, ev)
|
||||
|
||||
st, words = _tray_route(fc, found_mode)
|
||||
ctx.check(st == 200, "POST /motion/tray tray=%s -> %s %s", found_mode, st, words)
|
||||
switched = False
|
||||
_judge(ctx, fc, ev, "route_" + found_mode, found_mode, found, 0.0)
|
||||
finally:
|
||||
if switched:
|
||||
# The route is refused for a moment after the suite's Grbl client closes.
|
||||
first = sys.exc_info()[1]
|
||||
took = ctx.wait_for(lambda: _tray_route(fc, found_mode)[0] == 200 or None, 20, poll=0.5)
|
||||
ctx.log("put the tray back %s -> %s", found_mode, "taken" if took is not None else "not taken")
|
||||
ev["put_back"] = took is not None
|
||||
if first is None:
|
||||
ctx.check(took is not None, "the tray mode is left %s, not put back %s", other, found_mode)
|
||||
machine_idle(ctx)
|
||||
k1 = _kernel_position()
|
||||
ev["kernel"] = [found["kernel"], k1]
|
||||
ctx.check(k1 == found["kernel"], "the kernel moved across the switches: %s -> %s", found["kernel"], k1)
|
||||
ctx.log("PASS: the tray mode switched both ways through the route and through M103, Z and the reach "
|
||||
"moved by %.3f mm and back, nothing moved, and it is %s as found", abs(shift), found_mode)
|
||||
@@ -36,6 +36,7 @@ page's takeover does that; from a host, stop them first.
|
||||
| `ctlport_test.py` | Host-side controller port and line-multiplexer harness (null-sink controller): a scripted sender that counts every `ok` and `error` runs beside the port's one client on `grbl.ctl`. A port jog's status goes to the port and never to the sender, a port error does not reach the sender's parser, a sender line cancels a port jog and gets its own `ok` (a line queued right behind the port's included), plus the refusals, the single client, a CR LF sender, a soft reset, and the client as the dead-man. `python3 ctlport_test.py <grblHAL_glowforge>`. |
|
||||
| `cool_report_test.py` | Host-side cooling report harness (null-sink controller, a stand-in for forgectrl's listener on `FORGECTRL_PORT`): every `POST /cool/state` carries the secret the supervisor hands the controller at its spawn (`GF_REPORT_SECRET`) as `X-ForgeFIRM-Report`; with no secret the header is absent; a value that is not 32 hex digits, a CR LF with a header behind it included, never reaches the wire; and the homing runner the controller starts is handed the secret (it reports in the controller's place during a gfcloud `$H`), and none when the controller has none or a malformed one. `python3 cool_report_test.py <grblHAL_glowforge>`. |
|
||||
| `manual_home_test.py` | Host-side manual homing and motor release harness (null-sink controller, `GFSINK_DUMP` and `GFSINK_ATTR_LOG`): `$H` under `homing_mode = manual` ships nothing and declares X0 Y0, and with a manual home offset declares minus it and jogs dark to the origin before it answers, a jog cancel ending the move short; while the motors are released (`$MD`) every motion source is refused and ships nothing, `$X` and a soft reset do not unlock it, and only `$ME` and a manual `$H` write the energize. Imports its sender and port client from `ctlport_test.py`. `python3 manual_home_test.py <grblHAL_glowforge>`. |
|
||||
| `tray_test.py` | Host-side crumb tray harness (null-sink controller, `GFSINK_DUMP`): `M103 P1` and `P0` move Z and its envelope by `tray_offset_mm` on the lens step grid, tell the sender, write and remove the `tray.out` marker beside the shared config, and ship no step; the errors of `M103`; the switch waits for the move before it; the port's `tray` op and its refusals; the mode across a soft reset and a controller restart; the offset's range; an unreferenced lens; a camera home in both modes. Imports its sender and port client from `ctlport_test.py`. `python3 tray_test.py <grblHAL_glowforge>`. |
|
||||
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the density dose curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m4feeds [S] [F1] [F2]` (the density time base across feeds: one out-and-back line pair per feed at the same S under M4 density, one armed run; the operator reads within-line evenness and reversal darkness at both feeds - M4's velocity scaling is what holds dose per mm through the accel), `m4corner [S] [F]` (M4 velocity-scaled power into corners: a corner-heavy vector pattern at 30 % under M4 density, one armed run; the operator confirms every commanded segment marks - the floor makes a dropout unreachable - and the drill asserts the arm report, one discharge window and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <secs> [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
|
||||
| `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. |
|
||||
| `planner_blocks_test.py` | Host-side planner buffer depth harness (null-sink controller): `$398` written to 400 and to 1000, the controller restarted on the same settings store each time, and it must answer on the port, report the depth in its status report and run a move. A byte-wide ring index once made 255 blocks or more spin the start. Runs in the grblHAL repo's CI. |
|
||||
|
||||
@@ -33,8 +33,8 @@ job after it.
|
||||
8. the port's client is the dead-man: closing it cancels its jog
|
||||
9. every port operation ships dark. Under an open armed window, with M3
|
||||
modal and S above zero, a port jog, a canceled port jog, the refused
|
||||
jogs, and the panel operations add steps to the shipped stream and not
|
||||
one FIRE tick. A jog block carries the modal spindle state, so the
|
||||
jogs, and the panel operations (the tray's mode among them) add steps
|
||||
to the shipped stream and not one FIRE tick. A jog block carries the modal spindle state, so the
|
||||
stream's mask on jogging is the only thing that keeps a jog dark, and
|
||||
this is what holds the port to it
|
||||
10. "sender out" keeps senders from the network out, and only on an idle
|
||||
@@ -593,6 +593,8 @@ def test_port_dark():
|
||||
replies["release"] = s.port.request("release") # refused: a laser job is armed
|
||||
replies["energize"] = s.port.request("energize")
|
||||
replies["home"] = s.port.request("home") # manual: declares X0 Y0, moves nothing
|
||||
replies["tray out"] = s.port.request("tray out") # the Z frame moves, the head does not
|
||||
replies["tray in"] = s.port.request("tray in")
|
||||
replies["jog-after-home"] = s.port.request("jog G91 X6 F1200")
|
||||
s.sender.wait_state("Idle")
|
||||
time.sleep(0.5)
|
||||
@@ -600,6 +602,8 @@ def test_port_dark():
|
||||
steps1, fire1 = s.ticks()
|
||||
if replies["jog"] != "ok" or replies["jog-long"] != "ok" or replies["jog-after-home"] != "ok":
|
||||
fail("[dark] a port jog under the open window was refused: %r" % replies)
|
||||
if replies["tray out"] != "ok" or replies["tray in"] != "ok":
|
||||
fail("[dark] the tray op under the open window was refused: %r" % replies)
|
||||
if steps1 <= steps0:
|
||||
fail("[dark] the port jogs shipped no step: the case would prove nothing")
|
||||
if s.sender.saw("laser disarmed"):
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 514 LLC d/b/a OpenGlow
|
||||
# Written by Scott Wiederhold
|
||||
# https://community.openglow.org
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Host-side verification of the crumb tray's mode (glowforge_tray.c).
|
||||
|
||||
With the tray out the work sits on the floor of the cut area, and Z is the
|
||||
focal height above that floor: the Z position and the Z envelope move up by
|
||||
tray_offset_mm on the lens step grid (1.35" is 100 half-steps). M103 P1 sets
|
||||
the tray out, M103 P0 sets it in, and the controller port's "tray out|in"
|
||||
runs the same line for the panel. Runs the native grblHAL_glowforge binary
|
||||
in null-sink mode, with GFSINK_DUMP capturing the shipped stream, a scripted
|
||||
sender on the Grbl socket, and the port client of ctlport_test.py:
|
||||
|
||||
1. M103 P1 and P0 shift the reported Z by the offset's whole half-steps,
|
||||
tell the sender [MSG:Tray out] and [MSG:Tray in], keep Z referenced,
|
||||
write and remove the marker beside the shared config, and ship no
|
||||
step tick; asking for the mode already set changes nothing and says so
|
||||
2. the envelope moves with the position: with the tray out the ends of
|
||||
the lens's reach, shifted, run and two half-steps past them are
|
||||
refused, and a jog to a tray-in Z is refused; with the tray in again
|
||||
the same jog runs
|
||||
3. a missing P, a P other than 0 or 1, and an axis word on the line are
|
||||
errors (28, 39, 31) that change nothing
|
||||
4. the M-code is a barrier: behind a move it waits for the move to end,
|
||||
and the frame changes only once the machine is idle
|
||||
5. the port's tray op switches the mode with a sender connected, the
|
||||
sender's response count stays exact and it is told the mode; the op
|
||||
is refused out of form (error:invalid), during a program
|
||||
(busy:state), and while a job waits at a package's M-code
|
||||
(busy:mcode)
|
||||
6. the mode survives a soft reset, and a controller restart: a start with
|
||||
the marker present takes the lens reference in the tray-out frame,
|
||||
and one without it in the tray-in frame
|
||||
7. tray_offset_mm sets the shift, held to 13 to 60 mm
|
||||
8. an unreferenced lens shifts the same way, and stays pinned where it
|
||||
reads
|
||||
9. a camera home with the tray out declares Z the offset higher, and
|
||||
hands the runner the same park: the lens parks in the same place
|
||||
|
||||
The kernel-idle half of the barrier (the wait a package's M-code makes)
|
||||
needs the pulse device: a null sink is always idle.
|
||||
|
||||
Usage: tray_test.py [path/to/grblHAL_glowforge]
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ctlport_test as ct # noqa: E402
|
||||
|
||||
BIN = ct.BIN
|
||||
fail = ct.fail
|
||||
PORT = 2395
|
||||
SPM = 2.922
|
||||
EDGE = 3.35 # the edge's grid step is 10
|
||||
EDGE_K = 10
|
||||
SHIFT_K = 100 # the default offset, 34.29 mm
|
||||
DOWN, UP = 10, 12 # the fallback window
|
||||
|
||||
|
||||
def zk(k):
|
||||
return k / SPM
|
||||
|
||||
|
||||
class Session:
|
||||
"""One null-sink controller over its own state directory. `conf` is the
|
||||
shared config; with `referenced` the lens marker forgectrl leaves is
|
||||
there, so the controller takes the lens reference at its start."""
|
||||
|
||||
def __init__(self, conf=None, referenced=True):
|
||||
self.workdir = tempfile.mkdtemp(prefix="tray-")
|
||||
self.conf = os.path.join(self.workdir, "forgefirm.conf")
|
||||
self.dump = os.path.join(self.workdir, "stream.bin")
|
||||
self.marker = os.path.join(self.workdir, "tray.out")
|
||||
self.write_conf(conf or {})
|
||||
if referenced:
|
||||
with open(os.path.join(self.workdir, "lens.home"), "w") as f:
|
||||
f.write("edge 0 3 3\n")
|
||||
verdict = os.path.join(self.workdir, "cooling.state")
|
||||
self.stop = threading.Event()
|
||||
self.pub = threading.Thread(target=ct.publish_verdicts, args=(verdict, self.stop), daemon=True)
|
||||
self.pub.start()
|
||||
env = dict(os.environ, GFHOME_CONF=self.conf, GF_STATE_DIR=self.workdir,
|
||||
GF_VERDICT_FILE=verdict, GFSINK_DUMP=self.dump, FFLOG_STDERR="1")
|
||||
env.pop("GFSINK", None)
|
||||
env.pop("GF_SWITCH_FILE", None)
|
||||
self.env = env
|
||||
self.start()
|
||||
|
||||
def write_conf(self, conf):
|
||||
with open(self.conf, "w") as f:
|
||||
f.write("cool_fan_grace_s = 0\nlaser_disarm_s = 1\nlens_hall_edge_z_mm = %.2f\n" % EDGE)
|
||||
for k, v in conf.items():
|
||||
f.write("%s = %s\n" % (k, v))
|
||||
|
||||
def start(self):
|
||||
self.proc = subprocess.Popen([BIN, "-p", str(PORT)], cwd=self.workdir, env=self.env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
self.sender = ct.Sender(PORT)
|
||||
path = os.path.join(self.workdir, "grbl.ctl")
|
||||
for _ in range(50):
|
||||
if os.path.exists(path):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
fail("the controller did not create %s" % path)
|
||||
self.port = ct.PortClient(path)
|
||||
# The lens reference is taken on the first pass of the realtime hook.
|
||||
time.sleep(0.3)
|
||||
|
||||
def restart(self, hard):
|
||||
"""End the controller (SIGKILL when hard) and start another over the
|
||||
same state directory and shared config."""
|
||||
self.port.close()
|
||||
self.sender.close()
|
||||
if hard:
|
||||
self.proc.kill()
|
||||
else:
|
||||
self.proc.send_signal(signal.SIGINT)
|
||||
self.proc.wait(10)
|
||||
self.start()
|
||||
|
||||
def state(self):
|
||||
return self.port.state()
|
||||
|
||||
def z(self):
|
||||
return self.state()["mpos"][2]
|
||||
|
||||
def tray(self):
|
||||
return self.state()["tray"]
|
||||
|
||||
def ticks(self):
|
||||
"""Step ticks shipped so far (a byte with bit 7 set is a power byte)."""
|
||||
try:
|
||||
with open(self.dump, "rb") as f:
|
||||
data = f.read()
|
||||
except OSError:
|
||||
return 0
|
||||
return sum(1 for b in data if not b & 0x80 and b & 0x25)
|
||||
|
||||
def messages(self, text, want=None):
|
||||
"""How many [MSG:<text>] lines the sender has had. With `want`, wait
|
||||
up to a second for that many: the port answers at once, and the
|
||||
sender's lines go out on the controller's next pass."""
|
||||
end = time.time() + (1.0 if want is not None else 0)
|
||||
while True:
|
||||
n = self.sender.saw("[MSG:%s]" % text)
|
||||
if want is None or n >= want or time.time() > end:
|
||||
return n
|
||||
time.sleep(0.02)
|
||||
|
||||
def quiet(self):
|
||||
time.sleep(0.45)
|
||||
|
||||
def close(self):
|
||||
self.port.close()
|
||||
self.sender.close()
|
||||
self.proc.send_signal(signal.SIGINT)
|
||||
try:
|
||||
self.proc.wait(5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
self.stop.set()
|
||||
self.pub.join(2)
|
||||
shutil.rmtree(self.workdir, ignore_errors=True)
|
||||
|
||||
|
||||
def expect_z(s, k, what):
|
||||
z = s.z()
|
||||
if abs(z - zk(k)) > 0.002:
|
||||
fail("[%s] Z reads %.3f, not %.3f (%d half-steps)" % (what, z, zk(k), k))
|
||||
|
||||
|
||||
def refused(s, line):
|
||||
"""Send a line that must be refused, and return its error, acknowledged
|
||||
with an empty line (the core holds a parser error against the next
|
||||
g-code line until then)."""
|
||||
r = s.sender.send(line)
|
||||
if r.startswith("error"):
|
||||
if s.sender.send("") != "ok":
|
||||
fail("the empty line that acknowledges %s did not get ok" % r)
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def jog_to(s, k):
|
||||
"""An absolute jog to half-step k: its response."""
|
||||
r = s.sender.send("$J=G90 G21 Z%.4f F200" % zk(k))
|
||||
if r == "ok":
|
||||
s.sender.wait_state("Idle")
|
||||
elif s.sender.send("") != "ok":
|
||||
fail("the empty line after a refused jog did not get ok")
|
||||
return r
|
||||
|
||||
|
||||
def test_switch(s):
|
||||
expect_z(s, EDGE_K, "switch")
|
||||
st = s.state()
|
||||
if st["tray"] != "in" or not st["homed"] & 4:
|
||||
fail("[switch] not tray in with Z referenced at the start: %r" % st)
|
||||
ticks = s.ticks()
|
||||
if s.sender.send("M103 P1") != "ok":
|
||||
fail("[switch] M103 P1 was refused")
|
||||
st = s.state()
|
||||
if st["tray"] != "out" or not st["homed"] & 4:
|
||||
fail("[switch] not tray out with Z referenced after M103 P1: %r" % st)
|
||||
expect_z(s, EDGE_K + SHIFT_K, "switch")
|
||||
if not s.messages("Tray out", 1):
|
||||
fail("[switch] the sender was not told [MSG:Tray out]")
|
||||
if not os.path.exists(s.marker):
|
||||
fail("[switch] M103 P1 left no marker beside the shared config")
|
||||
if s.sender.send("M103 P1") != "ok" or s.tray() != "out":
|
||||
fail("[switch] a second M103 P1 was refused or changed the mode")
|
||||
expect_z(s, EDGE_K + SHIFT_K, "switch: the mode already set")
|
||||
if s.messages("Tray out", 2) != 2:
|
||||
fail("[switch] the mode already set was not told again")
|
||||
if s.sender.send("M103 P0") != "ok" or s.tray() != "in":
|
||||
fail("[switch] M103 P0 did not set the tray in")
|
||||
expect_z(s, EDGE_K, "switch: back in")
|
||||
if os.path.exists(s.marker) or not s.messages("Tray in", 1):
|
||||
fail("[switch] M103 P0 left the marker or did not tell the sender")
|
||||
time.sleep(0.3)
|
||||
if s.ticks() != ticks:
|
||||
fail("[switch] the switches shipped %d step ticks" % (s.ticks() - ticks))
|
||||
print("PASS [switch]: M103 P1 moved Z up %d half-steps and P0 back, the sender was told, the "
|
||||
"marker followed, and no step shipped" % SHIFT_K)
|
||||
|
||||
|
||||
def test_envelope(s):
|
||||
s.sender.send("M103 P1")
|
||||
base = EDGE_K + SHIFT_K
|
||||
for k, want, what in ((base + UP, "ok", "the top of the reach"),
|
||||
(base + UP + 2, "error:15", "two half-steps over the top"),
|
||||
(base - DOWN, "ok", "the bottom of the reach"),
|
||||
(base - DOWN - 2, "error:15", "two half-steps under the bottom"),
|
||||
(EDGE_K + UP, "error:15", "a tray-in Z")):
|
||||
r = jog_to(s, k)
|
||||
if r != want:
|
||||
fail("[envelope] tray out: a jog to %s (Z %.3f) got %r, not %s" % (what, zk(k), r, want))
|
||||
if jog_to(s, base) != "ok":
|
||||
fail("[envelope] the jog back to the edge was refused")
|
||||
s.sender.send("M103 P0")
|
||||
expect_z(s, EDGE_K, "envelope")
|
||||
if jog_to(s, EDGE_K + UP) != "ok":
|
||||
fail("[envelope] tray in: the jog to the same tray-in Z was refused")
|
||||
if jog_to(s, EDGE_K) != "ok":
|
||||
fail("[envelope] the jog back to the edge was refused")
|
||||
print("PASS [envelope]: with the tray out the shifted reach ran to its ends, past them and a "
|
||||
"tray-in Z were refused; with the tray in the tray-in Z ran")
|
||||
|
||||
|
||||
def test_errors(s):
|
||||
ticks = s.ticks()
|
||||
for line, want in (("M103", "error:28"), ("M103 P2", "error:39"), ("M103 P0.5", "error:39"),
|
||||
("M103 P-1", "error:39"), ("M103 P1 Z5", "error:31"), ("M103 P1 X5", "error:31")):
|
||||
r = refused(s, line)
|
||||
if r != want:
|
||||
fail("[errors] %r got %r, not %s" % (line, r, want))
|
||||
if s.tray() != "in" or os.path.exists(s.marker):
|
||||
fail("[errors] %r changed the mode" % line)
|
||||
expect_z(s, EDGE_K, "errors")
|
||||
time.sleep(0.3)
|
||||
if s.ticks() != ticks:
|
||||
fail("[errors] the refused lines shipped %d step ticks" % (s.ticks() - ticks))
|
||||
print("PASS [errors]: a missing P, P2, P0.5, P-1 and an axis word were refused and changed nothing")
|
||||
|
||||
|
||||
def test_barrier(s):
|
||||
"""Behind a move the switch waits for it to end."""
|
||||
x0 = s.state()["mpos"][0]
|
||||
s.sender.send("G91 G1 X10 F300") # about two seconds
|
||||
n = s.sender.count()
|
||||
s.sender.sock.sendall(b"M103 P1\n")
|
||||
seen_run = False
|
||||
end = time.time() + 10
|
||||
while time.time() < end:
|
||||
st = s.port.state()
|
||||
if st["state"] == "Run":
|
||||
seen_run = True
|
||||
if st["tray"] != "in" or abs(st["mpos"][2] - zk(EDGE_K)) > 0.002:
|
||||
fail("[barrier] the frame changed while the move before M103 ran: %r" % st)
|
||||
if st["tray"] == "out":
|
||||
break
|
||||
time.sleep(0.05)
|
||||
if not seen_run:
|
||||
fail("[barrier] the move was never seen running, so the case proves nothing")
|
||||
st = s.port.state()
|
||||
if st["tray"] != "out" or st["state"] != "Idle" or abs(st["mpos"][0] - x0 - 10) > 0.01:
|
||||
fail("[barrier] the switch did not land after the move ended: %r" % st)
|
||||
for _ in range(100):
|
||||
if s.sender.count() > n:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
s.sender.send("G90")
|
||||
s.sender.send("M103 P0")
|
||||
print("PASS [barrier]: M103 behind a move changed the frame only once the move had ended")
|
||||
|
||||
|
||||
def test_port(s):
|
||||
s.sender.wait_state("Idle")
|
||||
s.quiet()
|
||||
before = s.sender.count()
|
||||
told = s.messages("Tray out")
|
||||
r = s.port.request("tray out")
|
||||
if r != "ok" or s.tray() != "out":
|
||||
fail("[port] tray out got %r" % r)
|
||||
expect_z(s, EDGE_K + SHIFT_K, "port")
|
||||
if s.port.request("tray out") != "ok":
|
||||
fail("[port] a second tray out was refused")
|
||||
if s.messages("Tray out", told + 2) != told + 2:
|
||||
fail("[port] the sender was not told the mode each time")
|
||||
if s.port.request("tray sideways") != "error:invalid":
|
||||
fail("[port] a tray op out of form was not refused")
|
||||
if s.sender.count() != before:
|
||||
fail("[port] the sender got %d responses for the port's tray ops" % (s.sender.count() - before))
|
||||
|
||||
s.sender.send("G91 G1 X-10 F300")
|
||||
time.sleep(0.4)
|
||||
r = s.port.request("tray in")
|
||||
if r != "busy:state" or s.tray() != "out":
|
||||
fail("[port] tray in during a program got %r" % r)
|
||||
s.sender.wait_state("Idle")
|
||||
s.sender.send("G90")
|
||||
|
||||
if s.port.request("mcodes 160") != "ok":
|
||||
fail("[port] the M-code table was refused")
|
||||
n = s.sender.count()
|
||||
s.sender.sock.sendall(b"M160 P1\n")
|
||||
for _ in range(100):
|
||||
st = s.state()
|
||||
if st.get("mcode"):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
fail("[port] no M-code waits: %r" % s.state())
|
||||
r = s.port.request("tray in")
|
||||
if r != "busy:mcode" or s.tray() != "out":
|
||||
fail("[port] tray in while a job waits at an M-code got %r" % r)
|
||||
if s.port.request("mcode_result %d ok" % st["mcode"]["seq"]) != "ok":
|
||||
fail("[port] the M-code's answer was refused")
|
||||
for _ in range(100):
|
||||
if s.sender.count() > n:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
s.port.request("mcodes -")
|
||||
s.quiet()
|
||||
before = s.sender.count()
|
||||
if s.port.request("tray in") != "ok" or s.tray() != "in":
|
||||
fail("[port] tray in was refused")
|
||||
expect_z(s, EDGE_K, "port")
|
||||
if s.sender.count() != before or s.sender.send("G0 X0") != "ok":
|
||||
fail("[port] the sender's count was disturbed by the tray op")
|
||||
s.sender.wait_state("Idle")
|
||||
print("PASS [port]: tray out and in through the port with a sender connected, its count exact "
|
||||
"and the mode told; refused out of form, during a program, and at a package's M-code")
|
||||
|
||||
|
||||
def test_persistence(s):
|
||||
s.sender.send("M103 P1")
|
||||
s.sender.realtime(b"\x18")
|
||||
time.sleep(1.0)
|
||||
s.sender.send("$X")
|
||||
if s.tray() != "out":
|
||||
fail("[persistence] a soft reset set the tray in")
|
||||
expect_z(s, EDGE_K + SHIFT_K, "persistence: soft reset")
|
||||
s.restart(hard=True)
|
||||
st = s.state()
|
||||
if st["tray"] != "out" or not st["homed"] & 4:
|
||||
fail("[persistence] a restart over the marker came up %r" % st)
|
||||
expect_z(s, EDGE_K + SHIFT_K, "persistence: restart with the tray out")
|
||||
if jog_to(s, EDGE_K + SHIFT_K + UP) != "ok" or jog_to(s, EDGE_K + UP) != "error:15":
|
||||
fail("[persistence] the restarted controller's envelope is not the tray-out one")
|
||||
jog_to(s, EDGE_K + SHIFT_K)
|
||||
if s.port.request("tray in") != "ok":
|
||||
fail("[persistence] tray in was refused after the restart")
|
||||
expect_z(s, EDGE_K, "persistence: in after the restart")
|
||||
s.restart(hard=False)
|
||||
if s.tray() != "in":
|
||||
fail("[persistence] a restart with no marker came up tray out")
|
||||
expect_z(s, EDGE_K, "persistence: restart with the tray in")
|
||||
print("PASS [persistence]: the tray out survived a soft reset and a controller kill, the new "
|
||||
"controller referenced Z in the tray-out frame, and a start with no marker in the tray-in frame")
|
||||
|
||||
|
||||
def test_offset():
|
||||
for key, k in (("20", 58), ("5", 38), ("99", 175)):
|
||||
s = Session(conf={"tray_offset_mm": key})
|
||||
try:
|
||||
s.sender.send("M103 P1")
|
||||
expect_z(s, EDGE_K + k, "offset %s" % key)
|
||||
s.sender.send("M103 P0")
|
||||
expect_z(s, EDGE_K, "offset %s: back in" % key)
|
||||
finally:
|
||||
s.close()
|
||||
print("PASS [offset]: tray_offset_mm 20 moved Z 58 half-steps, 5 was held to 13 mm (38), "
|
||||
"99 to 60 mm (175)")
|
||||
|
||||
|
||||
def test_unreferenced():
|
||||
s = Session(referenced=False)
|
||||
try:
|
||||
z0 = s.z()
|
||||
k0 = round(z0 * SPM)
|
||||
s.sender.send("M103 P1")
|
||||
expect_z(s, k0 + SHIFT_K, "unreferenced")
|
||||
for step in ("1", "-1"):
|
||||
r = s.sender.send("$J=G91 G21 Z%s F200" % step)
|
||||
if r != "error:15":
|
||||
fail("[unreferenced] a Z jog of %s got %r: the pinned envelope did not follow" % (step, r))
|
||||
s.sender.send("")
|
||||
s.sender.send("M103 P0")
|
||||
expect_z(s, k0, "unreferenced: back in")
|
||||
finally:
|
||||
s.close()
|
||||
print("PASS [unreferenced]: an unreferenced lens shifted the same way and stayed pinned where it read")
|
||||
|
||||
|
||||
def test_camera_home():
|
||||
s = Session(conf={"homing_mode": "gfcloud"})
|
||||
park = os.path.join(s.workdir, "park.log")
|
||||
s.write_conf({"homing_mode": "gfcloud", "gfcloud_home_cmd": "echo $GFHOME_PARK_HALF_STEPS >> %s" % park})
|
||||
try:
|
||||
z = {}
|
||||
for mode in ("P0", "P1"):
|
||||
s.sender.send("M103 " + mode)
|
||||
if s.sender.send("$H") != "ok":
|
||||
fail("[camera-home] $H with M103 %s was refused" % mode)
|
||||
s.sender.wait_state("Idle")
|
||||
z[mode] = s.z()
|
||||
with open(park) as f:
|
||||
parks = f.read().split()
|
||||
if len(parks) != 2 or parks[0] != parks[1]:
|
||||
fail("[camera-home] the runner was handed parks %r, not the same one twice" % parks)
|
||||
k = EDGE_K + int(parks[0])
|
||||
if abs(z["P0"] - zk(k)) > 0.002 or abs(z["P1"] - zk(k + SHIFT_K)) > 0.002:
|
||||
fail("[camera-home] Z after the homes read %r, not %.3f and %.3f" % (z, zk(k), zk(k + SHIFT_K)))
|
||||
if jog_to(s, EDGE_K + SHIFT_K + UP) != "ok" or jog_to(s, EDGE_K + UP) != "error:15":
|
||||
fail("[camera-home] the envelope after a tray-out home is not the tray-out one")
|
||||
s.sender.send("M103 P0")
|
||||
finally:
|
||||
s.close()
|
||||
print("PASS [camera-home]: the park handed to the runner was %s half-steps in both modes, and Z "
|
||||
"after the home read %.3f tray in, %.3f tray out" % (parks[0], z["P0"], z["P1"]))
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(BIN):
|
||||
fail("no controller binary at %s" % BIN)
|
||||
s = Session()
|
||||
try:
|
||||
if s.sender.send("$102") != "ok" or not s.sender.saw("$102=2.922"):
|
||||
fail("$102 is not 2.922: this harness needs the default")
|
||||
test_switch(s)
|
||||
test_envelope(s)
|
||||
test_errors(s)
|
||||
test_barrier(s)
|
||||
test_port(s)
|
||||
test_persistence(s)
|
||||
finally:
|
||||
s.close()
|
||||
test_offset()
|
||||
test_unreferenced()
|
||||
test_camera_home()
|
||||
print("ALL PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user