Every commit. No marketing spin.
# 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 cmt4ci9db003t01mgxey1bosyAll 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, cmt52fjd8004101mgtq85iq1dwrap() 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.