diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index c7357f6..29383df 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -297,9 +297,11 @@ factory 2.6.0-2228 session; measured numbers in the facts bank). - **Ignored:** a lid open during a hunt, homing, a jog, or at idle. - **The button pauses and resumes a job.** Cloud mode uses the factory's laser-off backtrack and resume lead (`cloud_pause_backtrack_ticks` 2000 / - `cloud_resume_lead_ticks` 1950); GRBL mode uses feed hold / cycle start — the - kernel refuses a backtrack on a live-streamed ring, so a resumed GRBL cut - picks up where the deceleration ended. A pause is not a cancel: the latch + `cloud_resume_lead_ticks` 1950), on a preloaded job and a live-fed one + alike: the retrace is sized to `cnc/max_backtrack` and the lead follows it, + so a pause with little history behind it shortens both rather than failing. + GRBL mode uses feed hold / cycle start, so a resumed GRBL cut picks up where + the deceleration ended (item 18). A pause is not a cancel: the latch stays unlocked and the window open across it. There is no resume dwell: the safing chain re-arms ~216 ms before the first step (facts bank). - **`lid_policy = hold`** selects stock grblHAL door behavior instead (park in @@ -662,9 +664,12 @@ not a release. Bench-verified on the 16 MiB ring the earlier images shipped, and the mechanism is size-independent: 20 MB streamed at 100 kHz through the wrapping ring, 0 ENOMEM, 0.4 ms max write latency, starve → `underrun` per protocol. - The ring caps legacy cloud-mode job length (whole-file preload: ~1 MiB per - 100 s of 10 kHz stream, so ~56 min); the grblHAL live feed keeps only a few - KB in flight. + The ring holds ~1 MiB per 100 s of 10 kHz stream, so ~56 min of a cloud + print at a time; a longer job is fed live as it plays, and the grblHAL feed + keeps only a few KB in flight. The 32 KiB gap is retained history: the + writer stops that far short of the play head, so any fill leaves 3.2 s of + played program (at the print tick) to back a pause into, which is what + `cnc/max_backtrack` reports less the deceleration tail. - **Reserved memory**: 511 MiB usable DRAM (`0x10000000`–`0x2fefffff`), of which 96 MiB is reserved for DMA: the 32 MiB `cnc-pulsebuf` no-map pool (dynamically placed, `alignment = size`, so it lands at `0x2c000000`) plus 64 MiB of @@ -1248,13 +1253,17 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`. laser-off backtrack (`cloud_pause_backtrack_ticks` 2000), then a laser-off lead back up to speed on the next press (`cloud_resume_lead_ticks` 1950), so the beam returns only once the head is retracing ground it already cut - and is back at feed. That mechanism cannot be borrowed here: the kernel - refuses a negative `resume` with `EPERM` once the ring has been - live-streamed (`UAPI.md`), because the bytes to back into have already been - overwritten. + and is back at feed. The kernel offers that mechanism to a live feed as + well: what bounds a backward run is the ring's retained history, not how + the ring was filled, and the 32 KiB the writer must leave clear is 3.2 s of + history at the print tick (`cnc/max_backtrack`, `UAPI.md`). What is not + settled is the bookkeeping above it: a backward run moves the head and the + kernel's counters while grblHAL's planner still holds a partly executed + block, so borrowing the mechanism means reconciling the two, and a GRBL + cut runs a much shorter queue than a cloud print does. - So the equivalent belongs above the ring, where grblHAL still holds what - the kernel does not — the planned path. Shape to evaluate: capture the + So the equivalent likely belongs above the ring, where grblHAL still holds + what the kernel does not: the planned path. Shape to evaluate: capture the point where the beam went off at the hold; on the resume plan a laser-off retrace back along the path and a laser-off accelerate-in, and unmask FIRE only once the head is at feed and has passed the captured point. Open: how diff --git a/forgetest/forgetest/suite/cloud.py b/forgetest/forgetest/suite/cloud.py index a22206c..df0329a 100644 --- a/forgetest/forgetest/suite/cloud.py +++ b/forgetest/forgetest/suite/cloud.py @@ -764,20 +764,22 @@ def pause_resume(ctx): @test("cloud.oversize-stream", title="A print longer than the ring is fed while it plays", - subsystem="cloud", kind="live", est_min=10, + subsystem="cloud", kind="live", est_min=12, covers=_CLOUD_COVERS, - requires=["cloud.lid-interlock-abort"], + requires=["cloud.lid-interlock-abort", "cloud.pause-resume"], steps=[CLOUD_STEP, "Scrap on the bed and a LONG job ready in the app - one whose run time is longer " "than the ring holds (over an hour at the usual print tick). A full-bed raster " "engrave is the easy way to get one.", "Print from the app and press the button when it lights. Let it cut for about two " - "minutes, then cancel the print from the app."], + "minutes, pause and resume it with the button, then cancel the print from the app."], description="The service sends one pulse file for a print however long it is, and a long one " "is several times the ring: the machine holds the job in memory, fills the ring, " "starts, and tops the ring up as it drains. This checks the signature of that - " "the device in live-feed mode, the kernel's program total growing during the run, " - "and no underrun - and that the job still cancels cleanly.") + "and no underrun - that a live-fed print pauses and resumes the factory way, the " + "ring having kept the history to back into, and that the job still cancels " + "cleanly.") def oversize_stream(ctx): ev = ctx.evidence offset = enter_cloud(ctx) @@ -811,6 +813,27 @@ def oversize_stream(ctx): ev["underruns_during"] = after ctx.check(after == before, "the ring ran dry during the run (underruns %s -> %s)", before, after) + # The pause on a live feed: the ring keeps the history to back into, so a + # streamed print retraces and leads back on exactly like a preloaded one. + ev["max_backtrack"] = hw.sysfs_int("cnc/max_backtrack", 0) + ctx.log("max_backtrack while the feed runs: %s steps", ev["max_backtrack"]) + ctx.instruct("Press the button once (pause), watch the head stop and back up a few " + "millimeters, wait about 3 seconds, press it again (resume), then click Done.") + got = wait_log(ctx, offset, ["button pressed mid-run; pausing", "paused at", + "button pressed while paused; resuming"], 90) + ev["pause_log"] = {k: bool(v) for k, v in got.items()} + ctx.check(got["button pressed mid-run; pausing"], "the press did not pause the live-fed run") + ctx.check(got["paused at"], "the pause did not settle (no 'paused at')") + ctx.check(got["button pressed while paused; resuming"], "the second press did not resume") + backtracked = [ln for ln in log_lines_since(GFCLOUD_LOG, offset) + if "backtrack refused" in ln] + ev["backtrack_refused_lines"] = backtracked[:2] + ctx.check(not backtracked, "the live-fed pause could not back up (%s)", backtracked[:1]) + ctx.check(hw.sysfs_int("cnc/underruns", 0) == before, + "the pause or the resume starved the ring") + ctx.confirm("Did the head back up a few millimeters with the laser off on the first press, " + "and pick the cut back up on the second?") + ctx.instruct("Now cancel the print from the app.") fin = wait_action_finished(ctx, offset, "print", 300) ev["print_finished"] = message(fin) diff --git a/forgetest/forgetest/suite/kernel.py b/forgetest/forgetest/suite/kernel.py index 1c48628..80a7245 100644 --- a/forgetest/forgetest/suite/kernel.py +++ b/forgetest/forgetest/suite/kernel.py @@ -292,6 +292,89 @@ def k1_k2(ctx): "laser_enable/laser_on at 0 throughout") +# ---------------------------------------------------------------- backtrack + +@test("kernel.backtrack-bounds", title="A backward run is bounded by the history the ring still holds", + subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=2, + covers=_KERNEL_COVERS, + requires=["kernel.k1-k2"], + description="A pause walks the program backward to put the beam back over ground the job " + "already cut, and the ring is what remembers that ground. cnc/max_backtrack is " + "the distance still available: what has played, less the tail the deceleration " + "spends. This drills the boundary on real hardware - the readback matches the " + "bytes played, a step beyond it is refused rather than quietly shortened, and " + "the run at the boundary plays out and returns to idle. Motors locked, latch " + "locked, duty zero: nothing moves and nothing fires.") +def backtrack_bounds(ctx): + ev = ctx.evidence + tail = TICK_HZ * TICK_HZ // (2 * 125000) # v^2/2a: 400 steps at the print tick + with ctx.takeover(): + stream = POWER0 + PAD * (6 * TICK_HZ) + ctx.log("%d bytes = %.1f s of pads at %d Hz; decel tail %d steps", + len(stream), len(stream) / TICK_HZ, TICK_HZ, tail) + snap(ctx, "pre") + wr("cnc/motor_lock", 15) + wr("cnc/laser_latch", 1) + wr("cnc/ramp_rate", 125000) + wr("cnc/step_freq", TICK_HZ) + with PulseDevice(ctx) as dev: + dev.rewind() + wr("cnc/enable", 1) + ctx.sleep(0.5) + dev.write(stream) + wr("cnc/run", 1) + ctx.sleep(1.5) # well past the accel ramp + wr("cnc/stop", 1) + state = wait_state(ctx, "idle", 5, poll=0.01) + ctx.check(state == "idle", "the controlled stop did not reach idle (state=%s)", state) + + played = rd_pos()[3] + budget = int(rd("cnc/max_backtrack")) + ev["played"] = played + ev["max_backtrack"] = budget + ctx.log("played %d bytes; max_backtrack %d", played, budget) + ctx.check(budget > 0, "max_backtrack is %d after %d bytes played", budget, played) + # The two numbers come from different SDMA registers (the byte + # counter and the ring head), so allow a few bytes of skew - but + # not the whole gap, and not the whole played span. + ctx.check(abs(budget - (played - tail)) <= 8, + "max_backtrack %d is not the %d bytes played less the %d-step decel tail", + budget, played, tail) + + # One step past the boundary: refused, and the device stays idle. + refused = None + try: + wr("cnc/resume", -(budget + 1)) + except OSError as e: + refused = e.errno + ev["over_long_errno"] = refused + ctx.check(refused == errno.EPERM, + "a backtrack one step past the boundary was not refused with EPERM (%s)", + refused) + state = rd("cnc/state") + ctx.check(state == "idle", "the refused backtrack left the device %s", state) + + # At the boundary: runs, decelerates inside genuine data, ends idle. + wr("cnc/resume", -budget) + wait_state(ctx, "running", 2, poll=0.005) + hits, state = watch_laser_until_idle(ctx, 30) + after = int(rd("cnc/max_backtrack")) + ev["after"] = {"state": state, "max_backtrack": after, + "faults": rd("cnc/faults"), "underruns": rd("cnc/underruns")} + ctx.log("backtrack of %d done: state=%s max_backtrack now %d", budget, state, after) + # Leave the program drained so the device ends where the other + # drills expect it. + wr("cnc/resume", 0) + wait_state(ctx, "running", 2, poll=0.005) + wait_state(ctx, "idle", 30) + ctx.check(not hits, "the laser asserted during the backward run: %s", hits[:10]) + ctx.check(state == "idle", "the backward run ended in %s", state) + ctx.check(ev["after"]["faults"] == "0", "faults=%s after the backward run", + ev["after"]["faults"]) + ctx.log("PASS: max_backtrack tracks the played history, the boundary is enforced, " + "and the run at it plays out clean") + + # ---------------------------------------------------------------- K3 def _k3_phase(ctx):