Skip to content

Instantly share code, notes, and snippets.

@harrisonhjones
Last active August 29, 2015 14:19
Show Gist options
  • Save harrisonhjones/7f7eb97600069279e16e to your computer and use it in GitHub Desktop.
Save harrisonhjones/7f7eb97600069279e16e to your computer and use it in GitHub Desktop.
Spark Download File by Chunk
/* Includes ------------------------------------------------------------------*/
#include "application.h"
#include "flashHelper.h"
#include "Adafruit_mfGFX.h"
#include "Adafruit_SharpMem.h"
extern char* itoa(int a, char* buffer, unsigned char radix);
// Debug
// Turn ON DEBUG statements
//#define DBG_DEBUG(x); Serial.print("[DEBUG] "); Serial.print(x); Serial.println();
//#define DBG_DEBUG_2(x,y); Serial.print("[DEBUG] "); Serial.print(x); Serial.print(" = "); Serial.print(y); Serial.println();
// Turn OFF DEBUG statements
#define DBG_DEBUG(x);
#define DBG_DEBUG_2(x,y);
#define DBG_INFO(x); Serial.print("[INFO] "); Serial.print(x); Serial.println();
#define DBG_INFO_2(x,y); Serial.print("[INFO] "); Serial.print(x); Serial.print(" = "); Serial.print(y); Serial.println();
#define DBG_WARN(x); Serial.print("[WARN] "); Serial.print(x); Serial.println();
#define DBG_WARN_2(x,y); Serial.print("[WARN] "); Serial.print(x); Serial.print(" = "); Serial.print(y); Serial.println();
#define DBG_ERROR(x); Serial.print("[ERROR] "); Serial.print(x); Serial.println();
#define DBG_ERROR_2(x,y); Serial.print("[ERROR] "); Serial.print(x); Serial.print(" = "); Serial.print(y); Serial.println();
// Sharp Memory Display
Adafruit_SharpMem display(SCK, MOSI, A2); //SCK, MOSI, SS
#define BLACK 0
#define WHITE 1
void initSharpMemoryDisplay();
// HTTP Connection
TCPClient client;
const char host[] = "example.com"; // the host of the backend server
const unsigned int port = 80;
// System Mode
SYSTEM_MODE(SEMI_AUTOMATIC);
/* Defines */
#define NUM_CHUNKS 40
#define CHUNK_SIZE 240
#define CHUNK_DL_RETRY 10
#define BINARY_SIZE 9600
#define STATUS_UL_RETRY 10
#define HTTP_CONNECTED_TIMEOUT 2000 // Give the server 2 seconds to respond
// getNEOFile Return Values
#define SUCCESS 1
// -1 to -100 Misc Errors
#define ERR_END_OF_DOWNLOAD_CHUNK -1
#define ERR_RETRY_LIMIT -2
#define ERR_INVALID_SLOT_NUMBER -3
#define ERR_END_OF_UPLOAD_STATUS -4
// -101 to -200 HTTP ERRORS
#define ERR_HTTP_CANNOT_CONNECT -101
#define ERR_HTTP_TIMEOUT -102
#define ERR_HTTP_BAD_STATUS -103
#define ERR_HTTP_BAD_CONTENT_LENGTH -104;
// -201 to -300 Flash Write Errors
#define ERR_UNABLE_TO_WRITE_CHUNK -201
#define ERR_UNABLE_TO_CLEAR_FLASH -202
/* Variables */
/* Functions */
bool writeChunkToFlash(unsigned chunkNumber, unsigned char chunkData[CHUNK_SIZE], unsigned char imgSlotNum); // Define a function for writing a chunk to flash memory
int downloadChunk(unsigned chunkNumber, unsigned char imgSlotNum); // Define the main function to grab a file from a remote server
int downloadImage(unsigned char imgSlotNum);
bool clearFlash(unsigned char imgSlotNum);
int uploadStatusA(unsigned long wifiTime, unsigned long downloadTime, int downloadReturnValue);
int uploadStatus(unsigned long wifiTime, unsigned long downloadTime, int downloadReturnValue);
void downloadInitialImages();
int dispSlot(String slotString);
unsigned char dispSlotNum = 0;
bool slotChanged = false;
// OTA Slot Downloading
int downSlot(String slotString);
int downSlotNum = -1; // Do not download anything
/* Quick Background on Slots */
#define SLOT_LOADING 0
#define SLOT_ERROR 1
#define SLOT_ABOUT 2
#define SLOT_MAIN 20
#define EEPROM_DEVICE_INITIALIZED 0
/* This function is called once at start up ----------------------------------*/
void setup()
{
unsigned long downloadStart = 0;
unsigned long downloadTime = 0;
unsigned long wifiStart = 0;
unsigned long wifiTime = 0;
Serial.begin(9600);
initSharpMemoryDisplay();
wifiStart = millis();
DBG_INFO("Turning on WiFi & Connecting")
WiFi.on();
WiFi.connect();
while(WiFi.ready() == false);
DBG_INFO("WiFi connected");
// Wait for a second for the WiFi connection to work apparently
delay(1000);
wifiTime = millis() - wifiStart;
uint8_t deviceInitalized = EEPROM.read(EEPROM_DEVICE_INITIALIZED);
DBG_INFO_2("Device Initialized", deviceInitalized);
if(deviceInitalized!=1) // Check to see if the device is initialized
downloadInitialImages();
else
{
display.clearDisplay();
display.loadImageFromFlash(SLOT_MAIN);
}
/*
// Download the latest .NEO FILE
DBG_INFO_2("Downloading an image for slot",SLOT_MAIN);
downloadStart = millis();
int retVal = downloadImage(SLOT_MAIN);
downloadTime = millis() - downloadStart;
// If there was a complete failure display the error image otherwise serve the main image
if(retVal < 0)
{
DBG_ERROR_2("There was a critical error with the download process. Error", retVal)
display.clearDisplay();
display.loadImageFromFlash(SLOT_ERROR);
}
else
{
display.clearDisplay();
display.loadImageFromFlash(SLOT_MAIN); // Load the loading image
}
DBG_INFO_2("Download Time", downloadTime);
*/
// Send the device's status to the server
int retVal = 0;
retVal = uploadStatus(wifiTime, downloadTime, retVal);
if(retVal < 0)
{
DBG_ERROR_2("There was a critical error with the download process. Error", retVal);
}
else
{
DBG_INFO_2("Status uploaded. Attempts", retVal);
}
DBG_INFO("Setup Finished");
//Spark.sleep(SLEEP_MODE_DEEP, 3600);
Spark.connect();
Spark.function("dispSlot", dispSlot);
Spark.function("downSlot", downSlot);
RGB.control(false);
}
/* This function loops forever --------------------------------------------*/
void loop()
{
if(slotChanged == true)
{
slotChanged = false;
display.clearDisplay();
display.loadImageFromFlash(dispSlotNum); // Load the loading image
}
if(downSlotNum > 0)
{
Serial.print("Down Slot Num: ");
Serial.println(downSlotNum);
display.clearDisplay();
display.loadImageFromFlash(SLOT_LOADING); // Load the loading image
downloadImage(downSlotNum);
dispSlotNum = downSlotNum;
downSlotNum = -1; // Disable future slot downloading
slotChanged = true; // Enable automatic slow changing
}
}
int dispSlot(String slotString)
{
//parse the string into an integer
int state = atoi(slotString.c_str());
// If the given slot is -1 reset the EEPROM so the next boot up loads all the images again.
if (state == -1)
{
EEPROM.write(EEPROM_DEVICE_INITIALIZED, 0x00);
return 2;
}
//Check that the value it's been given is in the right range
if (state > 20) {state = 20;}
else if (state < 0) {state = 0;}
dispSlotNum = (unsigned char) state;
slotChanged = true;
return 1;
}
int downSlot(String slotString)
{
//parse the string into an integer
int slotNum = atoi(slotString.c_str());
if(slotNum > 79)
{
return -2;
}
if(slotNum < 0)
{
return -1;
}
downSlotNum = slotNum;
return 1;
}
void downloadInitialImages()
{
unsigned long downloadStart = 0;
unsigned long downloadTime = 0;
RGB.control(true);
RGB.color(0,0,255);
// Download the neo file for slot SLOT_LOADING
DBG_INFO_2("Downloading an image for slot",SLOT_LOADING);
downloadStart = millis();
int retVal = downloadImage(SLOT_LOADING);
downloadTime = millis() - downloadStart;
// If there was a complete failure display the error image otherwise serve the main image
if(retVal < 0)
{
DBG_ERROR_2("There was a critical error with the download process. Error", retVal)
display.clearDisplay();
}
else
{
display.clearDisplay();
display.loadImageFromFlash(SLOT_LOADING); // Load the loading image
}
DBG_INFO_2("Download Time", downloadTime);
RGB.color(0,127,255);
// Download the neo file for slot SLOT_ERROR
DBG_INFO_2("Downloading an image for slot",SLOT_ERROR);
downloadStart = millis();
retVal = downloadImage(SLOT_ERROR);
downloadTime = millis() - downloadStart;
// If there was a complete failure display the error image otherwise serve the main image
if(retVal < 0)
{
DBG_ERROR_2("There was a critical error with the download process. Error", retVal)
display.clearDisplay();
}
else
{
display.clearDisplay();
display.loadImageFromFlash(SLOT_ERROR); // Load the loading image
}
DBG_INFO_2("Download Time", downloadTime);
RGB.color(0,255,255);
// Download the neo file for slot SLOT_ABOUT
DBG_INFO_2("Downloading an image for slot",SLOT_ABOUT);
downloadStart = millis();
retVal = downloadImage(SLOT_ABOUT);
downloadTime = millis() - downloadStart;
// If there was a complete failure display the error image otherwise serve the main image
if(retVal < 0)
{
DBG_ERROR_2("There was a critical error with the download process. Error", retVal)
display.clearDisplay();
}
else
{
display.clearDisplay();
display.loadImageFromFlash(SLOT_ABOUT); // Load the loading image
}
DBG_INFO_2("Download Time", downloadTime);
RGB.color(0,255,127);
// Download the neo file for slot SLOT_MAIN
DBG_INFO_2("Downloading an image for slot",SLOT_MAIN);
downloadStart = millis();
retVal = downloadImage(SLOT_MAIN);
downloadTime = millis() - downloadStart;
// If there was a complete failure display the error image otherwise serve the main image
if(retVal < 0)
{
DBG_ERROR_2("There was a critical error with the download process. Error", retVal)
display.clearDisplay();
}
else
{
display.clearDisplay();
display.loadImageFromFlash(SLOT_MAIN); // Load the loading image
}
DBG_INFO_2("Download Time", downloadTime);
DBG_INFO("Setting the deviceInitalized EEPROM parameter to 1");
EEPROM.write(EEPROM_DEVICE_INITIALIZED, 0x01);
}
void initSharpMemoryDisplay()
{
SPI.begin();
SPI.setBitOrder(MSBFIRST);
SPI.setDataMode(SPI_MODE0);
SPI.setClockDivider(SPI_CLOCK_DIV16);
display.init(); // Initialize the hardware ports since cannot be done in class instantation above
display.begin();
display.clearDisplay();
display.loadImageFromFlash(0); // Load the loading image
}
bool clearFlash(unsigned char imgSlotNum)
{
// Each "slot" is 12288 (0x3000) bytes wide (because the flash can only be erased in 4KB chunks)
// Only 80 slots available
if(imgSlotNum > 79)
{
DBG_WARN_2("Invalid image slot number",imgSlotNum);
return false;
}
// So the slot starts @ 0x80000 + imgSlotNum * 0x3000
DBG_INFO_2("Erasing starting at ", 0x80000 + imgSlotNum * 0x3000);
sFLASH_EraseSector(0x80000 + imgSlotNum * 0x3000); // Erase 4KB (4096 bytes) sector
delay(20);
DBG_INFO_2("Erasing starting at ", 0x81000 + imgSlotNum * 0x3000);
sFLASH_EraseSector(0x81000 + imgSlotNum * 0x3000); // Erase 4KB (4096 bytes) sector
delay(20);
DBG_INFO_2("Erasing starting at ", 0x82000 + imgSlotNum * 0x3000);
sFLASH_EraseSector(0x82000 + imgSlotNum * 0x3000); // Erase 4KB (4096 bytes) sector
delay(20);
return true;
}
/**
* writeRowToFlash
*
* Writes a chunk into flash
*
* @return bool representing the success (true) or failure (false) of the function
*
*/
bool writeChunkToFlash(unsigned chunkNumber, unsigned char chunkData[CHUNK_SIZE], unsigned char imgSlotNum)
{
// Write to flash memory here
int extFlashOffset = chunkNumber*CHUNK_SIZE + imgSlotNum * 0x3000;
//DBG_INFO_2("Flash (psuedo)written starting at address",0x80000 + extFlashOffset);
if(SparkFlash_writeB(chunkData, CHUNK_SIZE, extFlashOffset))
{
DBG_DEBUG_2("Flash written starting at address",0x80000 + extFlashOffset);
return true;
}
else
{
DBG_WARN_2("Unable to write flash starting at address",0x80000 + extFlashOffset);
return false;
}
}
int downloadImage(unsigned char imgSlotNum)
{
// Only 80 slots available
if(imgSlotNum > 79)
{
DBG_WARN_2("Invalid image slot number",imgSlotNum);
return ERR_INVALID_SLOT_NUMBER;
}
if(clearFlash(imgSlotNum) == false)
{
DBG_WARN("Unable to clear flash");
return ERR_UNABLE_TO_CLEAR_FLASH;
}
// Grab control of the RGB LED
//RGB.control(true);
// Turn it "off"
//RGB.color(0,0,0);
int val = 0;
// Initalize a few variables to track local (each chunk) and total (all chunks) retry counts
unsigned int retryCount = 0;
unsigned char totalRetryCount = 0;
// Attempt to download each chunk
for(unsigned char i = 0; i<NUM_CHUNKS; i++)
{
// Atempt to download each chunk CHUNK_DL_RETRY times
while(retryCount < CHUNK_DL_RETRY)
{
if(downloadChunk(i, imgSlotNum) != SUCCESS)
{
// If downloadChunk was unsuccessful, increment the retry counters and try again
retryCount++;
totalRetryCount++;
delay(500);
}
else
{
// If downloadChunk was successful, reset the local retry counter and go to the next chunk
retryCount = 0;
break;
}
}
if(retryCount >= CHUNK_DL_RETRY)
{
// If we hit a retry limit exit the function with an error
//RGB.color(255,0,0);
return ERR_RETRY_LIMIT;
}
val = map(i, 0, NUM_CHUNKS-1, 0, 255);
//RGB.color(0,val,0);
}
// Return the total retry count (ideally 0)
return totalRetryCount;
}
/**
* downloadChunk
*
* Grabs part of a NEO file from a web server
*
* @return int representing the success or failure of the function.
*
* Negative return values represent failures
* Positive return values represent successes
* Should not return 0
* Return Values:
*
*/
int downloadChunk(unsigned chunkNumber, unsigned char imgSlotNum)
{
DBG_INFO_2("Downloading chunk", chunkNumber);
unsigned char chunkData[CHUNK_SIZE];
unsigned long httpLastDataTime = 0;
unsigned int bytesRead = 0;
// HTTP Downloadings Buffers, Variables, etc
bool parsingHeader= true;
char buffer[33];
char statusCodeStr[] = "NaN";
char c;
unsigned int contentLength = 0; // Detected HTTP Content Length
unsigned short statusCode = 0; // Detected HTTP Status Code
DBG_DEBUG("\tStep 1 - Connecting to server\n");
if (client.connect(host, port) == false)
{
DBG_ERROR("\tUnable to connect");
DBG_WARN("\tStopping TCPClient");
client.stop();
return ERR_HTTP_CANNOT_CONNECT;
}
DBG_DEBUG("\tStep 2 - Sending HTTP Request\n");
client.write("GET /sample.php HTTP/1.1\nHost: example.com:80\nRange: bytes=");
Serial.print("Bytes: ");
itoa(chunkNumber*CHUNK_SIZE,buffer,10);
client.write(buffer);
//Serial.print(buffer);
Serial.write(buffer);
client.write("-");
Serial.write("-");
itoa ((chunkNumber+1)*CHUNK_SIZE-1,buffer,10);
client.write(buffer);
Serial.write(buffer);
Serial.println();
// Send Device ID
client.write("\nX-Device-ID: ");
Spark.deviceID().toCharArray(buffer, 100);
client.write(buffer);
// Send Device ID
client.write("\nX-Device-Slot: ");
itoa (imgSlotNum,buffer,10);
client.write(buffer);
client.write("\n\n");
//Serial.write("\n\n");
httpLastDataTime = millis();
while(client.connected()) {
// Check for timeout condition
if((millis() - httpLastDataTime) > HTTP_CONNECTED_TIMEOUT)
{
// Timeout condition reached!
DBG_WARN("\tTimeout condition reached");
// Clear out any left over data (so the CC3000 won't crash)
while(client.available())
client.read();
DBG_WARN("\tStopping TCPClient");
client.stop();
DBG_WARN("\tReturning with error");
return ERR_HTTP_TIMEOUT;
}
while(client.available()) {
// If there's data to read, reset the timeout
httpLastDataTime = millis();
if (parsingHeader) {
DBG_DEBUG("\tStart parsing header");
// Parsing the Status Code
client.find((char*)"HTTP/1.1 "); // Move the pointer up to the start of the status code
client.readBytes(statusCodeStr, 3); // Read the status code into the statusCode variable
statusCode = atoi(statusCodeStr);
DBG_DEBUG_2("\tStatus Code",statusCode);
// Bad Status (not 206)
if(statusCode != 206)
{
DBG_WARN_2("\tBad Status",statusCode);
while(client.available())
client.read();
DBG_WARN("\tStopping TCPClient");
client.stop();
DBG_WARN("\tReturning with error");
return ERR_HTTP_BAD_STATUS;
}
// Parsing the Content Length
client.find((char*)"Content-Length: "); // Move the pointer up to the start of the content length
while (isdigit(c = client.read())) { // While we are reading the content length number
contentLength = contentLength*10 + (c - '0'); // Decode the ASCII number to a "real" number
}
DBG_DEBUG_2("\tContent Length",contentLength);
// Bad Content Length
if(contentLength != CHUNK_SIZE)
{
DBG_WARN_2("\tBad Content Length",contentLength);
while(client.available())
client.read();
DBG_WARN("\tStopping TCPClient");
client.stop();
DBG_WARN("\tReturning with error");
return ERR_HTTP_BAD_CONTENT_LENGTH;
}
// Parsing the actual content
client.find((char*)"\n\r\n"); // Move the pointer to the start of the content
parsingHeader = false;
DBG_DEBUG("\tEnd parsing header");
} else {
chunkData[bytesRead++] = client.read();
// Serial.write(".");
if(bytesRead == CHUNK_SIZE)
{
DBG_DEBUG("\tSuccessful Read of CHUNK_SIZE");
// Clear out any left over data (so the CC3000 won't crash)
while(client.available())
client.read();
DBG_DEBUG("\tStopping TCPClient");
client.stop();
if(writeChunkToFlash(chunkNumber, chunkData, imgSlotNum) == false)
{
DBG_ERROR("\tUnable to write chunk to flash");
return ERR_UNABLE_TO_WRITE_CHUNK;
}
else
return SUCCESS;
}
}
}
}
return ERR_END_OF_DOWNLOAD_CHUNK;
}
/**
* uploadStatus
*
* Upload the status of the device
*
* @return int representing the success or failure of the function.
*
* Negative return values represent failures
* Positive return values represent successes
* Should not return 0
* Return Values:
*
*/
int uploadStatus(unsigned long wifiTime, unsigned long downloadTime, int downloadReturnValue)
{
// Initalize a few variables to track retry counts
unsigned char retryCount = 0;
// Attempt to upload device status
while(retryCount < STATUS_UL_RETRY)
{
int retVal = uploadStatusA(wifiTime, downloadTime, downloadReturnValue);
if(retVal != SUCCESS)
{
// If uploadStatusA was unsuccessful, increment the retry counter and try again
DBG_WARN_2("Status upload sent unsuccessfully. Return value", retVal);
retryCount++;
delay(500);
}
else
{
// If uploadStatusA was successful, finish
DBG_INFO("Status upload sent successfully!");
return retryCount;
}
}
if(retryCount >= STATUS_UL_RETRY)
{
// If we hit a retry limit exit the function with an error
return ERR_RETRY_LIMIT;
}
// Return the total retry count (ideally 0)
return retryCount;
}
int uploadStatusA(unsigned long wifiTime, unsigned long downloadTime, int downloadReturnValue)
{
DBG_INFO("Uploading device status");
unsigned long httpLastDataTime = 0;
unsigned int bytesRead = 0;
// HTTP Downloadings Buffers, Variables, etc
bool parsingHeader= true;
char buffer[100];
char statusCodeStr[] = "NaN";
char c;
unsigned int contentLength = 0; // Detected HTTP Content Length
unsigned short statusCode = 0; // Detected HTTP Status Code
DBG_INFO("\tStep 1 - Connecting to server\n");
if (client.connect(host, port) == false)
{
DBG_ERROR("\tUnable to connect");
DBG_WARN("\tStopping TCPClient");
client.stop();
return ERR_HTTP_CANNOT_CONNECT;
}
DBG_INFO("\tStep 2 - Sending HTTP Request\n");
client.write("GET /sample.php HTTP/1.1\nHost: example.com:80");
// Send Device ID
client.write("\nX-Device-ID: ");
Spark.deviceID().toCharArray(buffer, 100);
client.write(buffer);
// Send Battery Level
client.write("\nX-Device-Battery-mVoltage: ");
itoa(3300,buffer,10);
client.write(buffer);
// Send Time
client.write("\nX-Device-Time: ");
itoa(Time.now(),buffer,10);
client.write(buffer);
// Send Next Update
client.write("\nX-Device-Next-Update: ");
itoa(Time.now()+360,buffer,10);
client.write(buffer);
// Send WiFi Time
client.write("\nX-Device-WiFi-Time: ");
itoa(wifiTime,buffer,10);
client.write(buffer);
// Send Download Time
client.write("\nX-Device-Download-Time: ");
itoa(downloadTime,buffer,10);
client.write(buffer);
// Send Download Return Value
client.write("\nX-Device-Download-Value: ");
itoa(downloadReturnValue,buffer,10);
client.write(buffer);
client.write("\n\n");
//Serial.write("\n\n");
httpLastDataTime = millis();
while(client.connected()) {
// Check for timeout condition
if((millis() - httpLastDataTime) > HTTP_CONNECTED_TIMEOUT)
{
// Timeout condition reached!
DBG_WARN("\tTimeout condition reached");
// Clear out any left over data (so the CC3000 won't crash)
while(client.available())
client.read();
DBG_WARN("\tStopping TCPClient");
client.stop();
DBG_WARN("\tReturning with error");
return ERR_HTTP_TIMEOUT;
}
while(client.available()) {
// If there's data to read, reset the timeout
httpLastDataTime = millis();
if (parsingHeader) {
DBG_DEBUG("\tStart parsing header");
// Parsing the Status Code
client.find((char*)"HTTP/1.1 "); // Move the pointer up to the start of the status code
client.readBytes(statusCodeStr, 3); // Read the status code into the statusCode variable
statusCode = atoi(statusCodeStr);
DBG_DEBUG_2("\tStatus Code",statusCode);
// Bad Status (not 200)
if(statusCode != 200)
{
DBG_WARN_2("\tBad Status",statusCode);
while(client.available())
client.read();
DBG_WARN("\tStopping TCPClient");
client.stop();
DBG_WARN("\tReturning with error");
return ERR_HTTP_BAD_STATUS;
}
// Parsing the Content Length
client.find((char*)"Content-Length: "); // Move the pointer up to the start of the content length
while (isdigit(c = client.read())) { // While we are reading the content length number
contentLength = contentLength*10 + (c - '0'); // Decode the ASCII number to a "real" number
}
DBG_DEBUG_2("\tContent Length",contentLength);
// Bad Content Length (not 2 for "ok")
if(contentLength != 2)
{
DBG_WARN("\tBad Content Length");
while(client.available())
client.read();
DBG_WARN("\tStopping TCPClient");
client.stop();
DBG_WARN("\tReturning with error");
return ERR_HTTP_BAD_CONTENT_LENGTH;
}
// Parsing the actual content
client.find((char*)"\n\r\n"); // Move the pointer to the start of the content
parsingHeader = false;
DBG_DEBUG("\tEnd parsing header");
} else {
// For now just read the response. In the future we might want to actually do something with it?
client.read();
bytesRead++;
// Serial.write(".");
if(bytesRead == 2)
{
DBG_INFO("\tSuccessful Read of 2");
// Clear out any left over data (so the CC3000 won't crash)
while(client.available())
client.read();
DBG_INFO("\tStopping TCPClient");
client.stop();
return SUCCESS;
}
}
}
}
return ERR_END_OF_UPLOAD_STATUS;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment