mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
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:
+38
-54
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
Reference in New Issue
Block a user