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.
This commit is contained in:
ScottW514
2026-09-01 18:48:47 -04:00
parent fb804e32bd
commit 64f552fc28
7 changed files with 250 additions and 43 deletions
+21 -17
View File
@@ -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
+42
View File
@@ -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
+7
View File
@@ -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": [],
+40 -26
View File
@@ -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")
@@ -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
+1
View File
@@ -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@<board> '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` /
+132
View File
@@ -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())