From 64f552fc281158995b20878144bf655be8108d0b Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Tue, 1 Sep 2026 18:48:47 -0400 Subject: [PATCH] laser power-good: the line characterized, the kernel-drill guard, the probe, the dev image's mmap and ctypes The supply's power-good line is active high, static across HV enable and emission, and driven; the facts bank and CAMPAIGN-LOG carry the measurement and the item closes. The kernel-drill latch-unlock guard read the old inverted value as HV not good, a check that was vacuous and would refuse every run once the module reads the line correctly; it now uses the chain's own witnesses, the charge-pump watchdog and the engine state. pgood_probe.py watches the line beside the chain through the kernel readbacks and is registered on the bench page. The dev image lists python3-mmap and python3-ctypes again for the pad-level bench tools the python trim had left without them. --- docs/BRINGUP.md | 38 ++--- docs/CAMPAIGN-LOG.md | 42 ++++++ forgetest/forgetest/bench.py | 7 + forgetest/forgetest/suite/kernel.py | 66 +++++---- .../images/forgefirm-image-dev.bb | 7 + scripts/bench/README.md | 1 + scripts/bench/pgood_probe.py | 132 ++++++++++++++++++ 7 files changed, 250 insertions(+), 43 deletions(-) create mode 100644 scripts/bench/pgood_probe.py diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 90bae54..a7992cd 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -324,8 +324,10 @@ from the kernel counters. Contract: [forgectrl](https://docs.forgefirm.org/techn **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` -is the only live HV telemetry on this PSU. `cnc/laser_pgood_sampled` is **not** -a usable witness here — it reads 0 through real cutting. +is the only live HV telemetry on this PSU. `cnc/laser_pgood` is the supply's +power-good, driven high the whole time the supply is healthy (it follows +neither HV_ENABLE nor emission): a supply-fault witness, never an emission +witness (facts bank "Emission and HV witnesses"). ## Lid, interlock and button policy @@ -1031,8 +1033,22 @@ is committed. count on a commanded fire window and returns to 0 at Idle — the reliable witness. `pic/hv_current` tracks the cut (0 idle → hundreds/1023 raw while firing) and is the only live HV telemetry on this PSU (`hv_voltage` is - grounded). `cnc/laser_pgood_sampled` stays 0 through real cutting: not usable - here. + grounded). **`cnc/laser_pgood` (J1_14, GPIO4_21) is the supply's power-good, + active high**, measured 2026-09-01 through the kernel readbacks at ~780 Hz: + the pin is high at idle, through four HV_ENABLE cycles of a dry run, and + through an armed S400 cut (714 laser pulses, `hv_current` to 1023), with + zero transitions in 225 s, and it stays high against a 100 kΩ pull-down + switched in at the pad (IOMUXC `0x020E03C4`, restored to `0x100b0`), so the + supply drives it. The supply's supervisor is a Weltrend WT7525 (PC-supply + supervisor; open-drain PGO reports every DC output within spec, drops on an + over/under-voltage or over-current fault, 300 ms delay after good), and the + reverse-engineering pinout sheets label J1_14 `HV_PFC_STOP` (TP_A2C). The + factory app reports the line as the `HVpg`/`HVps` tags and read it the same + way (0 at idle under the old inverted convention). The kernel now reads it + active high: `laser_pgood` 1 and `laser_pgood_sampled` 255 on a healthy + supply; a drop during an armed window is the cooling engine's supply + power-good warning, and it means a supply fault. The line has never been + seen low on this machine. - **The head MCU flag register and HEAD_IRQ.** The head MCU (a KL17 at i2c-3 @0x47, I²C-slave-only to the SoC) samples four head-local GPIO input levels once per main loop into the read-only flag register 0x05: b0 @@ -1270,19 +1286,7 @@ feature requests, enhancements) will eventually be tracked as GitHub issues. far): re-measure the two heat coefficients and the machine's air-assist offset; and if a lit check still trips, the void-on-emission design with the tube as its own flow tracer. -8. **Laser power-good: what the line means.** `cnc/laser_pgood` and its - sampled count are defined in the UAPI (active low, one sample every - ~3.9 ms), the facts bank records that the sampled count reads 0 through - real cutting, and the cooling engine warns - `laser power-good degraded during the armed window` whenever fewer - than half the samples read low, so the warning fires at every session - open and carries no information. Nobody knows what the line reports - on this PSU: whether it is the supply's own power-good, an HV-present - flag, a polarity we have inverted, or unconnected. Owed: the line on a - scope against `hv_current` through an armed cut, its meaning written - into the facts bank and the UAPI, and then either a warning that means - something or no warning. -9. **Initial commissioning: measure and set the machine's own numbers +8. **Initial commissioning: measure and set the machine's own numbers methodically.** Every tunable that was measured on the bench machine and shipped as a default varies from machine to machine: the flow check's bands and `cool_flow_rise`, the tube's heat coefficients diff --git a/docs/CAMPAIGN-LOG.md b/docs/CAMPAIGN-LOG.md index f8ac629..bf9e023 100644 --- a/docs/CAMPAIGN-LOG.md +++ b/docs/CAMPAIGN-LOG.md @@ -6977,6 +6977,48 @@ from the LAN by `live_fire_drills.py`, the operator on the button: Items 7 and 8 are bench-proven. The board runs the hot-deployed binary until the next flash. +## 2026-09-01: the laser supply's power-good line, characterized without a scope + +The "laser power-good" item asked what J1_14 reports. No scope on the +bench, so the line was read through the kernel's own readbacks with a +new probe (`scripts/bench/pgood_probe.py`, fed to the board over ssh +stdin) at 770 to 790 Hz, against LASER_ON, FIRE, the charge-pump +watchdog, HV_ENABLE and the doors from the switch device, and +`hv_current` at 20 Hz. Image 20260901220626 (dev), fresh boot. + +- **Dry run, 75 s.** Four jogs; `charge_pump_alive` and HV_ENABLE rose + and fell together within 5 ms at every run start and end. The pin + stayed high for every one of 59,518 samples. +- **Armed run, 150 s**: the `witness` drill, a 20 mm square at S400 F600. + 714 LASER_ON pulses over 8.0 s, `hv_current` 0 to 1023, HV_ENABLE up + for the run. The pin stayed high for every one of 115,872 samples. +- **Driven, not floating.** With a cross-built register tool the pad's + internal pull was switched to 100 kΩ pull-down (IOMUXC `0x020E03C4`, + `0x100b0` to `0x130b0`), then pull-up, then restored; the pin read + high under all three and the pinctrl view confirmed the restore. +- **What the supply has.** The reverse-engineering archive holds the + supply's datasheets and board photos: the supervisor board carries a + Weltrend WT7525 (PC-supply supervisor: open-drain PGO high once every + DC output is within spec, low on an over/under-voltage or over-current + fault, 300 ms delay), LM2901 comparators and four PC817 optocouplers. + The pinout and test-point sheets label J1_14 `HV_PFC_STOP` (TP_A2C). + The factory app reports the line as the `HVpg`/`HVps` header tags and + its logs show 0 at idle under the same inverted convention the module + inherited. + +Disposition: J1_14 is the supply's power-good, active high, static across +HV enable and emission, and driven. The module now reads it active high +(`laser_pgood` 1, `laser_pgood_sampled` counts good samples, 255 on a +healthy supply); the cooling engine's once-per-session warning keeps its +threshold and now means a supply fault; the catalog's kernel-drill +precheck, which read the old value as "HV not good", moves to the chain's +own witnesses. The line has never been seen low; a supply fault is the +only thing that would take it there. Owed: the change rides the next image +(kernel module), and the dev image regains `python3-mmap` and +`python3-ctypes`, which the python trim removed and which +`resume_dark_lead.py`, `cp_watchdog_timing.py` and the accelerometer +probes need. + ## Reference notes ### Head-IRQ source validation — the beam-emission hypothesis diff --git a/forgetest/forgetest/bench.py b/forgetest/forgetest/bench.py index b0f227f..c173d02 100644 --- a/forgetest/forgetest/bench.py +++ b/forgetest/forgetest/bench.py @@ -228,6 +228,13 @@ TOOLS = [ "a pause, how fast the chain re-arms, and - on a live run - the dark lead between FIRE and " "LASER_ON that a resumed cut loses. Dry by default; --run live needs the arm press, eye " "protection, fire watch, extinguisher, exhaust."}, + {"id": "pgood-probe", "title": "Supply power-good line against the chain", "script": "pgood_probe.py", + "safety": "dry", "where": "board", "ported": True, + "args": [_arg("secs", "float", 90.0, "sampling window", flag="--secs")], + "desc": "Watches the supply's power-good line (cnc/laser_pgood, reported as the raw pin level) beside " + "LASER_ON, FIRE, the charge-pump watchdog, HV_ENABLE and the doors through the kernel readbacks at a " + "few hundred hertz, with hv_current at 20 Hz, and prints every transition. It only watches: drive " + "the machine meanwhile (a jog, an armed cut, a pause, a lid open). Needs no pad mapping."}, # -- host-side harnesses (CI) ------------------------------------------------------ {"id": "laser-stream-test", "title": "Laser pulse-stream emission harness", "script": "laser_stream_test.py", "safety": "dry", "where": "host", "ported": False, "args": [], diff --git a/forgetest/forgetest/suite/kernel.py b/forgetest/forgetest/suite/kernel.py index 2ebfdea..433048e 100644 --- a/forgetest/forgetest/suite/kernel.py +++ b/forgetest/forgetest/suite/kernel.py @@ -9,9 +9,10 @@ laser_on_sampled (the gated LASER_ON output), interlock_circuit bit 3 (the commanded latch), faults and underruns. Every drill forces duty to zero before any FIRE bit, keeps motor_lock=15 (no axis moves), and re-locks the latch on every exit path. K3 and fire B/U unlock the latch -for their run and therefore refuse to proceed while laser_pgood reports -the HV supply good (the operator opens the lid: the safety chain holds -HV off). +for their run and therefore refuse to proceed unless the safety chain +holds HV off: the charge-pump watchdog dead and the pulse engine idle +(HV_ENABLE follows the pump; laser_pgood is the supply's power-good and +says nothing about HV). """ import errno import os @@ -130,33 +131,46 @@ class PulseDevice: return False -def hv_not_good(): - """Precheck for the drills that unlock the latch with a zero-duty - stream: they run only while the HV supply does NOT report good. At - idle the chain holds HV_ENABLE low, so this refuses a start only on a - machine that is not idle the way it should be; the cure is the lid - (the safety chain holds HV off with it open).""" - pgood = rd("cnc/laser_pgood") - if pgood is None: - return "cnc/laser_pgood unreadable" - if pgood != "0": - return ("laser_pgood=%s: the HV supply reports good; this drill unlocks the latch with a " - "zero-duty stream and needs HV not good (open the lid, then start again)" % pgood) +def hv_off_reason(): + """Why the safety chain is NOT holding HV off right now, or None. The + chain asserts HV_ENABLE only while a run feeds the charge-pump + watchdog, so a dead watchdog and an idle pulse engine mean HV off. + laser_pgood is the supply's power-good, high whenever the supply is + healthy, and says nothing about HV, so it plays no part here.""" + alive = rd("cnc/charge_pump_alive") + state = rd("cnc/state") + if alive is None or state is None: + return "cnc/charge_pump_alive or cnc/state unreadable" + if alive != "0" or state != "idle": + return ("charge_pump_alive=%s state=%s: the chain may be holding HV_ENABLE up" % (alive, state)) return None -def require_hv_not_good(ctx): +def hv_off(): + """Precheck for the drills that unlock the latch with a zero-duty + stream: they run only while the chain holds HV off. At idle nothing + feeds the watchdog, so this refuses a start only on a machine that is + not idle the way it should be.""" + why = hv_off_reason() + if why: + return ("%s; this drill unlocks the latch with a zero-duty stream and needs HV off " + "(let the machine go idle, then start again)" % why) + return None + + +def require_hv_off(ctx): """The same rule at the start of the run (the precheck ran a moment earlier; the machine must still agree).""" - pgood = rd("cnc/laser_pgood") - ctx.evidence["laser_pgood"] = pgood - ctx.check(pgood == "0", "laser_pgood=%s (HV supply reports good) - refusing the latch unlock", pgood) + why = hv_off_reason() + ctx.evidence["charge_pump_alive"] = rd("cnc/charge_pump_alive") + ctx.evidence["kernel_state"] = rd("cnc/state") + ctx.check(why is None, "%s - refusing the latch unlock", why) -def check_hv_not_good(ctx): +def check_hv_off(ctx): """The hard check right before an unlock (no prompt: forgectrl is down).""" - pgood = rd("cnc/laser_pgood") - ctx.check(pgood == "0", "laser_pgood=%s (HV supply reports good) - refusing the latch unlock", pgood) + why = hv_off_reason() + ctx.check(why is None, "%s - refusing the latch unlock", why) # ---------------------------------------------------------------- readbacks @@ -389,7 +403,7 @@ def _k3_phase(ctx): """K3: the latch unlocked during the accel ramp must not restore the FIRE drive while the run is in flight. Runs inside the caller's takeover.""" ev = ctx.evidence - check_hv_not_good(ctx) + check_hv_off(ctx) stream = POWER0 + FIRE * (3 * TICK_HZ) + PAD * (TICK_HZ // 2) ctx.log("K3: %d bytes = %.1f s of FIRE bits; ramp_rate 10000 Hz/s (~0.9 s accel " "window); unlock at t=+0.15 s", len(stream), len(stream) / TICK_HZ) @@ -442,7 +456,7 @@ def _fire_phase(ctx, mode): stream = _fire_stream() ctx.log("fire %s: stream %d bytes = %.3f s", mode, len(stream), len(stream) / TICK_HZ) if unlock: - check_hv_not_good(ctx) + check_hv_off(ctx) snap(ctx, "fire %s pre" % mode) wr("cnc/motor_lock", 15) wr("cnc/step_freq", TICK_HZ) @@ -518,7 +532,7 @@ def _fire_phase(ctx, mode): "mid-run unlock", subsystem="kernel", kind="auto", hardware="takeover", always=True, est_min=4, covers=_KERNEL_COVERS, - requires=["kernel.k1-k2"], precheck=hv_not_good, + requires=["kernel.k1-k2"], precheck=hv_off, steps=["Phases B, U and K3 unlock the latch with a zero-duty stream, so the drill starts only " "while the HV supply does not report good (true at idle; if it is refused, open the " "lid - the safety chain holds HV off - and start it again)."], @@ -532,7 +546,7 @@ def _fire_phase(ctx, mode): "flight - laser_enable stays 0 for the whole run.") def fire_line(ctx): ev = ctx.evidence - require_hv_not_good(ctx) + require_hv_off(ctx) with ctx.takeover(): try: a = _fire_phase(ctx, "A") diff --git a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb index 0982b3a..47300b3 100644 --- a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb +++ b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image-dev.bb @@ -12,9 +12,16 @@ IMAGE_INSTALL += " \ forgectrl \ forgetest \ python3-gfutilities-emulator \ + python3-mmap \ + python3-ctypes \ htop \ " +# python3-mmap and python3-ctypes serve the bench scripts that read the SoC +# pads through /dev/mem (resume_dark_lead.py, cp_watchdog_timing.py, the +# accelerometer probes). They are bench tooling: the release image carries +# neither, and forgetest itself imports neither. + # The release recipe trims nano from the shared base list (bench # convenience, 8.7 MB with libmagic); the dev image keeps it. Removal specs # accumulate and apply after every append, so the variable inside the diff --git a/scripts/bench/README.md b/scripts/bench/README.md index 0c3de99..20e584d 100644 --- a/scripts/bench/README.md +++ b/scripts/bench/README.md @@ -59,6 +59,7 @@ page's takeover does that; from a host, stop them first. | `puls_profile.py` | Decodes factory `.puls` streams (raw or GF1-headered) into velocity/accel profiles: peak speeds, ramp-slope fits, per-move segments, Z cadence. Runs anywhere (stdlib only). Source of the factory-true grblHAL defaults: 700/590 mm/s² accel, 200 mm/s max rate, 28160 Hz travel tick. | | `cp_watchdog_timing.py` | HV charge-pump watchdog one-shot timing (runs on the board): latches every CHG_PUMP feed pulse in GPIO3's edge detector (pin 24 only, IMR untouched, ICR2 restored on exit) and polls the `!Q` (`charge_pump_alive`) and `!HV_ENABLE` (`hv_enable`) pads through /dev/mem in a tight loop while it commands short local jogs; prints per-run t_w (last pulse → Q fall), Q → HV_ENABLE delay, priming latency and the feed period, with the loop's worst gap as the resolution. Motion only, laser locked, no other Grbl client attached. | | `resume_dark_lead.py` | Pause/resume safety-chain timing (runs on the board, as root): samples LASER_ON, FIRE, HV_ENABLE, the charge-pump watchdog, the button and the doors straight off the SoC pads through /dev/mem at ~2 kHz, with motion dated from the kernel step counters, across a pause and a resume driven by the operator's button presses. Reports how long HV_ENABLE survives the stream stopping, how fast the chain re-arms on the resume, and - on `--run live` - the dark lead between FIRE going back on and LASER_ON following it, in milliseconds and in millimeters at the job's feed. `--run dry` (default) commands no laser at all and `--auto P,R` drives the pause and resume with `!`/`~` for an unattended rehearsal; `--run live` needs the arm press, eye protection, fire watch, extinguisher and exhaust. GRBL mode, no other Grbl client attached. | +| `pgood_probe.py` | The supply's power-good line (J1_14, `cnc/laser_pgood`) against the laser chain, without a scope (runs on the board, as root): polls the kernel readbacks (`laser_pgood` reported as the raw pin level, `laser_on`, `laser_enable`, `charge_pump_alive`) and the switch device's HV_ENABLE and doors bits at a few hundred hertz, `hv_current` at 20 Hz, and prints every transition with a timestamp plus a per-line summary. Drive the machine meanwhile (a dry jog, an armed cut, a pause, a lid open); the probe only watches. Feed it over ssh stdin: `ssh root@ 'python3 - --secs 90' < pgood_probe.py`. | | `bench_m2.py` | Motion-quality bench, runs against the board over TCP:23: bounded round-trip jogs (sanity, max-rate, diagonal) + feed-hold/resume mid-move, reporting peak feed, state transitions, and position drift. | Data files kept beside the tools: `flow_matrix_results.json` / diff --git a/scripts/bench/pgood_probe.py b/scripts/bench/pgood_probe.py new file mode 100644 index 0000000..d3d9276 --- /dev/null +++ b/scripts/bench/pgood_probe.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""LASER_PGOOD (J1_14) against the rest of the laser chain, through the +kernel's readbacks. + +Runs ON the board as root. Polls the kernel's GPIO readbacks (sysfs, one +open descriptor each, re-read with pread) and the switch device's EV_SW +word (EVIOCGSW) as fast as the loop allows, a few hundred hertz, and +reports every transition of the watched lines with a timestamp, plus a +per-line summary, so the meaning of the supply's line can be read off +against what the chain and the supply were doing: idle, a dry run +(HV_ENABLE follows the charge pump), an armed cut (LASER_ON, FIRE, +hv_current), a pause and a resume, a lid open. hv_current comes from the +PIC at a lower rate and rides along as a range. + +PGOOD is reported as the RAW PIN LEVEL (the kernel's laser_pgood attribute +is the logical, inverted value: 1 = pin low). Everything else is the +kernel's logical value. + +The loop must not hog the CPU: single core, the protocol thread is +SCHED_OTHER, so the sampler sleeps between passes and reports its worst gap. + +Usage: pgood_probe.py [--secs N] [--json FILE] +Drive the machine from a sender or the button meanwhile; the probe only +watches. GRBL mode, any state. +""" +import argparse +import fcntl +import json +import os +import struct +import sys +import time + +CNC = '/sys/glowforge/cnc/' +ATTRS = [ # name, attribute, invert-to-raw + ('PGOOD', CNC + 'laser_pgood', True), + ('LASER_ON', CNC + 'laser_on', False), + ('FIRE', CNC + 'laser_enable', False), + ('CP_ALIVE', CNC + 'charge_pump_alive', False), +] +SWITCH_DEV = '/dev/input/event0' +SW_BITS = [('HV_ENABLE', 4), ('DOORS', 3)] # EV_SW codes on the switch device +EVIOCGSW = (2 << 30) | (8 << 16) | (0x45 << 8) | 0x1b +HV_CURRENT = '/sys/glowforge/pic/hv_current' +STATE = CNC + 'state' +HV_HZ = 20.0 +SLEEP_S = 0.001 + + +def rd(fd): + return os.pread(fd, 32, 0).strip() + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--secs', type=float, default=60.0) + ap.add_argument('--json', default='') + args = ap.parse_args() + try: + os.nice(5) + except OSError: + pass + fds = [(n, os.open(p, os.O_RDONLY), inv) for n, p, inv in ATTRS] + sw = os.open(SWITCH_DEV, os.O_RDONLY) + hv_fd = os.open(HV_CURRENT, os.O_RDONLY) + st_fd = os.open(STATE, os.O_RDONLY) + + def sample(): + s = {} + for n, fd, inv in fds: + v = int(rd(fd) or b'0') + s[n] = (1 - v) if inv else v + buf = fcntl.ioctl(sw, EVIOCGSW, b'\0' * 8) + for n, bit in SW_BITS: + s[n] = (buf[bit >> 3] >> (bit & 7)) & 1 + return s + + t0 = time.monotonic() + state = sample() + hv = int(rd(hv_fd) or b'0') + print('t=%8.3f start %s hv=%d kstate=%s' + % (0.0, ' '.join('%s=%d' % kv for kv in state.items()), hv, rd(st_fd).decode())) + sys.stdout.flush() + trans = [] + hv_log = [] + counts = {k: [0, 0] for k in state} + worst_gap = 0.0 + n = 0 + next_hv = t0 + t_prev = t0 + while True: + now = time.monotonic() + if now - t0 > args.secs: + break + gap = now - t_prev + if gap > worst_gap: + worst_gap = gap + t_prev = now + new = sample() + n += 1 + for k in new: + if new[k] != state[k]: + trans.append((now - t0, k, new[k])) + print('t=%8.3f %-9s -> %d hv=%s kstate=%s' + % (now - t0, k, new[k], rd(hv_fd).decode(), rd(st_fd).decode())) + sys.stdout.flush() + counts[k][new[k]] += 1 + state = new + if now >= next_hv: + hv_log.append((now - t0, int(rd(hv_fd) or b'0'))) + next_hv = now + 1.0 / HV_HZ + time.sleep(SLEEP_S) + secs = time.monotonic() - t0 + print('--- %.1f s, %d passes (%.0f Hz), worst gap %.1f ms' % (secs, n, n / secs, worst_gap * 1e3)) + for k, (c0, c1) in counts.items(): + print(' %-9s 0: %6.2f %% 1: %6.2f %%' % (k, 100.0 * c0 / n, 100.0 * c1 / n)) + if hv_log: + vals = [v for _t, v in hv_log] + lit = [t for t, v in hv_log if v > 20] + print(' hv_current min %d max %d; > 20 raw for %.1f s%s' + % (min(vals), max(vals), len(lit) / HV_HZ, + (' (%.1f .. %.1f s)' % (lit[0], lit[-1])) if lit else '')) + if args.json: + with open(args.json, 'w') as f: + json.dump({'secs': secs, 'passes': n, 'worst_gap_ms': worst_gap * 1e3, + 'counts': counts, 'transitions': trans, 'hv': hv_log}, f) + print('record: %s' % args.json) + return 0 + + +if __name__ == '__main__': + sys.exit(main())