Files
forgefirm/scripts/bench/manual_home_test.py
T
ScottW514 b494bbecb5 homing.manual and the host harness: the manual home jogs to an offset origin
homing.manual runs two homes. With manual_home_x and _y unset, $H
plays no byte and declares X0 Y0 (the checks it had). With 5 and 3, $H
declares the head at minus them and jogs it to X0 Y0 before it answers:
the sender is told the declared position, the kernel plays the move,
Grbl and forgectrl read X0 Y0, and a jog behind the origin draws
error:15. The test restores homing_mode and both offsets as found, and
returns the head by what the machine read, so a move cut short still
comes home. The change stays inside the test's own function.

manual_home_test.py: case 7 is the offset's new meaning (declared at
minus the offset, the move dark under a modal M3, the port's home, the
clamps); a new case cancels the move with a jog cancel and expects it
to stop short with $H saying where; case 8 reads the declared position
from the sender's message. The bench README says so.

Proof: manual_home_test.py ALL PASS against the driver change (ten
cases), and its cancel case fails against a build with the driver's
cancel hook left out. On the bench reference, fx14 with the driver
change and this suite file in place of the image's, homing.manual PASS
in campaign c-20260926000059-e731: with no offset no byte and X0 Y0;
with 5 and 3 the sender was told X-5.002 Y-3.000, the kernel played
1067 and 640 steps, Grbl and forgectrl read X0 Y0, error:15 behind the
origin; settings restored and the baseline clean. The forgetest unit
tests pass.

Acceptance: homing.manual is the test; its covers are unchanged (the
driver's src/**, forgectrl's status and main.c).
2026-09-26 10:02:10 -04:00

543 lines
25 KiB
Python

#!/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 manual homing provider and the motor release.
Runs the native grblHAL_glowforge binary in null-sink mode with GFSINK_DUMP
capturing the shipped byte stream and GFSINK_ATTR_LOG capturing the sysfs
writes the hardware would get (the release is the X and Y step currents at 0). An operator's hands are on a released gantry,
so the rule these cases pin is that nothing moves it and nothing but the
operator's own $ME or a manual $H energizes it:
1. $H with homing_mode = manual ships no step, declares X0 Y0 where the
head stands, marks X and Y homed and turns their soft limits on, leaves
Z where it was, and tells the sender the home was set by hand
2. $H is refused while a program runs
3. $MD writes the release, drops the X and Y reference at once, and locks
the machine in the alarm state; it is refused under an open armed window
4. while released, every motion source is refused and ships nothing: a
sender jog, a sender G0, a program line, a port jog, and a homing runner;
$X cannot unlock it and a soft reset does not either
5. only $ME and a manual $H write the energize, each exactly once; after
$ME the position is still unknown, and after the $H it is homed
6. the port's panel operations (release, energize, home) do the same with
a sender connected, and the sender's response count stays exact; home
is refused unless homing_mode = manual
7. manual_home_x and manual_home_y: how far in front of the stop blocks
the origin lies, never negative. With an offset the head at the blocks
is declared at minus it and jogs to the origin, dark, before $H
answers, through the sender and through the port; the envelope starts
at the origin, and an out-of-range offset is clamped; a jog cancel
ends the move short of the origin and $H says where it stopped
9. a controller that starts over a released gantry (the marker in the
state directory) comes up locked and writes no current until $ME
8. each provider reads its own offsets and no other's: with both pairs set,
a manual home declares minus manual_home_* and a camera home declares
gfcloud_home_*, which may be negative, with the envelope reaching back
to it
Usage: manual_home_test.py [path/to/grblHAL_glowforge]
"""
import os
import re
import shutil
import signal
import subprocess
import tempfile
import threading
import time
from ctlport_test import BIN, PortClient, Sender, fail, publish_verdicts
PORT = 2396
NOTICE = "Manual home: position set where the head was placed"
RELEASED = "Motors released"
RUNNER_MARK = "runner-ran"
class Session:
def __init__(self):
self.workdir = tempfile.mkdtemp(prefix="manual-home-")
self.conf = os.path.join(self.workdir, "forgefirm.conf")
self.dump = os.path.join(self.workdir, "stream.bin")
self.attr_log = os.path.join(self.workdir, "attr.log")
self.set_mode("manual")
verdict = os.path.join(self.workdir, "cooling.state")
self.stop = threading.Event()
self.pub = threading.Thread(target=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,
GFSINK_ATTR_LOG=self.attr_log, FFLOG_STDERR="1")
env.pop("GFSINK", None)
env.pop("GF_SWITCH_FILE", None)
self.env = env
self.proc = subprocess.Popen([BIN, "-p", str(PORT)], cwd=self.workdir, env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.sender = Sender(PORT)
path = os.path.join(self.workdir, "grbl.ctl")
for _ in range(50):
if os.path.exists(path):
break
time.sleep(0.1)
self.port = PortClient(path)
# A controller writes the hold currents at its start; what the cases
# count comes after that.
self.base = 0
start = self.attr_writes()
if start != [("33", "5")]:
fail("the controller's start wrote %r to the step currents, not the hold currents" % start)
self.base = len(start)
def set_mode(self, mode, extra=""):
"""The driver re-reads the file at every $H."""
with open(self.conf, "w") as f:
f.write("cool_fan_grace_s = 0\nlaser_disarm_s = 1\nhoming_mode = %s\n"
"gfcloud_home_cmd = touch %s\n%s"
% (mode, os.path.join(self.workdir, RUNNER_MARK), extra))
def motion_ticks(self):
"""Step and FIRE 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 & 0x35)
def fire_ticks(self):
"""FIRE ticks shipped so far."""
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 & 0x10)
def attr_writes(self):
"""The X and Y step-current writes the hardware would have got, in
order, as (x, y) pairs: ("0", "0") is a release, ("33", "5") the hold
currents, which is what an energize writes."""
vals = {"pic/x_step_current": [], "pic/y_step_current": []}
try:
with open(self.attr_log) as f:
for line in f:
parts = line.split()
if len(parts) == 2 and parts[0] in vals:
vals[parts[0]].append(parts[1])
except OSError:
pass
return list(zip(vals["pic/x_step_current"], vals["pic/y_step_current"]))[self.base:]
def respawn(self, hard=True):
"""End the controller (SIGKILL when hard: it gets no chance to tidy
up) and start another over the same state directory."""
self.port.close()
self.sender.close()
if hard:
self.proc.kill()
else:
self.proc.send_signal(signal.SIGINT)
self.proc.wait(10)
self.proc = subprocess.Popen([BIN, "-p", str(PORT)], cwd=self.workdir, env=self.env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.sender = Sender(PORT)
time.sleep(0.5)
self.port = PortClient(os.path.join(self.workdir, "grbl.ctl"))
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 declared(s):
"""The position the last manual home with an offset told the sender the
head stood at, on the step grid, or None."""
with s.sender.lock:
lines = list(s.sender.other)
for line in reversed(lines):
m = re.search(r"Manual home: the head is at X(-?[0-9.]+) Y(-?[0-9.]+); moving it to the origin", line)
if m:
return float(m.group(1)), float(m.group(2))
return None
def refused(s, line):
"""Send a line that must be refused, and return its error. The core holds
a parser error against every g-code line that follows until an empty line
acknowledges it (the streaming contract), so the acknowledgment is sent
here: the next line is then judged on its own merits."""
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 test_manual_home(s):
s.sender.send("G91")
s.sender.send("G0 X20 Y10") # away from wherever X0 Y0 was
s.sender.wait_state("Idle")
before = s.port.state()
if abs(before["mpos"][0]) < 1:
fail("[manual-home] the head did not move before the home: %r" % before)
if before["homed"] & 3:
fail("[manual-home] X or Y homed before any home: %r" % before)
if s.sender.send("$J=G91 X-100 F3000") != "ok": # unhomed: no X soft limit yet
fail("[manual-home] an unhomed jog past X0 was refused")
s.sender.wait_state("Idle")
time.sleep(0.5)
ticks = s.motion_ticks()
z_before = s.port.state()["mpos"][2]
if s.sender.send("$H") != "ok":
fail("[manual-home] $H under manual did not get ok")
time.sleep(0.5)
st = s.port.state()
if s.motion_ticks() != ticks:
fail("[manual-home] $H shipped %d motion ticks" % (s.motion_ticks() - ticks))
if st["mpos"][0] != 0 or st["mpos"][1] != 0:
fail("[manual-home] X and Y are not 0 after the home: %r" % st)
if st["homed"] & 3 != 3:
fail("[manual-home] X and Y are not marked homed: %r" % st)
if st["mpos"][2] != z_before:
fail("[manual-home] Z changed across the home: %r -> %r" % (z_before, st["mpos"][2]))
if st["state"] != "Idle":
fail("[manual-home] not idle after the home: %r" % st)
if not s.sender.saw(NOTICE):
fail("[manual-home] the sender was not told the home was set by hand")
r = refused(s, "$J=G91 X-5 F600") # off the bed: the soft limit is on now
if r != "error:15":
fail("[manual-home] a jog past X0 after the home got %r, not error:15" % r)
print("PASS [manual-home]: $H shipped nothing, declared X0 Y0, homed X and Y with soft "
"limits on, kept Z, and told the sender")
def test_home_refused_in_cycle(s):
s.sender.send("G90")
s.sender.send("G1 X40 F300")
time.sleep(0.5)
if s.port.state()["state"] != "Run":
fail("[home-in-cycle] the program is not running: %r" % s.port.state())
r = refused(s, "$H")
if r is None:
fail("[home-in-cycle] $H during a program was accepted")
if s.port.state()["homed"] & 3 != 3:
fail("[home-in-cycle] the refused $H dropped the reference")
s.sender.wait_state("Idle", timeout=15.0)
print("PASS [home-in-cycle]: $H during a program got %s and changed nothing" % r)
def test_release_refused_armed(s):
s.sender.send("M3 S100")
s.sender.send("G1 X45 F1500")
s.sender.wait_state("Idle") # motion done, the armed window still open
if not s.sender.saw("laser armed"):
fail("[release-armed] the laser line did not arm, so the case proves nothing")
r = refused(s, "$MD")
if r is None:
fail("[release-armed] $MD under an open armed window was accepted")
if s.attr_writes():
fail("[release-armed] the refused $MD wrote the release: %r" % s.attr_writes())
s.sender.send("M5")
time.sleep(2.5) # past laser_disarm_s: the window closes
print("PASS [release-armed]: $MD under an open armed window got %s and wrote nothing" % r)
def test_release_locks(s):
if s.sender.send("$MD") != "ok":
fail("[release] $MD at idle was refused")
st = s.port.state()
if s.attr_writes() != [("0", "0")]:
fail("[release] the release was not written once: %r" % s.attr_writes())
if not st["released"] or st["state"] != "Alarm" or st["homed"] & 3:
fail("[release] not released, locked and unreferenced: %r" % st)
if not st["homed"] & 4:
fail("[release] Z lost its reference: %r" % st)
if not s.sender.saw(RELEASED):
fail("[release] the sender was not told")
ticks = s.motion_ticks()
x0 = st["mpos"][0]
for line in ("$J=G91 X5 F600", "G0 X5", "G1 X5 F500", "G91 G1 Y3 F500"):
if refused(s, line) is None:
fail("[release] the sender's %r was accepted while released" % line)
s.quiet()
r = s.port.request("jog G91 X5 F600")
if r != "busy:released":
fail("[release] a port jog got %r while released" % r)
if refused(s, "$X") is None:
fail("[release] $X was accepted while released")
s.set_mode("gfcloud")
r = refused(s, "$H")
s.set_mode("manual")
if r is None or os.path.exists(os.path.join(s.workdir, RUNNER_MARK)):
fail("[release] a homing runner was not refused while released (%r)" % r)
s.sender.realtime(b"\x18")
time.sleep(1.0)
r = refused(s, "$X")
st = s.port.state()
if r is None or st["state"] != "Alarm" or not st["released"]:
fail("[release] a soft reset and $X unlocked a released machine: %r %r" % (r, st))
if refused(s, "G0 X5") is None:
fail("[release] G0 after a soft reset was accepted while released")
time.sleep(0.5)
if s.motion_ticks() != ticks or s.port.state()["mpos"][0] != x0:
fail("[release] something moved while released")
if s.attr_writes() != [("0", "0")]:
fail("[release] something other than $ME or $H wrote the step currents: %r" % s.attr_writes())
print("PASS [release]: released, locked in alarm, X and Y unreferenced, Z kept; a sender "
"jog, G0, program lines, a port jog, a homing runner, $X and a soft reset all "
"refused; nothing shipped, nothing energized")
def test_energize(s):
if s.sender.send("$ME") != "ok":
fail("[energize] $ME was refused")
st = s.port.state()
if s.attr_writes() != [("0", "0"), ("33", "5")]:
fail("[energize] the energize was not written once: %r" % s.attr_writes())
if st["released"] or st["state"] != "Idle" or st["homed"] & 3:
fail("[energize] not energized, idle and still unreferenced: %r" % st)
if s.sender.send("$ME") != "ok" or s.attr_writes() != [("0", "0"), ("33", "5")]:
fail("[energize] a second $ME wrote again: %r" % s.attr_writes())
if s.sender.send("$J=G91 X1 F600") != "ok":
fail("[energize] a jog after $ME was refused")
s.sender.wait_state("Idle")
s.sender.send("$MD")
if s.sender.send("$H") != "ok":
fail("[energize] a manual $H while released was refused")
st = s.port.state()
if s.attr_writes() != [("0", "0"), ("33", "5"), ("0", "0"), ("33", "5")]:
fail("[energize] the manual $H did not energize exactly once: %r" % s.attr_writes())
if st["released"] or st["state"] != "Idle" or st["homed"] & 3 != 3:
fail("[energize] not energized, idle and homed after the manual $H: %r" % st)
print("PASS [energize]: $ME energized once and left X and Y unreferenced; a manual $H "
"energized once and homed")
def test_port_panel_ops(s):
s.sender.wait_state("Idle")
s.quiet()
before = s.sender.count()
for op, want in (("release", True), ("energize", False), ("release", True)):
if s.port.request(op) != "ok":
fail("[port] %s was refused" % op)
if s.port.state()["released"] != want:
fail("[port] %s left released=%r" % (op, not want))
s.set_mode("gfcloud")
r = s.port.request("home")
s.set_mode("manual")
if r != "error:mode" or os.path.exists(os.path.join(s.workdir, RUNNER_MARK)):
fail("[port] home under gfcloud got %r" % r)
if s.port.request("home") != "ok":
fail("[port] home under manual was refused")
st = s.port.state()
if st["released"] or st["homed"] & 3 != 3 or st["state"] != "Idle":
fail("[port] not energized, homed and idle after the port's home: %r" % st)
if s.sender.count() != before:
fail("[port] the sender got %d responses for the port's commands"
% (s.sender.count() - before))
if s.sender.send("G0 X1") != "ok":
fail("[port] the sender's next line did not get ok")
print("PASS [port]: release, energize and home work through the port with a sender "
"connected, home is refused outside manual mode, and the sender's count is exact")
def test_restart_while_released(s):
"""A controller that dies under a released gantry is replaced by one that
takes the release over before it writes its first current: it comes up
locked, the hold currents are never written, and $ME still ends it."""
s.sender.wait_state("Idle")
if s.sender.send("$MD") != "ok":
fail("[restart] $MD was refused")
before = s.attr_writes()
marker = os.path.join(s.workdir, "motors.released")
if not os.path.exists(marker):
fail("[restart] the release left no marker in the state directory")
s.respawn(hard=True)
st = s.port.state()
if not st["released"] or st["state"] != "Alarm":
fail("[restart] the new controller did not take the release over: %r" % st)
started = s.attr_writes()[len(before):]
if started != [("0", "0")]:
fail("[restart] the new controller's start wrote %r to a released gantry, not 0 and 0" % started)
if refused(s, "$X") is None or refused(s, "$J=G91 X5 F600") is None:
fail("[restart] the new controller let a released machine be unlocked or jogged")
if s.sender.send("$ME") != "ok":
fail("[restart] $ME was refused after the restart")
st = s.port.state()
if st["released"] or st["state"] != "Idle" or s.attr_writes()[-1] != ("33", "5") or os.path.exists(marker):
fail("[restart] $ME did not energize and clear the marker: %r %r" % (st, s.attr_writes()[-1:]))
s.respawn(hard=False)
st = s.port.state()
if st["released"] or st["state"] != "Idle":
fail("[restart] a controller started with no marker came up released: %r" % st)
print("PASS [restart]: a controller killed under a released gantry was replaced by one that came "
"up locked and wrote 0 to X and Y at its start; $ME energized and cleared the marker; the next start was normal")
def test_stop_block_offsets(s):
"""manual_home_x and _y are how far in front of the stop blocks the origin
lies, never negative: the head at the blocks is declared at minus them and
jogs to the origin, dark, before $H answers, and the envelope starts at
the origin."""
s.sender.wait_state("Idle")
s.set_mode("manual", "manual_home_x = 12.5\nmanual_home_y = 8\n")
s.sender.send("M3 S100") # a modal beam the jog must not carry
ticks, fire = s.motion_ticks(), s.fire_ticks()
if s.sender.send("$H") != "ok":
fail("[offsets] $H under manual with offsets was refused")
st = s.port.state() # $H answers once the head has stopped
s.sender.send("M5")
if st["state"] != "Idle" or st["mpos"][0] != 0 or st["mpos"][1] != 0:
fail("[offsets] the head was not idle at the origin when $H answered: %r" % st)
if st["homed"] & 3 != 3:
fail("[offsets] X and Y are not marked homed: %r" % st)
d = declared(s)
if d is None or abs(d[0] + 12.5) > 0.01 or abs(d[1] + 8.0) > 0.01:
fail("[offsets] the sender was told the head stood at %r, not (-12.5, -8)" % (d,))
if s.motion_ticks() == ticks:
fail("[offsets] the move to the origin shipped nothing")
if s.fire_ticks() != fire:
fail("[offsets] the move to the origin shipped %d FIRE ticks" % (s.fire_ticks() - fire))
if s.sender.send("$J=G91 X5 Y5 F3000") != "ok":
fail("[offsets] a jog from the origin onto the bed was refused")
s.sender.wait_state("Idle")
if s.sender.send("$J=G91 X-5 Y-5 F3000") != "ok":
fail("[offsets] the jog back to the origin was refused")
s.sender.wait_state("Idle")
for line in ("$J=G91 X-1 F600", "$J=G91 Y-1 F600"):
r = refused(s, line)
if r != "error:15":
fail("[offsets] %s, behind the origin, got %r, not error:15" % (line, r))
s.set_mode("manual", "manual_home_x = -10.3\nmanual_home_y = 1e9\n")
if s.sender.send("$H", timeout=30.0) != "ok":
fail("[offsets] $H with out-of-range offsets was refused")
st = s.port.state()
if st["mpos"][0] != 0 or st["mpos"][1] != 0:
fail("[offsets] the head did not end at the origin: %r" % st["mpos"][:2])
d = declared(s)
if d is None or d[0] != 0 or abs(d[1] + 279.0) > 0.01:
fail("[offsets] a negative offset and an oversized one declared %r, not (0, -279)" % (d,))
# The panel's Set home here: the port answers once the head has stopped,
# and the sender's count stays exact.
s.set_mode("manual", "manual_home_x = 12.5\nmanual_home_y = 8\n")
s.quiet()
before = s.sender.count()
if s.port.request("home") != "ok":
fail("[offsets] the port's home with offsets was refused")
st = s.port.state()
if st["state"] != "Idle" or st["mpos"][0] != 0 or st["mpos"][1] != 0:
fail("[offsets] the head was not idle at the origin when the port's home answered: %r" % st)
if s.sender.count() != before:
fail("[offsets] the sender got %d responses for the port's home" % (s.sender.count() - before))
s.set_mode("manual")
print("PASS [offsets]: a manual home declared (-12.5, -8) and jogged to the origin before $H "
"answered, dark under a modal M3; the envelope started at the origin; a negative offset "
"became 0 and an oversized one the axis travel; the port's home answered at the origin")
def test_origin_jog_cancel(s):
"""A jog cancel during a manual home's move to the origin ends the move at
once, as it ends any jog, though the core's main loop is waiting for $H:
$H answers with the head short of the origin, and the sender is told
where it stopped."""
s.sender.wait_state("Idle")
s.set_mode("manual", "manual_home_y = 250\n")
n = s.sender.count()
s.sender.sock.sendall(("$H" + s.sender.eol).encode())
time.sleep(0.4) # into a move of about 1.6 s
s.sender.realtime(b"\x85")
end = time.time() + 5.0
while time.time() < end and s.sender.count() == n:
time.sleep(0.01)
with s.sender.lock:
r = s.sender.responses[n] if len(s.sender.responses) > n else None
st = s.port.state()
s.set_mode("manual")
if r != "ok":
fail("[origin-cancel] $H got %r" % r)
if st["state"] != "Idle" or st["mpos"][1] > -10.0:
fail("[origin-cancel] the jog cancel did not stop the move short of the origin: %r" % st)
if not s.sender.saw("Manual home: the move to the origin stopped at"):
fail("[origin-cancel] the sender was not told where the move stopped")
print("PASS [origin-cancel]: a jog cancel 0.4 s into the move to the origin stopped it at Y%.1f; "
"$H answered ok and said where it stopped" % st["mpos"][1])
def test_offsets_belong_to_their_provider(s):
"""Both pairs set at once: a manual home declares minus manual_home_* and
never looks at gfcloud_home_*, and a camera home (a stand-in runner that
exits 0) declares gfcloud_home_* and never looks at manual_home_*. A camera
home may lie behind the origin, and the envelope reaches back to it."""
both = "manual_home_x = 7\nmanual_home_y = 9\ngfcloud_home_x = -4.5\ngfcloud_home_y = 3.25\n"
s.sender.wait_state("Idle")
s.set_mode("manual", both)
if s.sender.send("$H") != "ok":
fail("[own-offsets] the manual home was refused")
d = declared(s)
if d is None or abs(d[0] + 7.0) > 0.01 or abs(d[1] + 9.0) > 0.01:
fail("[own-offsets] a manual home declared %r, not its own (-7, -9)" % (d,))
s.set_mode("gfcloud", both)
if s.sender.send("$H", timeout=30.0) != "ok":
fail("[own-offsets] the camera home (stand-in runner) was refused")
s.sender.wait_state("Idle")
st = s.port.state()
if abs(st["mpos"][0] + 4.5) > 0.01 or abs(st["mpos"][1] - 3.25) > 0.01:
fail("[own-offsets] a camera home declared %r, not its own (-4.5, 3.25)" % st["mpos"][:2])
if s.sender.send("$J=G91 X2 F3000") != "ok":
fail("[own-offsets] a jog from a camera home behind the origin was refused")
s.sender.wait_state("Idle")
if s.sender.send("$J=G91 X-2 F3000") != "ok":
fail("[own-offsets] the jog back to the camera home was refused")
s.sender.wait_state("Idle")
r = refused(s, "$J=G91 X-1 F600")
if r != "error:15":
fail("[own-offsets] a jog behind the camera home got %r, not error:15" % r)
r = refused(s, "$J=G91 Y-4 F600")
if r != "error:15":
fail("[own-offsets] a jog to Y-0.75, off the bed, got %r, not error:15" % r)
s.set_mode("manual")
print("PASS [own-offsets]: with both pairs set, a manual home declared (-7, -9) and a camera home "
"(-4.5, 3.25); the envelope reached back to the camera home on X and stopped at the origin on Y")
def main():
if not os.path.exists(BIN):
fail("no controller binary at %s" % BIN)
s = Session()
try:
test_manual_home(s)
test_home_refused_in_cycle(s)
test_release_refused_armed(s)
test_release_locks(s)
test_energize(s)
test_port_panel_ops(s)
test_restart_while_released(s)
test_stop_block_offsets(s)
test_origin_jog_cancel(s)
test_offsets_belong_to_their_provider(s)
finally:
s.close()
print("ALL PASS")
if __name__ == "__main__":
main()