diff --git a/forgetest/forgetest/baseline.py b/forgetest/forgetest/baseline.py index 5daad57..b5141aa 100644 --- a/forgetest/forgetest/baseline.py +++ b/forgetest/forgetest/baseline.py @@ -217,17 +217,25 @@ def read_position(): return None -def position_quantized(was, now): +def counter_steps_per_mm(): + """The scale of the X/Y position counters: the kernel counts the + microsteps of the mode it runs (cnc/x_mode), so x16 and x32 count two + and four times the x8 steps for the same millimeter. The x8 scale when + the attribute is unreadable.""" + return XY_STEPS_PER_MM_OF.get(hw.sysfs_int("cnc/x_mode"), XY_STEPS_PER_MM) + + +def position_quantized(was, now, spm=XY_STEPS_PER_MM): """True when two step-counter readings differ by no more than the 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.""" + the same integer is chance. spm is the counters' scale (the mode's).""" if was is None or now is None or now[2] != was[2]: 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) + return (abs(now[0] - was[0]) / spm <= POSITION_DEADBAND_MM and + abs(now[1] - was[1]) / spm <= POSITION_DEADBAND_MM) def read_ring_residue(): @@ -450,6 +458,9 @@ class Baseline: preserved state to hand back (post) - None compares nothing.""" left = [] self.mode = None + # the counters' scale is the mode the run counted under, read + # before the kernel side puts the settings' mode back + self._counter_spm = counter_steps_per_mm() self._forgectrl_side(left, captured) self._kernel_side(left) self._lamp_side(left) @@ -693,13 +704,14 @@ class Baseline: self.log("leds/%s: target 0, brightness %s; the fade from a level the machine " "itself ended, not a leftover" % (name, read_led(name, "brightness"))) - def _return_head(self, was, now): + def _return_head(self, was, now, spm=XY_STEPS_PER_MM): """Jog the head back along its own path by the kernel-measured X/Y - delta (Z is never touched), through the GRBL controller. Bounded: - beyond RETURN_MAX_MM per axis, or without a running GRBL - controller, the counters are reported and left.""" - dx = (now[0] - was[0]) / XY_STEPS_PER_MM - dy = (now[1] - was[1]) / XY_STEPS_PER_MM + delta (Z is never touched), through the GRBL controller. spm is + the counters' scale (the mode's). Bounded: beyond RETURN_MAX_MM + per axis, or without a running GRBL controller, the counters are + reported and left.""" + dx = (now[0] - was[0]) / spm + dy = (now[1] - was[1]) / spm if abs(dx) < 0.02 and abs(dy) < 0.02: return "unrestorable (Z only)" if now[2] != was[2] else "restored" if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM: @@ -787,8 +799,9 @@ 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(): - act = self._return_head(was, now) - if position_quantized(was, now): + spm = self._counter_spm + act = self._return_head(was, now, spm) + if position_quantized(was, now, spm): self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, " "not a leftover" % (now, was, act, POSITION_DEADBAND_MM)) else: diff --git a/forgetest/forgetest/suite/cloud.py b/forgetest/forgetest/suite/cloud.py index 58abd87..39fdee2 100644 --- a/forgetest/forgetest/suite/cloud.py +++ b/forgetest/forgetest/suite/cloud.py @@ -227,6 +227,11 @@ def gfhome_homing(ctx, ev, g): # homed: the counters are re-anchored at the corner, where the head stays ctx.counters_rezeroed() ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing") + # The driver answers $H when the session ends; that ok (and the + # session's messages) sit in the buffer behind the status reports and + # would pass for the reply to the caller's next command. + ack = " | ".join(ln.strip() for ln in g.drain().splitlines() if ln.strip()) + ctx.log("$H acknowledged: %s", ack[:160] or "(nothing buffered)") @test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt " diff --git a/forgetest/forgetest/suite/motion.py b/forgetest/forgetest/suite/motion.py index dded12c..150d1fb 100644 --- a/forgetest/forgetest/suite/motion.py +++ b/forgetest/forgetest/suite/motion.py @@ -764,10 +764,13 @@ def _return_x(ctx, delta_mm): "locked); the process resumed from the hang recovers on a soft reset and an unlock " "(the alarm is critical, so $X alone is refused; the reset acknowledges the " "fault to the stream and clears the stale ring) and moves again without a " - "restart. forgectrl " - "restart mid-move: the busy controller finishes the move unmanaged and the new " - "daemon retakes supervision at idle. After each drill the head is jogged back " - "by the kernel-measured distance.") + "restart. A short SIGSTOP (100 ms, inside the kernel's 200 ms queue) mid-move: " + "no underrun, but the step producer comes back later than its lead and its " + "late events clamp; unarmed that is a warning in the controller's log and the " + "move completes with every step (the armed case faults, proven on the host). " + "forgectrl restart mid-move: the busy controller finishes the move unmanaged " + "and the new daemon retakes supervision at idle. After each drill the head is " + "jogged back by the kernel-measured distance.") def deadman(ctx): import os as _os import signal as _signal @@ -897,6 +900,47 @@ def deadman(ctx): x1 = _kernel_x_mm(ctx) _return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None) + # ---- 2b. a short hang (inside the kernel queue): late events clamp, a warning, the move completes + m2 = wait_running(10) + pid2 = m2["pid"] + x0 = _kernel_x_mm(ctx) + underruns0 = hw.sysfs_int("cnc/underruns", 0) + log0 = _log_offset(GRBLHAL_LOG) + with ctx.grbl() as g: + clean_slate(ctx, g) + g.command("G91") + g.command("G1X30F300", timeout=0.5) # ~6 s of motion + ctx.sleep(1.0) + _os.kill(pid2, _signal.SIGSTOP) + time.sleep(0.1) + _os.kill(pid2, _signal.SIGCONT) + ctx.log("SIGSTOP 100 ms sent to controller pid %d mid-move", pid2) + peak, states, st = wait_idle(ctx, g, 30) + g.command("G90") + machine_idle(ctx) # the kernel's tail plays out behind grbl's Idle + warned = None + t0 = time.time() + while time.time() - t0 < 5 and not warned: + lines = _log_lines(GRBLHAL_LOG, log0, "late events clamped") + warned = lines[-1] if lines else None + if not warned: + ctx.sleep(0.5) + x1 = _kernel_x_mm(ctx) + ev["short_stall"] = {"states": states, "underruns": hw.sysfs_int("cnc/underruns", 0) - underruns0, + "warning": warned, "kernel_dx_mm": round((x1 - x0), 3) if (x0 is not None and x1 is not None) else None, + "state_after": (st or {}).get("state")} + ctx.log("short stall: states %s, underruns +%s, warning %r, kernel dx %s mm", states, + ev["short_stall"]["underruns"], warned, ev["short_stall"]["kernel_dx_mm"]) + ctx.check("TIMEOUT" not in states and str((st or {}).get("state", "")).startswith("Idle"), + "the move did not complete after the short stall (states %s)", states) + ctx.check(ev["short_stall"]["underruns"] == 0, "the short stall drained the ring (underruns +%s)", + ev["short_stall"]["underruns"]) + ctx.check(warned, "the controller did not warn about the clamped events after the short stall") + ctx.check(ev["short_stall"]["kernel_dx_mm"] is not None and abs(ev["short_stall"]["kernel_dx_mm"] - 30.0) < 0.3, + "the kernel counted %s mm for a 30 mm move after the short stall", ev["short_stall"]["kernel_dx_mm"]) + ctx.check(latch_locked(), "latch unlocked after the short stall") + _return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None) + # ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle m2 = wait_running(10) pid2 = m2["pid"] @@ -934,8 +978,79 @@ def deadman(ctx): x1 = _kernel_x_mm(ctx) _return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None) machine_idle(ctx) - ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, restart retook supervision (pid %s)", - respawn_s, halt_s, m3.get("pid")) + ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, short stall warned and completed, " + "restart retook supervision (pid %s)", respawn_s, halt_s, m3.get("pid")) + + +@test("motion.soft-limits", title="After a home the bed is the X/Y envelope", + subsystem="motion", kind="auto", mode="grbl", est_min=3, + covers=_MOTION_COVERS, requires=["motion.jog-roundtrip", "cloud.mode-switch"], + steps=["Bed clear, lid closed. The test homes the machine through the web service if it is " + "not homed (about a minute), then sends moves the controller must refuse."], + description="The machine has no limit switches, so the core's $20 cannot be turned on and " + "the X/Y soft limits are the driver's: off while the position is not trusted, on " + "after a home, when the envelope is the bed ($130 by $131 from the home corner). " + "Homed, a program move 5 mm past X max or Y max, or past the near edge, raises " + "ALARM:2 before any motion (the kernel counters do not move), a jog past the bed " + "is refused with error 15, and a move inside the bed runs. The Z envelope is the " + "lens window, as before.") +def soft_limits(ctx): + from .cloud import gfhome_homing + fc = ctx.forgectrl + ev = ctx.evidence + with ctx.grbl() as g: + clean_slate(ctx, g) + if not fc.status().get("homed"): + gfhome_homing(ctx, ev, g) + ctx.check(fc.status().get("homed"), "the machine is not homed") + x_travel = float(grbl_setting(g, "$130")) + y_travel = float(grbl_setting(g, "$131")) + ev["travel"] = {"x": x_travel, "y": y_travel} + k0 = kernel_xy_mm(ctx) + + def refused(cmd): + lines = g.command(cmd, timeout=2) + ctx.sleep(0.5) + text = "\n".join(lines) + g.drain() + k1 = kernel_xy_mm(ctx) + moved = max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1])) + st = g.status_report()["state"] + rec = {"cmd": cmd, "reply": lines, "alarm": "ALARM:2" in text, "moved_mm": round(moved, 3), "state": st} + ctx.log("%s", rec) + ctx.check(rec["alarm"], "%s was not refused with ALARM:2: %s", cmd, lines) + ctx.check(moved < 0.05, "%s moved the kernel %.3f mm before the alarm", cmd, moved) + g.realtime(0x18) + ctx.sleep(1.5) + g.drain() + unlock = g.command("$X") + ctx.check(unlock and unlock[-1] == "ok", "$X after the soft-limit alarm: %s", unlock) + st = g.status_report()["state"] + ctx.check(st.startswith("Idle"), "controller is %s after the recovery", st) + return rec + + ev["refused"] = [refused("G90 G1 X%.1f F600" % (x_travel + 5)), + refused("G90 G1 Y%.1f F600" % (y_travel + 5)), + refused("G90 G1 X-1 F600")] + # Homed still: the soft-limit alarm and its reset keep the reference. + ctx.check(fc.status().get("homed"), "the soft-limit alarm and the reset un-homed the machine") + jog = g.command("$J=G91X%.1fF1200" % (x_travel + 5)) + ev["jog"] = jog + ctx.check(any(l.startswith("error:15") for l in jog), "a jog past the bed was not refused with error 15: %s", jog) + # The core answers the line after an error with that error again + # until an empty line clears it (the sender's acknowledgment). + g.command("") + g.command("G90 G1 X10 Y10 F600", timeout=0.5) + peak, states, st = wait_idle(ctx, g, 20) + ev["inside"] = states + ctx.check("TIMEOUT" not in states, "a move inside the bed did not run") + g.command("G90 G1 X0 Y0 F600", timeout=0.5) + wait_idle(ctx, g, 20) + k1 = kernel_start(ctx) # at rest: grbl's Idle leads the kernel's tail + ev["kernel_back_mm"] = [round(k1[0] - k0[0], 3), round(k1[1] - k0[1], 3)] + ctx.check(max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1])) < 0.1, "the head did not come back to the corner: %s", + ev["kernel_back_mm"]) + ctx.log("PASS: X max, Y max and X min refused with ALARM:2 and no motion, the jog refused with " + "error 15, a move inside the bed ran") # ------------------------------------------------- lid / button (the factory's) diff --git a/forgetest/tests/test_baseline.py b/forgetest/tests/test_baseline.py index 6e3737d..025b4ef 100644 --- a/forgetest/tests/test_baseline.py +++ b/forgetest/tests/test_baseline.py @@ -126,6 +126,21 @@ class BaselineTests(unittest.TestCase): self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action) self.assertEqual(items["position"].found, [1000, 0, 0]) + def test_the_counters_are_read_at_the_modes_scale(self): + # 6400 steps is 30 mm at x32 (a return within the bound; on the + # host it stops at the missing controller) and 120 mm at x8 + # (beyond the bound). Read at the x8 scale, an x32 machine's + # displaced head is never jogged back. + b = self.bl() + cap = b.capture() + self._attr("cnc/x_mode", "32") + self._pos(6400, 0, 0) + items = {x.item: x for x in b.enforce("post", captured=cap)} + self.assertEqual(items["position"].action, "unrestorable: no running GRBL controller") + self._attr("cnc/x_mode", "8") + items = {x.item: x for x in b.enforce("post", captured=cap)} + self.assertEqual(items["position"].action, "unrestorable: 120.0/0.0 mm exceeds 100 mm") + def test_a_held_controller_is_reset_before_the_return_jog(self): # a pause test that failed while held leaves the controller in # Hold, which refuses a jog: the baseline resets out of it first @@ -434,6 +449,13 @@ class PositionDeadbandTests(unittest.TestCase): self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0])) self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0])) + def test_the_dead_band_scales_with_the_counters(self): + # the same step count is a quarter of the distance at x32 + over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2 + x32 = baseline.XY_STEPS_PER_MM_OF[32] + self.assertTrue(baseline.position_quantized([0, 0, 0], [over, 0, 0], x32)) + self.assertFalse(baseline.position_quantized([0, 0, 0], [4 * over, 0, 0], x32)) + def test_z_is_exact_because_the_return_never_moves_it(self): self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1])) diff --git a/scripts/bench/laser_stream_test.py b/scripts/bench/laser_stream_test.py index 0ff8ad2..3d4d329 100644 --- a/scripts/bench/laser_stream_test.py +++ b/scripts/bench/laser_stream_test.py @@ -107,6 +107,17 @@ over TCP, then checks the dumps against the kernel feeder contract: clock between two of its file reads, lit to the stop like rule 24: no dark cut runs out while the client waits for its next read; the engine's return resumes the cut lit + 28. a scheduling stall of the step producer (GFSINK_STALL_MS: the + producer is kept off the CPU longer than its lead) maps events + behind the ship cursor. Inside an armed window that is a fault: + ALARM:17, the window closed, the stream ended dark, and no step + burst shipped (the clamped events are never compressed onto later + bytes). Unarmed it is a warning in the log and the move completes + with every step in the stream, compressed + 29. a stall of the kernel write (GFSINK_WRITE_STALL_MS: the sink holds + the write the way a full ring holds it) stalls nothing but the + shipper: the producer keeps mapping at wall pace behind it, so the + job completes lit with no clamp, no burst, and a dark end The analog sessions select the reference mode through the config; on hardware the controller ignores it (density is the only product model - @@ -462,13 +473,15 @@ def publish_verdicts(path, stop): def run_session(name, steps, conf=None, workdir=None, keep=False, - arm_required=True): + arm_required=True, env_extra=None): """Launch the controller, run the job steps, return the dump bytes. Pass workdir + keep to chain launches over one settings file: the core precomputes the spindle PWM mapping once, when the spindle is enabled, so a $35 written at runtime only takes effect on the next - controller start.""" + controller start. env_extra adds to the controller's environment + (the host stall knobs). The controller's log (stderr) is kept in + run_session.err.""" if workdir is None: workdir = tempfile.mkdtemp(prefix="laser-test-") dump = os.path.join(workdir, "stream.bin") @@ -476,6 +489,7 @@ def run_session(name, steps, conf=None, workdir=None, keep=False, latch_log = os.path.join(workdir, "latch.log") env = dict(os.environ, GFSINK_DUMP=dump, GF_VERDICT_FILE=verdict, GFSINK_LATCH_LOG=latch_log, FFLOG_STDERR="1") + env.update(env_extra or {}) # The lens reference the daemon leaves before a controller starts: # forgectrl sweeps the carriage onto the hall edge and marks it, and # the controller opens the Z envelope on that mark. Without one Z @@ -558,6 +572,10 @@ def run_session(name, steps, conf=None, workdir=None, keep=False, proc.wait(5) except subprocess.TimeoutExpired: proc.kill() + try: + run_session.err = (proc.stderr.read() or b"").decode(errors="replace") + except (OSError, ValueError): + run_session.err = "" stop.set() pub.join(2) VERDICT_MODE["mode"] = "clean" @@ -1363,6 +1381,89 @@ def main(): "before the stop, lit after the return (%d fire ticks), latch %s" % (lit_lead, lit_lead * 1e3 / MACHINE_TICK_HZ, lit_after, seq)) + # --- rules 28 and 29: the stalls ------------------------------------- + # The step density the job plans: F3000 at 53.333 steps/mm is 2667 + # steps/s, 9.5 per 100 ticks. A clamp compresses the late events onto + # consecutive bytes, which is what the window count catches. + STALL_JOB = ["G90", "G21", "G1 X150 F3000"] + ARMED_STALL_JOB = ["G90", "G21", "M3 S500", "G1 X150 F3000"] + PLANNED_PER_100 = 3000.0 / 60.0 * STEPS_PER_MM / MACHINE_TICK_HZ * 100.0 + BURST_LIMIT = int(PLANNED_PER_100 * 1.3) + 1 + + def max_steps_per_window(ticks, win=100): + worst = 0 + for i in range(0, max(1, len(ticks) - win), win // 2): + worst = max(worst, sum(1 for t in ticks[i:i + win] if t & 0x01)) + return worst + + # 28a: the armed stall is a fault. The job ends in ALARM:17; a reset + # and an unlock bring the controller back so the session can end. + steps = ARMED_STALL_JOB + [("wait_state", "Alarm", 6.0), ("expect_text", "laser disarmed", 3.0), + ("rt", b"\x18"), ("sleep", 1.0), "$X"] + data = run_session("stall-armed", steps, conf=DENSITY_CONF_FLOORED, + env_extra={"GFSINK_STALL_MS": "300"}) + text, err = run_session.text, run_session.err + if "ALARM:17" not in text: + fail("[stall-armed] a producer stall inside the armed window did not fault (no ALARM:17)") + if "late events while the laser is armed" not in err: + fail("[stall-armed] the fault was not named in the log") + ticks = tick_bytes(data) + burst = max_steps_per_window(ticks) + if burst > BURST_LIMIT: + fail("[stall-armed] %d steps in a 100-tick window (planned %.1f): the clamped events " + "were shipped as a burst" % (burst, PLANNED_PER_100)) + check_termination("stall-armed", data) + seq = latch_transitions(run_session.latch) + if seq != ["unlock", "lock"]: + fail("[stall-armed] latch ownership sequence %s, expected unlock (arm), lock (the fault)" % seq) + print("PASS [stall-armed]: a 300 ms producer stall while armed faulted with ALARM:17, " + "%d steps per 100 ticks at most (planned %.1f), ends dark, latch %s" + % (burst, PLANNED_PER_100, seq)) + + # 28b: unarmed, the same stall is a warning and the move completes with + # every step, compressed. + data = run_session("stall-unarmed", STALL_JOB, conf=DENSITY_CONF_FLOORED, + arm_required=False, env_extra={"GFSINK_STALL_MS": "300"}) + text, err = run_session.text, run_session.err + if "ALARM" in text: + fail("[stall-unarmed] an unarmed stall raised an alarm") + if "late events clamped" not in err: + fail("[stall-unarmed] the clamp was not warned about in the log") + ticks = tick_bytes(data) + total = sum(1 for t in ticks if t & 0x01) + want = round(150 * STEPS_PER_MM) + if abs(total - want) > 2: + fail("[stall-unarmed] %d X steps in the stream, expected %d: steps were lost" % (total, want)) + burst = max_steps_per_window(ticks) + if burst <= BURST_LIMIT: + fail("[stall-unarmed] no step burst in the stream (%d per 100 ticks): the stall knob " + "did not stall the producer" % burst) + print("PASS [stall-unarmed]: the same stall unarmed was warned, the move completed with " + "all %d steps, the clamp visible as %d steps per 100 ticks" % (total, burst)) + + # 29: a write stall stalls only the shipper. The producer keeps its + # pace behind it, so nothing clamps and nothing bursts. + data = run_session("write-stall", ARMED_STALL_JOB + [WAIT_IDLE, "M5"], conf=DENSITY_CONF_FLOORED, + env_extra={"GFSINK_WRITE_STALL_MS": "300"}) + text, err = run_session.text, run_session.err + if "ALARM" in text: + fail("[write-stall] a write stall raised an alarm") + if "clamped" in err: + fail("[write-stall] a write stall clamped producer events: the back-off held the lock") + ticks = tick_bytes(data) + total = sum(1 for t in ticks if t & 0x01) + if abs(total - want) > 2: + fail("[write-stall] %d X steps in the stream, expected %d" % (total, want)) + burst = max_steps_per_window(ticks) + if burst > BURST_LIMIT: + fail("[write-stall] %d steps in a 100-tick window (planned %.1f): a burst" % (burst, PLANNED_PER_100)) + lit = count_fire(data) + if lit < 0.8 * want: + fail("[write-stall] the cut ran dark through the stall (%d fire ticks)" % lit) + check_termination("write-stall", data) + print("PASS [write-stall]: a 300 ms write stall left the producer on pace: all %d steps, " + "%d per 100 ticks at most, %d fire ticks, ends dark" % (total, burst, lit)) + # --- rule 22: a jog never fires, whatever the modal spindle says ---- # The arm flow runs on the M3 (window open), the modal spindle is on # at S1000, and the jogs come from Idle: exactly what a sender's Fire diff --git a/scripts/bench/z_envelope_test.py b/scripts/bench/z_envelope_test.py index 65c3d3a..3382af5 100644 --- a/scripts/bench/z_envelope_test.py +++ b/scripts/bench/z_envelope_test.py @@ -38,6 +38,17 @@ settings and places the edge on its step grid: wider window runs to its ends, two half-steps past either alarms 9. a stop count out of range (41) falls back on its side alone +The X and Y soft limits are the driver's as well: off while the +position is not trusted (the core's $20 cannot be turned on with $22 +off), on after a home, when the envelope is the bed. A scripted home +(homing_mode = gfcloud with a runner that exits 0, the host hook) sets +them: + + 10. after the home a program move past X or Y max raises ALARM:2 + before any motion, a jog past it is refused with error 15, and a + move inside the bed runs; a stream fault or a controller start + drops them again with the reference + The binary keeps its settings in EEPROM.DAT in the working directory, so each run starts from defaults in a temporary directory and leaves nothing behind. @@ -255,6 +266,51 @@ def referenced_cases(): s.close() +def homed_cases(): + """After a scripted gfcloud home the bed is the X/Y envelope.""" + conf = {"lens_hall_edge_z_mm": "3.35", "homing_mode": "gfcloud", "gfcloud_home_cmd": "true"} + s = Session(conf=conf, referenced=True) + try: + free_in_xy(s, "before the home") + x_travel = float(s.setting("$130")) + y_travel = float(s.setting("$131")) + reply = s.send("$H", 5.0) + if "ok" not in reply: + fail("the scripted home was refused: %r" % reply) + s.wait_idle() + st = s.send("?") + m = re.search(r"MPos:(-?[\d.]+),(-?[\d.]+)", st) + check(m and abs(float(m.group(1))) < 0.01 and abs(float(m.group(2))) < 0.01, + "homed: X and Y sit at the origin", "homed: X/Y are not at the origin: %r" % st) + check(s.move("G90 G0 X%.1f" % (x_travel + 5)), "homed: X past the bed alarms", + "homed: a move past X max ran") + s.wait_idle() + check(s.move("G90 G0 Y%.1f" % (y_travel + 5)), "homed: Y past the bed alarms", + "homed: a move past Y max ran") + s.wait_idle() + check(s.move("G90 G0 X-1"), "homed: X past the near edge alarms", + "homed: a move past X min ran") + s.wait_idle() + reply = s.send("$J=G91X%.1fF1200" % (x_travel + 5), 1.5) + check("error:15" in reply, "homed: a jog past the bed is refused with error 15", + "homed: a jog past the bed was not refused with error 15: %r" % reply) + # The core answers the line after an error with that error again + # until an empty line (or a $ command) clears it: the sender's + # acknowledgment at compatibility level 0. + s.send("", 1.0) + check(not s.move("G90 G0 X10 Y10"), "homed: a move inside the bed runs", + "homed: a move inside the bed was blocked") + s.wait_idle() + # A settings write does not drop them: the driver re-applies the + # mask after the core clears it. + s.write_setting("$20=0") + check(s.move("G90 G0 X%.1f" % (x_travel + 5)), "homed: X past the bed still alarms after a $20 write", + "homed: a $20 write freed X") + s.wait_idle() + finally: + s.close() + + def main(): if not os.path.isfile(BIN): fail("no controller binary at %s" % BIN) @@ -285,6 +341,7 @@ def main(): finally: s.close() referenced_cases() + homed_cases() print("PASS z_envelope_test")