baseline: take the fresh-boot reference after the controller applied its config

/mode reports controller=running at the spawn, so the reference dump
raced grblHAL's init writes and captured the supervisor's motion-probe
values (motor_lock 0, step_freq 10000, y_mode at the module default)
instead of the resting state. boot_reference() now waits for the
controller's markers (step_freq/motor_lock/y_mode at their fixed values,
bounded 20 s, GRBL mode only) plus a 1 s settle before dumping, retakes
a saved reference that shows the pre-config state while the boot is
still fresh, and marks it otherwise. Tests cover the wait, its timeout,
the non-GRBL no-op, the pre-config recognition and the retake.
This commit is contained in:
ScottW514
2026-08-16 20:00:08 -04:00
parent d218b3de25
commit b68d790b71
3 changed files with 156 additions and 7 deletions
+7 -1
View File
@@ -1502,7 +1502,13 @@ forgectrl restarts; the fresh-boot dump of this image (uptime 235 s)
confirmed the fixed values (`motor_lock 8`, `x/y_mode 8`, `x/y_decay 1`,
`step_freq 28160` - the controller's tick, not the probe's 10000 -
`ramp_rate 125000`, hold currents 33/5, lamps and button LEDs 0, heater
and TEC off). Proof: k1-k2 / k3 / fire-abu re-run under the baseline -
and TEC off). A reference dumped at uptime 30 s on 2026-08-16 showed the
probe values instead (`motor_lock 0`, `step_freq 10000`, `y_mode 1`): the
dump raced the controller's init writes - `/mode` reports `running` at the
spawn, not at the config - so `boot_reference()` now waits for the
controller's markers (`step_freq`/`motor_lock`/`y_mode` at their fixed
values, bounded 20 s) before dumping, and retakes a pre-config reference
while the boot is still fresh. Proof: k1-k2 / k3 / fire-abu re-run under the baseline -
counters (0,0,0) before and after, the probe verified in 3 s after every
takeover, no ladder, `post: clean` every time. (2) Two forgectrl items
for the operator's decision, not changed: the liveness probe should write
+67 -6
View File
@@ -517,8 +517,10 @@ def dump_all(bl):
def check_fixed_against(ref, log):
"""Compare the fixed constants with a fresh-boot dump; log the diffs
(a differing constant is a fact about this machine, not a leftover)."""
"""Compare the fixed constants with a fresh-boot dump; log the diffs.
With the dump taken after the controller applied its config (see
wait_controller_configured) a differing value is a fact about this
machine worth a look, not a leftover."""
diffs = []
sysfs = ref.get("sysfs") or {}
for attr, want in FIXED_SYSFS + IDLE_READBACKS:
@@ -530,6 +532,52 @@ def check_fixed_against(ref, log):
return diffs
# The attributes the GRBL controller writes at its own start (its analog
# config + machine tick): once they read the fixed values the controller
# has configured the machine. Before that the kernel shows the supervisor's
# motion-probe leftovers (motor_lock 0, step_freq 10000, y_mode at the
# module default) - the state /mode already calls "running", because
# "running" is the spawn, not the config.
CONFIGURED_MARKERS = [(a, dict(FIXED_SYSFS)[a]) for a in ("cnc/step_freq", "cnc/motor_lock", "cnc/y_mode")]
CONFIGURED_TIMEOUT_S = 20
CONFIGURED_SETTLE_S = 1.0
def reference_preconfig(ref):
"""True when a saved reference shows the pre-controller state: every
marker present differs from its fixed value (the probe's step_freq /
motor_lock and the module's y_mode together), i.e. it was dumped
before the controller's init writes landed."""
sysfs = (ref or {}).get("sysfs") or {}
seen = [(sysfs.get(a), want) for a, want in CONFIGURED_MARKERS if sysfs.get(a) is not None]
return bool(seen) and all(got != want for got, want in seen)
def wait_controller_configured(log, mode_body, timeout=CONFIGURED_TIMEOUT_S, sleep=time.sleep):
"""After the supervisor reports the controller running: block until the
GRBL controller's init writes have landed (the CONFIGURED_MARKERS read
their fixed values), then a short settle. Only the GRBL controller
writes those; in any other mode return at once. Bounded: on timeout
the caller proceeds and the reference will say so."""
if not (isinstance(mode_body, dict) and mode_body.get("controller") == "running"
and (mode_body.get("mode") or "grbl") == "grbl"):
return True
t0 = time.time()
while time.time() - t0 < timeout:
got = [(a, hw.sysfs_read(a)) for a, _ in CONFIGURED_MARKERS]
if all(g == want for (a, g), (_, want) in zip(got, CONFIGURED_MARKERS)):
sleep(CONFIGURED_SETTLE_S)
log("baseline: controller configured %.1f s after running" % (time.time() - t0))
return True
if any(g is None for _, g in got):
return True # no kernel sysfs (host run): nothing to wait for
sleep(0.25)
log("baseline: WARNING - controller did not apply its config within %d s (%s); "
"the reference may show the supervisor's probe values"
% (timeout, ", ".join("%s=%s" % (a, hw.sysfs_read(a)) for a, _ in CONFIGURED_MARKERS)))
return False
def boot_reference(log, data_dir):
"""The fresh-boot idle state of this boot: loaded from
<data_dir>/boot-<boot_id>.json when forgetest already took it, taken
@@ -539,20 +587,33 @@ def boot_reference(log, data_dir):
if not bid:
return None
path = os.path.join(data_dir, "boot-%s.json" % bid)
up = uptime_s()
try:
with open(path) as f:
ref = json.load(f)
log("baseline: fresh-boot reference loaded (%s, taken %s)" % (path, ref.get("ts")))
return ref
# A reference dumped before the controller had applied its config
# (the supervisor's probe values still showing) is retaken while
# the boot is fresh enough; otherwise it stands, marked.
if reference_preconfig(ref):
if up is not None and up <= BOOT_MAX_AGE_S:
log("baseline: fresh-boot reference %s predates the controller's config; retaking" % path)
ref = None
else:
log("baseline: NOTE fresh-boot reference %s predates the controller's config "
"(taken %s); the machine's probe values are in it - power-cycle to retake"
% (path, ref.get("ts")))
if ref is not None:
log("baseline: fresh-boot reference loaded (%s, taken %s)" % (path, ref.get("ts")))
return ref
except (OSError, ValueError):
pass
up = uptime_s()
if up is None or up > BOOT_MAX_AGE_S:
log("baseline: no fresh-boot reference for this boot (uptime %s s > %d s); "
"power-cycle to take one" % (up, BOOT_MAX_AGE_S))
return None
bl = Baseline(log)
bl.wait_settled()
mode = bl.wait_settled()
wait_controller_configured(log, mode)
ref = dump_all(bl)
ref.update({"ts": now_ts(), "boot_id": bid, "uptime_s": uptime_s()})
try:
+82
View File
@@ -1,6 +1,7 @@
"""The baseline: fixed resting values are restored, preserved values are
handed back, every deviation is a recorded leftover. Runs against a fake
sysfs tree; forgectrl is unreachable (service-side checks skip)."""
import json
import os
import shutil
import struct
@@ -161,5 +162,86 @@ class BaselineTests(unittest.TestCase):
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=28160"])
# -- the reference is taken after the controller applied its config -----
def _probe_state(self):
# what the kernel shows between the supervisor's motion probe and
# the GRBL controller's init writes
self._attr("cnc/motor_lock", "0")
self._attr("cnc/step_freq", "10000")
self._attr("cnc/y_mode", "1")
def test_wait_configured_returns_once_the_controller_wrote_its_config(self):
self._probe_state()
calls = {"n": 0}
def sleep(_s):
calls["n"] += 1
if calls["n"] == 3: # the controller's init writes land
for attr, val in baseline.CONFIGURED_MARKERS:
self._attr(attr, val)
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
timeout=5, sleep=sleep)
self.assertTrue(ok)
self.assertTrue(any("controller configured" in l for l in self.lines))
self.assertGreaterEqual(calls["n"], 4) # 3 polls + the settle
def test_wait_configured_times_out_and_says_so(self):
self._probe_state()
t = {"now": 0.0}
real_time = baseline.time.time
baseline.time.time = lambda: t["now"]
try:
def sleep(s):
t["now"] += s
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "grbl"}, timeout=2, sleep=sleep)
finally:
baseline.time.time = real_time
self.assertFalse(ok)
self.assertTrue(any("did not apply its config" in l for l in self.lines))
def test_wait_configured_is_a_noop_outside_grbl_mode(self):
self._probe_state()
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "cloud"}, timeout=1,
sleep=lambda s: self.fail("slept in cloud mode"))
self.assertTrue(ok)
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "stopped", "mode": "grbl"}, timeout=1,
sleep=lambda s: self.fail("slept with the controller stopped"))
self.assertTrue(ok)
def test_preconfig_reference_is_recognized(self):
self.assertTrue(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000", "cnc/y_mode": "1"}}))
self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "28160", "cnc/y_mode": "8"}}))
# a genuinely different single constant is a machine fact, not pre-config
self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"}}))
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
def test_stale_preconfig_reference_is_retaken_on_a_fresh_boot(self):
os.environ["FORGETEST_BOOT_ID"] = "test-boot-2"
try:
path = os.path.join(self.tmp, "boot-test-boot-2.json")
with open(path, "w") as f:
json.dump({"ts": "old", "sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000",
"cnc/y_mode": "1"}}, f)
ref = baseline.boot_reference(self.lines.append, self.tmp)
up = baseline.uptime_s()
if up is None or up > baseline.BOOT_MAX_AGE_S:
# too old to retake: the stale reference stands, marked
self.assertTrue(any("predates the controller's config" in l for l in self.lines))
self.assertEqual(ref["ts"], "old")
else:
self.assertTrue(any("retaking" in l for l in self.lines))
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "8")
finally:
os.environ.pop("FORGETEST_BOOT_ID", None)
if __name__ == "__main__":
unittest.main()