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