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

Made with Slides.com