Prove the controller's published state files

The lifecycle harness gains the state-files scenario: with GF_STATE_DIR
at the session workdir, grbl.settings carries the $$ view and follows
the derived floor through an M101 switch and its M2 revert, and
grbl.state follows the sender connection (a reconnect bumps the
generation), the armed window and the dose model, with ts_mono
advancing. The panel-serves catalog test asserts the /status grbl block
in GRBL mode with a live controller (fresh age, state, sender, laser,
modals) and GET /grbl/settings, and its covers name the publisher and
the serial layer; the glowforge_status.c covers entry selects nothing
until the grblHAL pin moves past the commit that adds the file. The
CAMPAIGN-LOG records the build and its proof; BRINGUP describes the
mechanism.
This commit is contained in:
ScottW514
2026-08-30 15:59:56 -04:00
parent 3025996c86
commit 917b4bb55e
4 changed files with 119 additions and 4 deletions
+14
View File
@@ -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 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. 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` **Emission evidence.** `cnc/laser_on_sampled` (surfaced as `/status`
`laser.emission_samples`) is the reliable live-emission witness; emission `laser.emission_samples`) is the reliable live-emission witness; emission
sensed with no armed window relocks the latch and stops motion. `pic/hv_current` sensed with no armed window relocks the latch and stops motion. `pic/hv_current`
+2
View File
@@ -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 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. 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 ## Reference notes
+27 -2
View File
@@ -221,11 +221,15 @@ def settings_bounds(ctx):
@test("forgectrl.panel-serves", title="Control panel and status endpoints", subsystem="forgectrl", @test("forgectrl.panel-serves", title="Control panel and status endpoints", subsystem="forgectrl",
kind="auto", est_min=1, kind="auto", est_min=1,
covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"), 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 " 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 " "the acceptance tool read (including the sys block: CPU busy percent over the "
"interval since the previous read, memory used percent), and /cam/status " "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): def panel_serves(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
ev = ctx.evidence ev = ctx.evidence
@@ -270,3 +274,24 @@ def panel_serves(ctx):
ev["cam_status"] = st ev["cam_status"] = st
ctx.log("GET /cam/status -> %s %s", st, cam) 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) 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)
+76 -2
View File
@@ -28,7 +28,12 @@ reported messages:
reset with the position kept (no alarm), the head returns to the 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 job start on its own; with lid_policy = hold the stock door hold and
cycle-start resume apply 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, 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 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 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)) f.write("laser_disarm_s = %d\n%s" % (disarm_s, conf_extra))
verdict = os.path.join(self.workdir, "cooling.state") verdict = os.path.join(self.workdir, "cooling.state")
env = dict(os.environ, GF_VERDICT_FILE=verdict, GFHOME_CONF=conf, 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("GFSINK", None)
env.pop("GF_SWITCH_FILE", None) env.pop("GF_SWITCH_FILE", None)
self.switch_file = None self.switch_file = None
@@ -260,6 +265,74 @@ class Session:
shutil.rmtree(self.workdir, ignore_errors=True) 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(): def test_job_window():
"""Rules 1 + 2: arm once per job, persist across M5/M3, close at M2, """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 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): if not os.path.isfile(BIN):
fail("controller binary not found at %s" % BIN) fail("controller binary not found at %s" % BIN)
test_job_window() test_job_window()
test_status_files()
test_sender_change() test_sender_change()
test_sender_change_mid_job() test_sender_change_mid_job()
test_rx_overrun_aborts() test_rx_overrun_aborts()