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/**.
This commit is contained in:
ScottW514
2026-09-01 18:03:45 -04:00
parent 6a48cd3969
commit 8a9c6a9062
6 changed files with 530 additions and 103 deletions
+38 -54
View File
@@ -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
+119
View File
@@ -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
+5 -5
View File
@@ -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.")
+140 -15
View File
@@ -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()
+67
View File
@@ -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")
+161 -29
View File
@@ -147,11 +147,17 @@ Drills (pass a name):
and fits rise against dose. JSON records like dpatch.
flowload t1 | flowload t2 <secs> [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__)