mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
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:
@@ -640,6 +640,83 @@ accordingly ("Automatic — AP country, else World").
|
||||
(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
|
||||
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
|
||||
pin). Method: direct PWMSAR duty steps (`scripts/bench/pwm_sweep.py`
|
||||
/ `pwm_hold.py`) with the controller stopped, cnc `disabled`
|
||||
|
||||
+25
-7
@@ -1,11 +1,29 @@
|
||||
# LightBurn setup & operation (ForgeFIRM, motion-only stage)
|
||||
# LightBurn setup & operation (ForgeFIRM)
|
||||
|
||||
Status: motion-only. At this
|
||||
stage **the laser cannot fire** — the driver forces the hardware laser
|
||||
latch locked and never emits the laser bit in the pulse stream. A "job"
|
||||
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
|
||||
standing scope checks (BRINGUP.md).
|
||||
Status: laser software implemented, **first light pending** (see
|
||||
BRINGUP.md for the commissioning record). The laser fires only inside
|
||||
an operator-armed window:
|
||||
|
||||
- **Starting a job that fires: press the button.** At the first
|
||||
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
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ PV = "0.1.0"
|
||||
# upstream).
|
||||
SRC_URI = "gitsm://github.com/ScottW514/grblHAL-glowforge.git;protocol=https;branch=main"
|
||||
# Pinned; bump deliberately after pushing grblHAL-glowforge changes.
|
||||
SRCREV = "b5c6d9c868b0c8bab7c6f15fd55134ffef1dc0be"
|
||||
SRCREV = "09bc882191e234318aabb186fcaec5d482fa9e79"
|
||||
|
||||
SRC_URI += "file://grblhal.init"
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user