Lawrence Technological University
College of Arts and Science
Department of Mathematics and Computer Sciences

Handouts

Ruby, OBDLink SX and other OBD II Adapters

   ScanTool released the OBDLink SX as a simple wired connector from your laptop to the OBD II port found on cars after about 1996. However, the support for OS X and Linux is missing and ScanTool does not offer a software development kit for this product. This page is about an experiment to connect my Mac, via the OBDLink SX, to a 1998 Ford Escort Wagon.

   Resources used:

   This first experiment is just a wrapper around the ELM AT and OBD commands. I gave names to some of the commands. Since my car is too old for CAN "stuffing" I added a small facility to ask for more than 1 PID at a time. Ruby is quite convenient for this sort of string processing application. minirap.rb is just the essentials of communicating with the OBDLink SX with Ruby.

#! /usr/bin/ruby
# minirap.rb
# Time-stamp: <2011-03-23 11:00>
# The essentials of connecting to the OBDLink SX.
require 'rubygems'
require 'serialport'
# Constant data:
OBDLinkSX = '/dev/tty.usbserial-00001004'
Baud = 115200
# Main loop.
begin
  SerialPort.open OBDLinkSX,Baud do |sp|
    puts 'Enter commands or "quit":'
    while true
      input = gets.strip
      break if input =~ /^\s*q/i # Quit.
      sp.write(input + "\r")
      answer = ""
      answer += sp.getc.chr until answer[-1,1] == '>' # Wait for prompt.
      answer = answer[0..-2].gsub(/[\000\r\n]/,"").strip
      cmd, ans = /(#{input})(.*)/i.match(answer)[1..2] # Echo, response. 
      print "#{ans}\n>"
    end
  end
# An exception for a file not found is a type of SystemCallError
rescue SystemCallError => problem
  puts problem
end

   The original rap.rb was a command line tool. Adding GUI gauges did not seem interesting at the time. However, for observing the rate of change of oxygen sensors a picture may well be worth a thousand words. The more recent version of rap.rb also serves the file http://localhost:8090/oxygensensor.html (or alternatively: http://localhost:8090/rap) which is a Javascript, HTML5 canvas program that gets the data from rap.rb in JSON format.

#! ruby
# rap.rb
# The serial port library connects to the chip in the OBDLink SX from
# Future Technology Devices International Ltd.  The Virtual COM Port Driver
# http://www.ftdichip.com/Drivers/VCP.htm updated 3-4-2013 v.2.2.18
# looked like it would make this Ruby code easier to run on other platforms,
# like Linux or Windows -- rather than accessing the USB port directly.
# require 'rubygems'
require 'serialport'
require 'monitor'
require 'socket'
# Constant data:
PORT = 8090
# Named commands:
Input_dictionary = {
  'reset'    => 'atz',
  'protocol' => 'atdp',
  'volts'    => 'atrv',
  'pids'     => '0100',
  'status'   => '0101',
  'emdtc'    => '0102',
  'fuel'     => '0103',
  'load'     => '0104',
  'ect'      => '0105',
  'trim1s'   => '0106',
  'trim1l'   => '0107',
  'map'      => '010B',
  'rpm'      => '010C',
  'mph'      => '010D',
  'advance'  => '010E',
  'iat'      => '010F',
  'maf'      => '0110',
  'tp'       => '0111',
  'osensors' => '0113',
  'os11v'    => '0114',
  'os12v'    => '0115',
  'obd'      => '011C',
  'ffpids'   => '0200',
  'dtc'      => '03',
  'clear'    => '04',
  'init'     => %w(reset protocol obd status),
  'o2'       => %w(os11v trim1s trim1l),
  'temp'     => %w(iat ect)
}
# Priority for operators used with commands:
# + for concatenation, * for repetition, () for grouping.
Priority = {'+' => 1, '*' => 2, '/' => 2, '(' => 3, ')' => 3}
# Global indicators:
$debug = ARGV.delete('--debug')
$errors = []
$translate_answers = 't'
$time0 = Time.now
$export_file_name = 'obdii-strip.txt'
$export_file = nil

# Monitored class:
class OBDLinkSX < Monitor

  def initialize
    @sp =  SerialPort.open '/dev/tty.usbserial-00001004',115200
    super
  end

  def ask(command)
    synchronize do
      @sp.write(command + "\r")
      answer = @sp.gets('>') # Wait for prompt.
      answer = answer[0..-2].gsub(/[\000\r\n]/,"").strip
      cmd, ans = /(#{command})(.*)/i.match(answer)[1..2]
    end
  end

  def close
    @sp.close
  end
end

# Subprograms:
# General help:
def help
  puts <<HELP1
Accepted operators for use with the list of commands are: + * / ()
concatenate command + command
repeat      commands * count
monitor     commands / monitoring period in seconds
e.g.
(rpm * 3 + mph)*4 would sample RPMs 3 times, then MPH once, repeated 4 times.
or
z + (s + rpm + os11v + r) * 100 would output 100 lines of time-rpm-o2.
General commands:
quit  Quit this communications program and close the connection.
help  Display this screen.
p     Pause 1 second.
P     Pause 1 minute.
z     Zero time-stamp.
s     Insert time-stamp.
r     New output line. 
-r    Change from the default mode of translating answers to raw mode.
-t    Change back to the translating answers mode.
-b    Display both raw and translated answers.
-f    Followed by filename for saving output (default: obdii-strip.txt).
-e    Export output to file.
-n    No more exporting -- close file if open.
-o    Save 100 oxygen sensor readings.
Enter Repeats last command input.
Command line options:
  --debug 
Named commands:
Name     Command
HELP1
  Input_dictionary.keys.sort.each do |k|
    puts k.ljust(10) + (Input_dictionary[k].class == Array ?
              Input_dictionary[k].join(' + ') : Input_dictionary[k])
  end
end

# Class to extract an HTTP request from a socket.
class Request
  attr_reader :request_type, :target, :content_type, :content_length,
              :params, :summary
  def initialize(session)
    request = [session.gets]  # 1st line of the request.
    request << session.gets while request[-1] =~ /\S/ # Remainder of header.
    @request_type = extract(request[0],/(GET|POST) /)
    query_string = extract(request[0],/[^?]*\?(\S+)/)
    if @request_type
      @target = extract(request[0],/#{@request_type} \/(.+?)[? ]/)
    else 
      @target = nil
    end
    headers = request[1..-1].join
    @content_type = extract(headers,/Content-Type:\s+(\S+)/)
    @content_length = extract(headers,/Content-Length:\s+(\S+)/).to_i
    if @request_type == 'POST'
      body = session.read(@content_length)
    else
      body = nil
    end
    kv_pairs = [query_string,body].compact
    if kv_pairs.empty?
      @params = {}
    else
      @params = Hash[kv_pairs.join("&").split(/&/).map do |kv|
        kv.split(/=/).map do |e|
          e.tr('+',' ').gsub(/%[a-fA-F0-9][a-fA-F0-9]/){|c| c[1,2].hex.chr}
        end
      end]
    end
    @summary =  "#{@request_type} #{@target} received " + `date` +
                "Content-Length: #{@content_length}\n" +
                @params.keys.map {|k| "#{k}:#{@params[k]}\n"}.join
  end

  def extract(str,pattern)
    m = str.match(pattern)
    m && m[1]
  end

end

def respond(session,body,mime="HTML")
  content_type = {"HTML" => "text/html","JSON" => "application/json"}
  reply = body
  begin
    session.puts "HTTP/1.1 200 OK"
    session.puts "Content-Type: #{content_type[mime]}"
    session.puts "Content-Length: #{reply.size}"
    session.puts
    session.print reply
  rescue Errno::EPIPE
    open("rap-server.log","a") {|f| f.puts "Connection closed by client"}
  end
end

# Recursive expansion of a named command -- returns an array.
def expand_name(token)
  if !Input_dictionary.has_key?(token)
    [token]
  elsif Input_dictionary[token].class != Array
    [Input_dictionary[token]]
  else
    Input_dictionary[token].inject([]) {|l,t| l + expand_name(t)}
  end
end

# Shunting-yard algorithm to remove parentheses from command expression.
def rpn_order(tokens)
  ops = []
  output = []
  tokens.each do |token|
    if !Priority[token]                         # Must be an operand, so
      output << token                           # directly to output.
    elsif token == ')'                          # If )
      while !ops.empty?                         # empty stack to matching (.
	top = ops.pop
        break if top == '('
        output << top
      end
    elsif ops.empty? or                         # if stack is empty, or
          ops.last == '(' or                    #   stack top is (, or
          token == '(' or                       #   token is (, or
          Priority[token] > Priority[ops[-1]]   #   P(token) > P(top), then
      ops.push token                            #   push operator on the stack.
    else                                        # Now P(token) <= P(top), so
      while !ops.empty? and                     #   empty stack
          ops.last != '(' and                   #   until ( is reached, or
          Priority[token] <= Priority[ops.last] #   P(token) > P(top)
	output << ops.pop
      end
      ops.push token # Then push the token on the operator stack.
    end
  end
  output << ops.pop while !ops.empty? and ops.last != '('
  if ops.empty?
    output
  else
    $errors << "Mismatched parentheses"
    []
  end
end
# Expand names to a list of ELM327 and STN11xx accepted commands.
def expanded(input)
  tokens = input.split(/\s*([+*()\/])\s*/).reject {|e| e.empty?}
  commands = rpn_order(tokens).map do |c|
    ex = expand_name(c)
    if ex.size == 1
      ex[0]
    else
      ex
    end
  end
  p commands if $debug
  commands = commands.inject([]) do |cmds,t|
    if t == '+'
      s1 = cmds.pop
      s2 = cmds.pop
      cmds.push [s2,s1].flatten
    elsif t == '*'
      cnt = cmds.pop.to_i
      s2 = cmds.pop
      s2_size = s2.class == Array ? s2.size : 1
      if cnt > 0
        s3 = ['repeat',cnt,cnt,s2_size + 6,s2,'end',-(s2_size + 4)].flatten
        cmds.push s3
      else
        $errors << "Repeat count must be a positive integer"
        cmds
      end
    elsif t == '/'
      t2 = cmds.pop.to_f
      s2 = cmds.pop
      s2_size = s2.class == Array ? s2.size : 1
      if t2 > 0.0
        s3 = ['monitor',Time.now + t2,t2,s2_size + 6,s2,
              'end',-(s2_size + 4)].flatten
        cmds.push s3
      else
        $errors << "Seconds of monitoring must be a positive number"
        cmds
      end
    else
      cmds << t
    end
  end
  commands << 'halt'
  if $errors.empty?
    commands.flatten
  else
    puts "Input errors: #{$errors.join(', ')}."
    ['halt']
  end
end
# Translates the strings of hex bytes from the STN11xx.
def translated(answer)
# 01 00 - Available PIDs
  if answer =~ /^\s*41\s+00/
    pid_bits = answer.split[2..-1].map {|byte|
      sprintf("%08b",byte.hex)}.join.split("")
    i = 1
    pids = []
    pid_bits.each {|b| pids << sprintf("%02X",i) if b == "1"; i += 1}
    "Available PIDS: " + pids.join(',')
# 01 01 - MIL, DTC count
  elsif answer =~ /^\s*41\s+01(\s+[a-f0-9]{2}){4}/i
    bytes = answer.split[2..-1].map {|byte| byte.hex}
    trans = []
    trans << "MIL #{bytes[0][7] == 1 ? "ON" : "OFF"}"
    trans << "DTC count: #{bytes[0] & 0x7f}"
    test_encoding = {
    # Test                     [Test available Test incomplete]
     "Misfire"              => [bytes[1][0],   bytes[1][4]],
     "Fuel System"          => [bytes[1][1],   bytes[1][5]],
     "Components"           => [bytes[1][2],   bytes[1][6]],
     "Reserved"             => [bytes[1][3],   bytes[1][7]],
     "Catalyst"             => [bytes[2][0],   bytes[3][0]],
     "Heated Catalyst"      => [bytes[2][1],   bytes[3][1]],
     "Evaporative System"   => [bytes[2][2],   bytes[3][2]],
     "Secondary Air System" => [bytes[2][3],   bytes[3][3]],
     "A/C Refrigerant"      => [bytes[2][4],   bytes[3][4]],
     "Oxygen Sensor"        => [bytes[2][5],   bytes[3][5]],
     "Oxygen Sensor Heater" => [bytes[2][6],   bytes[3][6]],
     "EGR System"           => [bytes[2][7],   bytes[3][7]]}
    ["Misfire","Fuel System","Components","Reserved","Catalyst",
     "Heated Catalyst","Evaporative System","Secondary Air System",
     "A/C Refrigerant","Oxygen Sensor","Oxygen Sensor Heater",
     "EGR System"].each do |t|
       trans << (t.ljust(25) +
                 (test_encoding[t][0] == 1 ? "Available   " : "Unavailable ") +
                 (test_encoding[t][1] == 1 ? "Incomplete" : "Complete"))
    end          
    trans.join("\n")
# 01 02 - Emmision Related DTC or 02 02 - Freeze frame DTC See 03.
# 01 03 - Fuel System Status
  elsif answer =~ /^\s*4[12]\s+03/
    fs = []
    answer.split[2,2].each_with_index do |a,i|
      if a.hex[0] == 1
        fs << "#{i + 1}: Open loop due to insufficient engine temperature"
      elsif a.hex[1] == 1
        fs << "#{i + 1}: Closed loop, using oxygen sensor feedback to" +
        " determine fuel mix"
      elsif a.hex[2] == 1
        fs << "#{i + 1}: Open loop due to engine load OR fuel cut due to" +
        " deacceleration"
      elsif a.hex[3] == 1
        fs << "#{i + 1}: Open loop due to system failure"
      elsif a.hex[4] == 1
        fs << "#{i + 1}: Closed loop, using at least 1 oxygen sensor" +
        " but a fault in the feedback system"
      else
        fs << "#{i + 1}: N/A"
      end
    end
    fs.join(', ')
# 01 04 - Engine Load %
  elsif answer =~ /^\s*4[12]\s+04/
    "#{sprintf("%.2f",(answer.split[2].hex & 0xff) * 100.0 / 255.0)}%"
# 01 05 - Engine Coolant Temperature
  elsif answer =~ /^\s*4[12]\s+05/
    "#{((answer.split[2].hex - 40) * 9.0 / 5.0 + 32).round} Degrees F."
# 01 06 - Bank 1 short Term fuel trim %
  elsif answer =~ /^\s*4[12]\s+06/
    "#{sprintf("%.2f",(answer.split[2].hex - 128) * 100.0 / 128.0)}%"
# 01 07 - Bank 1 long Term fuel trim %
  elsif answer =~ /^\s*4[12]\s+07/
    "#{sprintf("%.2f",(answer.split[2].hex - 128) * 100.0 / 128.0)}%"
# 01 0b - Intake Manifold Absolute Pressure
  elsif answer =~ /^\s*4[12]\s+0B/
    "#{answer.split[2].hex} kPa"
# 01 0c - Engine RPM
  elsif answer =~ /^\s*4[12]\s+0C/
    "#{answer.split[2,2].inject(0) {|v,e| v * 256 + e.hex} / 4} RPM"
# 01 0d - Vehicle Speed
  elsif answer =~ /^\s*4[12]\s+0D/
    "#{(answer.split[2].hex * 0.62137).round} MPH"
# 01 0e - Timing Advance degrees
  elsif answer =~ /^\s*4[12]\s+0E/
    "#{answer.split[2].hex / 2 - 64} degrees"
# 01 0f - Intake Air Temperature
  elsif answer =~ /^\s*4[12]\s+0F/
    "#{((answer.split[2].hex - 40) * 9.0 / 5.0 + 32).round} Degrees F."
# 01 10 - Mean air flow rate
  elsif answer =~ /^\s*4[12]\s+10/
    "#{answer.split[2,2].inject(0) {|v,e| v * 256 + e.hex} / 100.0} g/s"
# 01 11 - Throttle position
  elsif answer =~ /^\s*4[12]\s+11/
    "#{sprintf("%.2f",answer.split[2].hex * 100.0 / 255.0)}%"
# 01 13 - Oxygen sensors present
  elsif answer =~ /^\s*4[12]\s+13/
    n = answer.split[2].hex
    "Bank 1: #{n[0]} #{n[1]} #{n[2]} #{n[3]} " +
    "Bank 2: #{n[4]} #{n[5]} #{n[6]} #{n[7]} "
# 01 14 - B1S1 volts, short term fuel trim, or
# 01 15 - B1S2 volts, short term fuel trim
  elsif answer =~ /^\s*4[12]\s+1[45]/
    v, t = answer.split[2,2]
    unless t == 'FF'
      ft = sprintf("%.2f",(t.hex - 128) * 100.0 / 128.0)
    else
      ft = 'N/A'
    end    
    "#{sprintf("%.2f",v.hex * 0.005)}v. Trim % #{ft}"
# 01 1c - OBD Compliance
  elsif answer =~ /^\s*41\s+1C/
    ["Undefined",
     "OBD-II as defined by the CARB",
     "OBD as defined by the EPA",
     "OBD and OBD-II",
     "OBD-I",
     "Not meant to comply with any OBD standard",
     "Europe OBD",
     "Europe OBD and OBD-II",
     "Europe OBD and OBD",
     "Europe OBD, OBD and OBD II",
     "Japan OBD",
     "Japan OBD and OBD II",
     "Japan OBD and Europe OBD",
     "Japan OBD, Europe OBD and OBD II"][answer.split[2].hex]
# 02 00 - Available freeze frame PIDs
  elsif answer =~ /^\s*42\s+00\s+00/
    pid_bits = answer.split[3..-1].map {|byte|
      sprintf("%08b",byte.hex)}.join.split("")
    i = 1
    pids = []
    pid_bits.each {|b| pids << sprintf("%02X",i) if b == "1"; i += 1}
    unless Input_dictionary['ffall']
      look_up = Input_dictionary.invert
      ffall = []
      pids.each do |pid|
        cmd = look_up['01' + pid]
        if cmd
          cmd = 'ff' + cmd
          Input_dictionary[cmd] = '02' + pid
          ffall << cmd
        end
      end
      Input_dictionary['ffall'] = ffall
    end
    "Available Freeze Frame PIDS: " + pids.join(',')
# 03 - Trouble codes
  elsif answer =~ /^\s*43\s/ # 03 - DTCs
    dtc_bytes = answer.split[1..-1]
    dtc_bytes.shift if dtc_bytes.size[0] == 1 # Discard CAN DTC count byte.
    dtc_codes = dtc_bytes.size/2 # May be 0 if CAN protocol.
    dtc_codes = 3 if dtc_codes > 3  # Just do the first frame of 3.
    dtcs = (0...dtc_codes).map do |i|
      if dtc_bytes[2 * i] == '00'
        nil
      else
        %w{P C B U}[dtc_bytes[2 * i].hex >> 6] +
        (dtc_bytes[2 * i][0].hex & 0x3).to_s +
        dtc_bytes[2 * i][1] + dtc_bytes[2 * i + 1][0] + dtc_bytes[2 * i + 1][1]
      end
    end
    dtcs.compact!
    if dtcs.size > 0
      "DTCs : #{dtcs.join(", ")}"
    else
      "DTCs : None"
    end
  else
    answer
  end
end

### Entry
# Instantiate the monitored resource.
sx = OBDLinkSX.new
Thread.abort_on_exception = true
# Start the interactive mode as a separate thread.
rap = Thread.start do
  puts 'Enter commands or "help" or "quit":'
  last_input = ""
  while true  # Main interactive loop.
    print ">"
    input = gets.strip
    if input.empty?            # Like ELM327, Enter repeats last command.
      input = last_input
      puts "Repeating: #{input}"
    end
    if input =~ /^\s*q/i # Quit.
      puts "Ending interactive session."
      Thread.current.exit
    # Mode and settings commands:
    elsif input =~ /^\s*h/i       # Help.
      help()
      redo
    elsif input =~ /^\s*-t/i   # Set mode: translated responses.
      $translate_answers = 't'
      redo
    elsif input =~ /^\s*-r/i   # Set mode: raw responses.
      $translate_answers = 'r'
      redo
    elsif input =~ /^\s*-b/i   # Set mode: both raw and translated responses.
      $translate_answers = 'b'
      redo
    elsif m = /^\s*-f\s*(\S{1,25})?/i.match(input)   # Set export filename.
      $export_file_name = m[1] if m[1] # Otherwise leave unchanged.
      redo
    elsif input =~ /^\s*-e/i   # Set mode: export response strip.
      $export_file = open($export_file_name,"a")
      redo
    elsif input =~ /^\s*-n/i and 
                   $export_file         # Set mode: end export response strip.
      $export_file.close
      $export_file = nil
      redo
    else
      last_input = input       # Save last real input.
    end
    # Process input:
    $export_file.puts input if $export_file
    response = ""
    nl = false
    ip = 0
    program = expanded(input)
    p input if $debug
    p program if $debug
    while (command = program[ip]) != 'halt'
      p ip if $debug
      p command if $debug
      if command == 'repeat'
        if program[ip + 1] <= 0
          program[ip + 1] = program[ip + 2] # Reset for nesting.
          ip += program[ip + 3]
        else
          program[ip + 1] -= 1
          ip += 4
        end
        response = ""
      elsif command == 'monitor'
        if program[ip + 1] < Time.now
          program[ip + 1] = Time.now + program[ip + 2] # Reset for nesting.
          ip += program[ip + 3]
        else
          ip += 4
        end
      elsif command == 'end'
        ip += program[ip + 1]
        response = ""
      elsif command == 'p'
        ip += 1
        sleep 1
        response = '.'
      elsif command == 'P'
        ip += 1
        sleep 60
        response = '-'
      elsif command == 'z'
        ip += 1
        $time0 = Time.now
        response = ""
      elsif command == 'r'
        ip += 1
        response = "\n"
      elsif command =='s'
        ip += 1
        response = "%.4f: " % (Time.now - $time0)
      else # A command to be sent to the OBDLinkSX. 
        ip += 1
        cmd, ans = sx.ask(command)
        if $translate_answers == 't'
          response = translated(ans) + " "
        elsif $translate_answers == 'b'
          response = "#{cmd} #{ans}: #{translated(ans)} "
        else
          response = "#{cmd} #{ans} "
        end
      end
      $export_file.print response if $export_file
      print response
      if response == "\n"
        nl = true
      elsif response != ""
        nl = false
      end
    end
    $export_file.puts if $export_file
    print "\n"  unless nl
  end # Main while true loop.
end # Separate interactive thread.
# Separate thread for server.
serve = Thread.start do
  server = TCPServer.new(PORT) # Start the server.
  # Start the server log.
  open("rap-server.log","a") {|f| f.puts "Server started."}
  begin
  loop do
    session = server.accept
    r = Request.new(session)
    open("rap-server.log","a") {|f| f.puts r.summary}
    if r.request_type == "GET" and (r.target =~ /oxygensensor\.html/ or
                                    r.target =~ /rap/)
      body = File.new("oxygensensor.html").read
      respond(session,body,"HTML")
    elsif r.request_type == "GET" and  r.target =~ /o2\.json/
      bs = Input_dictionary['os' + r.params['o2s'] + 'v']
      json = []
      t0 = Time.now
      tend = t0 + 10
      while Time.now < tend
        v = sx.ask(bs)[1].split[2]
        json << "{\"seconds\":%.3f,\"millivolts\":%4d}" % [Time.now-t0,v.hex*5]
      end
      body = "{\"points\":[" + json.join(",\n") + "]}" 
      respond(session,body,"JSON")
      puts body if $debug
      puts "points: #{body.split("\n").size}" if $debug
    else
      # Error response...
    end
    sleep 0.01 # Delay for a slower client.
    session.close
  end
  ensure
    # Mark the server log and close it.
    open("rap-server.log","a") {|f| f.puts "Server shutdown"}
  end
end # Server thread.
while rap.alive?
end
serve.exit # Close down the server thread.
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>OBDII Oxygen Sensors</title>
  <meta NAME="author" CONTENT="John M. Miller M.D.">
  <meta NAME="copyright" CONTENT="&copy; 2014 John M. Miller M.D.">
  <script>
    // Global variables:
    var dc;
    var canvas;
    var xscale = 50;
    var xoffset = 100;
    var yscale = -0.4;
    var yoffset = 450;
    var bs;

    function to_png() { window.open().location=canvas.toDataURL("image/png"); }

    function get_o2() {
      document.getElementById("geto2").disabled = true;
      var xmlhttp = new XMLHttpRequest();
      xmlhttp.onreadystatechange=function() {
        if (xmlhttp.readyState==4 && xmlhttp.status==200) {
          var newData = eval('(' + xmlhttp.responseText + ')');
          dc.clearRect(0,0,canvas.width,canvas.height);
          draw_axes("Oxygen Sensor: Bank " + bs.charAt(0) +
                    ", Sensor " + bs.charAt(1));
          plot_points(newData.points);
          document.getElementById("waiting").style.display = "none";
          document.getElementById("geto2").disabled = false;
        }
      }
      bs = document.getElementById("os12").checked ?
           document.getElementById("os12").value :
           document.getElementById("os11").value;
      xmlhttp.open("GET","o2.json" + "?o2s=" + bs,true);
      xmlhttp.send();
      document.getElementById("waiting").style.display = "block";
    }

    function plot_points(pts) {
      dc.strokeStyle = "black";
      for (var i = 0;i < pts.length;i++) {
        if (i > 0) {
          dc.beginPath();
          dc.moveTo(x(pts[i-1].seconds),y(pts[i-1].millivolts));
          dc.lineTo(x(pts[i].seconds),y(pts[i].millivolts));
          dc.stroke();
        }
        dc.beginPath();
        dc.arc(x(pts[i].seconds),y(pts[i].millivolts),3,0,Math.PI*2,false);
        dc.stroke();
      }
      dc.beginPath(); // Kludge to make sure clearRect() works properly.
    }

    function initialPlot() {
      document.getElementById("waiting").style.display = "none";
      canvas = document.getElementById("drawingCanvas");
      dc = canvas.getContext("2d");
      draw_axes("Oxygen Sensor");
    }  

    function x(x1) { return x1 * xscale + xoffset; }

    function y(y1) { return y1 * yscale + yoffset; }

    function draw_axes(title) {
      // Title.
      dc.font = '20pt Helvetica';
      dc.textAlign = 'center';
      dc.textBaseline = 'middle';
      dc.fillStyle = 'black';
      dc.fillText(title,275,20);
      // Y and X axes.
      dc.font = '10pt Helvetica';
      dc.strokeStyle = "black";
      dc.moveTo(x(0),y(1000));
      dc.lineTo(x(0),y(0));
      dc.lineTo(x(10),y(0));
      dc.stroke();
      // Y ticks.
      dc.textAlign = 'right';
      dc.textBaseline = 'middle';
      dc.fillStyle = 'black';
      for (var y1=0;y1<=1000;y1+=100) { 
        dc.beginPath();
        dc.moveTo(x(0),y(y1));
        dc.lineTo(x(0) - 10,y(y1));
        dc.stroke();
        dc.fillText(y1,x(0) - 10,y(y1));
      }
      // X ticks.
      dc.textAlign = 'center';
      dc.textBaseline = 'top';
      for (var x1=0;x1<=10;x1+=1) { 
        dc.beginPath();
        dc.moveTo(x(x1),y(0));
        dc.lineTo(x(x1),y(0) + 10);
        dc.stroke();
        dc.fillText(x1,x(x1),y(0) + 10);
      }
      // Rich-Lean boundary.
      dc.strokeStyle = "red";
      dc.beginPath();
      dc.moveTo(x(0),y(450));
      dc.lineTo(x(10),y(450));
      dc.stroke();
      // X seconds.
      dc.strokeStyle = "cyan";
      for (var x1=0;x1<=10;x1+=1) { 
        dc.beginPath();
        dc.moveTo(x(x1),y(0));
        dc.lineTo(x(x1),y(1000));
        dc.stroke();
      }
      // Label Axes.
      dc.font = '20pt Helvetica';
      dc.textAlign = 'center';
      dc.textBaseline = 'middle';
      dc.fillStyle = 'black';
      dc.fillText('Seconds',x(5),y(-100));
      dc.save();
      dc.translate(50,210);
      dc.rotate(-Math.PI/2);
      dc.fillText('Millivolts',0,0);
      dc.restore();
    }

    window.onload = initialPlot;
  </script>
</head>

<body>
  <table>
      <td></td</tr>
    <tr valign=top>
      <td><canvas id="drawingCanvas" width="650" height="550"
                  onclick="to_png()" ></canvas></td>
      <td>
         <h3>OBDII Sensor Plot</h3>
         Bank 1, Sensor 1
         <input type="radio" name="o2s" id="os11" value="11" checked >
         Bank 1, Sensor 2
         <input type="radio" name="o2s" id="os12" value="12" ><br>
         <input type="button" id="geto2" onclick="get_o2()"
              value="Get Current Sensor Data" ><br>
         <div id="waiting">Waiting for new sensor data!</div>
         <br>Click on the graph to open a copy in a new window.</td></tr>
  </table>  
</body>
</html>

Web page screen shot: oxygensensor.html

Canvas saved as .png file: Canvas as .png

The command line:

$ ruby rap.rb
Enter commands or "help" or "quit":
>init
ELM327 v1.3a AUTO, SAE J1850 PWM OBD-II as defined by the CARB MIL OFF
DTC count: 0
Misfire                  Available   Complete
Fuel System              Available   Complete
Components               Available   Complete
Reserved                 Unavailable Complete
Catalyst                 Available   Complete
Heated Catalyst          Unavailable Complete
Evaporative System       Available   Complete
Secondary Air System     Unavailable Complete
A/C Refrigerant          Unavailable Complete
Oxygen Sensor            Available   Complete
Oxygen Sensor Heater     Available   Complete
EGR System               Available   Complete 
### watch rpms for 3 seconds:
>rpm/3
711 RPM 705 RPM 696 RPM 743 RPM 1071 RPM 1749 RPM
2593 RPM 2976 RPM 3341 RPM 3562 RPM 3707 RPM 
> q
Ending interactive session.

Revised July 11, 2014