Back to TOC Cross-Platform Development


Object-Oriented SDI-12 Communications

by David Perelman-Hall


Introduction

This article describes an object-oriented implementation of the SDI-12 protocol for data-logging systems founded on three tightly interlocked C++ classes. Programmers will be able to use these classes to implement the protocol on any hardware platform, as described below. Two of these classes, those above the solid line in Figure 1, are used to execute the SDI-12 command set, and form the real toolbox of this implementation. These classes are linked by inheritance, and present an invariant interface for communicating with SDI-12 data sensors, regardless of the hardware platform in use.

The top two classes are layered on a third class — the bottom layer of Figure 1 — which is a programmer-provided, hardware-specific method of executing communication commands akin to get and put. The intermediate template layer interfaces the upper layer SDI-12 command set with the communications requirements of the chosen hardware platform in the lower layer. For this article I use a class I developed called the SDI12_UART class to instantiate the CommunicationMethod template for SDI-12 on standard PC hardware.

SDI-12 Explained

SDI-12 stands for Serial Digital Interface at 1,200 baud (sometimes referred to as Serial Data Interface). It is a communications protocol for interfacing data recorders with microprocessor-based sensors. This interface provides a means to transfer measurements taken by an intelligent sensor to a data recorder.

The protocol grew out of the U.S. Geological Survey's need to define a constant interface to a growingly disparate set of serial data-acquisition systems. The protocol defines hardware configuration, timing, and data-transfer conditions for use of the SDI-12 bus, and lays out the command set needed for data retrieval. SDI-12-compliant sensors are intelligent, microprocessor-based sensors, all of which use SDI-12 to talk with data-logging equipment. Using SDI-12 provides substantial benefits, including plug-and-play modularity among a growing lineup of SDI-12 sensors, and the separation of data logging from data measuring.

If your data acquisition needs

then SDI-12 is a prime candidate for your system.

The SDI-12 protocol defines a multi-drop, multi-parameter interface, which means that more than one sensor can be connected via a single cable to a data logger, and that each sensor is capable of reporting multiple values. Although the predominate uses of SDI-12 are for monitoring water quality and level, others include logging of temperature, sea and tide states, bridge scour, snow weight, and distance. Communication between sensors and loggers is conducted via precisely-timed signal conditioning, which results in an exchange of ASCII characters on the data line. All SDI-12 interchanges, including the implementation presented here, adhere to the following general pattern:

1. The data logger places a break on the data line for a period not less than 12.5 milliseconds. The protocol defins no upper limit on the timing of the break. The break awakens the sensor(s).
2. The data logger then sets the data line in the marking, or idle, condition for not less than 8.33 milliseconds. This is to prevent unwanted sensor communication from appearing on the data line. According to the SDI-12 standard, sensors can go back to sleep after 100 milliseconds of marking.
3. The data logger issues one of the SDI-12 commands (see Figure 2) to a specified sensor and immediately begins searching for a reply. If there are other sensors on the line, they will be at different addresses, and they return to low-power standby mode.
4. The data logger captures the sensor response.

The protocol implements five basic comands (see Figure 2) , but since the take-a-measurement command has two forms (the "default" and "consecutive" Measures), there are actually six. The first character of every command issued by the data logger is always the sensor's address on the data line (standard addresses are ASCII '0' to '9'). The last character of every command is always the exclamation mark ('!'). The first character of every sensor's response is the sensor's address ('0' to '9') on the data line; the final characters which signal the end of the response are carriage return and line feed (<CR><LF>). I rely on these consistencies to parse communication strings.

The communication method I use in this article is a simple abstraction of the PC UART (Universal Asynchronous Receiver Transmitter), sending commands via a serial port, and relying on the timing capabilities of the PC system clock. In this case, the RS232 lines and signal levels leaving the serial port must be conditioned by a converter to comply with SDI-12 hardware specifications. However, the majority of SDI-12 communication activity occurs between intelligent data sondes and specialized, interrogating data-logging equipment. In the remote sensing world, PCs don't routinely figure into this picture, which is why the templatized intermediate layer is present — to make the code completely platform-generic. This code has been tested using an oscilloscope and the SDI-12 Verifier at NR Systems, Inc., Logan, Utah.

A note on timing: Correct signal timing on the SDI-12 bus is critical. The granularity of timing I achieve by using the PC system clock — about 1/18th second, or approximately 55 milliseconds — is close to the coarsest precision acceptable for SDI-12 timing demands. Because timing performance varies with hardware, it is unique to the user-defined Method class. If you develop your own Method class, you will have to supply your own timing.

Having a Template in the Family Tree

This code is modeled on a C++ pattern called the singleton letter/envelope pattern. A form of the handle/body pattern, this pattern can be used to simplify abstractions, often by separating off implementation details in the letter class while providing an interface to them in the envelope class. The envelope often maintains a pointer to the letter, forwarding calls for implementation functionality to the letter through this pointer. One important effect of the simplification is that, while the implementation can change, users remain unaware of it because they see and use only the unvarying envelope.

The singleton version of the letter/envelope pattern creates a tighter coupling between letter and envelope, in which the envelope pairs with a single stable letter class, often as a nested class within the envelope rather than as a pointer to a class.

My implementation is a modification of these patterns, in which a base class acts as the letter and a privately derived class functions as the envelope. In this case, however, the base class is a template, which makes possible an entire family of letters, and hence of letter/envelope pairs. The derived class funnels the SDI-12 command set to a tiny set of functions in the intermediate template class. The template class maintains a pointer to an instance of the Method class which the template class uses to pass functionality to the Method class. The Method class instantiates the template, and is responsible for fulfilling implementation details styled along the lines of get and put.

The base template class CommunicationMethod<class Method> is the letter, and its descendant, the class SDI12_Logger, is the envelope. These two classes are not meant to be altered, while the third — the class Method instantiating the template — is the implementation-specific one provided by the programmer, and hence the place where coding must take place to invest the template with hardware-specific functionality.

What is unique and useful about my approach is the combined use of private inheritance with a generic parent. Private inheritance (see Figure 3) models the "has-a" relationship instead of the "is-a" relationship. Consequently, the SDI12_Logger class uses its parent CommunicationMethod class as though it were a private data member, shielding the letter behind this access restriction. You can nevertheless rely on the compiler to enforce the requirement that the type of the user-supplied Method class is what the SDI12_Logger class requires for the chosen hardware. This is so because each new template instantiation will be a unique type which inheritance passes along to the derived SDI12_Logger class.

Implementing the Command Set

Programmers will use SDI12_Logger objects to talk to SDI-12 sensors. This class interface is shown in Listing 1. Programmers will principally be interested in how the six-membered SDI-12 command set has been implemented, as the five following member functions:

  • String SDI12_Logger::Verify() const;
  • String SDI12_Logger::Acknowledge()const;
  • String SDI12_Logger::Measure(const String& consecutive) const;
  • String SDI12_Logger::Identify();
  • String SDI12_Logger::Data(const String& consecutive);
  • The parameter to the Measure function permits extension of the Measure command to include all SDI-12 Measure commands. This function also retains the commonest SDI-12 measure command — "aM!" — as the default, thus letting five functions cover six SDI-12 functionalities. In addition to these five commands, the SDI12_Logger class has four other supporting member functions which play important roles:


  • SDI12_Logger::SDI12_REPLY SDI12_Logger::DataReady( const String&);
  • intSDI12_Logger::ParseReply(constString&);
  • intSDI12_Logger::IsValid();
  • voidSDI12_Logger::Setup(constString&);
  • I discuss the command set implementation first, since it is very basic. These command set functions are the only functions that cause command characters to be sent to an SDI-12 sensor. Each makes a call to ComunicationMethod<Method>::TalkToSDI, which in turn calls Method::SendCommand and Method::GetTerminatedString.

    Each function in the command set returns the SDI-12 sensor's reply to the given command, some of which are stored in private data members of the SDI12_Logger class. For example, the Acknowledge command, when issued to a sensor at address a, will return a String object bearing the address and a carriage return followed by a linefeed: String(a\r\n) (see Figure 2) . Figure 4 shows the message passing scenario which takes place when an SDI12_Logger object at address 0 is sent the Acknowledge message.

    Logger Class Support Functions

    The function

    SDI12_Logger::
        SDI12_REPLYSDI12_Logger::
        DataReady(constString&reply)
    

    is used to detect when a sensor issues a service request, which is the protocol's indication that freshly-measured data is ready to be retrieved. This function listens to the SDI-12 sensor, but doesn't send it any characters. This function's return value is one of the SDI12_Logger::SDI12_REPLY enumeration values. To use it, you first issue the desired form of the Measure command, and then immediately call DataReady, passing as a parameter the reply the sensor gave to the Measure command. DataReady learns the following from ParseReply:

    1) how long the sensor will take before making data available, and

    2) how many data values will then be available.

    DataReady can return one of the three SDI12_REPLY enumeration values: REPLY_FAIL, REPLY_OK, or REPLY_TIMEOUT.

    Two conditions can result in a return value of SDI12_Logger::REPLY_FAIL (the enumeration values should be fully qualified): when ParseData can't parse the String it receives as a result of the Measure command, or when DataReady receives a spurious service request string from the sensor. If either of these cases occur, it is unlikely that the Measure command succeeded — it should probably be reissued.

    A value of SDI12_Logger::REPLY_OK indicates that the sensor is holding a freshly-taken measurement ready for retrieval by a Data command. A value of SDI12_Logger::REPLY_TIMEOUT indicates that the Measure command succeeded, that ParseData was able to parse the reply, and that the program waited ttt seconds for a service request, as obligated to do by the protocol (see Figure 2) , but that no service request was received. In this case, the protocol says that the program may go ahead and issue a Data command anyway, expecting the data returned to be valid.

    The last two member functions of the SDI12_Logger class, IsValid and Setup, can be empty functions in the user-defined class. IsValid lets you verify the validity of the SDI12_Logger in whatever terms you choose. Because the programmer-defined communication method is inherited by SDI12_Logger, you can use IsValid as a kind of post-condition check on the communications class itself; in my case, the UART registers must be defined appropriately to work with the SDI-12 line-conditioning converter. Setup lets you pass information up the hierarchy tree from SDI12_Logger through the template class to the programmer-defined communication class. I use it to pass a String indicating which serial port to use.

    The protected Method *method data member in the template is accessible to the inheriting class. The programmer defines only four functions called through this pointer (two of which are IsValid and Setup) to instantiate the template.

    The two functions still to be discussed are used by the CommunicationMethod template class and its descendants to talk to the SDI-12 sensor, and as such they must fulfill the functions resembling put and get for the hardware being used. These are implementations of the following:

  • void SendCommand(const String& command) const;
  • int GetTerminatedString(String& str, int delayTime, char terminator, int length) const;
  • Neither one of these functions is called directly on an instance of SDI12_Logger. In fact, because these functions are buried in the base class, the user of SDI12_Logger never makes a direct call to them. Instead, the user calls only the command-set functions, realizing that SendCommand and GetTerminatedString lie at a deeper level than they need to access.

    Each String in the command set is eventually forwarded by TalkToSDI to SendCommand, which sends it to the SDI-12-compliant sensor. SendCommand should handle the probability that a retry is necessary. Sensors may awaken as late as 100 milliseconds after the data line is set in the break state. SendCommand first issues the command string ealier than this 100 millisecond interval is complete. Thus, a late-waking sensor will not receive the command. SDI12_UART::SendCommand accommodates this behavior: if it does not quickly detect a sensor response, it reissues the command shortly after 100 milliseconds have elapsed.

    GetTerminatedString gathers each sensor reply. The parameters to GetTerminatedString are 1) a reference to the String to be returned, 2) interval in seconds to wait for the terminating character while reading the input — this has a default of 1 second, 3) the terminating character to search for in the input, and 4) the maximum length of the return string in characters. GetTerminatedString returns non-zero if it found the terminator within the specified time period, otherwise it returns zero.

    Listing 1 provides the interface to the three classes I use to talk SDI-12 using a PC as the data-logging platform. The programmer-provided Method is class SDI12_UART, which inherits from a serial port class. I use the child class because I am able to implement the four programmer-defined functions in it instead of placing these functions in the parent serial port class where more than likely they do not belong. Listing 2 provides support classes such as the list template class and a timer class for PCs. The .cpp implementation of these interfaces, as well as port I/O functions and a test program, are provided on this month's code disk and online sources. When using the test program keep in mind the sequence of events to acquire a fresh data value: first issue a take-measure command, then wait upon the service request, and then issue a send-data command.

    Final Words

    We've heard it before: one benefit of object-oriented programming is reusable software that a programmer can grab off the shelf to satisfy an application need. To some extent this is happening with the Standard Template Library, and with classes like the string, date, and time classes shipped with most C++ compilers. For resuability to work, classes must be created with an eye toward availability. This seems to imply that class size should be small enough to make for rapid assimilation and usefulness. These closely-bundled, concise SDI-12 classes capitalize on inheritance and parameterization to fulfil the promise of reusability.o


    David Perelman-Hall has a Ph.D. from the University of Texas at Austin, and writes and programs for a living. Contact him at dph@io.com.