What We Shipped

Changelog

Every commit. No marketing spin.

1–30 of 637 commits
1 / 22
# Conflicts:
#	docs/agentic-mcp.md
#	docs/ipc-bridge.md
#	scripts/verify-agentic-mcp-package.mjs
#	src/renderer/src/main.js
#	src/renderer/src/renderer.js
#	src/renderer/src/workspaces/Drafting/evaluator.js
#	src/renderer/src/workspaces/Drafting/main.js
#	src/renderer/src/workspaces/index.js
#	src/shared/automation/command-registry.js
Add factory cut and marking recipes, Cebora relay and PSU control, separate Hypertherm mark/cut workflows, and automatic XP/SYNC migration. Cover chart labels, generation, posting, persistence, previews, quotes, and safety regressions. Update v2.5.6 release notes and dev version.
Persist native assembly state across reopen, export current evaluated component shapes without fake edits, make anonymous McMaster sourcing truthful and usable, and fence screenshot/discovery behavior against stale state.
Add PCB authoring, routing, imports, sourcing, manufacturing output, assembly and 3D workflows, production MCP coverage, validation corpora, and documentation.
Drain stale Windows auxiliary replies before EZCAD handoff, guard driver maintenance, and verify bundled USB and driver resources. Include libwdi sources and license notices, move nudge distance into the shared CAM transform HUD, and refresh v2.5.4 experimental release notes.

Windows validation card cmtoku5w4003701qmluqx2qns is closed by Travis's release-scope decision. Remaining hardware checks are deferred, not passed; fresh-driverless Windows setup remains next-release work.
Electron's sendInputEvent does not build the renderer's MouseEvent.buttons
from the event's `button` field — Blink derives it from the leftButtonDown /
middleButtonDown / rightButtonDown input MODIFIERS. We never set those, and
`button` reads 0 (left) on every move regardless, so every injected mouseMove
landed as buttons === 0: a hover.

Drafting/Sketch is the only surface in the renderer that gates drag
continuation on event.buttons, and its buttons === 0 branch actively cancels.
So marquee select, entity drag, dimension drag and the canvas-manip gizmo were
all dead over an OEM Connect Control session, while Design, CamTrace, Plasma,
Laser, Router, Oxyfuel, Ecad and Sketch3D — which track their own down-state —
were fine. That asymmetry was the whole diagnosis.

Fixed at the injector rather than in picking.js: one place repairs all four
drags plus Router's middle-button orbit guard and GcodePilot's hover
suppression, and leaves no trap armed for the next author.

- new remote-input.js: buttonModifiers() / mergeInputModifiers(), with the rule
  documented at the top. A missing `buttons` field (older viewer) degrades to
  [], never a guessed button
- sendRemoteInput merges it at the single point modifiers are computed, so
  mouse and key paths are both covered
- 12 unit tests + a source scan asserting the pre-fix line cannot return

The viewer half ships with the matching jetcad.io release; both sides are
needed for any observable change.

Card cmtk4satu005o01p4mh7js97q
Field-verified 2026-09-02 morning: Scan & Trace on the square+circle region
now completes cleanly ("working very well").

The failing run's registry told the whole story: 17 members = the 3 real
contours (1.107" square, 1.111" circle, 0.574" bore) + 14 flecks of pen debris
and dust, 0.008"-0.032" across. Then "component 1 of 17 could not be locked at
its OpenCV seed" — component 1 was the SQUARE, whose nearest-to-centroid seed
sat beside the speck cluster: extra ring crossings read as a junction, the lock
failed, and one failed seed aborted a job whose other 16 members were untouched.

1. registryMemberGates (scan.js): membership gates get a PHYSICAL floor
   (MIN_TRACEABLE_SPAN_IN = 0.08" + a matching area floor) instead of being
   pure multiples of the configured stroke width. The configured width (8 px)
   was half the real printed stroke (~17 px), so the old gates halved with it
   and pen-tip-sized noise walked in. All 14 field specks now fail by ~3x even
   at the wrong stroke; the smallest real contour to date clears by 5x. Tests
   use the exact field numbers.

2. Alternate acquisition seeds (opencv-ops.js altSeeds -> dialog machineSeeds):
   every stroke member also carries its pixels nearest the four bbox-edge
   midpoints, tried in order after the primary seed. The walker only needs ONE
   clean spot on a contour; the square had three clean sides and the job only
   ever tried the dirty one.

3. A member that cannot be acquired at any seed is SKIPPED (registry.fail +
   continue), never fatal. The registry is finite and each member is attempted
   exactly once, so continuing cannot loop — the abort-everything rule was
   protecting a search architecture that no longer exists. The job ends with
   "N traced · M skipped" plus a debris warning, and errors only when nothing
   at all could be traced. Coordinator termination is registry.remaining, since
   registry.done demands zero failures (the OpenCV live-path contract test now
   asserts the new, still registry-driven shape).

338 camtrace/camera/vision tests pass (+3 intentional replay skips).
Field-verified on the rig 2026-09-01 night: after these fixes Travis
recalibrated both planes successfully and traced the Sprocket outer cleanly.

## Autonomous scan (field-verified earlier tonight)

- The registry mosaic gets a real GUARD BAND again (registryMosaicPaddingIn:
  max(0.05", stroke x 4, tile radius / 2) — edge tiles already observe that band,
  so it costs raster memory, never motion). The rewrite had clipped the raster
  exactly at the marquee, so a part outline crossing it by a hair sat on the
  raster border and read as "touching unobserved coverage".
- "Incomplete" is COVERAGE-based only. A bbox poking past the marquee is not
  incompleteness — the marquee is a selection, not a fence. Third occurrence of
  this failure class (Gear 0.010" over the line, Gear rough-box, base plate);
  now pinned by scan tests.
- Incomplete contours are SKIPPED with an end-of-job report, never a job veto;
  it errors only when nothing at all is traceable. The seed-inside-region check
  honours the same guard margin.
- Dedicated scan feed (scanFeedDisplay, default 100, Advanced -> "Scan feed")
  for all autonomous NAVIGATION: lift/travel/descend, serpentine tiles, and
  contour-to-contour hops. Every hop previously ran at the 20 in/min measuring
  feed — 3.6 s of travel per 1.19" serpentine hop was the whole "over a minute
  for a 2x2 part". The precision walk keeps its measuring feed; the settle
  timeout budgets the feed actually sent.

## Calibration: two latent, environment-masked bugs, found the same night

Both only ever passed by environmental accident, and each masked the other, so
no dot placement could win: beside printed parts, base found the dot but every
grid leg failed; on fresh paper, base itself failed.

1. LEG TRACKING (dot beside printed geometry): locateDarkBlob seeds its flood
   at the fg pixel nearest the window centre — right at base, structurally
   wrong after a grid leg, which moves the dot off-centre and slides printed
   outlines toward the centre. The flood seeded a printed outline, it ran out
   of the window, touchesBorder refused it, and the dot in plain view was never
   considered: six legs, six nulls, "0 usable calibration samples".
   Fix: enumerateDarkBlobs (every component, with fillRatio/aspect/border
   stats) + locateFeatureMatched — per-leg fallback that selects the candidate
   area/shape-matched to the BASE dot. Printed outlines fail every gate.
   Includes an exact failure replay test (centre-seeded null + matched success
   on the same frame).

2. INK THRESHOLD (dot on fresh paper): darkMarkThreshold anchored "ink" at the
   darkest 0.2% OF THE WINDOW — ~2,300 px in the 1069² calibration window,
   MORE pixels than a ~2,000 px sharpie dot, so on clean paper the percentile
   ran past the ink and the contrast gate reported "No calibration mark found"
   over a sharp, centred dot.
   Fix: the ink anchor is an ABSOLUTE darkest-pixel count, clamp(n x 0.002,
   48, 400). On the 256² trace ROI this reproduces the old numbers exactly
   (131), so tracing is unchanged; on any larger window it anchors inside any
   mark bigger than ~25 px across. Regression: a lone r=25 dot in a 1069²
   vignetted window (fails on the old code) + blank paper still refused.

335 camtrace/camera/vision tests pass (+3 intentional replay skips).
Hardware-verified on the rig 2026-09-01: Travis traced the Sprocket's outside
contour after this build and called the result really good.

## OpenCV 4.12 as THE vision runtime

The shared camera-vision worker now runs pinned OpenCV 4.12 with no live legacy
segmentation fallback. Main owns resource-path resolution and WASM integrity
(resources/opencv/manifest.json + scripts/verify-opencv-runtime.mjs); preload
exposes only the verified payload; the worker owns undistortion, grayscale/blur,
thresholding, morphology, component/contour topology, registry construction,
ChArUco detection and lens solving. CamTrace keeps its task-specific walker,
corner lock, closure, duplicate corridors, fitting, preview and export. The old
custom scan/reacquire/completion-search path is removed. This substrate is the
one vision engine — future plate auto-square and remnant-recovery tools extend
it rather than growing their own.

## Baked Arducam kit lens profile

The real 2592x1944 Arducam kit camera ships profile
arducam-5648-5mp-s-hdr-2592x1944-v1: 15 retained field views solving at
0.976952 px RMS (fx=1470.709188 fy=1469.667348 cx=1315.950858 cy=979.535014).
Normal kit setup therefore skips ChArUco entirely and runs only the
camera-to-machine motion-dot XY grid.

## The coordinate-contract regression (found by the physical profile)

Centered synthetic fixtures hid three seams that the real lens exposed at once:
OpenCV detections are ALREADY undistorted, but live motion undistorted them
again; pixel tangents/tolerances were treated as absolute positions at (0,0);
and the scan mosaic anchored on the optical principal point instead of the
visible geometric crosshair (~22 px apart on this camera). Manual acquisition
inflated a 1.5 px center tolerance from ~0.0015" to 0.04494", declared an
off-center bore centered, and drove away. Fixed with separate raw-pixel,
undistorted-position and pixel-vector mappings; scan tiles anchor at the
undistorted visible crosshair. Physical-profile regression coverage guards both
manual centering and autonomous mosaic placement.

## Also in this set

- Deterministic Trace-Z contour registry (registry.js) with tests.
- Trace timing instrumentation (trace-timer.js).
- Deterministic autonomous scan ordering + dual-height scan hardening.

325 camtrace/camera/vision tests pass (+3 intentional replay skips).
The print polyline emitter walked pts[1..N) only and never used is_closed,
so a closed sketch printed missing the last side/corner. The closing
segment lives on the last vertex's bulge over N-1 -> wrap to 0; closed
loops store no duplicate closing vertex, so walk segCount = is_closed ? N : N-1
and land the path back on the start instead of appending Z (which draws a
straight and drops the bulge). Zero-length wrap arcs still skipped.
Adds the diagnostic loop CamTrace was missing, then uses it to fix a field
failure it made visible.

## Capture + replay

The numeric trace log records what the tracker DID; it cannot say why detection
decided anything, because the pixels are gone. Every fix up to now was therefore
inferred from consequences, and two of them were wrong in ways that cost a trip
to the machine to disprove.

- `src/main/camtrace/capture.js` writes the exact grayscale ROI handed to
  `detectFeature` for every frame, with its inputs and its verdict, to
  `<userData>/camtrace-captures/`. Binary PGM so nothing is re-encoded; JSONL so
  a crashed trace still parses. Self-prunes. On by default.
- `scripts/camtrace-replay.mjs` re-runs the SHIPPING detector over a recorded
  session off the rig — deterministic, no machine, no operator. Flags latched
  corner runs by name.
- `replay.test.js` replays every session under `test/camtrace/sessions/`;
  dropping a directory in is the whole act of adding a regression case.
- `src/shared/pgm.js` is the one grayscale codec, shared by all three.
- The walk now records its ACQUISITION frames. An acquisition failure logged
  `n: 0` and nothing else, which is precisely how the bug below reached the
  operator with no evidence.

## The off-centre-line deadlock

Two ring crossings under 180° apart were read as a corner. But an off-centre
STRAIGHT line does that too — it cuts the ring as a chord subtending
2·acos(d/R), ~109° at 0.05" off a 64 px ring. The misread is self-sealing: a
corner frame zeroes the lateral correction by design, so the crosshair never
moves onto the line, so the crossings stay apart. On the rig it walked an entire
edge of a square without correcting or recording anything.

- `chordInkFraction` asks the image which it is: for a line the chord IS the
  line (1.000 on every rig frame); for a corner it crosses the empty quadrant.
- The perpendicular scan can now REACH an off-centre line. Its window was
  halfStroke+margin ≈ 18 px against a line 37 px away, so it measured nothing
  and reported the crosshair as the feature — offset zero. Far off the line and
  perfectly centred were indistinguishable.
- Sub-resolution ring runs are rejected. A 1.3 px speck split a clean
  two-crossing frame into a phantom junction and skipped the chord test.
- Acquisition is lateral-only and records nothing until centred: it used to
  advance while correcting, so failing to correct looked like progress.
- Corner transits and endpoint recoveries are bounded by a creep budget. A stuck
  corner flag dead-reckoned 0.83" — a whole side of a square — sampling nothing.

## Corpus

- `rig-line-off-centre` — 12 real frames pinning the deadlock above.
- `rig-sprocket-tight-arcs` — 8 real frames, marked `pending`: the TARGET, not a
  passing case. Arcs tighter than ~1.69× the probe radius read as corners, and a
  0.04" step along the TANGENT of a 0.077" radius leaves the contour by 0.0104",
  larger than the stroke half-width. Diagnosis and both numbers are in its
  session.json; `smoothPathInk` is the arc-aware successor, present and
  documented but deliberately not wired in until the walk stops stepping along
  tangents.
A traced 1x1 square now comes out a square: 4 lines, 4 dead-sharp corners
(max |bulge| = 0), sides 1.0036-1.0100, corner angles within 0.27 deg of 90.
Verified against the real rig samples, not just synthetic ones. A traced
circle comes out as a true circle and explodes cleanly in Drafting.

Travis called the architecture: "maybe we should be classifying entity types
while we're tracing... I literally want lines, arcs, and circles to be traced
precisely." He was right, and the evidence was already in his own data.

ROOT CAUSE of the chamfered corners: CamTrace was running its metrology
samples through the ARTWORK tracer (splineFitTraceContour -> RDP -> fitArcs,
shared with the Design/Plasma image importers). That pipeline's job is a
smooth plausible curve through bitmap pixels, so it rounds every corner it is
handed - it was smoothing away the exact vertex corner reconstruction had
just inserted, then faceting the remains. Segmenting those same raw samples
and intersecting the fitted lines gives 90.02/89.85/90.20/89.93 degrees, so
the measurement was never the problem.

New `common/camtrace/primitives.js` - fit the primitives, do not smooth:
- Segment the samples into runs each explained by ONE primitive; the walker's
  corner flags are hard boundaries (they land perfectly - samples 15/40/65/90
  on the rig square).
- Fit each run GLOBALLY: total-least-squares line (PCA), algebraic circle
  (Kasa, internally centred - raw coords ~10 condition badly). N samples per
  edge average the noise down by sqrt(N); a curve through the points keeps it.
- Junctions come from INTERSECTION, not sampling: line/line IS the corner,
  exactly, however rounded the machine's physical path through the turn was.
  Tangent (line/arc fillet) junctions fall back to averaged projections where
  the intersection is ill-conditioned.
- Emit the canonical bulge polyline: line -> 0, arc -> tan(sweep/4), full
  circle -> TWO 180-degree halves.
- Line wins ties over arc, plus a SAGITTA gate, so a slightly-bowed straight
  edge never becomes a 400" radius.

Two bugs found by validating against Travis's actual trace (12 segments, then
9, before 4):
- Spans must NOT share a sample across a corner break. A point from the
  outgoing arm inside the incoming arm's fit cannot be a line, so it split,
  and the split emitted a short segment straight across the corner - that
  alone manufactures a chamfer.
- The 2-3 samples either side of a corner are contaminated (the classifier
  fires as the corner enters the probe ring, but the perpendicular scan is
  already clipping the other arm). They are now trimmed: the arm has 20+ good
  samples and the vertex comes from the intersection anyway.
Plus: closed contours are rotated to start at a corner (a trace starts
mid-edge, so the first and last spans are two halves of one side), runt
segments are dropped so neighbours intersect directly, and a wrap-merge
handles the no-hints case.

Also in this commit:
- Corner transits contribute MOTION, not geometry: the walker samples nothing
  while turning (its physical path through a corner is an arc, and sampling
  it bakes in a fillet no fitter can remove) and records the gap index.
- Servo/continuous tracer BENCHED after its first hardware run; Precision
  (step-settle-shoot) is the default again. Continuous stays selectable.

The artwork path remains reachable via `primitives:false` for genuinely
freehand shapes, where a best-fit curve IS the right answer. The shared
image-trace pipeline is untouched.

Card: cmtgp5vyj001b01p4xhyv2c95
Everything here came out of live bench rounds on the rig with the camera
zip-tied to the torch, culminating in a working recalibration after the lens
was manually focused. The v2 continuous-tracing plan (13 Q&A decisions) is on
card cmtgp5vyj001b01p4xhyv2c95.

Detection/tracking fixes, each pinned by a test that reproduces the field scene:
- INK-ANCHORED mark threshold, not Otsu: on wrinkled paper (shading 150-210)
  with one black dot, Otsu picked 212 and marked 53% of the window foreground;
  the flood bled from the dot into the shadows and reported "no mark found" on
  a perfect mark. Ink is DARK, full stop - threshold = ink core (0.2th pct)
  + 40% of the ink->paper span; a no-contrast window honestly returns null.
- Calibration mark search runs in a CENTRED window (~55%) with that local
  threshold, not the full frame - a table-shadow blob at the frame edge must
  not outvote the dot the operator aimed at. Mark preview grades through the
  same window, so its green means the grid will actually work.
- Corner handling: classify ring crossings into forward/back via the travel
  direction; at a corner the tangent commits to the OUTGOING arm (never the
  diagonal average), offset is forced to zero (no corner-blob centroid drag -
  the 0.024" worst-error of the first square), step pins to minimum.
- Single crossing = RECOVERY, not endpoint: creep toward the ink at min step;
  declare an open-contour end only after 5 consecutive singles with no net
  progress. An overshot corner and a true line end are indistinguishable from
  one frame - bouncing in place is the discriminator.
- Stroke width is MEASURED at trace start (measureStrokeWidth) - a wrong
  expected width truncates the perpendicular window and under-corrects by
  half every frame. Window is halfStroke+margin, ROI-capped, so a wide stroke
  can no longer reach the far arm of a corner.
- CW/CCW is consumed ONCE at walk begin to pick the travel sense; detections
  align to travel thereafter (no per-step direction multiplier).

Camera/UX (v2 phase 1):
- Focus Assist meter (live Laplacian sharpness + peak-hold): the lens has NO
  focus motor - proven by a full focus_absolute sweep with flat sharpness -
  so focus is set by rotating the M12 barrel against this meter. The fake
  Focus button is gone; sharpnessMetric() is also the sensor for the future
  auto-find-Trace-Z sweep (the Z axis is the focus actuator).
- Circular viewport: square disc clipped to a circle, video object-fit:cover
  (no letterbox bars), one composition pan∘zoom∘align∘cover shared by the
  picture, every overlay mark, and every mouse event. Crosshair anchors to
  the physical camera centre and rides with the image under zoom/pan.
- Wheel zoom-to-mouse (1-20x) + middle/right drag pan + Reset view.
- Jog | Region mouse modes; Region drags a PERSISTENT machine-space scan
  region (survives restarts, stays put on the plate as the camera moves),
  drawn with a dark-cased amber stroke that reads on white paper.
- Exposure is not geometry: auto-exposure defaults ON with live Exposure/Gain
  sliders; only autofocus is force-killed (it invalidates calibration).
- Gantry-sign fix (CALIBRATION_VERSION=2): the camera moves and the work does
  not, so the stored matrix is the feature-offset mapping = NEGATIVE of the
  raw motion response. v1 records are invalidated with an explaining badge,
  never auto-migrated (a wrong sign and a mirrored mount are indistinguishable
  from the numbers).
- machineStorage: 'camera' section accepted into extended_config; saves are
  verified by read-back (a silently-rejected section previously looked
  identical to success and the calibration evaporated).
- Raw-trace diagnostics: every walk logs [camtrace-raw] per-sample provenance
  (position, offsets, quality, crossings, corner flag, step) BEFORE fitting -
  tuning against the fitted output is tuning against the wrong signal.

Card: cmtgp5vyj001b01p4xhyv2c95
Draw a part on paper, put it on the table, walk the camera around the
outline, get a Drafting sketch or a Plasma part. First successful trace on
the rig today: a closed sharpie contour into a Drafting sketch.

GcodePilot owns the machine, so the tool lives there and EXPORTS geometry to
CAD — that boundary is why it is a GcodePilot toolbar tool and not a CAM
workspace feature. Gated on connected + homed + no run in flight.

How it works
- Side-by-side dialog: live camera in a circular viewport with a scope
  crosshair, and the accumulating trace with a live dimensioned bbox.
- The crosshair colour IS the confidence the motion decision uses, so the UI
  can never look confident while the tracker is guessing.
- Circular-probe tracker: a contour crosses a ring around the crosshair
  exactly twice, which gives both the tangent and the lock/weak/lost state.
  3+ crossings is a junction and it refuses to pick a branch — it slows down
  and shrinks its step instead.
- Step-settle-shoot: nothing is sampled while moving, so the sample point is
  machinePos + calibratedPixelOffset with no time term at all.
- Calibration is a machine-motion grid tracking a compact dot, solving one
  2x2 pixel->machine matrix per height (scale + rotation + skew + mirror in
  one object) with linear interpolation between calibrated Z planes.

Reuses the existing choke points throughout: splineFitTraceContour,
simplifyPoints(PRESET.TRACE), fitArcs, the bulge-aware polyline area, and
graph-viewport + wheelZoomFactor for the trace view.

Hard-won invariants, all test-pinned
- GANTRY SIGN: the camera moves and the work does not, so a fixed mark slides
  opposite to the machine. The stored matrix is the feature-offset mapping,
  which is the NEGATIVE of the raw motion response. Getting this wrong
  inverts click-to-jog, the walk and the view together.
- Calibration needs a DOT, not a line: motion along a stroke is invisible
  (aperture problem), so a line yields a rank-deficient solve. Guarded by a
  conditioning check on the sample Gram matrix, not a useless det != 0.
- V4L2 auto-modes must go OFF before the absolute values are writable —
  the driver returns EPERM otherwise. Verified on the real Arducam.
- Autofocus must always be killed (it invalidates calibration); auto-exposure
  must NOT be, because exposure is not geometry. Forcing manual before anyone
  picked a value is what made the camera need a flashlight.
- Otsu ties across a plateau on clean bimodal art — return the midpoint, and
  never measure contrast relative to the threshold.
- Loop closure is checked BEFORE appending the returning sample, so a closed
  contour never gets a duplicate closing vertex.

Also
- machineStorage: accept a 'camera' section (was silently rejected, so
  calibrations never persisted). Rides in extended_config as one JSON blob.
- Drafting: importExternalSketch() — the mirror of the CAM workspaces'
  importSketchFromDrafting(), so producers have one named entry point.
- Fix a DEAD contract test: sim-axis-limits read workspaces/JetFlight/main.js,
  which stopped existing at the GcodePilot fork, so the "real-machine path"
  assertion had been throwing ENOENT rather than passing. The send itself was
  never lost — only the test's path went stale.
- resources/opencv/: minimal opencv.js (core/imgproc/calib3d/objdetect) built
  by scripts/build-opencv-js.sh, for the ChArUco intrinsics step. Vendored and
  verified; the wizard step that consumes it is not written yet.

Docs: docs/GcodePilot/camtrace.md
Card: cmtgp5vyj001b01p4xhyv2c95
Factory recipe generation derived the power-supply operating mode as
`processKey.startsWith('finecut') ? 3 : 1`, and 3 is GOUGE in the Powermax
0x3080 / EX-TRAFIRE 0x2093 enum (1 = cut, 2 = expanded metal, 3 = gouge).

FineCut is not an operating mode. It is a CARTRIDGE — Hypertherm 810400 Table 7
lists "C MFNC" = FineCut Mechanized Cutting — and it cuts in mode 1. There is no
FineCut value for 0x3080 at all.

This is not limited to the new RS-485 link: the same field feeds the QtPlasmaC
post's `cm=` material word, so anyone running QtPlasmaC with pmx485 has been
switching their Powermax into gouge for FineCut jobs. Re-posting picks up the
corrected mode.

No shipped process is a gouging process, so every factory row is mode 1 today;
the derivation now keys off a gouge-named process so mode 3 stays expressible,
and a single chart row can still override via the entry spread.

Adds rs485-cut-mode.test.js: guards that the derivation never keys off FineCut
again, that the FineCut processes it used to mis-map still exist (so the guard
stays meaningful), and that per-row override still wins.

Card cmt4ci9db003t01mgxey1bosy
All 65 codes from 810400 Rev 4.1 Table 6, with Hypertherm's own A-BC-D display
format (the decimal register value padded to four digits and split — the doc's
worked example is 0x0079 = 121 = 0-12-1). Verified by extracting the codes from
the PDF and diffing against the table: no omissions, no inventions.

The format is applied to UNKNOWN codes too, so an unrecognised fault still reads
the way it does on the unit's display and in the service guides, rather than as
a bare "121".

Adds blocksStart per code, from the doc's "Steps Required to Remove?" column,
with one deliberate exception found while transcribing: 0-50-3 is documented as
showing "momentarily while the system reads data from the cartridge". It is a
status flash, not a fault. The previous any-non-zero-code gate would have
refused Cycle Start at random every time an operator pressed Start during a
cartridge read. It is now the single non-blocking code; a non-blocking code also
stays out of the operator feedback line and does not turn the PSU pill red.

describeFault is default-deny — an entry escapes the gate only by saying
blocksStart:false outright, and an unrecognised code always blocks.

hypertherm-legacy deliberately does NOT reuse the SYNC table: a third of it is
cartridge faults (0-14-x, 0-32-x, 0-98-1) that cannot occur on a unit with no
smart cartridge, and "Cartridge is not recognized" on a 45XP would be worse than
no description. It shares the display format only; descriptions need AN 807220.

Card cmt4ci9db003t01mgxey1bosy
Pushes the cut chart's amperage and cut mode into the plasma power supply at run
start and reads faults and live values back. A second serial link alongside the
motion controller's; settings and status only, so torch fire and arc voltage —
and therefore THC — are untouched.

One Modbus ASCII driver serves all four supported units, because they all speak
it: ThermaCut's TC Interface is a deliberate Hypertherm-legacy 0x2xxx clone
(same ×64 amps / ×128 psi), and Cebora's kit clones the SYNC 0x3xxx map. Vendor
differences live entirely in shared/gcodepilot/psu/profiles.js; psu-link.js has
no vendor conditionals, only capability-flag reads.

THE ZERO RULE. On the legacy 0x2xxx register set a force register written to
zero RELEASES the unit to its front panel — it is how exitRemote works, and what
LinuxCNC's pmx485.py close_machine() does. The plan had legacy and ThermaCut
sending pressure=0 as an "auto" hint copied from SYNC's 0x3082; that would have
dropped every Powermax legacy and every EX-TRAFIRE out of remote control
mid-program. pressureAutoZero is now true for hypertherm-sync ONLY, asserted by
a contract test on the exact profile list so it cannot spread by copy-paste.

Three invariants, each with a documented failure mode behind it:
  1. The front panel always comes back — exitRemote on run end, stop, alarm,
     soft reset, reboot, disconnect and link close.
  2. Zero means release, never auto (above).
  3. A single clean fault read is not proof a fault cleared. On the ThermaCut
     gateway, reading the fault register CLEARS it, so our own 1 Hz poll is the
     acknowledge; the sticky model holds the last code until 3 consecutive clean
     reads or the cycle-start gate flickers open.

Also fixes a defect the range clamp was hiding: a cut-chart row with no current
was clamped UP to the unit's minimum and silently commanded. enterRemote now
validates presence before clamping and refuses.

Notes:
- The post directive is stamped by the FRAMEWORK, not per-dialect — PlasmaPost
  has no onHeader of its own, so per-dialect stamping would only reach the posts
  we remembered to edit. Emitted via writeComment so MASSO's "#text" marker
  works; the parser accepts both wrappers.
- ONE setpoint per program. A nest with mixed amps/mode stamps nothing plus an
  explanatory comment rather than cutting the sheet at the first op's current.
- gp_psu_port is a separate top-level key from gp_psu so it can join
  CONNECTION_LOCAL_KEYS and strip on export, mirroring gp_port.
- Cebora's amperage encoding is undocumented; the link infers it from the unit's
  own permitted-range read and forces monitor-only when genuinely ambiguous.

103 new tests, with every doc-published worked example as a wire vector. No
hardware validation — field testers are Matt Rohrer (SYNC) and Scott Pridham
(EX-TRAFIRE). Cebora's profile is unreachable until its cutter models land
(card cmtgmlo6t001101p457twlt8m).

Cards cmt4ci9db003t01mgxey1bosy, cmt54sk6y004401mgohawbnu9, cmt52fjd8004101mgtq85iq1d
wrap() merges _caller into the caller's meta and both formats dropped any
splat object containing it — every main-process meta object was silently
discarded. The formats now strip the _caller KEY and print the rest.
The MAC slot probed os.networkInterfaces(), which only lists interfaces
holding an address — an offline license died on a network-down boot
(Two-Lau field incident). MACs now come from the hardware (/sys/class/net,
networksetup, Get-NetAdapter -Physical), validation accepts ANY physical
NIC's digest for the mac group (superset — fielded licenses keep working),
an insufficient probe is never cached, and every refusal logs its reason.
no-machine-identity (can't answer) on an ESTABLISHED session now gets a
bounded 7-day grace; machine-mismatch and fresh grants stay hard.
The post shipped avthc:false while the daemon had no AVTHC engine. The engine
shipped 2026-08-29, but machines that opened the post properties panel before
the flip snapshotted avthc:false into postConfig, silently dropping M103/M101/
M102 from posted programs (first rig cut, 2026-08-29).
Rig report (Travis, 2026-08-29): running the program with the torch off, fire_torch's
arc-OK wait passed instantly and the machine "cut" the whole contour believing the
arc had transferred — the pierce-retry logic never got the chance to run.

readInput() — the resolver behind the NGC runner's M66 wait — returned the RAW DIN
bit. The arc-OK port is a pull-up with activeLow: true (NC relay to ground), so with
no arc the line reads electrically HIGH: raw 1, "arc established". The User I/O
parity work established the invariant that inputs publish their LOGICAL asserted
state (a raw electrical level is not a logical state) and applied it to every
PUBLISHING path — indicators, status.io, the DRO dot, and yesterday the daemon's
ARC_OK slot — but the M66 READ path was missed, and it is the one fire_torch's
safety logic hangs off. FluidNC behaves logically here too: its firmware inverts
before reporting.

readInput now resolves the port's activeLow from the machine config and returns the
asserted state; ports missing from the config get the daemon default (activeLow
true). Driver test updated from raw to logical semantics with the rationale inline.

45 driver tests pass.
Mirrors daemon 7ca7495 + firmware 1d711e3 (heartbeat v3: calibrated 1 kHz arc
voltage, peak latch; fabricated arc voltage is dead on hardware).

- SLOTS: ARC_PEAK_V 72, BUFFER_SIZE 73.
- Driver arcPeakVolts is double-gated: the `arcPeak` daemon cap (an older daemon
  zero-fills the slot) AND the −1 pre-v3-firmware sentinel. Either miss reports
  null so #<_arc_peak_volts> resolves "unavailable" — never a fake 0 V peak that
  would fail every peck verification as a misfire.
- HOT_THC_KEYS += arc_voltage_scale / arc_voltage_offset, matching the daemon:
  the divider ratio is calibrated against a meter while watching the live ARC
  readout, and a reconnect per adjustment would make that loop unusable. Mirror
  guard test updated.

817 tests pass.
HOT_THC_KEYS joins the classifier, so changing a gain sends a minimal
UPDATE_CONFIG instead of tearing down and reconnecting the board.

Classified per key, like hardware.homing — not as a top-level passthrough, so an
unlisted future thc key rewires rather than inheriting hotness. The arc-voltage and
arc-OK INPUT selection still rewires: those are `role`s on hardware.ioPorts, i.e.
board wiring, not tuning.

Includes a MIRROR GUARD test asserting HOT_THC_KEYS matches the daemon's `hotThc`
list exactly. The two lists failing apart is silent in both directions — hot here but
cold there reconnects anyway, hot there but cold here never sends the update.

817 tests pass.
Both reported from the rig.

── The ARC label went gray on a working machine ─────────────────────────────

arcDroColors keyed the LABEL on the M101 armed flag AND target > floor. Neither
holds on an idle machine: nothing has run M101 yet, torch_off emits M102 after every
cut, and the target is 0 until a program sets it. So the label read "AVTHC off" on a
machine where AVTHC was configured and working.

It looked right on FluidNC only by accident — firmware reporting the legacy 3-field
`Arc:` sends no enabled/target at all, and the null case is treated as armed, so
those machines are permanently orange while a fully-reporting controller goes gray.

The label now tracks AVAILABILITY: the row only renders when AVTHC is enabled for the
machine, so it stays orange whenever height control is in play and greys only when the
operator Disables it from the pill (avthcMode 2). The VALUE keeps the stricter test
including the min_arc_voltage floor — red compensating, green in tolerance, orange
armed-but-no-arc, gray with no usable setpoint. That is the behaviour Travis
described: orange at idle, value going green/red by deadband.

── Torch Height Control had no fields ───────────────────────────────────────

Built out to match the FluidNC section field-for-field — threshold/deadband, min arc
voltage, PID P gain, arc voltage scale + offset, velocity anti-dive, engage distance,
max Z rate, averaging samples — with one deliberate difference: FluidNC picks its arc
pins by GPIO, JetFlight picks them from the USER I/O P-WORD list ("P1 · GPIO38"), the
same address the G-code uses. Selecting one moves the arc_voltage / arc_ok ROLE onto
that port and clears it from whichever port held it, because the daemon resolves both
by role and two claimants are ambiguous.

Two things found while building it:

  · gp_thc was never mapped into the daemon config — daemon-config.js passed
    `hardware` through but had no `thc`, so every value set here would have been
    cosmetic. Now sent as cfg.thc.
  · the length/feed fields store mm and mm/min, NOT JetFlight's usual inches, because
    gp_thc is SHARED with FluidNC whose thc: block is metric. Storing inches would
    make the same key mean two different things depending on which controller wrote it
    — exactly what sharing the block is meant to prevent.

Note: THC edits currently trigger a rewire rather than hot-applying (`thc` is not in
the hot-apply allowlist). That is the documented default-deny; making it hot needs a
matching change in the daemon's isHotConfigUpdate.

812 tests pass.
Rides daemon commit. Everything from the pill down through IPC to manager.js was
already firmware-agnostic; it dead-ended at four base-class no-ops the JetFlight
driver never overrode. This fills them in.

- setAvthcMode / setAvthcVoltage / nudgeAvthcVoltage / avthcManual on the JetFlight
  controller, as value-carrying daemon commands rather than the GRBL driver's ±1 V
  realtime-byte burst. No burst, no arcTarget read, no 400-byte cap, no 1 V
  quantization.
- setAvthcVoltage takes { enterOverride = true }. The daemon keeps setting a target
  and changing mode separate, so the DRIVER expresses intent: the pill means
  "override at this voltage" and passes the default.
- The manual-comp keep-alive lives in the driver, not the renderer. Host code above
  the driver stays untouched, and the timer dies with the driver — so a crashed or
  disconnected host stops the torch by going silent, which is the property the
  daemon's deadman exists for.
- avthcMode stops being a hardcoded null and reads THC_OVR_MODE, so the pill's
  firmware reconcile and shouldRestoreGcodeMode() stop being inert.
- _avthcOverrideEnabled() now gates on the daemon's `thcOverride` capability instead
  of gp_firmware === 'FluidNC'. An older daemon hides the pill, a newer one lights it
  up, with no version compare and no host release coupling.

Auto-Tune deliberately keeps entering Override on BOTH controllers for now. JetFlight
CAN set a target without changing mode, but with the mode left at Gcode the effective
target is the program's M103 and the override volts are ignored — the trim would go
nowhere. Decoupling only pays off for Auto-Tune once there is a TRIM command that
biases the M103 target directly; until then it borrows Override and hands it back,
exactly as on FluidNC. Recorded at the call site.

Slot mirror: THC_OVR_MODE 71, BUFFER_SIZE 72. 811 tests pass.
Rig report from Travis: during a probing cycle the homing badge flipped to a yellow
"Homing…" on a machine that was homed and stayed homed.

Homing and probing share the daemon's seek engine and therefore the HOMING_ACTIVE
slot, and _stateString treated any non-zero value as homing — so a touch-off
reported 'Home'. It now reports 'Run': the machine is moving under a commanded
cycle, which is what FluidNC reports for a G38. Checked before the unhomed block so
a probe on an unhomed machine (the first touch-off after an abort) still reads as
motion rather than Alarm. Everything that means "the seek engine is busy" — blocking
jog, routing Stop to HOME_ABORT — still tests > 0 and still catches both.

HOMING_ACTIVE_PROBE is exported from SLOTS.js so the sentinel has one definition
instead of a bare 99 in a comparison.

AVTHC surface for JetFlight plasma machines (card cmtd7szp501dy01pc6jlx1v6g):

- New "Torch Height Control" node in the JetFlight param UI — a single AVTHC
  on/off switch, plasma machines only. That switch is the gate: it is what puts the
  ARC row on the DRO and the AVTHC override on the overrides bar, which is how
  Travis asked for it. Tuning lives in the shared gp_thc block with FluidNC; the
  daemon applies its documented defaults until those fields are exposed.
- _arcReadoutEnabled() accepts JetFlight, so the ARC row, the arc-OK indicator and
  the arc-target readout light up exactly as they do on FluidNC.
- thcActive now reads THC_CORRECTING and thcEnabled reads THC_ACTIVE. Both used to
  read THC_ACTIVE, so the voltage readout could never go red — armed-and-steady and
  armed-and-chasing looked identical. See docs/avthc.md "enabled vs active".
- status gains arcSimulated, so the UI can tell a real measurement from the
  Z-derived simulation (which tracks commanded height and always looks perfect).
- The AVTHC override pill gets its OWN gate, _avthcOverrideEnabled(). The readout
  only needs the controller to REPORT arc state; the override needs it to ACCEPT a
  target and mode change mid-cut, and the JetFlight daemon has no equivalent of
  FluidNC's realtime bytes yet. A pill whose buttons silently do nothing is worse
  than no pill — it stays FluidNC-only until setAvthcMode/setAvthcVoltage land.

Slot mirror: THC_CORRECTING 70, BUFFER_SIZE 71.
Tests: new "a PROBE reports Run, not Home" regression; 811 pass across gcodepilot
main + renderer.
Slot mirror for daemon ae52208 — Slots.h COUNT 69 <-> SLOTS.js BUFFER_SIZE 69,
append-only. ARC_SIMULATED (68) is 1 when ARC_VOLTAGE is SYNTHETIC: the daemon
derives it from Z when the machine has no arc-voltage calibration, and it has
been indistinguishable from a real measurement until now. It tracks commanded
height, so an uncalibrated machine shows a perfectly behaved loop no matter what
the torch is doing. Zero-fill from an older daemon is the correct default — one
that lacks the slot predates the distinction.

THC_ACTIVE/THC_TARGET_V comments now name M101/M102/M103 instead of M200, and
record that the target survives M102.

Cards cmtdeks3e01ea01pcjkwulf5w, cmtd7szp501dy01pc6jlx1v6g.
The JetFlight plasma post never overrode onOperation, so it inherited PlasmaPost's
primitive cycle and toggled the torch with M100 P1/P0 — the SIMULATOR dialect
(plasmaSimPost.js), left behind when the posts were flipped to
Controller: GcodePilot / Firmware: JetFlight in ef65b9fc.

M100 is a real daemon code (handlers/torch/M100.h -> the role:"torch" port), so the
torch did fire — but the bare relay toggle skipped the whole pierce discipline: no
probe / per-pierce Z-zero, no preflow, no arc-OK wait, no retry, no consumables
prompt, no THC, and no buffer-synchronised on/off.

The post now calls the same fire_torch/torch_off subroutines the FluidNC post does,
byte-identical and unforked — which the User I/O parity work (cmtd7sm4k01dw01pcgyk5u786)
already made run unchanged on JetFlight.

- New properties fireTorchSub / torchOffSub / avthc (ships OFF) / useRadius;
  pierceWithZ and safeHeight removed — the subroutines own every Z move, and a
  post-emitted G0 Z fights fire_torch's #5422-anchored moves.
- onTorchOn/onTorchOff/onRapidZ/onLinearZ/onDwell are now stubs; header and footer
  no longer retract.
- AVTHC emits FluidNC's M103 Q / M101 / M102 verbatim rather than a JetFlight-specific
  code, so the host-side AVTHC editors (shared/ngc/edit-avthc.js, Auto-Tune writeback)
  — which match those M-numbers literally — work on JetFlight too. Daemon adoption of
  the trio is cmtdeks3e01ea01pcjkwulf5w.
- Peck and pierce-only branches, custom_gcode_before/after and the JC3AT chart stamp
  all carried over from the FluidNC post.
- Arc I/J now tracks _lastX/_lastY explicitly instead of re-parsing a formatted,
  suppressible Variable — a precision/robustness change, not a behaviour fix.

The cut path is byte-identical before and after; only the torch/Z framing changed.

Peck marks need the machine's Peck Verify Voltage set to 0 on JetFlight: fire_torch's
firmware-timed path uses M104, which the daemon does not register, and unknown codes
are silently dropped (MotionPlanner.cpp:200) — the relay never closes and the peck
makes no mark with no error. Lifts with cmtd7t7ec01e001pc31gwhwu5.

The 5-axis bevel post is unchanged and still emits M100 — cmtdeboqm01e801pc8bkso7xd.

Tests: new jetflight-plasma-post.test.js (24). post-peck-mark moves JetFlight out of
PRECISION_DIALECTS into a new SUBROUTINE_DIALECTS loop — the 5 ms pulse is now the
fire_torch delay argument, not a G4, and must still not round.

Card cmtdeaz7z01e601pcxuz64cl5. Not yet rig-verified.
Adds the card cmtd7sm4k01dw01pcgyk5u786 work to the open v2.5.3 rolling notes:
User I/O with FluidNC-convention P numbers (so fire_torch/torch_off run
unchanged on either controller), floating-head + ohmic touch-off, per-motor
homing switch selection with the same-cycle conflict guard, and the live DRO
during homing/touch-off with Stop/Escape abort. Also the arc-OK inverted-input
fix.

package.json → 2.5.3 (last pushed tag v2.5.2 + 1, matching the open notes file).
Pairs with daemon cf1cfa0 (live DRO during homing + HOME_ABORT).

A homing/probe cycle is driven by the daemon's seek engine on the heartbeat
thread, not the executor — so Stop's feed-hold/soft-reset path was aimed at
something idle and the cycle ran on regardless. safeStop() now sends
HOME_ABORT when a seek is active AND still takes the normal stop path: a
probe can be running inside an NGC program (a mid-run touchoff), and aborting
only the probe would let the program carry on. safeStop is the single choke
point for both the toolbar Stop button and the Escape hotkey, so neither
needs to know homing exists — no renderer change.

An abort is an operator action, not a fault: HOMING_DONE {aborted:true}
re-arms NOT HOMED (the daemon leaves the machine fully unhomed) but raises no
error indicator and no 'Homing failed' alarm text.

Tests: Stop-during-homing routes to HOME_ABORT and unhomes without a fault;
Stop-during-probe aborts the seek AND still issues the program stop.
1–30 of 637 commits
1 / 22