R&R

Roombas and Ruby

an introduction to programming robotic vacuums

by Eric Wood

eric@ericwood.org

@eric_b_wood

Meet the iRobot Roomba

What you'll need

  • Roomba
  • Serial cable 
    • Not your average serial cable!
    • mini-DIN 7-pin connector on one end :(
    • See end of presentation for DIY resources
  • Some sort of computer!
  • Potentially a USB to serial converter

So...why?

Conversing with Roombas

iRobot Serial Control Interface (SCI)

  • All Roombas since 2005 have this!
  • Control ALL motors, sensors, etc.
  • Pretty much the whole focus of the presentation :)

SCI "modes"

  • Off
    • Battery charge, loss of power
  • Passive
    • Control anything except actuators​
  • ​Safe
    • ​Actuator control, but safety-related sensors engaged (wheel drop, cliff)
  • ​FULL!
    • ​Complete control. No exceptions.

SCI commands

  • One byte opcode followed by optional data bytes
  • For example, the "start" command:
    • MUST be sent before any other instructions
    • Puts the Roomba into passive mode
    • To use, send "128" (no data bytes)

Mode-setting opcodes

  • 131 - safe
  • 132 - full (what we want)
  • 133 - power (virtual press of power button)

Driving!

  • opcode: 137
  • 4 data bytes (16 bit, signed, twos-complement)

 

[velocity (2 bytes)][radius (2 bytes)]

 

  • Velocity: speed in mm/s
    • Positive: forward, negative: backwards
    • Max: 500 mm/s (pretty fast!)
  • Radius:
    • Positive: left, negative: right
    • Lower: steeper turning radius (mm/s)

Driving example

Goal: drive in reverse at 200 mm/s while turning at a radius of 500mm

 

Velocity = -200 = 0xFF38 = [0xFF][0x38]

Radius   = -500 = 0x01F4 = [0x01][0xF4]

 

Bytes sent over serial:

[137][255][56][1][244]

Ruby + SCI

Speaking serial

require 'serialport'

# port is very OS/driver dependent...
# Typically, on *nix you're want this:
port = '/dev/ttyusbserial'

# baud:
# 115200 for Roomba 5xx
# 57600 for older (and iRobot Create)
baud = 115200
@serial = SerialPort.new(port, baud)
@serial.write('hello!')

Ones and zeroes

  • Ruby <3 binary (kind of...)
  • Introducing: Array.pack

    • "Packs the contents of arr into a binary sequence according to the directives in aTemplateString"

    • What we want: "C"

      • 8-bit unsigned integer (unsigned char)

Pack Example

>> [128].pack('C')
# => "\x80"

"C" is the directive for:

8-bit unsigned integer (unsigned char)

Our write function

# Converts input data (an array) into bytes before
# sending it over the serial connection.
def write_chars(data)
  data.map! do |c|
    if c.class == String
      result = c.bytes.to_a.map { |b| [b].pack("C") }
    else
      result = [c].pack("C")
    end
    
    result
  end
  
  data = data.flatten.join
  
  @serial.write(data)
  @serial.flush
end

Driving!

# Convert integer to two's complement signed 16 bit integer
def convert_int(int)
  [int].pack('s').reverse
end
def drive(velocity, radius)
  raise RangeError if velocity < -500 || velocity > 500
  raise RangeError if (radius < -2000 || radius > 2000) && radius != 0xFFFF
  
  velocity = convert_int(velocity)
  radius   = convert_int(radius)
  write_chars([DRIVE, velocity, radius])
end

Working with sensors

Types of sensors

  • Bump (front bumper; left and right)
  • Wheel drops
  • Cliff (left, right, front left, front right)
  • Virtual wall
  • Dirt (left, right)
  • Motor overcurrents (each motor type)
  • Remote control
  • Buttons
  • Wheel rotation (angle, distance)
  • Temperature
  • LOTS OF OTHER THINGS WE DON'T CARE ABOUT

Requesting sensor data

  • Sensors identified by ID
  • 7 different "groupings"
  • Methods for requesting data:
    • Query by grouping
    • Query list (we'll focus on this)
    • Stream

Query List

  • Opcode: 149
  • Arguments:
    • number of packets to request
    • list of packet IDs

 

Example:

get the distance travelled (19) and bumper (7)

[149][2][19][7]

Translating it into Ruby!

# Get sensors by list
# Array entry can be packet ID or symbol
def get_sensors_list(sensors)
  # convert from symbols to IDs
  sensors.map! { |l|
    l.class == Symbol ? SENSOR_SYMBOLS.find_index(l) : l
  }

  # request sensor data!
  request = [Constants::QUERY_LIST, sensors.length] + sensors
  write_chars(request)

  raw_data = ""
  sensors.each do |id|
    raw_data << @serial.read(SENSOR_PACKET_SIZE[id])
  end

  sensor_bytes_to_packets(raw_data, sensors)
end

Sensor constants

# Human readable packet names
# truncated for presentation, TOO MANY TO LIST!
SENSOR_SYMBOLS = [:ignore, :bumps_and_wheel_drops,:wall,:cliff_left,:cliff_front_left,
                  :cliff_front_right,:cliff_right,:virtual_wall,:wheel_overcurrents]

SENSOR_PACKET_SIZE = [0, 0, 0, 1, 1, 1, 1] # pretend this has everything
SENSOR_PACKET_SIGNEDNESS = [:na, :na, :na, :signed, :unsigned] #...

# map to appropriate classes for conversion...
SENSOR_PACKET_VALUE = {
  wall:                       Boolean,
  cliff_left:                 Boolean,
  cliff_front_left:           Boolean,
  charging_state:             ChargingState,
  oi_mode:                    OIMode,
  charging_sources_available: ChargingSourceAvailable,
  light_bumper:               LightBumper,
  wheel_overcurrents:         WheelOvercurrents,
  bumps_and_wheel_drops:      BumpsAndWheelDrops,
  infrared_character_omni:    InfraredCharacter,
  infrared_character_left:    InfraredCharacter,
  infrared_character_right:   InfraredCharacter
}

Mapping to native Ruby types

def sensor_bytes_to_packets(bytes, packets)
  # template string for unpacking the data
  pack = ''
  packets.each do |packet|
    size = SENSOR_PACKET_SIZE[packet]
    signedness = SENSOR_PACKET_SIGNEDNESS[packet]
    case size
    when 1 # 8 bit (big endian)
      case signedness
      when :signed
        pack << 'c'
      when :unsigned
        pack << 'C'
      end
    when 2 # 16 bit (big endian)
      case signedness
      when :signed
        pack << 's>'
      when :unsigned
        pack << 'S>'
      end
    end
  end

  data = bytes.unpack(pack)

  # CONTINUED ON NEXT SLIDE!
end

(continued)

results = {}
packets.each_with_index do |packet,index|
  packet_name = SENSOR_SYMBOLS[packet]
  unless packet_name == :ignore
    value = data[index]

    # map to native Ruby type
    converter = SENSOR_PACKET_VALUE[packet_name]
    value = converter.convert(value) if converter

    results[packet_name] = value
  end
end

results

(continued)

class BumpsAndWheelDrops
  def self.convert(v)
    h = {}
    h[:bump_right]       = v & 0b0001 > 0
    h[:bump_left]        = v & 0b0010 > 0
    h[:wheel_drop_right] = v & 0b0100 > 0
    h[:wheel_drop_left]  = v & 0b1000 > 0
    h
  end
end

Adding a "DSL"

Making things friendlier

  • So far things are fairly low-level
  • Sometimes you don't want that
  • Wouldn't it be cool if kids could use this?!
  • Solution:
    • Some kind of Domain-Specific Language (DSL)
    • Internal DSL; still writing Ruby

Our end goal

It'd be cool if we could make it look something like this:

require 'rumba'

Roomba.new('/dev/tty.usbserial') do
  safe_mode
  forward 1.meter
  rotate :left
  rotate -90 # degrees

  rotate :right
  rotate 90
  backward 1.meter

  # access to any methods in the Roomba class here!
end

Taking the easy way out...

def initialize(port, baud=57600, &block)
  # Snip...

  # initialize the "DSL" here!
  if block_given?
    instance_eval(&block)

    # clean up after ourselves (this is a Roomba, after all!)
    self.power_off
  end
end
  • Take a block in the initializer
  • Execute it in the newly created Roomba instance!

Adding some higher-level commands

# move both wheels at the same speed in a certain direction!
# NOTE THAT THIS BLOCKS UNTIL COMPLETE
def straight_distance(distance, speed: DEFAULT_SPEED)
  total = 0
  straight(speed)
  loop do
    total += get_sensor(:distance).abs
    break if total >= distance
  end

  halt
end

# distance is in mm!
def forward(distance, speed: DEFAULT_SPEED)
  straight_distance(distance, speed: speed)
end

# distance is in mm!
def backward(distance, speed: DEFAULT_SPEED)
  straight_distance(distance, speed: -speed)
end

Measurement helpers

It wouldn't be Ruby unless we monkeypatched built-ins!

# MEASUREMENT HELPERS
class Fixnum
  def inches
    25.4 * self
  end
  alias_method :inch, :inches

  def feet
    self.inches * 12
  end
  alias_method :foot, :feet

  def meters
    self * 1000
  end
  alias_method :meter, :meters
end

Cool...but can you actually demo something?

References, further reading

Roomba hacking in Ruby

By Eric Wood

Roomba hacking in Ruby

Introduction to the Roomba SCI and how to play with it in Ruby!

  • 1,076