Loading files
/* eslint-disable max-lines-per-function */
/*
* Copyright (C) 2021 Sienci Labs Inc.
*
* This file is part of gSender.
*
* gSender is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, under version 3 of the License.
*
* gSender is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with gSender. If not, see <https://www.gnu.org/licenses/>.
*
* Contact for information regarding this program and its license
* can be sent through gSender@sienci.com or mailed to the main office
* of Sienci Labs Inc. in Waterloo, Ontario, Canada.
*
*/
import ensureArray from "ensure-array";
import * as parser from "gcode-parser";
import _ from "lodash";
import map from "lodash/map";
import { YModem } from "server/lib/YModemUSB";
import {
ALARM,
CONTROLLER_READY,
CYCLE_START,
ERROR,
FEED_HOLD,
FILE_UNLOAD,
HOMING,
MACRO_LOAD,
MACRO_RUN,
METRIC_UNITS,
PROGRAM_END,
PROGRAM_PAUSE,
PROGRAM_RESUME,
PROGRAM_START,
SLEEP,
} from "../../../app/src/constants";
import delay from "../../lib/delay";
import EventTrigger from "../../lib/EventTrigger";
import ensurePositiveNumber from "../../lib/ensure-positive-number";
import evaluateAssignmentExpression from "../../lib/evaluate-assignment-expression";
import { extractRealtimeCommands } from "../../lib/extract-realtime-commands";
import Feeder from "../../lib/Feeder";
import GcodeToolpath from "../../lib/GcodeToolpath";
import { GrblHALFTP } from "../../lib/GrblHALFTP";
import {
GCODE_TRANSLATION_TYPE,
translateGcode,
} from "../../lib/gcode-translation";
import { determineHALMachineZeroFlag } from "../../lib/homing";
import JogStreamer, { describeJogStopReason } from "../../lib/JogStreamer";
import logger from "../../lib/logger";
import { PluginParserChain } from "../../lib/plugin-parsers";
import Sender, { SP_TYPE_CHAR_COUNTING } from "../../lib/Sender";
import ToolChanger from "../../lib/ToolChanger";
import translateExpression from "../../lib/translate-expression";
import Workflow, {
WORKFLOW_STATE_IDLE,
WORKFLOW_STATE_PAUSED,
WORKFLOW_STATE_RUNNING,
} from "../../lib/Workflow";
import config from "../../services/configstore";
import monitor from "../../services/monitor";
import pluginRegistry from "../../services/pluginregistry";
import taskRunner from "../../services/taskrunner";
import store from "../../store";
import {
A_AXIS_COMMANDS,
GLOBAL_OBJECTS as globalObjects,
WRITE_SOURCE_CLIENT,
WRITE_SOURCE_FEEDER,
WRITE_SOURCE_SERVER,
Y_AXIS_COMMANDS,
} from "../constants";
import { calcOverrides } from "../runOverride";
import {
ATCI_SUPPORTED_VERSION,
GRBL_HAL_ACTIVE_STATE_ALARM,
GRBL_HAL_ACTIVE_STATE_CHECK,
GRBL_HAL_ACTIVE_STATE_HOLD,
GRBL_HAL_ACTIVE_STATE_HOME,
GRBL_HAL_ACTIVE_STATE_IDLE,
GRBL_HAL_ACTIVE_STATE_RUN,
GRBL_HAL_ALARMS,
GRBL_HAL_ERRORS,
GRBL_HAL_SETTINGS,
GRBLHAL,
GRBLHAL_REALTIME_COMMANDS,
} from "./constants";
import GrblHalRunner from "./GrblHalRunner";
// % commands
const WAIT = "%wait";
const PREHOOK_COMPLETE = "%pre_complete";
const POSTHOOK_COMPLETE = "%toolchange_complete";
const PAUSE_START = "%pause_start";
const log = logger("controller:grblHAL");
// Commands the jog streamer either owns or can safely coexist with. Everything
// else aborts an active stream before it runs.
const JOG_COMMANDS = [
"jog:start",
"jog:update",
"jog:feed",
"jog:stop",
"jog:cancel",
];
const STREAM_SAFE_COMMANDS = [
"statusreport",
"reset",
"reset:soft",
"reset:quit",
];
const noop = _.noop;
// grblHAL only reports [AXS:] in its $I response on newer builds. When it is
// missing we retry $I a small, bounded number of times before giving up.
const AXS_QUERY_MAX_RETRIES = 2;
const AXS_QUERY_RETRY_INTERVAL = 2000; // ms between retries
const AXS_PROBE_TIMEOUT = 2000; // ms to wait for the $I reply's terminating ok
const AXS_UNKNOWN = "unknown";
const AXS_SUPPORTED = "supported";
const AXS_UNSUPPORTED = "unsupported";
class GrblHalController {
type = GRBLHAL;
// CNCEngine
engine = null;
// Sockets
sockets = {};
// Connection
connection = null;
connectionEventListener = {
data: (data) => {
log.silly(`< ${data}`);
this.runner.parse("" + data);
},
close: (err) => {
this.ready = false;
const currentLineRunning =
this.sender?.state?.totalSentToQueue -
this.sender?.state?.countdownQueue.length;
if (err) {
log.warn(`Disconnected from serial port "${this.options.port}":`, err);
}
this.close((err) => {
// Remove controller from store
const port = this.options.port;
store.unset(`controllers[${JSON.stringify(port)}]`);
// Destroy controller
this.destroy();
}, currentLineRunning);
},
error: (err) => {
this.ready = false;
if (err) {
log.error(
`Unexpected error while reading/writing serial port "${this.options.port}":`,
err,
);
}
},
};
// grblHAL
controller = null;
ready = false;
initialized = false;
state = {};
settings = {};
toolChangeContext = {};
queryTimer = null;
timePaused = null;
actionMask = {
queryParserState: {
state: false, // wait for a message containing the current G-code parser modal state
reply: false, // wait for an `ok` or `error` response
},
queryStatusReport: false,
// Respond to user input
replyParserState: false, // $G
replyStatusReport: false, // ?
alarmCompleteReport: false, //0x87
// Extra function queries
accessoryState: {
SD: false,
ATCI: false,
},
};
alarmActive = false; // state to keep track of whether we have queried for the alarm code
parserStateEnabled = false;
// [AXS:] probe state. Deliberately NOT in actionMask: clearActionValues()
// runs on every startup/[VER:] message - including the ones our own probing
// $I provokes - so a retry budget kept there is reset by the very response it
// is counting, and the probe never stops. See clearActionValues().
axsQueryCount = 0; // $I retries spent looking for [AXS:]
axsQueryLastTime = 0; // timestamp of the last probing $I
axsProbePending = false; // a probing $I is in flight, awaiting its ok
axsProbeTimer = null; // safety net for a probe whose ok never arrives
axsSupport = AXS_UNKNOWN; // unknown | supported | unsupported
actionTime = {
queryParserState: 0,
queryStatusReport: 0,
senderFinishTime: 0,
};
// Event Trigger
event = null;
// Feeder
feeder = null;
feederCB = null;
FOQueue = []; // feedrate override queue
runningFOQ = false;
SOQueue = []; // spindle override queue
runningSOQ = false;
// Sender
sender = null;
timeout = null;
forceOK = false;
// Shared context
sharedContext = {};
// Workflow
workflow = null;
// Homing information
homingStarted = false;
homingFlagSet = false;
hasHomedSet = false;
// Toolchange
toolChanger = null;
// Rotary
isInRotaryMode = false;
// Macro button resume
programResumeTimeout = null;
// SD card communication protocols
ymodem = null;
ymodemTransferInProgress = false;
ftpClient = null;
constructor(engine, connection, options) {
log.debug("constructor");
if (!engine) {
throw new Error("engine must be specified");
}
this.engine = engine;
const { port, baudrate, rtscts, network } = { ...options };
this.options = {
...this.options,
port: port,
baudrate: baudrate,
rtscts: rtscts,
network,
};
// Connection
this.connection = connection;
if (!this.connection) {
throw new Error("connection must be specified");
}
this.connection.setWriteFilter((data) => {
let line;
if (!Buffer.isBuffer(data)) {
line = data.trim();
}
if (!line) {
return data;
}
{
// Grbl settings: $0-$255
const r = line.match(/^(\$\d{1,3})=([\d.]+)$/);
if (r) {
const name = r[1];
const value = Number(r[2]);
if (name === "$13" && value >= 0 && value <= 65535) {
const nextSettings = {
...this.runner.settings,
settings: {
...this.runner.settings.settings,
[name]: value ? "1" : "0",
},
};
this.runner.settings = nextSettings; // enforce change
}
}
}
return data;
});
// Event Trigger
this.event = new EventTrigger((event, trigger, commands) => {
log.debug(
`EventTrigger: event="${event}", trigger="${trigger}", commands="${commands}"`,
);
if (trigger === "system") {
taskRunner.run(commands);
} else {
this.command("gcode", commands);
}
});
// Feeder
this.feeder = new Feeder({
dataFilter: (line, context) => {
const commentMatcher = /\s*;.*/g;
const comment = line.match(commentMatcher);
const commentString =
comment && comment[0].length > 0
? comment[0].trim().replace(";", "")
: "";
line = line.replace(commentMatcher, "").trim();
const ignoreEvent = context ? context.ignoreEvent : true;
context = this.populateContext(context);
// We don't want some of these events firing if updating EEPROM in a macro - super edge case.
const looksLikeEEPROM = line.charAt(0) === "$";
if (line[0] === "%") {
// %wait
if (line === WAIT) {
log.debug("Wait for the planner to empty");
return "G4 P0.5"; // dwell
}
if (line === PREHOOK_COMPLETE) {
log.debug("Finished Pre-hook");
this.feeder.hold({ data: "%toolchange" });
this.emit("toolchange:preHookComplete", commentString);
return "G4 P1";
}
if (line === POSTHOOK_COMPLETE) {
log.debug("Finished Post-hook, resuming program");
setTimeout(() => {
this.workflow.resume();
}, 1000);
return "G4 P1";
}
if (line === PAUSE_START) {
log.debug("Found M0/M1, pausing program");
this.emit("sender:M0M1", {
data: "M0/M1",
comment: commentString,
ignoreEvent: ignoreEvent,
});
return "G4 P0.5";
}
if (line === "%_GCODE_START") {
const { sent } = this.sender.state;
this.workflow.start();
// Feeder
this.feeder.reset();
// Sender (fast forward to sent which was set previous to this to account for rewind)
this.sender.setStartLine(sent);
this.sender.next({ startFromLine: true });
return "";
}
// Expression
// %_x=posx,_y=posy,_z=posz
evaluateAssignmentExpression(line.slice(1), context);
return "";
}
// line="G0 X[posx - 8] Y[ymax]"
// > "G0 X2 Y50"
// [\xNN] realtime-command tokens — write immediately, strip from line
{
const { line: rtLine, realtimeCmds } = extractRealtimeCommands(line);
realtimeCmds.forEach((cmd) => this.connection.writeImmediate(cmd));
line = rtLine;
if (!line) return "";
}
line = translateExpression(line, context);
const data = parser.parseLine(line, { flatten: true });
const words = ensureArray(data.words);
{
// Program Mode: M0, M1
// Look to check if first char is $ so we don't pause when updating an EEPROM macro.
const programMode = _.intersection(words, ["M0", "M1"])[0];
if (programMode === "M0" && !looksLikeEEPROM) {
log.debug("M0 Program Pause");
const payload = { data: "M0", comment: commentString };
this.feeder.hold(payload);
this.emit("feeder:pause", payload);
} else if (programMode === "M1" && !looksLikeEEPROM) {
log.debug("M1 Program Pause");
const payload = { data: "M1", comment: commentString };
this.feeder.hold(payload); // Hold reason
this.emit("feeder:pause", payload);
}
}
// More aggressive updating of spindle modals for safety
const spindleCommand = _.intersection(words, ["M3", "M4"])[0];
if (spindleCommand) {
this.updateSpindleModal(spindleCommand);
}
// // M6 Tool Change
if (_.includes(words, "M6")) {
const passthroughM6 = _.get(
this.toolChangeContext,
"passthrough",
false,
);
if (!passthroughM6) {
log.debug("M6 Tool Change");
this.feeder.hold({
data: "M6",
comment: commentString,
}); // Hold reason
line = line.replace(/M0*6(?!\d)/i, "(M6)");
}
}
if (this.isInRotaryMode) {
const containsACommand = A_AXIS_COMMANDS.test(line);
const containsYCommand = Y_AXIS_COMMANDS.test(line);
if (containsACommand && !containsYCommand) {
const isUsingImperialUnits = context.modal.units === "G20";
line = translateGcode({
gcode: line,
from: "A",
to: "Y",
regex: A_AXIS_COMMANDS,
type: isUsingImperialUnits
? GCODE_TRANSLATION_TYPE.TO_IMPERIAL
: GCODE_TRANSLATION_TYPE.DEFAULT,
});
}
}
return line;
},
});
this.feeder.on("data", (line = "", context = {}) => {
if (this.isClose()) {
log.error(`Serial port "${this.options.port}" is not accessible`);
return;
}
/*if (this.runner.isAlarm()) {
this.feeder.reset();
this.emit('workflow:state', this.workflow.state); // Propogate alarm code to UI
log.warn('Stopped sending G-code commands in Alarm mode');
return;
}*/
line = String(line).trim();
if (line.length === 0) {
return;
}
this.emit("serialport:write", line + "\n", {
...context,
source: WRITE_SOURCE_FEEDER,
});
this.write(line + "\n", {
...context,
source: WRITE_SOURCE_FEEDER,
});
log.silly(`> ${line}`);
});
this.feeder.on("hold", noop);
this.feeder.on("unhold", noop);
this.feeder.on("complete", () => {
this.consumeFeederCB();
});
// Sender
this.sender = new Sender(SP_TYPE_CHAR_COUNTING, {
// Deduct the buffer size to prevent from buffer overrun
bufferSize: 1024 - 300, // TODO: Parse this out from OPT
dataFilter: (line, context) => {
// Remove comments that start with a semicolon `;`
const commentMatcher = /\s*;.*/g;
const bracketCommentLine = /\([^)]*\)/gm;
const toolCommand = /(T)(-?\d*\.?\d+\.?)/;
const commentRegex = /\(([^)]*)\)|;(.*)/g;
const commentParts = [];
let m;
while ((m = commentRegex.exec(line)) !== null) {
const text = (m[1] !== undefined ? m[1] : m[2]).trim();
if (text) commentParts.push(text);
}
const commentString = commentParts.join(" ");
if (line[0] !== "%") {
line = line.replace(bracketCommentLine, "").trim();
line = line.replace(commentMatcher, "").replace("/uFEFF", "").trim();
}
context = this.populateContext(context);
const { sent, received } = this.sender.state;
if (line[0] === "%") {
// %wait
if (line === WAIT) {
log.debug(
`Wait for the planner to empty: line=${sent + 1}, sent=${sent}, received=${received}`,
);
this.sender.hold({ data: WAIT }); // Hold reason
return "G4 P0.5"; // dwell
}
// Expression
// %_x=posx,_y=posy,_z=posz
evaluateAssignmentExpression(line.slice(1), context);
return "";
}
// line="G0 X[posx - 8] Y[ymax]"
// > "G0 X2 Y50"
line = translateExpression(line, context);
const data = parser.parseLine(line, { flatten: true });
const words = ensureArray(data.words);
{
// Program Mode: M0, M1
const programMode = _.intersection(words, ["M0", "M1"])[0];
if (programMode === "M0") {
log.debug(
`M0 Program Pause: line=${sent + 1}, sent=${sent}, received=${received}`,
);
// Workaround for Carbide files - prevent M0 early from pausing program
if (sent > 10) {
this.workflow.pause({ data: "M0", comment: commentString });
this.command(
"gcode",
`${WAIT}\n${PAUSE_START} ;${commentString}`,
{ ignoreEvent: false },
);
}
// This regex is required since M00 (or M01, M06, etc) are valid gcode commands that
// some CAM programs issue. We normalize the line to M0 using `parseLine` for
// comparison's sake but the line sent to the controller hasn't been normalized
// and could still contain leading 0's
line = line.replace(/M0+(?!\d)/i, "(M0)");
} else if (programMode === "M1") {
log.debug(
`M1 Program Pause: line=${sent + 1}, sent=${sent}, received=${received}`,
);
this.workflow.pause({ data: "M1", comment: commentString });
this.command("gcode", `${WAIT}\n${PAUSE_START} ;${commentString}`, {
ignoreEvent: false,
});
line = line.replace(/M0*1(?!\d)/i, "(M1)");
}
}
// More aggressive updating of spindle modals for safety
const spindleCommand = _.intersection(words, ["M3", "M4"])[0];
if (spindleCommand) {
this.updateSpindleModal(spindleCommand);
}
if (_.includes(words, "M6")) {
log.debug(
`M6 Tool Change: line=${sent + 1}, sent=${sent}, received=${received}`,
);
const { toolChangeOption } = this.toolChangeContext;
const currentState = _.get(this.state, "status.activeState", "");
if (currentState === "Check") {
return line.replace(/M0*6(?!\d)/i, "(M6)");
}
const tool = line.match(toolCommand);
log.debug("Found tool");
let toolLabel = tool?.[0] || null;
let toolNumber = tool?.[2] || null;
if (tool && this.toolChangeContext.mappings) {
const remap = _.get(this.toolChangeContext.mappings, tool[2], null);
if (remap) {
log.debug(`Mapping ${tool} to T${remap}`);
line = line.replace(tool[0], `T${remap}`);
toolLabel = `T${remap}`;
toolNumber = String(remap);
} else {
log.debug(`no remap found for ${tool}`);
}
}
// Handle specific cases for macro and pause, ignore is default and comments line out with no other action
// If toolchange is at very beginning of file, ignore it
if (toolChangeOption !== "Ignore") {
this.workflow.pause({ data: "M6", comment: commentString });
if (toolChangeOption === "Code") {
setTimeout(() => {
this.emit("toolchange:start");
this.runPreChangeHook(commentString);
}, 500);
} else {
const count = this.sender.incrementToolChanges();
this.toolChanger.addInterval(() => {
// Emit the current state so latest tool info is available
this.runner.setTool(toolNumber); // set tool in runner state
this.emit("controller:state", GRBLHAL, this.state, toolNumber); // set tool in redux
this.emit(
"gcode:toolChange",
{
line: sent + 1,
count,
block: line,
tool: toolLabel,
option: toolChangeOption,
},
commentString,
);
});
}
}
const passthroughM6 = _.get(
this.toolChangeContext,
"passthrough",
false,
);
if (!passthroughM6 || toolChangeOption === "Code") {
line = line.replace(/M0*6(?!\d)/i, "(M6)");
}
//line = line.replace(`${tool?.[0]}`, `(${tool?.[0]})`);
}
/**
* Rotary Logic
* Need to change the A-axis movements to Y-movements to emulate the rotary axis on grbl
*/
if (this.isInRotaryMode) {
const containsACommand = A_AXIS_COMMANDS.test(line);
const containsYCommand = Y_AXIS_COMMANDS.test(line);
if (containsACommand && !containsYCommand) {
const isUsingImperialUnits = context.modal.units === "G20";
line = translateGcode({
gcode: line,
from: "A",
to: "Y",
regex: A_AXIS_COMMANDS,
type: isUsingImperialUnits
? GCODE_TRANSLATION_TYPE.TO_IMPERIAL
: GCODE_TRANSLATION_TYPE.DEFAULT,
});
}
}
/**
* End of Rotary Logic
*/
return line;
},
});
this.sender.on("data", (line = "", context = {}) => {
if (this.isClose()) {
log.error(`Serial port "${this.options.port}" is not accessible`);
return;
}
line = String(line).trim();
if (line.length === 0) {
log.warn(`Expected non-empty line: N=${this.sender.state.sent}`);
return;
}
this.emit("serialport:write", line + "\n", {
source: WRITE_SOURCE_FEEDER,
});
this.write(line + "\n");
});
this.sender.on("hold", noop);
this.sender.on("unhold", noop);
this.sender.on("start", (startTime) => {
this.actionTime.senderFinishTime = 0;
});
this.sender.on("end", (finishTime) => {
this.actionTime.senderFinishTime = finishTime;
if (
this.runner.state.status.activeState === GRBL_HAL_ACTIVE_STATE_CHECK
) {
log.info("Exiting check mode");
this.workflow.stopTesting();
this.command("gcode", "$C");
setTimeout(() => {
this.command("gcode", "[global.state.testWCS]");
}, 200);
this.emit("gcode_error_checking_file", this.sender.state, "finished");
}
});
this.sender.on("requestData", () => {
this.emit("requestEstimateData");
});
// Workflow
this.workflow = new Workflow();
this.workflow.on("start", (...args) => {
this.emit("workflow:state", this.workflow.state);
this.jogStreamer?.abort("workflow");
this.sender.rewind();
this.sender.resumeCountdown();
});
this.workflow.on("stop", (...args) => {
this.emit("workflow:state", this.workflow.state);
this.feeder.reset();
this.sender.rewind();
this.sender.stopCountdown();
});
this.workflow.on("pause", (...args) => {
this.emit("workflow:state", this.workflow.state);
this.jogStreamer?.abort("workflow");
if (args.length > 0) {
const reason = { ...args[0] };
this.sender.hold(reason); // Hold reason
} else {
this.sender.hold();
}
this.timePaused = new Date().getTime();
});
this.workflow.on("resume", (...args) => {
this.emit("workflow:state", this.workflow.state);
const pauseTime = new Date().getTime() - this.timePaused;
// if there was error and feeder was holding, don't reset
if (this.feeder.state.hold) {
this.feeder.unhold();
}
// Reset feeder prior to resume program execution
this.feeder.reset();
// Resume program execution
this.sender.unhold();
this.sender.resumeCountdown();
// subtract time paused
this.sender.next({ timePaused: pauseTime });
});
// Grbl
// Streams short incremental jog moves so the planner always has enough
// queued motion to hold a constant velocity. Owns the serial link for
// the duration of a jog - see the guards in command() below.
this.jogStreamer = new JogStreamer({
write: (line) =>
this.connection.write(line, { source: WRITE_SOURCE_CLIENT }),
getSettings: () => this.settings.settings || {},
getStatus: () => this.state.status || {},
getHomingFlag: () => this.homingFlagSet,
// The streamer consumes its own acks, so all it truly needs is that
// nobody else is waiting on one. Deliberately not gated on an idle
// workflow: jogging while a job is paused for a tool change is
// exactly when an operator reaches for the jog controls.
canStream: () =>
this.isOpen() &&
this.workflow.state !== WORKFLOW_STATE_RUNNING &&
!this.feeder.hasOutstanding() &&
this.sender.state.received >= this.sender.state.sent,
// $40 lets the firmware clamp jog targets itself, so we only work out
// a travel budget when it is off.
softLimitsEnabled: (settings) =>
settings.$20 === "1" && settings.$40 === "0",
// grblHAL handles A natively, so no rotary translation is needed.
lineFilter: (line) => line,
// A conservative floor; grblHAL reports its real buffer size in Bf:
// and the streamer raises this once it sees one.
rxBufferSize: 256,
log,
});
// The jog streamer talks to the operator through the console as one
// message family, written as the server rather than as machine traffic.
const announceJog = (message) => {
this.emit("serialport:write", `${message}\n`, {
source: WRITE_SOURCE_SERVER,
});
};
// stop() drains and abort() can still fire mid-drain, so one jog may
// raise both events - only announce the end of the jog once.
this.jogAnnounced = false;
const announceJogStopped = (reason) => {
if (!this.jogAnnounced) {
return;
}
this.jogAnnounced = false;
const why = describeJogStopReason(reason);
announceJog(`Stopped jogging${why ? ` - ${why}` : ""}`);
};
// One console line for the whole jog, not one per segment.
this.jogStreamer.on("start", ({ summary }) => {
this.jogAnnounced = true;
announceJog(summary);
});
this.jogStreamer.on("feedrate", ({ summary }) => {
announceJog(summary);
});
this.jogStreamer.on("stop", () => {
announceJogStopped();
});
this.jogStreamer.on("abort", (reason) => {
announceJogStopped(reason);
const handledElsewhere = ["cancel", "close", "destroy", "reset"];
if (!handledElsewhere.includes(reason) && this.isOpen()) {
this.write("\x85");
}
});
this.runner = new GrblHalRunner();
// Plugin-defined parsers. Fed from "raw", which fires for every line
// before any built-in parsing, so a plugin can see lines that never make
// it to serialport:read (status, json, descriptions). Observe-only:
// nothing here can consume a line or affect built-in parsing.
this.pluginParsers = new PluginParserChain({
emit: (eventName, payload) => this.emit(eventName, payload),
getWorkflowState: () => this.workflow?.state,
log,
});
this.runner.on("raw", ({ raw }) => {
// feed() already swallows everything; this is the second belt so a
// plugin can never break the serial read path.
try {
this.pluginParsers.feed(raw);
} catch (err) {
log.error(`plugin parser chain failed: ${err.message}`);
}
});
this.runner.on("spindle", (spindle) => {
this.emit("spindle:add", spindle);
this.emit("serialport:read", spindle.raw);
});
this.runner.on("json", (json) => {
this.emit("sdcard:json", json);
});
this.runner.on("status", (res) => {
// Allow status polling to start as soon as any status arrives (e.g. from 0x87 in Hold state)
if (!this.ready) {
this.ready = true;
}
// Make sure we also have axs parsed. This only ever *starts* a probe -
// resolveAxsProbe() decides what to do once the $I reply has landed.
if (
!this.runner.hasAXS() &&
!this.axsProbePending &&
this.axsSupport === AXS_UNKNOWN &&
this.axsQueryCount < AXS_QUERY_MAX_RETRIES &&
res.activeState === GRBL_HAL_ACTIVE_STATE_IDLE &&
Date.now() - this.axsQueryLastTime >= AXS_QUERY_RETRY_INTERVAL
) {
this.startAxsProbe();
}
//
// Path A: H flag present in status report (newer grblHAL firmware)
// H: field drives hasHomed (runtime homing state)
if (res.hasHomed !== undefined) {
//console.log(res);
const newHasHomed = res.hasHomed;
if (newHasHomed !== this.hasHomedSet) {
this.hasHomedSet = newHasHomed;
this.emit("homing:has-homed", this.hasHomedSet);
}
if (this.homingStarted) {
this.homingStarted = false;
}
}
this.actionMask.queryStatusReport = false;
// The reported position is the truth the streamer's locally
// decremented travel budget is only estimating.
this.jogStreamer.onStatus(res);
if (this.actionMask.replyStatusReport) {
this.actionMask.replyStatusReport = false;
this.emit("serialport:read", res.raw);
}
// Check if the recieve buffer is available in the status report
const rx = Number(_.get(res, "buf.rx", 0)) || 0;
if (rx > 0) {
// Do not modify the buffer size when running a G-code program
if (this.workflow.state !== WORKFLOW_STATE_IDLE) {
return;
}
// Check if the streaming protocol is character-counting streaming protocol
if (this.sender.sp.type !== SP_TYPE_CHAR_COUNTING) {
return;
}
// Check if the queue is empty
if (this.sender.sp.dataLength !== 0) {
return;
}
// Deduct the receive buffer length to prevent from buffer overrun
const bufferSize = rx - 8; // TODO
if (bufferSize > this.sender.sp.bufferSize) {
this.sender.sp.bufferSize = bufferSize;
}
}
});
this.runner.on("ok", (res) => {
// The $I reply is a block ([VER:], [OPT:], [AXS:], [PLUGIN:] ...)
// terminated by a single ok, so by the time we get here the whole
// reply has been seen. Resolve before the branches below, since each
// of them can consume the ok and return.
if (this.axsProbePending) {
this.resolveAxsProbe();
}
// we only query when parser state option in $10 is disabled
if (this.actionMask.queryParserState.reply && !this.parserStateEnabled) {
if (this.actionMask.replyParserState) {
this.actionMask.replyParserState = false;
this.emit("serialport:read", res.raw);
}
this.actionMask.queryParserState.reply = false;
return;
// if parser state is enabled, it does not send an 'ok' when the state auto emits
// so only consume the ok if the user entered $G
} else if (
this.actionMask.queryParserState.reply &&
this.parserStateEnabled
) {
if (this.actionMask.replyParserState) {
this.actionMask.replyParserState = false;
this.actionMask.queryParserState.reply = false;
this.emit("serialport:read", res.raw);
return;
}
}
// A streamed jog owns the serial link while it runs, so any ok it is
// still waiting on is its own. Consume it silently - letting it reach
// the feeder would desync the feeder's one-outstanding accounting.
if (this.jogStreamer.isActive() && this.jogStreamer.ack()) {
return;
}
const { hold, sent, received } = this.sender.state;
if (this.workflow.state === WORKFLOW_STATE_RUNNING) {
this.emit("serialport:read", res.raw);
if (hold && received + 1 >= sent) {
log.debug(
`Continue sending G-code: hold=${hold}, sent=${sent}, received=${received + 1}`,
);
this.sender.unhold();
}
this.sender.ack();
this.sender.next({ isOk: true });
return;
}
if (this.workflow.state === WORKFLOW_STATE_PAUSED && received < sent) {
this.emit("serialport:read", res.raw);
if (!hold) {
log.error("The sender does not hold off during the paused state");
}
if (received + 1 >= sent) {
log.debug(
`Stop sending G-code: hold=${hold}, sent=${sent}, received=${received + 1}`,
);
}
this.sender.ack();
this.sender.next({ isOk: true });
return;
}
this.emit("serialport:read", res.raw);
// Feeder
this.feeder.ack();
this.feeder.next();
});
this.runner.on("error", (res) => {
const errCode = Number(res.message) || undefined;
// An error on a streamed jog line belongs to the jog, not to the
// feeder or to whatever file happens to be loaded - and it must not
// pause a workflow that a jog cannot have been part of.
if (this.jogStreamer.isActive()) {
const wasJogError = this.jogStreamer.onError(res);
this.jogStreamer.abort("error");
if (wasJogError) {
const jogError =
_.find(this.settings.errors, { code: errCode }) || {};
this.emit("serialport:read", res.raw);
this.emit("error", {
type: ERROR,
code: `${errCode}`,
description: jogError.description ?? "",
line: "jog",
lineNumber: "",
origin: "Jog",
controller: GRBLHAL,
});
return;
}
}
// Only pause on workflow error with hold + sender halt
const isRunning = this.workflow.isRunning();
const firmwareIsAlarmed = this.runner.isAlarm();
if (isRunning) {
this.workflow.pause();
this.sender.hold();
this.connection.writeImmediate("\n");
this.write("!");
}
const code = Number(res.message) || undefined;
const error = _.find(this.settings.errors, { code: code }) || {};
// Don't emit errors to UI in situations where firmware is currently alarmed and always hide error 79
if (firmwareIsAlarmed || code === 79) {
return;
}
const { lines, received, name } = this.sender.state;
const { outstanding } = this.feeder.state;
const isFileError = lines.length !== 0;
//Check error origin
let errorOrigin = "";
let line = "";
if (store.get("inAppConsoleInput")) {
line = store.get("inAppConsoleInput") || "";
store.set("inAppConsoleInput", null);
errorOrigin = "Console";
} else if (outstanding > 0) {
errorOrigin = "Feeder";
line = "N/A";
} else if (isFileError) {
errorOrigin = name;
line = lines[received] || "";
} else {
errorOrigin = "Feeder";
line = "N/A";
}
this.emit("error", {
type: ERROR,
code: `${code}`,
description: _.get(error, "description", ""),
line: line,
lineNumber: isFileError ? received : "",
origin: errorOrigin,
controller: GRBLHAL,
fileRunning: isRunning,
});
if (
this.workflow.state === WORKFLOW_STATE_RUNNING ||
this.workflow.state === WORKFLOW_STATE_PAUSED
) {
const { lines, received } = this.sender.state;
const line = lines[received] || "";
const preferences = store.get("preferences") || {
showLineWarnings: false,
};
this.emit("serialport:read", `error:${code} (${error?.description})`);
if (error) {
if (preferences.showLineWarnings === false) {
const msg = `Error ${code} on line ${received} - ${error?.description}`;
this.emit("gcode_error", msg);
}
if (preferences.showLineWarnings) {
this.emit("workflow:state", this.workflow.state, {
validLine: false,
line: `${lines.length} ${line}`,
});
}
} else {
this.emit("serialport:read", res.raw);
}
this.sender.ack();
this.sender.next({ isOk: true });
return;
}
if (error) {
this.emit("serialport:read", `error:${code} (${error.description})`);
}
if (error.code === 60 || error.code === 64 || error.code === 62) {
this.runner.clearSDStatus();
this.emit("controller:state", GRBLHAL, this.runner.state);
}
const msg = `Error ${code} - ${error?.description}`;
this.emit("gcode_error", msg);
this.feeder.ack();
this.feeder.next();
});
this.runner.on("alarm", (res) => {
const code = Number(res.message) || this.state.status.subState;
//const alarm = _.find(this.settings.alarms, { id: code });
// default to grbl hal alarm constants we have saved if the alarms arent populated yet (ex. when there's an error on startup)
const alarm = this.settings?.alarms
? this.settings.alarms[code.toString()]
: _.find(GRBL_HAL_ALARMS, { code: code });
const { lines, received, name } = this.sender.state;
const { outstanding } = this.feeder.state;
const isFileError = lines.length !== 0;
//Check error origin
let errorOrigin = "";
let line = "";
if (store.get("inAppConsoleInput")) {
line = store.get("inAppConsoleInput") || "";
store.set("inAppConsoleInput", null);
errorOrigin = "Console";
} else if (
this.state?.status?.activeState === GRBL_HAL_ACTIVE_STATE_HOME
) {
errorOrigin = "Console";
line = "$H";
} else if (outstanding > 0) {
errorOrigin = "Feeder";
line = "N/A";
} else if (isFileError) {
errorOrigin = name;
line = lines[received] || "";
} else {
errorOrigin = "Feeder";
line = "N/A";
}
if (alarm) {
// Grbl v1.1
const isRunning = this.workflow.isRunning();
if (isRunning) {
this.workflow.stop();
}
this.emit("serialport:read", `ALARM:${code} (${alarm.description})`);
this.emit(
"error",
{
type: ALARM,
code: code,
description: alarm.description || "",
line: line,
lineNumber: isFileError ? received : "",
origin: errorOrigin,
controller: GRBLHAL,
},
isRunning,
);
// Force propogation of current state on alarm
this.state = this.runner.state;
this.emit("controller:state", GRBLHAL, this.state);
} else {
// Grbl v0.9
this.emit("serialport:read", res.raw);
}
});
this.runner.on("parserstate", (res) => {
//finished searching gCode file for errors
if (
this.sender.state.finishTime > 0 &&
this.sender.state.sent > 0 &&
this.runner.state.status.activeState === GRBL_HAL_ACTIVE_STATE_CHECK
) {
this.workflow.stopTesting();
this.command("gcode", "$C");
setTimeout(() => {
this.command("gcode", "[global.state.testWCS]");
}, 200);
this.emit("gcode_error_checking_file", this.sender.state, "finished");
return;
}
this.actionMask.queryParserState.state = false;
this.actionMask.queryParserState.reply = true;
if (this.actionMask.replyParserState) {
this.emit("serialport:read", res.raw);
}
});
this.runner.on("parameters", (res) => {
this.emit("serialport:read", res.raw);
});
this.runner.on("feedback", (res) => {
this.emit("serialport:read", res.raw);
});
this.runner.on("settings", (res) => {
const setting = _.find(GRBL_HAL_SETTINGS, { setting: res.name });
if (!res.message) {
// Grbl v1.1
if (setting?.message) {
this.emit(
"serialport:read",
`${res.name}=${res.value} (${setting.message}, ${setting.units})`,
);
} else {
this.emit("serialport:read", `${res.name}=${res.value}`);
}
}
// check if parser state option is enabled
if (res.name === "$10") {
const value = res.value;
// eslint-disable-next-line no-bitwise
if (value & 512) {
this.parserStateEnabled = true;
} else {
this.parserStateEnabled = false;
}
}
});
this.runner.on("info", (res) => {
this.emit("serialport:read", res.raw);
this.emit("grblHal:info", res);
});
this.runner.on("startup", (res, semver) => {
this.emit("serialport:read", res.raw);
// The startup message always prints upon startup, after a reset, or at program end.
// Setting the initial state when Grbl has completed re-initializing all systems.
this.clearActionValues();
// Set ready flag to true when a startup message has arrived
this.ready = true;
// Rewind any files in the sender
this.workflow.stop();
// Only initialize when we have a firmware version (triggered by $I / [VER:] response).
// The bare startup greeting fires without semver and should not trigger startup commands.
if (!this.initialized && semver !== undefined) {
this.initialized = true;
this.initController(semver);
}
});
this.toolChanger = new ToolChanger({
isIdle: () => {
if (!this.runner) {
return false;
}
return this.runner.isIdle();
},
intervalTimer: 200,
});
this.runner.on("others", (res) => {
this.emit("serialport:read", res.raw);
});
const emitDescriptions = _.debounce(() => {
this.emit("settings:description", this.runner.settings.descriptions);
}, 150);
this.runner.on("description", emitDescriptions);
this.runner.on("alarmDetail", (payload) => {
this.emit("settings:alarms", this.runner.settings.alarms);
});
const emitGroups = _.debounce(() => {
this.emit("settings:group", this.runner.settings.groups);
}, 150);
this.runner.on("groupDetail", emitGroups);
this.runner.on("sdcard", (payload) => {
this.emit("serialport:read", payload.raw);
this.emit("sdcard:files", payload);
});
this.runner.on("atci", (payload) => {
this.emit("serialport:read", payload.raw);
delete payload.raw;
if (payload.subtype) {
this.emit("atci", payload);
}
});
this.runner.on("autoconfig", (payload) => {
this.emit("serialport:read", payload.raw);
this.emit("grblHal:autoconfig", payload);
});
const queryStatusReport = () => {
// Check the ready flag
if (!this.ready) {
return;
}
// If transferring over ymodem, we cannot send any other commands or bad things happen.
if (this.ymodemTransferInProgress) {
return;
}
const now = new Date().getTime();
// The status report query (?) is a realtime command, it does not consume the receive buffer.
const lastQueryTime = this.actionTime.queryStatusReport;
if (lastQueryTime > 0) {
const timespan = Math.abs(now - lastQueryTime);
const toleranceTime = 5000; // 5 seconds
// Check if it has not been updated for a long time
if (timespan >= toleranceTime) {
log.debug(`Continue status report query: timespan=${timespan}ms`);
this.actionMask.queryStatusReport = false;
}
}
if (this.actionMask.queryStatusReport) {
return;
}
if (this.isOpen()) {
this.actionMask.queryStatusReport = true;
this.actionTime.queryStatusReport = now;
if (this.runner.isAlarm() && this.actionMask.alarmCompleteReport) {
this.connection.writeImmediate(Buffer.from([0x87]));
} else {
this.connection.writeImmediate(
GRBLHAL_REALTIME_COMMANDS.STATUS_REPORT,
); //? or \x80
}
}
};
const queryParserState = _.throttle(() => {
// Check the ready flag
// if parser state enabled, we dont need to query the parser state
if (!this.ready || this.parserStateEnabled) {
return;
}
// Do not send any commands while we are transferring a file or else bad things happen
if (this.ymodemTransferInProgress) {
return;
}
const now = new Date().getTime();
// Do not force query parser state ($G) when running a G-code program,
// it will consume 3 bytes from the receive buffer in each time period.
if (this.workflow.state === WORKFLOW_STATE_IDLE && this.runner.isIdle()) {
const lastQueryTime = this.actionTime.queryParserState;
if (lastQueryTime > 0) {
const timespan = Math.abs(now - lastQueryTime);
const toleranceTime = 10000; // 10 seconds
// Check if it has not been updated for a long time
if (timespan >= toleranceTime) {
log.debug(`Continue parser state query: timespan=${timespan}ms`);
this.actionMask.queryParserState.state = false;
this.actionMask.queryParserState.reply = false;
}
}
}
if (
this.actionMask.queryParserState.state ||
this.actionMask.queryParserState.reply
) {
return;
}
if (this.isOpen()) {
this.actionMask.queryParserState.state = true;
this.actionMask.queryParserState.reply = false;
this.actionTime.queryParserState = now;
this.connection.writeImmediate(
`${GRBLHAL_REALTIME_COMMANDS.GCODE_REPORT}`,
); // $G equivalent
}
}, 500);
this.queryTimer = setInterval(() => {
if (this.isClose()) {
// Serial port is closed
return;
}
// Feeder
if (this.feeder.peek()) {
this.emit("feeder:status", this.feeder.toJSON());
}
// Sender
if (this.sender.peek()) {
this.emit("sender:status", this.sender.toJSON());
}
const zeroOffset = _.isEqual(
this.runner.getWorkPosition(this.state),
this.runner.getWorkPosition(this.runner.state),
);
// Grbl settings
if (this.settings !== this.runner.settings) {
this.settings = this.runner.settings;
this.emit("controller:settings", GRBLHAL, this.settings);
// Update and emit homingFlag from $22 bit 3
const newHomingFlag = determineHALMachineZeroFlag({}, this.settings);
if (newHomingFlag !== this.homingFlagSet) {
this.homingFlagSet = newHomingFlag;
this.emit("homing:flag", this.homingFlagSet);
}
}
// Grbl state
if (this.state !== this.runner.state) {
// Unpause sending when hold state exited using macro buttons - We check if software sender paused + state changed from hold to idle/run
const currentActiveState = _.get(this.state, "status.activeState", "");
const runnerActiveState = _.get(
this.runner.state,
"status.activeState",
"",
);
// only pause countdown once machine is idle
if (
this.workflow.isPaused() &&
(currentActiveState === GRBL_HAL_ACTIVE_STATE_IDLE ||
currentActiveState === GRBL_HAL_ACTIVE_STATE_HOLD) &&
this.sender.isCountdownRunning()
) {
this.sender.pauseCountdown();
} else if (
// restart countdown if machine is still moving
currentActiveState === GRBL_HAL_ACTIVE_STATE_RUN &&
!this.sender.isCountdownRunning()
) {
this.sender.resumeCountdown();
}
if (
this.workflow.isPaused() &&
currentActiveState === GRBL_HAL_ACTIVE_STATE_HOLD &&
(runnerActiveState === GRBL_HAL_ACTIVE_STATE_IDLE ||
runnerActiveState === GRBL_HAL_ACTIVE_STATE_RUN)
) {
if (this.programResumeTimeout) {
clearTimeout(this.programResumeTimeout);
this.programResumeTimeout = null;
}
this.programResumeTimeout = setTimeout(() => {
if (this.workflow.isIdle()) {
return;
}
const as = _.get(this.state, "status.activeState");
if (
(as === GRBL_HAL_ACTIVE_STATE_IDLE ||
as === GRBL_HAL_ACTIVE_STATE_RUN) &&
this.sender.state.hold
) {
this.command("gcode:resume");
}
}, 1000);
}
this.state = this.runner.state;
this.emit("controller:state", GRBLHAL, this.state);
}
// check whether we need to get alarm code
const runnerActiveState = _.get(
this.runner.state,
"status.activeState",
"",
);
const runnerAlarmCode = _.get(this.runner.state, "status.alarmCode", "");
if (runnerActiveState === GRBL_HAL_ACTIVE_STATE_ALARM) {
if (runnerAlarmCode === "") {
// no code
// if this is the first alarm detection, we need a code
if (!this.alarmActive) {
this.alarmActive = true;
// enable full report for code get
this.actionMask.alarmCompleteReport = true;
}
} else {
this.alarmActive = true;
this.actionMask.alarmCompleteReport = false; // we dont need full report to get code anymore
}
} else {
// if no alarm, clear the alarm state var
if (this.alarmActive) {
this.alarmActive = false;
}
}
// Check the ready flag
if (!this.ready) {
return;
}
// ? - Status Report
queryStatusReport();
// $G - Parser State
queryParserState();
// Check if the machine has stopped movement after completion
if (this.actionTime.senderFinishTime > 0) {
const machineIdle = zeroOffset && this.runner.isIdle();
const now = new Date().getTime();
const timespan = Math.abs(now - this.actionTime.senderFinishTime);
const toleranceTime = 500; // in milliseconds
if (!machineIdle) {
// Extend the sender finish time
this.actionTime.senderFinishTime = now;
} else if (timespan > toleranceTime) {
log.silly(`Finished sending G-code: timespan=${timespan}`);
this.actionTime.senderFinishTime = 0;
// Stop workflow
this.command("gcode:stop");
}
}
}, 250);
// YModem instance
this.ymodem = new YModem();
this.ymodem.on("start", () => {
this.emit("ymodem:start");
this.ymodemTransferInProgress = true;
});
this.ymodem.on("progress", (progress) => {
this.emit("ymodem:progress", progress);
});
this.ymodem.on("abort", () => {
this.ymodemTransferInProgress = false;
this.emit("ymodem:error", "Transfer aborted by device.");
this.restoreListeners();
});
this.ymodem.on("error", (err) => {
this.ymodemTransferInProgress = false;
this.emit("ymodem:error", err);
this.restoreListeners();
});
this.ymodem.on("complete", () => {
//this.clearActionValues(); // Surely this fixes the extra OKs
this.emit("ymodem:complete");
this.ymodemTransferInProgress = false;
this.restoreListeners();
setTimeout(() => {
this.command("sdcard:list");
}, 150);
});
// FTP client
this.ftpClient = new GrblHALFTP();
this.ftpClient.on("start", (err) => {
this.emit("ymodem:start");
});
this.ftpClient.on("error", (err) => {
this.emit("ymodem:error", err);
});
this.ftpClient.on("complete", () => {
this.emit("ymodem:complete");
setTimeout(() => {
this.command("sdcard:list");
}, 150);
});
this.ftpClient.on("progress", (progress) => {
this.emit("ymodem:progress", progress);
});
}
async initController(semver) {
// Send \x87 as a raw realtime byte
// Moved this into connect again - don't need to send twice
/*if (this.connection) {
this.connection.write(Buffer.from([0x87]));
}*/
await delay(500);
const hasSD = _.get(this.state, "status.sdCard", null);
// Startup command sequence. Each step runs in order if enabled.
// To add future startup behaviour, append a new step object.
// Shape: { name: string, enabled: boolean, commands: string[], onRun?: () => void }
const startupSequence = [
{
name: "settings",
enabled: true,
commands: ["$$"],
},
{
name: "extendedInfo",
enabled: true,
commands: ["$ES", "$ESH", "$EG", "$EA", "$EE", "$#"],
},
{
name: "sdCard",
enabled: !!(hasSD && !this.actionMask.accessoryState.SD),
commands: ["$FM", "$F"],
onRun: () => {
this.actionMask.accessoryState.SD = true;
},
},
{
// TODO: Verify that semver 20231210 is valid for SLB as well
name: "spindles",
enabled: true,
commands: [semver >= 20231210 ? "$spindlesh" : "$spindles"],
},
];
for (const step of startupSequence) {
if (!step.enabled) {
continue;
}
log.info(`Running startup sequence step: ${step.name}`);
this.connection.write(step.commands.join("\n") + "\n");
if (step.onRun) {
step.onRun();
}
// eslint-disable-next-line no-await-in-loop
await delay(25);
}
this.event.trigger(CONTROLLER_READY);
}
populateContext(context = {}) {
// Machine position
const {
x: mposx,
y: mposy,
z: mposz,
a: mposa,
b: mposb,
c: mposc,
} = this.runner.getMachinePosition();
// Work position
const {
x: posx,
y: posy,
z: posz,
a: posa,
b: posb,
c: posc,
} = this.runner.getWorkPosition();
// Positions are raw firmware values - convert to mm if firmware is
// configured to report in inches ($13=1), to keep macro variables
// consistent with mm regardless of firmware reporting units.
const { $13 } = this.settings.settings;
const isReportingInches = $13 === "1";
const toMachineUnits = (val) => {
const num = Number(val) || 0;
return (isReportingInches ? num * 25.4 : num).toFixed(3);
};
// Modal group
const modal = this.runner.getModalGroup();
// Tool
const tool = this.runner.getTool();
const parameters = this.runner.getParameters();
// Program feedrate
const programFeedrate = this.runner.getCurrentFeedrate();
const spindleRate = this.runner.getCurrentSpindleRate();
return Object.assign(context || {}, {
// User-defined global variables
global: this.sharedContext,
// Bounding box
xmin: Number(context.xmin) || 0,
xmax: Number(context.xmax) || 0,
ymin: Number(context.ymin) || 0,
ymax: Number(context.ymax) || 0,
zmin: Number(context.zmin) || 0,
zmax: Number(context.zmax) || 0,
// Machine position
mposx: toMachineUnits(mposx),
mposy: toMachineUnits(mposy),
mposz: toMachineUnits(mposz),
mposa: toMachineUnits(mposa),
mposb: toMachineUnits(mposb),
mposc: toMachineUnits(mposc),
// Work position
posx: toMachineUnits(posx),
posy: toMachineUnits(posy),
posz: toMachineUnits(posz),
posa: toMachineUnits(posa),
posb: toMachineUnits(posb),
posc: toMachineUnits(posc),
// Modal group
modal: {
motion: modal.motion,
wcs: modal.wcs,
plane: modal.plane,
units: modal.units,
distance: modal.distance,
feedrate: modal.feedrate,
program: modal.program,
spindle: modal.spindle,
// M7 and M8 may be active at the same time, but a modal group violation might occur when issuing M7 and M8 together on the same line. Using the new line character (\n) to separate lines can avoid this issue.
coolant: ensureArray(modal.coolant).join("\n"),
},
// Tool
tool: Number(tool) || 0,
// G-code parameters
params: parameters,
// Program Feedrate
programFeedrate: programFeedrate,
// Current Spindle RPM
spindleRate: spindleRate,
// Global objects
...globalObjects,
});
}
// Reset [AXS:] probe state. Called once per connection, from open()/close().
resetAxsProbe() {
clearTimeout(this.axsProbeTimer);
this.axsProbeTimer = null;
this.axsProbePending = false;
this.axsQueryCount = 0;
this.axsQueryLastTime = 0;
this.axsSupport = AXS_UNKNOWN;
}
// Send a $I purely to look for an [AXS:] line, and wait for its reply.
startAxsProbe() {
this.axsQueryCount++;
this.axsQueryLastTime = Date.now();
this.axsProbePending = true;
// Safety net: the terminating ok can be swallowed (an error reply
// instead, or a job starting between the probe and the reply so that
// sender.ack() consumes it). Without this the probe would stay pending
// forever - which does stop the $I storm, but never populates the axes.
clearTimeout(this.axsProbeTimer);
this.axsProbeTimer = setTimeout(() => {
if (this.axsProbePending) {
log.debug("AXS probe timed out waiting for ok");
this.resolveAxsProbe();
}
}, AXS_PROBE_TIMEOUT);
this.writeln("$I");
}
// Decide what the completed $I reply told us about [AXS:] support.
resolveAxsProbe() {
this.axsProbePending = false;
clearTimeout(this.axsProbeTimer);
this.axsProbeTimer = null;
if (this.runner.hasAXS()) {
// Firmware reported its axes - never probe again.
this.axsSupport = AXS_SUPPORTED;
return;
}
if (this.axsQueryCount < AXS_QUERY_MAX_RETRIES) {
// Budget left; the status handler will start the next probe once
// AXS_QUERY_RETRY_INTERVAL has elapsed.
return;
}
// A full $I reply has now completed with no usable [AXS:] line, twice.
// This firmware does not report it, so stop asking and fall back.
this.axsSupport = AXS_UNSUPPORTED;
// Read the runner's status directly, not this.state: the controller only
// syncs this.state from the runner inside the 250ms queryTimer, so it lags
// and is empty before the first tick.
const axes = this.runner.setInferredAxesFromStatus(
this.runner.state.status,
);
if (axes) {
log.info(
`No [AXS:] in $I response; inferred axes from status report: ${axes.join("")}`,
);
} else {
log.info("No [AXS:] in $I response and no position to infer axes from");
}
}
// NOTE: this runs on every startup/reset message, which includes the [VER:]
// line of any $I reply - including ones this controller sends itself. So it
// can fire several times a second. Retry budgets and give-up flags must NOT
// live in actionMask, or they get reset by the very responses they count.
// Keep them as controller fields reset in open()/close() instead - see the
// axs* fields.
clearActionValues() {
this.actionMask.queryParserState.state = false;
this.actionMask.queryParserState.reply = false;
this.actionMask.queryStatusReport = false;
this.actionMask.replyParserState = false;
this.actionMask.replyStatusReport = false;
// Accessory queries
this.actionMask.accessoryState.SD = false;
this.actionMask.ATCI = false;
this.actionTime.queryParserState = 0;
this.actionTime.queryStatusReport = 0;
this.actionTime.senderFinishTime = 0;
}
destroy() {
this.jogStreamer?.abort("destroy");
if (this.queryTimer) {
clearInterval(this.queryTimer);
this.queryTimer = null;
}
if (this.axsProbeTimer) {
clearTimeout(this.axsProbeTimer);
this.axsProbeTimer = null;
}
if (this.runner) {
this.runner.removeAllListeners();
this.runner = null;
}
if (this.pluginParsers) {
this.pluginParsers.destroy();
this.pluginParsers = null;
}
if (this.toolChanger) {
this.toolChanger.clearInterval();
}
this.sockets = {};
if (this.connection) {
this.connection = null;
}
if (this.event) {
this.event = null;
}
if (this.feeder) {
this.feeder = null;
}
if (this.sender) {
this.sender = null;
}
if (this.workflow) {
this.workflow = null;
}
}
get status() {
return {
port: this.options.port,
baudrate: this.options.baudrate,
rtscts: this.options.rtscts,
sockets: Object.keys(this.sockets),
ready: this.ready,
controller: {
type: this.type,
settings: this.settings,
state: this.state,
},
feeder: this.feeder.toJSON(),
sender: this.sender.toJSON(),
workflow: {
state: this.workflow.state,
},
};
}
restoreListeners() {
this.connection.restoreListeners();
}
open(port, baudrate, refresh = false, callback = noop) {
if (!refresh) {
this.connection.on("data", this.connectionEventListener.data);
this.connection.on("close", this.connectionEventListener.close);
this.connection.on("error", this.connectionEventListener.error);
}
callback(); // register controller
// Nothing else here matters if connecting to existing instantiated controller.
// Don't re-run the startup query sequence (initController) here — it writes
// directly to the serial line ($$, $ES, $EG, etc.) and can collide with an
// actively streaming job. The joining socket already gets current cached
// state via addConnection().
if (refresh) {
this.initialized = true;
return;
}
// Manifest-declared parsers go live here, so they are watching from the
// moment the port opens regardless of whether any plugin UI is mounted.
this.reloadPluginParsers();
this.workflow.stop();
// Clear action values
this.clearActionValues();
// Fresh connection: start over on looking for [AXS:]
this.resetAxsProbe();
// Send $I to query firmware version; the startup event handler will take it from here.
// Also send 0x87 before $I as a raw realtime byte — this bypasses Hold state restrictions
// and triggers a status response that sets this.ready and populates activeState in the UI.
setTimeout(() => {
if (this.connection) {
this.connection.write(Buffer.from([0x87]));
this.write("$I\n");
}
}, 500);
}
close(callback, currentLineRunning) {
const { port } = this.options;
// Assertion check
if (!this.connection) {
const err = `Serial port "${port}" is not available`;
callback(new Error(err));
return;
}
// Stop status query
this.ready = false;
// A stream must never outlive the connection it is writing to.
this.jogStreamer.abort("close");
// Clear initialized flag
this.initialized = false;
// Flush any open plugin blocks with complete:false, so a subscriber that
// was waiting on a block the disconnect made impossible to finish gets a
// terminal result instead of hanging.
this.pluginParsers?.reset("close");
// Stop and reset any in-flight [AXS:] probe
this.resetAxsProbe();
// Reset homing runtime state
if (this.hasHomedSet) {
this.hasHomedSet = false;
this.emit("homing:has-homed", false);
}
this.emit(
"serialport:closeController",
{
port: port,
inuse: false,
},
currentLineRunning,
);
if (this.isClose()) {
callback(null);
return;
}
callback(null);
}
isOpen() {
return this.connection && this.connection.isOpen();
}
isClose() {
return !this.isOpen();
}
loadFile(gcode, { name }, refresh = false) {
if (refresh && !this.workflow.isIdle()) {
log.debug("Skip loading file: workflow is not idle");
return; // Don't reload file if controller is running;
}
log.debug(`Loading file '${name}' to controller`);
this.command("gcode:load", name, gcode);
}
// --- Plugin parsers -------------------------------------------------------
/**
* Rebuilds the manifest-declared parser chain from the plugin registry.
* Called on port open and whenever plugins are enabled, disabled, or
* imported. Runtime registrations are untouched.
*/
reloadPluginParsers() {
if (!this.pluginParsers) {
return;
}
try {
this.pluginParsers.setManifestParsers(
pluginRegistry.getPluginParserSpecs(),
);
} catch (err) {
log.error(`Failed to load plugin parsers: ${err.message}`);
}
}
/**
* @param {string} ownerId Identifies the registering plugin iframe instance,
* so its parsers can be dropped when it unmounts.
*/
registerPluginParsers(ownerId, pluginId, specs) {
if (!this.pluginParsers) {
return { registered: [], errors: [], warnings: [] };
}
return this.pluginParsers.registerRuntime(ownerId, pluginId, specs);
}
unregisterPluginParsers(ownerId, parserId) {
if (!this.pluginParsers) {
return { ok: true };
}
return this.pluginParsers.unregisterRuntime(ownerId, parserId);
}
/**
* Sends a command and collects every line the firmware sends back until a
* terminator, giving plugins the request/response shape the event stream
* cannot express.
*
* @param {(err: Error|null, result?: object) => void} callback
*/
pluginQuery(cmd, opts = {}, callback = noop) {
if (!this.isOpen()) {
callback(new Error("Serial port is not open"));
return;
}
if (!this.pluginParsers) {
callback(new Error("Plugin parsers are not available"));
return;
}
if (typeof cmd !== "string" || cmd.trim() === "") {
callback(new Error("A command is required"));
return;
}
const allowDuringJob = Boolean(opts.allowDuringJob);
if (!this.workflow.isIdle() && !allowDuringJob) {
callback(
Object.assign(new Error("Machine is busy running a job"), {
code: "EBUSY",
}),
);
return;
}
// While a job streams, the firmware emits an `ok` per accepted line, so
// `ok`/`error` are indistinguishable from the sender's own responses. A
// query that runs then MUST bring its own terminator.
if (allowDuringJob && typeof opts.until !== "object") {
callback(
new Error(
'A query with allowDuringJob needs an explicit "until" pattern — ' +
"ok/error cannot be told apart from the running job's responses",
),
);
return;
}
if (this.pluginQueryInFlight) {
callback(
Object.assign(new Error("Another query is already in flight"), {
code: "EBUSY",
}),
);
return;
}
let until = opts.until ?? "ok-or-error";
if (until && typeof until === "object" && until.source) {
try {
until = new RegExp(
until.source,
(until.flags || "").replace(/[gy]/g, ""),
);
} catch (err) {
callback(new Error(`Invalid "until" pattern: ${err.message}`));
return;
}
}
this.pluginQueryInFlight = true;
// Open the capture window BEFORE writing. The write goes synchronously
// into the serial stream and feed() is driven off runner.parse(), so no
// response line can slip between the two.
this.pluginParsers.beginCapture({
until,
maxLines: opts.maxLines,
timeout: opts.timeout,
includeStatusReports: Boolean(opts.includeStatusReports),
onDone: (result) => {
this.pluginQueryInFlight = false;
callback(null, result);
},
});
// emit=true so a plugin-issued write is visible to the operator in the
// console rather than happening invisibly.
this.writeln(cmd, {}, true);
}
addConnection(socket) {
if (!socket) {
log.error("The socket parameter is not specified");
return;
}
if (!_.isEmpty(this.settings)) {
// controller settings
socket.emit("controller:settings", GRBLHAL, this.settings);
}
if (!_.isEmpty(this.state)) {
// controller state
socket.emit("controller:state", GRBLHAL, this.state);
}
if (this.feeder) {
// feeder status
socket.emit("feeder:status", this.feeder.toJSON());
}
if (this.sender) {
// sender status
socket.emit("sender:status", this.sender.toJSON());
}
if (this.workflow) {
// workflow state
socket.emit("workflow:state", this.workflow.state);
}
// Replay homing runtime state so reconnecting clients get the current value
socket.emit("homing:has-homed", this.hasHomedSet);
}
emit(eventName, ...args) {
this.connection.emitToSockets(eventName, ...args);
}
consumeFeederCB() {
if (this.feederCB && typeof this.feederCB === "function") {
this.feederCB();
this.feederCB = null;
}
}
consumeFOQ() {
if (!this.runningFOQ) {
this.runningFOQ = true;
let index = 0;
while (this.FOQueue.length > 0) {
const command = this.FOQueue.shift();
setTimeout(
() => {
this.connection.writeImmediate(command);
},
25 * (index + 1),
);
index++;
}
this.runningFOQ = false;
}
}
consumeSOQ() {
if (!this.runningSOQ) {
this.runningSOQ = true;
let index = 0;
while (this.SOQueue.length > 0) {
const command = this.SOQueue.shift();
setTimeout(
() => {
this.connection.writeImmediate(command);
},
25 * (index + 1),
);
index++;
}
this.runningSOQ = false;
}
}
command(cmd, ...args) {
const handler = {
"firmware:recievedProfiles": () => {
const [files] = args;
this.emit("task:finish", files);
},
"firmware:grabMachineProfile": () => {
const machineProfile = store.get("machineProfile");
this.emit("sender:status", machineProfile);
},
"gcode:load": () => {
let [name, gcode, context = {}, callback = noop] = args;
if (typeof context === "function") {
callback = context;
context = {};
}
// G4 P0 or P with a very small value will empty the planner queue and then
// respond with an ok when the dwell is complete. At that instant, there will
// be no queued motions, as long as no more commands were sent after the G4.
// This is the fastest way to do it without having to check the status reports.
//const dwell = '%wait ; Wait for the planner to empty';
// Insert dwell for firmware < ATCI_SUPPORTED_VERSION where $392 is not acted on by firmware
const semver = this.runner.settings?.version?.semver ?? 0;
const preferences = store.get("preferences", {});
const spindleOnDelay = Number(_.get(preferences, "spindleDelay", 0));
console.log("Spindle delay: ", spindleOnDelay);
if (semver < ATCI_SUPPORTED_VERSION && spindleOnDelay > 0) {
gcode = gcode.replace(
/\b(?:S\d* ?M[34]|M[34] ?S\d*)\b(?! ?G4 ?P?\b)/g,
`$& G4 P${spindleOnDelay}`,
);
}
this.toolChangeContext.mappings = {};
const ok = this.sender.load(name, gcode + "\n", context);
if (!ok) {
callback(new Error(`Invalid G-code: name=${name}`));
return;
}
log.debug(
`Load G-code: name="${this.sender.state.name}", size=${this.sender.state.gcode.length}, total=${this.sender.state.total}`,
);
this.workflow.stop();
callback(null, this.sender.toJSON());
},
"gcode:unload": () => {
this.workflow.stop();
this.engine.unload();
// Sender
this.sender.unload();
this.emit("file:unload");
this.event.trigger(FILE_UNLOAD);
},
start: () => {
log.warn(
`Warning: The "${cmd}" command is deprecated and will be removed in a future release.`,
);
this.command("gcode:start");
},
"gcode:start": () => {
const preferences = store.get("preferences", {});
const defaultSpindleDelay = Number(
_.get(preferences, "spindleDelay", 0),
);
const [lineToStartFrom, zMax, safeHeight = 10] = args;
const totalLines = this.sender.state.total;
const startEventEnabled = this.event.hasEnabledEvent(PROGRAM_START);
this.emit(
"job:start",
Boolean(lineToStartFrom && lineToStartFrom <= totalLines),
);
const atci = _.get(this.settings, "info.NEWOPT.ATC", "0") === "1";
this.command("gcode", "%global.state.workspace=modal.wcs");
if (lineToStartFrom && lineToStartFrom <= totalLines) {
const { lines = [] } = this.sender.state;
const firstHalf = lines.slice(0, lineToStartFrom);
let feedFound = false;
let feedRate = 200;
let spindleRate = 0;
const getWordValue = (token, words) => {
for (const wordPair of words) {
const [word, value] = wordPair;
if (word === token) {
return value;
}
}
return 0;
};
const wordValueInWords = (token, words) => {
let found = false;
words.forEach((word) => {
if (word[0] === token) {
found = true;
}
});
return found;
};
const toolpath = new GcodeToolpath();
toolpath.loadFromStringSync(firstHalf.join("\n"), (data) => {
const { words, line } = data;
if (line.includes("F")) {
feedRate = getWordValue("F", words);
feedFound = true;
}
if (line.includes("S")) {
if (wordValueInWords("S", words)) {
spindleRate = getWordValue("S", words);
}
}
});
const modal = toolpath.getModal();
const hasSeenM6 = toolpath.hasSeenM6();
const position = toolpath.getPosition();
const coolant = {
mist: "",
flood: "",
};
const hasSpindle = modal.spindle !== "M5";
if (modal.coolant) {
if (modal.coolant.includes("M7")) {
coolant.mist = "M7";
}
if (modal.coolant.includes("M8")) {
coolant.flood = "M8";
}
}
const { x: xVal, y: yVal, z: zVal, a: aVal } = position;
const modalGCode = [];
if (!feedFound) {
feedRate = modal.units === "G21" ? 200 : 8;
}
const wcs = _.get(this.state, "parserstate.modal.wcs", "G54");
let modalWcs = modal.wcs;
if (modalWcs !== wcs && modalWcs === "G54") {
modalWcs = wcs;
}
const setModalGcode =
modal.motion === "G2" || modal.motion === "G3"
? `${modal.motion} X${xVal.toFixed(3)} J0 F${feedRate}`
: `${modal.motion}`;
// Move up and then to cut start position
modalGCode.push(this.event.getEventCode(PROGRAM_START));
modalGCode.push(`G0 G90 G21 Z${zMax + safeHeight}`);
// ATCI - add M6 before spindles turned on to get correct tool to spin up
// Only insert if an M6 was actually parsed - a bare T word with no M6
// (some CAM posts never emit M6) should not trigger a tool change here
if (atci && modal.tool !== 0 && hasSeenM6) {
if (this.toolChangeContext.mappings) {
const remap = _.get(
this.toolChangeContext.mappings,
modal.tool,
null,
);
if (remap) {
modalGCode.push(`M6 T${remap}`);
} else {
modalGCode.push(`M6 T${modal.tool}`);
}
} else {
modalGCode.push(`M6 T${modal.tool}`);
}
}
if (hasSpindle) {
modalGCode.push(`${modal.spindle} S${spindleRate}`);
} else {
modalGCode.push(`${modal.units} F${feedRate}`);
}
modalGCode.push(`G0 G90 G21 X${xVal.toFixed(3)} Y${yVal.toFixed(3)}`);
if (aVal) {
modalGCode.push(`G0 G90 G21 A${(Number(aVal) % 360).toFixed(3)}`);
}
modalGCode.push(`G0 G90 G21 Z${zVal.toFixed(3)}`);
// Set modals based on what's parsed so far in the file
modalGCode.push(
`${modal.units} ${modal.distance} ${modal.arc} ${modalWcs} ${modal.plane} ${coolant.flood} ${coolant.mist}`,
);
modalGCode.push(`F${feedRate}`);
modalGCode.push(setModalGcode);
if (defaultSpindleDelay > 0) {
modalGCode.push(`G4 P${defaultSpindleDelay}`);
}
modalGCode.push("%_GCODE_START");
// console.log(modalGCode);
// Fast forward sender to line
this.sender.setStartLine(lineToStartFrom);
this.command("gcode", modalGCode);
} else if (startEventEnabled) {
this.feederCB = () => {
// Feeder
this.feeder.reset();
this.workflow.start();
// Sender
this.sender.next();
this.feederCB = null;
};
this.event.trigger(PROGRAM_START);
} else {
this.workflow.start();
// Feeder
this.feeder.reset();
// Sender
this.sender.setStartLine(0);
this.sender.next({ startFromLine: true });
}
},
stop: () => {
log.warn(
`Warning: The "${cmd}" command is deprecated and will be removed in a future release.`,
);
this.command("gcode:stop", ...args);
},
// @param {object} options The options object.
// @param {boolean} [options.force] Whether to force stop a G-code program. Defaults to false.
"gcode:stop": async () => {
this.workflow.stop();
clearInterval(this.programResumeTimeout);
const [options] = args;
const { force = false } = { ...options };
this.emit("job:stop");
const wcs = _.get(this.state, "parserstate.modal.wcs", "G54");
if (force) {
let activeState;
activeState = _.get(this.state, "status.activeState", "");
this.write("\x19");
if (activeState === GRBL_HAL_ACTIVE_STATE_RUN) {
this.write("!"); // hold
}
await delay(700); // delay 700ms
this.write("\x18"); // ^x
// Handle resetting workspace after stop back to selected one
if (wcs !== "G54") {
await delay(200);
this.writeln(wcs);
}
}
// Moved this to end so it triggers AFTER the reset on force stop
this.event.trigger(PROGRAM_END);
this.sender.stopCountdown();
},
pause: () => {
log.warn(
`Warning: The "${cmd}" command is deprecated and will be removed in a future release.`,
);
this.command("gcode:pause");
},
"gcode:pause": async () => {
if (this.event.hasEnabledEvent(PROGRAM_PAUSE)) {
this.workflow.pause();
this.event.trigger(PROGRAM_PAUSE);
} else {
this.workflow.pause();
await delay(100);
this.write(GRBLHAL_REALTIME_COMMANDS.FEED_HOLD);
}
},
resume: () => {
log.warn(
`Warning: The "${cmd}" command is deprecated and will be removed in a future release.`,
);
this.command("gcode:resume");
},
"gcode:resume": async () => {
const [ignoreEvents = false] = args;
log.debug("gcode:resume called - program to continue sending");
if (!ignoreEvents && this.event.hasEnabledEvent(PROGRAM_RESUME)) {
this.feederCB = () => {
this.write(GRBLHAL_REALTIME_COMMANDS.CYCLE_START);
this.workflow.resume();
this.feederCB = null;
};
this.event.trigger(PROGRAM_RESUME);
} else {
this.write(GRBLHAL_REALTIME_COMMANDS.CYCLE_START);
await delay(1000);
this.workflow.resume();
}
},
"feeder:feed": () => {
const [commands, context = {}] = args;
this.command("gcode", commands, context);
},
"feeder:start": async () => {
if (this.workflow.state === WORKFLOW_STATE_RUNNING) {
return;
}
this.write("~");
await delay(1000);
this.feeder.unhold();
this.feeder.next();
},
"feeder:stop": () => {
clearInterval(this.feederCB);
this.feeder.reset();
this.workflow.stop();
this.write("\x19");
delay(100).then(() => {
this.write("~");
});
},
feedhold: () => {
this.event.trigger(FEED_HOLD);
this.write("!");
},
feedhold_alt: () => {
this.event.trigger(FEED_HOLD);
this.write(GRBLHAL_REALTIME_COMMANDS.FEED_HOLD);
},
cyclestart: () => {
this.event.trigger(CYCLE_START);
this.write("~");
},
cyclestart_alt: () => {
this.event.trigger(CYCLE_START);
this.write(GRBLHAL_REALTIME_COMMANDS.CYCLE_START);
},
statusreport: () => {
this.write("?");
},
homing: () => {
const [axis] = args;
this.event.trigger(HOMING);
this.homingStarted = true; // Update homing cycle as having started
if (axis) {
this.writeln("$H" + axis);
} else {
this.writeln("$H");
}
this.state.status.activeState = GRBL_HAL_ACTIVE_STATE_HOME;
this.emit("controller:state", GRBLHAL, this.state);
},
sleep: () => {
this.event.trigger(SLEEP);
this.writeln("$SLP");
},
unlock: () => {
this.feeder.reset();
this.writeln("$X");
this.write(GRBLHAL_REALTIME_COMMANDS.CMD_SOFT_STOP);
},
populateConfig: () => {
this.writeln("$$");
},
reset: () => {
this.workflow.stop();
this.feeder.reset();
this.write("\x18"); // ^x
},
"reset:soft": () => {
this.workflow.stop();
this.feeder.reset();
this.write("\x19"); // HAL soft stop command
},
"reset:limit": () => {
this.workflow.stop();
this.feeder.reset();
this.write("\x18"); // ^x
delay(350).then(() => {
this.writeln("$X");
delay(500).then(() => {
this.connection.writeImmediate(
GRBLHAL_REALTIME_COMMANDS.COMPLETE_REALTIME_REPORT,
);
});
});
},
restart: () => {
this.command("gcode", "$REBOOT");
},
checkStateUpdate: () => {
this.emit("controller:state", GRBLHAL, this.state);
},
// Feed Overrides
// @param {number} value The amount of percentage increase or decrease.
feedOverride: () => {
const [value] = args;
const [feedOV] = this.state.status.ov;
const diff = value - feedOV;
if (value === 100) {
this.FOQueue.push(String.fromCharCode(0x90));
} else {
this.FOQueue.push(...calcOverrides(diff, "feed"));
}
this.consumeFOQ();
this.sender.setOvF(value);
},
// Spindle Speed Overrides
// @param {number} value The amount of percentage increase or decrease.
spindleOverride: () => {
const [value] = args;
const [, , spindleOV] = this.state.status.ov;
let diff = value - spindleOV;
//Limits for keyboard/gamepad shortcuts
if (value < 10) {
diff = 10 - spindleOV;
} else if (value > 230) {
diff = 230 - spindleOV;
}
if (value === 100) {
this.SOQueue.push(String.fromCharCode(0x99));
} else {
this.SOQueue.push(...calcOverrides(diff, "spindle"));
}
this.consumeSOQ();
},
// Rapid Overrides
// @param {number} value A percentage value of 25, 50, or 100. A value of zero will reset to 100%.
// 100: Set to 100% full rapid rate.
// 50: Set to 50% of rapid rate.
// 25: Set to 25% of rapid rate.
rapidOverride: () => {
const [value] = args;
if (value === 0 || value === 100) {
this.write("\x95");
} else if (value === 50) {
this.write("\x96");
} else if (value === 25) {
this.write("\x97");
}
},
"lasertest:on": () => {
const [power = 0, duration = 0] = args;
const maxS = ensurePositiveNumber(this.runner.getSetting("$730", 255));
const laserPower = ensurePositiveNumber(maxS * (power / 100)).toFixed(
2,
);
const commands = [
// https://github.com/gnea/grbl/wiki/Grbl-v1.1-Laser-Mode
// The laser will only turn on when Grbl is in a G1, G2, or G3 motion mode.
"G1F1 M3 S" + laserPower,
];
if (duration > 0) {
commands.push("G4P" + ensurePositiveNumber(duration));
commands.push("M5 S0");
}
this.state.parserstate.modal.spindle = "M3";
this.emit("controller:state", GRBLHAL, this.state);
this.command("gcode", commands);
},
"lasertest:off": () => {
const commands = ["M5S0"];
this.command("gcode", commands);
},
"laserpower:change": () => {
const [power = 0, maxS = 1000] = args;
const commands = [
// https://github.com/gnea/grbl/wiki/Grbl-v1.1-Laser-Mode
// The laser will only turn on when Grbl is in a G1, G2, or G3 motion mode.
"S" +
Math.round(ensurePositiveNumber(maxS * (power / 100)) * 100) / 100,
];
this.command("gcode", commands);
},
"spindlespeed:change": () => {
const [speed = 0] = args;
const commands = ["S" + speed];
this.command("gcode", commands);
},
gcode: () => {
const [commands, context] = args;
const data = ensureArray(commands)
.join("\n")
.split(/\r?\n/)
.filter((line) => {
if (typeof line !== "string") {
return false;
}
return line.trim().length > 0;
});
this.feeder.feed(data, context);
if (!this.feeder.isPending()) {
this.feeder.next();
}
},
"gcode:test": () => {
this.feederCB = () => {
this.feeder.reset();
this.workflow.start();
this.sender.next();
this.feederCB = null;
};
this.command("gcode", ["%global.state.testWCS=modal.wcs", "$C"]);
},
"gcode:safe": () => {
const [commands, prefUnits] = args;
const deviceUnits = this.state.parserstate.modal.units;
let code = [];
if (!deviceUnits) {
log.error("Unable to determine device unit modal");
return;
}
// Force command in preferred units
if (prefUnits !== deviceUnits) {
code.push(prefUnits);
}
code = code.concat(commands);
// return modal to previous state if they were different previously
if (prefUnits !== deviceUnits) {
code = code.concat(deviceUnits);
}
this.command("gcode", code);
},
"jog:start": () => {
const [axes, feedrate = 1000, units = METRIC_UNITS] = args;
// Keep the machine-zero flag current; the streamer reads it to work
// out how much travel is left.
this.homingFlagSet = determineHALMachineZeroFlag({}, this.settings);
this.jogStreamer.start({ axes, feedrate, units });
},
"jog:update": () => {
const [axes, feedrate] = args;
this.jogStreamer.update({ axes, feedrate });
},
"jog:feed": () => {
const [axes, feedrate, units = METRIC_UNITS] = args;
this.homingFlagSet = determineHALMachineZeroFlag({}, this.settings);
this.jogStreamer.feed({ axes, feedrate, units });
},
"jog:stop": () => {
this.jogStreamer.stop();
this.write("\x85");
},
"jog:cancel": () => {
this.jogStreamer.abort("cancel");
this.write("\x85");
},
"macro:run": () => {
let [id, context = {}, callback = noop] = args;
if (typeof context === "function") {
callback = context;
context = {};
}
const macros = config.get("macros");
const macro = _.find(macros, { id: id });
if (!macro) {
log.error(`Cannot find the macro: id=${id}`);
return;
}
this.event.trigger(MACRO_RUN);
this.command("gcode", macro.content, context);
callback(null);
},
"macro:load": () => {
let [id, context = {}, callback = noop] = args;
if (typeof context === "function") {
callback = context;
context = {};
}
const macros = config.get("macros");
const macro = _.find(macros, { id: id });
if (!macro) {
log.error(`Cannot find the macro: id=${id}`);
return;
}
this.event.trigger(MACRO_LOAD);
this.command(
"gcode:load",
macro.name,
macro.content,
context,
callback,
);
},
"watchdir:load": () => {
const [file, callback = noop] = args;
const context = {}; // empty context
monitor.readFile(file, (err, data) => {
if (err) {
callback(err);
return;
}
this.command("gcode:load", file, data, context, callback);
});
},
"machineprofile:load": () => {
const [machineProfile] = args;
store.set("machineProfile", machineProfile);
},
"settings:updated": () => {
const [newSettings = {}] = args;
const currentSettings = store.get("preferences") || {};
const updated = {
...currentSettings,
...newSettings,
};
store.set("preferences", updated);
},
"toolchange:context": () => {
const [context] = args;
this.toolChangeContext = { ...this.toolChangeContext, ...context };
},
"toolchange:pre": () => {
log.debug("Starting pre hook");
this.runPreChangeHook();
},
"toolchange:post": () => {
log.debug("starting post hook");
this.command("feeder:start");
this.runPostChangeHook();
},
"wizard:start": () => {
log.debug("Wizard kickoff code");
const [gcode] = args;
this.toolChanger.addInterval(() => {
this.command("gcode", gcode);
});
},
"wizard:step": () => {
const [stepIndex, substepIndex] = args;
this.feederCB = () => {
this.emit("wizard:next", stepIndex, substepIndex);
};
},
realtime_report: () => {
this.write(`${GRBLHAL_REALTIME_COMMANDS.COMPLETE_REALTIME_REPORT}\n`);
},
error_clear: () => {
this.write("$");
},
"toolchange:acknowledge": () => {
this.emit("serialport:write", "Toolchange Ack sent", {
source: WRITE_SOURCE_FEEDER,
});
this.write(GRBLHAL_REALTIME_COMMANDS.TOOL_CHANGE_ACK);
},
virtual_stop_toggle: () => {
this.write(GRBLHAL_REALTIME_COMMANDS.VIRTUAL_STOP_TOGGLE);
},
updateEstimateData: () => {
const [estimateData] = args;
this.sender.setEstimateData(estimateData.estimates);
this.sender.setEstimatedTime(estimateData.estimatedTime);
},
updateRotaryMode: () => {
const [isInRotaryMode] = args;
this.isInRotaryMode = isInRotaryMode;
},
"runner:resetSettings": () => {
this.runner.deleteSettings();
},
"sdcard:mount": () => {
this.write("$FM\n");
this.runner.setSDStatus();
this.emit("controller:state", GRBLHAL, this.runner.state);
},
"sdcard:list": () => {
const [type = "cnc"] = args;
this.runner.clearSDFiles();
this.runner.setSDStatus();
this.emit("controller:state", GRBLHAL, this.runner.state);
if (type === "cnc") {
this.write("$FM\n");
delay(100).then(() => {
this.write("$F\n");
});
return;
}
if (type === "all") {
this.write("$FM\n$F+\n");
}
},
"sdcard:read": () => {
const [fileName] = args;
// Always request $F<= if ATCI.macro
if (fileName === "ATCI.macro") {
this.command("gcode", `$F<=${fileName}`);
return;
}
const availableFiles = _.get(this.state, "sdcard.files", []);
const hasFile = _.find(
availableFiles,
(file) => file.name === fileName,
);
if (hasFile) {
this.command("gcode", `$F<=${fileName}`);
}
},
"sdcard:run": () => {
const [filePath] = args;
this.writeln(`$F=${filePath}`);
},
"sdcard:delete": () => {
const [filePath] = args;
this.writeln(`$FD=${filePath}`);
},
"ymodem:upload": async () => {
const [fileData] = args;
this.command("sdcard:mount");
setTimeout(async () => {
if (this.runner.isSDMounted()) {
if (this.connection.isNetwork()) {
const [address] = this.connection.getFTPInfo();
const FTPPort = Number(this.runner.getSetting("$308", 21));
await this.ftpClient.openConnection(
address,
FTPPort,
"grblHAL",
"grblHAL",
);
await this.ftpClient.sendFile(fileData);
return;
}
this.ymodem.sendFile(
fileData,
this.connection.getConnectionObject(),
);
} else {
this.emit(
"ymodem:error",
"SD Card not detected, please insert an SD Card in FAT32 format, 32 GB or under, and try again",
);
}
}, 1250);
},
"ymodem:uploadFiles": () => {
const [files] = args;
this.command("sdcard:mount");
setTimeout(async () => {
if (this.runner.isSDMounted()) {
if (this.connection.isNetwork()) {
const [address] = this.connection.getFTPInfo();
const FTPPort = Number(this.runner.getSetting("$308", 21));
await this.ftpClient.openConnection(
address,
FTPPort,
"grblHAL",
"grblHAL",
);
await this.ftpClient.sendFiles(files);
return;
}
this.ymodem.sendFiles(files, this.connection.getConnectionObject());
} else {
this.emit(
"ymodem:error",
"SD Card not detected, please insert an SD Card in FAT32 format, 32 GB or under, and try again",
);
}
}, 1500);
},
"ymodem:cancel": () => {
console.log("cancel upload");
},
}[cmd];
if (!handler) {
log.error(`Unknown command: ${cmd}`);
return;
}
// A jog stream consumes its own acks, which is only safe while it is the
// sole writer of ack-producing lines. Anything else claiming the link
// ends the stream first.
if (
this.jogStreamer.isActive() &&
!JOG_COMMANDS.includes(cmd) &&
!STREAM_SAFE_COMMANDS.includes(cmd)
) {
this.jogStreamer.abort(`command:${cmd}`);
}
handler();
}
write(data, context) {
// Assertion check
if (this.isClose()) {
log.error(`Serial port "${this.options.port}" is not accessible`);
return;
}
const cmd = data.trim();
this.actionMask.replyStatusReport =
cmd === GRBLHAL_REALTIME_COMMANDS.STATUS_REPORT ||
cmd === GRBLHAL_REALTIME_COMMANDS.COMPLETE_REALTIME_REPORT ||
this.actionMask.replyStatusReport;
// GCODE_REPORT is "$G\n" - the trailing newline is what makes the firmware
// execute it - but cmd is trimmed, so comparing the two raw would never
// match and a user-typed $G would get no reply echoed to the console.
this.actionMask.replyParserState =
cmd === GRBLHAL_REALTIME_COMMANDS.GCODE_REPORT.trim() ||
this.actionMask.replyParserState;
this.connection.write(data, {
...context,
source: WRITE_SOURCE_CLIENT,
});
log.silly(`> ${data}`);
}
writeln(data, context, emit = false) {
if (_.includes(GRBLHAL_REALTIME_COMMANDS, data)) {
this.write(data, context);
} else {
this.write(data + "\n", context);
}
if (emit) {
this.emit("serialport:write", data + "\n", {
...context,
source: WRITE_SOURCE_FEEDER,
});
}
}
convertGcodeToArray(gcode) {
//Clean up lines and remove ones that are comments and headers
const lines = gcode.split("\n").filter((line) => line.trim().length > 0);
return lines;
}
updateSpindleModal(modal) {
this.state.parserstate.modal.spindle = modal;
this.emit("controller:state", GRBLHAL, this.state);
}
/* Runs specified code segment on M6 command before alerting the UI as to what's happened */
runPreChangeHook(comment = "") {
let {
preHook = "",
postHook = "",
skipDialog = false,
} = this.toolChangeContext;
preHook = `G4 P1\n${preHook}`;
const block = this.convertGcodeToArray(preHook);
// If we're skipping dialog, combine both blocks and append a toolchange end so the program continues as expected
if (skipDialog) {
block.push("G4 P1");
block.push(...this.convertGcodeToArray(postHook));
block.push(POSTHOOK_COMPLETE);
}
// If we're not skipping, add a prehook complete to show dialog to continue toolchange operation
if (!skipDialog) {
block.push(`${PREHOOK_COMPLETE} ;${comment}`);
}
this.command("gcode", block);
}
runPostChangeHook() {
let { postHook } = this.toolChangeContext || "";
postHook = `G4 P1\n${postHook}`;
const block = this.convertGcodeToArray(postHook);
block.push(POSTHOOK_COMPLETE);
this.command("gcode", block);
}
setSenderTimeout() {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
// job idle
if (this.state.status.activeState === GRBL_HAL_ACTIVE_STATE_IDLE) {
// force one ok for query parser and another for the line
this.forceOK = true;
this.sender.ack();
this.runner.forceOK();
} else if (this.workflow.state === WORKFLOW_STATE_RUNNING) {
// if job not idle but running, reset timeout
this.setSenderTimeout();
} else if (
this.state.status.activeState === GRBL_HAL_ACTIVE_STATE_IDLE &&
this.workflow.state === WORKFLOW_STATE_IDLE
) {
// job done
this.sender.next({ forceEnd: true }); // force job end
}
}, 5000);
}
}
export default GrblHalController;
b6d6e761eThis file is pinned to the selected release. Download the original to open it in your design tool.