diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index ede7356..7234541 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -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 diff --git a/forgetest/forgetest/baseline.py b/forgetest/forgetest/baseline.py index 77f7dcf..6e1b18b 100644 --- a/forgetest/forgetest/baseline.py +++ b/forgetest/forgetest/baseline.py @@ -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 /boot-.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: diff --git a/forgetest/tests/test_baseline.py b/forgetest/tests/test_baseline.py index 82cbd28..8a5f358 100644 --- a/forgetest/tests/test_baseline.py +++ b/forgetest/tests/test_baseline.py @@ -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()