diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 4cd9997..480eb44 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -315,6 +315,20 @@ regardless of the sender's M8/M9; a SUSPECT/FAULT verdict inside an armed window takes the safe posture (feed hold + run airflow). SUSPECT auto-resumes on a clean re-check; FAULT leaves the hold and the gate for the operator. +**The controller publishes its state for the daemon.** forgectrl can +never open the Grbl socket (a connection displaces the sender), so the +controller writes two files under `/run/forgefirm`, atomically, on +edges: `grbl.settings` (the `$$` view, rewritten on every setting +change, the M101 switch included) and `grbl.state` (JSON: machine state +and alarm, the sender session with peer and generation, the laser's +armed window and dose model with its floor, the exact `[GC:...]` modal +report, overrides, driver version, `ts_mono` for age; on change plus a +5 s heartbeat). forgectrl echoes the state file in `GET /status` as the +`grbl` block only while it supervises a live GRBL controller, serves +the settings file at `GET /grbl/settings`, and the panel's GRBL card +renders it. Position stays out: it changes per segment and is served +from the kernel counters. Contract: `forgectrl/docs/SERVICES.md`. + **Emission evidence.** `cnc/laser_on_sampled` (surfaced as `/status` `laser.emission_samples`) is the reliable live-emission witness; emission sensed with no armed window relocks the latch and stops motion. `pic/hv_current` diff --git a/docs/CAMPAIGN-LOG.md b/docs/CAMPAIGN-LOG.md index d1af650..596e153 100644 --- a/docs/CAMPAIGN-LOG.md +++ b/docs/CAMPAIGN-LOG.md @@ -5998,6 +5998,8 @@ Each run: `cool_flow_recheck_s = 600` for the run and removed after, the `dpatch Built the same day, host-proven, no fire: the dose-model switch. `M101 P0` (analog) / `M101 P1` (density) as a driver M-code, refused with the spindle commanded on (error 253, reason reported) or the controller not idle, program-scoped with `Q1` to stick; the per-model floors as config keys (`laser_floor_density` 10, `laser_floor_analog` 16) loaded into `$35` in RAM at every arm and switch with the PWM mapping re-precomputed, the stored setting never written; the stream leading the first run after a switch dark; the cooling report carrying the model in force (`model=` on `POST /cool/state`, the engine preferring it to the config key for the tube-heat share); the five `laser_*` keys in forgectrl's settings whitelist and the panel's GRBL tab with help text; `laser.power-floor` made model-aware and the new catalog test `laser.power-model-switch`. Proof: `tests/laser_arm_test.c` cases J to O (derived floors, validate and execute refusals, switch, revert, Q1, reset), `laser_stream_test.py` rules 18 to 21 (a typed `$35` overwritten at the arm; both switch directions rendering exactly at the boundary with no continuous FIRE at full duty across it; the refusal leaving the stream unchanged; `M2` reverting and `Q1` holding), forgectrl's host tests. Found on the way: the core skips every G-code line after an error until the sender resyncs with an empty line or a `$` command (`protocol.c`), which is how the harness now follows a refused switch. One flake, not a defect: rule 13's mask comparison slipped one tick at the tail while a build ran alongside in the same VM (the shipper is wall-paced); three runs alone were identical. +Built the same evening, host-proven: the controller's published state (the C6 gap, shaped as files by the operator's decision rather than an HTTP push). The controller writes `grbl.settings` and `grbl.state` under `/run/forgefirm` atomically on edges plus a heartbeat; forgectrl echoes the state file in `/status` as the `grbl` block only while its supervisor holds a live GRBL controller, serves the settings file at `GET /grbl/settings`, and the panel's GRBL card renders the sender session, machine state, laser window with its dose model, and the modal report. Proof: the new `status_grbl_test` (fresh, torn, stale and dead-controller cases), the lifecycle harness's state-files scenario (the files follow connect, arm, `M101`, the `M2` revert and a reconnect's generation bump; found on the way - an arm or `M101` moves `$35` in RAM with no settings-changed event, so the publisher watches the value), the full forgectrl host-test sweep, the stream harness, 252 forgetest unit tests and the coverage lint. Not yet on the bench: the board still runs the pre-C6 hot-deployed binaries, so the panel card reads "no report" until the next deploy or image. + Bench-proven the same day, one armed run of the new `mswitch` drill on the hot-installed cross-built binaries (forgectrl md5 4dc4677a, grblHAL_glowforge 17e5502f, operator-run install): the arm reported "laser armed (density, floor 10 %)", `M5` then `M101 P0` answered ok and reported "laser power model set for this program (analog, floor 16 %)", `$$` read `$35=16` while analog was in force and `$35=10` again after `M2` reported the revert, the armed window carried across the switch with no re-prompt, and the 25 Hz trace showed exactly two discharge segments, the density line pulsed (hv mean 390, max-mean 547) and the analog line steady (mean 567, max-mean 28), dark after the second `M5` (hv max 0). Board cleaned; the hot-deployed binaries stay until the next flash, `/tmp/*.prev` is the rollback. ## Reference notes diff --git a/forgetest/forgetest/suite/forgectrl.py b/forgetest/forgetest/suite/forgectrl.py index c625a92..a4aa858 100644 --- a/forgetest/forgetest/suite/forgectrl.py +++ b/forgetest/forgetest/suite/forgectrl.py @@ -221,11 +221,15 @@ def settings_bounds(ctx): @test("forgectrl.panel-serves", title="Control panel and status endpoints", subsystem="forgectrl", kind="auto", est_min=1, covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"), - ("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c")], + ("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), + ("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c")], description="The panel page is served, /status carries the machine telemetry the panel and " "the acceptance tool read (including the sys block: CPU busy percent over the " "interval since the previous read, memory used percent), and /cam/status " - "answers.") + "answers. In GRBL mode with a live controller, /status also echoes the " + "controller's published state file as the grbl block (fresh age, machine " + "state, sender session, laser window and dose model, modal report) and " + "GET /grbl/settings serves the published $$ view.") def panel_serves(ctx): fc = ctx.forgectrl ev = ctx.evidence @@ -270,3 +274,24 @@ def panel_serves(ctx): ev["cam_status"] = st ctx.log("GET /cam/status -> %s %s", st, cam) ctx.check(st == 200 and isinstance(cam, dict) and "running" in cam, "GET /cam/status -> %s", st) + + # The controller's published state, echoed only while a live GRBL + # controller runs (glowforge_status.c -> /run/forgefirm -> /status). + st, mode = fc.get("/mode") + if isinstance(mode, dict) and mode.get("mode") == "grbl" and mode.get("controller") == "running": + g = ctx.forgectrl.status().get("grbl") or {} + ev["grbl"] = g + ctx.log("/status grbl=%s", g) + ctx.check(isinstance(g.get("age_s"), (int, float)) and g["age_s"] < 30, + "/status grbl block missing or stale: %s", g) + rep = g.get("report") or {} + for key in ("state", "sender", "laser", "modals"): + ctx.check(key in rep, "/status grbl.report lacks %r", key) + ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"), + "grbl.report.laser carries no model: %s", rep.get("laser")) + st, text = fc.get("/grbl/settings", raw=True) + ev["grbl_settings_status"] = st + ctx.check(st == 200 and b"$35=" in (text or b""), + "GET /grbl/settings -> %s without the $$ view", st) + else: + ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode) diff --git a/scripts/bench/laser_lifecycle_test.py b/scripts/bench/laser_lifecycle_test.py index 59bd957..cb11eb2 100644 --- a/scripts/bench/laser_lifecycle_test.py +++ b/scripts/bench/laser_lifecycle_test.py @@ -28,7 +28,12 @@ reported messages: reset with the position kept (no alarm), the head returns to the job start on its own; with lid_policy = hold the stock door hold and cycle-start resume apply - 9. the job start survives a pause: a job paused and resumed by the + 9. the controller publishes its state for the daemon: grbl.settings + (the $$ view) and grbl.state (JSON with ts_mono, machine state, + sender session, laser window and dose model, modals) appear under + GF_STATE_DIR, follow the sender connection, the armed window and + an M101 switch, and carry a fresh ts_mono + 10. the job start survives a pause: a job paused and resumed by the button, then cancelled by the lid, returns to where the job began, not to where it was paused (the core restarts a held cycle through Idle); a job abandoned in a hold and reset ends there, so the next @@ -158,7 +163,7 @@ class Session: f.write("laser_disarm_s = %d\n%s" % (disarm_s, conf_extra)) verdict = os.path.join(self.workdir, "cooling.state") env = dict(os.environ, GF_VERDICT_FILE=verdict, GFHOME_CONF=conf, - FFLOG_STDERR="1") + GF_STATE_DIR=self.workdir, FFLOG_STDERR="1") env.pop("GFSINK", None) env.pop("GF_SWITCH_FILE", None) self.switch_file = None @@ -260,6 +265,74 @@ class Session: shutil.rmtree(self.workdir, ignore_errors=True) +def read_state(sess, key=None, timeout=8.0): + """The published grbl.state as text, polled until `key` appears (the + publisher runs on the protocol thread's pace; a '?' keeps it ticking).""" + path = os.path.join(sess.workdir, "grbl.state") + deadline = time.time() + timeout + text = "" + while time.time() < deadline: + sess.sock.sendall(b"?") + read_avail(sess.sock, sess.log, 0.2) + try: + with open(path) as f: + text = f.read() + except OSError: + text = "" + if text.endswith("\n") and (key is None or key in text): + return text + time.sleep(0.2) + return text + + +def test_status_files(): + print("state files: settings and state published, following the edges") + s = Session("state-files", disarm_s=30) + try: + st = read_state(s, '"connected":true') + assert '"state":"Idle"' in st, "state file lacks Idle: %r" % st + assert '"connected":true' in st, "sender not reported connected: %r" % st + assert '"armed":false' in st, "armed before any job: %r" % st + assert '"model":"density"' in st and '"floor_pct":10' in st, \ + "model/floor missing: %r" % st + assert '"modals":"[GC:' in st, "modal report missing: %r" % st + ts0 = float(st.split('"ts_mono":')[1].split(',')[0]) + + cfg = open(os.path.join(s.workdir, "grbl.settings")).read() + assert "$35=" in cfg and "$30=" in cfg, "settings file lacks $ lines" + + # The armed window and a dose-model switch reach the file. + send_line(s.sock, "G91", s.log) + send_line(s.sock, "M3 S100", s.log) + st = read_state(s, '"armed":true') + assert '"armed":true' in st, "armed window not published: %r" % st + send_line(s.sock, "M5", s.log) + send_line(s.sock, "M101 P0", s.log) + st = read_state(s, '"model":"analog"') + assert '"model":"analog"' in st and '"floor_pct":16' in st, \ + "the M101 switch not published: %r" % st + cfg = open(os.path.join(s.workdir, "grbl.settings")).read() + assert "$35=16" in cfg, "the switched floor not republished: %r" % [ + l for l in cfg.splitlines() if l.startswith("$35")] + send_line(s.sock, "M2", s.log) + st = read_state(s, '"model":"density"') + assert '"model":"density"' in st, "the M2 revert not published: %r" % st + + # A reconnect bumps the generation and stays connected. + gen0 = int(st.split('"generation":')[1].split(',')[0]) + s.sock.close() + s.sock = s.connect() + st = read_state(s, '"connected":true') + gen1 = int(st.split('"generation":')[1].split(',')[0]) + assert gen1 > gen0, "generation did not advance on reconnect (%d -> %d)" % (gen0, gen1) + ts1 = float(st.split('"ts_mono":')[1].split(',')[0]) + assert ts1 > ts0, "ts_mono did not advance" + print("PASS [state-files]: published, armed/switch/revert followed, " + "generation %d -> %d" % (gen0, gen1)) + finally: + s.close() + + def test_job_window(): """Rules 1 + 2: arm once per job, persist across M5/M3, close at M2, fresh consent for the next job. The grace is set far beyond the test @@ -724,6 +797,7 @@ def main(): if not os.path.isfile(BIN): fail("controller binary not found at %s" % BIN) test_job_window() + test_status_files() test_sender_change() test_sender_change_mid_job() test_rx_overrun_aborts()