Click here to Skip to main content
15,868,070 members
Articles / Programming Languages / Python

Rotating a Cube with an L3G4200D Gyro Chip wired to a BeagleBone Black

Rate me:
Please Sign up or sign in to vote.
5.00/5 (22 votes)
8 May 2016CPOL11 min read 57K   180   23   16
My adventure with hardware and communicating between a BeagleBoneBlack and a C# app on Windows.
Using C# and RabbitMQ to communicate between the BeagleBoneBlack and the Windows C#.

Image 1

Introduction

I recently had fun using a BeagleBone Black (BBB) to send x-y-z rotational movement to a C# app, which would then rotate a 3D cube in response to the physical movement of the gyro.  I used RabbitMQ to communicate between the BBB and the Windows C# app.  This article shows how it's all done.  The code pretty much just work for the RaspberryPi as well.

The Hardware

The two hardware pieces that you'll need are:

  • A BeagleBoneBlack (or rPi, Arduino, or similar that supports I2C
  • An L3G4200D (or similar) gryo module

I2C is a multi-master, multi-slave, single-ended, serial computer bus.  The idea here is that you can have many devices communicating over two wires.  Each device has a unique address that lets you select to which device you're talking.

Note that I had purchased the BBB Starter Kit as this came with a lot of goodies in addition to the BBB itself: powersupply, breadboard, and patch wires, to name a few.

Image 2

The BeagleBoneBlack

The BBB has a lot of features, most of which I won't be using!

Image 3

The only reason I am using a BBB instead of an rPi or other single board computer (SBC) is because I have a couple of these lying around for some work that I'm doing for a client.

The L3G4200D Gyro

I picked this up at Radio Shack, of all places. 

Image 4

This is a 3-axis gryroscope -- it will report changes in orientation in three dimensions.  There is also a temperature sensor, which I won't be using.  I'm also not using the high pass filtering option or FIFO queues.  A good PDF datasheet on this device can be found here.

Hardware and Software Setup

Rather than use the Angstrom distro of Linux, I opted to install Debian -- mainly because we had experienced some problems with Angstrom stability.

Image 5

Installing Debian

Hardware

To install Debian, you pretty much need all of this to get started so that you configure Debian.  Once done, you really don't need the monitor or keyboard anymore because you can SSH into Debian from Windows.

To get started:

  • Obviously, a BeagleboneBlack and 5V power supply
  • An HDMI compatible monitor
  • USB keyboard
  • Ethernet cable
  • Ethernet router (usually your DSL/Cable modem will provide extra Ethernet ports)
  • Micro MMC card – I’ve been using SanDisk 32GB cards,
  • A computer able to read micro MMC cards. Most computers and laptops have this ability, you may need MMC to microSD adapter, or use an MMC USB reader like this one.

Software

Installation

On your Windows computer, install:

  1. 7-zip
  2. Win32DiskImager
  3. PuTTY
  4. WinSCP

Once that's done:

  1. Using 7-zip, unzip the Debian image
  2. Using Win32DiskImager, write the image to the uMMC card (make sure you don’t write it to your computer’s hard disk!)
  3. With the Beaglebone powered off, insert the uMMC into the micro SD slot
  4. Connect the Ethernet cable, USB keyboard, HDMI monitor to the BBB
  5. Plug the other end of the Ethernet cable into your router (which your computer should also be connected to, either physically or wirelessly.)
  6. Plug in the power to the BBB

When Debian boots, you should be able to log in with the username “debian” and the password “temppwd”

At the prompt, type in “ifconfig” and press enter. You should see something similar to:

eth0 Link encap:Ethernet HWaddr 7c:66:9d:53:cd:97
inet addr:192.168.1.42 Bcast:192.168.1.255 Mask:255.255.255.0
inet6 addr: fe80::7e66:9dff:fe53:cd97/64 Scope:Link

Note the “inet addr”, for example, above, it is 192.168.1.42

Launch PuTTY and for the host name, enter “debian@” followed by the IP address of the BBB, similar to this:

Image 6

Click on the Open button at the bottom of the PuTTY window. You will need to acknowledge (click on Yes) the prompt that comes up “Continue connection to an unknown server and add its host key to a cache”. Enter the debian password (temppwd). You should something similar to this:

Image 7

Congratulations! You are remotely connected to the BBB!

Open WinSCP and edit the session settings, similar to this (replace the IP with your BBB’s IP):

Image 8

Click on the Login button (you will probably need to confirm the “Continue connection to an unknown server and add its host key to a cache” question again).

You should now be able to use WinSCP to transfer files between your Windows computer and the BBB!

Image 9

Note that Debian comes with an SSH server built in.  On other OS's, like Ubuntu, you'll have to install the SSH server:

sudo apt install openssh-server

The BBIO Software

Adafruit has a pre-built I/O library for both the rPi and the BBB and excellent documentation on getting started.  Basically, you run these commands from an SSH client terminal window:

sudo apt-get update
sudo apt-get install build-essential python-dev python-setuptools python-pip python-smbus -y
sudo pip install Adafruit_BBIO

Please visit their website (link above) for further information.

The BBIO software is designed to be used with Python.  The beauty of Python (and similar interpreted languages) is that you can run (and more importantly, test) all of the hardware independent code on both Windows and the Debian OS. 

Python in Visual Studio

I'm still using Visual Studio 2012, so I find the Python Tools for Visual Studio plugin to be very helpful, as I can use a decent IDE for developing the Python code.  PyCharm, by JetBrains, is also an excellent choice.

RabbitMQ

My demo application uses RabbitMQ to communicate between the BBB and Windows.  You can download the RabbitMQ server from this page.

Configuring RabbitMQ

In order to send messages between two devices (other than just two applications on localhost), you'll need to set up a username and password for RabbitMQ, and the permissions for the account.

Open up a console window to the RabbitMQ server "sbin" folder.  The easiest way to do this is, after installing RabbitMQ, you should be able to open in from Start -> All Programs -> RabbitMQ Server -> RabbitMQ Command Prompt (sbin dir).  In the console window, set up a user and permissions:

Terminal
rabbitmqctl add_user [user] [password]
rabbitmqctl set_user_tags [user] administrator
rabbitmqctl set_permissions -p / [user] ".*" ".*" ".*"

Replacing [user] and [password] with your personal preference.  The username and password will be used later on the BBB client.

Various Pieces on the BBB

Finally, you'll need need to install some additional pieces on the BBB.  SSH into the BBB, and:

1. Install pika, the RabbitMQ Python client

sudo pip install pika

2. Install bitstring, used to work with binary data:

sudo pip install bitstring

Wiring the Gyro to the BBB

With the power off to the BBB, wire up the gyro.  Although this website uses a different gyro, I found it very useful to figure out how to wire up the device to the I2C bus.  Only four connections are required:

Device Pin    BeagleBone    Signal
GND           P9-1          GND
VCC           P9-3          +3.3V
SCL           P9-19         I2C2-SCL
SDA           P9-20         I2C2-SDA

Image 10

(Visio is not the right tool for this!)

A complete description of the pinouts on the BBB is:

Image 11

Software

Now all that's needed are the two pieces of software -- the Python code on the BBB and the C# rotating cube (or rectangle in my case) on Windows.

Python Code

The Python code is separated into three modules:

  1. The "app"
  2. The gyro reader
  3. The RabbitMQ queue sender

The App

This "main application" instantiates the Gyro class and QueueSender, configured with the username and password you set up earlier for the RabbitMQ server.  It also defines a callback function which sends a JSON string over the RabbitMQ queue:

Python
from L3G4200D import *
from QueueSender import *

class App(object):
  RABBITMQ_PORT = 5672
  SERVER_QUEUE_NAME = "bbbsensors"

  def __init__(self):
    self.gyro = Gyro(self.callback)
    self.queue = QueueSender('[user]', '[password]', '[serverip]', App.RABBITMQ_PORT, App.SERVER_QUEUE_NAME)

  def callback(self, x, y, z):
    self.queue.send({"x":str(x), "y":str(y), "z":str(z)}) 

if __name__ == '__main__':
  App().gyro.run()

Again, replace [user] and [password] with the username and password you configured in the RabbitMQ server.  Also, replace [serverip] with the IP address of the machine running the RabbitMQ server.

Queue Sender

The QueueSender class uses pika to send the message over the RabbitMQ queue.  Note the exception handler.  RabbitMQ will shut down a queue if it has not been in use for a while (10 minutes or so, I believe.)  Even though we're constantly sending messages, it's a good idea to re-initialize the queue on exception and try again.

Python
import pika

class QueueSender(object):
  def __init__(self, username, password, ipAddress, port, queueName):
    self.username = username
    self.password = password
    self.ipAddress= ipAddress
    self.port = port
    self.queueName = queueName
    self.initialize()

  def initialize(self):
    self.credentials = pika.PlainCredentials(self.username, self.password)
    self.connection = pika.BlockingConnection(pika.ConnectionParameters(self.ipAddress, self.port, '/', self.credentials))
    self.channel = self.connection.channel()

  def send(self, msg):
    try:
      self.channel.basic_publish(exchange='', routing_key=self.queueName, body=str(msg))
    except:
      print("Exception on send. Re-establishing connection...")
      self.initialize()
      try:
        self.channel.basic_publish(exchange='', routing_key=self.queueName, body=str(msg))
      except:
        print("Unable to send!")

The Gyro Reader

Lastly, we have the longest bit of code, which reads the gyroscope ad-infinitum.  A small timeout is introduced between reads so as not to swamp the message queue.  Also note that this code does not use interrupts.  The chip can be configured to generate an interrupt when data is available, but wiring that into the Python code is something that is beyond my knowledge at this point!

Python
import smbus
import bitstring 
import time

class Gyro(object):
  ADDRESS = 0x69
  BUS1 = 1

  def __init__(self, callback):
    self.bus1 = smbus.SMBus(Gyro.BUS1)
    self.initializeDevice()
    self.callback = callback
    print "Calibrating..."
    self.calibrate();
    print "Rest Offsets: " + str(self.restOffsetX) + ", " + str(self.restOffsetY) + ", " + str(self.restOffsetZ)

  def initializeDevice(self):
    self.bus1.write_byte_data(Gyro.ADDRESS, 0x20, 0x0F) # normal, x/y/z enabled

  def calibrate(self):
    """ Average 500 samples to get the rest state offset for each vector. """
    sampleSize = 500
    dx = 0.0
    dy = 0.0
    dz = 0.0

    for n in xrange(sampleSize):
      x, y, z = self.readRaw()
      dx += x
      dy += y
      dz += z

    self.restOffsetX = int(dx / sampleSize)
    self.restOffsetY = int(dy / sampleSize)
    self.restOffsetZ = int(dz / sampleSize)

  def readRaw(self):
    xl = self.bus1.read_byte_data(Gyro.ADDRESS, 0x28) 
    xh = self.bus1.read_byte_data(Gyro.ADDRESS, 0x29) 
    yl = self.bus1.read_byte_data(Gyro.ADDRESS, 0x2A)
    yh = self.bus1.read_byte_data(Gyro.ADDRESS, 0x2B)
    zl = self.bus1.read_byte_data(Gyro.ADDRESS, 0x2C)
    zh = self.bus1.read_byte_data(Gyro.ADDRESS, 0x2D)
    # status = self.bus1.read_byte_data(Gyro.ADDRESS, 0x27)

    x = bitstring.Bits(uint = (xh << 8) | xl, length=16).unpack('int')[0]
    y = bitstring.Bits(uint = (yh << 8) | yl, length=16).unpack('int')[0]
    z = bitstring.Bits(uint = (zh << 8) | zl, length=16).unpack('int')[0]

    return x, y, z

  def readAdjusted(self):
    """ Adjust raw vectors by the rest offsets. """
    x, y, z = self.readRaw()
    x -= self.restOffsetX
    y -= self.restOffsetY
    z -= self.restOffsetZ
    return x, y, z

  def run(self):
    while True:
      x, y, z = self.readAdjusted()
      self.callback(x, y, z)
      time.sleep(0.01)

Notice that this code first runs a calibration routine.  This is probably not the best implementation, but the purpose is to compensate for drift because the gyro will not return 0 when sitting still -- in fact, there's quite a bit of noise, +/-128 typically.  The calibration attempts to determine what the average offset from 0 is, which would otherwise cause drift in the rotation when the gyro is stationary.

Also notice the commented out line that reads the status.  In a more robust application, the status is useful in determining whether rotational data is available, as well as whether there are buffer overruns.  Because this is a non-interrupt implementation implemented in an interpreted language, there will almost always be buffer overruns, which for the purpose of this demo we are ignoring.

C# Code

On the C# side, I'm using two additional assemblies:

GryoData

This is a simple class into which to deserialize the JSON data:

C#
using System;

namespace BeagleboneSensors
{
  public class GyroData
  {
    public int X { get; set; }
    public int Y { get; set; }
    public int Z { get; set; }
  }
}

Helpers

A class I use all the time to determine my localhost IP address:

C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;

namespace BeagleboneSensors
{
  public static class Helpers
  {
    public static List<IPAddress> GetLocalHostIPs()
    {
      IPHostEntry host;
      host = Dns.GetHostEntry(Dns.GetHostName());
      List<IPAddress> ret = host.AddressList.Where(ip => ip.AddressFamily == AddressFamily.InterNetwork).ToList();

      return ret;
    }
  }
}

This determines our IP address, which in our case, is where the RabbitMQ server is running.

RabbitMqIO

This class is responsible for creating a connection to the RabbitMQ server, and for gracefully shutting it down when the program exits.

C#
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Net;

using RabbitMQ.Client;

namespace BeagleboneSensors
{
  public abstract class RabbitMqIO
  {
    protected List<IPAddress> localHostIPs;
    protected IConnection connection;
    protected IModel channel;

    public RabbitMqIO()
    {
      localHostIPs = Helpers.GetLocalHostIPs();
    }

    public virtual void CreateConnection()
    {
      ConnectionFactory factory = new ConnectionFactory();
      factory.UserName = ConfigurationManager.AppSettings["username"];
      factory.Password = ConfigurationManager.AppSettings["password"];
      factory.VirtualHost = "/";
      factory.Protocol = Protocols.DefaultProtocol;
      factory.HostName = localHostIPs[0].ToString();
      factory.Port = 5672;

      connection = factory.CreateConnection();
      channel = connection.CreateModel();
    }

    public virtual void Shutdown()
    {
      channel.Dispose();
      connection.Dispose();
    }
  }
}

We obtain the RabbitMQ server's username and password from the app.config file:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <startup>
    <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
  </startup>
  <appSettings>
    <add key ="username" value="[user]"/>
    <add key ="password" value="[password]"/>
  </appSettings>
</configuration>

Replace [user] and [password] with the same username and password you set up in the RabbitMQ server.

RabbitMqReceiver

The receiver is derived from the RabbitMqIO class and implements the CreateConnection method, which declares the queue on which we are listening and sets up and event receiver, which parses the JSON data, populates a GyroData object, and fires a handler for doing something with the data.

C#
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Net;
using System.Text;

using Newtonsoft.Json;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;

using Clifton.Core.ExtensionMethods;

namespace BeagleboneSensors
{
  public class GyroEventArgs : EventArgs
  {
    public GyroData GyroData {get;protected set;}

    public GyroEventArgs(GyroData data)
    {
      GyroData = data;
    }
  }

  public class RabbitMqReceiver : RabbitMqIO
  {
    public string QueueName { get; set; }
    public event EventHandler<GyroEventArgs> GyroData;

    public RabbitMqReceiver()
    {
      QueueName = "bbbsensors";
    }

    public override void CreateConnection()
    {
      base.CreateConnection();
      DeclareQueue(channel, QueueName);
      EventingBasicConsumer consumer = new EventingBasicConsumer(channel);

      consumer.Received += (model, eventArgs) =>
      {
        byte[] body = eventArgs.Body;
        string message = Encoding.UTF8.GetString(body);
        System.Diagnostics.Debug.WriteLine(message);
        GyroData data = JsonConvert.DeserializeObject<GyroData>(message);
        GyroData.Fire(this, new GyroEventArgs(data));
      };

    channel.BasicConsume(QueueName, true, consumer);
    }

    private static void DeclareQueue(IModel channel, string queueName)
    {
      channel.QueueDeclare(queueName, false, false, false, null);
    }
  }
}

Like I said earlier, the use of NewtonSoft JSON is sort of pathetic, but then again, if I were to extend this to do more interesting things, it would come in handy to have a proper JSON parser already in use.

The Main Program

The main application setup is very simple:

C#
using System;
using System.Collections.Generic;
using System.Windows.Forms;

namespace BeagleboneSensors
{
  static class Program
  {
    public static RabbitMqReceiver receiver;

    [STAThread]
    static void Main()
    {
      Application.EnableVisualStyles();
      Application.SetCompatibleTextRenderingDefault(false);

      receiver = new RabbitMqReceiver();
      receiver.CreateConnection();

      Form form = new FrmRender();
      form.FormClosing += (sender, args) =>
      {
        receiver.Shutdown();
      };

    Application.Run(form);
    }
  }
}

Cube Rendering

Image 12

OK, it's not a cube, it's a rectangle.  Actually, it's rectangular box.  I totally "borrowed" this code from vcskicks.com here and modified it to color the sides and removed the manual x/y/z sliders and other UI elements.  I'm not going to go into how the rotation works, you can read the article (and the previous one referenced as well.)  What I did do is wire up the GyroData event handler:

C#
public FrmRender()
{
  InitializeComponent();
  Program.receiver.GyroData += OnGyroData;
}

and implemented the handler:

C#
private float dx = 0;
private float dy = 0;
private float dz = 0;

private void OnGyroData(object sender, GyroEventArgs e)
{
  this.BeginInvoke(() =>
  {
    dx += (float)(e.GyroData.X / 4000.0);
    dy += (float)(e.GyroData.Y / 4000.0);
    dz += (float)(e.GyroData.Z / 4000.0);

    mainCube.RotateX = dx;
    mainCube.RotateY = dy;
    mainCube.RotateZ = dz;
    Render();
  });
}

This implements what was originally done by the sliders.  It was a great example of code re-use and got me the results I wanted with minimal effort.

Other Things of Interest

BeagleBone CPU Frequency

The default CPU frequency of the BBB is 300Mhz.  If you want, you can crank this up to 1Ghz with:

sudo cpufreq-set -f 1000MHz

Because of the 10ms sample delay in the Python code, I've not noticed that this makes any difference.

You can determine what frequency your BBB is running at with:

cpufreq-info
Terminal
debian@beaglebone:~/gyro$ cpufreq-info
cpufrequtils 008: cpufreq-info (C) Dominik Brodowski 2004-2009
Report errors and bugs to cpufreq@vger.kernel.org, please.
analyzing CPU 0:
driver: generic_cpu0
CPUs which run at the same hardware frequency: 0
CPUs which need to have their frequency coordinated by software: 0
maximum transition latency: 300 us.
hardware limits: 300 MHz - 1000 MHz
available frequency steps: 300 MHz, 600 MHz, 800 MHz, 1000 MHz
available cpufreq governors: conservative, ondemand, userspace, powersave, performance
current policy: frequency should be within 300 MHz and 1000 MHz.
The governor "ondemand" may decide which speed to use
within this range.
current CPU frequency is 300 MHz.
cpufreq stats: 300 MHz:99.95%, 600 MHz:0.01%, 800 MHz:0.00%, 1000 MHz:0.03% (338)

Detecting I2C Devices

You can detect I2C devices (but be careful) using the i2cdetect program (read about it here):

i2cdetect -y -r 1

This gives you a device map:

Terminal
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f 
00:          -- -- -- -- -- -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- UU UU UU UU -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- 69 -- -- -- -- -- --
70: -- -- -- -- -- -- -- --

The device ID of the L3G4200D gryo is "69", you can see that it is detected there for address 69.

Conclusion

This has been a fun project, and I'm planning on doing other fun things like a little weather station with temperature, pressure, and humidity sensors.  The BBB is definitely "overpowered" for an example like this -- if you wanted to use the gyro on, say, a radio controlled car or other RC device (drone anyone?) I'd definitely suggest looking into a lower power consuming device, like an Arduino or rPi, though of course you'd have to configure a wifi or other wireless connection.

Also, a lot of the hard work has already been done -- the adafruit BBIO library is fantastic, and Python is a great language to prototype working with I/O, but if you want to do anything of quality (for example, using interrupts), C is pretty much the language I would go with, so you can use compiled, high performance code.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Architect Interacx
United States United States
Blog: https://marcclifton.wordpress.com/
Home Page: http://www.marcclifton.com
Research: http://www.higherorderprogramming.com/
GitHub: https://github.com/cliftonm

All my life I have been passionate about architecture / software design, as this is the cornerstone to a maintainable and extensible application. As such, I have enjoyed exploring some crazy ideas and discovering that they are not so crazy after all. I also love writing about my ideas and seeing the community response. As a consultant, I've enjoyed working in a wide range of industries such as aerospace, boatyard management, remote sensing, emergency services / data management, and casino operations. I've done a variety of pro-bono work non-profit organizations related to nature conservancy, drug recovery and women's health.

Comments and Discussions

 
GeneralMy vote of 5 Pin
netizenk7-Dec-17 8:52
professionalnetizenk7-Dec-17 8:52 
Questionwow Pin
Member 1330349010-Jul-17 20:43
Member 1330349010-Jul-17 20:43 
GeneralMy vote of 5 Pin
Gun Gun Febrianza6-Jun-16 11:27
Gun Gun Febrianza6-Jun-16 11:27 
GeneralMy vote of 5 Pin
raddevus17-May-16 7:29
mvaraddevus17-May-16 7:29 
PraiseBBB, RabbitMQ wow! Pin
User 24629918-May-16 16:15
professionalUser 24629918-May-16 16:15 
PraiseWow Pin
JAnderson858-May-16 12:52
JAnderson858-May-16 12:52 
GeneralRe: Wow Pin
Marc Clifton8-May-16 14:51
mvaMarc Clifton8-May-16 14:51 
QuestionAwesome ! Pin
Garth J Lancaster8-May-16 12:45
professionalGarth J Lancaster8-May-16 12:45 
AnswerRe: Awesome ! Pin
Marc Clifton8-May-16 14:50
mvaMarc Clifton8-May-16 14:50 
GeneralRe: Awesome ! Pin
Garth J Lancaster9-May-16 16:12
professionalGarth J Lancaster9-May-16 16:12 
GeneralRe: Awesome ! Pin
Marc Clifton10-May-16 4:54
mvaMarc Clifton10-May-16 4:54 
GeneralRe: Awesome ! Pin
Garth J Lancaster16-May-16 18:35
professionalGarth J Lancaster16-May-16 18:35 
GeneralRe: Awesome ! Pin
Garth J Lancaster21-May-16 18:50
professionalGarth J Lancaster21-May-16 18:50 
GeneralRe: Awesome ! Pin
Marc Clifton22-May-16 3:53
mvaMarc Clifton22-May-16 3:53 
Hi Garth,

Sorry about not replying to your previous post -- the mikrobus cape looks really great, in fact, I passed the info along to my client in case he thinks we can use it on our project.

As to your question -- you can either copy the pieces that you want from my article (just link back to mine) or if you want, we can co-author, assuming I make a meaningful contribution to your article, particularly if you guide me on what changes you'd want.

Either way!

Marc
Imperative to Functional Programming Succinctly

Contributors Wanted for Higher Order Programming Project!

Learning to code with python is like learning to swim with those little arm floaties. It gives you undeserved confidence and will eventually drown you. - DangerBunny

GeneralRe: Awesome ! Pin
Marc Clifton1-Jun-16 4:13
mvaMarc Clifton1-Jun-16 4:13 
GeneralRe: Awesome ! Pin
Garth J Lancaster1-Jun-16 15:16
professionalGarth J Lancaster1-Jun-16 15:16 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.