Connecting to serial ports
It is time to have some fun. Let’s break out of the confinements of the machine we are working on and step into the world of peripherals.
However, before we look at the code, we have to see how software communicates with hardware.
The path to the hardware
Let’s say we have an application connecting to some hardware. It doesn’t matter what kind of hardware, but let’s say we want to send data to a USB port.
Sending data from our application to the device involves several steps where the data is transformed. It is slightly like the OSI layer we discussed in the previous chapter.
It all starts with our application. We devised the C# code to send data to the USB device. We have downloaded the correct NuGet packages, installed the frameworks, written the code, and compiled it into an executable.
When that executable runs, that code is called the correct code in the .NET libraries installed on your machine when you installed the runtime or the SDK. The BCL has a class called SerialPort
that receives the commands from your code and translates them to the next layer, where the .NET runtime hands over the commands to the operating system. In our case, that is Windows. Windows looks at the data and where it needs to go and decides it cannot handle it. It is hardware, so the operating system calls upon the device driver for the USB port.
The device driver ensures it has everything needed for the specific hardware it is written for. It knows about baud rates, parity, stop bits, and so on. Once that is all figured out, the device driver sends the data to the USB/serial controller. This controller is a bit of hardware that physically connects to the port.
Once the data has come that far, it leaves our system on a set of wires, out of our machine, and on its way to some other hardware.
A lot is going on, but we hardly see it. All we see in our code is the following:
using var serialPort = new SerialPort( "COM3", 9600, Parity.None, 8, StopBits.One); serialPort.Open(); try { serialPort.Write([42],0, 1); } finally { serialPort.Close(); }
We create an instance of the SerialPort
class. We give it the parameters it expects. First, we need to specify which port we want to talk to. Computers usually have more than one serial port. In the old days, computers did have a minimal number of physical ports. They were either parallel ports, capable of sending multiple bits simultaneously, or serial ports, which could only process one bit simultaneously. The serial ports were also called Communication Ports, shortened to COMs. In my example, we connect to the third one because I happen to know there is hardware attached to that port that I can talk to.
I also give it the speed – in my case, 9,600 baud.
Baud versus bits per second
There is a common misconception about the best way to describe the speed of communications. We used baud for the older COM ports. The term baud is named after the French scientist Jean-Maurice-Emile Baudot (1845–1903), who worked on a system to allow multiple transmissions of a single telegraph wire. Baud stands for the number of signal changes per second.
Bits per second means just that – how many bits per second can we send? Since baud is analog and can combine signals, there is no direct relationship between baud and bits per second.
However, in most cases, they are pretty close. 9,600 baud can be considered to be around 9,600 bits per second. But don’t rely on it!
On a related note, a byte does not have to be 8 bits. A byte transmitted over a wire can be as long as 12 bits, depending on the communication settings.
We also define parity as none. We set the data packet to be 8 bits. We also add 1 stop bit.
The settings I have given here (no parity, 8 bits, and 1 stop bit) are the default settings, but you could have omitted them. However, you must ensure the device on the other side of the line uses the same settings. You can imagine what a mess it would be if you send 10 bits per byte, some of which are for error checking, and the other side expects only 8 bits to be sent per byte. It is better to be clear about this sort of thing.
Once we have SerialPort
, we can open the connection. And when that is done, we send 1 byte over the wire. Somewhere in the chain from our application to the actual wires, the parity, conversion to the correct number of bits, and stop bits are added or converted, but we have nothing to do with that. The BCL, OS, and device drivers take care of it.
Of course, we finalize it all by closing the port again.
Receiving data is just as simple, but we will look at that later on.
Why do we care?
Serial communication, especially over COM ports, is old-school technology. These days, we use a wired network, Wi-Fi, Bluetooth, or USB if we want to connect to other hardware. Or, at least, that is what you might think.
For most software developers, this is true. They will hardly ever encounter a thing such as a serial port. But we system programmers are not like most software developers. We deal with hardware. And often, that hardware is old. Or at least the design of that hardware is old.
For instance, many factories have robots. A lot of them communicate over serial ports. Medical devices are another example. It takes a very long time to certify medical equipment, so the manufacturer is usually very reluctant to change part of the hardware just because a new kind of cable has been announced. They tend to stick to what works. As long as serial communications work well enough, they keep using them.
Industrial CNC machines, barcode scanners, and GPS receivers are all examples of hardware still used widely today that rely on serial ports. We system programmers are the developers most likely to encounter those devices.
So, it’s crucial that you know what serial communication is and what it does. But, of course, how can you program for it?
Although you are not likely to see actual D-Port-style serial connectors on computers anymore (unless you specifically add one), serial ports are still a thing. The difference between those older ports and what we use today is that we use virtual COM ports.
The operating system and the device drivers channel communication through the USB port to the outside world, mimicking the older ports. The D-style ports had multiple pins for power, ground, data, TX signals, and much more. These days, the USB devices take care of that. But if you want to connect to one of these older machines, you can get cheap and simple USB-to-serial (or, technically, RS232) converters.
I suspect we will have serial ports for a long time to come. That is why I’m spending so much time discussing them here in this chapter.
A word about parity, data sizes, and stop bits
We set the serial port in the previous sample to use no parity, 8 data bits, and 1 stop bit. But what does that mean?
Usually, you do not need to care about how the actual hardware communicates. If you want to load a file from your storage medium, you are not bothered by the internal workings. You do not care whether the medium is a super-fast SSD or a slow SD card inserted somewhere. You choose where to store your data, and you are good to go. The operating system and device drivers take care of the rest.
For COMs, this isn’t an option. You do not have to worry about the voltages across the wires, but you have to know a bit more about how the devices want to communicate. Oh, in case you are wondering, for low-speed USB devices, the voltages are between 0.0V and 0.3V for a zero and between 2.8V and 3.6V for a one. Now you know.
So, what do we need to know if we want to communicate over a serial communication line? Well, there are four parameters we need to decide on. Both the sender and the recipient need to agree on this. The serial protocol does not care: it only knows how to put ones and zeros on that line. We need to tell our software what that data means.
The parameters we need to set are the speed, whether we want to use parity, the size of a data packet, and whether we want to use stop bits.
Speed
Speed is essential. We specify the speed as the number of changes in voltages per second. We do not specify it in bits per second. This distinction is important because a bit is a discrete unit. A bit is a bit. Nothing more, nothing less. But a bit does not exist in the world of electronics; all we can deal with is a flow of electrons, making up voltages (I am really oversimplifying things here, but the basic idea is valid).
If a wire has a high voltage for a second, followed by a low voltage for a second, we have no idea what that means. It is just that – one second of high voltage, followed by one second of low voltage.
But if we establish that we can do four changes per second, we can determine that we got eight changes; the first four were high, and the second four were low. Then, we can agree that we had four 1s, followed by four 0s. Thus, in two seconds, we transmitted the bits 11110000. But if we would have established that we can do eight changes per second, the data would have been 11111111 00000000. And that is an entirely different number.
The baud rate, which we use to specify the speed, tells a system how much data is transmitted in a certain time or how long it takes to send one element (OK, this is a bit) over the wire.
It’s all about timing, which can help hardware do some rudimentary error checking. I will explain this when we talk about stop bits.
Parity
Sometimes, data gets messed up. We are dealing with electrical connections here, which can sometimes be unreliable. Sometimes, the voltage drops or a spike occurs, getting in the way of the data we want to send. There are several advanced ways to handle this, but the oldest and easiest way to do some rudimentary checking is by using parity.
Three kinds of parity checks exist – even, odd, and none. None is the simplest – we do not want any checks.
The other two, even and odd, mean we add one extra bit to each data packet. That extra bit is either a one or a zero, so the total number of ones in the packages, including the parity bit, is an even or odd number.
Let’s say we want to transmit the following sequence of 4 bits – 1011. If the parity is set to even, the system counts the number of ones in that message. It notices there are three, which is an odd number. We need to make it even, so the system adds a one to the package and sends that over the wire, resulting in the bits 10111.
If we had chosen to send 1001 over the wire, the number of ones is already even, so there is no need for an extra one. The system adds a zero and sends 10010 over the wire.
On the receiving end, the system counts the numbers of one in the package and checks to see whether it is indeed an even number. If that is not the case, something has gone wrong. The system can then ignore that package or request a resend.
Of course, if we had set the parity to odd, it would have only added a one if the number of ones in the data package was even.
If two bits flip instead of just one, the system falls apart. There is no way to tell that that happened with this simple setup. There are other ways to do that, but you must implement them yourself.
Parity does add data to a package, slowing communications down slightly.
The data size
How significant is a byte? I guess you are inclined to say 8 bits. But in the early days of computing, this was not a fixed number. There were lots of 10-bit-based computers. Data transmission was slow and expensive back then, so they decided they could get away with sending only 7 bytes if they wanted to send text. After all, most ASCII characters fit in 7 bits. So why send extra data? I know that these days, it is hard to imagine people worrying about an extra bit, but remember that times change. For instance, the first modem I used to connect my computer to the outside world had a transmission speed of 1200/75. That means it could receive with a speed of around 1,200 bits per second, or roughly 120 bytes per second. But I could only upload with 75 baud. That is around 10 bytes per second. Removing one bit can make a big difference in those cases!
SerialPort
allows you to choose the size of your data package. This size is the number of bits each package contains, not counting the parity bit or any stop bits. You can choose between 7 or 8 bits. Technically, you could specify other sizes. In reality, you never encounter that in practice.
7 bits is enough for ASCII characters. If you use 8 bits, you double the amount of information you can transmit in one single go, but you also make it a bit slower. In the world of serial communication, this can be important.
The default is 8 bits, but if you want to really get the most out of your system, 7 bits might be a good idea.
Stop bits
Then, we have stop bits. Stop bits are added to a data package to signal that it is the end of that package. You can decide between 1, 1.5, or 2 stop bits. The system adds these bits to the end of the package, usually ones. Adding data achieves three things – first, it signals the end of the package. It helps detect timing issues or errors and allows the hardware to catch up.
Stop bits are not actual bits; they are not data. They do not reach the software at the end of the chain. Instead, they are a fixed amount of time when the voltage is high. This explains why we can have 1.5 stop bits. There is no half a bit, but you can set the voltage high for half the time it takes to transmit one bit. Remember when I said that timing can help detect errors? This is what I was talking about.
If the receiving system thinks it has received the agreed-upon 8 data bits and parity bits, it expects a stop bit (assuming we set the stop bit to 1). If the voltage on the line is low, something has gone wrong. Combined with the parity bit, this can detect simple errors.
The stop bits can be 1, 1.5, or 2 bits long (remember that they are not bits but the amount of time it takes to send a bit). Adding extra time between two packets means that a receiving system has time to process the bits it got, calculate the parity, and pass it on to the rest of the system before the next package arrives. Again, in these days of ultra-fast hardware, it seems weird to take that into account, but when serial communication was devised, adding 1, 1.5, or even 2 bits of pause could mean the difference between an excellent working system or a barrage of errors.
Working with an Arduino
I do not have a medical MRI machine nearby, so I cannot show you how to connect to one of those using the discussed techniques. However, I do have another device lying around – an Arduino Uno.
Arduinos are really cheap microcontrollers. Although an actual Arduino can set you back $20 to $30; comparable devices with the same capabilities can be found for about $5 to $7. For that price, you get a good running microcontroller that you can connect to your computer, program against, and use to hook up all sorts of hardware.
The hardware is simple – a CPU, a USB connector, a little memory, and an EEPROM that can hold your program. Also, an Arduino has pins that you can use to connect to other hardware.
You can program your Arduino with a free tool called the Arduino IDE. Now, this book is about systems programming with C# and .NET, and not about Arduino. But I need to talk about this briefly in case you decide to get an Arduino and follow along. If you do, great! If you don’t, continue reading until we get to the part about faking hardware.
I chose Arduino because it uses a serial port to communicate with your computer. It is cheap, and many people have one lying around somewhere. Then, we can build an elementary device to talk to and have it talk to us. Do not worry if you have no experience with these devices; I will explain everything you need to know to follow along.
We need to write some software for the Arduino. The code is simple and included in the GitHub repo that accompanies this book.
But before we look at the code, let me explain the device we will create.
The device
I want our Windows machine to be more susceptible to the world outside its enclosure. I want it to be aware of sounds. I want a device that warns Windows when a loud noise is detected.
We could use a microphone and plug that into the correct ports, but a microphone is complicated. It can record sounds in high fidelity. I do not want that; I only want to know whether there is a loud sound, not what kind of sound it is. Furthermore, we can only have one microphone in use at a time. So, if we were to use the microphone, we would not be able to use our machines to make Teams calls or anything like that.
It’s best to offload that work to a separate device. To do that, we need a couple of things:
- An Arduino or a compatible device
- A breadboard. This is a piece of plastic with wires, allowing us to plug in hardware and connect them without soldering.
- A KY-037 sound detector. This very simple device puts out a voltage as soon as it “hears” a noise. They cost anywhere between $1.50 and $3.00.
- An LED and a 200 ohm resistor (optional). I thought it would be fun to light up an LED when the device hears a sound. You do not need this; the Arduino has a built-in LED we can also use.
- A USB cable to connect it all to our machine.
- Some wires to connect the different parts.
The schematics for this device look like this:
Figure 9.1: The sound detector schematics
If you have never worked with this sort of electronics before, do not worry. It is not as scary as you might think. The thing at the bottom of the preceding figure is the Arduino. As I stated earlier, it has pins we can connect wires to hook it up with other hardware. I have used four wires here. The one from the bottom to the breadboard (the white piece of plastic) is connected to a 5-volt power supply from the Arduino. I have connected it to the lowest row on the breadboard.
The breadboard works like this – all the little holes on the lowest row are electrically connected. That means if I plug in a wire with 5 volts on one of the holes in that row, all the other holes in that same horizontal row will also have 5 volts. The same happens with the second row from the bottom; they are also all horizontally connected. I use this for the ground connections. I hook one of the holes up with the Arduino’s GND (meaning ground) pins. All the wires I plug into the second row are connected to the ground.
The red piece of electronics you see is connected to the breadboard. Except for the lowest two rows of the breadboard, each column is also connected. That means if I plug something into the hole in the first column (above the two bottom rows), all the holes above it are also connected. Columns are isolated from each other.
The breadboard consists of two halves – a bottom and a top half. These two halves are entirely isolated; no wires run from one half to another. So, the top half of the breadboard is a mirror of the bottom half.
I plugged the KY-037 (the red thing in the schematic) into the breadboard. I connected the 5-volt from the first row to the correct column. I did the same for the GND signal. Then, I connected a wire from the leftmost pin of the KY-037 directly to pin 8 of the Arduino.
I did a similar thing for the LED; the plus side of the LED is connected to pin 13 of the Arduino, and the negative side is connected to a 200-ohm resistor that, in turn, is connected to the GND row of the breadboard (and, thus, to the GND of the Arduino).
Are you with me so far?
The idea is simple – if the KY-037 detects a sound, it will (if powered by the 5 volts from the Arduino) put a voltage on the D0 line connected to pin 8 on the Arduino. If that happens, the microprocessor can pick that up and put a voltage on pin 13. That will light up the LED.
If the sound is dropped, the voltage on pin 8 will also go to LOW, and we can program the Arduino to stop the LED. That is, of course, achieved by removing the voltage from pin 13.
The Arduino software
We need to instruct the Arduino on how to behave. That means we have to program it. We can use the free Arduino IDE to write and deploy our software to the device. The device itself is simple; it can only have one program. That program starts as soon as the device is powered on and does not stop until the power is removed. There is no real operating system, no loading, and no multitasking.
The program itself is also straightforward. It consists of two parts. The first part is a method called setup()
. This method is called as soon as the program starts (or as soon as the Arduino powers up). It is called only once and is a good place to do some initialization.
There is another method called loop()
. This method is, as the name suggests, a loop. The Arduino goes through the code in loop()
, and restarts at the beginning of loop()
as soon as it reaches the end. And that’s it. Of course, you can (and should) write your own methods and functions, but this is needed to get the device going.
The programming is done in C (technically, it can be C++, but let’s not go there). The IDE can compile the code for you and deploy it to an attached Arduino. When you connect your Arduino through a USB cable to your machine, the IDE recognizes it and knows how to talk to the microcontroller.
The software I want to use looks like this:
#define LedPin 13 #define SoundPin 8 int _prevResult = LOW; void setup() { pinMode(LedPin, OUTPUT); pinMode(SoundPin, INPUT); Serial.begin(9600); } void loop() { int soundPinData = digitalRead(SoundPin); if(soundPinData != _prevResult){ _prevResult = soundPinData; if(soundPinData == HIGH) { Serial.write(1); digitalWrite(LED_BUILTIN, HIGH); } else { Serial.write("0"); digitalWrite(LED_BUILTIN, LOW); } delay(100); } }
And that’s it.
Let’s explore it.
First, I define some constants. I create the LedPin
constant and set it to 13. This pin 13 is the number of the pin we connect the LED to see whether sound is detected. I chose pin 13 because most Arduino devices have a built-in LED on the board, connected to pin 13. So, if you do not want an external LED, you can look at the board and see the same effect.
I also define the pin that the KY-037 uses to send the signal back to us, pin 8, and I call it SoundPin
. There is no specific reason I chose pin 8; it was conveniently located on the Arduino, so I could easily attach it to the breadboard.
Then, we have the setup()
method. Again, this is used to initialize the system. We do three things here:
- We set the direction of pin 13 to
OUTPUT
; we do this by callingpinMode(LedPin, OUTPUT)
. This direction means that the Arduino can use this pin to write to. We need this to turn the LED on or off. - We set the direction of pin 8 to
INPUT
, by callingpinMode(SoundPin, INPUT)
. Now, the Arduino knows we want to read from that pin instead of writing to it. - We open the serial port. We do that by calling
Serial.begin(9600)
. This opens the serial connection through the USB connector to whatever it connects. We tell it we have a speed of 9600 baud. We could have specified the parity, packet size, and the number of stop bits, but the defaults (no parity, 8 bits, and 1 stop bit) are good enough for us. We need to remember these settings, as we will need them at the receiving end as well.
Then, we can look at the loop()
method.
We begin with reading from the SoundPin
pin. We do that by calling digitalRead(SoundPin)
. Remember that the KY-037 adds voltage to the device when it hears a sound. We can read that result; the voltage level is translated into a one or a zero. We compare that with the results of the previous reading; if the value is different than before, we suddenly hear something (or stop hearing something). If that is the case, we determine whether there was a sound and add that information to the serial bus; we use Serial.write(1)
or Serial.write(0)
to send that value. You can as quickly send a string over the serial port by calling Serial.PrintLn("My data")
. However, we do not need that in this case.
Then, depending on the conditions, we turn the LED on or off. Just like we used digitalRead()
to read the state of a pin, we can now use digitalWrite()
to set the state.
Finally, we call delay(100)
to give the sound 100 milliseconds to die out.
And then it starts all over again; we are in a loop after all.
That’s it. Upload that program to the Arduino and watch what happens. If you make a noise, you will see the LED light up. You haven’t seen the effect of a serial.print()
yet, but we will fix that next.
Receiving serial data with .NET
We have done a lot already. But that was just the setup to get to where we really want to be as system programmers – dealing with code in our C# programs.
I have written a sample that does just that; it opens the serial port, and it gets data. That in itself is not too hard; I have shown you how to open SerialPort
and write data to it. Reading data from that same port is just as easy; SerialPort.ReadLine()
, for instance, is one way of doing it.
However, there are a lot more considerations when dealing with other hardware, and that’s what we will discuss here.
First, the sample I provide is not a console application. It is a worker service. I chose this template because I want this code to work quietly in the background and only do something when data comes in on the serial bus. This is the closest we can get to writing a device driver in .NET. Second, USB and serial ports are brittle. It’s not that they fail a lot, but it is extremely easy to remove a device and plug it in again. You can never be sure that the device you need is attached to your computer.
Users rarely remove their primary hard drive. Network adapters tend to stay inside. Network cables can be removed but hardly ever are. USB devices, however, are plugged in and removed again all the time. Sometimes, that happens intentionally, and sometimes, your cat decides to play around with that thing with blinky lights and wires hanging out of it (yes, that happened to me when I was writing this chapter).
If we cannot rely on the presence of the device we want to talk with, we need to make sure it is there before we do something. We also need to handle a scenario where the device gets unplugged while working with it.
Luckily, we already know how to do this. In previous chapters, we looked into Windows Management Instrumentation (WMI). That allowed us to investigate the hardware attached to our machine, and we saw that it could raise events if something changed. That sounds like something we can use here.
Watching the COM ports
I created a class called ComPortWatcher
. As the name suggests, this watches the COM ports on my machine. The class implements the IComPortWatcher
interface, which looks like this:
public interface IComPortWatcher : Idisposable { event EventHandler<ComPortChangedEventArgs>? ComportAddedEvent; event EventHandler<ComPortChangedEventArgs>? ComportDeletedEvent; void Start(); void Stop(); string FindMatchingComPort(string partialMatch); }
The interface declares two events. These events get called when a device we are interested in is plugged into a computer or when such a device is removed again. Other classes can subscribe to these events and take action.
We have a method called Start()
that starts watching the ports. Stop()
does the opposite – it stops watching the ports.
I also added a method called FindMatchingComPort
(string partialMatch
). All devices have a set of properties, sometimes including the Caption. That Caption contains some information about the device attached to our machine. In the case of the Arduino, Caption
contains the Arduino
string and the actual COM port. This method tries to find that string and extracts the correct COM port, so we can use that to open the serial connection.
Let’s look at the implementation. We will start with the easiest, FindMatchingComPort(string partialMatch)
. This is what that looks like:
public string FindMatchingComPort(string partialMatch) { string comPortName; var searcher = new ManagementObjectSearcher( @$"Select * From Win32_PnPEntity Where Caption Like '%{partialMatch}%'"); var devices = searcher.Get(); if ( devices.Count > 0) { var firstDevice = devices.Cast<ManagementObject>().First(); comPortName = GetComPortName(firstDevice["Caption"]. ToString()); } else { comPortName = string.Empty; } return comPortName; }
I am skipping a lot of error checking and safeguarding; otherwise, the code becomes too long to read. I am sure that you can spot what I left out and figure out how to do that yourself. Here I have focussed on only the essential parts.
First, I create a new instance of the ManagementObjectSearcher
class. I give it the "Select * From Win32_PnPEntity Where Caption Like '%{partialMatch}%'"
search string. This searches through all Plug and Play devices and tries to match the caption of those devices with whatever string we pass in. Again, in my case, I give it the Arduino
string.
If there are no matches, we simply return an empty string, stating that no Arduino devices are found. However, if one is found (I only check for one; this is one of those areas you can improve a lot on), I take that caption and use some regular expression (RegEx) code (in the GetComPortName()
method) to extract the name of the COM port.
That RegEx code looks like this:
private string GetComPortName(string foundCaption) { var regExPattern = @"(COM\d+)"; var match = Regex.Match(foundCaption, regExPattern); return match.Success? Match.Groups[1].Value : string.Empty;}
This code is pretty straightforward. We take the "(COM\d+)"
RegEx pattern, which means we look for the string COM, followed by one or more numbers. Then, we return that part of the string. The caption of the port on my machine looks like Arduino Uno (COM4),
so this method returns, in my case, the COM4
string.
The Start()
method of this class sets up the watchers. We have two private members in the class:
private ManagementEventWatcher? _comPortDeletedWatcher; private ManagementEventWatcher? _comPortInsertedWatcher;
These are the WMI watchers that can trigger events when something interesting happens. What we define as interesting is specified in the Start()
method. Here it goes:
public void Start() { if (_isRunning) return; var queryInsert = "SELECT * FROM __InstanceCreationEvent WITHIN 1 " + "WHERE TargetInstance ISA 'Win32_PnPEntity' " + "AND TargetInstance.Caption LIKE '%Arduino%'"; var queryDelete = "SELECT * FROM __InstanceDeletionEvent WITHIN 1 " + "WHERE TargetInstance ISA 'Win32_PnPEntity' " + "AND TargetInstance.Caption LIKE '%Arduino%'"; _comPortInsertedWatcher = new ManagementEventWatcher(queryInsert); _comPortInsertedWatcher.EventArrived += HandleInsertEvent; _comPortInsertedWatcher.Start(); _comPortDeletedWatcher = new ManagementEventWatcher(queryDelete); _comPortDeletedWatcher.EventArrived += HandleDeleteEvent; _comPortDeletedWatcher.Start(); _isRunning = true; }
First, I check to see whether this is not already running. There is no point in doing this twice. Then, I define the query string that defines the searches for both inserting and deleting devices.
When a device is inserted, the __InstanceCreatedEvent
class in the operating system gets information about that device. We query for that class, but only if the target is a Plug and Play device (Win32_PnpEntity
) and Caption
contains Arduino. I am not interested in any other device.
I create a similar query string for the deletion event.
Then, I create an instance of that Watcher
class, give it the query, and set up the event handlers. Finally, I call Start()
on the watchers so that they start doing what they are meant to do.
The Stop()
method stops the watchers and cleans them up. There is nothing special there, but you can look at the code in the GitHub repository for further details.
The event handlers are slightly more interesting than the Stop()
method. Have a look:
private void HandleInsertEvent(object sender, EventArrivedEventArgs e) { var newInstance = e.NewEvent["TargetInstance"] as ManagementBaseObject; var comPortName = GetComPortName(newInstance["Caption"]. ToString()); Task.Run(() => ComportAddedEvent?.Invoke(this, new ComPortChangedEventArgs(comPortName))); }
This method is called when the watcher sees an exciting event in the operating system. We take EventArgs
(of type EventArrivedEventArgs
), take the NewEvent
property, and get the TargetInstance
member. We cast that to its correct type, ManagementBaseObject
, and remove the caption. Then, we extract the COM port name and call any attached event handler. Since I know the attached event handler will start the serial communication, I wrap it in a Task.Run()
method, making it work asynchronously and, thus, stopping it from blocking the current thread. Remember that all things that take time, such as I/O, should be written as asynchronous code.
The event handler for the delete event looks similar.
With this class in place, we can sit back and relax. We can ensure a COM port is available when needed, and we can take action if it gets unplugged.
Wrapping the serial port
There is a slight problem with the serial port class in .NET. It is not written for this day and age. It is a leftover from a much slower world. It is not asynchronous. And that can be a problem. Serial communications are slow enough already, and all calls to it block the thread it runs on. We need to wrap the class into something more modern.
I created an interface that shows us how to do this:
public interface IasyncSerial { bool IsOpen { get; } void Open(string portName, int baudRate = 9600, Parity parity = Parity.None, int dataBits = 8, StopBits stopBits = StopBits.One); void Close(); Task<byte> ReadByteAsync(CancellationToken stoppingToken); }
The interface has an IsOpen
property that can help us prevent more than one connection from opening. We have the Open()
method, and I wrote it so that the parameters are there, but when a user of this class omits them, the serial port gets created with the default settings.
We have a Close()
method that closes the connection.
I also added a ReadByteAsync()
method that reads 1 byte from the device. I do not need more; our sound detector device only sends 1 byte at a time.
Let’s look at the implementation.
First, I have a private member in the class:
private SerialPort? _serialPort;
We have already encountered the SerialPort
class, so the implementation of the Open()
method should be familiar:
public void Open( string portName, int baudRate = 9600, Parity parity = Parity.None, int dataBits = 8, StopBits stopBits = StopBits.One) { if (IsOpen) throw new InvalidOperationException("Serial port is already open"); _serialPort = new SerialPort( portName, baudRate, parity, dataBits, stopBits); _serialPort.Open(); IsOpen = true; }
Nothing special happens here – we create an instance of the SerialPort
class, give it the correct parameters, and then open it. That’s it.
Close()
is even simpler – it only calls Close()
on the _serialPort
member. OK, it does that and a bit of cleaning up.
ReadByteAsycn()
is a lot more interesting. It is the reason we wrote this class. Here it is:
public Task<byte> ReadByteAsync(CancellationToken stoppingToken) { return Task.Run(() => { if (!IsOpen) throw new InvalidOperationException("Serial port is not open"); var buffer = new byte[1]; try { _serialPort?.Read(buffer, 0, 1); } catch (OperationCanceledException) { // This happens when the device has been unplugged // We pass it a 0xFF to indicate that the device is no // longer available buffer[0] = 255; } return buffer[0]; }, stoppingToken); }
Again, we wrap the synchronous calls in Task.Run()
so that the whole thing becomes asynchronous. We return that Task
to the caller.
We call _serialPort?.Read(buffer,0,1)
. This results in one byte of data, if available. If no data is available, this call is blocked until the data is there. That is why we use Task.Run()
– we do not want to block our entire system and wait for a single byte to come in.
However, if the device is removed from our system while waiting for that data, we get OperationCanceledException
. That makes sense; we are waiting for data from a device that no longer exists. We catch that exception and return the 0xFF
byte. Since we know we can only get a 0
or a 1
from the Arduino board (that’s how we programmed it), we can safely use this magical number here to indicate an error.
Let’s see how we can use these two classes.
Making it all work together
I mentioned that we are building a worker service. This service runs in the background and does not influence other codes or programs. The default template gives you a class called Worker
, where we can do the actual work. We shall add our code to this Worker
class.
But before doing that, we need to change the Program
class slightly. One of the nice things about the worker service template is that it gives you dependency injection for free, out of the box. We can use that to register our IAsyncSerial
and IComPortWatcher
interfaces and their accompanying classes. That way, we do not have to create instances ourselves.
The Program
class needs to be changed to look like this:
var builder = Host.CreateApplicationBuilder(args); builder.Services.AddTransient<IComPortWatcher, ComPortWatcher>(); builder.Services.AddTransient<IAsyncSerial, AsyncSerial>(); builder.Services.AddHostedService<Worker>(); var host = builder.Build(); host.Run();
As you can see, we registered our new interfaces and classes, making them available for anyone needing one. And that anyone in our case is the Worker
class. Let’s look at the constructor:
public Worker(ILogger<Worker> logger, IAsyncSerial serial, IComPortWatcher comPortWatcher) { _logger = logger; _serial = serial; _comPortWatcher = comPortWatcher; _comPortName = _comPortWatcher.FindMatchingComPort("Arduino"); _deviceIsAvailable = !string.IsNullOrWhiteSpace(_comPortName); _comPortWatcher.ComportAddedEvent += HandleInsertEvent; _comPortWatcher.ComportDeletedEvent += HandleDeleteEvent; _comPortWatcher.Start(); if (_deviceIsAvailable) StartSerialConnection(); }
We set the incoming instances of our classes and then look for a COM port attached to an Arduino. If there is one, we can set the _deviceIsAvailable
variable to true.
We add the events that get called when the device is inserted or deleted. Then, if a device is already available, we start the serial connection.
That method, StartSerialConnection()
, looks like this:
private void StartSerialConnection() { if (_serial.IsOpen) return; _serial.Open(_comPortName); _deviceIsAvailable = true; }
Since we have already done the hard work in the AsyncSerial
class, we can simply call it _serialOpen(_comPortName)
.
The event handler for ComportAddedEvent
does more or less the same thing:
private void HandleInsertEvent(object? sender, ComPortChangedEventArgs e) { _comPortName = e.ComPortName; _logger.LogInformation($"New COM port detected: {_comPortName}"); if (!string.IsNullOrEmpty(_comPortName)) StartSerialConnection(); }
The event gets the name of the COM port from the ComPortWatcher
class. So, all we have to do here is save that name and start the communications.
The actual work happens in the ExecuteAsync
method of the worker. As you probably recall, the runtime calls this part of the class to do the actual work. Usually, this method contains a loop that gets repeated until CancellationToken
signals that it needs to stop. Our version looks like this:
protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { if (_deviceIsAvailable) { var receivedByte = await _serial?. ReadByteAsync(stoppingToken); if (receivedByte == 0xFF) { StopSerialConnection(); _logger.LogWarning("Device is ejected."); } else { _logger.LogInformation($"Data received: {receivedByte:X}"); } } await Task.Delay(10, stoppingToken); } }
First, we check whether a device is available. There is no point in reading data if no device is attached, right?
Call the new ReadByteAsync()
method if there is a device, and check the results. If they return 0xFF
, we have a problem – the device is removed. Otherwise, we just display the data we have.
And that’s all there is to it! That was quite a lot. We introduced the Arduino and built our own device from it. We learned what communication over serial ports looks like. We discussed extracting data from our serial ports and how to make it work asynchronously. All in all, I think you deserve a break. We covered a lot of ground here.
Take a look at the complete sample in the GitHub repository to see the little details I left out here. However, with the information I just gave you, you have everything you need to start talking to serial devices!