From 8a9c6a90621953dfbcf05062aff4d4dbac5d50c9 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Tue, 1 Sep 2026 18:03:45 -0400 Subject: [PATCH] laser: feed hold and resume in GRBL mode, the sender-change hold Stream harness rule 21: a feed hold leaves no dark ground in either mode (lit into the hold, dark while held, lit from the first step out), with realtime and wait-state steps in the session runner. Lifecycle harness: the hold a sender change puts a running job into, the resume that re-arms a held job from the sender and from the button, a reset from a held job, and the resume after the grace closed the window in Hold. Live-fire drills: holdres (the pause as a corner in time, the re-arm after the grace); senderchg follows the hold. BRINGUP: the gapless-pause and sender-change items close, the facts bank gains the measured hold and resume behavior; CAMPAIGN-LOG records the proof and the bench runs. Acceptance: the pause-resume-lid-cancel text follows the behavior; the driver stays covered by src/**. --- docs/BRINGUP.md | 92 ++++++------- docs/CAMPAIGN-LOG.md | 119 ++++++++++++++++ forgetest/forgetest/suite/laser.py | 10 +- scripts/bench/laser_lifecycle_test.py | 155 +++++++++++++++++++-- scripts/bench/laser_stream_test.py | 67 +++++++++ scripts/bench/live_fire_drills.py | 190 ++++++++++++++++++++++---- 6 files changed, 530 insertions(+), 103 deletions(-) diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 9790d1a..90bae54 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -348,10 +348,19 @@ factory 2.6.0-2228 session; measured numbers in the facts bank). `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 7). 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). + GRBL mode uses feed hold / cycle start: the deceleration runs lit and + velocity-scaled, the dwell is dark, and the resume is lit from its first + step, so a pause is a sharp corner in time (facts bank "Feed hold and + resume in GRBL mode"). 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). A pause that outlives + the disarm grace closes the window; the next cycle start (`~`, the button, + or the cooling client's auto-resume) then re-arms first: the button lights + and the press resumes the job. +- **A sender change while a job runs** holds the job and closes the window + (the consent belonged to the displaced session), so the next sender finds + the cut in Hold where it stopped, with a short dark deceleration behind + it, and resumes it through the same re-arm, or resets it. - **`lid_policy = hold`** selects stock grblHAL door behavior instead (park in Door, cycle start after the lid closes resumes with position intact). @@ -909,6 +918,28 @@ is committed. above it. Raw `hv_current` counts are a presence/absence witness only: the per-rung means are non-monotonic at the top of the ladder and the signal has no characterized transfer function. +- **Feed hold and resume in GRBL mode** (stream-measured on the null-sink + build at the 28160 Hz tick: a 100 mm/s cut at S500, `laser_dose_curve = off`, + floor 10 %, `!` mid-line, `~` after `Hold:0`): the planned deceleration runs + lit in both modes. The core's `disable_laser_during_hold` acts in + `state_suspend_manager`, which runs only once the hold has completed, so the + beam goes off at the end of the deceleration, never at its start. Under + `M4` the density follows velocity down to the floor (fire per step 2.9 at + cruise, 5.4 in the last 25 ms at 13 mm/s), the stream between the last step + and the first step is dark, and the resume lights 9 ticks after the first + step with the deceleration's profile in reverse: **a pause under `M4` is a + sharp corner in time**, and the corner rolloff governs its mark. Under `M3` + the fire rate stays constant through the deceleration (fire per step rises + 2.9 to 22, the `M3` corner dose) and the resume is lit from its first step + as well: the segments the core prepares while held carry the spindle + update (the core fork sets it at hold completion; without that the `M3` + resume ran dark for 2453 ticks, 87 ms, one segment buffer). A hold whose + window closed under the grace resumes through the resume gate: the + sender's `~`, the button, or the cooling client's auto-resume lights the + button and waits for the press from the poll, never inside the core's held + state (a blocking arm wait there pumps the core's suspend loop, which spins + until the hold ends, so the press is never read), and the cycle start is + issued once the press has re-armed. - **Factory power model** (three cloud cuts of one 1" square, same location, material and speed, only the UI power setting changed, pulse files captured from each): the **power byte is pinned at 127** @@ -1229,54 +1260,7 @@ feature requests, enhancements) will eventually be tracked as GitHub issues. cheap opportunistic check during live fire still stands: log GPIO3_22 edges plus `head/beam_detect_digital|_analog` while firing. -7. **Gapless pause and resume in GRBL mode (planned).** A pause leaves a mark - in the cut. With laser mode on, the core stops the beam at the start of the - hold (`disable_laser_during_hold`, on by default), so the head travels the - whole deceleration dark, and the resume re-accelerates from a standstill at - the point the decel ended: an unburned length, then a restart that dwells - through the accel. At constant power (`M3`) that restart is a deeper spot - you can see; `M4` scales power with velocity and mostly hides it, but - neither closes the gap. GRBL mode should pause and resume with no - discontinuity in the cut, the way the factory does. - - Cloud mode already does, on the kernel's waypoint resume - (`cloud_pause_backtrack_ticks` 2000, `cloud_resume_lead_ticks` 1950), and - the kernel offers the same mechanism to a live feed, bounded by the ring's - retained history (`cnc/max_backtrack`; the facts bank "SDMA pulse engine" - and [the pulse feeder contract](https://docs.forgefirm.org/technical/forgefirm/pulse-feeder-contract/)). 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 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 - far back is enough (2000/1950 ticks is a reference, not a transferable - number, since the tick rates differ), whether the retrace can reuse - planner blocks or needs a synthesized one, what a hold inside an arc or a - raster line does to it, and how it composes with the armed window's disarm - grace across a long hold. - -8. **A sender change while a job runs: discussion.** Today a sender that - disconnects mid-job leaves the motion running to the end of what the - controller holds, with the window closed and fire suppressed (the - consent belonged to the displaced session), so the job finishes dark - and the material is left with an unfinished cut. This is the stock - Grbl and grblHAL expectation for the motion (the core switches streams - with no hold and no alarm, and senders treat a lost connection as a - failed job); ForgeFIRM adds only the disarm on top. The open question - is whether the disarm should also - feed-hold the job, so a reconnecting sender can press and resume where - the cut stopped instead of finding the head at the end of a dark pass: - a hold parks the head over hot material with the assist air on the run - profile, and the grace then closes the window in Hold as it does today; - running on leaves a clean stop position but wastes the piece. Decide - with the gapless pause and resume item (7), which owns the resume - mechanics. -9. **The flow check on a second machine.** The lit-tube flow check is in +7. **The flow check on a second machine.** The lit-tube flow check is in place: the engine reads means, takes its baseline under the run profile, and takes the tube's share off (`cool_laser_heat_cw`, `cool_laser_heat_density`); `cooling.flow-under-load` is the catalog's @@ -1286,7 +1270,7 @@ feature requests, enhancements) will eventually be tracked as GitHub issues. far): re-measure the two heat coefficients and the machine's air-assist offset; and if a lit check still trips, the void-on-emission design with the tube as its own flow tracer. -10. **Laser power-good: what the line means.** `cnc/laser_pgood` and its +8. **Laser power-good: what the line means.** `cnc/laser_pgood` and its sampled count are defined in the UAPI (active low, one sample every ~3.9 ms), the facts bank records that the sampled count reads 0 through real cutting, and the cooling engine warns @@ -1298,7 +1282,7 @@ feature requests, enhancements) will eventually be tracked as GitHub issues. scope against `hv_current` through an armed cut, its meaning written into the facts bank and the UAPI, and then either a warning that means something or no warning. -11. **Initial commissioning: measure and set the machine's own numbers +9. **Initial commissioning: measure and set the machine's own numbers methodically.** Every tunable that was measured on the bench machine and shipped as a default varies from machine to machine: the flow check's bands and `cool_flow_rise`, the tube's heat coefficients diff --git a/docs/CAMPAIGN-LOG.md b/docs/CAMPAIGN-LOG.md index f86b02b..f8ac629 100644 --- a/docs/CAMPAIGN-LOG.md +++ b/docs/CAMPAIGN-LOG.md @@ -6858,6 +6858,125 @@ drill's threshold 40 is 0.31 g, not 0.62 g). Normal commanded motion reads under 0.2 g and a rail strike 1.8 g and up, so the factory band sits where the bench says it should. +## 2026-09-01: feed hold and resume in GRBL mode, measured on the null-sink stream + +Prompted by the "gapless pause" item, which said the head travels the +hold's deceleration dark. A scratch harness ran the native null-sink +controller (`GFSINK_DUMP`, the stream harness's launch pattern) built from +grblHAL 575ff97: `G1 X150 F6000` at S500 (`laser_dose_curve = off`, floor +10 %), `!` 0.7 s in, `~` after `Hold:0`, under `M4` and then `M3`; then the +same job with `laser_disarm_s = 2` and a switch file, held past the grace +and resumed by `~` and by the button. The dump was read in 25 ms windows +(704 ticks) on both sides of the stop. + +- **The deceleration is lit, in both modes.** The item's claim was wrong: + the core's `disable_laser_during_hold` acts in `state_suspend_manager`, + which runs only once the handler is `state_await_resume`, so the beam + goes off when the hold completes, not when it starts. `M4`: fire per + step 2.89 at 100 mm/s, 2.86 at 82, 3.02 at 64, 3.27 at 47, 3.74 at 29, + 5.41 at 13 (the 10 % floor). `M3`: 381 to 385 fire ticks in every + window, so fire per step rises from 2.9 to 22.4. +- **The dwell is dark.** Between the last step and the first step: 10 fire + ticks under `M4` (the tail of the last pulse, far inside the stepless + FIRE limit), 0 under `M3`. +- **`M4` resumes lit from its first step.** First fire 9 ticks after the + first step; fire per step 7.60 at 11 mm/s, 4.51 at 28, 3.67 at 46, 3.32 + at 63, 3.08 at 81, 2.94 at 96, 2.84 at 100: the deceleration's profile + in reverse. A pause under `M4` is a sharp corner in time. +- **`M3` resumes dark for 87 ms.** First fire 2453 ticks after the first + step; the first three windows (113 steps, 2.1 mm) carry no fire, the + fourth 198 ticks, then 384. The cause is not isolated; the restore's + laser-on reaches the stream about one segment buffer late. +- **Resume after the grace closed the window.** `~`: `[MSG:Restoring + spindle]`, then the arm prompt, then nothing: presses of 0.15 s and + 0.5 s were not honored, `?` kept answering `Hold:0` at the pause + position, `M5` got no ok. The controller sits in the arm wait until its + timeout (not waited out). Button: the press that resumes is still down + when the arm wait starts, so it is taken as the consent (`button + pressed - job resumed`, `Restoring spindle`, the prompt, `laser armed`, + all in one press) and the job continued lit with the plain `M4` profile. + +Disposition: item 7 is re-scoped to the `M3` resume lead plus a harness +rule; the `~` wedge is recorded under item 8, whose hold option depends on +it; the measurements are in the facts bank. Nothing changed in code. + +## 2026-09-01: the M3 resume lead and the held-job resume fixed, host-proven + +Both findings of the entry above, fixed the same day and proven on the +null-sink harnesses. Items 7 and 8 close. + +- **The `M3` resume lead, root cause.** A laser-push trace in the stream + engine showed the core issuing the `M3` relight 2451 producer ticks + after the first resume step: the segments a resume executes first are + prepared while the job is still held (`state_await_hold` clears the + step-control flags at hold completion and prepping proceeds from there), + and with `update_spindle_rpm` cleared an `M3` block prepares them + without a spindle update, so the level the restore sets reaches the + stream only with the first segment prepared after the buffer drains. + `M4` never showed it because a dynamic block updates every segment. + Fix, core fork (`state_machine.c`, hold completion): in laser mode reset + the stepper's rpm cache to 0 and set `update_spindle_rpm`, so the first + segment prepared while held re-asserts the programmed power. Proof: the + same drill, `M3` first fire 0 ticks after the first resume step (was + 2453), the relight landing at the segment load about 90 ticks before + the step, exactly as at a job start; `M4` unchanged (9 ticks). +- **The `~` wedge, root cause.** The resume's spindle restore runs inside + the held state; the blocking arm wait it reaches pumps + `protocol_execute_realtime`, which enters the core's suspend loop + (`while(sys.suspend)`) and spins there until the hold ends, so the arm + loop's switch read never runs again. The button path escaped only + because the resuming press was still down at the wait's first read. + Fix, driver: a resume gate (`gflaser_resume_gate`) that every cycle + start passes on its way to the core: the sender's `~` in `serial.c`, + the button toggle in Hold, and the cooling client's auto-resume. A held + laser job whose window has closed re-arms first, with the press + collected from the poll (`rearm_poll`, never inside the held state) and + the cycle start issued once the window is open; the blocking wait is + refused inside a held state as a belt-and-braces (dark, reported). The + arm flow is split into `arm_gates` and `arm_complete`, shared by both. + Proof: the same drill, `~` after the grace: the prompt, the press, + `laser armed`, the job finished at X=150 with the plain `M4` profile. +- **A sender change now holds the job.** `gflaser_poll` feed-holds a + running job before it disarms on a sender change, so the next sender + finds the cut in Hold where it stopped (the deceleration behind it runs + dark, since the consent belonged to the displaced session) and resumes + it through the gate, or resets it. +- **Harness coverage.** Stream harness rule 21 (`hold-m4`, `hold-m3`): + lit into the hold, dark while held, lit from the first step out, with + the realtime `!`/`~` steps and a `wait_state` helper added to the + session runner. Lifecycle harness: `sender-change-mid-job` now asserts + the hold and the `~` re-arm; new `sender-change-rearm` (button build: + the prompt on `~`, the press, the finish), `sender-change-reset` (a + reset from the held job ends in Idle, no alarm), `resume-after-grace` + (the window closes in Hold, `~` prompts, the press re-arms, the cut + finishes). Unit tests: `serial_test` and `laser_arm_test` stub the new + calls. + +**Bench, the same day, on dev image 20260831225403 with the controller +hot-deployed from the working tree** (cross-built with the recipe's own +toolchain and flags from the Yocto work directory; md5 c8494aac, the image +binary saved as `/tmp/grblHAL_glowforge.prev`). Two armed runs, driven +from the LAN by `live_fire_drills.py`, the operator on the button: + +- **`holdres`** (new drill): a 30 mm `M4` line at F300 S400 held about 2 s + in and resumed, then the 90 degree corner; the same hold under `M3` on + the return line; then a hold that outlived the grace. All three legs + held and resumed; the window closed in Hold after 61 s, `~` lit the + button with `press the button to resume the laser job`, the press + re-armed, the line finished. The operator judged the marks good: the + `M4` pause against the corner, and no dark lead after the `M3` pause. +- **`senderchg`** (rewritten for the hold): a 20 mm `M3` line at F60, the + connection dropped 5.0 s in. The job was held 1.9 s after the drop with + the window closed (`hv_current` dark 0.03 s after the drop), the new + session's `~` prompted at +9.7 s, the press re-armed at +15.2 s + (`laser armed`, then the core's `Restoring spindle`), and the rest of + the line marked: 44 lit samples before the drop, 0 between the drop and + the press, 106 after. Record `senderchg_20260901-180135.json` on the + driving host. + +Items 7 and 8 are bench-proven. The board runs the hot-deployed binary +until the next flash. + ## Reference notes ### Head-IRQ source validation — the beam-emission hypothesis diff --git a/forgetest/forgetest/suite/laser.py b/forgetest/forgetest/suite/laser.py index ef7ce06..9d99314 100644 --- a/forgetest/forgetest/suite/laser.py +++ b/forgetest/forgetest/suite/laser.py @@ -754,14 +754,14 @@ def arm_wait_lid(ctx): "Press the physical button when it lights white (arm). Once the cut is under way the " "test asks for a press (pause), then another (resume), then for the lid: open it and " "leave it open until the head has come back, then close it.", - "Keep the pause short: the armed window's idle grace closes it after about a minute in " - "a hold, and a job that disarms cannot resume its emission."], + "A pause longer than the armed window's idle grace (about a minute) closes the window; " + "the resume then lights the button again and waits for a press."], description="The machine's own controls during one armed burn, in the order the factory uses " "them. Press: the job feed-holds, emission stops, and the latch stays UNLOCKED " "with the armed window open - a pause is not a cancel. Press again: the cut " - "resumes from where it stopped (GRBL has no backtrack; the kernel refuses one on " - "a live-streamed ring), and the cut is M3 so the restart leaves a mark to " - "look at rather than one M4 hides. Lid: emission stops in hardware, the job is cancelled " + "resumes from where it stopped, lit from the first step (a pause is a sharp corner " + "in time; the cut is M3 so the corner dose is there to look at). Lid: emission " + "stops in hardware, the job is cancelled " "with the reason reported, the controller resets with the position kept and no " "alarm, the armed window closes and the kernel latch relocks, the hardware button " "latch reads SET, and the head returns to the job start with the lid still open.") diff --git a/scripts/bench/laser_lifecycle_test.py b/scripts/bench/laser_lifecycle_test.py index ac3ceec..5f76072 100644 --- a/scripts/bench/laser_lifecycle_test.py +++ b/scripts/bench/laser_lifecycle_test.py @@ -66,6 +66,7 @@ ARMED = "laser armed" DISARMED = "laser disarmed - latch locked" BLOCKED = "laser fire blocked" PROMPT = "press the button to start the laser job" +RESUME_PROMPT = "press the button to resume the laser job" LID_CANCEL = "lid opened during arm - job cancelled" LOOP_CANCEL = "interlock open during arm - job cancelled" @@ -363,12 +364,11 @@ def test_job_window(): def test_sender_change_mid_job(): """Rule 3, the hard case: the sender changes while the job is still - running with the spindle on, so the core never turns the spindle off - between the two sessions. The next laser-on from the new sender must - still prompt: the arm decision reads the window, not the spindle-state - record (a job whose M5 was lost leaves that record on the same way). - M3 after M4 is a state change the core always pushes through - set_state, planner-synced, so it lands once the first job's move ends.""" + running with the spindle on. The window closes and the job is HELD + where the cut stopped, so the next sender finds it in Hold rather than + at the end of a dark pass. Its cycle start goes through the resume + gate, which re-arms first: with no button on a host build that is + immediate, and the job then runs to its end lit.""" s = Session("sender-change-mid-job", disarm_s=60) try: send_line(s.sock, "M4 S100", s.log) @@ -378,22 +378,144 @@ def test_sender_change_mid_job(): time.sleep(1.0) s.sock.close() # mid-move, spindle on time.sleep(0.5) - # The disarm message is written while no client is connected, and - # output with no client is discarded, so the new session cannot - # see it; the re-arm below is the evidence the window closed. + # The disarm and hold messages are written while no client is + # connected, and output with no client is discarded, so the new + # session cannot see them; the Hold state and the re-arm below are + # the evidence. s.sock = s.connect() + st = s.wait_state("Hold", 5) + if st is None: + fail("[sender-change-mid-job] the job was not held on the sender change " + "(state %r)" % s.state()) + x_held = s.mpos_x() + if x_held is None or not 0.5 < x_held < 4.5: + fail("[sender-change-mid-job] held outside the move (MPos X %s)" % x_held) before = s.armed_count() - s.send_raw("M3 S100") # laser-on against a closed window + s.sock.sendall(b"~") # the new sender resumes end = time.time() + 15 while time.time() < end and s.armed_count() != before + 1: read_avail(s.sock, s.log, 0.2) if s.armed_count() != before + 1: - fail("[sender-change-mid-job] a laser-on after a mid-job sender change did not " - "re-arm: the arm read the stale spindle state instead of the window") - send_line(s.sock, "M5", s.log) + fail("[sender-change-mid-job] the resume after a mid-job sender change did " + "not re-arm") wait_idle(s.sock, s.log) - print("PASS [sender-change-mid-job]: a laser-on against a window closed mid-job " - "prompted and re-armed") + x_end = s.mpos_x() + if x_end is None or abs(x_end - 5.0) > 0.01: + fail("[sender-change-mid-job] the resumed job did not finish the move " + "(MPos X %s)" % x_end) + send_line(s.sock, "M5", s.log) + print("PASS [sender-change-mid-job]: the job held at X=%.3f on the sender change, " + "re-armed on the resume and finished at X=%.3f" % (x_held, x_end)) + finally: + s.close() + + +def test_sender_change_holds_and_rearms(): + """Rule 3 on a machine with a button: the held job's resume from the + new sender prompts for the press (collected from the poll, never + inside the core's held state), and the press re-arms and resumes.""" + s = Session("sender-change-rearm", disarm_s=60, switches=SW_CLOSED) + try: + s.send_raw("M4 S100") + if not wait_for(s.log, PROMPT, 5, s.sock): + fail("[sender-change-rearm] no arm prompt") + s.press_button() + if not wait_for(s.log, ARMED, 5, s.sock): + fail("[sender-change-rearm] the press did not arm") + send_line(s.sock, "G1 X5 F60", s.log) # 5 s of motion + time.sleep(1.0) + s.sock.close() # mid-move, spindle on + time.sleep(0.5) + s.sock = s.connect() + if s.wait_state("Hold", 5) is None: + fail("[sender-change-rearm] the job was not held on the sender change") + before = s.armed_count() + s.sock.sendall(b"~") + if not wait_for(s.log, RESUME_PROMPT, 5, s.sock): + fail("[sender-change-rearm] the resume did not prompt for the button") + if s.wait_state("Hold", 2) is None: + fail("[sender-change-rearm] the job left Hold before the press") + s.press_button() + end = time.time() + 10 + while time.time() < end and s.armed_count() != before + 1: + read_avail(s.sock, s.log, 0.2) + if s.armed_count() != before + 1: + fail("[sender-change-rearm] the press did not re-arm the held job") + wait_idle(s.sock, s.log) + x_end = s.mpos_x() + if x_end is None or abs(x_end - 5.0) > 0.01: + fail("[sender-change-rearm] the resumed job did not finish the move " + "(MPos X %s)" % x_end) + send_line(s.sock, "M5", s.log) + print("PASS [sender-change-rearm]: the held job prompted on ~, the press re-armed " + "it, and it finished at X=%.3f" % x_end) + finally: + s.close() + + +def test_sender_change_then_reset(): + """A new sender that does not want the held job resets it: a soft + reset from the hold ends the job with the position kept and no alarm.""" + s = Session("sender-change-reset", disarm_s=60) + try: + send_line(s.sock, "M4 S100", s.log) + send_line(s.sock, "G1 X5 F60", s.log) + if not wait_for(s.log, ARMED, 5, s.sock): + fail("[sender-change-reset] first laser-on did not arm") + time.sleep(1.0) + s.sock.close() + time.sleep(0.5) + s.sock = s.connect() + if s.wait_state("Hold", 5) is None: + fail("[sender-change-reset] the job was not held on the sender change") + s.sock.sendall(b"\x18") + st = s.wait_state("Idle", 5) + if st is None: + fail("[sender-change-reset] a reset from the held job did not end in Idle " + "(state %r)" % s.state()) + print("PASS [sender-change-reset]: a reset from the held job ends in Idle, no alarm") + finally: + s.close() + + +def test_resume_after_grace(): + """Rule 4, the other half: a job whose window closed while it sat in + Hold can still be resumed. The sender's ~ prompts for the button, the + press re-arms, and the cut finishes lit (the controller must not sit + in the arm wait inside the held state, where a press is never read).""" + s = Session("resume-after-grace", disarm_s=2, switches=SW_CLOSED) + try: + s.send_raw("M4 S100") + if not wait_for(s.log, PROMPT, 5, s.sock): + fail("[resume-after-grace] no arm prompt") + s.press_button() + if not wait_for(s.log, ARMED, 5, s.sock): + fail("[resume-after-grace] the press did not arm") + send_line(s.sock, "G1 X5 F60", s.log) # 5 s of motion + time.sleep(1.0) + s.sock.sendall(b"!") # feed hold + if not wait_for(s.log, DISARMED, 10, s.sock): + fail("[resume-after-grace] the window did not close in the hold") + before = s.armed_count() + s.sock.sendall(b"~") + if not wait_for(s.log, RESUME_PROMPT, 5, s.sock): + fail("[resume-after-grace] the resume did not prompt for the button") + if s.wait_state("Hold", 2) is None: + fail("[resume-after-grace] the job left Hold before the press") + s.press_button() + end = time.time() + 10 + while time.time() < end and s.armed_count() != before + 1: + read_avail(s.sock, s.log, 0.2) + if s.armed_count() != before + 1: + fail("[resume-after-grace] the press did not re-arm the held job") + wait_idle(s.sock, s.log) + x_end = s.mpos_x() + if x_end is None or abs(x_end - 5.0) > 0.01: + fail("[resume-after-grace] the resumed job did not finish the move " + "(MPos X %s)" % x_end) + send_line(s.sock, "M5", s.log) + print("PASS [resume-after-grace]: ~ prompted, the press re-armed the held job, " + "and it finished at X=%.3f" % x_end) finally: s.close() @@ -797,8 +919,11 @@ def main(): test_status_files() test_sender_change() test_sender_change_mid_job() + test_sender_change_holds_and_rearms() + test_sender_change_then_reset() test_rx_overrun_aborts() test_hold_grace() + test_resume_after_grace() test_button_wait_arms() test_lid_open_in_wait() test_button_pause_resume() diff --git a/scripts/bench/laser_stream_test.py b/scripts/bench/laser_stream_test.py index 94cf61e..b981bb0 100644 --- a/scripts/bench/laser_stream_test.py +++ b/scripts/bench/laser_stream_test.py @@ -353,6 +353,19 @@ def wait_idle(sock, log): fail("controller never returned to Idle") +def wait_state(sock, log, prefix, timeout=5.0): + """Poll '?' until the state word starts with prefix (e.g. 'Hold:0').""" + end = time.time() + timeout + while time.time() < end: + sock.sendall(b"?") + read_avail(sock, log, 0.3) + m = re.findall(r"<([A-Za-z]+(?::\d)?)", "".join(log[-3:])) + if m and m[-1].startswith(prefix): + return + time.sleep(0.1) + fail("controller never reached %s" % prefix) + + def publish_verdicts(path, stop): """Publish a fresh, clean cooling verdict every 0.5 s (the arm flow refuses without one; freshness window is 2 s). Same-host monotonic @@ -418,6 +431,10 @@ def run_session(name, steps, conf=None, workdir=None, keep=False, wait_idle(sock, log) elif isinstance(step, tuple) and step[0] == "sleep": time.sleep(step[1]) + elif isinstance(step, tuple) and step[0] == "rt": + sock.sendall(step[1]) # a realtime character: no ok follows + elif isinstance(step, tuple) and step[0] == "wait_state": + wait_state(sock, log, step[1]) else: send_line(sock, step, log) @@ -929,6 +946,56 @@ def main(): print("PASS [curve]: S %s -> densities %s through the bench-default curve " "(floored at %.3f)" % (list(CURVE_S), [round(g, 3) for g in got], floor_frac)) + # --- rule 21: a feed hold leaves no dark ground, in either mode ----- + # One long line at 100 mm/s, held mid-move and resumed. The planned + # deceleration runs lit (M4 velocity-scaled, M3 constant), the + # stationary stretch is dark, and the acceleration out of the hold is + # lit from its first step: a pause is a sharp corner in time. The + # first-window check is what pins the M3 resume, which once ran dark + # for a segment buffer because the segments prepped while held + # carried no spindle update. + HOLD_WIN = 704 # 25 ms at 28160 Hz + HOLD_EDGE = 120 # one pulse straddles each edge + for mode, job in (("m4", ["G90", "G21", "M4 S0", "G1 X150 F6000 S500"]), + ("m3", ["G90", "G21", "M3 S500", "G1 X150 F6000"])): + steps = job + [("sleep", 0.7), ("rt", b"!"), ("wait_state", "Hold:0"), + ("sleep", 0.5), ("rt", b"~"), WAIT_IDLE, "M5"] + data = run_session("hold-" + mode, steps, conf=DENSITY_CONF_FLOORED) + check_fire_gaps("hold-" + mode, data) + ticks = tick_bytes(data) + step = [1 if t & 0x05 else 0 for t in ticks] + fire = [1 if t & 0x10 else 0 for t in ticks] + first = step.index(1) + last = len(step) - 1 - step[::-1].index(1) + best, run = (0, 0), 0 + for i in range(first, last + 1): + if step[i]: + if run > best[0]: + best = (run, i - run) + run = 0 + else: + run += 1 + dlen, dstart = best + dend = dstart + dlen + if dlen < 2000: + fail("[hold-%s] no hold in the stream (longest stepless run %d ticks)" + % (mode, dlen)) + decel = sum(fire[dstart - HOLD_WIN:dstart]) + dwell = sum(fire[dstart + HOLD_EDGE:dend - HOLD_EDGE]) + accel = sum(fire[dend:dend + HOLD_WIN]) + if decel < 50: + fail("[hold-%s] the deceleration into the hold ran dark (%d fire ticks in " + "its last 25 ms)" % (mode, decel)) + if dwell: + fail("[hold-%s] FIRE while held: %d fire ticks in the stationary stretch" + % (mode, dwell)) + if accel < 50: + fail("[hold-%s] the resume ran dark (%d fire ticks in the 25 ms after the " + "first step)" % (mode, accel)) + print("PASS [hold-%s]: lit into the hold (%d fire ticks), dark while held " + "(%d ticks), lit from the first step out (%d fire ticks)" + % (mode, decel, dlen, accel)) + print("PASS: all stream emission rules hold") diff --git a/scripts/bench/live_fire_drills.py b/scripts/bench/live_fire_drills.py index 00c6d8a..36a0599 100644 --- a/scripts/bench/live_fire_drills.py +++ b/scripts/bench/live_fire_drills.py @@ -147,11 +147,17 @@ Drills (pass a name): and fits rise against dose. JSON records like dpatch. flowload t1 | flowload t2 [pct] | flowload fit senderchg A sender change mid-job: a 20 mm line at F60 lit on the - press, the connection dropped five seconds in, a reconnect, - the move finishing dark, then a fresh M3 and a 5 mm line that - must prompt for the button again. PASS: two prompts, two arms, - no lit sample between the drop and the second press, the - second line marks. Two presses; JSON record. + press, the connection dropped five seconds in, the job held + where it stopped, a reconnect, then ~ from the new session, + which must light the button and wait. PASS: the hold, the + resume prompt, no lit sample between the drop and the second + press, the rest of the line marks. Two presses; JSON record. + holdres Feed hold and resume, the pause as a corner in time: a 30 mm + M4 line held near 10 mm and resumed, then a 90 degree corner + to compare the marks; the same hold under M3 on the return + line (no dark lead on the resume); then a hold past the + disarm grace, resumed with ~ through the re-arm prompt. Two + presses: the arm, and the re-arm after the long hold. overrun An RX overrun mid-job: a 20 mm line at F60 lit on the press, a 93-line fill written at once three seconds in (about 1270 bytes against the 1023-byte ring). PASS: the controller @@ -2389,7 +2395,6 @@ def drill_flowload(g): SENDERCHG_S = 400 SENDERCHG_LINE1 = 'G1 X20 F60' # 20 s of motion SENDERCHG_DROP_S = 5.0 # lit seconds before the drop -SENDERCHG_LINE2 = 'G1 X5 F600' def senderchg_wait_reply(g, needle, timeout): @@ -2420,9 +2425,9 @@ def drill_senderchg(g): poller.start() arm_cue() print('>>> Scrap with 25 mm of free +X travel from the head. TWO presses: one') - print('>>> for the first line, and a second one after the reconnect, when the') - print('>>> prompt comes again. The line goes dark at the drop and the head') - print('>>> finishes the move without a sender; that is expected.\n') + print('>>> for the line, and a second one after the reconnect, when the button') + print('>>> lights again. The line goes dark at the drop and the head HOLDS') + print('>>> where it is; the new session resumes it with your second press.\n') print('G91/G21: %s / %s' % (g.cmd('G91'), g.cmd('G21'))) t_m3 = time.time() for ln in ('M3 S%d' % SENDERCHG_S, SENDERCHG_LINE1, 'M5'): @@ -2445,31 +2450,33 @@ def drill_senderchg(g): time.sleep(1.0) g2 = Grbl(HOST, PORT) # a new session t_reconnect = time.time() - st = g2.wait_state('Idle', 40) - t_idle1 = time.time() - print(' reconnected; the first move ended dark %.1f s after the drop (state %s)' - % (t_idle1 - t_drop, st)) + st = g2.wait_state('Hold', 10) + print(' reconnected; the job is held %.1f s after the drop (state %s)' + % (time.time() - t_drop, st)) cs = sample_forgectrl() or {} print(' engine after the drop: armed=%s verdict=%s' % (cs.get('armed'), cs.get('verdict'))) outcome = 'done' t_run2 = None try: - for ln in ('M3 S%d' % SENDERCHG_S, SENDERCHG_LINE2, 'M5'): - g2.s.sendall(ln.encode() + b'\n') - prompt2 = senderchg_wait_reply(g2, 'press the button', 10) - print(' second prompt after the reconnect: %s' % prompt2) - if not prompt2: - outcome = 'no-prompt' + if not st.startswith('Hold'): + outcome = 'not-held' g2.rt(b'\x18') else: - st = g2.wait_state('Run', FLOWLOAD_PRESS_WAIT_S) - if not st.startswith('Run'): - outcome = 'no-run' + g2.rt(b'~') # the new sender resumes the held job + prompt2 = senderchg_wait_reply(g2, 'press the button to resume', 10) + print(' resume prompt after the reconnect: %s' % prompt2) + if not prompt2: + outcome = 'no-prompt' g2.rt(b'\x18') else: - t_run2 = time.time() - g2.wait_state('Idle', 30) - print(' M2: %s' % g2.cmd('G90\nM2', timeout=10)) + st = g2.wait_state('Run', FLOWLOAD_PRESS_WAIT_S) + if not st.startswith('Run'): + outcome = 'no-run' + g2.rt(b'\x18') + else: + t_run2 = time.time() + g2.wait_state('Idle', 60) + print(' M2: %s' % g2.cmd('G90\nM2', timeout=10)) finally: try: g2.cmd('M5', timeout=1) @@ -2499,13 +2506,14 @@ def drill_senderchg(g): print(' %+7.1f s armed=%s' % (p['t'] - t_m3, p['armed'])) last = p['armed'] print('--- emission (lit samples at 25 Hz) ---') - print(' first line, press to drop: %d samples (%.1f s)' % (len(lit1), len(lit1) / 25.0)) + print(' line, press to drop: %d samples (%.1f s)' % (len(lit1), len(lit1) / 25.0)) print(' drop to second press: %d samples <- must be 0' % len(lit_gap)) - print(' second line: %d samples (%.1f s)' % (len(lit2), len(lit2) / 25.0)) + print(' the resumed rest of it: %d samples (%.1f s)' % (len(lit2), len(lit2) / 25.0)) ok = outcome == 'done' and prompt1 and len(lit1) > 25 and not lit_gap and len(lit2) > 5 print('\n%s: %s' % ('PASS' if ok else 'FAIL', - 'the drop closed the window, nothing fired until the second press, ' - 'and the second laser-on prompted and armed' if ok else outcome)) + 'the drop held the job and closed the window, nothing fired until ' + 'the second press, and ~ from the new session prompted, re-armed ' + 'and finished the line' if ok else outcome)) rec = {'drill': 'senderchg', 'date': time.strftime('%Y-%m-%dT%H:%M:%S'), 't_m3': t_m3, 't_run1': t_run1, 't_drop': t_drop, 't_reconnect': t_reconnect, 't_run2': t_run2, 'outcome': outcome, 'pass': bool(ok), @@ -3092,6 +3100,129 @@ def drill_expstop(g): return trail +# --- feed hold and resume: the pause is a corner in time ---------------------- + +# One armed run on scrap, at F300 and S400. Under M4 a 30 mm line is held +# about 2 s in and resumed a second later, then the path turns 90 degrees: +# the pause mark near 10 mm and the corner at 30 mm sit on the same scrap +# for the eye, and should match. Under M3 the same hold on the return +# line: the resume must show no dark lead. Then a hold that outlives the +# disarm grace: the window closes in Hold, the sender's ~ must light the +# button and wait, the press re-arms, and the line finishes lit. Two +# presses: the arm, and the re-arm after the long hold. +HOLDRES_S = 400 +HOLDRES_F = 300 + + +def holdres_pause(g, label, wait_disarm=False): + """Hold the running move ~2 s in, then resume; with wait_disarm the hold + outlives the grace and the resume goes through the re-arm prompt.""" + st = g.wait_state('Run', 20) + if not st.startswith('Run'): + return 'no-run (%s)' % st + time.sleep(2.0) + g.rt(b'!') + st = g.wait_state('Hold', 5) + if not st.startswith('Hold'): + return 'no-hold (%s)' % st + seen = len(g.log) + if wait_disarm: + t0 = time.time() + while time.time() - t0 < 120: + s = sample_forgectrl() + if s and not s['armed']: + break + time.sleep(1) + else: + return 'still armed after 120 s in Hold' + print(' %s: the window closed in Hold after %.0f s; sending ~' % (label, time.time() - t0)) + g.rt(b'~') + if not senderchg_wait_reply(g, 'press the button to resume', 10): + return 'no resume prompt on ~' + print(' >>> PRESS the button to resume the job') + st = g.wait_state('Run', FLOWLOAD_PRESS_WAIT_S) + if not st.startswith('Run'): + return 'no-run after the press (%s)' % st + if not any('laser armed' in ln for _t, ln in g.log[seen:]): + return 'resumed without re-arming' + else: + time.sleep(1.0) + g.rt(b'~') + st = g.wait_state('Run', 5) + if not st.startswith('Run'): + return 'no-run on ~ (%s)' % st + st = g.wait_state('Idle', 60) + return 'ok' if st.startswith('Idle') else 'never idle (%s)' % st + + +def drill_holdres(g): + print('=== feed hold and resume: the pause is a corner in time ===') + print('connect: %s' % prepare(g)) + print('spindle off: %s' % g.cmd('M5')) + pre = sample_forgectrl() + if pre is None or pre.get('armed'): + print('REFUSED: the armed window is still open (or forgectrl is unreachable)') + return 2 + arm_cue() + print('>>> Scrap with 35 mm of free +X and 25 mm of free +Y travel. TWO presses:') + print('>>> one to arm, one when the button lights again after the long hold.\n') + print('G91/G21: %s / %s' % (g.cmd('G91'), g.cmd('G21'))) + results = [] + + def leg(lines): + for ln in lines: + g.s.sendall(ln.encode() + b'\n') + + def plain(lines): + leg(lines) + g.wait_state('Run', 20) + g.wait_state('Idle', 60) + + try: + # A: M4, a 30 mm line held near 10 mm, then the corner leg. + leg(('M4 S%d' % HOLDRES_S, 'G1 X30 F%d' % HOLDRES_F)) + if not senderchg_wait_reply(g, 'press the button', 10): + print('ABORTED: no arm prompt') + return 1 + r = holdres_pause(g, 'M4 pause') + results.append(('A M4 pause beside the corner', r)) + print(' A: %s' % r) + if r != 'ok': + return 1 + plain(('G1 Y10 F%d' % HOLDRES_F,)) + # B: M3 on the return line, the same hold. + leg(('M3 S%d' % HOLDRES_S, 'G1 X-30 F%d' % HOLDRES_F)) + r = holdres_pause(g, 'M3 pause') + results.append(('B M3 pause, no dark lead', r)) + print(' B: %s' % r) + if r != 'ok': + return 1 + # C: back under M4, a plain leg up, then the hold past the grace. + leg(('M4 S%d' % HOLDRES_S,)) + plain(('G1 Y10 F%d' % HOLDRES_F,)) + leg(('G1 X30 F%d' % HOLDRES_F,)) + r = holdres_pause(g, 'grace', wait_disarm=True) + results.append(('C hold past the grace, ~ re-arms', r)) + print(' C: %s' % r) + finally: + try: + g.cmd('M5', timeout=1) + except Exception: + pass + if 'Hold' in g.status(): + g.rt(b'\x18') + ok = bool(results) and all(r == 'ok' for _n, r in results) + print('\n--- results ---') + for name, r in results: + print(' %s: %s' % (name, r)) + print('\n%s: %s' % ('PASS' if ok else 'FAIL', + 'held and resumed under M4 and M3, and the hold past the grace ' + 're-armed on ~ and finished; now judge the marks: the M4 pause ' + 'against the corner, the M3 pause for a dark lead' if ok + else 'see above')) + return 0 if ok else 1 + + def drill_ctrlstart(g): """Resume supervision after expstop: POST /controller/start, then report /mode. No motion, no laser.""" @@ -3114,6 +3245,7 @@ def main(): 'dpatch': drill_dpatch, 'flowload': drill_flowload, 'm4corner': drill_m4corner, 'm4feeds': drill_m4feeds, 'senderchg': drill_senderchg, 'overrun': drill_overrun, + 'holdres': drill_holdres, 'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart} if drill not in drills: print(__doc__)