Sunrise Machine with the Tessel 2

Pages
Contributors: lyzadanger
Favorited Favorite 1

Program It

Get the Software

If you have git installed and feel comfortable using that tool, open your terminal and run:

git clone https://github.com/bocoup/j5ik-sunrise-machine;
cd j5ik-sunrise machine;
npm install;

If git is not your thing, you can also download the project as a zip file:

You can always find the most up to date content at in the GitHub repository as well.

Extract the contents however you prefer. Open your terminal, and navigate to the extracted project directory.

Install the Software

First, install some dependencies. In your project directory, run the command:

npm install

Configuration: config.js

The only code changes you'll need to make from the supplied code with the Sunrise Machine are in its configuration. Copy the included config.js.example file to a new file named config.js:

language:javascript
module.exports = {
  lat                : 43.34, // Your latitude (This is Vermont)
  long               : -72.64, // Your longitude (This is Vermont)
  utcOffset          : '-04:00', // Your timezone relative to UTC
  autoSchedule       : 'sunrise', // Any property from `suncalc` or `false` to disable
  postToTwitter      : true, // `true` will create posts of GIFs
  tweetBody          : 'It is a beautiful day!', // Text tweeted with GIFs
  consumer_key       : '', // Twitter
  consumer_secret    : '', // Twitter
  access_token_key   : '', // Twitter
  access_token_secret: '', // Twitter
  captureFrequency   : 30, // in seconds (default one image every 30s)
  captureCount       : 20, // number of stills per montage
  calibrateCamera    : 3, // length of camera calibration (default 3s)
  statusLED          : true, // Enable RGB status LED? false will turn it off
  basedir            : '/mnt/sda1/captures', // where files get stored
  dateFormat         : 'ddd MMM DD, YYYY HH:mm:ss',
};

This configuration object is organized such that the properties you're most likely to change are near the top.

  • You'll want to change, at the very least, the lat and long values to the coordinates of where you are located.
  • To account for the Tessel 2's proclivity for UTC, you can provide a utcOffset value. -04:00 is the offset for daylight savings time on the east coast of the U.S. (EDT)—four hours "behind" UTC. This setting isn't critical—the dates used by the Tessel to figure out to record will still be accurate, but if you want logging and folder names for your images and movies to use your local time zone (i.e. not UTC), change this value.
  • autoSchedule can be set to any of suncalc's sun event names, or can be set to false to disable scheduled recording (you can always trigger manual movies by pressing the Sunrise Machine's pushbutton).
  • If you enable postToTwitter, you'll need to fill in the Twitter API credentials that follow.
  • I encourage you to experiment with the value of calibrateCamera. If you find that your camera is taking great stills without calibration, you can save power and time by disabling calibration (set this property to 0 or false to turn it off).
  • You can disable the statusLED if you like. This can save power and your nerves if you find the light or blinking annoying (or it is reflecting off a window during capture or something).

At this point, your Sunrise Machine is configured and ready. If you're in a hurry, you can skip to the Run It! section.

Exploring the Code

When the Sunrise Machine's code is complete, your project directory will look like this:

language:console
├── av.js
├── config.js
├── node_modules
├── recording.js
├── index.js
└── tweet.js

config.js is the configuration file you just created. node_modules is where the project's dependencies—installed above—live.

The other components of the Sunrise Machine are:

  • tweet.js, which integrates with the Twitter API and can upload animated GIFs
  • recording.js, which provides a class that encapsulates the tasks of a time-lapse recording session (scheduling still capture, kicking off the processing of stills into movies, etc.)
  • av.js, which contains lower-level code to execute the capture and processing of images and video
  • index.js, which is the code that controls the Tessel's inputs and outputs and pulls it all together. This is the script you'll run on your Tessel.

We encourage you to dig into the module files and see how the pieces all fit together, if you're curious!

Capturing and Processing Video with the Tessel 2: av.js

The image capture, video building and animated-GIF creating powers of the Sunrise Machine are significantly influenced by the existing tessel-av module, which is a great starting place for grabbing still images or streaming video. Like tessel-av, the Sunrise Machine's image-capture code takes advantage of the cross-platform ffmpeg software, which is available for you already on your Tessel. No installation or configuration needed!

ffmpeg is a hugely-powerful command-line tool that can encode and decode (and transcode and mux and demux and stream and filter and play and and and) a dizzying array of different kinds of audio and video. Just learning how to put together commands to do simple video capture and storage can take a while (trust me).

Keep in mind that the Tessel 2 is able to do all this despite its mere 64MB of RAM. It's quite a trooper.

av.js spawns ffmpeg processes using Node.js' built-in child_process module. The av module contains an appropriately-titled ffmpeg function. The ffmpeg function spawns an ffmpeg child process with the arguments provided and returns a Promise:

language:javascript
function ffmpeg (opts) {
  opts.unshift('-v', 'fatal'); // Comment this line to see ffmpeg logging
  const ffmpegProcess = cp.spawn('ffmpeg', opts);
  return new Promise((resolve, reject) => {
    ffmpegProcess.on('exit', code => {
      if (code != 0) {
        console.error(code);
        reject(code);
      } else {
        resolve();
      }
    });
    ffmpegProcess.stderr.on('data', data => {
      console.log(data.toString()); // Logging when not suppressed
    });
  });
}

Each of the four exported functions (calibrate, captureStill, videoFromStills and animatedGIFFromVideo) are intended to fulfill one AV task by invoking the ffmpeg function with the needed arguments. These functions also return a Promise. Here's a condensed view of the rest of the av.js module:

language:javascript
// "Calibrate" a camera for `duration` seconds by letting it output to /dev/null
module.exports.calibrate = function (duration) {
  const videoArgs = [ /* ffmpeg arguments needed to calibrate camera */ ];
  return ffmpeg(videoArgs).then(() => '/dev/null');
};

// Capture a single still image at 320x240 from the attached USB camera
module.exports.captureStill = function (filepath) {
  const captureArgs = [ /* ffmpeg arguments needed to capture a still */];
  return ffmpeg(captureArgs).then(() => filepath);
};

// Build an MP4 video from a collection of JPGs indicated by `glob`
module.exports.videoFromStills = function (glob, outfile) {
  const stillArgs = [ /* ffmpeg arguments needed to create a video from stills */];
  return ffmpeg(stillArgs).then(() => outfile);
};

// Create an animated GIF from an MP4 video. First, generate a palette.
module.exports.animatedGIFFromVideo = function (videofile, outfile) {
  const paletteFile = '/tmp/palette.png';
  const paletteArgs = [ /* arguments needed to create a color palette */ ];
  return ffmpeg(paletteArgs).then(() => {
    const gifArgs = [ /* arguments needed to create an animated GIF */ ];
    return ffmpeg(gifArgs).then(() => outfile);
  });
};

Arguments are cobbled together in each function. Here's the entirety of the captureStill function, for instance:

language:javascript
// Capture a single still image at 320x240 from the attached USB camera
module.exports.captureStill = function (filepath) {
  const captureArgs = [
    '-y', // Overwrite
    '-s', '320x240', // maximum resolution the Tessel 2's memory can handle
    '-r', '15', // Framerate
    '-i', '/dev/video0', // Mount location of video camera
    '-q:v', '2', // Quality (1 - 31, 1 is highest)
    '-vframes', '1', // Total number of frames in the "video"
    filepath
  ];
  return ffmpeg(captureArgs).then(() => filepath);
};

Note that the animatedGIFFromVideo function is a two-step process that first creates a palette from a movie and subsequently creates an animated GIF from the movie based on that palette. GIFs are restricted to 256 colors, so using an intelligently-generated palette can result in a higher-quality GIF.

Tweeting Animated GIFS: tweet.js

tweet.js uses the twitter npm package to communicate with the Twitter API, making the several API calls necessary to upload an animated GIF. Once the GIF is uploaded, it posts a tweet containing the GIF. It exports a function, tweetGIFindex.js can call this function to tweet a GIF.

Taking Care of Details: recording.js

The JavaScript class exported by recording.js, Recording, encapsulates some of the tasks of managing a time-lapse recording session, like naming of files and managing timeouts between captures. A Recording instance can be started with its start method and canceled with its cancel method. The Recording handles the rest, invoking specific functions in the av module to accomplish tasks like calibration, capturing stills and building movies and GIFs.

Where it all Happens: index.js

We've met the supporting cast. Now let's walk through, in entirety, index.js, which is the script you'll run directly on your Tessel to make the Sunrise Machine go! Here it is, with lots of comments.

language:javascript
/* require external dependencies */
const five      = require('johnny-five'); // Johnny-Five!
const Tessel    = require('tessel-io'); // J5 I/O plugin for Tessel boards
const suncalc   = require('suncalc'); // For calculating sunrise/set, etc., times
const Moment    = require('moment'); // For wrangling Dates because otherwise ugh
// Require other modules from sunriseMachine's code:
const config    = require('./config');
const Recording = require('./recording');
const tweetGIF  = require('./tweet');

// Instantiate a new `Board` object for the Tessel
const board = new five.Board({
  io: new Tessel() // Use the Tessel-IO plugin
});

// Instantiate some variables for later
var currentRecording, scheduled;

board.on('ready', () => { // Here we go. The board is ready. Let's go!
  // Instantiate J5 component objects: a pushbutton and RGB status LED
  const button    = new five.Button('A2');
  const statusLED = new five.Led.RGB(['A5', 'A6', 'B5']);
  // When the button is `press`ed, invoke the `toggle` function
  button.on('press', toggle);
  // Put the sunrise machine in standby mode
  standBy();

  // Put the sunrise machine in standby. Schedule the next recording if needed
  function standBy () {
    currentRecording      = null;       // There is no active recording. Explicitly.
    const scheduleDetails = schedule(); // See if there is a recording to schedule
    // If there is a scheduled recording, `scheduled` will be a Node Timeout object
    // if `scheduled` is falsey, there is nothing scheduled, so put the SM in regular standby
    const nextStatus      = (scheduled) ? 'scheduled' : 'standby';
    // Set the sunrise machine's status and log any message returned by
    // the schedule function
    setStatus(nextStatus, scheduleDetails);
  }

  // button `press` callback. Start a manual recording, or cancel an in-progress recording
  function toggle () {
    if (currentRecording) { // if currentRecording is truthy, there is an in-progress recording
      currentRecording.cancel(); // so, cancel it
    } else { // otherwise, start a new recording
      record('Manual recording');
    }
  }

  function schedule () { // Schedule to record next autoSchedule event
    if (!config.autoSchedule) { // auto-scheduling is disabled in config
      return 'Nothing to schedule! Sunrise machine in manual mode';
    }
    const eventName    = config.autoSchedule;
    const now          = Moment();
    // Using noon explicitly when querying about sun events adds a level
    // of safety; using times near the start or end of the day can kick up
    // some unexpected results sometimes.
    const noonToday    = Moment().hour(12).minute(0);
    const noonTomorrow = Moment().add(1, 'days').hour(12).minute(0);

    // Ask `suncalc` for the times of various sun events today, at the
    // lat/long defined in the config
    var sunEvents = suncalc.getTimes(noonToday.valueOf(),
      config.lat, config.long);
    var eventDate, delta;

    if (!sunEvents.hasOwnProperty(eventName)) { // Invalid value for config.autoSchedule
      return `Not scheduling: ${eventName} is not a known suncalc event`;
    }

    if (sunEvents[eventName].getTime() < now.valueOf()) {
      // The event has already happened for today. Check when tomorrow's is...
      sunEvents = suncalc.getTimes(noonTomorrow.valueOf(),
        config.lat, config.long);
    }

    // Using the config.utcOffset value to bring the date into local timezone
    // Note that the actual date underneath is not changing, just its representation
    eventDate = Moment(sunEvents[eventName]).utcOffset(config.utcOffset);
    // How long is the event from now, in milliseconds?
    delta = eventDate - now;
    if (!scheduled) { // Don't reschedule if already scheduled
      // Schedule the recording by setting a timeout for the number of milliseconds
      // until the event
      scheduled = setTimeout(() => {
        record(`Automatic ${eventName} recording`); // Kick off a recording
        scheduled = null; // Unset scheduled because we're done here
      }, delta);
    }
    return `Scheduled for ${eventName}: ${eventDate.format(config.dateFormat)}`;
  }

  // Kick off a time-lapse recording!
  function record (name) {
    if (currentRecording) {
      log('Another recording session already in progress');
      return false;
    }
    // Create a new Recording object to do some heavy lifting for us
    const recording = new Recording(name, config);
    // Bind to a bunch of events on the Recording. Several of these update
    // the SM's status and/or log a message:
    recording.once('start', () => {
      setStatus('recording', `${recording.name} starting`);
    });
    recording.on('calibrate', () => setStatus('calibrating'));
    recording.on('capture:start', () => setStatus('capturing'));
    recording.once('cancel', () => log(`${recording.name} canceled`));

    // When one of the stills in a session is successfully captured, take note!
    recording.on('capture:done',  (filepath, images, totalnum) => {
      setStatus('recording',
        `Oh, snap! Captured ${images.length} of ${totalnum}`);
    });
    // When the stills are captured and the movies made, Tweet the result
    // if config indicates it should be done
    recording.once('done', videoFile => {
      if (config.postToTwitter) {
        tweetGIF(config, videoFile, config.tweetBody);
      }
      log(`${recording.name} complete`);
    });
    // Once the Recording exits, put the SM back in standby (this will
    // cause the next recording to get scheduled, if needed)
    recording.once('exit', standBy);

    // Start the recording!
    recording.start();

    // Reference this Recording as the SM's currentRecording
    currentRecording = recording;
    return recording;
  }

  // Indicate the sunrise machine's "status" by changing the state of the
  // RGB LED and optionally logging a message
  function setStatus (status, msg) {
    if (!config.statusLED) return; // Leave it alone if it's not enabled
    statusLED.stop().on(); // Stop any blinking that may be going on
    switch (status) {
      case 'standby':
        statusLED.color('blue');
        break;
      case 'scheduled':
        statusLED.color('green');
        break;
      case 'recording':
        statusLED.color('yellow');
        break;
      case 'calibrating':
        statusLED.color('orange').blink(500);
        break;
      case 'capturing':
        statusLED.color('red').blink(250);
        break;
      default:
        // Hmmm, I don't understand this status :)
        statusLED.color('purple').blink(1000);
        break;
    }
    if (msg) {
      log(msg);
    }
  }

  // This log function could be altered to log to a file, e.g.
  function log (msg) {
    const now = Moment().utcOffset(config.utcOffset).format(config.dateFormat);
    console.log(`${now}:
      ${msg}`);
  }
});