Application Development for Integrating YSI EXO2 Sonde into Smart Mooring using Bristlemouth

My name is Cobi Christiansen and I am the Data Operations Specialist at Coastal Carolina University. I am working on integrating a YSI EXO2 water quality sonde that has the following sensors: temperature, conductivity, dissolved oxygen, pH, and turbidity. The physical integration is nearly complete, but I am still working on developing an application that parses the incoming sonde data, aggregates data over a period of time, calculates min, max, mean, and standard deviation over aggregation period, converts stats to number format, and finally sends data to the bristlemouth network. I am trying to modify the existing code from src/apps/bm_devkit/rbr_coda_example. I have some coding experience, but C++ is new to me.

I am struggling to get the parser to work with my line of data (outlined at the top of code below). I reached out to YSI to see if the sonde can spit out just the data values without the #, but I have not heard back yet.

Any assistance with this code would be greatly appreciated!

/*
 * This is a lightweight example Dev Kit application (v.0.6.0) to integrate a YSI EXO2 Sonde.
 * Sonde Paramters: temp, conductivity, DO, pH
 * Example line: # 21.853 0.790 0.00 7.58 
 * Current Issue 1: Parsing the "#" in the line... "ERR - can't parse value of ValueType 0"
    Solution?: Find the correct way to parse the "#" or find a way to exclude the first character when parsing
 * Current Issue 2: It does not seem to be aggregating the data correctly
    Solution?: Check to make sure exo_data is pulling correctly. Does this have to do with the parsing issue?
 */

#include "user_code.h"
#include "LineParser.h"
#include "OrderedSeparatorLineParser.h"
#include "array_utils.h"
#include "avgSampler.h"
#include "bm_network.h"
#include "bm_printf.h"
#include "bm_pubsub.h"
#include "bristlefin.h"
#include "bsp.h"
#include "debug.h"
#include "lwip/inet.h"
#include "payload_uart.h"
#include "sensors.h"
#include "stm32_rtc.h"
#include "task_priorities.h"
#include "uptime.h"
#include "usart.h"
#include "util.h"

#define LED_ON_TIME_MS 20
#define LED_PERIOD_MS 1000
#define DEFAULT_BAUD_RATE 9600
#define DEFAULT_LINE_TERM 13 // A command is terminated by a <CR>
#define BYTES_CLUSTER_MS 50
// How often to compute and return statistics
#define EXO_AGG_PERIOD_MIN 0.5
// 10 min => 600,000 ms
#define EXO_AGG_PERIOD_MS (EXO_AGG_PERIOD_MIN * 60 * 1000)
// We have enough RAM that we can keep it simple for shorter durations - use 64 bit doubles, buffer all readings.
// We could be much more RAM and precision efficient by using numerical methods like Kahan summation and Welford's algorithm.*/
#define MAX_EXO_SAMPLES ((EXO_AGG_PERIOD_MS / 1000) + 10)
typedef struct {
  uint16_t sample_count;
  double min;
  double max;
  double mean;
  double stdev;
} __attribute__((__packed__)) exoData_t;
#define EXO_DATA_SIZE sizeof(exoData_t)

// Create an instance of the averaging sampler for our data
static AveragingSampler exo_data;

// app_main passes a handle to the user config partition in NVM.
extern cfg::Configuration *userConfigurationPartition;

// A timer variable we can set to trigger a pulse on LED2 when we get payload serial data
static int32_t ledLinePulse = -1;
static u_int32_t baud_rate_config = DEFAULT_BAUD_RATE;
static u_int32_t line_term_config = DEFAULT_LINE_TERM;

// A buffer for our data from the payload uart
char payload_buffer[2048];


/*  For Turning Text Into Numbers...
 *    Setup a LineParser to turn the ASCII serial data from the YSI EXO2 sonde into numbers
 *    Lines from the EXO2 look like: # 21.853 0.790 0.00 7.58 
 *    256 character buffer should be more than enough 
 *    For unsigned ints, let's use 64 bits, and for floating point let's use 64 bit doubles.
 *    We've got luxurious amounts of RAM on this chip, and it's much easier to avoid roll-overs and precision issues
 *    by using it vs. troubleshooting them because we prematurely optimized things.
 *    Declare the parser here with separator, buffer length, value types array, and number of values per line.
 *    We'll initialize the parser later in setup to allocate all the memory we'll need.
 */
ValueType valueTypes[] = {TYPE_INVALID, TYPE_DOUBLE, TYPE_DOUBLE, TYPE_DOUBLE, TYPE_DOUBLE};
OrderedSeparatorLineParser parser(" ", 256, valueTypes, 5);

void setup(void) {
  /* USER ONE-TIME SETUP CODE GOES HERE */

  // Initialize the exo data buffer to be the size we need.
  exo_data.initBuffer(MAX_EXO_SAMPLES);
  // Initialize our LineParser, which will allocated any needed memory for parsing.
  parser.init();
  // Setup the UART – the on-board serial driver that talks to the RS232 transceiver.
  PLUART::init(USER_TASK_PRIORITY);
  // Baud set per expected baud rate of the sensor.
  PLUART::setBaud(baud_rate_config);
  // Enable passing raw bytes to user app.
  PLUART::setUseByteStreamBuffer(true);
  // Enable parsing lines and passing to user app.
  /// Warning: PLUART only stores a single line at a time. If your attached payload sends lines
  /// faster than the app reads them, they will be overwritten and data will be lost.
  PLUART::setUseLineBuffer(true);
  // Set a line termination character per protocol of the sensor.
  PLUART::setTerminationCharacter((char)line_term_config);
  // Turn on the UART.
  PLUART::enable();
  // Enable the input to the Vout power supply.
  bristlefin.enableVbus();
  // ensure Vbus stable before enable Vout with a 5ms delay.
  vTaskDelay(pdMS_TO_TICKS(5));
  // enable Vout, 12V by default.
  bristlefin.enableVout();
}

void loop(void) {
  /* USER LOOP CODE GOES HERE */
  // This aggregates exo readings into stats, and sends them along to Spotter
  static u_int32_t exoStatsTimer = uptimeGetMs();
  if ((u_int32_t)uptimeGetMs() - exoStatsTimer >= EXO_AGG_PERIOD_MS) {
    exoStatsTimer = uptimeGetMs();
    double mean = 0, stdev = 0, min = 0, max = 0;
    uint16_t n_samples = 0;
    if (exo_data.getNumSamples()) {
      mean = exo_data.getMean();
      stdev = exo_data.getStd(mean);
      min = exo_data.getMin();
      max = exo_data.getMax();
      n_samples = exo_data.getNumSamples();
      exo_data.clear();
    }

    // Get the RTC if available
    RTCTimeAndDate_t time_and_date = {};
    rtcGet(&time_and_date);
    char rtcTimeBuffer[32];
    rtcPrint(rtcTimeBuffer, &time_and_date);

    bm_fprintf(0, "payload_data_agg.log",
               "tick: %llu, rtc: %s, n: %u, min: %.4f, max: %.4f, mean: %.4f, "
               "std: %.4f\n",
               uptimeGetMs(), rtcTimeBuffer, n_samples, min, max, mean, stdev);
    bm_printf(0,
              "[exo-agg] | tick: %llu, rtc: %s, n: %u, min: %.4f, max: %.4f, "
              "mean: %.4f, std: %.4f",
              uptimeGetMs(), rtcTimeBuffer, n_samples, min, max, mean, stdev);
    printf("[exo-agg] | tick: %llu, rtc: %s, n: %u, min: %.4f, max: %.4f, "
           "mean: %.4f, std: %.4f\n",
           uptimeGetMs(), rtcTimeBuffer, n_samples, min, max, mean, stdev);
    uint8_t tx_data[EXO_DATA_SIZE] = {};
    exoData_t tx_exo = {
        .sample_count = n_samples, .min = min, .max = max, .mean = mean, .stdev = stdev};
    memcpy(tx_data, (uint8_t *)(&tx_exo), EXO_DATA_SIZE);
    if (spotter_tx_data(tx_data, EXO_DATA_SIZE, BM_NETWORK_TYPE_CELLULAR_IRI_FALLBACK)) {
      printf("%llut - %s | Sucessfully sent Spotter transmit data request\n", uptimeGetMs(),
             rtcTimeBuffer);
    } else {
      printf("%llut - %s | Failed to send Spotter transmit data request\n", uptimeGetMs(),
             rtcTimeBuffer);
    }
  }

  // This checks for a trigger set by ledLinePulse when data is received from the payload UART.
  // Each time this happens, we pulse LED2 Green.
  static bool led2State = false;
  // If LED2 is off and the ledLinePulse flag is set, turn it on Green.
  if (!led2State && ledLinePulse > -1) {
    bristlefin.setLed(2, Bristlefin::LED_GREEN);
    led2State = true;
  }
  // If LED2 has been on for LED_ON_TIME_MS, turn it off.
  else if (led2State && ((u_int32_t)uptimeGetMs() - ledLinePulse >= LED_ON_TIME_MS)) {
    bristlefin.setLed(2, Bristlefin::LED_OFF);
    ledLinePulse = -1;
    led2State = false;
  }

  // This section demonstrates a simple non-blocking bare metal method for rollover-safe timed tasks, like blinking an LED.
  // More canonical (but more arcane) modern methods of implementing this kind functionality would bee to use FreeRTOS tasks or hardware timer ISRs.

  static u_int32_t ledPulseTimer = uptimeGetMs();
  static u_int32_t ledOnTimer = 0;
  static bool led1State = false;

  // Turn LED1 on green every LED_PERIOD_MS milliseconds.
  if (!led1State && ((u_int32_t)uptimeGetMs() - ledPulseTimer >= LED_PERIOD_MS)) {
    bristlefin.setLed(1, Bristlefin::LED_GREEN);
    ledOnTimer = uptimeGetMs();
    ledPulseTimer += LED_PERIOD_MS;
    led1State = true;
  }
  // If LED1 has been on for LED_ON_TIME_MS milliseconds, turn it off.
  else if (led1State && ((u_int32_t)uptimeGetMs() - ledOnTimer >= LED_ON_TIME_MS)) {
    bristlefin.setLed(1, Bristlefin::LED_OFF);
    led1State = false;
  }

  // Read a cluster of bytes if available
  // A timer is used to try to keep clusters of bytes (say from lines) in the same output.
  static int64_t readingBytesTimer = -1;
  // Note - PLUART::setUseByteStreamBuffer must be set true in setup to enable bytes.
  if (readingBytesTimer == -1 && PLUART::byteAvailable()) {
    // Get the RTC if available
    RTCTimeAndDate_t time_and_date = {};
    rtcGet(&time_and_date);
    char rtcTimeBuffer[32];
    rtcPrint(rtcTimeBuffer, &time_and_date);
    printf("[payload-bytes] | tick: %" PRIu64 ", rtc: %s, bytes:", uptimeGetMs(),
           rtcTimeBuffer);
    // not very readable, but it's a compact trick to overload our timer variable with a -1 flag
    readingBytesTimer = (int64_t)((u_int32_t)uptimeGetMs());
  }
  while (PLUART::byteAvailable()) {
    readingBytesTimer = (int64_t)((u_int32_t)uptimeGetMs());
    uint8_t byte_read = PLUART::readByte();
    printf("%02X ", byte_read);
  }
  if (readingBytesTimer > -1 &&
      (u_int32_t)uptimeGetMs() - (u_int32_t)readingBytesTimer >= BYTES_CLUSTER_MS) {
    printf("\n");
    readingBytesTimer = -1;
  }

  // Read a line if it is available
  if (PLUART::lineAvailable()) {
    // Shortcut the raw bytes cluster completion so the parsed line will be on a new console line
    if (readingBytesTimer > -1) {
      printf("\n");
      readingBytesTimer = -1;
    }
    uint16_t read_len = PLUART::readLine(payload_buffer, sizeof(payload_buffer));

    // Get the RTC if available
    RTCTimeAndDate_t time_and_date = {};
    rtcGet(&time_and_date);
    char rtcTimeBuffer[32] = {};
    rtcPrint(rtcTimeBuffer, NULL);
    bm_fprintf(0, "exo_raw.log", "tick: %" PRIu64 ", rtc: %s, line: %.*s\n", uptimeGetMs(),
               rtcTimeBuffer, read_len, payload_buffer);
    bm_printf(0, "[exo] | tick: %" PRIu64 ", rtc: %s, line: %.*s", uptimeGetMs(), rtcTimeBuffer,
              read_len, payload_buffer);
    printf("[exo] | tick: %" PRIu64 ", rtc: %s, line: %.*s\n", uptimeGetMs(), rtcTimeBuffer,
           read_len, payload_buffer);

    // trigger a pulse on LED2
    ledLinePulse = uptimeGetMs();

    // Now when we get a line of text data, our LineParser turns it into numeric values.
    if (parser.parseLine(payload_buffer, read_len)) {
      printf("parsed values: %llu | %f\n", parser.getValue(0).data, parser.getValue(1).data, parser.getValue(2).data), parser.getValue(3).data, parser.getValue(4).data;
    } else {
      printf("Error parsing line!\n");
      return; 
    }

    // Now let's aggregate those values into statistics
    if (exo_data.getNumSamples() >= MAX_EXO_SAMPLES) {
      printf("ERR - No more room in exo reading buffer, already have %d "
             "readings!\n",
             MAX_EXO_SAMPLES);
      return; 
    }

    double exo_reading = parser.getValue(1).data.double_val;
    exo_data.addSample(exo_reading);

    printf("count: %u/%d, min: %f, max: %f\n", exo_data.getNumSamples(),
           MAX_EXO_SAMPLES, exo_data.getMin(), exo_data.getMax());
  }
}
1 Like

Hi Cobi,

Great work! Thanks for sharing. I’m really excited about your integration. A couple small changes are all that’s required. Here’s a diff, and I’ll explain below.

77,78c77,78
< ValueType valueTypes[] = {TYPE_INVALID, TYPE_DOUBLE, TYPE_DOUBLE, TYPE_DOUBLE, TYPE_DOUBLE};
< OrderedSeparatorLineParser parser(" ", 256, valueTypes, 5);
---
> ValueType valueTypes[] = {TYPE_DOUBLE, TYPE_DOUBLE, TYPE_DOUBLE, TYPE_DOUBLE};
> OrderedSeparatorLineParser parser(" #", 256, valueTypes, 4);
243c243
<       printf("parsed values: %llu | %f\n", parser.getValue(0).data, parser.getValue(1).data, parser.getValue(2).data), parser.getValue(3).data, parser.getValue(4).data;
---
>       printf("parsed values: %f, %f, %f, %f\n", parser.getValue(0).data, parser.getValue(1).data, parser.getValue(2).data, parser.getValue(3).data);

In order to skip the # at the start of the sensor’s output, you can add the # character to the string of delimiters (in addition to space) on line 78.

It’s also important to note that TYPE_INVALID triggers an immediate parsing failure, so you can’t use it in the list of value types. Just pass an array of 4 TYPE_DOUBLE and change the number at the end of line 78 from 5 to 4.

Line 243 which prints “parsed values” had the closing parenthesis in the wrong place and needed to be modified slightly. Usually the compiler would tell you about that, but the rest of the line just happened to be technically valid C++, so it compiled fine. :man_facepalming:

After these changes the code works as intended, including aggregation. It’s set on line 37 to aggregate and transmit every 30 seconds, which is probably too often. You might burn through a surprising amount of :moneybag: :moneybag: :moneybag: for the telemetry, so I recommend changing EXO_AGG_PERIOD_MIN from 0.5 to 5 as it was in the original example.

You might be surprised by the data that comes from the Sofar API. The example packs the number of samples, min, max, mean, and stdev into a struct for transmission, with each number encoded little endian because that’s how the microcontroller represents the numbers in memory. You need to reverse the order of the bytes to correctly decode them.

Let me explain with an example. I set up another device to behave like the sonde and send your example line # 21.853 0.790 0.00 7.58 every 5 seconds over serial to the dev kit. I see this output from the dev kit console:

[exo-agg] | tick: 5675120, rtc: 2024-07-01T21:06:48.015, n: 5, min: 0.7900, max: 0.7900, mean: 0.7900, std: 0.0000
5675120t - 2024-07-01T21:06:48.015 | Sucessfully sent Spotter transmit data request

And I can see the transmission in the Spotter USB console:

2024-07-01T20:46:48.800Z [MS] [INFO] Added message(id: 386 len: 63) to queue MS_Q_LEGACY: (1)!
Message: DE 66 83 15 B8 DA 01 AC 02 1D A1 2D C6 5D E9 6B 41 71 E5 E2 FC 2D 53 33 39 46 86 2C 20 05 00 48 E1 7A 14 AE 47 E9 3F 48 E1 7A 14 AE 47 E9 3F 48 E1 7A 14 AE 47 E9 3F 00 00 00 00 00 00 00 00 

The first 29 bytes are a header added by the Spotter. After that come your data members. I’ll break them apart with an explanation.

  • 05 00 => 0005: number of readings in the aggregated sample as an unsigned 16-bit integer
  • 48 E1 7A 14 AE 47 E9 3F => 0x3fe947ae147ae148: the low-level double-precision floating-point representation of 0.79. You can verify this and play around using Float Toy. This represents the minimum from the sample.
  • 48 E1 7A 14 AE 47 E9 3F => 0x3fe947ae147ae148 again for the max
  • 48 E1 7A 14 AE 47 E9 3F => 0x3fe947ae147ae148 again for the mean
  • 00 00 00 00 00 00 00 00 => 0: the standard deviation

When you pull this data from the raw-messages API as described in Guide 5, you’ll want to write your own post-processing script to decode the data.

Hope that helps!

1 Like

Thank you for your assistance and explanation. I was still getting a parsing error when I added the # as a separator. I looked closely at what OrderedSeparatorLineParser.cpp wanted and I saw that you can include a header. I changed the code below and it seems to be working as intended now. I am working on cleaning up the code and aggregating the stats from each sensor instead of just one.

77,78c77,78cc77,78
< ValueType valueTypes[] = {TYPE_INVALID, TYPE_DOUBLE, TYPE_DOUBLE, TYPE_DOUBLE, TYPE_DOUBLE};
< OrderedSeparatorLineParser parser(" ", 256, valueTypes, 5);
---
> ValueType valueTypes[] = {TYPE_DOUBLE, TYPE_DOUBLE, TYPE_DOUBLE, TYPE_DOUBLE};
> OrderedSeparatorLineParser parser(" #", 256, valueTypes, 4);
---
> ValueType valueTypes[] = {TYPE_DOUBLE, TYPE_DOUBLE, TYPE_DOUBLE, TYPE_DOUBLE};
> OrderedSeparatorLineParser parser(" ", 256, valueTypes, 4, "#");
2 Likes

Outstanding! Thanks! I didn’t know about the optional header argument. Bravo!

1 Like