mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
Acceptance catalog: the rest of the lid/button/interlock drills, and a pause/resume chain-timing tool
Catalog 34 -> 39. Every remaining bench drill of the lid/button parity work
is now a test, with drills that exercise the same path combined:
motion.lid-cancel-home also cancels from a hold - a job paused on the
button is ended by the lid, never resumed - so the
armed window and the hardware button latch stay in
agreement by construction.
motion.cancel-abort also asserts what a sender abort must NOT do: it
stops where it stopped and never returns home. The
return-to-start belongs to the lid policy alone.
motion.interlock-cancel-park (new) the interlock loop cancels like the lid,
and the lid opened during the return home does not
interrupt it.
motion.lid-policy-hold (new) the other policy: Door park, no cancel, no
return, and a cycle start finishes the move. The
setting is restored on the way out.
laser.pause-resume-live (new) the button pause/resume during a live cut:
emission stops, the latch stays UNLOCKED and the
armed window open (a pause is not a cancel), the
next press resumes and the job finishes.
cloud.interlock-abort-park (new) the same interlock/park pair in cloud mode.
cloud.pause-cancel-paths (new) the two non-finishing ends of a print, each
from the state the factory ends it in: paused on the
button then cancelled by the lid, and cancelled from
the app while running.
The shared cancel tail (reason reported, reset without an alarm, position
kept, head back at the job start with the KERNEL counters confirming it) is
now one helper, so every trigger is judged the same way.
scripts/bench/resume_dark_lead.py: samples LASER_ON, FIRE, HV_ENABLE, the
charge-pump watchdog, the button and the doors off the SoC pads at ~2 kHz
through /dev/mem, with motion dated from the kernel step counters, across a
pause and a resume. Levels are taken at idle and everything after is reported
as a change from that baseline, so no polarity assumption is baked in. Dry by
default, with --auto driving the pause and resume through ! / ~ for an
unattended rehearsal; --run live adds the dark lead between FIRE and LASER_ON,
in milliseconds and in millimeters at the job's feed.
Bench registry: argument specs can name a flag (--feed 600) instead of being
positional, and an optional argument with an empty default is left off the
command line entirely.
Host proof: 104 unit tests (20 in the cloud suite - the two new cloud tests
replay the machine's own lid-abort excerpt, with the interlock and the app
cancel substituted for the trigger, and fail for the right reasons), coverage
lint 0 uncovered across 39 tests.
This commit is contained in:
@@ -52,6 +52,7 @@ page's takeover does that; from a host, stop them first.
|
||||
| `build-feeder.sh` | Cross-compiles `feeder.c` the same way. |
|
||||
| `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. |
|
||||
| `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` /
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resume dark-lead characterization (runs ON the board, as root).
|
||||
|
||||
Measures what the safety chain does across a pause and a resume, at pad
|
||||
resolution, so the GRBL resume dwell can be decided from numbers instead
|
||||
of from the mark alone. The pause and the resume are the operator's
|
||||
physical button presses - the same toggle the controller ships.
|
||||
|
||||
Sampled straight from the SoC pads (no kernel change, no scope), one
|
||||
32-bit read per bank per pass:
|
||||
|
||||
LASER_ON GPIO1_05 gated output of the safety AND gate (active low)
|
||||
CP_ALIVE GPIO1_08 charge-pump watchdog !Q (0 = alive)
|
||||
BUTTON_LATCH GPIO1_03 1 = latch SET (fire blocked)
|
||||
DOORS GPIO1_00 0 = both lid switches closed
|
||||
FIRE GPIO2_30 laser-enable line, read from the data register
|
||||
(the SDMA writes it; GDIR says whether it is driven)
|
||||
BUTTON GPIO4_09 big button (0 = pressed)
|
||||
HV_ENABLE GPIO4_06 chain output readback, inverted (0 = asserted)
|
||||
|
||||
Levels are read once at idle and every later sample is reported as a
|
||||
change from that baseline, so no polarity assumption is baked in.
|
||||
|
||||
The headline number is the **dark lead**: FIRE re-asserted (the stream is
|
||||
commanding emission again) -> LASER_ON asserted (the chain actually lets
|
||||
the beam through). At the job's feed rate that is also a distance, which
|
||||
is what shows up in the mark. The pause side reports how long HV_ENABLE
|
||||
survives the stream stopping, and the charge-pump one-shot period behind it.
|
||||
|
||||
The loop must not hog the CPU: this is a single-core part, only the
|
||||
shipper thread is SCHED_FIFO, and starving the SCHED_OTHER protocol thread
|
||||
mid-job would underrun the ring. It samples at ~2 kHz with a positive nice
|
||||
and reports the worst gap it actually achieved.
|
||||
|
||||
Usage: resume_dark_lead.py [--run dry|live] [--power S] [--feed F]
|
||||
[--len MM] [--passes N] [--mode m3|m4]
|
||||
[--secs N] [--auto P,R] [--json FILE]
|
||||
|
||||
--run dry (default) a plain G1 travel of --len at --feed, no laser
|
||||
command at all. Exercises the button pause/resume and gives
|
||||
the HV_ENABLE / charge-pump timings, which do not need fire -
|
||||
the pump runs for any pulse-engine run. Run this first.
|
||||
--run live LIVE FIRE: M3 (or M4) at --power. Requires the arm press, eye
|
||||
protection, exhaust, fire watch, extinguisher, scrap under the
|
||||
head. Adds the LASER_ON edge - the dark lead itself - and the
|
||||
physical mark to measure.
|
||||
|
||||
Operator sequence in both cases: press to arm (live only), then press once
|
||||
mid-move to pause, and once more to resume. GRBL mode only, with the
|
||||
controller idle and no other Grbl client attached (a connection here
|
||||
displaces the sender).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import mmap
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
GPIO1, GPIO2, GPIO4 = 0x0209C000, 0x020A0000, 0x020A8000
|
||||
DR, GDIR, PSR = 0x00, 0x04, 0x08
|
||||
|
||||
# name -> (bank, register, bit). Order is the report order.
|
||||
SIGNALS = [
|
||||
('FIRE', 'g2', DR, 30),
|
||||
('LASER_ON', 'g1', PSR, 5),
|
||||
('HV_ENABLE', 'g4', PSR, 6),
|
||||
('CP_ALIVE', 'g1', PSR, 8),
|
||||
('BUTTON', 'g4', PSR, 9),
|
||||
('BUTTON_LATCH', 'g1', PSR, 3),
|
||||
('DOORS', 'g1', PSR, 0),
|
||||
]
|
||||
|
||||
# Motion comes from the kernel's step counters, not from the step lines:
|
||||
# a step pulse is microseconds wide, so a pad sampler at this rate catches
|
||||
# only the occasional one. cnc/position is 32 binary bytes (X, Y, Z steps
|
||||
# first) and re-reading it at MOTION_HZ dates the restart closely enough to
|
||||
# compare against a chain that re-arms in single-digit milliseconds.
|
||||
POSITION = '/sys/glowforge/cnc/position'
|
||||
MOTION_HZ = 50.0
|
||||
MOTION_GAP_S = 0.10
|
||||
|
||||
SLEEP_S = 0.0005
|
||||
HOST, PORT = '127.0.0.1', 23
|
||||
|
||||
|
||||
class Pads:
|
||||
"""One mmap per bank; a pass reads three words."""
|
||||
|
||||
def __init__(self):
|
||||
self.fd = os.open('/dev/mem', os.O_RDWR | os.O_SYNC)
|
||||
self.m = {
|
||||
'g1': mmap.mmap(self.fd, 4096, mmap.MAP_SHARED,
|
||||
mmap.PROT_READ, offset=GPIO1),
|
||||
'g2': mmap.mmap(self.fd, 4096, mmap.MAP_SHARED,
|
||||
mmap.PROT_READ, offset=GPIO2),
|
||||
'g4': mmap.mmap(self.fd, 4096, mmap.MAP_SHARED,
|
||||
mmap.PROT_READ, offset=GPIO4),
|
||||
}
|
||||
|
||||
# (bank, register) -> mask of the bits actually watched there. The step
|
||||
# lines and the PWM share these banks, so an unmasked word compare would
|
||||
# log a transition on every step.
|
||||
SOURCES = []
|
||||
for _n, _b, _r, _bit in SIGNALS:
|
||||
for _i, (_sb, _sr, _sm) in enumerate(SOURCES):
|
||||
if (_sb, _sr) == (_b, _r):
|
||||
SOURCES[_i] = (_sb, _sr, _sm | (1 << _bit))
|
||||
break
|
||||
else:
|
||||
SOURCES.append((_b, _r, 1 << _bit))
|
||||
|
||||
def words(self):
|
||||
m = self.m
|
||||
return tuple(struct.unpack_from('<I', m[b], r)[0] & mask
|
||||
for b, r, mask in self.SOURCES)
|
||||
|
||||
|
||||
def decode(self, w):
|
||||
src = {(b, r): v for (b, r, _m), v in zip(self.SOURCES, w)}
|
||||
return {n: (src[(b, r)] >> bit) & 1 for n, b, r, bit in SIGNALS}
|
||||
|
||||
def fire_is_driven(self):
|
||||
gdir = struct.unpack('<I', self.m['g2'][GDIR:GDIR + 4])[0]
|
||||
return bool((gdir >> 30) & 1)
|
||||
|
||||
def close(self):
|
||||
for m in self.m.values():
|
||||
m.close()
|
||||
os.close(self.fd)
|
||||
|
||||
|
||||
class Grbl:
|
||||
def __init__(self):
|
||||
self.s = socket.create_connection((HOST, PORT), timeout=5)
|
||||
self.s.settimeout(0.2)
|
||||
time.sleep(0.5)
|
||||
self.drain()
|
||||
|
||||
def drain(self):
|
||||
out = b''
|
||||
try:
|
||||
while True:
|
||||
d = self.s.recv(4096)
|
||||
if not d:
|
||||
break
|
||||
out += d
|
||||
except socket.timeout:
|
||||
pass
|
||||
return out.decode('ascii', 'replace')
|
||||
|
||||
def send(self, line):
|
||||
self.s.sendall(line.encode() + b'\n')
|
||||
|
||||
def status(self):
|
||||
self.s.sendall(b'?')
|
||||
deadline = time.time() + 1.0
|
||||
text = ''
|
||||
while time.time() < deadline:
|
||||
text += self.drain()
|
||||
if '>' in text:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
return text[text.rfind('<'):text.rfind('>') + 1] if '<' in text else ''
|
||||
|
||||
def state(self):
|
||||
st = self.status()
|
||||
return st[1:].split('|')[0] if st else ''
|
||||
|
||||
|
||||
class Counters:
|
||||
"""X/Y/Z step counters, re-read from the same open descriptor."""
|
||||
|
||||
def __init__(self):
|
||||
self.f = open(POSITION, 'rb', buffering=0)
|
||||
|
||||
def read(self):
|
||||
self.f.seek(0)
|
||||
return struct.unpack_from('<iii', self.f.read(32), 0)
|
||||
|
||||
def close(self):
|
||||
self.f.close()
|
||||
|
||||
|
||||
def sysfs(name):
|
||||
try:
|
||||
with open('/sys/glowforge/cnc/' + name) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return '?'
|
||||
|
||||
|
||||
def sample(pads, seconds, grbl, job_lines, marks, auto=()):
|
||||
"""Sample the pads for `seconds`, sending `job_lines` once settled.
|
||||
`auto` is a schedule of (t_seconds, realtime_byte, label) sent from the
|
||||
sampling loop - used by the unattended rehearsal, where `!` and `~`
|
||||
stand in for the operator's pause and resume presses.
|
||||
Returns (baseline, events, worst_gap). An event is (t, changed_dict)."""
|
||||
words = pads.words()
|
||||
base = pads.decode(words)
|
||||
events = []
|
||||
motion = []
|
||||
t0 = time.perf_counter()
|
||||
prev = words
|
||||
prev_state = base
|
||||
counters = Counters()
|
||||
prev_pos = counters.read()
|
||||
next_pos_t = t0
|
||||
last_move = None
|
||||
last = t0
|
||||
worst = 0.0
|
||||
sent = False
|
||||
end = t0 + seconds
|
||||
while True:
|
||||
now = time.perf_counter()
|
||||
if now >= end:
|
||||
break
|
||||
gap = now - last
|
||||
if gap > worst:
|
||||
worst = gap
|
||||
last = now
|
||||
w = pads.words()
|
||||
if w != prev:
|
||||
st = pads.decode(w)
|
||||
changed = {k: v for k, v in st.items() if v != prev_state[k]}
|
||||
if changed:
|
||||
events.append((now - t0, changed, st))
|
||||
prev, prev_state = w, st
|
||||
if now >= next_pos_t:
|
||||
next_pos_t = now + 1.0 / MOTION_HZ
|
||||
pos = counters.read()
|
||||
if pos != prev_pos:
|
||||
prev_pos = pos
|
||||
if last_move is None or now - last_move > MOTION_GAP_S:
|
||||
motion.append([now - t0, now - t0])
|
||||
else:
|
||||
motion[-1][1] = now - t0
|
||||
last_move = now
|
||||
if not sent and now - t0 > 1.0:
|
||||
for ln in job_lines:
|
||||
grbl.send(ln)
|
||||
marks.append(('job sent', now - t0))
|
||||
sent = True
|
||||
while auto and now - t0 >= auto[0][0]:
|
||||
_at, ch, label = auto.pop(0)
|
||||
grbl.s.sendall(ch)
|
||||
marks.append((label, now - t0))
|
||||
time.sleep(SLEEP_S)
|
||||
counters.close()
|
||||
return base, events, worst, motion
|
||||
|
||||
|
||||
def edges(events, name, base, to_active):
|
||||
"""Times at which `name` moved to (to_active=True) or away from its
|
||||
non-baseline level."""
|
||||
out = []
|
||||
for t, changed, _st in events:
|
||||
if name in changed:
|
||||
active = changed[name] != base[name]
|
||||
if active == to_active:
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def report(base, events, worst, args, marks, motion):
|
||||
print('\nbaseline at idle: %s' % ' '.join(
|
||||
'%s=%d' % (n, base[n]) for n, _b, _r, _bit in SIGNALS))
|
||||
print('worst sampling gap: %.2f ms (%d transitions)'
|
||||
% (worst * 1000.0, len(events)))
|
||||
for label, t in marks:
|
||||
print(' %-10s t=%.3f s' % (label, t))
|
||||
|
||||
print('\n--- transitions (t in s from sampler start) ---')
|
||||
for t, changed, _st in events:
|
||||
desc = ' '.join('%s->%s' % (k, 'ACTIVE' if v != base[k] else 'idle')
|
||||
for k, v in sorted(changed.items()))
|
||||
print(' %8.4f %s' % (t, desc))
|
||||
|
||||
fire_on = edges(events, 'FIRE', base, True)
|
||||
fire_off = edges(events, 'FIRE', base, False)
|
||||
lon_on = edges(events, 'LASER_ON', base, True)
|
||||
lon_off = edges(events, 'LASER_ON', base, False)
|
||||
hv_on = edges(events, 'HV_ENABLE', base, True)
|
||||
hv_off = edges(events, 'HV_ENABLE', base, False)
|
||||
cp_on = edges(events, 'CP_ALIVE', base, True)
|
||||
cp_off = edges(events, 'CP_ALIVE', base, False)
|
||||
btn = edges(events, 'BUTTON', base, True)
|
||||
|
||||
print('\n--- motion (step-line activity) ---')
|
||||
for a, b in motion:
|
||||
print(' %8.4f -> %8.4f (%.0f ms)' % (a, b, (b - a) * 1000.0))
|
||||
|
||||
print('\n--- summary ---')
|
||||
print('button presses: %s' % (', '.join('%.3f' % t for t in btn) or 'none'))
|
||||
|
||||
def after(times, t):
|
||||
later = [x for x in times if x > t]
|
||||
return later[0] if later else None
|
||||
|
||||
mm_s = args.feed / 60.0
|
||||
starts = [a for a, _b in motion]
|
||||
stops = [b for _a, b in motion]
|
||||
|
||||
# Each pause and resume is dated from what triggered it - the operator's
|
||||
# button press, or the ! / ~ the rehearsal sends in its place - so the
|
||||
# dry and the live run report the same way.
|
||||
triggers = [(lbl, t) for lbl, t in marks
|
||||
if lbl.startswith(('pause', 'resume'))]
|
||||
if btn:
|
||||
triggers = [('press %d' % (i + 1), t) for i, t in enumerate(btn)]
|
||||
triggers.sort(key=lambda x: x[1])
|
||||
|
||||
for lbl, t in triggers:
|
||||
line = ['%-8s at %7.3f:' % (lbl, t)]
|
||||
for nm, times in (('motion stops', stops), ('motion starts', starts),
|
||||
('FIRE clear', fire_off), ('FIRE set', fire_on),
|
||||
('LASER_ON off', lon_off), ('LASER_ON on', lon_on),
|
||||
('HV drop', hv_off), ('HV up', hv_on),
|
||||
('CP fell', cp_off), ('CP alive', cp_on)):
|
||||
x = after(times, t)
|
||||
if x is not None and x - t < 2.0:
|
||||
line.append('%s +%.0f ms' % (nm, (x - t) * 1000.0))
|
||||
print(' ' + ' | '.join(line))
|
||||
|
||||
# The dark lead is what a resumed cut loses: emission commanded again
|
||||
# (FIRE) but the chain not yet letting the beam through (LASER_ON).
|
||||
leads = []
|
||||
for t in fire_on:
|
||||
x = after(lon_on, t)
|
||||
if x is not None and x - t < 2.0:
|
||||
leads.append((x - t) * 1000.0)
|
||||
if leads:
|
||||
print('\nDARK LEAD (FIRE set -> LASER_ON): %s ms -> %s mm at F%g'
|
||||
% (', '.join('%.1f' % x for x in leads),
|
||||
', '.join('%.2f' % (x / 1000.0 * mm_s) for x in leads),
|
||||
args.feed))
|
||||
elif args.run == 'dry':
|
||||
print('\n(dry run: FIRE and LASER_ON never assert - the chain '
|
||||
'numbers above are the ones this rehearsal establishes)')
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--run', choices=('dry', 'live'), default='dry',
|
||||
dest='run',
|
||||
help='dry: travel only, no laser commanded (default). '
|
||||
'live: command the laser - LIVE FIRE.')
|
||||
ap.add_argument('--mode', choices=('m3', 'm4'), default='m3')
|
||||
ap.add_argument('--power', type=int, default=400)
|
||||
ap.add_argument('--feed', type=float, default=1200.0)
|
||||
ap.add_argument('--len', type=float, default=80.0, dest='length')
|
||||
ap.add_argument('--passes', type=int, default=1,
|
||||
help='alternating +X/-X moves (dry: gives a long window '
|
||||
'to press in; live: keep 1 so the mark is one line)')
|
||||
ap.add_argument('--secs', type=float, default=45.0)
|
||||
ap.add_argument('--auto', default='',
|
||||
help='unattended rehearsal (dry only): PAUSE,RESUME in '
|
||||
'seconds from the job start, e.g. --auto 3,6 - sends '
|
||||
'! and ~ instead of waiting for button presses')
|
||||
ap.add_argument('--json', default='')
|
||||
args = ap.parse_args()
|
||||
|
||||
if os.geteuid() != 0:
|
||||
sys.exit('must run as root (needs /dev/mem)')
|
||||
|
||||
grbl = Grbl()
|
||||
st = grbl.state()
|
||||
if not st.startswith('Idle'):
|
||||
sys.exit('controller is not Idle (%s) - clear it first' % (st or '?'))
|
||||
print('controller: %s interlock_circuit=%s faults=%s button_latch=%s'
|
||||
% (st, sysfs('interlock_circuit'), sysfs('faults'),
|
||||
sysfs('button_latch')))
|
||||
|
||||
moves = ['G1 X%g F%g' % (args.length * (1 if i % 2 == 0 else -1), args.feed)
|
||||
for i in range(max(1, args.passes))]
|
||||
lines = ['G21', 'G91']
|
||||
if args.run == 'live':
|
||||
lines += ['%s S%d' % (args.mode.upper(), args.power)] + moves \
|
||||
+ ['M5', 'G90']
|
||||
print('\n>>> LIVE FIRE. Eye protection, exhaust running, fire watch,')
|
||||
print('>>> extinguisher in reach, scrap under the head with %g mm'
|
||||
% args.length)
|
||||
print('>>> of clear travel in +X.')
|
||||
print('>>> 1) press the button to ARM (the stream waits for it)')
|
||||
print('>>> 2) press once mid-cut to PAUSE')
|
||||
print('>>> 3) press once more to RESUME')
|
||||
else:
|
||||
lines += moves + ['G90']
|
||||
print('\n>>> DRY rehearsal - no laser is commanded.')
|
||||
if not args.auto:
|
||||
print('>>> 1) press the button mid-move to PAUSE')
|
||||
print('>>> 2) press once more to RESUME')
|
||||
print('>>> sampling for %g s from now.\n' % args.secs)
|
||||
|
||||
try:
|
||||
os.nice(5)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
auto = []
|
||||
if args.auto:
|
||||
if args.run == 'live':
|
||||
sys.exit('--auto is a dry rehearsal aid; a live run uses the button')
|
||||
t_p, t_r = (float(x) for x in args.auto.split(','))
|
||||
auto = [(1.0 + t_p, b'!', 'pause (!)'), (1.0 + t_r, b'~', 'resume (~)')]
|
||||
print('unattended: ! at +%g s and ~ at +%g s after the job starts'
|
||||
% (t_p, t_r))
|
||||
|
||||
pads = Pads()
|
||||
print('FIRE line is %s at idle'
|
||||
% ('driven' if pads.fire_is_driven() else 'high impedance'))
|
||||
marks = []
|
||||
try:
|
||||
base, events, worst, motion = sample(pads, args.secs, grbl, lines,
|
||||
marks, auto)
|
||||
finally:
|
||||
pads.close()
|
||||
|
||||
report(base, events, worst, args, marks, motion)
|
||||
print('\nfinal: state=%s kernel=%s interlock_circuit=%s faults=%s'
|
||||
% (grbl.state(), sysfs('state'), sysfs('interlock_circuit'),
|
||||
sysfs('faults')))
|
||||
|
||||
if args.json:
|
||||
with open(args.json, 'w') as f:
|
||||
json.dump({'baseline': base, 'worst_gap_s': worst,
|
||||
'live': args.run == 'live', 'mode': args.mode,
|
||||
'power': args.power, 'feed': args.feed,
|
||||
'marks': marks, 'motion': motion,
|
||||
'events': [(t, c) for t, c, _s in events]}, f, indent=1)
|
||||
print('wrote %s' % args.json)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user