Gregwar/neje_controller

Controller for NEJE master 20W laser engraver

★ 0Forks 0PythonGitHub ↗Compare

README

neje-controller

A web-based controller for a NEJE laser engraver running GRBL 0.8c, written for cutting vector shapes from DXF files.

NEJE XY laser engraver

uv run neje-controller

Then open http://127.0.0.1:8000.


What it does

  • Jog the machine, set a work origin, and run G-code by hand
  • Load a DXF and drag it into place on the work area
  • Give each colour in the drawing its own feed rate, power and pass count
  • Trace the outline with the beam off before committing to a cut
  • Dry-run the whole job with the laser disarmed
  • Stream the job with live progress, pause/resume, and a stop that darkens the beam immediately

It cuts vectors. There is no raster/dither engraving mode.

The interface

The work area is always on screen, sized to fit without scrolling. The options are on the left, in four tabs:

Tab
Control position readout, origin, jogging, manual laser, console
Job load a DXF, size and orientation, frame, start/stop
Colours one cut operation per colour
Settings work envelope, axis direction, firmware settings

Position the job by dragging it on the work area. It is clamped to the work envelope while you drag, since nothing stops the head at the end of the rails.


Safety

The laser is disarmed at startup and cannot be fired until you arm it explicitly. While disarmed:

  • any command containing M3 or M4 is refused before it reaches the serial port — including hand-typed console commands
  • starting a job is refused
  • jogging, framing, previewing and settings all still work

Arming is undone automatically on disconnect, on alarm, when a job finishes, when a job is stopped, and after five minutes idle.

STOP (or the Escape key) sends a soft reset, then M5, cancels the job and disarms — from anywhere in the interface. Stopping mid-motion leaves GRBL in Alarm, because it can no longer vouch for the position and this machine has no switches to re-find it. Press Unlock, jog back to your material and set the origin again before the next job. The interface says so when it happens.

Hold to fire fires only while the button is held. Because a mouse-up can go missing over HTTP, the browser sends a keep-alive every 300 ms and the controller cuts the beam if it stops hearing them for 0.9 s — covering a closed tab, a dropped network, a sleeping laptop, or a pointer released off the edge of the button. A single burst is capped at 10 s regardless.

None of this is a substitute for laser goggles and staying with the machine.


This machine specifically

Values below were read from the machine, not assumed.

Firmware Grbl 0.8c
Port /dev/ttyUSB0 (CH340)
Baud 9600 — 0.8c's default, not the 115200 that 1.1 uses
Steps/mm 40 on X, Y and Z ($0–$2)
Homing disabled ($17=0) — there are no limit switches
Work envelope −150…+150 mm on both axes, configurable
Hard limits disabled ($16=0)
$6 invert mask 32 — X direction inverted, Y normal

No homing means the origin is yours to set

There is no machine home to return to. Park the head wherever you want the origin — by hand or by jogging — and press Set origin here (G92 X0 Y0).

The job may then sit anywhere around that point, on any side of it, so the work envelope is a range in each direction rather than a corner-anchored bed. It defaults to −150…+150 mm on both axes; set it in Settings to match the travel your machine actually has from where you park it. Those bounds are checked before any jog or job is sent, and are the only thing standing between a mistake and a stalled gantry.

Axis direction

If an axis runs the wrong way, use Invert X/Y direction. That toggles the relevant bit of GRBL's $6 step-port invert mask and writes it to EEPROM, so the machine, the readout and the preview agree afterwards. (On the standard Uno pin map the direction lines are bits 5 and 6; the step pulses on bits 2–4 are left alone.) Inverting in software instead would leave the position readout disagreeing with the machine, so it is not offered.

Laser power

Stock NEJE wiring drives the laser from a pin with no timer behind it, so S is either zero or full — there is no PWM. Leave Laser control on On/off only, and control how deeply it cuts with feed rate and passes, which is what the per-colour operations are for. The S word is still emitted, so the same program works unchanged if the machine ever gains real PWM; switch the setting to PWM then and the power column becomes live.

To find out which you have: arm the laser, hold the fire button at a low power value and then a high one, and see whether the mark changes. If it does not, it is on/off.


Working with DXF files

Colours and layers both come through. Colour is resolved the way a CAD tool would draw it: true colour (group 420) first, then the ACI index (group 62), then BYLAYER/BYBLOCK resolved against the layer table.

Arcs, circles, ellipses and splines are flattened to within curve_tolerance (0.05 mm by default). Blocks are expanded, hatches contribute their outlines, and text is skipped — there is no stroke font to trace.

Loose segments are welded back together. R12 exporters routinely write a shape as a pile of unconnected LINE entities; cutting those as-is would mean a full stop, an M5, a planner drain and an M3 at every corner. Segments that share an endpoint are rejoined into one continuous path — but only within the same layer and colour, so a red cut line is never merged into the blue score line it happens to touch.

Setting the real size

Most DXFs declare no units at all ($INSUNITS = 0), so "1 unit = 1 mm" is an assumption rather than a fact — and different programs assume differently, which is why the same file can measure 72 mm in one tool and something else in another.

Rather than argue about it, type the size you want into Width or Height in the Job tab. The scale factor is derived from the measured geometry, so whatever the exporter thought its units were stops mattering.

A newly loaded file always starts at scale 1, unrotated and centred on the origin — it never inherits the previous drawing's scale.

If a drawing comes in mirrored

Some exporters write DXF with Y pointing down, the way a screen does, instead of up as the format requires. The drawing then appears mirrored about the X axis, and every DXF-aware program shows it the same way — the file, not the reader, is wrong. Tick Flip vertically in the Job tab.

The included models/tetrahedron.dxf is one of these. Its header also declares $EXTMAX as 8.5, 11 (a Letter page) while its geometry runs to 117 × 175, so the loader flags files whose declared extents do not contain their own geometry: that inconsistency is a reliable sign of an exporter careless enough to have got the Y direction wrong too.

Per-colour operations

Each colour becomes one operation with its own feed, power and pass count.

Operations run fastest feed first. A quicker pass cuts shallower, so scoring and engraving happen while the workpiece is still whole and held together, and the slow through-cut that frees the part — and lets it shift — is left until last. The order is enforced when the program is generated, not just displayed, so it holds however the settings were entered.

Travel is optimised within an operation but never across one, so that order is never quietly undone.

Dry runs

Pressing Start while the laser is disarmed runs the job as a dry run: the same motion at the same feeds, with no spindle command anywhere in the program. That is a stronger guarantee than relying on the arm interlock to reject M3 lines, because rejected lines mid-stream would desynchronise the job. The button says which one you are about to get.


Notes on GRBL 0.8c

0.8c predates nearly everything a modern sender assumes, which shapes the implementation:

  • No $J= jogging. Jogs are ordinary G91 relative moves.
  • No overrides. The 0x9x realtime bytes do not exist.
  • No laser mode ($32). The beam is only off once M5 has been sent.
  • Errors are prose — error: Expected command letter, not error:1.
  • Spindle commands are not synchronised with motion. A G4 P0 dwell is emitted before every M3/M5 to drain the planner first, so the beam cannot switch on during the travel move that precedes a cut, nor switch off before the last segment has finished. It costs a brief stop at each path start.
  • Status reports splice into other lines. serial_write() runs the runtime handler whenever the TX buffer fills, so a report answered mid-write lands inside whatever line was being sent, newline and all. The reader extracts reports from the raw byte stream before splitting lines, discards fragments a reset left unterminated, and holds back a report that has not finished arriving. This matters: a splice inside an ok would otherwise produce o and k, no acknowledgement would ever be counted, and streaming would deadlock partway through a cut. See tests_stream.py.
  • A ? during the banner truncates it. Status polling is suspended while the banner is expected and for the duration of a $$ query, which otherwise loses settings lines.
  • ok means "parsed", not "moved". GRBL acknowledges a line when it lands in the planner, so the final acknowledgement arrives a whole buffer of motion early. A job is only reported complete once every line is acknowledged and the machine has returned to Idle — otherwise the progress bar would hit 100%, and the automatic disarm would fire M5, while the laser was still cutting.

Flow control is the usual character-counting scheme against a 128-byte buffer.


What has been verified on the machine

Confirmed against the hardware, laser disarmed throughout:

  • connection, banner (Grbl 0.8c) and a complete 23-line $$ read
  • jogging both axes, with the readout tracking exactly (0 → 5 → 0)
  • the work-area check refusing a jog that would leave the bed
  • a frame trace of a 27 × 24 mm outline, completing at Idle in 4.4 s
  • jogging to negative coordinates, and a dry run of a job placed around the origin, with needs_laser false and no spindle command sent
  • STOP halting motion mid-job, cancelling it and disarming
  • recovery afterwards via Unlock and re-zeroing
  • every laser-on path refused while disarmed: console M3, job start, burst, and pulse

Not yet verified, because it needs the beam:

  • whether S does anything on this machine (see Laser power)
  • whether the beam is active-high — if it fires on M5 and goes dark on M3, the enable line is inverted and the firmware needs INVERT_SPINDLE_ENABLE_PIN
  • the real cutting feed and pass count for your material

Layout

neje/grbl.py       serial protocol, streaming, safety interlock
neje/dxf.py        DXF -> polylines, with colour resolution
neje/toolpath.py   welding, placement, travel optimisation, G-code
neje/web.py        REST API
neje/__main__.py   entry point
tests_stream.py    regression tests for the 0.8c byte-stream quirks
tests_ui.js        headless checks of the interface's render path

Run the tests with:

uv run python tests_stream.py
node tests_ui.js

config.json is written next to the package and holds the port, baud, bed size, feeds and laser mode.

Options

uv run neje-controller --port 8000 --serial-port /dev/ttyUSB0 --baud 9600 --connect

--host 0.0.0.0 exposes the interface to the network. Anyone who can reach that port can fire the laser, so only do it on a network you trust.

Contributors

Gregwar

Issues