Click here to Skip to main content
15,885,032 members
Articles / Internet of Things / Raspberry-Pi / RaspberryPi4

Interface with Raspberry Pi I2C Sensors, Using .NET/Blazor

Rate me:
Please Sign up or sign in to vote.
5.00/5 (11 votes)
5 Jan 2021CPOL12 min read 13.4K   183   18   3
Using a Blazor Server, running on a Raspberry Pi, to display output from an MCP9808 I2C sensor
The Raspberry Pi can host a Blazor server to interface with sensors using the Runtime.InteropServices, accessing the Linux C libraries. First from a Console application, then with Blazor Server. I will also demonstrate adding a simple HTML meter to provide a little GUI. There are many third party gauge controls available, many are free, if you don't want to roll your own.

Introduction

Although I found articles on running .NET and Blazor on a Pi, I did not find anything using them to create a dashboard for I2C bus (Inter-Integrated Circuit, usually shown I2C) sensors. I wanted the ability to create GUI apps on the Pi that could be accessed from browsers on the network. My first attempt was accomplished by creating a syslog server on a Windows system and having the Pi write to it. Not really what I wanted. I thought it would be educational, as well as functional to use the newer .NET/Blazor technology to build a server on the Pi. With the newer Pi4 having up to 8GB of memory, many options for serving GUI exist.

Posts by two people on the Internet helped me get started: Jeremy Lindsay (on Wordpress) and Bradley Wells on his own site. Like most searching, one has to add to and modify to fit his unique situation. This is the result of that modification.

Background

The need came about when a friend was looking for a way to replace 1-wire bus temperature sensors in an office building. Since these sensors share the bus, it is difficult to troubleshoot. The sensor used in this article is I2C connected. For multiple sensors, a multiplexor is available for isolation (externally available, not in the Pi).

Using the Code

Caution: Linux is case sensitive.

Hardware

  • Raspberry Pi (I used version 4)
  • Power supply
  • Sensor MCP9808 temperature module
  • Small breadboard
  • Jumpers male/female and male/male 4 each (if you use one of the T-Cobbler breakouts, you won't need female to male)

Software

Latest version of Raspian (built from Debian). Download from https://www.raspberrypi.org/software/. At present, I am running 32 bit. I have not had much success trying to run .NET on the 64 bit versions (mostly BETA). If you have not provisioned the OS to a Pi before, there are step by step instructions at the same site.

When you configure the Pi, enable SSH, VNC and I2C. The first boot up should offer to do that, otherwise use Preferences. If you want to be able to FTP from your other systems, install vsftpd.

Programming on the Pi

I prefer to program the .NET stuff using Visual Studio Code, connected remotely from my Linux host. You can also use Visual Studio running on a Windows system, and copy the files over to the Pi and then do the publish from there. When you connect remotely via VS Code, you will get a prompt informing you about debugging and a link to a github article for doing same. I do not include that here. The 2 applications are pretty simple.

For the basics, fire up VS Code and remote connect to the Pi via SSH: hit Ctrl-shift-p, then scroll down to "Remote-SSH Connect to host". Enter pi@<IP address> (eg: pi@10.0.2.29). This assumes you are using the default user "pi". My pi is at address 10.0.2.29. Open the Terminal (Terminal/new terminal). We can run all of the below commands on this terminal.

Install .NET Core, for ARM

Available from: https://dotnet.microsoft.com/download/dotnet

At this time, 5.0.101 is the latest level. The SDK's are in the left column, you want the 32 bit version I have had best luck downloading the tarball and installing manually. You can get it onto your Pi several ways.

  1. Download to the Pi.
  2. Download to your host and transfer it to the Pi via FTP (you will need to install vsftpd).
  3. Sneakernet, download to host and transfer via thumb drive.
  4. If host is Linux, put the SD card in the host and copy to it. Once on your Pi:

Put the tarball in the Download directory. Remember, Linux is case sensitive. Linux uses "/" for directory structure, not "\".

Setup

Once you have the file in the @HOME/Downloads directory on the Pi, detailed instructions are available on line, but the steps are as follows:

  1. SSH into the Pi, or open a remote session on the Pi in VS Code (what I use), you can also just open a terminal on the Pi itself. I recommend VS Code, see instructions above. Note that you can also use remote SSH from the WSL on Windows.
  2. In a terminal session, issue the following commands:
    C++
    cd $HOME/Downloads
    mkdir -p $HOME/dotnet
    tar zxf dotnet-sdk-5.0.101-linux-arm.tar.gz -C $HOME/dotnet

    Make sure to use the level you downloaded, mine was 5.0.101.

  3. The tar command may take several seconds. When complete, add 2 environment variables with the following commands from the terminal:
    export DOTNET_ROOT=$HOME/dotnet
    export PATH=$PATH:$HOME/dotnet
  4. To make these 2 environment variables survive a reboot, add them to the bottom of your $HOME/.bashrc file. Note the "." in the name. You should be able to do it in VS Code or you could do it with:
    nano $HOME/.bashrc (nano is a rather basic editor, no mouse). 

    To test the install, in the termina issue:

    dotnet --version
    5.0.101

Create a console application to test our device and .NET

cd ~
mkdir TempConsole
cd TempConsole
dotnet new console
The template "Console Application" was created successfully.
Processing post-creation actions...
Running 'dotnet restore' on /home/pi/TempConsole/TempConsole.csproj...
  Determining projects to restore...
  Restored /home/pi/TempConsole/TempConsole.csproj (in 75 ms).
Restore succeeded.

dotnet run
Hello World

Note that you may get a nag screen about the debugger not working for Linux arm and offering a work around. I have not tested that for the Pi. If I am doing much Blazor stuff, I prefer to debug the non-GPIO stuff in Visual Studio and then copy the files to the Pi for completion. You will see in the final version that I have try/catch around the sensor stuff which allows running on a Windows system.

So, we have a console program in .NET, with the obligatory Hello World.

Wiring

The wiring is simple. The pin layout isn't. The pin numbers do not align with the GPIO numbers. Not really a problem for this device, I will reference pin numbers (not GPIO). We need 4 jumpers from the Pi to the device, Vcc (3.3 volts on pin #1) , Ground (on pin #6), I2C data (SDA, on pin #3) and I2C clock (CLK on pin #5). Raspberrypi.org has pictures of the layouts. My Pi came with one in the kit.

Modifying the simple console application we just created to get the data from the temperature module:
The datasheet for the module can be found at the manufacturer's site.

We need an I2C library to make life easier getting data from the temperature module. Today, the C# libraries do not exist. Well, if they do, I couldn't find them. So, we will use the Linux C library functions with interopServices. Example:

C#
[DllImport("libc.so.6", EntryPoint = "open")]
 public static extern int Open(string fileName, int mode);

For the open function.

Note that libc.so.6 is a symlink to the latest level of the library, currently libc-2.28.so on my Pi. Altogether, we need 4 functions: open, ioctl, read and write. I think the MCP9808 is a little unique in that it requires a write function to tell it which register we want to read.

In Linux, we talk to devices in the /dev folder. The I2C buses are there too. We open the first I2C device with the open function:

Open("/dev/i2c-1", OPEN_READ_WRITE);

This returns a handle (int). We use the handle for the Ioctl function which mounts our device. We set our client to 0x0703. The default bus device address for our device is 0x1A, there are also address pins to allow multiple devices, you jumper them high. We only need the default. The write command tells the device we want to read from register 0x05. We then read 2 bytes into the deviceData byte array. This gives the high and low order temperature bytes. The device data sheet gives the formula for calculating the temperature in degrees C. Some high school physics tells us how to get the F value. I am ignoring return codes here for brevity, you should check them, they return -1 for errors.

In your VS Code editor, go to the tempconsole folder and open the Program.cs file. in the Program.cs file replace all contents with:

C#
using System;
using System.Runtime.InteropServices;
namespace TempConsole
{
    class Program
    {
        // constants for i2c
        private static int OPEN_READ_WRITE = 2;
        private static int I2C_CLIENT = 0x0703;

       // externals for the i2c libraries
        [DllImport("libc.so.6", EntryPoint = "open")]
        private static extern int Open(string fileName, int mode);
        [DllImport("libc.so.6", EntryPoint = "ioctl", SetLastError = true)]
        private static extern int Ioctl(int fd, int request, int data);
        [DllImport("libc.so.6", EntryPoint = "read", SetLastError = true)]
        private static extern int Read(int handle, byte[] data, int length);
        [DllImport("libc.so.6", EntryPoint = "write", SetLastError = true)]
        private static extern int Write(int handle, byte[] data, int length);

        static void Main(string[] args)
        {
            // read from I2C device bus 1
            int i2cHandle = Open("/dev/i2c-1", OPEN_READ_WRITE);
            // mount the device at address 0x1A for communication
            int registerAddress = 0x1A;
            int deviceReturnCode = Ioctl(i2cHandle, I2C_CLIENT, registerAddress);
            Console.WriteLine(deviceReturnCode.ToString());
            //set byte arrays for specifying  the register and reading the data
            byte[] deviceData = new byte[2];
            byte[] reg=new byte[1];
            //we have to tell it what register to read 0x05
            reg[0]=0x05;
            deviceReturnCode= Write(i2cHandle,reg,1);
            //now we can read 2 bytes
            deviceReturnCode= Read(i2cHandle, deviceData, deviceData.Length);
            int msb=deviceData[0];  //most significant byte
            int lsb=deviceData[1];  //least significant byte
            //calculate according to the datasheet
            msb=msb & 0x1F;
            double tempc = (msb * 256) + lsb;
            if (tempc > 4095)//positive
                tempc -= 8192;//remove sign bit
            tempc *= .0625;
            //and a little high school physics for F
            double tempf=32+tempc*9/5;
            Console.WriteLine(tempc.ToString("N1")+(char)176+"C");
            Console.WriteLine(tempf.ToString("N1")+(char)176+"F");     
        }
    }
}

Now, to execute the program. In the terminal run:

C#
dotnet run
24.0°C
75.2°F

That tells us our module is working as well as .NET itself. You will get your own temperatures.

Blazor

Now we will bring Blazor into the act using our remote VS Code connection from the VS Code Terminal:

cd ~
mkdir tempserver
cd tempserver
dotnet new server

Similar to our Console program, this should create a Blazor server application and eventually say "Restore succeeded". Our first order of business is to make the server available to other systems on the LAN, the default Url is localhost only. Open the Program.cs file. In the IHostBuilder method there is a line "webBuilder.UseStartup<Startup>():" beneath that, add the line: WebBuilder.UseUrls("Http://*:5000"); Your method should now contain:

.ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                    webBuilder.UseUrls("Http://*:5000");
                });

Save the file and run:

dotnet run
Building...
info: Microsoft.Hosting.Lifetime[0]
      Now listening on: http://[::]:5000
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /home/pi/tempserver

Instead of getting our text output, we now have a Blazor server listening on port 5000. VS Code will usually ask if you want to open it in your host's browser. In my Pi, that would be http://10.0.2.29:5000. We did not set up for https because we have no certificate. If you open a port (like 5001) for https, your browser will try to connect via https and some will refuse to open it. Just use http.

Image 1

Another Hello World. That is a lot of progress coming from a Pi. To exit the program, from the terminal: Ctrl-c. Let's move our console temperature program over to the Blazor application and display the temperature on there. We will use the same code for reading from the sensor but put it in a function named GetTheTemp. First, we need to add a little (very basic) HTML for displaying the reading. A basic HTML meter will add some rather crude GUI. As I said earlier, there are many gauges you can use, I use one vendor's free version. Staying away from 3rd party stuff, I just used the basic meter here.

HTML
<p>-50<meter id="temp" value=@theTempC min="-50" max="100" ></meter>100 </p>

Where our temp will be in the variable theTempC (Celius). A button to get the reading (we will add a timer later).

C#
button class="btn btn-primary" @onclick="startRead">Start Temp Reading</button>

A text showing the readings in C and F.

HTML
<p>Temp is: @theTempC &#176C @theTempF &#176F </p>

A text to show an error if there is one.

HTML
<p>Error: @errorMessage</p>

In the Pages folder, open the file Index.razor. Keep the top line @page "/" Replace everything else with:

C#
@using System;
@using System.Runtime.InteropServices;
<h1>Current Temperature</h1>
<p>-50<meter id="temp" 
value=@theTempC min="-50" max="100" ></meter>100 </p>
<button class="btn btn-primary" @onclick="GetTheTemp">Start Temp reading</button>
<p>Temp is: @theTempC &#176C @theTempF &#176F </p><p>Error: @errorMessage</p>
@code 
{    
    public double currentTemp = 0.0;   
    public string theTempC = string.Empty;    
    public string theTempF = string.Empty;    
    int OPEN_READ_WRITE = 2;    
    string errorMessage = string.Empty;   
    [DllImport("libc.so.6", EntryPoint = "open")]
            private static extern int Open(string fileName, int mode);
            [DllImport("libc.so.6", EntryPoint = "ioctl", SetLastError = true)]
            private static extern int Ioctl(int fd, int request, int data);
            [DllImport("libc.so.6", EntryPoint = "read", SetLastError = true)]
            private static extern int Read(int handle, byte[] data, int length);
            [DllImport("libc.so.6", EntryPoint = "write", SetLastError = true)]
            private static extern int Write(int handle, byte[] data, int length);
    public  void GetTheTemp()   
    {        
        try        
        {                       
        int I2C_CLIENT = 0x0703; // read from I2C device bus 1            
        int i2cHandle = Open("/dev/i2c-1", OPEN_READ_WRITE);            
        // open the device at address 0x1A for communication            
        int registerAddress = 0x1A;            
        int deviceReturnCode = Ioctl(i2cHandle, I2C_CLIENT, registerAddress); //set byte 
                                 // arrays for specifying  the register and reading the data
        byte[] deviceData = new byte[2];            
        byte[] reg = new byte[1];            
        //we have to tell it to read register 0x05            
        reg[0] = 0x05;            
        deviceReturnCode = Write(i2cHandle, reg, 1);            
        //now we can read 2 bytes            
        deviceReturnCode = Read(i2cHandle, deviceData, deviceData.Length);            
        int msb = deviceData[0];  //most significant byte            
        int lsb = deviceData[1];  //least significant byte            
        //calculate according to the datasheet            
        msb = msb & 0x1F;            
        double tempc = (msb * 256) + lsb;            
        if (tempc > 4095)         //positive                
        tempc -= 8192;            //remove sign bit            
        tempc *= .0625;            
        //and a little high school physics for F            
        double tempf = 32 + tempc * 9 / 5;           
        currentTemp = tempc;            
        theTempC =     Convert.ToInt32(tempc).ToString();            
        theTempF = Convert.ToInt32(tempf).ToString();        
    }        
    catch (Exception e)        
    {                        
    errorMessage = e.ToString();  
    }    
  }    
}

Another dotnet run should show our temperature on the Home page after clicking the button.

Image 2

That is nice, but we have a hacked up version of the default server template and I have had mixed results when more than one system accesses the Blazor server. So I decided to make some changes and replace the function with a service.

  1. Create a timer service to refresh the reading.
  2. Convert the GetTheTemp function into a service.
  3. Customize to remove stuff we don't need/want from the Template.
  4. Publish (similar to linking) the solution so as not to have .NET do a build every time I want to run it.

Delete the Data folder.

In the Pages folder, delete Counter.razor and FetchData.razor.

Edit the following files:

In Startup.cs, remove the lines:

C#
using tempserver.Data;

and

C#
services.AddSingleton<WeatherForecastService>();

In the Shared/NavMenu.razor file, delete the list items for counter and fetchdata:

HTML
<li class="nav-item px-3">
  <NavLink class="nav-link" href="counter">
    <span class="oi oi-plus" aria-hidden="true"></span> Counter
  </NavLink>
</li>
<li class="nav-item px-3">
  <NavLink class="nav-link" href="fetchdata">
    <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
  </NavLink>
</li>

I also changed "Home" to Temperature.

In Shared/MainLayout.razor, I removed the About link. <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>

If you want to include your own About link, edit it instead of removing it.

Make another .NET run and access the server to make sure we didn't break anything. You should see our modifications.

Image 3

Now, we need to remove the "Start temp" button, we should use a timer to update the reading instead of having to click the button. We will create the timer as a service and convert our GetTheTemp method to a service.

In the tempserver folder, add a folder named Services. Open the Services folder and add a file named TempTimer.cs, and a file named GetTemp.cs.

My folder structure now looks like:

Image 4

Open the TempTime.cs and add the following:

C#
using System;
using System.Timers;
namespace tempserver.Services
{
    public class TempTimer
    {
        private Timer aTimer;
        public void SetTimer(double interval)
        {
            aTimer = new Timer(interval);
            aTimer.Elapsed += TimedOut;
            aTimer.Enabled = true;
        }
        public event Action OnTimeout;
        private void TimedOut(Object source, ElapsedEventArgs e)
        {
            OnTimeout();
        }
    }
}

Save the file.

Edit the GetTemp.cs file, add the following:

C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Runtime.InteropServices;
namespace tempserver.Services
{
    public class GetTemp
    {
        public string theTempC = string.Empty;
        public string theTempF = string.Empty;
        int OPEN_READ_WRITE = 2;
        public string errorMessage = "OK";
        [DllImport("libc.so.6", EntryPoint = "open")]
        private static extern int Open(string fileName, int mode);
        [DllImport("libc.so.6", EntryPoint = "ioctl", SetLastError = true)]
        private static extern int Ioctl(int fd, int request, int data);
        [DllImport("libc.so.6", EntryPoint = "read", SetLastError = true)]
        private static extern int Read(int handle, byte[] data, int length);
        [DllImport("libc.so.6", EntryPoint = "write", SetLastError = true)]
        private static extern int Write(int handle, byte[] data, int length);
        public void GetTheTemp()
        {
            try
            {
                int I2C_CLIENT = 0x0703;            // read from I2C device bus 1
                int i2cHandle = Open("/dev/i2c-1", OPEN_READ_WRITE);
                // open the device at address 0x1A for communication
                int registerAddress = 0x1A;
                int deviceReturnCode = Ioctl(i2cHandle, I2C_CLIENT, registerAddress);            
                //set byte arrays for specifying  the register and reading the data
                byte[] deviceData = new byte[2];
                byte[] reg = new byte[1];
                //we have to tell it to read register 0x05
                reg[0] = 0x05;
                deviceReturnCode = Write(i2cHandle, reg, 1);
                //now we can read 2 bytes
                deviceReturnCode = Read(i2cHandle, deviceData, deviceData.Length);
                int msb = deviceData[0];  //most significant byte
                int lsb = deviceData[1];  //least significant byte 
                //calculate according to the datasheet
                msb = msb & 0x1F;
                double tempc = (msb * 256) + lsb;
                if (tempc > 4095)         //positive
                    tempc -= 8192;        //remove sign bit
                tempc *= .0625;
                //and a little high school physics for F
                double tempf = 32 + tempc * 9 / 5;
                theTempC = Convert.ToInt32(tempc).ToString();
                theTempF = Convert.ToInt32(tempf).ToString();
            }
            catch (Exception e)
            {
                errorMessage = e.ToString();
                  //so we can see the exception
            }
        }
    }
}

Most of it is a copy of our method. Save the file. We need to register both classes as services. Open the file Startup.cs and in the ConfigureServices method, add:

C#
services.AddTransient<Services.TempTimer>();

and:

C#
services.AddSingleton<Services.GetTemp>();

That method should look like this:

C#
public void ConfigureServices(IServiceCollection services)

        {
            services.AddRazorPages();
            services.AddServerSideBlazor();
            services.AddTransient<Services.TempTimer>();
            services.AddSingleton<Services.GetTemp>();           
        }

Open Index.razor. We will get rid of the button, the method GetTheTemp and inject our classes. so, just replace everything with:

C#
@inject Services.TempTimer aTimer
@inject Services.GetTemp gTemp
@page "/"
@using System;
@using System.Runtime.InteropServices;

<h1>Current Temperature</h1>
<p>-50<meter id="temp" 
value=@gTemp.theTempC min="-32" max="100" ></meter>100 </p>
<p>Temperature: @gTemp.theTempC &#176C @gTemp.theTempF &#176F </p>
<p>Status: @gTemp.errorMessage</p>
@code 
{    
    protected override void OnInitialized()
    {
        gTemp.GetTheTemp();
                InvokeAsync(() => base.StateHasChanged());
           StartTimer();
    }
    void StartTimer()
    {
        InvokeAsync(() => base.StateHasChanged());
        aTimer.SetTimer(2000);
        // 2 seconds
        aTimer.OnTimeout += TimedOut;  
    }    

    void TimedOut()
    {
        gTemp.GetTheTemp();
        InvokeAsync(() => base.StateHasChanged());
        // refreshes the GUI
     }
}

Note that sTimer and gTemp are object names we will use to access the classes, I just made them up (I am lousy at naming things). Save the file and make another .NET run. This is our final version, such as it is.

Image 5

Avoiding static electricity by discharging yourself first, place your finger on the temp module and you should see the temperature rise every 2 seconds. Change the timer to suit yourself. To avoid having to do the build each time, we will publish our program. In the terminal, run:

C#
dotnet publish -r linux-arm -c Release -o /home/pi/tempserver/publish -p:PublishSingleFile=true
(after a minute or so)
Copyright (C) Microsoft Corporation. All rights reserved.

  Determining projects to restore...
  Restored /home/pi/tempserver/tempserver.csproj (in 378 ms).
  tempserver -> /home/pi/tempserver/bin/Release/net5.0/linux-arm/tempserver.dll
  tempserver -> /home/pi/tempserver/bin/Release/net5.0/linux-arm/tempserver.Views.dll
  tempserver -> /home/pi/tempserver/publish/

This will create an executable file named tempserver in the publish directory. The directory contains:

-rw-r--r--  1 pi pi  195 Dec 25 11:00 appsettings.Development.json
-rw-r--r--  1 pi pi  192 Dec 25 11:00 appsettings.json
-rwxr-xr-x  1 pi pi  78M Jan  2 17:48 tempserver
-rw-r--r--  1 pi pi  22K Jan  2 17:48 tempserver.pdb
-rw-r--r--  1 pi pi  20K Jan  2 17:48 tempserver.Views.pdb
drwxr-xr-x  3 pi pi 4.0K Jan  2 17:48 wwwroot

Note the size of the tempserver executable. 78 Meg. It contains the DLLs and such.

The executable is in the publish folder, the default, you can specify a different folder in the publish command.

To run it, we:

cd ./publish
./tempserver 
info: Microsoft.Hosting.Lifetime[0]
      Now listening on: http://[::]:5000
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /home/pi/tempserver/publish

Again:

Image 6

Now you can access from any device on your LAN, no need for VS Code, SSH into the Pi to start or just add it to the startup programs.

My CS professor used to say things like: "As an exercise for the student", add a text box to allow the user to select the delay time for refreshing the data. I set it for 2 seconds for test purposes. Have at it.

Conclusion

Having installed .NET on our Pi, we have opened up the ability to create a Web Server using Blazor and C#. The Pi has limited resources but I found performance to be acceptable.

Some things to consider about I2C. The bus is capacitance sensitive more so than resistance. This results in limitations concerning distance as well as the number of devices you can connect. You can work around some of this by varying pull up resisters but I prefer using an I2C extender that connects over Cat 5 (or above) cable. For shorter distances, you can also use a multiplexor to add addresses and separate data and clock lines.

License

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


Written By
Software Developer
United States United States
Been around forever. Wrote my first "program" in 1964. Spent many years supporting process control systems. Would have retired but never got around to putting comments in my code.

Comments and Discussions

 
PraiseGreat Cross-Platform Example Pin
mldisibio7-Jan-21 10:12
mldisibio7-Jan-21 10:12 
QuestionBlazor server? Pin
J4Nch6-Jan-21 7:29
J4Nch6-Jan-21 7:29 
AnswerRe: Blazor server? Pin
dkurok7-Jan-21 1:03
dkurok7-Jan-21 1:03 

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.