Sunrise Machine with the Tessel 2
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
andlong
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 tofalse
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 to0
orfalse
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 GIFsrecording.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 videoindex.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, tweetGIF
— index.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}`);
}
});