laser milestone: grblhal SRCREV -> 09bc882, host stream test, docs

BRINGUP item 2: grbl-mode laser software implemented + bench-verified
without fire (record in the gate list); first light pending.
LIGHTBURN.md: arming/button-press operation, S-max 1000, fire gates.
scripts/bench/laser_stream_test.py: host-side stream-dump contract
verification against the null-sink build.
This commit is contained in:
ScottW514
2026-08-09 16:13:03 -04:00
parent 2219ec24f7
commit 3c095ccbd1
4 changed files with 309 additions and 8 deletions
+77
View File
@@ -640,6 +640,83 @@ accordingly ("Automatic — AP country, else World").
(bit 7) + bit 4 laser-enable, M3/M4/$32 semantics, PWM-reset rule per (bit 7) + bit 4 laser-enable, M3/M4/$32 semantics, PWM-reset rule per
the contract. **No live fire before the standing scope gates.** Gate the contract. **No live fire before the standing scope gates.** Gate
status: status:
- **GRBL-MODE LASER SOFTWARE: IMPLEMENTED 2026-08-09, bench-verified
without fire. FIRST LIGHT PENDING (operator-run, chain armed).**
- Architecture: the real spindle lives in
`grblHAL-glowforge/src/glowforge_laser.c`; per-segment spindle
updates (the core's laser-mode path, running on the stepper
producer thread at exact virtual-tick positions) map power/fire
transitions onto the pulse-byte grid via `gf_stream_laser()`,
and the shipper emits them: a power byte (0x80 | 7-bit duty,
raw PWMSAR counts, 127 = 100 %) inserted ahead of the first
tick byte it covers, FIRE as bit 4 OR'd into tick bytes. The
spindle PWM is precomputed to a period of exactly 127 so
computed values ARE power bytes ($30 default 1000 → S1000 =
127). Contract rules enforced structurally: a power byte leads
every kernel run before any fire bit (run start resets duty to
~100 %), transitions are coalesced per tick so power bytes are
never consecutive, and power bytes cost no machine tick (the
SDMA script processes the following byte in the same EPIT
interrupt), leaving the wall-clock due math untouched. Fire
only ever rides motion segments of laser blocks - jogs, G0 and
homing are fire-free by construction, and the end-of-data
backstop covers every stream end.
- **Arming - the operator's button press is required.** The first
laser-on of a job (M3/M4, always planner-synced by the core)
refuses outright if a coolant fire gate stands, else forces the
run fan profile on, unlocks the kernel laser latch, lights the
button white and blocks the gcode stream - pumping real-time
traffic exactly like the homing session - until the operator
presses the physical button (EV_SW bit 2), a soft reset aborts,
or `laser_button_timeout_s` (default 300 s) expires into
alarm 3. The armed window survives S changes and M5/M3 toggles
(no re-prompt mid-job) and closes - relocking the latch - after
`laser_disarm_s` (default 60 s) of spindle-off idle, or
immediately on alarm/homing/reset/stream fault. Both keys live
in the shared machine config, re-read per arm.
- **Underrun policy while armed: fail safe, no retry.** The
stop/run recovery restarts the kernel run, which resets the
duty to ~100 % - replaying queued fire bits would fire at full
power - so an armed underrun acks the kernel and faults (alarm,
latch relock). Motion-only streams keep the one-shot retry.
- **Coolant fire gates live** (`gfcool_fire_ok`): flow FAULT or
over-ceiling coolant temperature (resume-gate hysteresis)
blocks arming and suppresses fire mid-job with a loud warning.
While armed the run fan profile + flow interrogation are forced
on regardless of the sender's M8/M9; a flow SUSPECT/FAULT
verdict inside an armed window takes the safe posture (feed
hold + run airflow; laser mode drops the spindle in hold).
SUSPECT auto-resumes on a clean re-check; FAULT leaves the hold
and the gate for the operator.
- **Host verification** (`scripts/bench/laser_stream_test.py`,
null-sink + `GFSINK_DUMP` stream capture, M4 job S500→S1000
with a G0 return): power byte leads the stream, no consecutive
power bytes, first FIRE bit rides nonzero duty, M4 dynamic
accel scaling visible (duties 44/52 on the ramp), S500 plateau
63 / S1000 127 exact, 28 354 fire ticks = the cutting time at
28160 Hz, X peak 533 steps net 0 (steps survive the
insertions), and 534 dark steps after the last fire bit = the
entire G0 return.
- **On-board no-fire verification 15/15 PASS** (chain unarmed,
nobody at the button; `laser_arm_test.py` drill): latch locked
at idle and through jogs (interlock_circuit 13), M4 → prompt +
latch unlocked (5) + button LED white + run fans forced +
status served during the wait, soft-reset abort relocks + LED
off, 3 s timeout drill → warning + ALARM:3 + relock, jogs
clean after. One transient on the first-ever arm: the
air-assist run write didn't land (204) - a head-I²C first-write
blip; deterministic PASS on every rerun, and real jobs re-apply
run fans with every M8. Note for senders: a disconnecting
sender leaves a pending arm wait until the button timeout
clears it (latch relocks then).
- **Remaining for first light** (operator present, coolant
flowing, never autonomous): the chain-armed procedure itself;
verify the hardware button latch persists across kernel-run
gaps mid-job (if OK_2_FIRE drops between motion bursts, the
fix is a stream keepalive across armed gaps); interlock-trip
recovery; warm-baseline flow-check behavior under real laser
heating; then the planned low-temperature gates and TEC
handling below.
- **LASER_PWM waveform: PASSED 2026-08-02** (scope on the physical - **LASER_PWM waveform: PASSED 2026-08-02** (scope on the physical
pin). Method: direct PWMSAR duty steps (`scripts/bench/pwm_sweep.py` pin). Method: direct PWMSAR duty steps (`scripts/bench/pwm_sweep.py`
/ `pwm_hold.py`) with the controller stopped, cnc `disabled` / `pwm_hold.py`) with the controller stopped, cnc `disabled`
+25 -7
View File
@@ -1,11 +1,29 @@
# LightBurn setup & operation (ForgeFIRM, motion-only stage) # LightBurn setup & operation (ForgeFIRM)
Status: motion-only. At this Status: laser software implemented, **first light pending** (see
stage **the laser cannot fire** — the driver forces the hardware laser BRINGUP.md for the commissioning record). The laser fires only inside
latch locked and never emits the laser bit in the pulse stream. A "job" an operator-armed window:
runs every motion of the design (cuts at cut speed, travels at travel
speed) with the beam off. Live fire is a later milestone, gated on the - **Starting a job that fires: press the button.** At the first
standing scope checks (BRINGUP.md). laser-on command of a job the machine unlocks its laser latch,
lights the big button **white**, and pauses the incoming gcode until
you **press the button** (the same press the factory firmware
requires). LightBurn simply waits; press the button and the job
runs. If nobody presses within `laser_button_timeout_s` (default
300 s) the job aborts with alarm 3. Stop in LightBurn (soft reset)
cancels the wait at any time.
- One press covers the whole job — power changes and M5/M3 toggles do
not re-prompt. The window relocks after `laser_disarm_s` (default
60 s) of idle with the spindle off; the next job prompts again.
- S-value scale: `$30` defaults to 1000, so set LightBurn's S-max to
1000. 100 % power = S1000. Use M4 (variable/dynamic) mode for cuts
and engraves.
- The machine forces the cut fan profile on while armed and
continuously verifies coolant flow; a flow fault or over-temperature
pauses/blocks firing (messages appear in LightBurn's console).
- The hardware safety chain stands above all of this: lid open,
interlock open, or power faults make firing physically impossible
regardless of software state.
## One-time device setup ## One-time device setup
@@ -11,7 +11,7 @@ PV = "0.1.0"
# upstream). # upstream).
SRC_URI = "gitsm://github.com/ScottW514/grblHAL-glowforge.git;protocol=https;branch=main" SRC_URI = "gitsm://github.com/ScottW514/grblHAL-glowforge.git;protocol=https;branch=main"
# Pinned; bump deliberately after pushing grblHAL-glowforge changes. # Pinned; bump deliberately after pushing grblHAL-glowforge changes.
SRCREV = "b5c6d9c868b0c8bab7c6f15fd55134ffef1dc0be" SRCREV = "09bc882191e234318aabb186fcaec5d482fa9e79"
SRC_URI += "file://grblhal.init" SRC_URI += "file://grblhal.init"
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Host-side verification of the laser pulse-stream emission.
Runs the native grblHAL_glowforge binary in null-sink mode with
GFSINK_DUMP capturing the shipped byte stream, drives a small M4 laser
job over TCP, then checks the dump against the kernel feeder contract:
1. a power byte (bit 7) leads the stream, before any tick byte
2. no two consecutive power bytes (the SDMA script drops the second)
3. the first FIRE bit (0x10) comes after a nonzero power byte
4. power values match the S words ($30=1000 -> S500 = 63, S1000 = 127)
5. FIRE only spans the cutting moves: none before the job, none during
the G0 return, none at the tail
6. step accounting survives the insertions: X returns to net zero and
peaks at the programmed 10 mm
Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge)
"""
import os
import re
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build-native/grblHAL_glowforge")
PORT = 2399
STEPS_PER_MM = 53.333
JOB = [
"M4 S0",
"G1 X5 F600 S500",
"G1 X10 S1000",
"G0 X0",
"M5",
]
def fail(msg):
print("FAIL: %s" % msg)
sys.exit(1)
def send_line(sock, line, log):
sock.sendall((line + "\n").encode())
while True:
r = read_avail(sock, log, 5.0, until=("ok", "error"))
if r is None:
fail("no ok/error for %r" % line)
if r == "error":
fail("error response to %r" % line)
return
def read_avail(sock, log, timeout, until=None):
end = time.time() + timeout
buf = b""
while time.time() < end:
sock.settimeout(max(0.05, end - time.time()))
try:
data = sock.recv(4096)
except socket.timeout:
data = b""
if data:
buf += data
log.append(data.decode(errors="replace"))
if until:
for token in until:
if re.search(r"^%s\b" % token, buf.decode(errors="replace"), re.M):
return token
elif until is None:
return None
return None
def main():
workdir = tempfile.mkdtemp(prefix="laser-test-")
dump = os.path.join(workdir, "stream.bin")
env = dict(os.environ, GFSINK_DUMP=dump)
env.pop("GFSINK", None)
proc = subprocess.Popen([BIN, "-p", str(PORT)], cwd=workdir, env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
try:
sock = None
for _ in range(50):
try:
sock = socket.create_connection(("127.0.0.1", PORT), timeout=1)
break
except OSError:
time.sleep(0.1)
if sock is None:
fail("cannot connect to the controller")
log = []
read_avail(sock, log, 0.5) # banner / hello
for line in JOB:
send_line(sock, line, log)
# Wait for the motion to play out on the wall clock (the shipper
# is wall-paced), then for the Idle report.
idle = False
for _ in range(100):
sock.sendall(b"?")
read_avail(sock, log, 0.3)
if re.search(r"<Idle", "".join(log[-3:])):
idle = True
break
time.sleep(0.2)
if not idle:
fail("controller never returned to Idle")
time.sleep(1.0) # let the shipper drain the tail
text = "".join(log)
if "laser armed" not in text:
fail("no 'laser armed' message (arming flow did not run)")
sock.close()
finally:
proc.send_signal(signal.SIGINT)
try:
proc.wait(5)
except subprocess.TimeoutExpired:
proc.kill()
data = open(dump, "rb").read()
if not data:
fail("empty stream dump")
# --- contract checks -------------------------------------------------
if not data[0] & 0x80:
fail("stream does not lead with a power byte (first byte 0x%02x)" % data[0])
prev_power = False
cur_power = 0
fire_ticks = [] # (tick_index, power_at_that_tick)
x_pos = 0
x_min = x_max = 0
tick = 0
first_fire_power = None
for b in data:
if b & 0x80:
if prev_power:
fail("consecutive power bytes at tick %d" % tick)
prev_power = True
cur_power = b & 0x7F
continue
prev_power = False
if b & 0x10:
if first_fire_power is None:
first_fire_power = cur_power
fire_ticks.append((tick, cur_power))
if b & 0x01:
x_pos += -1 if b & 0x02 else 1
x_min = min(x_min, x_pos)
x_max = max(x_max, x_pos)
if b & 0x24:
fail("unexpected Y/Z step at tick %d (byte 0x%02x)" % (tick, b))
tick += 1
if not fire_ticks:
fail("no FIRE bits in the stream")
if first_fire_power == 0:
fail("first FIRE bit rides duty 0 (power-before-fire violated)")
powers = sorted(set(p for _, p in fire_ticks))
if powers[-1] != 127:
fail("S1000 did not reach duty 127 (max %d)" % powers[-1])
if not any(60 <= p <= 66 for p in powers):
fail("S500 plateau (~63) not seen (powers %s)" % powers[:20])
expect_peak = round(10 * STEPS_PER_MM)
if abs(x_max - expect_peak) > 2:
fail("X peak %d steps, expected ~%d" % (x_max, expect_peak))
if x_pos != 0:
fail("X net %d steps after return to 0" % x_pos)
if x_min < 0:
fail("X went negative (min %d)" % x_min)
last_fire = fire_ticks[-1][0]
tail_steps = 0
tick = 0
prev_power = False
for b in data:
if b & 0x80:
prev_power = True
continue
if tick > last_fire and b & 0x01:
tail_steps += 1
tick += 1
if tail_steps < 400:
fail("only %d fire-free steps after the last FIRE bit - G0 return not dark" % tail_steps)
print("PASS: %d bytes, %d power bytes, %d fire ticks, powers %s, "
"X peak %d steps net 0, %d dark return steps"
% (len(data), sum(1 for b in data if b & 0x80), len(fire_ticks),
powers, x_max, tail_steps))
shutil.rmtree(workdir, ignore_errors=True)
if __name__ == "__main__":
main()