Pushing Data To Google Sheets - New Thread

I have been working on pushing data to Google Sheets and I thought I would share what I have learned and see if anyone can help me with one more refinement to this approach.

Use Case - My sleepy cellular devices send data to Ubidots every hour. However, Ubidots does not like textual data (except as context for a numerical variable) and it is not well suited for data that is related to system configuration as these values only rarely change. So, I thought it would be cool to send system configuration data for every device in my fleet once a day. That way, I could see what the current configuration was for all my devices and spot issues outside of the hourly reporting / events from Ubidots.

My starting point was the excellent (thank you again @rickkas7 ) application note:

https://docs.particle.io/datasheets/app-notes/an011-publish-to-google-sheets/

Note: There have been some changes in the Google App Script UI and you will need to select “Use Legacy Editor” in order to follow the tutorial. In fact, I do not think using the new editor is an option as you lose “versioning” and the ability to run the scripts “Anyone including anonymous” which are not (at least from what I can see) options anymore. This is a bit worrying as it implies that this approach may not work in the future.

I was able to follow the instructions and then modify the script to do the following: Add the device name and append each update as a new row on the current sheet. As I have over 180 devices in this product, this makes more sense than the “device per tab” or the “device per sheet” approach. For those who are interested, here is my script:

function test() {
  var e = {};
  e.parameter = {};
  e.parameter.event = 'GoogleSheetsExport';
  e.parameter.data = '[1,2,3,4,5,6,7,8]';
  e.parameter.coreid = '1f0030001647ffffffffffff';
  e.parameter.published_at = new Date().toISOString();
  doPost(e);
}

function doPost(e) {
  // e.parameter.event
  // e.parameter.data
  // e.parameter.coreid
  // e.parameter.published_at "2016-04-16T13:37:08.728Z"

  var particleApiToken = '{{Enter your API Token Here}}';

  var publishedAt = new Date(e.parameter.published_at);
  var cacheKey = 'deviceName';

  var dataArray = [];
  try {
    dataArray = JSON.parse(e.parameter.data);
  }
  catch(e) {
  }
  
  var cache = CacheService.getScriptCache();
  var deviceNameCache = cache.get(cacheKey);
  
  if (!deviceNameCache) {
    // The device name was not cached, so use the Particle Cloud API
    var result = UrlFetchApp.fetch('https://api.particle.io/v1/devices?access_token=' + particleApiToken);   
    var resultJson = JSON.parse(result.getContentText());
    
    deviceNameCache = {};

    for(var ii = 0; ii < resultJson.length; ii++) {
      deviceNameCache[resultJson[ii].id] = resultJson[ii].name;
    }
    cache.put(cacheKey, JSON.stringify(deviceNameCache));
  }
  else {
    deviceNameCache = JSON.parse(deviceNameCache);
  }
  
  // Use the device name if known, otherwise use Device ID
  var deviceName = deviceNameCache[e.parameter.coreid];
  if (!deviceName) {
    Logger.log('Unknown device ID');
    return;
  }

  var sheet = SpreadsheetApp.getActiveSheet();

  var row = [e.parameter.coreid, deviceName, publishedAt];

  row = row.concat(dataArray);

  sheet.appendRow(row);

  var result = {};
  result.ok = true;

  return ContentService.createTextOutput(JSON.stringify(result))
    .setMimeType(ContentService.MimeType.JSON);
}

and in my sketch:

/**
 * @brief This function published system status information daily to a Google Sheet where I can monitor config / health for the fleet
 * 
 * @details These are values that don't need to be reported hourly and many have string values that Ubidots struggles with.  Testing this approach 
 * to see if it can give me a more consistent view of fleet health and allow me to see device configuration when it is off-line
 * 
 * @link https://docs.particle.io/datasheets/app-notes/an011-publish-to-google-sheets/ @endlink
 * 
 */
void publishToGoogleSheets() {
  char data[256];                                                     // Store the date in this character array - not global
  char solarString[16];
  char verboseString[16];
  (sysStatus.solarPowerMode) ? strncpy(solarString,"Solar",sizeof(solarString)) : strncpy(solarString,"Utility",sizeof(solarString));
  (sysStatus.verboseMode) ? strncpy(verboseString, "Verbose",sizeof(verboseString)) : strncpy(verboseString, "Not Verbose",sizeof(verboseString));

  snprintf(data, sizeof(data), "[\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%i sec\",\"%i%%\"]", solarString, lowPowerModeStr, currentOffsetStr, openTimeStr, closeTimeStr, sensorTypeConfigStr, verboseString, current.maxConnectTime, current.minBatteryLevel);

  publishQueue.publish("GoogleSheetsExport", data, PRIVATE, WITH_ACK);
  Log.info("published: %s", data);

}

I call this function once a day to collect the following information:

  • Solar or Utility Power
  • Low Power Mode (sleepy device)
  • Time Zone setting (helpful to make sure daylight saving time algorithm is working)
  • Park Open and Close time (device goes into ultra low power sleep when park is closed)
  • Sensor type (Car counter, rpedestrian counter, etc)
  • The longest connection time over the past day
  • The lowest battery charge level over the past day

I hope is helpful to anyone looking to do the same thing. I also have two requests for help:

  1. Particle - I noted the security risk that using the API to get the device name. However, a manual approach is not feasible as the fleet grows. Any chance we could get “Read Only” API Tokens to enable this type of integration without the risk of a full access token? Does this already exist somehow?
  2. Community - I am still learning this App Script stuff so if anyone could suggest how to modify my script to do this, I would be very grateful: Instead of appending each new write as a new row, check to see if a row for a given device is already present and overwrite it. This way, the spreadsheet does not quickly grow to 1000s of rows and each devices’ most recent configuration is stored. Even hints are appreciated.

Also, if there is some wonderful and better way to do this, I am all ears.

Thanks,

Chip

1 Like

A read-only access token is available as part of Team Access Controls. Right now it’s an enterprise only feature, but keep an eye out for news about that. The idea is that you create a new Particle account. Give this account View Only access to your product. Then create a non-expiring token for that user account and embed that in your Google Sheets spreadsheet.

CloudConfigRK is similar to what you’ve done, but there are just more options available for storing the data locally and also retrieving it from different locations. It also shows how to add a button your Google Sheet to send the configuration to online devices.

It is possible to use the Google Apps Script with the current editor. There are a few additional things you need to set it up. Just follow the instructions here.

1 Like

@rickkas7 ,

Thank you for the tip on Team Access Controls, I will give that a shot.

Looking at CloudConfigRK, this looks like a dream come true. I was happy just seeing all my configuration data in one place. It seems like you have solved the issue of making changes as well. Thank you! I will start playing with this right away.

This is another great example of Particle providing tools that make it possible to scale with the platform.

Thanks,

Chip

@rickkas7, very exciting regarding read-only access token - are you able to share any rough timeline for when that might be available to non-Enterprise plans?

@rickkas7 ,

I am assuming that the API User feature is what you were referring to and this is a great idea. The only issue at this point is that, as I understand it, I would need a separate sheet for each project as I cannot set an organizational API user who can look up device names. @mike.sheward - Is this correct?

Over time, the Google Sheet was getting way too long as the script I posted above always appends a new row. I determined that it would be more useful to have each device occupy one row in the spreadsheet and new updates would overwrite the row. That way, the sheet did not get too long and always reflected the latest data (with timestamp).

Here is the code I used to accomplish this - the deviceID / row relationship is stored in document properties. There may be a better way but this seems to work.

I replaced this line in the sketch above:

sheet.appendRow(row);

with this:

  // This is the new section that determines if a row should be appended or overwritten
  // Row numbers are printed in the last column for testing and will be removed
  var documentProperties = PropertiesService.getDocumentProperties();           // Store properties in the document
  var rowIndex = documentProperties.getProperty(e.parameter.coreid);            // Index will tbe the deviceID

  if (!rowIndex) {                            // Row information not found - new row needed
    rowIndex = sheet.getLastRow() + 1;        // Get the last row number
    documentProperties.setProperty(e.parameter.coreid, rowIndex);    // Store the row number in the properties for next time
    row = row.concat(rowIndex);               // Concatenate the row number - for testing - to be removed
    sheet.appendRow(row);                     // Add this row to the end of the sheet
    Logger.log('New Entry - Appended');
  }
  else {
    row = row.concat(rowIndex);               // Concatenate the row number - for testing - to be removed
    sheet.getRange(Math.floor(rowIndex), 1, 1, row.length).setValues([row]);    // Overwrite the row
    Logger.log('Existing Entry - Overwritten');
  }

As I was testing, I realized that I may need to periodically reset the document properties so, I added this function which I can run from the App Script editor:

function propertiesReset() {
  // Deletes all document properties.
  var documentProperties = PropertiesService.getDocumentProperties();
  documentProperties.deleteAllProperties();
  Logger.log('Cleared All Document Properties');
}

I hope this is helpful.

Chip

2 Likes

Update,

So this is working well with one small exception - all these devices jumps on-line and push data at the same time. Because of that, I noticed that some of the devices were assigned the same row number. This is an issue as the next day they will start to over-write one another.

If someone knows of a better way to do this that can avoid duplicate row numbers being assigned for simultaneous entries, I am all ears. Otherwise, I created this function to reset the row property assignments and have it run at the end of each day before the row properties can cause a problem.

Here is the function that renumbers the rows. Again, this is outside my c++ comfort zone so suggestions welcome:

function resetRowProperties() {
  var sheet = SpreadsheetApp.getActiveSheet();
  var documentProperties = PropertiesService.getDocumentProperties();
  var range = sheet.getDataRange();
  var values = range.getValues();
  documentProperties.deleteAllProperties();
  Logger.log('Cleared All Document Properties');
  for (var rowIndex = 2; rowIndex <= sheet.getLastRow(); rowIndex++) {
    documentProperties.setProperty(range.getCell(rowIndex,1), rowIndex);    // Store the row number in the properties for next time
    var cell = sheet.getRange(rowIndex,13);
    cell.setValue(rowIndex);
  }
  Logger.log('Reset Row Properties for the Sheet');
}

Thanks,

Chip

Interesting... I'm not sure how to do this within the script you have but what I did was assign each device a reporting offset in seconds. I initially was going to just use a random number generator of say 0-30 seconds but instead decided to configure it from the cloud. This configuration is pushed to the device via Subscribe event. This way, I can provide each device a defined "offset" in seconds of when that device should publish it's data.

For example... if all devices are scheduled to publish the top of every hour. Then what actually happens is each device publishes data back at the top of the hour + that devices specific offset. The main reason I did this was to reduce the peaks of my backend script as well as SQL server utilization. Basically instead of having 30 devices all report back in the same second each hour. The 30 devices all report back between 12:00:00 and 12:00:30. I basically tell the device to sleep for the number of seconds to the top of the next hour + report back offset.

By the way… thanks for the reminder on the CloudConfigRK. I currently pass my device configuration to the device as a JSON object and was just going to start looking at how to update that via Particle Function as well as saving to eeprom. Looks like that library will make it much easier! As you said… libraries, packages and documentation like this is what makes Particle so great and easy to use. In your case, however you use CloudConfig you could add a variable for Offset so device don’t report simultaneously.

@jgskarda ,

I love the offset idea. I had hoped that the randomization would arise naturally as each of these devices might take slightly different amounts of time to connect to the Particle network. In fact, of the 106 who reported this morning, only three row numbers conflicted. If I added a little more randomization as you suggest, it might reduce the conflicts further.

Thanks, Chip

Interesting… yeah I suppose you already have some natural randomness in how long it takes to connect. The offset idea may not help much then or if it does it won’t make your row conflicts 0 maybe just reduce it. That is unless you are OK in spreading out the reporting time to be over several minutes or even longer. I personally did the offset as a number of my devices are always powered on and thus they stay connected to the cloud. They all publish at the top of every hour (and again at the :05, :10, :15, etc.). All devices that are powered on would all publish and nearly the exact second of the day. This was my method to spreading that 1 second burst over 30 seconds.

Is the Google sheet a long term thing and something you plan on expanding or just a minimally viable product (MVP) version to quickly prove a concept out or keep it at small scale? As I mentioned in prior posts/messages… you could consider moving from the MVP google sheet to say SQL and an AWS, Azure, google function that process the web hook. It would simply parse out the data and update the row within a SQL table. That would be a bit more scalable and you wouldn’t run into that issue of duplicate rows in Google sheets. But yeah… I suppose that defeats the simplicity and MVP type approach of a google sheet. :slight_smile: I personally started with Google sheets and was actually using IFFT to get the data there for my early prototype. Now I store all the raw data into SQL and run queries to get the most recent data for each device.

another option without moving to another cloud is to stay in Google, and store the data on firebase (the firestore db).
There is this tutorial from @rickkas7 , or this other example to start.

But I guess nothing beats the ease of looking at your data offered by a spreadsheet on Google Sheets.

Imagine if we could have both, firebase for db and data on a spreadsheet like this person describes here.
Not bad.

Gustavo.

1 Like

Thanks for sharing the links @gusgonnet. Yeah there are lots of options these days... I've been meaning to spend time investigating other noSQL data storage options such as FireBase or any other options. Especially for the time series sensor data. Early on my Particle journey 3-4 years ago I just picked a path a stuck with it. At that time, I was using SQL more and more as part of my day job and the company I work for primarily uses Azure for cloud services. I just picked that and ran with it. It's been meeting my needs but I'm sure there are easier ways to do it with Firebase or other methods. Would love to have more free time to explore things like Firebase and expand my knowledge a bit. The tutorial link you shared looks like a great place to start. Hopefully someday...

That's the golden ticket... I didn't do it yet for my own Particle project, however I've been playing more and more with PowerQuery (Comes with Excel and is the same foundation in how you build data connections with things like Power BI). It should be pretty quick and easy to use PowerQuery to pull the data from your database (whether it be SQL, noSQL, Google, AWS, Azure, etc.) and pull that into either Excel or even PowerBI. It's an area I've been exploring as part of my day job more and more. Once again hope to eventually have time to build something for my own use just haven't had the time or a strong enough of a need to make it a priority. Would be fun to compare notes sometime between SQL and using Azure and Firebase using Google.

2 Likes

@all,

Well, looks like I have not yet got to MVP on the Google Sheets. The remove redundant row properties sketch I listed above ran last night as intended. However, this morning, all the devices that connected ignored the existing device properties and simply appended a new row.

I feel like I am very close with this approach but I cannot use the spreadsheet for viewing of the current status or for using @rickkas7 CloudConfigRK if the number of rows simply grows and grows. I am going to go back to Stack Overflow and see if I can get someone who is good at App Script to help:

Stay tuned.

Chip

1 Like

@all,

I think I may have solved the functionality - albeit in a very inelegant way.

I was unable to get the document properties approach to work as two devices connecting at the same time would get assigned the same row numbers. So, now each device, when it connects builds a giant array of the entire sheet and then searches for the deviceID. If found, it overwrites that row. If not, it appends to the end. I am glad that I do not have to pay for memory or compute here as the size of the array that each device creates will grow as the sheet grows. Will keep thinking of a better solution and open to suggestions.

In the mean time, here is the latest code:

/**
 * @OnlyCurrentDoc
 */

function test() {
  
  var e = {};
  e.parameter = {};
  e.parameter.event = 'GoogleSheetsExport';
  e.parameter.data = '[1,2,3,4,5,6,7,8,0.9]';
  e.parameter.coreid = 'e00fce68ae51b4fb8e2e05b4';
  e.parameter.published_at = new Date().toISOString();
  doPost(e);
}

function doPost(e) {
  // e.parameter.event
  // e.parameter.data
  // e.parameter.coreid
  // e.parameter.published_at "2016-04-16T13:37:08.728Z"

  var particleApiToken = '{{Your API Key Here}}';

  var publishedAt = new Date(e.parameter.published_at);
  var cacheKey = 'deviceName';

  var dataArray = [];
  try {
    dataArray = JSON.parse(e.parameter.data);
  }
  catch(e) {
  }
  
  var cache = CacheService.getScriptCache();
  var deviceNameCache = cache.get(cacheKey);
  
  if (!deviceNameCache) {
    // The device name was not cached, so use the Particle Cloud API
    var result = UrlFetchApp.fetch('https://api.particle.io/v1/devices?access_token=' + particleApiToken);   
    var resultJson = JSON.parse(result.getContentText());
    
    deviceNameCache = {};

    for(var ii = 0; ii < resultJson.length; ii++) {
      deviceNameCache[resultJson[ii].id] = resultJson[ii].name;
    }
    cache.put(cacheKey, JSON.stringify(deviceNameCache));
  }
  else {
    deviceNameCache = JSON.parse(deviceNameCache);
  }
  
  // Use the device name if known, otherwise use Device ID
  var deviceName = deviceNameCache[e.parameter.coreid];
  if (!deviceName) {
    Logger.log('Unknown device ID');
    return;
  }

  var sheet = SpreadsheetApp.getActiveSheet();
  var range = sheet.getDataRange();
  var values = range.getValues();
  var rowContents = [e.parameter.coreid, deviceName, publishedAt];
  rowContents = rowContents.concat(dataArray);

  for(var rowIndex = 0; rowIndex < values.length; rowIndex++) {
    Logger.log(values[rowIndex][0]);
    if (values[rowIndex][0] == e.parameter.coreid) {
      rowContents = rowContents.concat(rowIndex);               // Concatenate the row number - for testing - to be removed
      sheet.getRange(rowIndex+1, 1, 1, rowContents.length).setValues([rowContents]);    // Overwrite the row
      Logger.log('Existing Entry - Overwritten');
      Logger.log(rowIndex);
      break;
    }
    else if (rowIndex == values.length -1) {
      rowContents = rowContents.concat(rowIndex);               // Concatenate the row number - for testing - to be removed
      sheet.appendRow(rowContents);                     // Add this row to the end of the sheet
      Logger.log('New Entry - Appended');
    }
  }

  var result = {};
  result.ok = true;

  return ContentService.createTextOutput(JSON.stringify(result))
    .setMimeType(ContentService.MimeType.JSON);
}

Hope this helps folks.

Chip

2 Likes