Particle Projects

June 2023

Particle Boat Monitor


Functional Requirements

  • Power on/off switch with initial visual indication of on/off status.
  • Monitor 3x bilge pumps and notifiy the user when any become active.
  • Track the boat's position with a GPS.
  • Track the boat's speed in knots
  • Report the last time data was published
  • Notify the user if the boat's position has moved more than 122 m or 400 ft AND the engine is not running (anchor drag).   The 122 m / 400 ft threshold was chosen because a boat swinging 180° around an anchor with 200 ft of anchor rode out will appear to move 122 m / 400 ft.
  • Publish data at a minimum interval of 5 minutes to minimize cellular data usage.  
  • If a bilge pump has been activated since the last time data was published, then publish all data.  
  • If no GPS fix is possible, don't publish the GPS position.

 

Hardware

 

Pinout Diagram
Boron
Argon
Xenon
Feather
Spec.
Custom
Project
FeatherWing
| Boron
Argon
Xenon
Feather
Spec.
Custom
Project
FeatherWing
RST RST   | --- --- ---
3V3 3V3   | --- --- ---
D20 ARf   | --- --- ---
GND GND   | --- --- ---
A0 D19 A0   | Li+ Li+  
A1 D18 A1   | EN EN  
A2 D17 A2   | Vusb Vusb +5VDC
A3 D16 A3   | D8
UART2 RTS
D13 DI Bilge Fwd
A4 D15 A4 or D24 (2)   | D7
LED
D12 Pushbutton LED
A5 D14 SS A5 or D25 (2)   | D6
UART2 CTS
D11 DI Bilge Mid
D13
SCK
SCK   | D5
UART2 RX
D10 DI Bilge Aft
D12
MOSI
MOSI   | D4
UART2 TX
MISO
D9 DI Eng Run
D11
MISO
MISO   | D3
SCL1
MOSI
UART1_CTS
D6  
D10
UART1 Rx
Rx GPS | D2
SDA1
SCK
UART1_RTS
D5  
D9
UART1 Tx
Tx GPS | D1
SCL
SCL  
NC (1)   | D0
SDA
SDA  

Color Key: SPI   I2C (I2C pullup on FeatherWing, not Feather)   GPIO free  

 

Firmware

The Particle Boron cellular IoT device will publish a JSON string to the Particle Cloud, referencing a Particle webhook.   The webhook reformats the data, and then sends it to the Blynk Cloud via a HTTP GET, updating the Blynk datastreams.   Note that the device doesn't run Blynk code, and therefore it will never appear as "online" to Blynk.  


/*
   Project KnotWorkin
   Author:  Mark Kiehl / Mechatronic Solutions LLC
   Date: June 2023
  
   Publish to Blynk every 5 minutes only if:
     - Any bilge pump has been activated.
	 - If the boat's position has changed by more than 122 m / 400 ft
	   AND the engine is not running (anchor drag).
	 - The boats position if a GPS fix was obtained. 

   Blue LED on D7:
    Turns on constant during setup, then breathes when called in loop() unless:
      mode 4 (fast burst every 1 s) if GPS cannot get a fix. 

   The GPS red LED blinks at about 1Hz while it's searching for satellites,
   and blinks once every 15 seconds when a fix is found.

  Hardware:
    Custom FeatherWing for 5V power to Vusb and 4x digital input monitoring.
    Adafruit GPS FeatherWing

  Software:
    Adafruit GPS FeatherWing library
    Custom code for sending data to Particle Webhook for Blynk.

 */

#include "Particle.h"

const char* firmware_version = "0.0.0";
uint8_t led_mode = 0;
boolean just_started = true;

/////////////////////////////////////////////////////////////////////////
// blinkLEDnoDelay()
unsigned long LEDblinkPeriod = 8;
unsigned long LEDblinkLast = 0;
uint8_t LEDblinkPWM = 0;
bool LEDblinkState = false;
uint8_t LEDlastMode = 0;

void blinkLEDnoDelay(byte pin, byte mode) {
  // Blink the LED on 'pin' without using delay() according to
  // the 'mode' argument defined below. 
  // pin must support PWM. 
  // 
   // mode:
  //  0 = breathing
  //  1 = blink slow constantly
  //  2 = blink fast constantly
  //  3 = slow burst every 1 second
  //  4 = fast burst every 1 second
  //
  // 0=breathing; 1=slow blink; 2=fast blink; 3=slow burst; 4=fast burst
  // Required global variables: LEDblinkPeriod, LEDblinkLast, LEDblinkPWM, LEDblinkState, LEDlastMode
  if (mode == 0) {
    // breathing
    LEDblinkPeriod = 8;
    if (LEDlastMode != mode) {
      LEDblinkPWM = 0;
      LEDblinkState = true;
      digitalWrite(pin, LOW);
    }
    if (millis() - LEDblinkLast >= LEDblinkPeriod) {
        if (LEDblinkPWM > 254) LEDblinkState = false;
        if (LEDblinkPWM < 1) LEDblinkState = true;
        if (LEDblinkState) {
            LEDblinkPWM++;
        } else {
            LEDblinkPWM--;
        }
        analogWrite(pin, LEDblinkPWM);
        LEDlastMode = mode;
        LEDblinkLast = millis();
    }
  } else if (mode == 1) {
    // blink slow constantly
    LEDblinkPeriod = 1000;
    if (millis() - LEDblinkLast >= LEDblinkPeriod) {
        digitalWrite(pin, LEDblinkState);
        LEDblinkState = !LEDblinkState;
        LEDlastMode = mode;
        LEDblinkLast = millis();
    }
  } else if (mode == 2) {
    // blink fast constantly
    LEDblinkPeriod = 100;
    if (millis() - LEDblinkLast >= LEDblinkPeriod) {
        digitalWrite(pin, LEDblinkState);
        LEDblinkState = !LEDblinkState;
        LEDlastMode = mode;
        LEDblinkLast = millis();
    }
  } else if (mode == 3) {
    // slow burst every 1 second
    // Slow 4 blinks (lazy burst) followed by 1 sec pause
    if (LEDlastMode != mode) {
      LEDblinkPWM = 0;
      LEDblinkState = true;
      LEDblinkPeriod = 100;
    }
    if (millis() - LEDblinkLast >= LEDblinkPeriod) {
        if (LEDblinkPWM < 7) {
          if (LEDblinkPWM == 0) LEDblinkState = true;
          digitalWrite(pin, LEDblinkState);
          LEDblinkPeriod = 100;
          LEDblinkState = !LEDblinkState;
          LEDblinkPWM++;
        } else {
          digitalWrite(pin, LOW);
          LEDblinkPWM = 0;
          LEDblinkPeriod = 1000;
        }
        LEDlastMode = mode;
        LEDblinkLast = millis();
    }
  } else if (mode == 4) {
    // fast burst every 1 second
    // Fast 4 blinks (burst) followed by 1 sec pause
    if (LEDlastMode != mode) {
      LEDblinkPWM = 0;
      LEDblinkState = true;
      LEDblinkPeriod = 25;
    }
    if (millis() - LEDblinkLast >= LEDblinkPeriod) {
        if (LEDblinkPWM < 7) {
          if (LEDblinkPWM == 0) LEDblinkState = true;
          digitalWrite(pin, LEDblinkState);
          LEDblinkPeriod = 25;
          LEDblinkState = !LEDblinkState;
          LEDblinkPWM++;
        } else {
          digitalWrite(pin, LOW);
          LEDblinkPWM = 0;
          LEDblinkPeriod = 1000;
        }
        LEDlastMode = mode;
        LEDblinkLast = millis();
    }
  } // mode
}   // blinkLEDnoDelay()


void blinkERR(byte ledPIN){
  // S-O-S
  const uint8_t S = 150;
  const uint16_t O = 300;
  for(uint8_t i = 3; i>0; i--){
    digitalWrite(ledPIN, HIGH);
    delay(S);
    digitalWrite(ledPIN, LOW);
    delay(S);
  }    
  delay(200);
  for(uint8_t i = 3; i>0; i--){
    digitalWrite(ledPIN, HIGH);
    delay(O);
    digitalWrite(ledPIN, LOW);
    delay(O);
  }    
  delay(200);
  for(uint8_t i = 3; i>0; i--){
    digitalWrite(ledPIN, HIGH);
    delay(S);
    digitalWrite(ledPIN, LOW);
    delay(S);
  }    
  delay(200);
} // blinkERR()


/////////////////////////////////////////////////////////////////////////
// digital inputs

// Bundle all of the digital input data into a structure.
const byte DI_COUNT = 4;
// initialize DI_DEFAULT_STATE LOW if pulldown resistor, HIGH if pullup resistor.
// Must use the same LOW / HIGH (pullup / pulldown) for all digital inputs monitored.
// timer_interval and timer_last are used for debounce. 
const byte DI_DEFAULT_STATE = HIGH;
struct digital_inputs_t {
  uint8_t pin;
  uint8_t state;
  uint8_t last_state;
  uint32_t timer_interval;
  uint32_t timer_last;
  boolean alarm;
  uint32_t state_change_count;
};
digital_inputs_t arr_di[DI_COUNT];


void ProcessDigitalInputs() {
  // Publish the arr_di[i].state_change_count ONLY when the change in state is 
  // from DI_DEFAULT_STATE to !DI_DEFAULT_STATE.
  // Look for a change in state (HIGH/LOW) for the digital inputs referenced by arr_di.
  // If the change in state is from DI_DEFAULT_STATE to !DI_DEFAULT_STATE, and
  // arr_di[i].alarm == false, then increment arr_di[i].state_change_count and
  // publish the change (arr_di[i].alarm = true).
  for (uint8_t i=0; i<DI_COUNT; i++) {
    if (arr_di[i].timer_last > millis())  arr_di[i].timer_last = millis();
    arr_di[i].state = digitalRead(arr_di[i].pin);
    if (arr_di[i].state != arr_di[i].last_state && millis() - arr_di[i].timer_last > arr_di[i].timer_interval) {
      // Change in state for arr_di[i].pin detected. 
      if (arr_di[i].state != DI_DEFAULT_STATE && arr_di[i].alarm == false) {
        led_mode = 3; // 3 = slow burst every 1 second  
        arr_di[i].state_change_count++; // Only count the change from DI_DEFAULT_STATE to !DI_DEFAULT_STATE. (HIGH = bilge switch off) to LOW = bilge switch on).
        arr_di[i].alarm = true; // Causes the arr_di[i].state_change_count to be published by publishTimer()
      }      
      arr_di[i].last_state = arr_di[i].state;
      arr_di[i].timer_last = millis();
    } 
  } // for
}  // ProcessDigitalInputs()


////////////////////////////////////////////////////////////////
// Adafruit GPS FeatherWing
// https://www.adafruit.com/product/3133
// https://learn.adafruit.com/adafruit-ultimate-gps-featherwing?view=all
// Install library "Adafruit_GPS" from the Particle cloud. 
#include <Adafruit_GPS.h>
// Serial1 is also UART1 on the Boron/Argon/Xenon pins D10/Rx and D9/Tx
#define GPSSerial Serial1
// Connect to the GPS on the hardware port
Adafruit_GPS GPS(&GPSSerial);
// Set GPSECHO to 'false' to turn off echoing the GPS data to the Serial console
// Set to 'true' if you want to debug and listen to the raw GPS sentences
#define GPSECHO false

/////////////////////////////////////////////////////////////////////////
// Particle publish to webhook / Blynk only when something happens.

const uint32_t TIMER_INTERVAL_MS = 300000;   // Used to limit the frequency of publishing.  Use 5 min or 300000 ms
uint32_t last_publish_ms = 0;
float lat = 0.0;
float lon = 0.0;
float knots = 0.0;
float vdc_batt = 0.0;
bool publish_to_blynk = false;
uint8_t loc = 0;    // 1 when the GPS position of the boat has changed by more than 122 m or 400 ft.

void publishTimer() {
  if (last_publish_ms > millis())  last_publish_ms = millis();
  if (millis() - last_publish_ms >= TIMER_INTERVAL_MS) {

    //  Publish to Blynk if any arr_di[i].alarm == true, on first restart,
    //  or if the boat's position has changed by more than 122 m or 400 ft.
    publish_to_blynk = false;

    // Determine if any DI has alarm = true
    for (int i=0; i<DI_COUNT; i++) {
      if (arr_di[i].alarm == true) {
        publish_to_blynk = true;
      }
    }  // for

    uint32_t V0 = arr_di[0].state_change_count;	// fwd bilge pump
    uint32_t V1 = arr_di[1].state_change_count;	// mid bilge pump
    uint32_t V2 = arr_di[2].state_change_count;	// aft bilge pump
    uint32_t V3 = 0;	// engine running; 1 = true, 0 = false
    if (arr_di[3].state != DI_DEFAULT_STATE) {
      V3 = 1;
    }

    loc = 0;  // location has not changed (default).
    
    if (GPS.fix) {
      Serial.printlnf("GPS UTC %4d-%02d-%02dT%02d:%02d:%02d%+05d, fix qual %d, sat %d, lat %0.5f, lon %0.5f, knots %0.1f, %0.1f deg, altitude %0.1f m, mag var %0.1f deg", GPS.year+2000, GPS.month, GPS.day, GPS.hour, GPS.minute, GPS.seconds, 0, GPS.fixquality, GPS.satellites, GPS.latitudeDegrees, GPS.longitudeDegrees, GPS.speed, GPS.altitude, GPS.angle, GPS.magvariation);
      if ((int)lat == 0 || (int)lon == 0) {
        lat = GPS.latitudeDegrees;
        lon = GPS.longitudeDegrees;
        knots = GPS.speed;
      }

      // Simulate location change
      //lat = lat + 0.0012; // more than 400 ft
      //Serial.printlnf("Latitude/Longitude: %f, %f", lat, lon);

      // FIX:  0 means no 'valid fix', 1 means 'normal precision', and 2 means the position data is further corrected by some differential system.  Typically 1 with internal antenna, 2 with external antenna.
      // Sat:  => 4 typical, but over time in home varies from 4 to 6 with internal antenna. With external antenna, typically 8 to 10. 
      // Note that knots will float at 0.1 to 1.2 when the GPS is at rest with the internal antenna.  With external antenna, value is 0.0 to 0.01 knots.
      // Lat/Lon in decimal degrees to 3 decimal places is 111 m / 364 ft, to 4 places = 11.1 m or 36.4 ft.
      // A change in GPS latitude/latitude of 0.0011 is a distance of 122 m or 400 ft (a boat swinging around an anchor with 200 ft of anchor rode out).
      if (GPS.fixquality > 0 && (fabs(fabs(GPS.latitudeDegrees) - fabs(lat)) > 0.0011 || fabs(fabs(GPS.longitudeDegrees) - fabs(lon)) > 0.0011)) {
        double delta_lat_m = fabs(fabs(GPS.latitudeDegrees) - fabs(lat))*10000.0/90.0*1000.0;  // distance in m
        double delta_lon_m = fabs(fabs(GPS.longitudeDegrees) - fabs(lon))*10000.0/90.0*1000.0;
        double delta_lat_ft = fabs(fabs(GPS.latitudeDegrees) - fabs(lat))*10000.0/90.0*3280.4;  // distance in ft
        double delta_lon_ft = fabs(fabs(GPS.longitudeDegrees) - fabs(lon))*10000.0/90.0*3280.4;
        double delta_m = max(delta_lat_m, delta_lon_m);
        double delta_ft = max(delta_lat_ft, delta_lon_ft);
        if (digitalRead(arr_di[3].pin) == DI_DEFAULT_STATE) {
          // The engine is not running & the boat is moving.. Anchor drag!
          publish_to_blynk = true;
          loc = 1;
          Serial.printlnf("The boat has moved a distance of %f m or %f ft since the last time the GPS position was reported, and the engine is NOT running", delta_m, delta_ft);
        } else {
          Serial.printlnf("The boat has moved a distance of %f m or %f ft since the last time the GPS position was reported, and the engine is running", delta_m, delta_ft);
        }
      }
      if (GPS.fixquality > 0 && just_started == true) {
        publish_to_blynk = true;
        just_started = false;
      } 
      lat = GPS.latitudeDegrees;
      lon = GPS.longitudeDegrees;
      knots = GPS.speed;
      if (led_mode != 3) led_mode = 0;
    } else {
        led_mode = 4; // 4 = fast burst every 1 second because no GPS fix.
    } // GPS.Fix

    if (publish_to_blynk == true) {
      //for(uint8_t i = 10; i>0; i--) { digitalWrite(D7, HIGH); delay(10); digitalWrite(D7, LOW); delay(10); }    
      char data[125]; // See serial output for the actual size in bytes.
      snprintf(data, sizeof(data), "{\"V0\":%lu,\"V1\":%lu,\"V2\":%lu,\"V3\":%lu,\"lat\":%f,\"lon\":%f,\"knots\":%f,\"loc\":%u}", V0, V1, V2, V3, lat, lon, knots, loc);
      Serial.printlnf("Sending to Blynk: '%s' with size of %u bytes", data, strlen(data));
      bool pub_result = Particle.publish("KnotWorkin", data, PRIVATE);
      if (pub_result) {
        for (int i=0; i<DI_COUNT; i++) {
          arr_di[i].alarm = false;
        }
        publish_to_blynk = false;
        if (led_mode != 4) led_mode = 0;
      } else {
        Serial.println("ERROR: Particle.publish()");
        blinkERR(D7);
      }
      last_publish_ms = millis(); // Limit publish frequency to limit data usage.
    } else {
      last_publish_ms = millis() - 2000;  // Don't check if publish required too frequently. 
    } // publish_to_blynk

  }
} // publishTimer()


void setup() {

  pinMode(D7, OUTPUT);
  digitalWrite(D7, HIGH);

  Serial.begin(9600);
  waitFor(Serial.isConnected, 30000);
  delay(1000);
  Serial.printlnf("Device OS v%s", System.version().c_str());
  Serial.printlnf("Free RAM %lu bytes", System.freeMemory());
  Serial.printlnf("Firmware version v%s", firmware_version);

  // Serial1 on Argon/Boron is the main UART serial the GPS FeatherWing is connected to.
  // Below is for testing the GPS only.
  //Serial1.begin(9600);
  //delay(5000);

  // start Adafruit GPS FeatherWing
  // Note: If the GPS FeatherWing is not attached, the code continues.
  GPS.begin(9600);
  // Turn on RMC (recommended minimum) and GGA (fix data) including altitude
  GPS.sendCommand(PMTK_SET_NMEA_OUTPUT_RMCGGA);
  // Set the update rate
  GPS.sendCommand(PMTK_SET_NMEA_UPDATE_1HZ); // 1 Hz update rate
  // Request updates on antenna status, comment out to keep quiet
  //GPS.sendCommand(PGCMD_ANTENNA);
  delay(1000);
  // Ask for firmware version
  //GPSSerial.println(PMTK_Q_RELEASE);
  // end Adafruit GPS FeatherWing

  // Initialize arr_di
  arr_di[0].pin = D8;	// DI bilge fwd
  arr_di[1].pin = D6;	// DI bilge mid
  arr_di[2].pin = D5;	// DI bilge aft
  arr_di[3].pin = D4;	// DI eng run
  for (int i=0; i<DI_COUNT; i++) {
    if (DI_DEFAULT_STATE == LOW) {
      //pinMode(arr_di[i].pin, INPUT_PULLDOWN);
      pinMode(arr_di[i].pin, INPUT);
      arr_di[i].state = LOW;
      arr_di[i].last_state = LOW;
    } else {
      //pinMode(arr_di[i].pin, INPUT_PULLUP);
      pinMode(arr_di[i].pin, INPUT);
      arr_di[i].state = HIGH;
      arr_di[i].last_state = HIGH;
    }
    arr_di[i].timer_interval = 50; // debounce timer 100 ms
    arr_di[i].timer_last = millis();
    arr_di[i].alarm = false;
    arr_di[i].state_change_count = 0;
  }

  //last_publish_ms = millis();
  randomSeed(millis());
  digitalWrite(D7, LOW);
} // setup()


void loop() {

  ProcessDigitalInputs();

  // Below absolutely required here.
  if (GPSSerial.available()) {
    GPS.read();
    if (GPS.parse(GPS.lastNMEA())) {
      if (GPS.newNMEAreceived()) {
          if (!GPS.parse(GPS.lastNMEA())) {
            // sets the newNMEAreceived() flag to false
          }
      }
    } // GPS
  }
  // debug GPS
  // if (Serial1.available()) { char c = Serial1.read(); Serial.write(c); }

  publishTimer();

  blinkLEDnoDelay(D7, led_mode);

} // loop()

 

Particle Integration Webhook

Click do see the Particle integration webhook


{
  "token": "your Blynk 32 char device token",
  "V0": "{{V0}}",
  "V1": "{{V1}}",
  "V2": "{{V2}}",
  "V3": "{{V3}}",
  "V4": "{{lon}},{{lat}}",
  "V5": "{{knots}}",
  "V6": "{{{PARTICLE_PUBLISHED_AT}}}",
  "V7": "{{loc}}"
}

See the

 

Blynk

Blynk Datastreams
Virtual Pin Data Type Comment
V0 Integer Forward bilge pump on/off count
V1 Integer Mid bilge pump on/off count
V2 Integer Aft bilge pump on/off count
V3 Integer Engine running (1 = yes, 0 = no) count
V4 Location GPS latitude & longitude
V5 Double Boat speed [knots] (from GPS)
V6 String Last date/time data was published
V7 Integer Boat moving (position changed by more than 122 m / 400 ft) since last published data.

An image of the Blynk app is shown below.   The count of times the forward, mid, and aft bilge pumps operate is a more robust approach to monitoring their activity.   The 'Eng Run' and 'Anchor Drag' widgets are latching pushbutton switches that display the state of each, and the switch function allows the user to easily reset the Blynk datastream.  

 

Blynk Automation

 

 

Under Development

 


Do you need help developing or customizing a IoT product for your needs?   Send me an email requesting a free one hour phone / web share consultation.  

 

The information presented on this website is for the author's use only.   Use of this information by anyone other than the author is offered as guidelines and non-professional advice only.   No liability is assumed by the author or this web site.