From 3c095ccbd1790d582b82915af77e21bc37cf8aa3 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sun, 9 Aug 2026 16:13:03 -0400 Subject: [PATCH] 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. --- docs/BRINGUP.md | 77 +++++++ docs/LIGHTBURN.md | 32 ++- .../grblhal-glowforge/grblhal-glowforge.bb | 2 +- scripts/bench/laser_stream_test.py | 206 ++++++++++++++++++ 4 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 scripts/bench/laser_stream_test.py diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index cdf3074..709f611 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -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` diff --git a/docs/LIGHTBURN.md b/docs/LIGHTBURN.md index a875e93..8c1d51f 100644 --- a/docs/LIGHTBURN.md +++ b/docs/LIGHTBURN.md @@ -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 diff --git a/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb b/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb index fa9c8ae..9e2f7a9 100644 --- a/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb +++ b/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge.bb @@ -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" diff --git a/scripts/bench/laser_stream_test.py b/scripts/bench/laser_stream_test.py new file mode 100644 index 0000000..ae03d6a --- /dev/null +++ b/scripts/bench/laser_stream_test.py @@ -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" 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()