import SerialPort from 'serialport'
import {Writable} from 'stream'
import errors from './errors.js'
const emptyPromise = () => {
let res
let promise = new Promise(_res => (res = _res))
promise.resolve = (...args) => (res(...args), promise.done = true)
return promise
}
/**
* Methods and properties available on an instantiated `gerbil` object.
* @namespace gerbil */
/**
* this is the gerbil module
* @module gerbil-cnc
* @function
* @arg {string} ttyPath path to serial port
* @arg {object} [options] sets up instance options, this is optional
* @arg {boolean|number} [options.autoReconnect=1000] reconnection interval,
* if falsy then reconnection isn't attempted on drop.
*
* @exports module:gerbil-cnc
* @returns {gerbil}
* @public
*/
let main = (
ttyPath,
{autoReconnect} = {
autoReconnect: 1000,
pollInterval: 200,
}
) => {
let retry, port, parser, busy
let status = {
connected: false
}
let handleDisconnect = () => {
status = {connected: false}
if (gerbil.machineReady.done) gerbil.machineReady = emptyPromise()
}
let writeRaw = data => {
if (!(status.connected && status.version)) throw new Error('machine disconnected')
port.write(data)
}
let machineStatus = (() => {
let parseStatus = line => Object.fromEntries(
line.trim()
.slice(1,-1) //remove surrounding <>
.split('|')
.map((s,i) => {
if (i === 0) return ['Status', s]
let [key, value = true] = s.split(':')
let values = value?.split?.(',').map(n => parseFloat(n)) || true
if (values?.length === 1) values = value
if (key === 'Bf') {
key = 'Buffer'
let [planner, rx] = values
values = {
planner,
rx
}
}
if (key === 'FS') {
key = 'Speeds'
let [feed, spindle] = values
values = {
feed,
spindle
}
}
if (key === 'F') {
key = 'Speeds'
let [feed] = values
values = {feed}
}
if (key === 'MPos' || key === 'WPos') {
let [x,y,z] = values
values = {x,y,z}
}
return [key, values]
}))
return () => new Promise (res => {
writeRaw('?')
router.next('status', response => (res(parseStatus(response))))
})})()
let settings = _settings => new Promise (async res => {
let {Status} = await gerbil.cmds.machineStatus()
if (Status !== 'Idle') throw new Error('$ commands cannot be sent unless Grbl is IDLE')
if (_settings === undefined){
let settings = {}
let getSettings = (setting, unbind) => {
let [key, value] = setting.split('=')
settings[key] = value.trim()
if (setting.slice(0,4) === '$132') {
unbind()
res(settings)
}
}
writeRaw('$$\n')
router.every('setting',getSettings)
} else {
let entries = Object.entries(_settings)
//basic sanity check
entries.forEach(([key]) => {
if (!key.match(/^\$\d+$/)) throw new Error(`invalid setting: ${key}`)
})
let length = entries.length
let message = entries.map(([key,value]) => `${key}=${value}\n`).join('')
let getOk = (ok, unbind) => {
length--
if (length < 1){
unbind()
res()
}
}
writeRaw(message)
router.every('ok',getOk)
}
})
let softReset = () => new Promise (res => {
writeRaw('\u0018')
router.next('any', res)
})
let feedHold = () => {
writeRaw('!')
return machineStatus()
}
let resume = () => {
writeRaw('~')
return machineStatus()
}
let writeLine = message => new Promise (res => {
if (message.split('\n').filter(a=>a).length > 1)
throw new Error('writeLine interface is only meant for single-line messages')
if (message.length > 127)
throw new Error(`message larger than GRBL buffer (128btyes), ignored\nmsg: ${message}`)
if (!message[message.length-1].match(/\n|\r/))
message += '\n'
let data = ''
let unbindGather = router.every('any', val => data += val+'\n')
let finalize = ok => {
unbindGather()
if (ok === 'ok') {
res(data)
} else {
res({error: ok, message: errors[ok]})
}
}
router.next('response', finalize)
writeRaw(message)
})
let stream = (() => {
let corked = false
const RX = 126 //126 because i always want to have space for a ?
//this may not be necessary? not sure if ? goes into the buffer
let streaming = false
let currentRx = []
let buffer = []
let onData = (data,unbind) => {
currentRx.shift()
processBuffer()
if (currentRx.length === 0 && buffer.length === 0) {
unbind()
streaming = false
}
}
let processBuffer = () => {
let toSend = ''
while (
!corked &&
//buffer has things in it
buffer.length > 0 &&
//and the buffer doesn't have more than 128 characters in it
(currentRx.reduce((a,b)=>a+b.length,0)+buffer[0].length < RX)
) {
let command = buffer.shift()
currentRx.push(command)
toSend += command
}
if (toSend) {
writeRaw(toSend)
}
}
let write = async string => {
if (!streaming) {
streaming = true
router.every('ok', onData)
}
let commands = string.split('\n').map(s => s.trim()).filter(a=>a).map(a=>a+'\n')
commands.forEach(command => buffer.push(command))
processBuffer()
}
let cancel = (tmp = buffer) => {
buffer = []
let {machineBuffer} = status()
return {buffer:tmp, machineBuffer}
}
let status = () => {
return {
buffer: buffer.map(v=>v),
buffered: buffer.length,
rxBuffered: currentRx.reduce((a,b)=>a+b.length,0),
machineBuffer: currentRx.map(v=>v),
streaming
}
}
/**
* This is an implemementation of Grbl's character-counting stream method.
* @namespace gerbil.stream
*/
let stream = {
cork: _=>corked = true,
uncork: _=>(corked = false,processBuffer()),
/**
* Write to Grbl using the character-counting stream method. In practice
* this is the correct way to send gcode.
* @func
* @arg {string} string
* @returns {void}
*
* @memberof gerbil.stream
*/
write,
status,
cancel
}
return stream
})()
let router = (() => {
let listeners = {
any: new Set(),
version: new Set(),
ok: new Set(),
status: new Set(),
setting: new Set(),
error: new Set(),
response: new Set(),
}
let lineType = line => {
if (line.length === 0) return 'empty'
if (line === 'ok') return 'ok'
if (line[0] === '<') return 'status'
if (line.match('Grbl')) return 'version'
if (line.match(/^\$\d+=/)) return 'setting'
if (errors[line]) return 'error'
return 'unknown'
}
let buffer = Buffer.alloc(0)
let input = new Writable({
write: (chunk, encoding, callback) => {
let data = Buffer.concat([buffer, chunk])
let position
while ((position = data.indexOf('\n')) !== -1) {
let line = data.slice(0,position-1).toString()
let type = lineType(line)
let response = type === 'ok' || type === 'error'
gerbil.onEveryLine?.(line)
listeners.any?.forEach(fn => fn(line))
listeners[type]?.forEach(fn => fn(line))
if (response) listeners.response?.forEach(fn => fn(line))
data = data.slice(position+1)
}
buffer = data
callback()
},
})
let every = (type, func) => {
if (!listeners[type]) throw new Error(`event type: "${type}" is unsupported`)
let bindUnbind = v => func(v, () => listeners[type].delete(bindUnbind))
listeners[type].add(bindUnbind)
return () => listeners[type].delete(bindUnbind)
}
let next = (type, func) => {
if (!listeners[type]) throw new Error(`event type: "${type}" is unsupported`)
let unbindOnRun = value => (func(value), listeners[type].delete(unbindOnRun))
listeners[type].add(unbindOnRun)
return () => listeners[type].delete(unbindOnRun)
}
return {
every,
next,
input
}
})()
router.every('error', error => console.error(error+'\n'+errors[error]))
router.every('version', line => {
status.version = line.match(/ [\d,\.]+\w+ /gim)?.[0].trim()
gerbil.onMachineReady?.(status)
gerbil.machineReady?.resolve(status)
})
let connect = ttyPath => new Promise(res => {
port = new SerialPort(ttyPath, { baudRate: 115200 }, e => {
if (e) {
handleDisconnect()
if (autoReconnect) {
retry = setTimeout(() => {
connect(ttyPath)
}, autoReconnect)
}
} else {
status.connected = true
status.tty = ttyPath
port.on('close', _ => (handleDisconnect(), connect(ttyPath)))
port.pipe(router.input)
}
})
})
/*
let disconnect = () => new Promise(res => {
clearTimeout(retry)
port.cose(res)
})
*/
connect(ttyPath)
let gerbil = {
/**
* A promise that resolves when the machine is ready, works similarly to
* {@link gerbil.onMachineReady}. If the machine is in a ready state and
* subsequently becomes unready this value is overwritten with a new promise
* that resolves the next time the machine is ready.
* @type {Promise}
*
* @example
* let ready = await gerbil.machineReady
* console.log(ready)
* > {connected: true, version: '1.1h', ttyPath: '/dev/ttyACM0'}
*
* @memberOf gerbil
*/
machineReady: emptyPromise(),
/**
* Set callback to execute when GRBL trasmits it's version string. This
* happens on initial connection or reset. The callback is passed the status
* object.
*
* @example
* gerbil.onMachineReady = console.log
* > {connected: true, version: '1.1h', ttyPath: '/dev/ttyACM0'}
*
* @memberOf gerbil
*/
onMachineReady:undefined,
/** @namespace gerbil.cmds */
cmds: {
/**
* Wraps Grbl's [`$$$$` and `$x=val`]{@link https://github.com/gnea/grbl/wiki/Grbl-v1.1-Configuration#grbl-settings} commands, used to retrieve and configure machine settings.
* Call without any arguments to retrieve current settings as an object. Call with an object in the same format to set settings. See example for more details.
*
* @example
* //set pulse width to 10μs
* gerbil.cmds.settings({'$0': '10'})
*
* //retrieve settings
* gerbil.cmds.settings().then(console.log)
*
* > {
* '$0': '10',
* '$1': '25',
* '$2': '0',
* '$3': '4',
* '$4': '0',
* ...
* }
*
* @func
* @arg {object} [settings]
* @return {promise(object)}
* @memberOf gerbil.cmds
*/
settings,
/**
* Retrieve the current machine status using [`?`]{@link https://github.com/gnea/grbl/wiki/Grbl-v1.1-Interface#real-time-status-reports}.
* Not all values are guaranteed to be present.
*
* @memberOf gerbil.cmds
* @return {promise(object)} current machine status as object
* ```js
* {
* Status: 'Idle',
* MPos: { x: 0, y: 0, z: 0 },
* Buffer: { planner: 15, rx: 128 },
* Speeds: { feed: 0, spindle: 0 }
* }
* ```
* @func
*
*/
machineStatus,
softReset,
feedHold,
resume,
},
/**
* Set callback to execute on every line received from the serialport.
* This is called with the trimmed string (no trailing newline) each time
* Grbl spits out a new line.
*
* @example
* gerbil.onEveryLine = console.log
* > ok
* > ok
*
* @memberOf gerbil
*/
onEveryLine:undefined,
/**
* write a single line to Grbl, this will throw if you try to use it to send
* multiple lines, or a line longer than 128 bytes, returns a Promise that
* resolves to be either a string containing any output Grbl sent between invocation
* and the first `ok` (inclusive). Makes sure that whatever you sent is terminated
* with a newline.
*
* ```
* writeLine('$I').then(console.log)
*
* > [VER:1.1h.20190724:]
* [OPT:VC,15,128]
* ok
* ```
* or an error object looking something like this:
* ```js
* {
* error: "error:2"
* message: "Numeric value format is not valid or missing an expected value."
* }
* ```
*
* @arg {string} message a line to send to Grbl
* @memberOf gerbil
* @return {Promise(string)|Promise(errorObj)} Grbl data until `ok`
* @func
*/
writeLine,
/**
* Write raw data to Grbl, can be a buffer or a string and is not validated
* in any way. This is for internal use and probably shouldn't be used
* externally unless you're just using this and {@link gerbil.onEveryLine}
*
* @arg {string|buffer} data
* @memberOf gerbil
* @return {void}
* @func
*/
writeRaw,
stream,
/**
* @memberOf gerbil
* @returns {object} immediately returns the inner status object
* ```js
* {
* connected: true, //always present, true if serialport link established
* ttyPort: '/dev/ttyACM0', //present if link established
* version: '1.1h', //GRBL version, present once initial handshake received
* }
* ```
* @func
*/
driverStatus : () => ({status})
}
return gerbil
}
export default main