Where the Heck am I? Connecting .NET 2.0 to a GPS
| |
This article explains how to connect to GPS device using .Net 2.0 and determine someone’s location. |
|
Scott Hanselman
Difficulty: Intermediate
Time Required: 3-6 hours
Cost: $100-$200
Hardware:
|
Upgrading an Application
I picked up the new Microsoft Streets and Trips 2005 with GPS and frankly I had pretty low expectations. I mean, how good could a $100 GPS possibly be? Well, pretty darn good. Not only is it TINY, but it uses the simple NMEA GPS Data standard. When you plug in the GPS via USB a virtual serial port is installed that lets you talk to the GPS as if it were just any old COM Port. This GPS speaks at 4800bps 8N1. If you open up HyperTerminal the GPS will just start spewing.
Then I noticed that EdJez had started working on a .NET 1.1 application to speak NMEA to GPSs so I remembered the first rule of programming - never write what you can beg, borrow or steal. I emailed Ed and his crew said I could inherit their .NET 1.1 work in progress. This meant that not only would I be able to talk to a GPS with .NET 2.0, but I'd also get to experience a pretty good upgrade scenario.
I read through the newly-converted-to-2.0 code and came up with a few things I'd like to work on. The original .NET 1.1 project consisted of a GPS Library that handled serial port IO and the NMEA Protocol, and a Windows Forms Executable called GPS Client that used the GPS Library. Remember that .NET 1.x didn't include managed support for talking to serial ports, so it used a technique called "Platform Invoke" or P/Invoke to talk directly to the non-.NET Win32 (Application Programming Interface) API. The Win32 methods aren't nearly as friendly as .NET and this particular library spent 400 lines of code just managing the serial ports.
Fortunately .NET 2.0 includes the new System.IO.Ports, so I figured I'd be able to use these. I started by opening the 1.1 Solution (.SLN) File into Visual Studio 2005 which launched an Upgrade Wizard. The Upgrade Wizard created a backup folder and reported no errors during the conversion of both projects! Fabulous. Now, even though the upgrade worked perfectly and the application ran unchanged, I did receive a number of warnings during compilation that I wanted to fix.
Also, because there were so many ways to hack serial port communication in .NET 1.1 there's no clean way for the Upgrade Wizard to convert the code to use System.IO.Ports. That means that the Upgrade Wizard does what it can to get your application running - very often without any code changes at all - but doesn't make design suggestions.
First, I took a chance and deleted the SerialHelper.cs and SerialDCB.cs files that contained the P/Invoke code. That means I literally deleted an entire subsystem. I knew that the application wouldn't compile after I'd done this, but it was a quick way to find out exactly how tangled the serial port code was. Don't be afraid to do brash experiments like this when you're working with your own code. The worst case scenario means you waste some time and you have to undo your change, but the best case scenario can be pretty great. I call this "programming by deletion." I figured there was a pretty good chance that reading from a serial port is a problem that's been solved pretty well and I assumed .NET 2.0 would get it right.
In this case, it turned out that the GPS Client only opened, read, then closed the serial ports using the old library. (That means there were 400 lines of code in the 1.1 version dedicated to supporting just these three methods!) It was pretty easy then to hook up the System.IO.Ports.SerialPort methods and move on to the next few small cosmetic issues.
Previous 1.x code
//serialHelper contains 200 lines of P/Invoke code,
// is specific to this application and has the
// speed and details hardcoded inside a method called InitializeserialHelper.
COMPort = (string)COMlistBox.SelectedItem;
serialHelper.Initialize();
New 2.0 code
Visual C#
//serialHelper.COMPort = (string)COMlistBox.SelectedItem;
//serialHelper.Initialize();
port.PortName = COMlistBox.SelectedItem as string;
port.Parity = Parity.None;
port.BaudRate = 4800;
port.StopBits = StopBits.One;
port.DataBits = 8;
port.Open();
Visual Basic
port.PortName = CType(COMlistBox.SelectedItem, String)
port.Parity = Parity.None
port.BaudRate = 4800
port.StopBits = StopBits.One
port.DataBits = 8
port.Open()
The new code is more explicit and very simple. The read() code changed also:
Previous 1.x code
byte[] bData = null;
bData = serialHelper.Read();
protocol.ParseBuffer(bData)
The old code was totally custom and embedded in the SerialHelper library. Note that bData is initialized to null but is returned from the .Read() method. The buffer was being allocated inside the old library. Now that we are using the generic .NET 2.0 SerialPort library we had to be more explicit and own our buffer.
New 2.0 code
Visual C#
byte[] bData = new byte[256];
//bData = serialHelper.Read();
port.Read(bData, 0, 256);
protocol.ParseBuffer(bData);
Visual Basic
Dim bData(255) As Byte
'bData = serialHelper.Read();
port.Read(bData, 0, 256)
protocol.ParseBuffer(bData)
Other Opportunities for Improvement
There was a HashTable in the previous version that associated an integer satelliteid with a Satellite object. The collection in the previous version was totally custom, required about 20 lines of miscellaneous code and was used like this:
Visual C#
foreach(Satellite sat in protocol.GPGSV.Satellites.satCollection.Values)
Visual Basic
Dim sat As Satellite
For Each sat In protocol.GPGSV.Satellites.satCollection.Values
Since .NET 2.0 includes support for generics which allow you to use a syntax like Dictionary<int, Satellite> to create a strongly typed collection entirely at runtime. The usage doesn't change much; it becomes slightly simpler. However, the whole custom collection class from the 1.x versions goes away and we yank another 20 lines without losing any functionality.
foreach(Satellite sat in protocol.GPGSV.Satellites.Values)
These kind of small, but significant changes are the kinds you'll find yourself coding if you ever have the opportunity to upgrade an application to from .NET 1.x to .NET 2.0. Things get simpler, can be done in fewer lines of code and just work.
The original application didn't do any resizing of the component controls. Writing a WinForms application that looks polished is difficult for most programmers. Getting the spacing of controls correct is difficult and folks, myself included, just don't have the patience to get the spacing and resizing correct. WinForms and the Visual Studio WinForms Designer has always supported a feature called "anchoring" that lets you keep one edge anchored to a point on the form while another edge or edges moves as the form resizes. I added resizing for the whole application, but particularly the Satellite View since it's always cool when graphics resize smoothly, eh?
NMEA Command and Data processing
The GPS Library exposes an object model that represents the NMEA standard. The National Marine Electronics Association (NMEA) created a standard data format consisting of a series of data sentences. Each sentence begins with a '$' and ends with a carriage return/line feed sequence. The data is contained within this single line with data items separated by commas. Here's an example from reference web page of a "Satellites in View" sentence. The Raw NMEA tab fills a text box with the actual data string. That raw data tabs was all I needed to add support for new NMEA data sentences.
$GPGSV,2,1,08,01,40,083,46,02,17,308,41,12,07,344,39,14,22,228,45*75
Where:
GSV Satellites in view
2 Number of sentences for full data
1 sentence 1 of 2
08 Number of satellites in view
01 Satellite PRN number
40 Elevation, degrees
083 Azimuth, degrees
46 SNR - higher is better
for up to 4 satellites per sentence
*75 the checksum data, always begins with *
The first word after $ is the command. There are over a dozen commands depending on which version of the specification your GPS supports. The slick thing about this simple format is that if your program doesn't understand a command it can ignore it. The original 1.1 application only supported a few commands, and I added a few more for this article. Each sentence is validated against checksum value at the end to ensure data integrity.
After we've found the command after the $, processing it is straightforward and extensibility is just adding a case to a switch statement:
Visual C#
public void ProcessCommand(string sCmd, byte[] bData)
{
string data = EncodeToString(bData);
switch(sCmd)
{
case "GPGGA":
ProcessGPGGA(data);
break;
case "GPGSA":
ProcessGPGSA(data);
break;
case "GPGSV":
ProcessGPGSV(data);
break;
case "GPRMC":
ProcessGPRMC(data);
break;
case "GPRMB":
//ProcessGPRMB(pData);
break;
case "GPZDA":
//ProcessGPZDA(pData);
break;
default:
break;
}
CommandCount = CommandCount + 1;
}
Visual Basic
Public Sub ProcessCommand(sCmd As String, bData() As Byte)
Dim data As String = EncodeToString(bData)
Select Case sCmd
Case "GPGGA"
ProcessGPGGA(data)
Case "GPGSA"
ProcessGPGSA(data)
Case "GPGSV"
ProcessGPGSV(data)
Case "GPRMC"
ProcessGPRMC(data)
Case "GPRMB"
'ProcessGPRMB(pData);
Case "GPZDA"
'ProcessGPZDA(pData);
Case Else
End Select
CommandCount = CommandCount + 1
End Sub 'ProcessCommand
Each ProcessXXXXX method takes the comma separated data after the command and loads it into an object model. For example, the GPGSV (Satelllites in View) command can be created by reading the spec above and applying it. Before any commands are passed to ProcessCommand() their integrity is validated against the trailing checksum value.
Visual C#
public void ProcessGPGSV(string data)
{
//parses the GPGSV stream to extract satellite information
string[] fields = Regex.Split(data,",");
uint totalNumberOfMessages = Convert.ToUInt32(fields[0]);
//make sure the data is OK. valid range is 1..8 channels
if ((totalNumberOfMessages > 8) || (totalNumberOfMessages <= 0))
return;
GPGSV.TotalNumberOfMessages = totalNumberOfMessages;
//message number
int nMessageNumber = Convert.ToInt32(fields[1]);
//make sure it is 0..9...
if ((nMessageNumber > 9) || (nMessageNumber < 0))
return;
//sats in view
GPGSV.SatellitesInView = Convert.ToInt32(fields[2]);
for (int iSat = 0; iSat < 4; iSat++)
{
Satellite sat = new Satellite();
sat.Id = Convert.ToInt32(fields[3+iSat*4]);
sat.Elevation = Convert.ToInt32(fields[4+iSat*4]);
sat.Azimuth = Convert.ToInt32(fields[5+iSat*4]);
sat.SignalQuality = Convert.ToInt32(fields[6+iSat*4]);
sat.Used = IsSatelliteUsed(sat.Id);
GPGSV.Satellites.Add(sat);
}
GPGSV.Count ++;
}
Visual Basic
Public Sub ProcessGPGSV(data As String)
'parses the GPGSV stream to extract satellite information
Dim fields As String() = Regex.Split(data, ",")
Dim totalNumberOfMessages As System.UInt32 =
Convert.ToUInt32(fields(0))
'ToDo: Unsigned Integers not supported
'make sure the data is OK. valid range is 1..8 channels
If totalNumberOfMessages > 8 OrElse totalNumberOfMessages <= 0 Then
Return
End If
GPGSV.TotalNumberOfMessages = totalNumberOfMessages
'message number
Dim nMessageNumber As Integer = Convert.ToInt32(fields(1))
'make sure it is 0..9...is there an inconsistency? 8/9?
If nMessageNumber > 9 OrElse nMessageNumber < 0 Then
Return
End If
'sats in view
GPGSV.SatellitesInView = Convert.ToInt32(fields(2))
'for(int iSat = 0; iSat < GPGSV.SatellitesInView; iSat++)
Dim iSat As Integer
For iSat = 0 To 3
Dim sat As New Satellite()
sat.Id = Convert.ToInt32(fields((3 + iSat * 4)))
sat.Elevation = Convert.ToInt32(fields((4 + iSat * 4)))
sat.Azimuth = Convert.ToInt32(fields((5 + iSat * 4)))
sat.SignalQuality = Convert.ToInt32(fields((6 + iSat * 4)))
sat.Used = IsSatelliteUsed(sat.Id)
GPGSV.Satellites.Add(sat)
Next iSat
GPGSV.Count += 1
End Sub 'ProcessGPGSV
The now-loaded object model loaded with data is then used to paint satellites on the WinForm and load various labels with data.
Painting the Satellites
The most fun part of any WinForms application for me is custom painting. The Satellite Tracking tab in this application takes the newly loaded GPGSV object and presents it visually. It's important to note that we're not doing the painting directly from the raw data string. If you're a new programmer it may seem like extra work, but it's much easier to "refine" the physical data (the data on the wire) into a logical form (our GPS object model) then do processing, painting or calculations on the logical model. You'll be partially insulated if the raw data should change in future. You'll find that encapsulation, or data hiding (even from yourself), is the most powerful aspect of Object Oriented Programming. Why should I have to sweat the details of the serial point and data format when I just want paint some satellites? By moving the data through multiple finite and simple bite-sized stages rather than one super-transformation you'll be able to create applications that are larger and more functional than any you've created before. OOP allows you to focus on the problem at hand while trusting in the other subsystems of your larger program.
Custom painting code in .NET is a joy. We create a Graphics context from a picture box that we'll be painting to. We paint a small Rectangle for each Satellite in the Satellite dictionary<int, Satellite> collection while doing some math on the azimuth and elevation values and map them to the circles and x,y coordinate system of the picture box. The picture box may resize and the satellites will move accordingly.
Visual C#
private void DisplaySatellites()
{
Pen circlePen = new Pen(System.Drawing.Color.DarkBlue,1);
Graphics g = picSats.CreateGraphics();
int centerX = picSats.Width/2;
int centerY = picSats.Height/2;
double maxRadius = (Math.Min(picSats.Height,picSats.Width)-20) / 2;
//draw circles
double[] elevations = new double[] {0,Math.PI/2, Math.PI/3 ,
Math.PI / 6};
foreach(double elevation in elevations)
{
double radius = (double)System.Math.Cos(elevation) * maxRadius;
g.DrawEllipse(circlePen,(int)(centerX - radius) ,
(int)(centerY - radius),
(int)(2 * radius),(int)( 2* radius));
}
//90 degrees elevation reticule
g.DrawLine(circlePen,new Point(centerX-3,centerY),
new Point(centerX + 3,centerY));
g.DrawLine(circlePen,new Point(centerX,centerY-3),
new Point(centerX,centerY+3));
Pen satellitePen = new Pen(System.Drawing.Color.LightGoldenrodYellow,4);
foreach(Satellite sat in protocol.GPGSV.Satellites.Values)
{
//SNIP...
//draw satellites
double h = (double)System.Math.Cos(
(sat.Elevation*Math.PI)/180) * maxRadius;
int satX = (int)(centerX + h * Math.Sin(
(sat.Azimuth * Math.PI)/180));
int satY = (int)(centerY - h * Math.Cos(
(sat.Azimuth * Math.PI)/180));
g.DrawRectangle(satellitePen,satX,satY, 4,4);
g.DrawString(sat.Id.ToString(),
new Font("Verdana", 8, FontStyle.Regular),
new System.Drawing.SolidBrush(Color.Black),
new Point(satX + 5, satY + 5));
}
}
Visual Basic
Private Sub DisplaySatellites()
labelSatellitesInView.Text =
protocol.GPGSV.SatellitesInView.ToString()
Dim circlePen As New Pen(System.Drawing.Color.DarkBlue, 1)
Dim g As Graphics = picSats.CreateGraphics()
Dim centerX As Integer = picSats.Width / 2
Dim centerY As Integer = picSats.Height / 2
Dim maxRadius As Double = (Math.Min(picSats.Height, picSats.Width)
- 20) / 2
'draw circles
Dim elevations() As Double = {0, Math.PI / 2, Math.PI / 3,
Math.PI / 6}
Dim elevation As Double
For Each elevation In elevations
Dim radius As Double =
System.Convert.ToDouble(System.Math.Cos(elevation)) *
maxRadius
g.DrawEllipse(circlePen, _CType(Fix(centerX - radius), Single),
_CType(Fix(centerY - radius), Single),
_CType(Fix(2 * radius), Single),
_CType(Fix(2 * radius), Single))
Next elevation
'90 degrees elevation reticule
g.DrawLine(circlePen, New Point(centerX - 3, centerY),
New Point(centerX + 3, centerY))
g.DrawLine(circlePen, New Point(centerX, centerY - 3),
New Point(centerX, centerY + 3))
Dim satellitePen As New Pen(System.Drawing.Color.LightGoldenrodYellow,
4)
Dim sat As Satellite
For Each sat In protocol.GPGSV.Satellites.Values
'if has a listitem
Dim lvItem As ListViewItem = CType(sat.Thing, ListViewItem)
If lvItem Is Nothing Then
lvItem = New ListViewItem(New String()
{sat.Id.ToString(), sat.Elevation.ToString(),
sat.Azimuth.ToString(), sat.Used.ToString()})
listSatellites.Items.Add(lvItem)
sat.Thing = lvItem 'lvItem;
Else
lvItem.Text = sat.Id.ToString()
lvItem.SubItems(1).Text = sat.Elevation.ToString()
lvItem.SubItems(2).Text = sat.Azimuth.ToString()
lvItem.SubItems(3).Text = sat.Used.ToString
End If
'draw satellites
Dim h As Double =
System.Convert.ToDouble(System.Math.Cos(sat.Elevation *
Math.PI / 180)) * maxRadius
Dim satX As Integer = Fix(centerX + h * Math.Sin(sat.Azimuth *
Math.PI / 180))
Dim satY As Integer = Fix(centerY - h * Math.Cos(sat.Azimuth *
Math.PI / 180))
g.DrawRectangle(satellitePen, satX, satY, 4, 4)
g.DrawString(sat.Id.ToString(), New Font("Verdana", 8,
FontStyle.Regular), New System.Drawing.SolidBrush(
Color.Black), New Point(satX + 5, satY + 5))
Next sat
End Sub 'DisplaySatellites
Conclusion
At this point, we've got a .NET 2.0 WinForms Application that will listen to any NMEA compliant GPS on a serial port. It receives data from a serial port, loads it into objects in memory, then updates the WinForm using the data in those objects. Here's some ideas of other things you can do yourself to extend this project:
- Implement the complete NWEA specification, or just add one or two new data sentences.
- Use the MapPoint Web Service to retrieve an image and paint it on the WinForm.
- Create a tab that creates "geo-art" by drawing a location trail as you move.
Enjoy expanding on the existing project and remember not to fear the words "Some Assembly Required!"
Big thanks to Edward Jezierski for the original 1.1 app, and to Daniel Cazzullino and Eugenio Pace for their help!