Grove-VOC and eCO2 Gas Sensor (SGP30)

In preparation for a project to monitor the fumes (initially Volatile Organic Compounds) levels around the 3D Printers and Laser Cutters in a school makerspace I purchased a Grove -VOC and eCO2 Gas Sensor (SGP30) for evaluation.

Seeeduino Nano easySensors shield and Grove VOC & eCO2 Sensor

Seeeduino Nano devices have a single on-board I2C socket which meant I didn’t need a Grove Shield for Arduino Nano which reduced the size and cost of the sensor node.

I downloaded the sample code from the Seeedstudio wiki and modified my Easy Sensors Arduino Nano Radio Shield RFM69/95 Payload Addressing client to use the sensor.

My first attempt failed with an issues accessing an Analog port to read the serial number from the Microchip ATSHA204 security chip. After looking at the Seeed SGP30 library source code (based on Sensiron samples) I think the my Nano device was running out of memory. I then searched for other Arduino compatible SGP30 libraries and rebuilt he application with the one from Sparkfun,

/*
  Copyright ® 2019 August devMobile Software, All Rights Reserved

  THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY
  KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
  IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR
  PURPOSE.

  You can do what you want with this code, acknowledgment would be nice.
  http://www.devmobile.co.nz

  Seeedstudio Grove - VOC and eCO2 Gas Sensor (SGP30)
  https://www.seeedstudio.com/Grove-VOC-and-eCO2-Gas-Sensor-SGP30-p-3071.html

  Seeeduino Nano 
  https://www.seeedstudio.com/Seeeduino-Nano-p-4111.html
  
  Polycarbonate enclosure approx 3.5" x 4.5"
    2 x Cable glands
    1 x Grommet to seal SMA antenna connector
    3M command adhesive strips to hold battery & device in place
   
*/
#include <stdlib.h&gt;
#include "SparkFun_SGP30_Arduino_Library.h" 
#include <LoRa.h&gt;
#include <sha204_library.h&gt;

//#define DEBUG
//#define DEBUG_TELEMETRY
//#define DEBUG_LORA
#define DEBUG_VOC_AND_CO2

#define UNITS_VOC "ppb"
#define UNITS_CO2 "ppm"

// LoRa field gateway configuration (these settings must match your field gateway)
const byte DeviceAddressMaximumLength = 15 ;
const char FieldGatewayAddress[] = {"LoRaIoT1"};
const float FieldGatewayFrequency =  915000000.0;
const byte FieldGatewaySyncWord = 0x12 ;

// Payload configuration
const int ChipSelectPin = 10;
const int ResetPin = 9;
const int InterruptPin = 2;

// LoRa radio payload configuration
const byte SensorIdValueSeperator = ' ' ;
const byte SensorReadingSeperator = ',' ;
const unsigned long SensorUploadDelay = 60000;

// ATSHA204 secure authentication, validation with crypto and hashing (currently only using for unique serial number)
const byte Atsha204Port = A3;
atsha204Class sha204(Atsha204Port);
const byte DeviceSerialNumberLength = 9 ;
byte deviceSerialNumber[DeviceSerialNumberLength] = {""};

SGP30 mySensor; //create an object of the SGP30 class

const byte PayloadSizeMaximum = 64 ;
byte payload[PayloadSizeMaximum];
byte payloadLength = 0 ;


void setup()
{
  Serial.begin(9600);

#ifdef DEBUG
  while (!Serial);
#endif
 
  Serial.println("Setup called");

  Serial.print("Field gateway:");
  Serial.print(FieldGatewayAddress ) ;
  Serial.print(" Frequency:");
  Serial.print( FieldGatewayFrequency,0 ) ;
  Serial.print("MHz SyncWord:");
  Serial.print( FieldGatewaySyncWord ) ;
  Serial.println();
  
   // Retrieve the serial number then display it nicely
  if(sha204.getSerialNumber(deviceSerialNumber))
  {
    Serial.println("sha204.getSerialNumber failed");
    while (true); // Drop into endless loop requiring restart
  }

  Serial.print("SNo:");
  DisplayHex( deviceSerialNumber, DeviceSerialNumberLength);
  Serial.println();

  Serial.println("LoRa setup start");

  // override the default chip select and reset pins
  LoRa.setPins(ChipSelectPin, ResetPin, InterruptPin);
  if (!LoRa.begin(FieldGatewayFrequency))
  {
    Serial.println("LoRa begin failed");
    while (true); // Drop into endless loop requiring restart
  }

  // Need to do this so field gateway pays attention to messsages from this device
  LoRa.enableCrc();
  LoRa.setSyncWord(FieldGatewaySyncWord);

#ifdef DEBUG_LORA
  LoRa.dumpRegisters(Serial);
#endif
  Serial.println("LoRa Setup done.");

  // Configure the DF Robot SHT20, temperature &amp; humidity sensor
  Serial.println("SGP30 setup start");  
  Wire.begin();
  if(mySensor.begin() == false)
  {
    Serial.println("SQP-30 initialisation failed");
    while (true); // Drop into endless loop requiring restart
  }
  mySensor.initAirQuality();
  delay(1000);  
  Serial.println("SGP30 setup done");

  PayloadHeader((byte *)FieldGatewayAddress,strlen(FieldGatewayAddress), deviceSerialNumber, DeviceSerialNumberLength);

  Serial.println("Setup done");
  Serial.println();
}


void loop()
{
  unsigned long currentMilliseconds = millis();  

  Serial.println("Loop called");

  mySensor.measureAirQuality();

  PayloadReset();  

  PayloadAdd( "v", mySensor.TVOC, false);
     
  PayloadAdd( "c", mySensor.CO2, false);
  
  #ifdef DEBUG_VOC_AND_CO2  
    Serial.print("VoC:");
    Serial.print( mySensor.TVOC ) ;
    Serial.print( UNITS_VOC ) ;
    Serial.print(" Co2:");
    Serial.print( mySensor.CO2 ) ;
    Serial.println( UNITS_CO2 ) ;
  #endif

  #ifdef DEBUG_TELEMETRY
    Serial.println();
    Serial.print("RFM9X/SX127X Payload length:");
    Serial.print(payloadLength);
    Serial.println(" bytes");
  #endif

  LoRa.beginPacket();
  LoRa.write(payload, payloadLength);
  LoRa.endPacket();

  Serial.println("Loop done");
  Serial.println();

  delay(SensorUploadDelay - (millis() - currentMilliseconds ));
}


void PayloadHeader( const byte *to, byte toAddressLength, const byte *from, byte fromAddressLength)
{
  byte addressesLength = toAddressLength + fromAddressLength ;

  payloadLength = 0 ;

  // prepare the payload header with "To" Address length (top nibble) and "From" address length (bottom nibble)
  
  payload[payloadLength] = (toAddressLength << 4) | fromAddressLength ;
  payloadLength += 1;

  // Copy the "To" address into payload
  memcpy(&amp;payload[payloadLength], to, toAddressLength);
  payloadLength += toAddressLength ;

  // Copy the "From" into payload
  memcpy(&amp;payload[payloadLength], from, fromAddressLength);
  payloadLength += fromAddressLength ;
}


void PayloadAdd( const char *sensorId, float value, byte decimalPlaces, bool last)
{
  byte sensorIdLength = strlen( sensorId ) ;

  memcpy( &amp;payload[payloadLength], sensorId,  sensorIdLength) ;
  payloadLength += sensorIdLength ;
  payload[ payloadLength] = SensorIdValueSeperator;
  payloadLength += 1 ;
  payloadLength += strlen( dtostrf(value, -1, decimalPlaces, (char *)&amp;payload[payloadLength]));
  if (!last)
  {
    payload[ payloadLength] = SensorReadingSeperator;
    payloadLength += 1 ;
  }
  
#ifdef DEBUG_TELEMETRY
  Serial.print("PayloadAdd float-payloadLength:");
  Serial.print( payloadLength);
  Serial.println( );
#endif
}


void PayloadAdd( char *sensorId, int value, bool last )
{
  byte sensorIdLength = strlen(sensorId) ;

  memcpy(&amp;payload[payloadLength], sensorId,  sensorIdLength) ;
  payloadLength += sensorIdLength ;
  payload[ payloadLength] = SensorIdValueSeperator;
  payloadLength += 1 ;
  payloadLength += strlen(itoa( value,(char *)&amp;payload[payloadLength],10));
  if (!last)
  {
    payload[ payloadLength] = SensorReadingSeperator;
    payloadLength += 1 ;
  }
  
#ifdef DEBUG_TELEMETRY
  Serial.print("PayloadAdd int-payloadLength:" );
  Serial.print(payloadLength);
  Serial.println( );
#endif
}


void PayloadAdd( char *sensorId, unsigned int value, bool last )
{
  byte sensorIdLength = strlen(sensorId) ;

  memcpy(&amp;payload[payloadLength], sensorId,  sensorIdLength) ;
  payloadLength += sensorIdLength ;
  payload[ payloadLength] = SensorIdValueSeperator;
  payloadLength += 1 ;
  payloadLength += strlen(utoa( value,(char *)&amp;payload[payloadLength],10));
  if (!last)
  {
    payload[ payloadLength] = SensorReadingSeperator;
    payloadLength += 1 ;
  }
  
#ifdef DEBUG_TELEMETRY
  Serial.print("PayloadAdd uint-payloadLength:");
  Serial.print(payloadLength);
  Serial.println( );
#endif
}


void PayloadReset()
{
  byte fromAddressLength = payload[0] &amp; 0xf ;
  byte toAddressLength = payload[0] &gt;&gt; 4 ;
  
  payloadLength = toAddressLength + fromAddressLength + 1;
}


void DisplayHex( byte *byteArray, byte length) 
{
  for (int i = 0; i < length ; i++)
  {
    // Add a leading zero
    if ( byteArray[i] < 16)
    {
      Serial.print("0");
    }
    Serial.print(byteArray[i], HEX);
    if ( i < (length-1)) // Don't put a - after last digit
    {
      Serial.print("-");
    }
  }
}    

The code is available on GitHub.

11:32:52.947 -> Setup called
11:32:52.947 -> Field gateway:LoRaIoT1 Frequency:915000000MHz SyncWord:18
11:32:53.085 -> SNo:01-23-21-61-D6-D1-F5-86-EE
11:32:53.118 -> LoRa setup start
11:32:53.118 -> LoRa Setup done.
11:32:53.153 -> SGP30 setup start
11:32:54.083 -> SGP30 setup done
11:32:54.117 -> Setup done
11:32:54.117 -> 
11:32:54.117 -> Loop called
11:32:54.152 -> VoC:0ppb Co2:400ppm
11:32:54.187 -> Loop done
11:32:54.187 -> 
11:33:54.092 -> Loop called
11:33:54.127 -> VoC:0ppb Co2:400ppm
11:33:54.195 -> Loop done
11:33:54.195 -> 
11:34:54.098 -> Loop called
11:34:54.133 -> VoC:17ppb Co2:425ppm
11:34:54.201 -> Loop done
11:34:54.201 -> 
11:35:54.109 -> Loop called
11:35:54.142 -> VoC:11ppb Co2:421ppm
11:35:54.210 -> Loop done
11:35:54.210 -> 
11:36:54.109 -> Loop called
11:36:54.143 -> VoC:3ppb Co2:409ppm
11:36:54.212 -> Loop done
11:36:54.212 -> 
11:37:54.135 -> Loop called
11:37:54.135 -> VoC:12ppb Co2:400ppm
11:37:54.204 -> Loop done
11:37:54.204 -> 
11:38:54.126 -> Loop called
11:38:54.161 -> VoC:11ppb Co2:439ppm
11:38:54.231 -> Loop done

To configure the device in Azure IoT Central (similar process for Adafruit.IO, working on support for losant, ubidots and MyDevices) I copied the SNo: from the Arduino development tool logging window and appended c for the CO2 parts per million (ppm), v for VOC parts per billion (ppb) unique serial number from the ATSHA204A chip. (N.B. pay attention to the case of the field names they are case sensitive)

Azure IoT Central configuration

Overall the performance of the VoC sensor data is looking pretty positive, the eCO2 readings need some further investigation as they track the VOC levels. The large spike in the graph below is me putting an open vivid marker on my desk near the sensor.

eCO2 and VOC levels in my office for a day

Bill of materials (prices as at August 2019)

  • Seeeduino Nano USD6.90
  • Grove – VOC and eCO2 Gas Sensor (SGP30) USD15.90
  • EasySensors Arduino Nano radio shield RFM95 USD15.00

DF Robot Temperature & Humidity Sensor(SHT20) trial

In preparation for a project to build weather stations to place at a couple of local schools I purchased a DF Robot SHT20 Temperature & Humidity Sensor for evaluation.

Seeeduino Nano, EasySensors Shield & DF Robot Sensor test rig

The Seeeduino Nano devices I’m testing have a single on-board I2C socket which meant I didn’t need a Grove Shield for Arduino Nano which reduced the size and cost of the sensor node.

To test my setup I installed the DFRobot Arduino SHT20 library and downloaded a demo application to my device.

I started with my Easy Sensors Arduino Nano Radio Shield RFM69/95 Payload Addressing client and modified it to use the SHT20 sensor.

/*
  Copyright ® 2019 August devMobile Software, All Rights Reserved

  THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY
  KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
  IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR
  PURPOSE.

  You can do what you want with this code, acknowledgment would be nice.
  http://www.devmobile.co.nz

  DF Robot SHT20 Temperature &amp; Humidity sensor   https://www.dfrobot.com/wiki/index.php/SHT20_I2C_Temperature_%26_Humidity_Sensor_(Waterproof_Probe)_SKU:_SEN0227  

  Seeeduino Nano 
  https://www.seeedstudio.com/Seeeduino-Nano-p-4111.html
  
  Polycarbonate enclosure approx 3.5" x 4.5"
    2 x Cable glands
    1 x ufl to SMA connector
    3M command adhesive strips to hold battery &amp; device in place
   
*/
#include <stdlib.h&gt;
#include <DFRobot_SHT20.h&gt;
#include <LoRa.h&gt;
#include <sha204_library.h&gt;

//#define DEBUG
//#define DEBUG_TELEMETRY
//#define DEBUG_LORA
//#define DEBUG_TEMPERATURE_AND_HUMIDITY

#define UNITS_HUMIDITY "%"
#define UNITS_TEMPERATURE "°c"

// LoRa field gateway configuration (these settings must match your field gateway)
const byte DeviceAddressMaximumLength = 15 ;
const char FieldGatewayAddress[] = {"LoRaIoT1"};
const float FieldGatewayFrequency =  915000000.0;
const byte FieldGatewaySyncWord = 0x12 ;

// Payload configuration
const int ChipSelectPin = 10;
const int ResetPin = 9;
const int InterruptPin = 2;

// LoRa radio payload configuration
const byte SensorIdValueSeperator = ' ' ;
const byte SensorReadingSeperator = ',' ;
const unsigned long SensorUploadDelay = 60000;

// ATSHA204 secure authentication, validation with crypto and hashing (currently only using for unique serial number)
const byte Atsha204Port = A3;
atsha204Class sha204(Atsha204Port);
const byte DeviceSerialNumberLength = 9 ;
byte deviceSerialNumber[DeviceSerialNumberLength] = {""};

// SHT20 Air temperature and humidity sensor
DFRobot_SHT20 sht20;

const byte PayloadSizeMaximum = 64 ;
byte payload[PayloadSizeMaximum];
byte payloadLength = 0 ;


void setup()
{
  Serial.begin(9600);

#ifdef DEBUG
  while (!Serial);
#endif
 
  Serial.println("Setup called");

  Serial.print("Field gateway:");
  Serial.print(FieldGatewayAddress ) ;
  Serial.print(" Frequency:");
  Serial.print( FieldGatewayFrequency,0 ) ;
  Serial.print("MHz SyncWord:");
  Serial.print( FieldGatewaySyncWord ) ;
  Serial.println();
  
   // Retrieve the serial number then display it nicely
  if(sha204.getSerialNumber(deviceSerialNumber))
  {
    Serial.println("sha204.getSerialNumber failed");
    while (true); // Drop into endless loop requiring restart
  }

  Serial.print("SNo:");
  DisplayHex( deviceSerialNumber, DeviceSerialNumberLength);
  Serial.println();

  Serial.println("LoRa setup start");

  // override the default chip select and reset pins
  LoRa.setPins(ChipSelectPin, ResetPin, InterruptPin);
  if (!LoRa.begin(FieldGatewayFrequency))
  {
    Serial.println("LoRa begin failed");
    while (true); // Drop into endless loop requiring restart
  }

  // Need to do this so field gateway pays attention to messsages from this device
  LoRa.enableCrc();
  LoRa.setSyncWord(FieldGatewaySyncWord);

#ifdef DEBUG_LORA
  LoRa.dumpRegisters(Serial);
#endif
  Serial.println("LoRa Setup done.");

  // Configure the DF Robot SHT20, temperature &amp; humidity sensor
  Serial.println("SHT20 setup start");  
  sht20.initSHT20();
  delay(100);
  sht20.checkSHT20();    
  Serial.println("SHT20 setup done");

  PayloadHeader((byte *)FieldGatewayAddress,strlen(FieldGatewayAddress), deviceSerialNumber, DeviceSerialNumberLength);

  Serial.println("Setup done");
  Serial.println();
}


void loop()
{
  unsigned long currentMilliseconds = millis();  
  float temperature ;
  float humidity ;

  Serial.println("Loop called");

  PayloadReset();  

  humidity = sht20.readHumidity();          
  PayloadAdd( "h", humidity, 0, false);

  temperature = sht20.readTemperature();               
  PayloadAdd( "t", temperature, 1, false);
  
  #ifdef DEBUG_TEMPERATURE_AND_HUMIDITY  
    Serial.print("H:");
    Serial.print( humidity, 0 ) ;
    Serial.print( UNITS_HUMIDITY ) ;
    Serial.print("T:");
    Serial.print( temperature, 1 ) ;
    Serial.println( UNITS_TEMPERATURE ) ;
  #endif

  #ifdef DEBUG_TELEMETRY
    Serial.println();
    Serial.print("RFM9X/SX127X Payload length:");
    Serial.print(payloadLength);
    Serial.println(" bytes");
  #endif

  LoRa.beginPacket();
  LoRa.write(payload, payloadLength);
  LoRa.endPacket();

  Serial.println("Loop done");
  Serial.println();

  delay(SensorUploadDelay - (millis() - currentMilliseconds ));
}


void PayloadHeader( const byte *to, byte toAddressLength, const byte *from, byte fromAddressLength)
{
  byte addressesLength = toAddressLength + fromAddressLength ;

  payloadLength = 0 ;

  // prepare the payload header with "To" Address length (top nibble) and "From" address length (bottom nibble)
  
  payload[payloadLength] = (toAddressLength << 4) | fromAddressLength ;
  payloadLength += 1;

  // Copy the "To" address into payload
  memcpy(&amp;payload[payloadLength], to, toAddressLength);
  payloadLength += toAddressLength ;

  // Copy the "From" into payload
  memcpy(&amp;payload[payloadLength], from, fromAddressLength);
  payloadLength += fromAddressLength ;
}


void PayloadAdd( const char *sensorId, float value, byte decimalPlaces, bool last)
{
  byte sensorIdLength = strlen( sensorId ) ;

  memcpy( &amp;payload[payloadLength], sensorId,  sensorIdLength) ;
  payloadLength += sensorIdLength ;
  payload[ payloadLength] = SensorIdValueSeperator;
  payloadLength += 1 ;
  payloadLength += strlen( dtostrf(value, -1, decimalPlaces, (char *)&amp;payload[payloadLength]));
  if (!last)
  {
    payload[ payloadLength] = SensorReadingSeperator;
    payloadLength += 1 ;
  }
  
#ifdef DEBUG_TELEMETRY
  Serial.print("PayloadAdd float-payloadLength:");
  Serial.print( payloadLength);
  Serial.println( );
#endif
}


void PayloadAdd( char *sensorId, int value, bool last )
{
  byte sensorIdLength = strlen(sensorId) ;

  memcpy(&amp;payload[payloadLength], sensorId,  sensorIdLength) ;
  payloadLength += sensorIdLength ;
  payload[ payloadLength] = SensorIdValueSeperator;
  payloadLength += 1 ;
  payloadLength += strlen(itoa( value,(char *)&amp;payload[payloadLength],10));
  if (!last)
  {
    payload[ payloadLength] = SensorReadingSeperator;
    payloadLength += 1 ;
  }
  
#ifdef DEBUG_TELEMETRY
  Serial.print("PayloadAdd int-payloadLength:" );
  Serial.print(payloadLength);
  Serial.println( );
#endif
}


void PayloadAdd( char *sensorId, unsigned int value, bool last )
{
  byte sensorIdLength = strlen(sensorId) ;

  memcpy(&amp;payload[payloadLength], sensorId,  sensorIdLength) ;
  payloadLength += sensorIdLength ;
  payload[ payloadLength] = SensorIdValueSeperator;
  payloadLength += 1 ;
  payloadLength += strlen(utoa( value,(char *)&amp;payload[payloadLength],10));
  if (!last)
  {
    payload[ payloadLength] = SensorReadingSeperator;
    payloadLength += 1 ;
  }
  
#ifdef DEBUG_TELEMETRY
  Serial.print("PayloadAdd uint-payloadLength:");
  Serial.print(payloadLength);
  Serial.println( );
#endif
}


void PayloadReset()
{
  byte fromAddressLength = payload[0] &amp; 0xf ;
  byte toAddressLength = payload[0] &gt;&gt; 4 ;
  
  payloadLength = toAddressLength + fromAddressLength + 1;
}


void DisplayHex( byte *byteArray, byte length) 
{
  for (int i = 0; i < length ; i++)
  {
    // Add a leading zero
    if ( byteArray[i] < 16)
    {
      Serial.print("0");
    }
    Serial.print(byteArray[i], HEX);
    if ( i < (length-1)) // Don't put a - after last digit
    {
      Serial.print("-");
    }
  }
}    

The code is available on GitHub.

20:52:09.656 -> Setup called
20:52:09.690 -> Field gateway:LoRaIoT1 Frequency:915000000MHz SyncWord:18
20:52:09.794 -> SNo:01-23-21-61-D6-D1-F5-86-EE
20:52:09.828 -> LoRa setup start
20:52:09.828 -> LoRa Setup done.
20:52:09.862 -> SHT20 setup start
20:52:09.932 -> End of battery: no
20:52:09.932 -> Heater enabled: no
20:52:09.965 -> Disable OTP reload: yes
20:52:09.999 -> SHT20 setup done
20:52:09.999 -> Setup done
20:52:09.999 -> 
20:52:09.999 -> Loop called
20:52:10.067 -> H:60%T:20.0°c
20:52:10.136 -> Loop done
20:52:10.136 -> 
20:53:09.915 -> Loop called
20:53:10.019 -> H:61%T:20.5°c
20:53:10.088 -> Loop done
20:53:10.088 -> 

To configure the device in Azure IoT Central (similar process for Adafruit.IO, working on support for losant, ubidots and MyDevices) I copied the SNo: from the Arduino development tool logging window and appended p10 for PM 1 value, p25 for PM2.5 value and p100 for PM10 value to the unique serial number from the ATSHA204A chip. (N.B. pay attention to the case of the field names they are case sensitive)

When I moved the sensor indoors it appeared to take a while to warm up and after a while the metal body still felt cold. The sensor element is surrounded by quite a bit of protective packaging for outdoors use and I that would have a bit more thermal inertia the than the lightweight indoor enclosure.

It would be good to run the sensor alongside a calibrated temperature & humidity sensor to see how accurate and responsive it is.

Bill of materials (prices as at August 2019)

  • Seeeduino Nano USD6.90
  • Grove Screw Terminal USD2.90
  • DF Robot SHT20 I2C Temperature & Humidity Sensor USD22.50
  • EasySensors Arduino Nano radio shield RFM95 USD15.00

Grove – Laser PM2.5 Sensor(HM3301) trial

In preparation for a project to monitor the particulates levels around the 3D Printers and Laser Cutters in a school makerspace I purchased a Grove -Laser PM2.5 Sensor (HM3301) for evaluation.

Seeeduino, Grove HM3301 and easysensors shield

The Seeeduino Nano devices I’m testing have a single on-board I2C socket which meant I didn’t need a Grove Shield for Arduino Nano which reduced the size and cost of the sensor node.

To test my setup I installed the Seeed PM2.5 Sensor HM3301 Software Library and downloaded the demo application to my device.

I started with my Easy Sensors Arduino Nano Radio Shield RFM69/95 Payload Addressing client and modified it to use the HM3301 sensor.

After looking at the demo application I stripped out the checksum code and threw the rest away. In my test harness I have extracted only the PM1.0/PM2.5/PM10.0 (concentration CF=1, Standard particulate) in μg/ m3 values from the sensor response payload.

/*
  Copyright ® 2019 August devMobile Software, All Rights Reserved

  THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY
  KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
  IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR
  PURPOSE.

  You can do what you want with this code, acknowledgment would be nice.

  http://www.devmobile.co.nz

*/
#include <stdlib.h&gt;
#include <LoRa.h&gt;
#include <sha204_library.h&gt;
#include "Seeed_HM330X.h"

//#define DEBUG
//#define DEBUG_TELEMETRY
//#define DEBUG_LORA

const byte SensorPayloadLength = 28 ;
const byte SensorPayloadBufferSize  = 29 ;
const byte SensorPayloadPM1_0Position = 4;
const byte SensorPayloadPM2_5Position = 6;
const byte SensorPayloadPM10_0Position = 8;

HM330X sensor;
byte SensorPayload[SensorPayloadBufferSize];
  
// LoRa field gateway configuration (these settings must match your field gateway)
const byte DeviceAddressMaximumLength = 15 ;
const char FieldGatewayAddress[] = {"LoRaIoT1"};
const float FieldGatewayFrequency =  915000000.0;
const byte FieldGatewaySyncWord = 0x12 ;

// Payload configuration
const int ChipSelectPin = 10;
const int ResetPin = 9;
const int InterruptPin = 2;

// LoRa radio payload configuration
const byte SensorIdValueSeperator = ' ' ;
const byte SensorReadingSeperator = ',' ;
const unsigned long SensorUploadDelay = 60000;

// ATSHA204 secure authentication, validation with crypto and hashing (currently only using for unique serial number)
const byte Atsha204Port = A3;
atsha204Class sha204(Atsha204Port);
const byte DeviceSerialNumberLength = 9 ;
byte deviceSerialNumber[DeviceSerialNumberLength] = {""};

const byte PayloadSizeMaximum = 64 ;
byte payload[PayloadSizeMaximum];
byte payloadLength = 0 ;


void setup()
{
  Serial.begin(9600);

#ifdef DEBUG
  while (!Serial);
#endif
 
  Serial.println("Setup called");

  Serial.print("Field gateway:");
  Serial.print(FieldGatewayAddress ) ;
  Serial.print(" Frequency:");
  Serial.print( FieldGatewayFrequency,0 ) ;
  Serial.print("MHz SyncWord:");
  Serial.print( FieldGatewaySyncWord ) ;
  Serial.println();
  
   // Retrieve the serial number then display it nicely
  if(sha204.getSerialNumber(deviceSerialNumber))
  {
    Serial.println("sha204.getSerialNumber failed");
    while (true); // Drop into endless loop requiring restart
  }

  Serial.print("SNo:");
  DisplayHex( deviceSerialNumber, DeviceSerialNumberLength);
  Serial.println();

  Serial.println("LoRa setup start");

  // override the default chip select and reset pins
  LoRa.setPins(ChipSelectPin, ResetPin, InterruptPin);
  if (!LoRa.begin(FieldGatewayFrequency))
  {
    Serial.println("LoRa begin failed");
    while (true); // Drop into endless loop requiring restart
  }

  // Need to do this so field gateway pays attention to messsages from this device
  LoRa.enableCrc();
  LoRa.setSyncWord(FieldGatewaySyncWord);

#ifdef DEBUG_LORA
  LoRa.dumpRegisters(Serial);
#endif
  Serial.println("LoRa Setup done.");

  // Configure the Seeedstudio CO2, temperature &amp; humidity sensor
  Serial.println("HM3301 setup start");
  if(sensor.init())
  {
    Serial.println("HM3301 init failed");
    while (true); // Drop into endless loop requiring restart
  
  }
  delay(100);
  Serial.println("HM3301 setup done");

  PayloadHeader((byte *)FieldGatewayAddress,strlen(FieldGatewayAddress), deviceSerialNumber, DeviceSerialNumberLength);

  Serial.println("Setup done");
  Serial.println();
}

void loop()
{
  unsigned long currentMilliseconds = millis();  
  byte sum=0;
  short pm1_0 ;
  short pm2_5 ;
  short pm10_0 ;

  Serial.println("Loop called");

  if(sensor.read_sensor_value(SensorPayload,SensorPayloadBufferSize) == NO_ERROR)
  {
    // Calculate then validate the payload "checksum"
    for(int i=0;i<SensorPayloadLength;i++)
    {
        sum+=SensorPayload[i];
    }
    if(sum!=SensorPayload[SensorPayloadLength])
    {
        Serial.println("Invalid checksum");
        return;
    }    

    PayloadReset();
    
    pm1_0 = (u16)SensorPayload[SensorPayloadPM1_0Position]<<8|SensorPayload[SensorPayloadPM1_0Position+1];
    Serial.print("PM1.5: ");
    Serial.print(pm1_0);
    Serial.println("ug/m3 ") ;

    PayloadAdd( "P10", pm1_0, false);
    
    pm2_5 = (u16)SensorPayload[SensorPayloadPM2_5Position]<<8|SensorPayload[SensorPayloadPM2_5Position+1];
    Serial.print("PM2.5: ");
    Serial.print(pm2_5);
    Serial.println("ug/m3 ") ;

    PayloadAdd( "P25", pm2_5, 1, false);

    pm10_0 = (u16)SensorPayload[SensorPayloadPM10_0Position]<<8|SensorPayload[SensorPayloadPM10_0Position+1];
    Serial.print("PM10.0: ");
    Serial.print(pm10_0);
    Serial.println("ug/m3 ");

    PayloadAdd( "P100", pm10_0, 0, true) ;

    #ifdef DEBUG_TELEMETRY
      Serial.println();
      Serial.print("RFM9X/SX127X Payload length:");
      Serial.print(payloadLength);
      Serial.println(" bytes");
    #endif

    LoRa.beginPacket();
    LoRa.write(payload, payloadLength);
    LoRa.endPacket();
  }
  Serial.println("Loop done");
  Serial.println();
  
  delay(SensorUploadDelay - (millis() - currentMilliseconds ));
}

void PayloadHeader( const byte *to, byte toAddressLength, const byte *from, byte fromAddressLength)
{
  byte addressesLength = toAddressLength + fromAddressLength ;

  payloadLength = 0 ;

  // prepare the payload header with "To" Address length (top nibble) and "From" address length (bottom nibble)
  
  payload[payloadLength] = (toAddressLength << 4) | fromAddressLength ;
  payloadLength += 1;

  // Copy the "To" address into payload
  memcpy(&amp;payload[payloadLength], to, toAddressLength);
  payloadLength += toAddressLength ;

  // Copy the "From" into payload
  memcpy(&amp;payload[payloadLength], from, fromAddressLength);
  payloadLength += fromAddressLength ;
}

void PayloadAdd( const char *sensorId, float value, byte decimalPlaces, bool last)
{
  byte sensorIdLength = strlen( sensorId ) ;

  memcpy( &amp;payload[payloadLength], sensorId,  sensorIdLength) ;
  payloadLength += sensorIdLength ;
  payload[ payloadLength] = SensorIdValueSeperator;
  payloadLength += 1 ;
  payloadLength += strlen( dtostrf(value, -1, decimalPlaces, (char *)&amp;payload[payloadLength]));
  if (!last)
  {
    payload[ payloadLength] = SensorReadingSeperator;
    payloadLength += 1 ;
  }
  
#ifdef DEBUG_TELEMETRY
  Serial.print("PayloadAdd float-payloadLength:");
  Serial.print( payloadLength);
  Serial.println( );
#endif
}

void PayloadAdd( char *sensorId, int value, bool last )
{
  byte sensorIdLength = strlen(sensorId) ;

  memcpy(&amp;payload[payloadLength], sensorId,  sensorIdLength) ;
  payloadLength += sensorIdLength ;
  payload[ payloadLength] = SensorIdValueSeperator;
  payloadLength += 1 ;
  payloadLength += strlen(itoa( value,(char *)&amp;payload[payloadLength],10));
  if (!last)
  {
    payload[ payloadLength] = SensorReadingSeperator;
    payloadLength += 1 ;
  }
  
#ifdef DEBUG_TELEMETRY
  Serial.print("PayloadAdd int-payloadLength:" );
  Serial.print(payloadLength);
  Serial.println( );
#endif
}

void PayloadAdd( char *sensorId, unsigned int value, bool last )
{
  byte sensorIdLength = strlen(sensorId) ;

  memcpy(&amp;payload[payloadLength], sensorId,  sensorIdLength) ;
  payloadLength += sensorIdLength ;
  payload[ payloadLength] = SensorIdValueSeperator;
  payloadLength += 1 ;
  payloadLength += strlen(utoa( value,(char *)&amp;payload[payloadLength],10));
  if (!last)
  {
    payload[ payloadLength] = SensorReadingSeperator;
    payloadLength += 1 ;
  }
  
#ifdef DEBUG_TELEMETRY
  Serial.print("PayloadAdd uint-payloadLength:");
  Serial.print(payloadLength);
  Serial.println( );
#endif
}

void PayloadReset()
{
  byte fromAddressLength = payload[0] &amp; 0xf ;
  byte toAddressLength = payload[0] &gt;&gt; 4 ;
  
  payloadLength = toAddressLength + fromAddressLength + 1;
}

void DisplayHex( byte *byteArray, byte length) 
{
  for (int i = 0; i < length ; i++)
  {
    // Add a leading zero
    if ( byteArray[i] < 16)
    {
      Serial.print("0");
    }
    Serial.print(byteArray[i], HEX);
    if ( i < (length-1)) // Don't put a - after last digit
    {
      Serial.print("-");
    }
  }
}    

The code is available on GitHub.

20:45:38.021 -> Setup called
20:45:38.054 -> Field gateway:LoRaIoT1 Frequency:915000000MHz SyncWord:18
20:45:38.156 -> SNo:01-23-8C-48-D6-D1-F5-86-EE
20:45:38.190 -> LoRa setup start
20:45:38.190 -> LoRa Setup done.
20:45:38.224 -> HM3301 setup start
20:45:38.292 -> HM3301 setup done
20:45:38.292 -> Setup done
20:45:38.292 -> 
20:45:38.325 -> Loop called
20:45:38.325 -> PM1.5: 10ug/m3 
20:45:38.359 -> PM2.5: 14ug/m3 
20:45:38.359 -> PM10.0: 19ug/m3 
20:45:38.393 -> Loop done
20:45:38.393 -> 
20:46:38.220 -> Loop called
20:46:38.220 -> PM1.5: 10ug/m3 
20:46:38.255 -> PM2.5: 15ug/m3 
20:46:38.255 -> PM10.0: 20ug/m3 
20:46:38.325 -> Loop done
20:46:38.325 -> 
20:47:38.181 -> Loop called
20:47:38.181 -> PM1.5: 10ug/m3 
20:47:38.181 -> PM2.5: 14ug/m3 
20:47:38.216 -> PM10.0: 19ug/m3 
20:47:38.250 -> Loop done
20:47:38.284 -> 
20:48:38.123 -> Loop called
20:48:38.123 -> PM1.5: 10ug/m3 
20:48:38.158 -> PM2.5: 14ug/m3 
20:48:38.158 -> PM10.0: 19ug/m3 
20:48:38.193 -> Loop done
20:48:38.227 -> 
20:49:38.048 -> Loop called
20:49:38.082 -> PM1.5: 10ug/m3 
20:49:38.082 -> PM2.5: 14ug/m3 
20:49:38.117 -> PM10.0: 19ug/m3 
20:49:38.151 -> Loop done
20:49:38.151 -> 
20:50:38.010 -> Loop called
20:50:38.010 -> PM1.5: 9ug/m3 
20:50:38.010 -> PM2.5: 13ug/m3 
20:50:38.045 -> PM10.0: 18ug/m3 
20:50:38.079 -> Loop done
20:50:38.079 -> 

To configure the device in Azure IoT Central (similar process for Adafruit.IO, working on support for losant, ubidots and MyDevices) I copied the SNo: from the Arduino development tool logging window and appended p10 for PM 1 value, p25 for PM2.5 value and p100 for PM10 value to the unique serial number from the ATSHA204A chip. (N.B. pay attention to the case of the field names they are case sensitive)

Azure IoT Central telemetry configuration

The rapidly settled into a narrow range of readings, but spiked when I took left it outside (winter in New Zealand) and the values spiked when food was being cooked in the kitchen which is next door to my office.

It would be good to run the sensor alongside a professional particulates monitor so the values could be compared and used to adjust the readings of the Grove sensor if necessary.

Hour of PM1, PM2.5 & PM10 readings in my office early evening
CO2 and particulates values while outside on my deck from 10:30pm to 11:30pm

Bill of materials (prices as at August 2019)

  • Seeeduino Nano USD6.90
  • Grove – Laser PM2.5 Sensor (HM3301) USD29.90
  • EasySensors Arduino Nano radio shield RFM95 USD15.00

Grove – Carbon Dioxide Sensor(SCD30) trial

In preparation for another student project to monitor the temperature, humidity and CO2 levels in a number of classrooms I purchased a couple of Grove – CO2, Temperature & Humidity Sensors (SCD30) for evaluation.

Seeeduino, Grove SCD30 and easysensors shield

Seeeduino Nano devices have a single on-board I2C socket which meant I didn’t need a Grove Shield for Arduino Nano which reduced the size and cost of the sensor node.

I downloaded the seeedstudio wiki example calibration code, compiled and uploaded it to one of my Seeeduino Nano devices. When activated for the first time a period of minimum 7 days is needed so that the sensor algorithm can find its initial parameter set. During this period the sensor has to be exposed to fresh air for at least 1 hour every day.

During the calibration process I put the device in my garage and left the big door open for at least an hour every day. Once the sensor was calibrated I bought it inside at put it on the bookcase in my office.

I modified my Easy Sensors Arduino Nano Radio Shield RFM69/95 Payload Addressing client to use the sensor.

/*
  Copyright ® 2019 August devMobile Software, All Rights Reserved

  THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY
  KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
  IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR
  PURPOSE.

  You can do what you want with this code, acknowledgment would be nice.

  http://www.devmobile.co.nz

*/
#include <stdlib.h&gt;
#include <LoRa.h&gt;
#include <sha204_library.h&gt;
#include "SCD30.h"

//#define DEBUG
//#define DEBUG_TELEMETRY
//#define DEBUG_LORA

// LoRa field gateway configuration (these settings must match your field gateway)
const byte DeviceAddressMaximumLength = 15 ;
const char FieldGatewayAddress[] = {"LoRaIoT1"};
const float FieldGatewayFrequency =  915000000.0;
const byte FieldGatewaySyncWord = 0x12 ;

// Payload configuration
const int ChipSelectPin = 10;
const int ResetPin = 9;
const int InterruptPin = 2;

// LoRa radio payload configuration
const byte SensorIdValueSeperator = ' ' ;
const byte SensorReadingSeperator = ',' ;
const unsigned long SensorUploadDelay = 300000;

// ATSHA204 secure authentication, validation with crypto and hashing (currently only using for unique serial number)
const byte Atsha204Port = A3;
atsha204Class sha204(Atsha204Port);
const byte DeviceSerialNumberLength = 9 ;
byte deviceSerialNumber[DeviceSerialNumberLength] = {""};

const byte PayloadSizeMaximum = 64 ;
byte payload[PayloadSizeMaximum];
byte payloadLength = 0 ;


void setup()
{
  Serial.begin(9600);

#ifdef DEBUG
  while (!Serial);
#endif
 
  Serial.println("Setup called");

  Serial.print("Field gateway:");
  Serial.print(FieldGatewayAddress ) ;
  Serial.print(" Frequency:");
  Serial.print( FieldGatewayFrequency,0 ) ;
  Serial.print("MHz SyncWord:");
  Serial.print( FieldGatewaySyncWord ) ;
  Serial.println();
  
   // Retrieve the serial number then display it nicely
  if(sha204.getSerialNumber(deviceSerialNumber))
  {
    Serial.println("sha204.getSerialNumber failed");
    while (true); // Drop into endless loop requiring restart
  }

  Serial.print("SNo:");
  DisplayHex( deviceSerialNumber, DeviceSerialNumberLength);
  Serial.println();

  Serial.println("LoRa setup start");

  // override the default chip select and reset pins
  LoRa.setPins(ChipSelectPin, ResetPin, InterruptPin);
  if (!LoRa.begin(FieldGatewayFrequency))
  {
    Serial.println("LoRa begin failed");
    while (true); // Drop into endless loop requiring restart
  }

  // Need to do this so field gateway pays attention to messsages from this device
  LoRa.enableCrc();
  LoRa.setSyncWord(FieldGatewaySyncWord);

#ifdef DEBUG_LORA
  LoRa.dumpRegisters(Serial);
#endif
  Serial.println("LoRa Setup done.");

  // Configure the Seeedstudio CO2, temperature &amp; humidity sensor
  Serial.println("SCD30 setup start");
  Wire.begin();
  scd30.initialize();  
  delay(100);
  Serial.println("SCD30 setup done");

  PayloadHeader((byte *)FieldGatewayAddress,strlen(FieldGatewayAddress), deviceSerialNumber, DeviceSerialNumberLength);

  Serial.println("Setup done");
  Serial.println();
}

void loop()
{
  unsigned long currentMilliseconds = millis();  
  float temperature ;
  float humidity ;
  float co2;

  Serial.println("Loop called");

  if(scd30.isAvailable())
  {
    float result[3] = {0};
    PayloadReset();

    // Read the CO2, temperature &amp; humidity values then display nicely
    scd30.getCarbonDioxideConcentration(result);

    co2 = result[0];
    Serial.print("C:");
    Serial.print(co2, 1) ;
    Serial.println("ppm ") ;

    PayloadAdd( "C", co2, 1, false);
    
    temperature = result[1];
    Serial.print("T:");
    Serial.print(temperature, 1) ;
    Serial.println("C ") ;

    PayloadAdd( "T", temperature, 1, false);

    humidity = result[2];
    Serial.print("H:" );
    Serial.print(humidity, 0) ;
    Serial.println("% ") ;

    PayloadAdd( "H", humidity, 0, true) ;

    #ifdef DEBUG_TELEMETRY
      Serial.println();
      Serial.print("RFM9X/SX127X Payload length:");
      Serial.print(payloadLength);
      Serial.println(" bytes");
    #endif

    LoRa.beginPacket();
    LoRa.write(payload, payloadLength);
    LoRa.endPacket();
  }
  Serial.println("Loop done");
  Serial.println();
  
  delay(SensorUploadDelay - (millis() - currentMilliseconds ));
}
...
}    

The code is available on GitHub.

20:38:56.746 -> Setup called
20:38:56.746 -> Field gateway: Frequency:915000000MHz SyncWord:18
20:38:56.849 -> SNo:01-23-39-BD-D6-D1-F5-86-EE
20:38:56.884 -> LoRa setup start
20:38:56.919 -> LoRa Setup done.
20:38:56.919 -> SCD30 setup start
20:38:56.986 -> SCD30 setup done
20:38:56.986 -> Setup done
20:38:57.020 -> 
20:39:06.966 -> Received packet
20:39:06.966 -> Packet size:18
20:39:06.999 -> To len:9
20:39:06.999 -> From len:8
20:39:06.999 -> To:01-23-39-BD-D6-D1-F5-86-EE
20:39:07.034 -> From:4C-6F-52-61-49-6F-54-31
20:39:07.069 -> FieldGateway:4C-6F-52-61-49-6F-54-31
20:39:07.104 -> RSSI -55
20:39:07.139 -> Loop called
20:39:07.139 -> C:730.8ppm 
20:39:07.139 -> T:23.1C 
20:39:07.173 -> H:46% 
20:39:07.173 -> Loop done
20:39:07.208 -> 
20:39:37.123 -> Loop called
20:39:37.158 -> C:529.9ppm 
20:39:37.158 -> T:23.2C 
20:39:37.158 -> H:48% 
20:39:37.228 -> Loop done
20:39:37.228 -> 

To configure the device in Azure IoT Central (similar process for Adafruit.IO, working on support for losant, ubidots and MyDevices) I copied the SNo: from the Arduino development tool logging window and appended c for the CO2 parts per million (ppm), h for the humidity % and t for the temperature °C to the unique serial number from the ATSHA204A chip. (N.B. pay attention to the case of the field names they are case sensitive)

Azure IoT Central telemetry configuration

Overall the performance of the sensor is looking pretty positive, the CO2 levels fluctuate in a acceptable range (based on office occupancy), and the temperature + humidity readings track quite closely to the other two sensor nodes in my office. The only issue so far is my lack of USB-C cables to power the devices in the field

CO2, Humidity and Temperature in my office for a day

Bill of materials (prices as at August 2019)

  • Seeeduino Nano USD6.90
  • Grove – CO2, Humidity & Temperature Sensor(SCD30) USD59.95
  • EasySensors Arduino Nano radio shield RFM95 USD15.00

Windows 10 IoT Core Cognitive Services Azure IoT Hub Client

This application builds on Windows 10 IoT Core Cognitive Services Vision API client. It uses my Lego brick classifier model and a new m&m object detection model.

m&m counter test rig

I created a new Visual Studio 2017 Windows IoT Core project and copied across the Windows 10 IoT Core Cognitive Services Custom Vision API code, (changing the namespace and manifest details) and added the Azure Devices Client NuGet package.

Azure Devices Client NuGet

In the start up code I added code to initialise the Azure IoT Hub client, retrieve the device twin settings, and update the device twin properties.

try
{
	this.azureIoTHubClient = DeviceClient.CreateFromConnectionString(this.azureIoTHubConnectionString, this.transportType);
}
catch (Exception ex)
{
	this.logging.LogMessage("AzureIOT Hub DeviceClient.CreateFromConnectionString failed " + ex.Message, LoggingLevel.Error);
	return;
}

try
{
	TwinCollection reportedProperties = new TwinCollection();

	// This is from the OS
	reportedProperties["Timezone"] = TimeZoneSettings.CurrentTimeZoneDisplayName;
	reportedProperties["OSVersion"] = Environment.OSVersion.VersionString;
	reportedProperties["MachineName"] = Environment.MachineName;

	reportedProperties["ApplicationDisplayName"] = package.DisplayName;
	reportedProperties["ApplicationName"] = packageId.Name;
	reportedProperties["ApplicationVersion"] = string.Format($"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}");

	// Unique identifier from the hardware
	SystemIdentificationInfo systemIdentificationInfo = SystemIdentification.GetSystemIdForPublisher();
	using (DataReader reader = DataReader.FromBuffer(systemIdentificationInfo.Id))
	{
		byte[] bytes = new byte[systemIdentificationInfo.Id.Length];
		reader.ReadBytes(bytes);
		reportedProperties["SystemId"] = BitConverter.ToString(bytes);
	}
	this.azureIoTHubClient.UpdateReportedPropertiesAsync(reportedProperties).Wait();
}
catch (Exception ex)
{
	this.logging.LogMessage("Azure IoT Hub client UpdateReportedPropertiesAsync failed " + ex.Message, LoggingLevel.Error);
	return;
}

try
{
	LoggingFields configurationInformation = new LoggingFields();

	Twin deviceTwin = this.azureIoTHubClient.GetTwinAsync().GetAwaiter().GetResult();

	if (!deviceTwin.Properties.Desired.Contains("ImageUpdateDue") || !TimeSpan.TryParse(deviceTwin.Properties.Desired["ImageUpdateDue"].value.ToString(), out imageUpdateDue))
	{
		this.logging.LogMessage("DeviceTwin.Properties ImageUpdateDue setting missing or invalid format", LoggingLevel.Warning);
		return;
	}
	configurationInformation.AddTimeSpan("ImageUpdateDue", imageUpdateDue);

	if (!deviceTwin.Properties.Desired.Contains("ImageUpdatePeriod") || !TimeSpan.TryParse(deviceTwin.Properties.Desired["ImageUpdatePeriod"].value.ToString(), out imageUpdatePeriod))
	{
		this.logging.LogMessage("DeviceTwin.Properties ImageUpdatePeriod setting missing or invalid format", LoggingLevel.Warning);
		return;
	}
…
	if (!deviceTwin.Properties.Desired.Contains("DebounceTimeout") || !TimeSpan.TryParse(deviceTwin.Properties.Desired["DebounceTimeout"].value.ToString(), out debounceTimeout))
	{
		this.logging.LogMessage("DeviceTwin.Properties DebounceTimeout setting missing or invalid format", LoggingLevel.Warning);
		return;
	}
				configurationInformation.AddTimeSpan("DebounceTimeout", debounceTimeout);

	this.logging.LogEvent("Configuration settings", configurationInformation);
}
catch (Exception ex)
{
	this.logging.LogMessage("Azure IoT Hub client GetTwinAsync failed or property missing/invalid" + ex.Message, LoggingLevel.Error);
	return;
}

When the digital input (configured in the app.settings file) is strobed or the timer fires (configured in the device properties) an image is captured, uploaded to Azure Cognitive Services Custom Vision for processing.

The returned results are then post processed to make them Azure IoT Central friendly, and finally uploaded to an Azure IoT Hub.

For testing I have used a simple object detection model.

I trained the model with images of 6 different colours of m&m’s.

For my first dataset I tagged the location of a single m&m of each of the colour in 15 images.

Testing the training of the model

I then trained the model multiple times adding additional images where the model was having trouble distiguishing colours.

The published name comes from the training performance tab

Project settings

The projectID, AzureCognitiveServicesSubscriptionKey (PredictionKey) and PublishedName (From the Performance tab in project) are from the custom vision project properties.

All of the Custom Vision model settings are configured in the Azure IoT Hub device properties.

The app.settings file contains only the hardware configuration settings and the Azure IoT Hub connection string.

{
  "InterruptPinNumber": 24,
  "interruptTriggerOn": "RisingEdge",
  "DisplayPinNumber": 35,
  "AzureIoTHubConnectionString": "",
  "TransportType": "Mqtt"
} 

The LED connected to the display pin is illuminated while an image is being processed or briefly flashed if the insufficient time between image captures has passed.

The image data is post processed differently based on the model.

// Post process the predictions based on the type of model
switch (modelType)
{
	case ModelType.Classification:
		// Use only the tags above the specified minimum probability
		foreach (var prediction in imagePrediction.Predictions)
		{
			if (prediction.Probability >= probabilityThreshold)
			{
				// Display and log the individual tag probabilities
				Debug.WriteLine($" Tag valid:{prediction.TagName} {prediction.Probability:0.00}");
				imageInformation.AddDouble($"Tag valid:{prediction.TagName}", prediction.Probability);
					telemetryDataPoint.Add(prediction.TagName, prediction.Probability);
			}
		}
		break;

	case ModelType.Detection:
		// Group the tags to get the count, include only the predictions above the specified minimum probability
		var groupedPredictions = from prediction in imagePrediction.Predictions
										 where prediction.Probability >= probabilityThreshold
										 group prediction by new { prediction.TagName }
				into newGroup
										 select new
										 {
											 TagName = newGroup.Key.TagName,
											 Count = newGroup.Count(),
										 };

		// Display and log the agregated predictions
		foreach (var prediction in groupedPredictions)
		{
			Debug.WriteLine($" Tag valid:{prediction.TagName} {prediction.Count}");
			imageInformation.AddInt32($"Tag valid:{prediction.TagName}", prediction.Count);
			telemetryDataPoint.Add(prediction.TagName, prediction.Count);
		}
		break;
	default:
		throw new ArgumentException("ModelType Invalid");
}

For a classifier only the tags with a probability greater than or equal the specified threshold are uploaded.

For a detection model the instances of each tag are counted. Only the tags with a prediction value greater than the specified threshold are included in the count.

19-08-14 05:26:14 Timer triggered
Prediction count 33
 Tag:Blue 0.0146500813
 Tag:Blue 0.61186564
 Tag:Blue 0.0923164859
 Tag:Blue 0.7813785
 Tag:Brown 0.0100603029
 Tag:Brown 0.128318727
 Tag:Brown 0.0135991769
 Tag:Brown 0.687322736
 Tag:Brown 0.846672833
 Tag:Brown 0.1826635
 Tag:Brown 0.0183384717
 Tag:Green 0.0200069249
 Tag:Green 0.367765248
 Tag:Green 0.011428359
 Tag:Orange 0.678825438
 Tag:Orange 0.03718319
 Tag:Orange 0.8643157
 Tag:Orange 0.0296728313
 Tag:Red 0.02141669
 Tag:Red 0.7183208
 Tag:Red 0.0183610674
 Tag:Red 0.0130951973
 Tag:Red 0.82097
 Tag:Red 0.0618815944
 Tag:Red 0.0130757084
 Tag:Yellow 0.04150853
 Tag:Yellow 0.0106579047
 Tag:Yellow 0.0210028365
 Tag:Yellow 0.03392527
 Tag:Yellow 0.129197285
 Tag:Yellow 0.8089519
 Tag:Yellow 0.03723789
 Tag:Yellow 0.74729687
 Tag valid:Blue 2
 Tag valid:Brown 2
 Tag valid:Orange 2
 Tag valid:Red 2
 Tag valid:Yellow 2
 05:26:17 AzureIoTHubClient SendEventAsync start
 05:26:18 AzureIoTHubClient SendEventAsync finish

The debugging output of the application includes the different categories identified in the captured image.

I found my small model was pretty good at detection of individual m&m as long as the ambient lighting was consistent, and the background fairly plain.

Sample image from test rig

Every so often the camera contrast setting went bad and could only be restored by restarting the device which needs further investigation.

Image with contrast problem

This application could be the basis for projects which need to run an Azure Cognitive Services model to count or classify then upload the results to an Azure IoT Hub or Azure IoT Central for presentation.

With a suitable model this application could be used to count the number of people in a room, which could be displayed along with the ambient temperature, humidity, CO2, and noise levels in Azure IoT Central.

The code for this application is available In on GitHub.

Windows 10 IoT Core Cognitive Services Custom Vision API

This application was inspired by one of teachers I work with wanting to count ducks in the stream on the school grounds. The school was having problems with water quality and the they wanted to see if the number of ducks was a factor. (Manually counting the ducks several times a day would be impractical).

I didn’t have a source of training images so built an image classifier using my son’s Lego for testing. In a future post I will build an object detection model once I have some sample images of the stream captured by my Windows 10 IoT Core time lapse camera application.

To start with I added the Azure Cognitive Services Custom Vision API NuGet packages to a new Visual Studio 2017 Windows IoT Core project.

Azure Custom Vision Service NuGet packacges

Then I initialised the Computer Vision API client

try
{
	this.customVisionClient = new CustomVisionPredictionClient(new System.Net.Http.DelegatingHandler[] { })
	{
		ApiKey = this.azureCognitiveServicesSubscriptionKey,
		Endpoint = this.azureCognitiveServicesEndpoint,
	};
}
catch (Exception ex)
{
	this.logging.LogMessage("Azure Cognitive Services Custom Vision Client configuration failed " + ex.Message, LoggingLevel.Error);
	return;
}

Every time the digital input is strobed by the infra red proximity sensor or touch button an image is captured, uploaded for processing, and results displayed in the debug output.

For testing I have used a simple multiclass classifier that I trained with a selection of my son’s Lego. I tagged the brick size height x width x length (1x2x3, smallest of width/height first) and colour (red, green, blue etc.)

Azure Cognitive Services Classifier project creation
Custom vision projects
Lego classifier project properties

The projectID, AzureCognitiveServicesSubscriptionKey (PredictionKey) and PublishedName (From the Performance tab in project) in the app.settings file come from the custom vision project properties.

{
  "InterruptPinNumber": 24,
  "interruptTriggerOn": "RisingEdge",
  "DisplayPinNumber": 35,
  "AzureCognitiveServicesEndpoint": "https://australiaeast.api.cognitive.microsoft.com",
  "AzureCognitiveServicesSubscriptionKey": "41234567890123456789012345678901s,
  "DebounceTimeout": "00:00:30",
  "PublishedName": "LegoBrickClassifierV3",
  "TriggerTag": "1x2x4",
  "TriggerThreshold": "0.4",
  "ProjectID": "c1234567-abcdefghijklmn-1234567890ab"
} 

The sample application only supports one trigger tag + probability and if this condition satisfied the Light Emitting Diode (LED) is turned on for 5 seconds. If an image is being processed or the minimum period between images has not passed the LED is illuminated for 5 milliseconds .

private async void InterruptGpioPin_ValueChanged(GpioPin sender, GpioPinValueChangedEventArgs args)
{
	DateTime currentTime = DateTime.UtcNow;
	Debug.WriteLine($"Digital Input Interrupt {sender.PinNumber} triggered {args.Edge}");

	if (args.Edge != this.interruptTriggerOn)
	{
		return;
	}

	// Check that enough time has passed for picture to be taken
	if ((currentTime - this.imageLastCapturedAtUtc) < this.debounceTimeout)
	{
		this.displayGpioPin.Write(GpioPinValue.High);
		this.displayOffTimer.Change(this.timerPeriodDetectIlluminated, this.timerPeriodInfinite);
		return;
	}

	this.imageLastCapturedAtUtc = currentTime;

	// Just incase - stop code being called while photo already in progress
	if (this.cameraBusy)
	{
		this.displayGpioPin.Write(GpioPinValue.High);
		this.displayOffTimer.Change(this.timerPeriodDetectIlluminated, this.timerPeriodInfinite);
		return;
	}

	this.cameraBusy = true;

	try
	{
		using (Windows.Storage.Streams.InMemoryRandomAccessStream captureStream = new Windows.Storage.Streams.InMemoryRandomAccessStream())
		{
			this.mediaCapture.CapturePhotoToStreamAsync(ImageEncodingProperties.CreateJpeg(), captureStream).AsTask().Wait();
			captureStream.FlushAsync().AsTask().Wait();
			captureStream.Seek(0);

			IStorageFile photoFile = await KnownFolders.PicturesLibrary.CreateFileAsync(ImageFilename, CreationCollisionOption.ReplaceExisting);
			ImageEncodingProperties imageProperties = ImageEncodingProperties.CreateJpeg();
			await this.mediaCapture.CapturePhotoToStorageFileAsync(imageProperties, photoFile);

			ImageAnalysis imageAnalysis = await this.computerVisionClient.AnalyzeImageInStreamAsync(captureStream.AsStreamForRead());

			Debug.WriteLine($"Tag count {imageAnalysis.Categories.Count}");

			if (imageAnalysis.Categories.Intersect(this.categoryList, new CategoryComparer()).Any())
			{
				this.displayGpioPin.Write(GpioPinValue.High);

				// Start the timer to turn the LED off
				this.displayOffTimer.Change(this.timerPeriodFaceIlluminated, this.timerPeriodInfinite);
					}

					LoggingFields imageInformation = new LoggingFields();

					imageInformation.AddDateTime("TakenAtUTC", currentTime);
					imageInformation.AddInt32("Pin", sender.PinNumber);
					Debug.WriteLine($"Categories:{imageAnalysis.Categories.Count}");
					imageInformation.AddInt32("Categories", imageAnalysis.Categories.Count);
					foreach (Category category in imageAnalysis.Categories)
					{
						Debug.WriteLine($" Category:{category.Name} {category.Score}");
						imageInformation.AddDouble($"Category:{category.Name}", category.Score);
					}

					this.logging.LogEvent("Captured image processed by Cognitive Services", imageInformation);
				}
			}
			catch (Exception ex)
			{
				this.logging.LogMessage("Camera photo or save failed " + ex.Message, LoggingLevel.Error);
			}
			finally
			{
				this.cameraBusy = false;
			}
		}

		private void TimerCallback(object state)
		{
			this.displayGpioPin.Write(GpioPinValue.Low);
		}

		internal class CategoryComparer : IEqualityComparer<Category&gt;
		{
			public bool Equals(Category x, Category y)
			{
				if (string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase))
				{
					return true;
				}

				return false;
			}

			public int GetHashCode(Category obj)
			{
				return obj.Name.GetHashCode();
			}
		}

I found my small model was pretty good at tagging images of Lego bricks as long as the ambient lighting was consistent and the background fairly plain.

When tagging many bricks my ability to distinguish pearl light grey, light grey, sand blue and grey bricks was a problem. I should have started with a limited palette (red, green, blue) of colours and shapes for my models while evaluating different tagging approaches.

The debugging output of the application includes the different categories identified in the captured image.

Digital Input Interrupt 24 triggered RisingEdge
Digital Input Interrupt 24 triggered FallingEdge
Prediction count 54
 Tag:Lime 0.529844046
 Tag:1x1x2 0.4441353
 Tag:Green 0.252290249
 Tag:1x1x3 0.1790101
 Tag:1x2x3 0.132092983
 Tag:Turquoise 0.128928885
 Tag:DarkGreen 0.09383947
 Tag:DarkTurquoise 0.08993266
 Tag:1x2x2 0.08145093
 Tag:1x2x4 0.060960535
 Tag:LightBlue 0.0525473
 Tag:MediumAzure 0.04958712
 Tag:Violet 0.04894981
 Tag:SandGreen 0.048463434
 Tag:LightOrange 0.044860106
 Tag:1X1X1 0.0426577441
 Tag:Azure 0.0416654423
 Tag:Aqua 0.0400410332
 Tag:OliveGreen 0.0387720577
 Tag:Blue 0.035169173
 Tag:White 0.03497391
 Tag:Pink 0.0321456343
 Tag:Transparent 0.0246597622
 Tag:MediumBlue 0.0245670844
 Tag:BrightPink 0.0223842952
 Tag:Flesh 0.0221406389
 Tag:Magenta 0.0208457354
 Tag:Purple 0.0188888311
 Tag:DarkPurple 0.0187285
 Tag:MaerskBlue 0.017609369
 Tag:DarkPink 0.0173041821
 Tag:Lavender 0.0162359159
 Tag:PearlLightGrey 0.0152829709
 Tag:1x1x4 0.0133710662
 Tag:Red 0.0122602312
 Tag:Yellow 0.0118704
 Tag:Clear 0.0114340987
 Tag:LightYellow 0.009903331
 Tag:Black 0.00877647
 Tag:BrightLightYellow 0.00871937349
 Tag:Mediumorange 0.0078356415
 Tag:Tan 0.00738664949
 Tag:Sand 0.00713921571
 Tag:Grey 0.00710422
 Tag:Orange 0.00624707434
 Tag:SandBlue 0.006215865
 Tag:DarkGrey 0.00613187673
 Tag:DarkBlue 0.00578308525
 Tag:DarkOrange 0.003790971
 Tag:DarkTan 0.00348462746
 Tag:LightGrey 0.00321317
 Tag:ReddishBrown 0.00304117263
 Tag:LightBluishGrey 0.00273489812
 Tag:Brown 0.00199119

I’m going to run this application repeatedly, adding more images and retraining the model to see how it performs. Once the model is working wll I’ll try downloading it and running it on a device

Custom Vision Test Harness running on my desk

This sample could be used as a basis for projects like this cat door which stops your pet bringing in dead or wounded animals. The model could be trained with tags to indicate whether the cat is carrying a “present” for their human and locking the door if it is.

STM32 Blue Pill LoRaWAN node

A few weeks ago I ordered an STM32 Blue Pill LoRaWAN node from the M2M Shop on Tindie for evaluation. I have bought a few M2M client devices including a Low power LoRaWan Node Model A328, and Low power LoRaWan Node Model B1284 for projects and they have worked well. This one looked interesting as I had never used a maple like device before.

Bill of materials (Prices as at July 2019)

  • STM32 Blue Pill LoRaWAN node USD21
  • Grove – Temperature&Humidity Sensor USD11.5
  • Grove – 4 pin Female Jumper to Grove 4 pin Conversion Cable USD3.90

The two sockets on the main board aren’t Grove compatible so I used the 4 pin female to Grove 4 pin conversion cable to connect the temperature and humidity sensor.

STM32 Blue Pill LoRaWAN node test rig

I used a modified version of my Arduino client code which worked after I got the pin reset pin sorted and the female sockets in the right order.

/*
  Copyright ® 2019 July devMobile Software, All Rights Reserved

  THIS CODE AND INFORMATION IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY
  KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
  IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR
  PURPOSE.
  
  Adapted from LoRa Duplex communication with Sync Word

  Sends temperature &amp; humidity data from Seeedstudio 

  https://www.seeedstudio.com/Grove-Temperature-Humidity-Sensor-High-Accuracy-Min-p-1921.html

  To my Windows 10 IoT Core RFM 9X library

  https://blog.devmobile.co.nz/2018/09/03/rfm9x-iotcore-payload-addressing/
*/
#include <itoa.h&gt;     
#include <SPI.h&gt;     
#include <LoRa.h&gt;

#include <TH02_dev.h&gt;

#define DEBUG
//#define DEBUG_TELEMETRY
//#define DEBUG_LORA

// LoRa field gateway configuration (these settings must match your field gateway)
const char DeviceAddress[] = {"BLUEPILL"};

// Azure IoT Hub FieldGateway
const char FieldGatewayAddress[] = {"LoRaIoT1"}; 
const float FieldGatewayFrequency =  915000000.0;
const byte FieldGatewaySyncWord = 0x12 ;

// Bluepill hardware configuration
const int ChipSelectPin = PA4;
const int InterruptPin = PA0;
const int ResetPin = -1;

// LoRa radio payload configuration
const byte SensorIdValueSeperator = ' ' ;
const byte SensorReadingSeperator = ',' ;
const byte PayloadSizeMaximum = 64 ;
byte payload[PayloadSizeMaximum];
byte payloadLength = 0 ;

const int LoopDelaySeconds = 300 ;

// Sensor configuration
const char SensorIdTemperature[] = {"t"};
const char SensorIdHumidity[] = {"h"};


void setup()
{
  Serial.begin(9600);
#ifdef DEBUG
  while (!Serial);
#endif
  Serial.println("Setup called");

  Serial.println("LoRa setup start");

  // override the default chip select and reset pins
  LoRa.setPins(ChipSelectPin, ResetPin, InterruptPin);
  if (!LoRa.begin(FieldGatewayFrequency))
  {
    Serial.println("LoRa begin failed");
    while (true); // Drop into endless loop requiring restart
  }

  // Need to do this so field gateways pays attention to messsages from this device
  LoRa.enableCrc();
  LoRa.setSyncWord(FieldGatewaySyncWord);

#ifdef DEBUG_LORA
  LoRa.dumpRegisters(Serial);
#endif
  Serial.println("LoRa setup done.");

  PayloadHeader((byte*)FieldGatewayAddress, strlen(FieldGatewayAddress), (byte*)DeviceAddress, strlen(DeviceAddress));

 // Configure the Seeedstudio TH02 temperature &amp; humidity sensor
  Serial.println("TH02 setup");
  TH02.begin();
  delay(100);
  Serial.println("TH02 Setup done");  

  Serial.println("Setup done");
}

void loop() {
  // read the value from the sensor:
  double temperature = TH02.ReadTemperature();
  double humidity = TH02.ReadHumidity();

  Serial.print("Humidity: ");
  Serial.print(humidity, 0);
  Serial.print(" %\t");
  Serial.print("Temperature: ");
  Serial.print(temperature, 1);
  Serial.println(" *C");

  PayloadReset();

  PayloadAdd(SensorIdHumidity, humidity, 0) ;
  PayloadAdd(SensorIdTemperature, temperature, 1) ;

  LoRa.beginPacket();
  LoRa.write(payload, payloadLength);
  LoRa.endPacket();

  Serial.println("Loop done");

  delay(LoopDelaySeconds * 1000);
}


void PayloadHeader( byte *to, byte toAddressLength, byte *from, byte fromAddressLength)
{
  byte addressesLength = toAddressLength + fromAddressLength ;

#ifdef DEBUG_TELEMETRY
  Serial.println("PayloadHeader- ");
  Serial.print( "To Address len:");
  Serial.print( toAddressLength );
  Serial.print( " From Address len:");
  Serial.print( fromAddressLength );
  Serial.print( " Addresses length:");
  Serial.print( addressesLength );
  Serial.println( );
#endif

  payloadLength = 0 ;

  // prepare the payload header with "To" Address length (top nibble) and "From" address length (bottom nibble)
  payload[payloadLength] = (toAddressLength << 4) | fromAddressLength ;
  payloadLength += 1;

  // Copy the "To" address into payload
  memcpy(&amp;payload[payloadLength], to, toAddressLength);
  payloadLength += toAddressLength ;

  // Copy the "From" into payload
  memcpy(&amp;payload[payloadLength], from, fromAddressLength);
  payloadLength += fromAddressLength ;
}


void PayloadAdd( const char *sensorId, float value, byte decimalPlaces)
{
  byte sensorIdLength = strlen( sensorId ) ;

#ifdef DEBUG_TELEMETRY
  Serial.println("PayloadAdd-float ");
  Serial.print( "SensorId:");
  Serial.print( sensorId );
  Serial.print( " sensorIdLen:");
  Serial.print( sensorIdLength );
  Serial.print( " Value:");
  Serial.print( value, decimalPlaces );
  Serial.print( " payloadLength:");
  Serial.print( payloadLength);
#endif

  memcpy( &amp;payload[payloadLength], sensorId,  sensorIdLength) ;
  payloadLength += sensorIdLength ;
  payload[ payloadLength] = SensorIdValueSeperator;
  payloadLength += 1 ;
  payloadLength += strlen( dtostrf(value, -1, decimalPlaces, (char *)&amp;payload[payloadLength]));
  payload[ payloadLength] = SensorReadingSeperator;
  payloadLength += 1 ;

#ifdef DEBUG_TELEMETRY
  Serial.print( " payloadLength:");
  Serial.print( payloadLength);
  Serial.println( );
#endif
}


void PayloadAdd( const char *sensorId, int value )
{
  byte sensorIdLength = strlen( sensorId ) ;

#ifdef DEBUG_TELEMETRY
  Serial.println("PayloadAdd-int ");
  Serial.print( "SensorId:");
  Serial.print( sensorId );
  Serial.print( " sensorIdLen:");
  Serial.print( sensorIdLength );
  Serial.print( " Value:");
  Serial.print( value );
  Serial.print( " payloadLength:");
  Serial.print( payloadLength);
#endif

  memcpy( &amp;payload[payloadLength], sensorId,  sensorIdLength) ;
  payloadLength += sensorIdLength ;
  payload[ payloadLength] = SensorIdValueSeperator;
  payloadLength += 1 ;
  payloadLength += strlen( itoa( value, (char *)&amp;payload[payloadLength], 10));
  payload[ payloadLength] = SensorReadingSeperator;
  payloadLength += 1 ;

#ifdef DEBUG_TELEMETRY
  Serial.print( " payloadLength:");
  Serial.print( payloadLength);
  Serial.println( );
#endif
}

void PayloadAdd( const char *sensorId, unsigned int value )
{
  byte sensorIdLength = strlen( sensorId ) ;

#ifdef DEBUG_TELEMETRY
  Serial.println("PayloadAdd-unsigned int ");
  Serial.print( "SensorId:");
  Serial.print( sensorId );
  Serial.print( " sensorIdLen:");
  Serial.print( sensorIdLength );
  Serial.print( " Value:");
  Serial.print( value );
  Serial.print( " payloadLength:");
  Serial.print( payloadLength);
#endif

  memcpy( &amp;payload[payloadLength], sensorId,  sensorIdLength) ;
  payloadLength += sensorIdLength ;
  payload[ payloadLength] = SensorIdValueSeperator;
  payloadLength += 1 ;
  payloadLength += strlen( utoa( value, (char *)&amp;payload[payloadLength], 10));
  payload[ payloadLength] = SensorReadingSeperator;
  payloadLength += 1 ;

#ifdef DEBUG_TELEMETRY
  Serial.print( " payloadLength:");
  Serial.print( payloadLength);
  Serial.println( );
#endif
}


void PayloadReset()
{
  byte fromAddressLength = payload[0] &amp; 0xf ;
  byte toAddressLength = payload[0] &gt;&gt; 4 ;
  byte addressesLength = toAddressLength + fromAddressLength ;

  payloadLength = addressesLength + 1;

#ifdef DEBUG_TELEMETRY
  Serial.println("PayloadReset- ");
  Serial.print( "To Address len:");
  Serial.print( toAddressLength );
  Serial.print( " From Address len:");
  Serial.print( fromAddressLength );
  Serial.print( " Addresses length:");
  Serial.print( addressesLength );
  Serial.println( );
#endif
}

To get the application to compile I also had to include itoa.h rather than stdlib.h.

maple_loader v0.1
Resetting to bootloader via DTR pulse
[Reset via USB Serial Failed! Did you select the right serial port?]
Searching for DFU device [1EAF:0003]...
Assuming the board is in perpetual bootloader mode and continuing to attempt dfu programming...

dfu-util - (C) 2007-2008 by OpenMoko Inc.

Initially I had some problems deploying my software because I hadn’t followed the instructions and run the installation batch file.

14:03:56.946 -> Setup called
14:03:56.946 -> LoRa setup start
14:03:56.946 -> LoRa setup done.
14:03:56.946 -> TH02 setup
14:03:57.046 -> TH02 Setup done
14:03:57.046 -> Setup done
14:03:57.115 -> Humidity: 76 %	Temperature: 18.9 *C
14:03:57.182 -> Loop done
14:08:57.226 -> Humidity: 74 %	Temperature: 18.7 *C
14:08:57.295 -> Loop done
14:13:57.360 -> Humidity: 76 %	Temperature: 18.3 *C
14:13:57.430 -> Loop done
14:18:57.475 -> Humidity: 74 %	Temperature: 18.2 *C
14:18:57.544 -> Loop done
14:23:57.593 -> Humidity: 70 %	Temperature: 17.8 *C
14:23:57.662 -> Loop done
14:28:57.733 -> Humidity: 71 %	Temperature: 17.8 *C
14:28:57.802 -> Loop done
14:33:57.883 -> Humidity: 73 %	Temperature: 17.9 *C
14:33:57.952 -> Loop done
14:38:57.997 -> Humidity: 73 %	Temperature: 18.0 *C
14:38:58.066 -> Loop done
14:43:58.138 -> Humidity: 73 %	Temperature: 18.1 *C
14:43:58.208 -> Loop done
14:48:58.262 -> Humidity: 73 %	Temperature: 18.3 *C
14:48:58.331 -> Loop done
14:53:58.374 -> Humidity: 73 %	Temperature: 18.2 *C
14:53:58.444 -> Loop done
14:58:58.509 -> Humidity: 73 %	Temperature: 18.3 *C
14:58:58.578 -> Loop done
15:03:58.624 -> Humidity: 65 %	Temperature: 16.5 *C
15:03:58.694 -> Loop done
15:08:58.766 -> Humidity: 71 %	Temperature: 18.8 *C
15:08:58.836 -> Loop done
15:13:58.893 -> Humidity: 75 %	Temperature: 19.1 *C
15:13:58.963 -> Loop done

I configured the device to upload to my Azure IoT Hub/Azure IoT Central gateway and after getting the device name configuration right it has been running reliably for a couple of days

Azure IoT Central Temperature and humidity

The device was sitting outside on the deck and rapid increase in temperature is me bringing it inside.