sphero-pwn
Version:
Driver for Sphero robots
534 lines (484 loc) • 18.8 kB
text/coffeescript
Command = require './command.coffee'
Session = require './session.coffee'
EventEmitter = require 'events'
# High-level API for commanding a robot.
class Robot extends EventEmitter
# Creates a high-level wrapper for a communication channel to a robot.
#
# @param {Channel} channel the communication channel to a robot; the same
# channel should not be used to construct two instances of this class
constructor: (channel) ->
@_channel = channel
@_session = new Session channel
@_session.onAsync = @_onAsync.bind(@)
@_session.onError = (error) => @emit 'error', error
# Closes the underlying communication channel with the robot.
#
# @return {Promise<Boolean>} resolved when the communication channel is fully
# closed
close: ->
@_session.close()
# Returns the underlying communication channel with the robot.
#
# @return {Channel} the underlying communication channel to the robot
channel: ->
@_channel
# Pings the robot, to test that the communication channel works.
#
# @return {Promise<Boolean>} resolved with true when the robot responds to
# the ping
ping: ->
command = new Command 0x00, 0x01, 0
@_session.sendCommand(command).then (response) ->
true
# Returns the versions of the components in the robot's software stack.
#
# @return {Promise<Object>} resolved with the versions in the robot's
# software stack
getVersions: ->
command = new Command 0x00, 0x02, 0
@_session.sendCommand(command).then (response) ->
Robot._versionsFromData response.data
# Parses software stack versions from an API response.
#
# @param {Buffer} data the data field in the API response
# @return {Object} parsed version numbers
@_versionsFromData: (data) ->
responseVersion = data[0]
versions = {}
parseNibbles = (byte) ->
major: (byte >> 4), minor: (byte & 0x0F)
if responseVersion >= 1
versions.model = data[1]
versions.hardware = data[2]
versions.spheroApp =
version: data[3]
revision: data[4]
versions.bootloader = parseNibbles data[5]
versions.basic = parseNibbles data[6]
versions.macros = parseNibbles data[7]
if responseVersion >= 2
versions.api =
major: data[8]
minor: data[9]
versions
# Sets the robot's name, as seen by other applications.
#
# @param {String} name the robot name
# @return {Promise<Boolean>} resolved with true when the robot responds to
# the ping
setDeviceName: (name) ->
if name.length > 16
error = new Error "Name too long; #{name.length} characters exceeds" +
'16-character limit'
return Promise.reject(error)
command = new Command 0x00, 0x10, name.length
command.setDataString 0, name
@_session.sendCommand(command).then (response) ->
true
# Retrieves the robot's name and Bluetooth identification info.
#
# @return {Promise<Object>} resolved with an object representing the
# Bluetooth info; the object has properties 'name', 'mac', and 'colors'
getBluetoothInfo: ->
command = new Command 0x00, 0x11, 0
@_session.sendCommand(command).then (response) ->
Robot._bluetoothInfoFromData response.data
# Parses Bluetooth information from an API response.
#
# @param {Buffer} data the data field in the API response
# @return {Object} parsed Bluetooth information
@_bluetoothInfoFromData: (data) ->
name = data.toString('utf8', 0, 16).replace(/\u0000*$/, '')
mac = data.toString 'utf8', 16, 28
colors = for i in [0...3]
@_colorNameFromCode data.readUInt8(29 + i)
{ name: name, mac: mac, colors: colors }
# Converts a Sphero API color code to a name.
#
# @param {Number} code the Sphero API color code
# @return {String} the name of the color; 'invalid' if the code does not
# represent a known color code
@_colorNameFromCode: (code) ->
return 'invalid' if code < 0 || code > 7
@_colorCodes[code]
# @return {Array<String>} color names for the Sphero API color codes
@_colorCodes =
['invalid', 'red', 'green', 'blue', 'orange', 'purple', 'white', 'yellow']
# Obtains the robot's hackability.
#
# @return {Promise<String>} resolved with a string describing the device's
# mode; the string will either be 'normal' or 'hack'
getDeviceMode: ->
command = new Command 0x02, 0x44, 0
@_session.sendCommand(command).then (response) ->
Robot._deviceModeFromCode response.data[0]
# Sets the robot's hackability.
#
# @param {String} mode either 'normal' or 'hack'
# @return {Promise<Boolean>} resolved with true when the command completes
setDeviceMode: (mode) ->
command = new Command 0x02, 0x42, 1
command.setDataUint8 0, Robot._deviceModeCode(mode)
@_session.sendCommand(command).then (response) ->
true
# Converts a user-friendly device mode string into a code for the Sphero API.
#
# @param {String} mode either 'normal' or 'hack'
# @return {Integer} the Sphero API code for the given device mode
@_deviceModeCode: (mode) ->
switch mode
when 'normal'
0
when 'hack'
1
else
mode
# Coverts a Sphero API device mode code into a user-friendly string.
#
# @param {Integer} modeCode the Sphero API code for the given device mode
# @return {String} either 'normal' or 'hack'
@_deviceModeFromCode: (modeCode) ->
switch modeCode
when 0
'normal'
when 1
'hack'
else
modeCode
# Obtains the robot's configuration flags that persist across power cycles.
#
# @return {Promise<Object>} resolved with a JSON-serializable object with one
# boolean value per flag
getPermanentFlags: ->
command = new Command 0x02, 0x36, 0
@_session.sendCommand(command).then (response) ->
Robot._permanentFlagsFromCode response.data.readUInt32BE(0)
# Sets the robot's flags that persist across power cycles.
#
# @param {Object<String, Boolean>} flags a JSON-serializable object with one
# boolean value per flag
# @option flags {Boolean} noSleepWhileCharging
# @option flags {Boolean} vectorDrive
# @option flags {Boolean} noLevelingWhileCharging
# @option flags {Boolean} tailLedAlwaysOn
# @option flags {Boolean} motionTimeouts
# @option flags {Boolean} demoMode
# @option flags {Boolean} lightDoubleTap
# @option flags {Boolean} heavyDoubleTap
# @option flags {Boolean} gyroMaxAsync
# @return {Promise<Boolean>} resolved with true when the command completes
setPermanentFlags: (flags) ->
command = new Command 0x02, 0x35, 4
command.setDataUint32 0, Robot._permanentFlagsCode(flags)
@_session.sendCommand(command).then (response) ->
true
# Converts a developer-friendly map of permanent flags to a Sphero API code.
#
# @param {Object<String, Boolean>} flags a JSON-serializable object with one
# boolean value per flag
# @return {Number} a 32-bit integer containing the permanent flags
@_permanentFlagsCode: (flags) ->
code = 0
for own name, value of flags
unless mask = @_permanentFlagMasks[name]
throw new Error("Unknown flag #{name}")
code |= mask if value
code
# Converts a Sphero API permanent flags value into a developer-friendly map.
#
# @param {Number} flagsCode a 32-bit integer containing the permanent flags
# @return {Object<String, Boolean>} a JSON-serializable object with one
# boolean value per flag
@_permanentFlagsFromCode: (flagsCode) ->
flags = {}
for own name, mask of @_permanentFlagMasks
if (flagsCode & mask) isnt 0
flags[name] = true
flagsCode ^= mask
else
flags[name] = false
unless flagsCode is 0
throw new Error("Unknown flag bits #{flagsCode}")
flags
# @return {Object<String, Number>} maps developer-friendly permanent flag
# names to their bit masks
@_permanentFlagMasks =
noSleepWhileCharging: 0x01
vectorDrive: 0x02
noLevelingWhileCharging: 0x04
tailLedAlwaysOn: 0x08
motionTimeouts: 0x10
demoMode: 0x20
lightDoubleTap: 0x40
heavyDoubleTap: 0x80
gyroMaxAsync: 0x100
# Sets the power level of the robot's back LED.
#
# @param {Number} powerLevel the power level of the robot's back LED; 0 means
# that the LED is turned off, and 255 sets the LED at the maximum
# brightness
# @return {Promise<Boolean>} resolved with true when the command completes
setBackLed: (powerLevel) ->
command = new Command 0x02, 0x21, 1
command.setDataUint8 0, powerLevel
@_session.sendCommand(command).then (response) ->
true
# Obtains the color of the robot's RGB LED.
#
# @return {Promise<Object>} resolved with a value between 0 and 255
# indicating the power level of the robot's back LED
getUserRgbLed: ->
command = new Command 0x02, 0x22, 0
@_session.sendCommand(command).then (response) ->
{
red: response.data[0], green: response.data[1],
blue: response.data[2]
}
# Sets the color of the robot's RGB LED.
#
# @param {Object} rgb the RGB components that make up the RGB LED color
# @option rgb {Number} red 0-255
# @option rgb {Number} green 0-255
# @option rgb {Number} blue 0-255
# @return {Promise<Boolean>} resolved with true when the command completes
setUserRgbLed: (rgb) ->
command = new Command 0x02, 0x20, 4
command.setDataUint8 0, rgb.red
command.setDataUint8 1, rgb.green
command.setDataUint8 2, rgb.blue
command.setDataUint8 3, 1
@_session.sendCommand(command).then (response) ->
true
# Reinitializes the macro executive.
#
# This aborts the currently running macro and removes all user macros from
# memory.
#
# @return {Promise<Boolean>} resolved with true when the command completes
resetMacros: ->
command = new Command 0x02, 0x54, 0
@_session.sendCommand(command).then (response) ->
true
# Obtains information about the currently running macro.
#
# @return {Promise<Object?>} resolved with information about the currently
# running macro; the object has two keys, 'macroId' and 'commandId'; the
# object can be null if no macro is running
getMacroStatus: ->
command = new Command 0x02, 0x56, 0
@_session.sendCommand(command).then (response) ->
Robot._macroStatusFromData response.data
# Converts a Sphero API response to developer-friendly macro status data.
#
# @param {Buffer} data the Sphero API response
# @return {Object?<String, Number>} object with two keys, 'macroId' and
# 'commandId'
@_macroStatusFromData: (data) ->
macroId = data[0]
commandId = data.readUInt16BE 1
if macroId is 0
null
else
if macroId < 32
type = 'system'
else if macroId is 0xFE
type = 'streaming'
else if macroId is 0xFF
type = 'temporary'
else
type = 'user'
{ macroId: macroId, commandId: commandId, type: type }
# Stops the currently running macro.
#
# @return {Promise<Object?>} resolves to information about the aborted macro;
# the object has two keys, 'macroId' and 'commandId'
abortMacro: ->
command = new Command 0x02, 0x55, 0
@_session.sendCommand(command).then (response) ->
status = Robot._macroStatusFromData response.data
return status if status is null
if status.commandId is 0xFFFF
# TODO(pwnall): come up with a better way to point out system macros
status.aborted = false
else
status.aborted = true
status
# Stores a macro in the robot's memory.
#
# @param {Number} macroId the macro's ID number; 255 is the temporary macro,
# 254 is the streaming macro, and 0-31 are system macros
# @param {Buffer} macroBytes the compiled macro's contents
# @return {Promise<Boolean>} resolves to true when the command completes
loadMacro: (macroId, macroBytes) ->
if macroBytes.length <= 253
return @_saveMacro macroId, macroBytes
if macroId isnt 0xFF
error = new Error "Macro length #{macroBytes.length} exceeds maximum " +
'of 253 bytes; only the temporary macro be longer'
return Promise.reject(error)
offset = 0
loadNextFragment = =>
length = macroBytes.length - offset
length = 253 if length > 253
if length is 0
return Promise.resolve true
@_appendMacroFragment(offset is 0,
macroBytes.slice(offset, offset + length))
.then ->
offset += length
loadNextFragment()
loadNextFragment()
# Stores a macro in the robot's memory.
#
# This only works for macros that have at most 253 bytes. {Robot#loadMacro}
# handles all the cases correctly.
#
# @param {Number} macroId the macro's ID number; 255 is the temporary macro,
# 254 is the streaming macro, and 0-31 are system macros
# @param {Buffer} macroBytes the compiled macro's contents
# @return {Promise<Boolean>} resolves to true when the command completes
_saveMacro: (macroId, macroBytes) ->
if macroBytes.length > 253
error = new Error(
"Macro length #{macroBytes.length} exceeds maximum of 253 bytes")
return Promise.reject(error)
if macroId is 0xFF
commandId = 0x51 # Save temporary macro.
else
commandId = 0x52 # Save macro.
command = new Command 0x02, commandId, 1 + macroBytes.length
command.setDataUint8 0, macroId
command.setDataBytes 1, macroBytes
@_session.sendCommand(command).then (response) ->
true
# Appends a fragment of a macro to the robot's temporary storage.
#
# @param {Boolean} firstFragment true if this is the first appended fragment,
# false otherwise
# @param {Buffer} macroBytes the compiled macro's contents
# @return {Promise<Boolean>} resolves to true when the command completes
_appendMacroFragment: (firstFragment, fragmentBytes) ->
if fragmentBytes.length > 254
error = new Error "Macro fragment length #{fragmentBytes.length} " +
'exceeds maximum of 254 bytes'
return Promise.reject(error)
if firstFragment is true
command = new Command 0x02, 0x58, 1 + fragmentBytes.length
command.setDataUint8 0, 0xFF
command.setDataBytes 1, fragmentBytes
else
command = new Command 0x02, 0x58, fragmentBytes.length
command.setDataBytes 0, fragmentBytes
@_session.sendCommand(command).then (response) ->
true
# Runs a macro.
#
# @param {Number} macroId the macro's ID number; 0-31 are system macros,
# 32-253 are user-persistent macros, 254 is the streaming macro, and 255
# is the temporary macro
# @return {Promise<Boolean>} resolves to true when the macro has been queued
# for execution
runMacro: (macroId) ->
command = new Command 0x02, 0x50, 1
command.setDataUint8 0, macroId
@_session.sendCommand(command).then (response) ->
true
# Aborts the currently running orbBasic program.
#
# @return {Promise<Boolean>} resolved with true when the command completes
abortBasic: ->
command = new Command 0x02, 0x63, 0
@_session.sendCommand(command).then (response) ->
true
# Executes the orbBasic program in a storage area.
#
# @param {String} area the area storing the program; 'ram' or 'flash'
# @param {Number} startLine the line number where the execution should start
# @return {Promise<Boolean>} resolved with true when the command completes
runBasic: (area, startLine) ->
command = new Command 0x02, 0x62, 3
command.setDataUint8 0, Robot._basicAreaToCode(area)
command.setDataUint16 1, startLine
@_session.sendCommand(command).then (response) ->
true
# Loads an orbBasic program into a storage area.
#
# This is a convenience wrapper around the {Robot#eraseBasicArea} and
# {Robot#appendBasicToArea} primitives.
#
# @param {String} area the area storing the program; 'ram' or 'flash'
# @param {String} fragment the orbBasic program fragment to be appended
# @return {Promise<Boolean>} resolved with true when the command completes
loadBasic: (area, program) ->
program += "\0" unless program.endsWith("\0")
offset = 0
loadNextFragment = =>
length = program.length - offset
length = 253 if length > 253
if length is 0
return Promise.resolve true
@_appendBasicToArea(area, program.substring(offset, offset + length))
.then ->
offset += length
loadNextFragment()
@eraseBasicArea(area).then loadNextFragment
# Erases an orbBasic program.
#
# This is a primitive operation used by {Robot#loadBasic}.
#
# @param {String} area the area storing the program; 'ram' or 'flash'
# @return {Promise<Boolean>} resolved with true when the command completes
eraseBasicArea: (area) ->
command = new Command 0x02, 0x60, 1
command.setDataUint8 0, Robot._basicAreaToCode(area)
@_session.sendCommand(command).then (response) ->
true
# Appends an orbBasic program fragment to a storage area.
#
# This is a primitive operation used by {Robot#loadBasic}.
#
# @param {String} area the area storing the program; 'ram' or 'flash'
# @param {String} fragment the orbBasic program fragment to be appended
# @return {Promise<Boolean>} resolved with true when the command completes
_appendBasicToArea: (area, fragment) ->
if fragment.length > 253
error = new Error "orbBasic fragment length #{fragment.length} " +
'exceeds maximum of 253 bytes'
return Promise.reject(error)
command = new Command 0x02, 0x61, 1 + fragment.length
command.setDataUint8 0, Robot._basicAreaToCode(area)
command.setDataString 1, fragment
@_session.sendCommand(command).then (response) ->
true
# Converts a developer-friendly orbBasic storage area to a Sphero API code.
#
# @param {String} area an orbBasic area; 'ram' or 'flash'
# @return {Number} the Sphero API code for the area
@_basicAreaToCode: (area) ->
switch area
when 'ram'
0
when 'flash'
1
else
area
# Called when an asynchronous message is received from the robot.
#
# @param {Object} async the asynchronous message
_onAsync: (async) ->
switch async.idCode
when 0x06
event =
markerId: async.data[0], macroId: async.data[1],
commandId: async.data.readUInt16BE(2)
@emit 'macro', event
when 0x08
event = { message: async.data.toString('ascii') }
@emit 'basic', event
when 0x09
event = { message: async.data.toString('ascii') }
@emit 'basicError', event
else
@emit 'async', async
module.exports = Robot