Extending Visual Basic's Comm Control
Adding Xmodem support
Michael Floyd
Michael is executive editor for DDJ and author of Developing Visual Basic 4 Communications Applications (Coriolis Group, 1995). He can be contacted at the DDJ offices or [email protected].
Support for OLE custom controls (OCXs) is a new feature in Visual Basic 4.0 (VB4). One OCX, the Visual Basic communications (Comm) control, hides the low-level details of serial communications (fetching characters from the UART and the like) while providing a high-level interface based on the event-driven model. You simply write code in response to events. When a character comes through the serial port, you can grab it using an Input method. Thus, the Comm control works fine when streaming text from the serial port into a terminal window. However, the Visual Basic Comm control does not directly support protocols such as Xmodem for transferring binary files--a necessity for any real communications program.
There are several options for supporting binary transfers. You can, for instance, buy an enhanced, add-on Comm control from Crescent Software (the original developer of the bare-bones Visual Basic Comm control) that supports most popular file-transfer protocols--Xmodem, Ymodem, Zmodem, and the like. Alternatively, you can write your own Comm control, or extend the minimal control included with VB4. In this article, I'll describe how to extend the Comm control by adding support for the Xmodem protocol (sometimes called "Modem7"). Although this implementation only supports checksums for error detection, you can easily add a cyclic redundancy check (CRC).
Comm-Control Crash Course
The Comm control supports both event-driven and polling methods to send and receive data through the serial port. It generates a single event, OnComm, which can trap events such as receiving data through the serial port and errors such as a full transmit buffer. You can control how often the CommEvReceive event is generated by setting the RThreshold property. For example, setting RThreshold to 1 causes an CommEvReceive event to be generated each time a character is received in the input buffer. The value of an event or error is stored as an integer in the CommEvent property, so you can use CommEvent to determine the most recent event or error. For polling, you can disable the generation of the CommEvReceive event by setting the RThreshold property to 0.
You can begin "talking" to the serial port in as few as ten lines of code. Characters are sent using the Output function and received using the Input function; see Example 1. You start by setting the CommPort property to establish which serial port your modem is connected to. If you are on COM1:, set ComPort=1. You then establish the port settings with the Settings property. A typical setting might be Settings="9600,n,8,1". Before opening the communications port, you also need to tell the Comm control how many characters to fetch from the input buffer. The InputLen property sets or returns the number of characters to be read when using the Input procedure. Setting InputLen to 0 tells the communications control that the entire receive buffer should be read when using the Input procedure.
TinyComm
To demonstrate how to develop a serial-communications program, I've written a terminal program called "TinyComm." One of the reasons I chose this name is that the program's Xmodem code is based on that presented by Al Stevens in his "C Programming" column (DDJ, February and March 1989). TinyComm is a minimal implementation of a terminal program, but despite its simplicity, TinyComm is more robust than the sample VBTERM application supplied with Visual Basic. TinyComm allows you to select a remote system from a phone-book entry and dial that system. Once connected, you can log in and interact with the remote system. Here, I will focus on just a few of TinyComm's subroutines; the complete project is available electronically (see "Availability," on page 3).
TinyComm consists of three forms: a main window (TinyCom.frm), a phone-book-dialer window (Phonebk.frm), and an About box (AboutBox.frm). The main window consists of the terminal window and six pull-down menus. The terminal window presents some interesting challenges. A text-box control provides much of the functionality this window will require. However, some modifications must be made to support communications. For instance, characters typed into the text box must be sent to the serial port and echoed back on the screen. Additionally, when data comes through the serial port, you need to display it to the screen. You would also like to be able to resize the window, and have the data adjust itself properly.
CommCtrl.OnComm() (I've named this instance of the control CommCtrl) uses a Case statement to direct the program to the proper handler. When data is received through the serial port, the CommEvReceive case is triggered. CommEvReceive first grabs data from the communications control's receive buffer and assigns it to the TerminalTxt string variable. This gives us a chance to filter the data before displaying it on the screen. Next, the cursor position in the terminal window is determined by calculating the length of text in the Window and assigning that value to TerminalWindow.SelStart. Normally, the SelStart property determines the starting point of text that has been highlighted and selected in the text box. If no text has been selected, as in this case, SelStart indicates the position to begin inserting text.
To handle backspace characters, you test for the ASCII-character value 8H. If a backspace is detected, you subtract 1 from the current cursor position and assign that as the new SelStart position. On the screen, this moves the cursor back one space and erases the character that was in this position. Finally, the filtered text is displayed on the screen using SelText. Of course, there are plenty of other things you could filter and handle here, including special characters used in terminal emulation.
The other important form involves the phone book, an Access database (.MDB) file. This version of TinyComm provides no direct interface to the phone book, so you must add entries using the Data Manager. The PhoneBook form consists of a data grid and five button controls, three of which are currently disabled. The fourth button control closes the PhoneBook form, and the final button, Dial, dials the phone number of the system currently selected in the data grid.
The DialBtn_Click() subroutine retrieves the dialup information from the database and passes Comm control settings and the phone number to CallNum(). DialBtn_Click() first disables the Dial and Quit buttons and enables the Cancel button (which aborts the dialing process). Next, the database is opened and the record pointer is moved to the first record in the database. I use FindFirst to locate the NameStr in the database, so a SQL query is constructed and stored in the Query variable. The phone number associated with NameStr is placed in the Number variable and passed to CallNum for dialing. When control returns from CallNum, the buttons are reset to their prior state.
Xmodem Refresher
The Xmodem protocol is well documented and has been covered extensively in DDJ (most recently in "Intelligent XYModem," by Tim Kientzle, December 1994). Still, a quick refresher is in order. According to the Xmodem protocol, data are broken into 128-byte chunks and packaged into blocks (or packets) for transmission. Each data packet is prefixed with a special Start of Header (SOH) character, a block number, and the one's complement of the block number. This is followed by 128 bytes of data. Finally, a checksum character is appended to the data packet. Figure 1 shows how the message block is packaged.
In general terms, the transfer begins with the receiver sending out a series of Negative Acknowledgment (NAK) characters at ten-second intervals. When the sending program sees a NAK, it sends the first block of data. The receiving program examines the data block and checks it for problems. If there are none, the receiver sends an Acknowledgment (ACK) character indicating the block is fine. If, on the other hand, there is a problem with the transmission, a NAK is sent. The sender responds to a NAK by resending the bad block, and to an ACK, by sending the next block. The process continues until an End of Transmission (EOT) character is received or the file transfer is aborted.
When the first packet is received, the receiver examines the first byte for the SOH character (01H). If the header character is found, the receiver assumes that the message block is valid and begins the file transfer in earnest. The receiving program next takes the block number and calculates the one's complement to the block number. This value is compared to the one's complement sent by the sending program. If the two values match, everything is fine and the receiver extracts the 128 bytes of data. The one's complement is computed in VB by performing a bitwise Not(). If the two values do not match, there is an error in the transmission and the packet must be resent. The receiving program notifies the sender by sending a NAK (&H15).
The next step in the process is for the receiver to calculate a checksum of the 128 bytes and compare this value to the checksum that was sent by the remote system. Assuming the two checksum values match and the one's complement values match, the receiver sends an ACK character (&H6). The checksum is calculated by summing each of the data bytes. Figure 2 shows the algorithm for downloading a file using Xmodem.
Implementing Xmodem
Listing One shows the download_xmodem() routine, which is based on C code written by Al Stevens. Thus, if you're a C programmer, you should be able to follow the Visual Basic code without difficulty. However, while variables and subroutines may follow the general structure of Al's code, there are some significant differences.
The download_xmodem() routine takes a file handle as an argument. Thus, the calling routine must create a valid file handle and open the file prior to calling download_xmodem(). The calling routine is also responsible for closing the file after the file transfer is complete. The code in Figure 3 can be used to call the download_xmodem() subroutine.
My version of download_xmodem() polls the serial port for input rather than using event-driven methods. This is accomplished by first setting the InputLen property to 1, which tells the control to receive characters through the comm port, one at a time. Next, the Comm control's RThreshold property is set to 0, thus disabling the generation of the OnComm event. In TinyComm, disabling CommEvReceive has the side effect of disabling output to the terminal window. Unfortunately, this also disables all other events and error messages processed by this event. I've temporarily disabled the CommEvReceive event to simplify the discussion and to focus on Xmodem rather than event processing. Note, however, that the global TerminalMode variable has been set to False at the beginning of Listing One. Code in OnComm's receive event handler checks TerminalMode and disables output to the window when the variable is set to False.
One difference between the C and Visual Basic versions of download_xmodem() shows up in the ReadComm() subroutine. To be useful to download_xmodem(), each string character retrieved from the Comm control's Input method must be converted to an integer value representing its ASCII equivalent. The ReadComm() subroutine shown in Listing One grabs a string character from the serial port, converts it to an integer, and returns the result. Visual Basic's Asc() function performs the conversion. If a null string is encountered, ReadComm() returns 0.
Another difference involves the Delay() function (which Al calls the sleep() function). Delay() is used to pause the system for a predetermined period of time. For example, to initiate the file transfer, download_xmodem() sends a NAK and checks the input buffer to see if an ACK response character has been sent. If not, the subroutine waits approximately six seconds (I've shortened the delay time), then sends out another NAK. Visual Basic provides a Timer control that can be used for just this purpose. The Timer control uses the PC's system clock to generate an event after a set period of time (specified in milliseconds). However, the accuracy of the Timer control is limited by the system clock, which generates a clock tick every 1/18th of a second. When the Timer event is generated, I increment a global variable called SecondsElapsed. The Delay() subroutine loops until the desired number of seconds have elapsed.
Delay() also periodically issues a DoEvents(), which hands control over to Windows to process other events within the system. Without DoEvents(), the system appears to be hung. I've found Delay() useful in many situations, and have even included it as part of a scripting language for TinyComm that I call "TinyScript.''
Conclusion
Clearly, I've only touched on TinyComm's highlights. You can add many features, including more event and error handlers. Most terminal programs support file capture, as well as ASCII file send and receive capability. I have also shown only the basics of Xmodem support. There is, of course, a complimentary upload_xmodem() subroutine. In addition, it is rather easy to add CRC support to the Xmodem subroutines, and you will undoubtedly want to take full advantage of the visual controls supplied by Visual Basic.
Example 1: Opening the serial port and initializing the modem.
Figure 2: Xmodem's download algorithm.
Copyright © 1995, Dr. Dobb's Journal
CommCtrl.CommPort = 1
CommCtrl.Settings = "9600,n,8,1"
CommCtrl.InputLen = 0
CommCtrl.PortOpen = True
CommCtrl.Output = "ATZ" + Chr(13) + chr(10)
Do
DummyVar = DoEvents()
Loop Until CommCtrl.InBufferCount >= 2
InString$ = CommCtrl.Input
CommCtrl.PortOpen = False
Figure 1: Xmodem data packet.
Send NAKs every 10 seconds until a packet is received
If packet received then check for SOH
If SOH then
get block number
calculate One's complement to block number
compare complement to the complement sent in the packet
If local complement <> remote complement then
Send NAK and repeat process
Else
get 128 bytes of data
calculate checksum
compare local checksum to packet checksum
If local checksum <> packet checksum then
Send NAK and repeat process
Else
write data to file
send ACK
End If
End If
Read next SOH
If SOH = EOT then transmission successful
If SOH = CAN then transmission aborted
If SOH = &H01 then get next packet (repeat)
Figure 3: Calling download_xmodem().
FileHandle = FreeFile 'Get the next free file handle
FileName = "SomeFile"
Open FileName For Output As FileHandle
download_xmodem (FileHandle)
Close
Listing One
' xmodem.bas -- Michael Floyd -- Dr. Dobb's Journal, December 1995.)
Global Const RETRIES = 12
Global Const CRCTRIES = 2
Global Const PADCHAR = &H1A
Global Const SOH = &H1
Global Const EOT = &H4
Global Const ACK = &H6
Global Const NAK = &H15
Global Const CAN = &H18
Global Const CRC = "C"
Global tries, SecondsElapsed As Integer
Global InBuffer As String
Sub Delay(Seconds)
SecondsElapsed = 0
If Seconds < 1 Then
Terminal.Timer1.Interval = 1000 * Seconds
Else
Terminal.Timer1.Interval = 1000
End If
Terminal.Timer1.Enabled = True 'Enable timer
Do While SecondsElapsed <= Seconds
If I Mod 10 = 0 Then DoEvents
Terminal.Label1.Caption = SecondsElapsed
I = I + 1
Loop
Terminal.Timer1.Enabled = False
End Sub
Sub download_xmodem(FileNum)
Dim buffer, Checksum, Block, RemoteChecksum, RemoteComplement, _____LINEEND____
RemoteBlockNumber, SOHChar As Integer
Dim ByteArray$(1 To 128)
TerminalMode = False 'Disable output to terminal
Block = 0
SOHChar = 0
fst = True
Terminal.CommCtrl.InBufferCount = 0 'Flush the Input buffer
Terminal.CommCtrl.InputLen = 1 'Receive one char at a time
Terminal.CommCtrl.RThreshold = 0 'Disable generation of OnComm Event
tries = 0
TIMEOUT = 6
test_wordlen
' send NAKs until the sender starts sending
Do While (SOHChar <> SOH) And (tries < RETRIES)
tries = tries + 1
Terminal.CommCtrl.Output = Chr$(NAK)
Delay 1
SOHChar = ReadComm()
If SOHChar <> SOH Then
Delay 6
End If
Loop
Do While tries < RETRIES
' -- Receive the data and build the file --
Terminal.Label1.Caption = "Block " + Str(Block + 1)
If Not (fst) Then
TIMEOUT = 10
SOHChar = ReadComm()
If TimedOut() Then
MsgBox "Timed Out"
End If
If SOHChar = CAN Then
MsgBox "CAN Received"
Exit Do
End If
If SOHChar = EOT Then
Terminal.CommCtrl.Output = Chr$(ACK)
MsgBox "EOT Received"
Exit Do
End If
If SOHChar <> SOH Then
If SOHChar = EOT Then
Terminal.CommCtrl.Output = Chr$(ACK)
MsgBox "EOT Received"
Exit Do
End If
Do While (SOHChar <> SOH)
If tries >= RETRIES Then
MsgBox "SOH errors!"
Exit Do
End If
tries = tries + 1
Terminal.CommCtrl.InBufferCount = 0 'Flush Input buffer
Terminal.CommCtrl.Output = Chr$(NAK)
Delay 1
SOHChar = ReadComm()
Loop
End If
End If
fst = False
TIMEOUT = 1 ' Switch to one sec. timeouts
RemoteBlockNumber = ReadComm() ' Read block number
RemoteComplement = ReadComm() ' Read 1's complement
Checksum = 0
DLInfo.Label1.Caption = "Block: " + Str(RemoteBlockNumber) + _____LINEEND____
" SOHChar: " + Str(SOHChar)
' ---- data block -----
For I = 1 To 128
buffer = ReadComm()
Buf$ = Buf$ + Chr$(buffer)
Checksum = Checksum + buffer
Next
Checksum = Checksum And 255
' ---- checksum from sender ----
RemoteChecksum = ReadComm()
' --- Handle resent blocks ---
If RemoteBlockNumber = Block Then
FilePos = Seek(FileNum)
Seek FileNum, FilePos - 128
' --- handle out of synch block numbers ---
ElseIf RemoteBlockNumber <> (Block + 1) Then
receive_error "No next sequential block", CAN
Exit Do
End If
Block = RemoteBlockNumber
' --- test the block # 1's complement ---
BlocksComplement = (Not RemoteBlockNumber And &HFF)
If (RemoteComplement And &HFF) <> BlocksComplement Then
receive_error "One's complement does not match", NAK
End If
' --- test chksum or crc vs one sent ---
If Checksum <> RemoteChecksum Then
receive_error "non-matching Checksums", NAK
End If
' --- write the block to disk ---
For I = 1 to Len(Buf$)
Print #FileNum, Mid(Buf$, I, 1)
Next I
Terminal.CommCtrl.Output = Chr$(ACK)
Delay 0.5
Loop
If SOHChar = EOT Then
MsgBox "Transfer Complete"
Else
MsgBox "Transfer Aborted"
End If
TIMEOUT = 10
Terminal.CommCtrl.InBufferCount = 0 'Flush the buffer
Terminal.CommCtrl.InputLen = 0 'Receive all chars in buffer
Terminal.CommCtrl.RThreshold = 1 'Enable generation of OnComm Event
TerminalMode = True 'Enable output to terminal
End Sub
Function ReadComm() As Integer
Dim Tmp As String
' ReadComm reads a character from the Comm control's input buffer
' and returns the ASCII value of that character. If a null string is
' encountered, ReadComm returns 0.
If Terminal.CommCtrl.InBufferCount > 0 Then
Tmp = Terminal.CommCtrl.Input
If Tmp <> "" Then
ReadComm = Asc(Tmp)
Else
ReadComm = 0
End If
Else
ReadComm = 0
End If
End Function
Static Sub receive_error(ErrorMsg, Rtn)
tries = tries + 1
If TIMEOUT = 1 Then
MsgBox "error " + ErrorMsg
End If
End Sub
Sub test_wordlen()
Settings = Terminal.CommCtrl.Settings
If InStr(Settings, ",8,") = 0 Then
MsgBox "Must be 8 Data Bits"
tries = RETRIES
End If
End Sub
Function TimedOut() As Integer
Ticker = 1
If Ticker = 0 Then
TimedOut = True
Else
TimedOut = False
End If
End Function