forgetest: the camera-home tests hand the head back where they found it

homing.cloud-offsets, setup.check-envelope and cloud.mode-switch let the
service move the head, and ended with it where the service left it: at
the camera home, or, in cloud.mode-switch, under the camera, where the
service's re-hunt had taken it. Each told the baseline the counters had
been re-zeroed at the starting position, so the hand-back saw nothing to
do. No counter reading can say where the head was found: every service
motion zeroes the counters at its start, and the home and every
controller start zero them again.

suite/homeoff.py now holds the helpers for a test that lets the service
move the head:

- session_travel sums a client's own record of each motion's end ("end
  positions (x, y, z)", the counters the motion zeroed at its start, in
  x8 steps, the one mode a service motion runs at). A motion with no end
  on record, or a log rotated under the run, leaves the travel unknown.
- camera_home_return drops the camera home, jogs the head back by the
  session's travel plus what the counters read since the home, starts
  the controller once more so the counters read zero where the head
  began, and tells the baseline. cloud_mode_return does the same for a
  stay in cloud mode, from the cloud client's log.
- A travel that cannot be known fails the run and moves nothing. A
  hand-back that fails while the test is already failing is logged, and
  the test's own failure is the one reported.
- judge_whole_motions fails a homing motion that stopped short: the
  position declared after it is false.

cloud.mode-switch imports the helpers inside its function, so no other
cloud test's fingerprint moves. Its cloud stretch no longer goes through
return_head, which read 0/0 after the controller's start and left the
head under the camera.

Proof: tests/test_camera_home_return.py, 15 cases on the machine's own
log lines (a session stopped short, a whole three-motion one, a cut log,
a refused motion, homed and unhomed counters, a restart since the home,
the first failure winning). tests/test_cloud_suite.py's mode-switch fakes
now zero the counters and remove the anchor at a controller start, write
the anchor at the home, and move the counters on a jog: 10 cases, a
failed hand-back that must not hide the test's failure among them.
forgetest 498 OK. On the bench reference, with the driver and the runner
fixes: homing.cloud-offsets, setup.check-envelope and cloud.mode-switch
PASS, each ending with the head where it was found; cloud.mode-switch
jogged its cloud stretch 246.06/139.01 mm back with the counters across
the jog agreeing, and every baseline was clean.

Acceptance: the three tests are the change; their fingerprints move and
no other test's does.
This commit is contained in:
ScottW514
2026-09-25 14:22:50 -04:00
parent d40ebb090e
commit cfef6a1b84
5 changed files with 544 additions and 34 deletions
+248
View File
@@ -0,0 +1,248 @@
# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""A test that lets the service move the head hands it back where it found
it. Every service motion zeroes the step counters at its start, and a home
and every controller start zero them again, so no counter reading says
where the test found the head: the client's own record does. Every motion
logs the counters it ended on, and the travel is their sum (x8 steps, the
one mode a service motion runs at), plus what the counters read since. The
log lines are the machine's own, trimmed to the motion records: a homing
session whose one motion the cooling engine stopped short, and a whole
three-motion one. Runs against scratch logs and fakes for the controller."""
import os
import shutil
import sys
import tempfile
import unittest
from forgetest.runner import Failed
from forgetest.suite import cloud, homeoff
STOPPED = """\
2026-09-25T15:56:19.641824+00:00 gfhome[7387] INFO gfhome:home service action: motion (ready)
2026-09-25T15:56:19.644041+00:00 gfhome[7387] INFO machine:_motion start motion
2026-09-25T15:56:19.970342+00:00 gfhome[7387] INFO machine:_run_loop starting run
2026-09-25T15:56:20.905040+00:00 gfhome[7387] ERR machine:_run_loop run stopped short: 8130 of 21756 bytes played; the job did not finish
2026-09-25T15:56:20.918652+00:00 gfhome[7387] INFO machine:_feed_and_run end positions (actual/expected): X (4390/12115), Y (2682/7402), Z (0/0)
2026-09-25T15:56:20.937235+00:00 gfhome[7387] INFO machine:_motion_locked end positions (4390, 2682, 0)
2026-09-25T15:56:20.938026+00:00 gfhome[7387] INFO machine:_motion end motion
2026-09-25T15:56:20.938677+00:00 gfhome[7387] INFO basemachine:_finish_action motion [1588842275]: finished with event ":cancelled"
2026-09-25T15:56:21.145580+00:00 gfhome[7387] INFO gfhome:home motion completed
"""
WHOLE = "".join(
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion start motion\n"
"2026-09-07T20:%s gfhome[28592] INFO machine:_feed_and_run end positions (actual/expected): "
"X (%d/%d), Y (%d/%d), Z (0/0)\n"
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion_locked end positions (%d, %d, 0)\n"
"2026-09-07T20:%s gfhome[28592] INFO machine:_motion end motion\n"
"2026-09-07T20:%s gfhome[28592] INFO basemachine:_finish_action motion [%d]: finished with event "
"\":completed\"\n" % (t, t, x, x, y, y, t, x, y, t, t, i)
for i, (t, x, y) in enumerate((("25:50.1", 4774, 4600), ("25:56.2", -71, -144), ("26:02.9", -13096, -7399))))
class FakeGrbl:
def __init__(self, ctx):
self.ctx = ctx
def __enter__(self):
return self
def __exit__(self, *a):
return False
def command(self, line, timeout=None):
self.ctx.jogs.append(line)
return ["ok"]
class FakeCtx:
def __init__(self):
self.forgectrl = object()
self.jogs = []
self.lines = []
self.rezeroed = 0
def check(self, cond, msg, *args):
if not cond:
raise Failed(msg % args if args else msg)
def log(self, msg, *args):
self.lines.append(msg % args if args else msg)
def grbl(self):
return FakeGrbl(self)
def counters_rezeroed(self):
self.rezeroed += 1
class HandBackTests(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self.log = os.path.join(self.dir, "gfhome.log")
self.cloudlog = os.path.join(self.dir, "gfcloud.log")
self.ctx = FakeCtx()
self.anchor = True
self.counters = [0, 0, 0]
self.restarts = 0
self.spm = 213.333 # the controller's x32 scale
saved_logs = (cloud.GFHOME_LOG, cloud.GFCLOUD_LOG)
self.addCleanup(lambda: setattr(cloud, "GFHOME_LOG", saved_logs[0]))
self.addCleanup(lambda: setattr(cloud, "GFCLOUD_LOG", saved_logs[1]))
cloud.GFHOME_LOG, cloud.GFCLOUD_LOG = self.log, self.cloudlog
patches = {
"counter_frame": lambda: [1, 2] if self.anchor else None,
"read_position": lambda: list(self.counters),
"counter_steps_per_mm": lambda: self.spm,
"_drop_reference": self._restart,
"clean_slate": lambda ctx, g: None,
"wait_idle": self._jog_ends,
"machine_idle": lambda ctx: None,
}
saved = {k: getattr(homeoff, k) for k in patches}
self.addCleanup(lambda: [setattr(homeoff, k, v) for k, v in saved.items()])
for k, v in patches.items():
setattr(homeoff, k, v)
self.addCleanup(shutil.rmtree, self.dir, True)
def _restart(self, ctx, fc):
# a controller start zeroes the counters where the head stands and
# removes the anchor
self.restarts += 1
self.counters = [0, 0, 0]
self.anchor = False
def _jog_ends(self, ctx, g, timeout=30.0):
# the jog played: the counters moved by what it asked, at the scale
words = dict((w[0], float(w[1:])) for w in self.ctx.jogs[-1].split()[2:4])
self.counters = [self.counters[0] + round(words["X"] * self.spm),
self.counters[1] + round(words["Y"] * self.spm), 0]
return 0.0, ["Jog", "Idle"], {}
def write(self, text, path=None):
with open(path or self.log, "a", encoding="utf-8", newline="\n") as f:
f.write(text)
def expect_jog(self, dx, dy):
self.assertEqual(self.ctx.jogs, ["$J=G91 G21 X%.3f Y%.3f F2400" % (-dx, -dy)])
# -- the travel ------------------------------------------------------
def test_the_travel_is_the_sum_of_the_motions_ends(self):
self.write(WHOLE)
self.assertEqual(homeoff.session_travel(0), (4774 - 71 - 13096, 4600 - 144 - 7399))
def test_the_travel_starts_at_the_mark(self):
self.write(WHOLE)
mark = homeoff.session_mark()
self.write(STOPPED)
self.assertEqual(homeoff.session_travel(mark), (4390, 2682))
def test_the_cloud_clients_log_is_read_the_same_way(self):
self.write(WHOLE.replace("gfhome[", "gfcloud["), self.cloudlog)
self.assertEqual(homeoff.session_travel(0, cloud.GFCLOUD_LOG), (4774 - 71 - 13096, 4600 - 144 - 7399))
self.assertEqual(homeoff.session_travel(0), (0, 0)) # gfhome's log is its own
def test_a_motion_with_no_end_leaves_the_travel_unknown(self):
self.write(STOPPED.split("machine:_motion_locked")[0])
self.assertIsNone(homeoff.session_travel(0))
def test_a_log_rotated_under_the_run_leaves_the_travel_unknown(self):
self.write(STOPPED)
self.assertIsNone(homeoff.session_travel(os.path.getsize(self.log) + 1))
# -- the stopped-short judge --------------------------------------------
def test_a_motion_stopped_short_fails_the_judge(self):
self.write(STOPPED)
ev = {}
with self.assertRaises(Failed):
homeoff.judge_whole_motions(self.ctx, ev, 0)
self.assertEqual(len(ev["short_motions"]), 2)
def test_whole_motions_pass_the_judge(self):
self.write(WHOLE)
ev = {}
homeoff.judge_whole_motions(self.ctx, ev, 0)
self.assertEqual(ev["short_motions"], [])
# -- after a camera home -------------------------------------------------
def test_homed_the_head_goes_back_by_the_travel_and_the_counters_since(self):
# the home zeroed the counters; the test's own jogs left a step or so
self.write(STOPPED)
self.counters = [round(1.0 * self.spm), 0, 0]
since = self.counters[0] / self.spm
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
self.expect_jog(4390 / homeoff.XY_STEPS_PER_MM + since, 2682 / homeoff.XY_STEPS_PER_MM)
# dropped before the jog, and once more with the head back
self.assertEqual(self.restarts, 2)
self.assertEqual(self.counters, [0, 0, 0])
self.assertEqual(self.ctx.rezeroed, 1)
def test_unhomed_the_counters_are_not_counted_twice(self):
# a failed session leaves the last motion's end in the counters,
# which the travel already counts
self.write(STOPPED)
self.counters = [4390, 2682, 0]
self.anchor = False
homeoff.camera_home_return(self.ctx, {"homed": False}, 0)
self.expect_jog(4390 / homeoff.XY_STEPS_PER_MM, 2682 / homeoff.XY_STEPS_PER_MM)
def test_an_unknown_travel_moves_nothing(self):
self.write(STOPPED.split("machine:_motion_locked")[0])
with self.assertRaises(Failed):
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
self.assertEqual(self.ctx.jogs, [])
self.assertEqual(self.ctx.rezeroed, 0)
def test_a_restart_since_the_home_moves_nothing(self):
# the anchor gone: the counters no longer read from the home
self.write(STOPPED)
self.anchor = False
with self.assertRaises(Failed):
homeoff.camera_home_return(self.ctx, {"homed": True}, 0)
self.assertEqual(self.ctx.jogs, [])
def test_a_head_that_never_moved_is_not_jogged(self):
homeoff.camera_home_return(self.ctx, {}, 0)
self.assertEqual(self.ctx.jogs, [])
self.assertEqual((self.restarts, self.ctx.rezeroed), (1, 1))
# -- after cloud mode ----------------------------------------------------
def test_after_cloud_mode_the_head_goes_back_by_the_clients_record(self):
# the service's re-hunt, cut short by the switch back: to the middle
# of the bed and one correction (the bench reference, 2026-09-25)
self.write(WHOLE[:WHOLE.index("2026-09-07T20:26:02.9")].replace("gfhome[", "gfcloud["), self.cloudlog)
ev = {}
homeoff.cloud_mode_return(self.ctx, ev, 0)
self.expect_jog((4774 - 71) / homeoff.XY_STEPS_PER_MM, (4600 - 144) / homeoff.XY_STEPS_PER_MM)
self.assertEqual(tuple(ev["cloud_return"]["session_travel_steps"]), (4774 - 71, 4600 - 144))
# no drop before the jog (the switch back started the controller), one after
self.assertEqual((self.restarts, self.ctx.rezeroed), (1, 1))
def test_after_cloud_mode_an_unknown_travel_moves_nothing(self):
self.write(STOPPED.replace("gfhome[", "gfcloud[").split("machine:_motion_locked")[0], self.cloudlog)
with self.assertRaises(Failed):
homeoff.cloud_mode_return(self.ctx, {}, 0)
self.assertEqual(self.ctx.jogs, [])
# -- the first failure wins --------------------------------------------
def test_a_hand_back_that_fails_after_the_test_failed_is_logged_not_raised(self):
self.write(STOPPED.split("machine:_motion_locked")[0])
ev = {"homed": True}
with self.assertRaises(Failed) as cm:
try:
raise Failed("the test's own failure")
finally:
homeoff.camera_home_return(self.ctx, ev, 0)
self.assertEqual(str(cm.exception), "the test's own failure")
self.assertIn("cannot be known", ev["return"]["failed"])
self.assertTrue(any("the head is not back" in l for l in self.ctx.lines), self.ctx.lines)
self.assertEqual(self.ctx.jogs, [])
self.assertIsNone(sys.exc_info()[1])
if __name__ == "__main__":
unittest.main()
+79 -2
View File
@@ -28,6 +28,7 @@ import time
import unittest
import helpers
from forgetest import baseline
from forgetest.runner import Context, Failed, Run
from forgetest.suite import cloud
@@ -566,8 +567,21 @@ class CloudSuiteTests(unittest.TestCase):
# -- the mode switch: hunt with the lid open, then $H -----------------------
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
# A homing session's motions as gfhome logs them (the bench reference,
# 2026-09-25): a small correction, then into the camera home.
HOME_SESSION = [
"2026-09-25T16:43:45.374951+00:00 gfhome[12982] INFO machine:_motion start motion",
"2026-09-25T16:43:45.853976+00:00 gfhome[12982] INFO machine:_motion_locked end positions (-13, -13, 0)",
"2026-09-25T16:43:45.854819+00:00 gfhome[12982] INFO machine:_motion end motion",
"2026-09-25T16:43:45.856076+00:00 gfhome[12982] INFO basemachine:_finish_action motion [1588864529]: "
"finished with event \":completed\"",
"2026-09-25T16:43:51.600734+00:00 gfhome[12982] INFO machine:_motion start motion",
"2026-09-25T16:43:53.605402+00:00 gfhome[12982] INFO machine:_motion_locked end positions (-13095, -7399, 0)",
"2026-09-25T16:43:53.606171+00:00 gfhome[12982] INFO machine:_motion end motion",
"2026-09-25T16:43:53.606916+00:00 gfhome[12982] INFO basemachine:_finish_action motion [1588864567]: "
"finished with event \":completed\""]
def mode_switch_setup(self, hunt_lines=None, home_complete=True, lid_late=False):
def mode_switch_setup(self, hunt_lines=None, home_complete=True, lid_late=False, home_lines=None):
"""The fakes a mode-switch run needs: grbl to answer $H, the lid
lamp attr, homing_mode = gfcloud, the service lines landing on
the switch to cloud - the client's start at once, its session and
@@ -576,11 +590,16 @@ class CloudSuiteTests(unittest.TestCase):
reads as a run to the cooling engine while it lasts) - the re-hunt
on the lid close, and gfhome finishing the homing after $H. With
lid_late the hunt is requested before the lid opens: the race the
test must call."""
test must call. The step counters are the machine's: a controller
start zeroes them and removes the homing anchor, the home zeroes
them and writes it, and a jog moves them by what it asked."""
self.grbl = helpers.FakeGrbl().start()
os.makedirs(self.sysfs + "pic", exist_ok=True)
self._attr("pic/lid_led", "236")
self.fc.state["settings"]["homing_mode"] = "gfcloud"
anchor = os.path.join(self.tmp, "grblhal.homed")
self.addCleanup(setattr, baseline, "ANCHOR_PATH", baseline.ANCHOR_PATH)
baseline.ANCHOR_PATH = anchor
lines = fixture("huntlid")
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
hunt_part, close_part = cut(post, "_switch_event lid closed")
@@ -588,6 +607,20 @@ class CloudSuiteTests(unittest.TestCase):
hunt_part = hunt_lines(hunt_part)
fc = self.fc
def controller_start():
self._pos(0, 0, 3)
if os.path.exists(anchor):
os.remove(anchor)
def jog(line):
if line.startswith("$J=G91"):
words = dict((w[0], float(w[1:])) for w in line[3:].split() if w[0] in "XY")
with open(self.sysfs + "cnc/position", "rb") as f:
x, y, z = struct.unpack("<3i", f.read(12))
self._pos(x + round(words.get("X", 0.0) * baseline.XY_STEPS_PER_MM),
y + round(words.get("Y", 0.0) * baseline.XY_STEPS_PER_MM), z)
self.grbl.on_command = jog
def hunt():
self.append(hunt_part, delay=0.0)
time.sleep(4.0) # the hunt outlasts the session wait on the bench
@@ -608,7 +641,14 @@ class CloudSuiteTests(unittest.TestCase):
hunt()
threading.Thread(target=land, daemon=True).start()
elif path == "/mode" and form.get("controller") == "grbl":
controller_start()
self.grbl.state = "Idle"
elif path == "/controller/stop":
fc.state["mode"] = dict(fc.state["mode"], controller="standby", pid=0)
elif path == "/controller/start":
controller_start()
fc.state["mode"] = dict(fc.state["mode"], controller="running", pid=4300, motion="verified")
fc.state["status"]["homed"] = False
return None
self.fc.on_post = on_post
@@ -623,7 +663,11 @@ class CloudSuiteTests(unittest.TestCase):
def homing():
self.grbl.state = "Home"
with open(self.homelog, "ab") as f:
f.write(("\n".join(home_lines or self.HOME_SESSION) + "\n").encode())
time.sleep(0.4)
self._pos(0, 0, 3)
open(anchor, "w").close()
self.grbl.state = "Idle"
self.fc.state["status"]["homed"] = True
if home_complete:
@@ -658,6 +702,39 @@ class CloudSuiteTests(unittest.TestCase):
self.assertEqual([r["state"] for r in ev["actions"]], ["open", "close"])
self.assertEqual(self.script.asked, [])
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
# The head goes back where the test found it, each stretch by its
# client's record: the re-hunt of the excerpt ends 2/7 steps from
# where the cloud mode began, and the homing went into the corner.
self.assertEqual(tuple(ev["cloud_return"]["session_travel_steps"]), (2, 7))
self.assertEqual(tuple(ev["return"]["session_travel_steps"]), (-13108, -7412))
spm = baseline.XY_STEPS_PER_MM
self.assertEqual([l for l in self.grbl.sent if l.startswith("$J=")],
["$J=G91 G21 X%.3f Y%.3f F2400" % (-2 / spm, -7 / spm),
"$J=G91 G21 X%.3f Y%.3f F2400" % (13108 / spm, 7412 / spm)])
# a start after each jog, and the camera home dropped before the second
self.assertEqual([p for p, f in self.fc.posts if p.startswith("/controller/")],
["/controller/stop", "/controller/start"] * 3)
with open(self.sysfs + "cnc/position", "rb") as f:
self.assertEqual(struct.unpack("<3i", f.read(12))[:2], (0, 0))
def test_mode_switch_a_hand_back_that_fails_too_does_not_hide_the_failure(self):
# gfhome never said it homed, and its last motion has no end on
# record: the travel is unknown, the head is not moved, and the
# failure reported is the test's own
hooks = self.mode_switch_setup(home_complete=False, home_lines=self.HOME_SESSION[:5])
threading.Thread(target=self.wait_home_command, daemon=True).start()
self.assertFails(cloud.mode_switch, "no 'homing complete' line", hooks=hooks)
run = self.script.run
self.assertTrue(any("the head is not back" in l for l in run.lines), run.lines[-6:])
self.assertIn("cannot be known", run.evidence["return"]["failed"])
self.assertEqual(len([l for l in self.grbl.sent if l.startswith("$J=")]), 1) # the cloud stretch's
def test_mode_switch_fails_when_the_travel_in_cloud_mode_cannot_be_known(self):
# the hunt's motion has no end on record (a client killed inside it)
hooks = self.mode_switch_setup(
hunt_lines=lambda part: [l for l in part if not l.endswith("machine:_motion end motion")])
self.assertFails(cloud.mode_switch, "the travel in cloud mode cannot be known", hooks=hooks)
self.assertEqual([l for l in self.grbl.sent if l.startswith("$J=")], [])
def test_mode_switch_opens_the_lid_behind_the_controller_and_ahead_of_the_hunt(self):
# No controller starts with the enclosure open: the switch is made