Source: ledcontroller.js

/**
 * Dreamscapes\ledctl
 *
 * Licensed under the BSD-3-Clause license
 * For full copyright and license information, please see the LICENSE file
 *
 * @author     Robert Rossmann <rr.rossmann@me.com>
 * @copyright  2014 Robert Rossmann
 * @link       https://github.com/Dreamscapes/ledctl
 * @license    http://choosealicense.com/licenses/BSD-3-Clause  BSD-3-Clause License
 */

'use strict'

/** @private */
var _ = require('lodash')
  , fs = require('fs')
  , path = require('path')
  , util = require('util')
  , async = require('async')
  , morse = require('./serialisers/morse')
  , fmt = util.format   // shortcut...
  // Every time we run .discover(), the results for given ROOT are cached here
  // This improved test runs from 42ms to 34ms with 19 tests - I'd say that's worth it, given the
  // expected massive load this library will have to withstand...
  , discoveredLEDs = {}

module.exports = LEDController


/**
 * @summary   All the fun begins by creating an instance of this class
 *
 * @class
 * @param     {String}    identifier  The LED identifier - one of the values returned by
 *                                    {@link LEDController.discover}. If there is only one LED
 *                                    available, the identifier is optional.
 */
function LEDController (identifier) {

  var availableLEDs = LEDController.discover()
  // If no identifier specified but there is only a single LED available, control that one
  if (! identifier && availableLEDs.length === 1) identifier = availableLEDs[0]

  // scope
  var that = this
  // Full fs path to the LED's directory
    , location = path.join(LEDController.ROOT, identifier || '')
  // Helper function... I will use this a LOT
    , define = Object.defineProperty.bind(null, this)
  // Brightness and triggers will be attached to current instance down the constructor
    , brightness
    , triggers

  // Required (and required-to-be-valid) argument
  // Also takes care of identifier being undefined
  if (! _(availableLEDs).contains(identifier))
    throw new Error(fmt('No such LED: \'%s\' in %s', identifier, LEDController.ROOT))

  // brightness information (will be attached to `this`)
  brightness =
  { min: 0
  , max: parseInt(contentOf(location, 'max_brightness'))
  }
  // brightness.cur
  Object.defineProperty(brightness, 'cur',
  { enumerable: true
  , get: function getBrightness () {
      return parseInt(contentOf(that.location, 'brightness'))
    }
  })

  // triggers information (will be attached to `this`)
  triggers =
  { all: getTriggerData(location).all
  }
  // triggers.cur
  Object.defineProperty(triggers, 'cur',
  { enumerable: true
  , get: function getTrigger () {
      return getTriggerData(that.location).cur
    }
  })


  /**
   * @summary   The LED's identifier, as given to the constructor
   *
   * @desc      Just a helper for you, dear developer...
   *
   * @readonly
   * @member    {String}    LEDController#id
   */
  define('id', { enumerable: true, value: identifier })

  /**
   * @summary   The current LED's full filesystem location
   *
   * @readonly
   * @member    {String}    LEDController#location
   */
  define('location', { enumerable: true, value: location })

  /**
   * @summary   The LED's brightness information
   *
   * @readonly
   * @member    {Object}    LEDController#brightness
   * @property  {integer}   min         Minimum brightness level allowed
   * @property  {integer}   max         Maximum brightness level allowed
   * @property  {integer}   cur         Current brightness level (sync getter)
   */
  define('brightness', { enumerable: true, value: Object.freeze(brightness) })

  /**
   * @summary   The LED's supported triggers and currently enabled trigger
   *
   * @readonly
   * @member    {Object}    LEDController#triggers
   * @property  {Array}     all         List of supported triggers (strings)
   * @property  {String}    cur         Currently selected trigger (sync getter)
   */
  define('triggers', { enumerable: true , value: Object.freeze(triggers) })

  /**
   * @summary   A worker queue that makes sure we write the events to the filesystem in the intended
   *            order
   *
   * @private
   * @member    {Object}    LEDController#writer
   * @see       {@link https://github.com/caolan/async#queueworker-concurrency Async / Queue}
   */
  define('writer', { value: async.queue(writeWorker.bind(this), 1) })

  /**
   * @summary   A worker queue that handles blinking operations and keeps the events ordered in the
   *            asynchronous chaos of Node
   *
   * @private
   * @member    {Object}    LEDController#blinker
   * @see       {@link https://github.com/caolan/async#queueworker-concurrency Async / Queue}
   */
  define('blinker', { value: async.queue(blinkWorker.bind(this), 1) })
}


/**
 * @summary   Root path where LEDController should look for LEDs
 *
 * @desc      Feel free to override if your OS puts LED definitions somewhere else.
 *
 * @type      {String}
 * @default   /sys/class/leds/
 */
LEDController.ROOT = '/sys/class/leds/'

/**
 * @summary   Base rate at which the LED will blink
 *
 * @desc      The rate controls how fast the LED will blink. If the base blink event duration is one
 *            second, and the rate is 2, then the actual duration of the blink event will be
 *            calculated as duration divided by rate, i.e. 1s / 2 => 0.5s. The rate gives you an
 *            opportunity to speed up or slow down blink rate without modifying any of your code.
 *
 * @type      {Number}
 * @default   1
 */
LEDController.RATE = 1


/**
 * @summary   Discover available LEDs on current system
 *
 * @desc      This sync function may be quite slow depending on the number of files and folders
 *            within {@link LEDController.ROOT}.
 *
 * @return    {Array}                 List of LED IDs willing to bend to your will
 */
LEDController.discover = function discover () {

  var root = LEDController.ROOT   // Shortcut
    , candidates = fs.existsSync(root)  ? fs.readdirSync(root)
                                        : []

  // Results already in cache?
  if (discoveredLEDs[root])
    return discoveredLEDs[root]   // No need to access fs again!

  // Do a best effort guess if the folders within root really look like LEDs and ignore the rest

  // Get only directories from current folder
  candidates = candidates.filter(function (candidate) {
    return fs.statSync(path.join(root, candidate)).isDirectory()
  })

  // Get only those directories which contain file 'trigger' and 'brightness'
  candidates = candidates.filter(function (candidate) {
    var folder = fs.readdirSync(path.join(root, candidate))

    return  _(folder).contains('trigger') &&    // I promised myslef I will not do this...
            _(folder).contains('brightness')    // but they align so perfectly!
  })

  // Cache the results for later use to speed things up
  discoveredLEDs[root] = Object.freeze(candidates)    // Into the ice with ya!

  return candidates
}

/**
 * @summary   A custom blink parser for you to implement
 *
 * @typedef   {Function}    LEDController.Parser
 * @param     {mixed}       input     Whatever you would like to convert into blink events
 * @return    {Array}       An array of {@link LEDController.Blink Blink} objects which represent
 *                          the sequence in which the LED should blink
 *
 * @see       {@link LEDController#morse Example parser implementation}
 * @see       serialisers/morse.js
 */

/**
 * @summary   Make your own blinker!
 *
 * @param     {String}                parser    The name of the parser - method of this name will be
 *                                              added to {@link LEDController LEDController's}
 *                                              prototype
 * @param     {LEDController.Parser}  handler   The actual parser function
 */
LEDController.register = function register (parser, handler) {

  // Make sure handler is really a function
  if (typeof handler !== 'function')
    throw new Error(fmt('Handler must be a function, %s given', typeof handler))

  // Make sure this function is not already on the prototype
  if (LEDController.prototype[parser])
    throw new Error(fmt('Property/function: \'%s\' already defined on prototype', parser))

  LEDController.prototype[parser] = function customParser (input, done) {

    var that = this   // scope
      , emptyCallback = normaliseCallback()   // This will be fun!
      , data

    try {
      // Summon the handler to do the master's bidding
      data = handler(input)
    } catch (e) {
      // If we have a callback, we should always call it in next tick
      if (typeof done === 'function')
        return setImmediate(done, e)

      // No callback -> no mercy!
      throw e
    }

    // Normalise
    data = data instanceof Array  ? data
                                  : [data]

    // Push the data to the blinker queue
    data.forEach(function (blink, i) {
      if (data[i + 1]) {
        that.blinker.push(blink, emptyCallback)   // Let's leave the callback empty!
      } else {
        that.blinker.push(blink, done)    // Call the callback only at the very end
      }
    })

    return this
  }
}


/**
 * @summary   Set the LED's trigger
 *
 * @param     {String}      value     The trigger to be used, as available in
 *                                    {@link LEDController#triggers|triggers' all property}
 * @param     {Function}    done      Optional callback (if omitted, it will throw on error)
 * @return    {this}
 */
LEDController.prototype.trigger = function trigger (value, done) {

  var that = this   // scope
  done = normaliseCallback(done)    // Normalise because we might need to call it from here

  // Is this trigger supported in the LED?
  if (! _(this.triggers.all).contains(value)) {
    // It's not! You will witness your mistake... in the next event loop!
    setImmediate(done, new Error(fmt('Unsupported trigger: \'%s\' for LED %s', value, that.id)))
  } else {
    // Good to go - let's write the trigger to filesystem
    this.writer.push({ to: 'trigger', data: value }, done)
  }

  // Always return even if there's error above so that we do not mask the real error by killing the
  // process with "unable to read property of undefined"
  return this
}

/**
 * @summary   Set the LED's brightness to desired value
 *
 * @desc      The integer value must be from the supported range (min <= value <= max).
 *
 * @param     {Number}      value     The new brightness to be set
 * @param     {Function}    done      Optional callback (if omitted, it will throw on error)
 * @return    {this}
 */
LEDController.prototype.setBrightness = function setBrightness (value, done) {

  // Normalise value - ensure value is an integer and within acceptable range
  value = parseInt(value)
  value = value <= this.brightness.min  ? this.brightness.min
                                        : Math.min(value, this.brightness.max)

  this.writer.push({ to: 'brightness', data: value }, done)

  return this
}

/**
 * @summary   Conjure photons (Turn the LED on)
 *
 * @param     {Function}    done      Optional callback (if omitted, it will throw on error)
 * @return    {this}
 */
LEDController.prototype.turnOn = function turnOn (done) {

  this.setBrightness(this.brightness.max, done)

  return this
}

/**
 * @summary   Stop conjuring photons (Turn the LED off)
 *
 * @param     {Function}    done      Optional callback (if omitted, it will throw on error)
 * @return    {this}
 */
LEDController.prototype.turnOff = function turnOff (done) {

  this.setBrightness(this.brightness.min, done)

  return this
}

/**
 * @summary   A Blink describes a blink event
 *
 * @typedef   {Object}      LEDController.Blink
 * @property  {Number}      rate      Override {@link LEDController.RATE default rate} of blinking
 * @property  {Number}      for       For how much percent of Blink.of should the LED be turned on?
 * @property  {Number}      of        What is the total duration of the blink (on + off time)
 */

/**
 * @summary   Blink the LED!
 *
 * @param     {LEDController.Blink}   opts      Provide some info about how the LED should blink
 * @param     {Function}              done      Optional callback
 *                                              (if omitted, it will throw on error)
 * @return    {this}
 */
LEDController.prototype.blink = function blink (opts, done) {

  this.blinker.push(opts, done)

  return this
}

/**
 * @summary   Remove any blink events possibly scheduled and turn the LED off
 *
 * @param     {Function}    done      Optional callback (if omitted, it will throw on error)
 * @return    {this}
 */
LEDController.prototype.reset = function reset (done) {

  this.blinker.kill()
  this.writer.kill()
  this.turnOff(done)

  return this
}

/**
 * @summary   Override default .toString() function to return proper class name
 *
 * @return    {String}      If not called from a subclass, returns [object LEDController]
 */
LEDController.prototype.toString = function toString () {

  return fmt('[object %s]', this.constructor.name)
}

/**
 * @summary   Override default .valueOf() to return current brightness
 *
 * @desc      This implementation is currently experimental and feedback on its usability is very
 *            welcome. It does, however, kind of make sense - a "value" of a LED could only be
 *            represented by either its colour or brightness (or a combination thereof). And colour
 *            we do not support.
 *            **Note: this function blocks the proces while reading current brightness.**
 *
 * @return    {Number}      LED's current brigthness
 */
LEDController.prototype.valueOf = function valueOf () {

  return this.brightness.cur
}


/**
 * @summary   Turn regular text into morse code emitted from your LED
 *
 * @desc      This method is implemented using the {@link LEDController.Parser Parser} and attached
 *            to LEDController's prototype using {@link LEDController.register}.
 *
 * @method    LEDController#morse
 * @param     {String}      input     The text to be morse-coded
 * @param     {Function}    done      Optional callback called at the end of sequence
 * @return    {this}
 */
LEDController.register('morse', morse)


// Helper functions

/**
 * @summary   Read the content of a file within base, sync
 *
 * @private
 * @param     {String}      base      The base path where to look for file
 * @param     {String}      file      File path relative to base
 * @return    {String}      The file's contents as UTF-8, trimmed
 */
function contentOf (base, file) {

  return fs.readFileSync(path.join(base, file), 'utf8').trim()
}

/**
 * @summary   Read the content of the /trigger file for a particular LED and parse trigger
 *            information (available triggers & currently selected trigger)
 *
 * @private
 * @param     {String}      location  LED's location on the filesystem
 * @return    {Object}      The trigger info for this LED (`{ all: Array, cur: String }`)
 */
function getTriggerData (location) {

  // Get available triggers for this LED
  var triggers =
  { all: contentOf(location, 'trigger').split(' ')
  , cur: undefined    // In a moment...
  }

  // Find currently set trigger (looks like [this] <- note the brackets)
  // Note for my future self - You decided to use for instead of .forEach because you can break out
  // of the loop once you have what you need.
  for (var i in triggers.all) {
    if (triggers.all[i][0] === '[') {   // Gotcha!
      triggers.cur = triggers.all[i].replace(/[\[\]]/g, '')   // Remove the surrounding []
      triggers.all[i] = triggers.cur    // Also remove the [] from the list of all triggers
      break   // Nothing more to do, GTFO
    }
  }

  return triggers
}

/**
 * @summary   Normalise an optional callback into actual function
 *
 * @desc      Some LEDController's methods have their callbacks optional. To ensure we actually have
 *            something we can call we pass the optional callback into this function which checks
 *            if the handler is a function and if it's not, it will provide us with a generic error
 *            handler (which throws on error, in an async call. Yeah, what better to do.)
 *
 * @private
 * @param     {mixed}       handler   The original function, or undefined. Or null. Or whatever.
 * @return    {Function}    A function that can be called with optional error object
 */
function normaliseCallback (handler) {

  if (typeof handler === 'function')
    return handler

  return function unforgivingCallback (err) {

    if (err)
      throw err   // Got error! What do we do? We panic!
  }
}

/**
 * @summary   Worker function used to control the blinking of the LED
 *
 * @private
 * @param     {LEDController.Blink}   opts    The object describing the blink event
 * @param     {Function}              done    Optional callback (if omitted, it will throw on error)
 */
function blinkWorker (opts, done) {
  /* jshint validthis:true */   // We are binding this function to current instance in constructor

  opts = opts || {}
  done = normaliseCallback(done)

  var that = this   // scope
    // Some scientific calculations follow...
    , rate = opts.rate || LEDController.RATE
    , coef = opts.for >= 0 ? opts.for : 50
    , of = (opts.of >= 0 ? opts.of : 1000) / rate
    // Translate percentages to actual times
    , timeOn = of / 100 * coef
    , timeOff = of - timeOn

  function nextStep (next, time) {

    return function nextStepHandler (err) {

      if (err)
        return next(err)

      setTimeout(next, time)
    }
  }

  async.series(
    // Turn the LED on for timeOn miliseconds
    { on: function blinkOn (next) {

        // Special case: timeOn === 0
        if (timeOn === 0)
          // Do not even turn the led on
          return next()

        that.turnOn(nextStep(next, timeOn))
      }

    // Turn the LED off for timeOff miliseconds
    , off: function blinkOff (next) {

        that.turnOff(nextStep(next, timeOff))
      }
    }
  , done
  )
}

/**
 * @summary   Ensures that write operations to fs take place in the intended order
 *
 * @private
 * @param     {Object}      opts      Should contain enough information to perform a file write
 * @param     {String}      opts.to   Target file to be written to
 * @param     {mixed}       opts.data Data to be written to the file
 * @param     {Function}    done      Called when the operation has been completed
 */
function writeWorker (opts, done) {
  /* jshint validthis:true */   // We are binding this function to current instance in constructor

  fs.writeFile(path.join(this.location, opts.to), opts.data, 'utf8', normaliseCallback(done))
}