main.js

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