Plantcare!

This is an IoT project using the google cloud platform. I developed an ESP32 based hardware device. The Firmware is updated over the air after CI/CD in Google Cloud Build. I present here the main cpp file of the firmware and the cloud bild file.

Let’s chechout the code:

#include <Arduino.h>
#include <CloudIoTCore.h>
#include <CloudIoTCoreMqtt.h>
#include <WiFi.h>
#include <MQTT.h>
#include "ciotc_config.h" // Configure with your settings
#include <time.h>
#include <WiFiClientSecure.h>
#include <driver/adc.h>
#include <esp_wifi.h>
#include <Update.h>

Most of these libraries are pretty comon. The Cloud IoT Core library comes from here: https://github.com/GoogleCloudPlatform/google-cloud-iot-arduino It has tools to connect IoT devices to the Google Cloud IoT Core.

Some of the other libraries come from here: https://github.com/espressif/arduino-esp32 This repository is maintained by espressif, the vendor of the ESP32 chip.

#define uS_TO_S_FACTOR 1000000ull /* Conversion factor for micro seconds to seconds */
#define TIME_TO_SLEEP 7200     /* Time ESP32 will go to sleep (in seconds) */

// These variables are stored in the RTC Memory and thereby survive reboot. 
RTC_DATA_ATTR int bootCount = 0;
RTC_DATA_ATTR int32_t uptime = 0;

The RTC_DATA_ATTR macros are special in the ESP32, because they survive reboot. So I use them here to count boots.


#if ARDUHAL_LOG_LEVEL >= ARDUHAL_LOG_LEVEL_DEBUG
#include "esp32-hal-log.h"
#define DEBUG_MSG(...) log_d("%s",String( __VA_ARGS__ ).c_str() )
#define DEBUG_ENABLE true
#define DEBUG_UPTIME String((int32_t)esp_timer_get_time() / 1000)
#else
#define DEBUG_MSG(...)
#define DEBUG_ENABLE false
#endif

Here I define some debugging macros. It makes sense to turn this off in production. Too much serial output draws a lot of power from a battery powered device.


CloudIoTCoreDevice *device;
CloudIoTCoreMqtt *mqtt;
WiFiClientSecure *netClient;
MQTTClient *mqttClient;

unsigned long iss = 0;
String jwt;

esp_sleep_wakeup_cause_t wakeup_reason;

long lastMsg = 0;
long lastDisconnect = 0;
char msg[20];
int counter = 0;
int forceJwtExpSecs = 3600; // One hour is typical, up to 24 hours.

uint16_t sensfs;
uint16_t sensfm;
uint16_t sensfl;
uint16_t sensb;
uint16_t volt;
int8_t power = 0;

These are just some declarations and initializiations. Now lets have a look at the OTA update.

// Utility to extract header value from headers
String getHeaderValue(String header, String headerName) {
  return header.substring(strlen(headerName.c_str()));
}

// OTA Logic 
void execOTA(String newVersion) {
  WiFiClientSecure client;
  int contentLength = 0;
  bool isValidContentType = false;

  // Google Storage Bucket Config
  String host = "storage.googleapis.com"; // Host => storage.googleapis.com
  int port = 443; // Non https. For HTTPS 443. As of today, HTTPS doesn't work.
  String bin = "/plantcare-firmware/v" + String(newVersion)  + "/firmware.bin"; // bin file name with a slash in front.
  client.setCACert(ota_root_ca);
  Serial.println("Connecting to: " + String(host));
  // Connect to Google Storage
  if (client.connect(host.c_str(), port)) {
    // Connection Succeed.
    // Fecthing the bin
    Serial.println("Fetching Bin: " + String(bin));

    // Get the contents of the bin file
    client.print(String("GET ") + bin + " HTTP/1.1\r\n" +
                 "Host: " + host + "\r\n" +
                 "Cache-Control: no-cache\r\n" +
                 "Connection: close\r\n\r\n");

    unsigned long timeout = millis();
    while (client.available() == 0) {
      if (millis() - timeout > 10000) {
        Serial.println("Client Timeout !");
        client.stop();
        return;
      }
    }
    // Once the response is available,
    // check stuff

    while (client.available()) {
      // read line till /n
      String line = client.readStringUntil('\n');
            
      // remove space, to check if the line is end of headers
      line.trim();

      // if the the line is empty,
      // this is end of headers
      // break the while and feed the
      // remaining `client` to the
      // Update.writeStream();
      if (!line.length()) {
        //headers ended
        break; // and get the OTA started
      }

      // Check if the HTTP Response is 200
      // else break and Exit Update
      if (line.startsWith("HTTP/1.1")) {
        if (line.indexOf("200") < 0) {
          Serial.println("Got a non 200 status code from server. Exiting OTA Update.");
          break;
        }
      }

      // extract headers here
      // Start with content length
      if (line.startsWith("Content-Length: ")) {
        contentLength = atoi((getHeaderValue(line, "Content-Length: ")).c_str());
        Serial.println("Got " + String(contentLength) + " bytes from server");
      }

      // Next, the content type
      if (line.startsWith("Content-Type: ")) {
        String contentType = getHeaderValue(line, "Content-Type: ");
        Serial.println("Got " + contentType + " payload.");
        if (contentType == "application/octet-stream") {
          isValidContentType = true;
        }
      }
    }
  } else {
    // Connect to Google failed
    // Could be network problems 
    Serial.println("Connection to " + String(host) + " failed. Please check your setup");
    // retry?
    // execOTA();
  }

  // Check what is the contentLength and if content type is `application/octet-stream`
  Serial.println("contentLength : " + String(contentLength) + ", isValidContentType : " + String(isValidContentType));

  // check contentLength and content type
  if (contentLength && isValidContentType) {
    // Check if there is enough to OTA Update
    bool canBegin = Update.begin(contentLength);

    // If yes, begin
    if (canBegin) {
      Serial.println("Begin OTA. This may take 2 - 5 mins to complete. Things might be quite for a while.. Patience!");
      // No activity would appear on the Serial monitor
      // So be patient. This may take 2 - 5mins to complete
      size_t written = Update.writeStream(client);

      if (written == contentLength) {
        Serial.println("Written : " + String(written) + " successfully");
      } else {
        Serial.println("Written only : " + String(written) + "/" + String(contentLength) + ". Retry?" );
        // retry??
        // execOTA();
      }

      if (Update.end()) {
        Serial.println("OTA done!");
        if (Update.isFinished()) {
          Serial.println("Update successfully completed. Rebooting.");
          ESP.restart();
        } else {
          Serial.println("Update not finished? Something went wrong!");
        }
      } else {
        Serial.println("Error Occurred. Error #: " + String(Update.getError()));
      }
    } else {
      // not enough space to begin OTA
      // Understand the partitions and
      // space availability
      Serial.println("Not enough space to begin OTA");
      client.flush();
    }
  } else {
    Serial.println("There was no content in the response");
    client.flush();
  }
}

This function simply calls an HTTPS GET request to get the content and length of the new firmware binary to download. If checks are ok, the download is executed.

int versionCompare(String v1, String v2) 
{ 
    //  vnum stores each numeric part of version 
    int vnum1 = 0, vnum2 = 0; 
  
    //  loop untill both string are processed 
    for (int i=0,j=0; (i<v1.length() || j<v2.length()); ) 
    { 
        //  storing numeric part of version 1 in vnum1 
        while (i < v1.length() && v1[i] != '.') 
        { 
            vnum1 = vnum1 * 10 + (v1[i] - '0'); 
            i++; 
        } 
  
        //  storing numeric part of version 2 in vnum2 
        while (j < v2.length() && v2[j] != '.') 
        { 
            vnum2 = vnum2 * 10 + (v2[j] - '0'); 
            j++; 
        } 
  
        if (vnum1 > vnum2) 
            return 1; 
        if (vnum2 > vnum1) 
            return -1; 
  
        //  if equal, reset variables and go for next numeric 
        // part 
        vnum1 = vnum2 = 0; 
        i++; 
        j++; 
    } 
    return 0; 
} 

// This handles incoming mqtt messages
void messageReceived(String &topic, String &payload) {
  Serial.println("incoming: " + topic + " - " + payload);
  String onlineVersion = payload; 
  // Compare versions
  if (versionCompare(VERSION, onlineVersion) < 0) {
      DEBUG_MSG("New Version available. Starting Update.");
      execOTA(onlineVersion);
  }
  else if (versionCompare(VERSION, onlineVersion) > 0) 
      DEBUG_MSG("This version is newer than online version.");
  else
      DEBUG_MSG("Firmware up to date!");
}

String getJwt() {
  iss = time(nullptr);
  DEBUG_MSG("Refreshing JWT");
  jwt = device->createJWT(iss, jwt_exp_secs);
  return jwt;
}

Here we have a few helper functions. Nothing special, but it surprised me, that comparing version numbers is such a hustle.


bool initClients()
{
  device = new CloudIoTCoreDevice(project_id, location, registry_id,
                                  device_id, private_key_str);
  mqttClient = new MQTTClient(512);
  mqttClient->setOptions(180, true, 1000); // keepAlive, cleanSession, timeout
  netClient = new WiFiClientSecure();
  netClient->setCACert (root_cert_lts);
  mqtt = new CloudIoTCoreMqtt(mqttClient, netClient, device);
  mqtt->setUseLts(true);
  mqtt->startMQTT();

  DEBUG_MSG("Connecting to " + String(CLOUD_IOT_CORE_MQTT_HOST_LTS));
  
  // Establish LTS Connection
  int ret = netClient->connect(CLOUD_IOT_CORE_MQTT_HOST_LTS, (uint16_t) CLOUD_IOT_CORE_MQTT_PORT);
  if(ret == 0 && netClient->lastError(nullptr, 0) == -9984){
    DEBUG_MSG("Problem with Root CA. Trying backup.");
    netClient->setCACert(root_cert_lts_backup);
  }
  ret = netClient->connect(CLOUD_IOT_CORE_MQTT_HOST_LTS, (uint16_t) CLOUD_IOT_CORE_MQTT_PORT);
  if(ret == 0 && netClient->lastError(nullptr, 0) == -9984){
    DEBUG_MSG("Again problem with Root CA. Cancel.");
    return false;
  }
  if (ret == 0) {
    DEBUG_MSG("Problem with WIFIClientSecure. Error Code: " + String(ret));
    return false;
  }

  mqtt->mqttConnect(true);

  lastDisconnect = millis();

  return true;
}

bool reinitClients()
{
  delete (mqttClient);
  delete (netClient);
  delete (device);
  delete (mqtt);
  bool res = initClients();
  return res;
}

Here I initialize a client for the MQTT protocol and a client for networking. The issue here is security. LTS is used so we need a root CA.

/*
Method to sync time with ntp server
*/
time_t sync_time(){
    // Timesync
    DEBUG_MSG("Timesync started. uptime_Timesync_init:" + DEBUG_UPTIME);
    configTime(0, 0, "time.google.com", "pool.ntp.org", "time.nist.gov");
    DEBUG_MSG("Waiting on time sync...");
    while (time(nullptr) < 1510644967)
    {
      delay(10);
    }
    time_t now = time(nullptr);
    DEBUG_MSG("Timesync done. Time: " + String(ctime(&now)) + " uptime_Timesync_done:" + DEBUG_UPTIME);
    return now;
}

This seems small, but is a big issue. Acurate time is important for security protocolls and the sync itself took pretty long and I couldn’t figure out why exactly that is. My guess is, that time sync protocolls, work with convergence somehow.


/*
Method to print the reason by which ESP32
has been awaken from sleep
*/
void print_wakeup_reason()
{
  wakeup_reason = esp_sleep_get_wakeup_cause();

  switch (wakeup_reason)
  {
  case ESP_SLEEP_WAKEUP_UNDEFINED:
    DEBUG_MSG("Reset was not caused by waking up from deep sleep.");
    break;
  case ESP_SLEEP_WAKEUP_ALL:
    DEBUG_MSG("Not a wakeup cause, used to disable all wakeup sources with esp_sleep_disable_wakeup_source. ");
    break;
  case ESP_SLEEP_WAKEUP_EXT0:
    DEBUG_MSG("Wakeup caused by external signal using RTC_IO");
    break;
  case ESP_SLEEP_WAKEUP_EXT1:
    DEBUG_MSG("Wakeup caused by external signal using RTC_CNTL");
    break;
  case ESP_SLEEP_WAKEUP_TIMER:
    DEBUG_MSG("Wakeup caused by timer");
    break;
  case ESP_SLEEP_WAKEUP_TOUCHPAD:
    DEBUG_MSG("Wakeup caused by touchpad");
    break;
  case ESP_SLEEP_WAKEUP_ULP:
    DEBUG_MSG("Wakeup caused by ULP program");
    break;
  default:
    DEBUG_MSG("Wakeup cause unknown");
    break;
  }
}

void printLocalTime()
{
  struct tm timeinfo;
  if(!getLocalTime(&timeinfo)){
    log_d("Failed to obtain time");
    return;
  }
  log_d("bootuptime_readable: ");
  if(DEBUG_ENABLE) Serial.println(&timeinfo, "%A, %B %d %Y %H:%M:%S");
}

void dns(){

  IPAddress ServerIP;
  const char mqttServerName[] = CLOUD_IOT_CORE_MQTT_HOST_LTS;
  DEBUG_MSG("Trying to solve Hostname " + String(mqttServerName));     
  WiFi.hostByName(mqttServerName, ServerIP);
  DEBUG_MSG("IP: " + ServerIP.toString());
}

Here we have again a few helper functions. I want to mention the dns() function, which simply tries to resolve the hostname of the mqtt server. This also took quite long on the ESP, which surprised me a little. Now we are where the fun begins. setup() is the main function of any android based code. I will split up this funtion here because there is a lot going on.

void setup()
{

  // put your setup code here, to run once:
  if(DEBUG_ENABLE) Serial.begin(115200);

  //Increment boot number and print it every reboot
  time_t bootuptime;
  time(&bootuptime);
  DEBUG_MSG("Initialization done. Boot number: " + String(bootCount) +" bootupime: "+bootuptime+ " uptime0:" + DEBUG_UPTIME);
  bootCount++;
  DEBUG_MSG("Firmware Version: " + String(VERSION));
  if(DEBUG_ENABLE) printLocalTime();

  //Print the wakeup reason for ESP32
  print_wakeup_reason();
  

Just a few wake up messages. This is important, because we want to know, why it woke up.

  // Setup Sensor channel
   touch_pad_init();

  // Setup Voltage channel
  adc1_config_width(ADC_WIDTH_BIT_12);
  adc1_config_channel_atten(ADC1_CHANNEL_3, ADC_ATTEN_DB_11);

  // WiFi
  DEBUG_MSG("Connecting to WiFi ... Status:" + String(WiFi.status()) + " uptime_WiFi_init:" + DEBUG_UPTIME);
  DEBUG_MSG("SSID:" + String(ssid) + " pw:" + String(password));
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  int cnt = 0;
  while (WiFi.status() != WL_CONNECTED)
  {
        delay(500);
    Serial.print(".");
    if (cnt++ >= 10)
    {
      WiFi.beginSmartConfig();
      while (1)
      {
        delay(1000);
        if (WiFi.smartConfigDone())
        {
          Serial.println("SmartConfig Success");
          strncpy( ssid, WiFi.SSID().c_str(), WiFi.SSID().length() );
          ssid[WiFi.SSID().length()] = 0;
          strncpy( password, WiFi.psk().c_str(), WiFi.psk().length() );
          password[WiFi.psk().length() ] = 0;
          Serial.println(ssid);
          Serial.println(password);
          break;
        }
        else
        {
          Serial.println("connecting...");
        }
      }
    }
  }
  DEBUG_MSG("Connected to WiFi. uptime_WiFi_done:" + DEBUG_UPTIME);

Here the wifi connection is established. This worked really well.

  // DNS 
  DEBUG_MSG("Resolving hostname. uptime_DNS_init:" + DEBUG_UPTIME);
  dns();
  DEBUG_MSG("Got IP Adress. uptime_DNS_done:" + DEBUG_UPTIME);

I decided to seperate DNS, because it took a long time during booting.


  if(wakeup_reason == ESP_SLEEP_WAKEUP_UNDEFINED)
  {
    DEBUG_MSG("Boot up caused by reset. No Deep Sleep. Syncing time.");
    sync_time();
  }

Here I checked wether boot up was caused by pushing a button on the device itself.


  // Init and Connect MQTT
  DEBUG_MSG("Init MQTT started. uptime_MQTT_init_init:" + DEBUG_UPTIME);
  bool res = initClients();
  // Only do this, when initialization is successful.   
  if (res) {
    DEBUG_MSG("Init MQTT started. uptime_MQTT_init_done:" + DEBUG_UPTIME);
    DEBUG_MSG("Init MQTT started. uptime_MQTT_init:" + DEBUG_UPTIME);
    int loopReturn = mqttClient->loop();
    lwmqtt_return_code_t returnCode = mqttClient->returnCode();
    int reconnect_count = 0;
    if(returnCode == LWMQTT_NOT_AUTHORIZED)
    {
      DEBUG_MSG("MQTT Bad Client ID. Maybe time out of sync. Syncing time.");
      time_t ntp = sync_time();
      DEBUG_MSG("Time was off by [bootup-ntp]:" + String(bootuptime-ntp));
    }
    if(returnCode == LWMQTT_BAD_USERNAME_OR_PASSWORD)
    {
      DEBUG_MSG("MQTT Bad Credentials. Maybe time out of sync. Syncing time.");
      time_t ntp = sync_time();
      DEBUG_MSG("Time was off by [bootup-ntp]:" + String(bootuptime-ntp));
    }

Many problems were caused by an off system clock. It is a good thing, this is failing. Otherwise this could be exploited.

    while (returnCode != LWMQTT_CONNECTION_ACCEPTED)
    {
      DEBUG_MSG("Reinitiating clients. Count: " + String(reconnect_count));
      reinitClients();
      loopReturn = mqttClient->loop();
      reconnect_count++;
    }
    DEBUG_MSG("Init and Connect MQTT done. uptime_MQTT_done:" + DEBUG_UPTIME);

Sometimes it helps to just reinitialize everything.

    // Read sensor data and pack msg
    DEBUG_MSG("Read Sensor data started. uptime_sens_init:" + DEBUG_UPTIME);
    time_t nownow;
    time(&nownow);
    sensfs = touchRead(T9);
    sensfm = touchRead(T2);
    sensfl = touchRead(T4);
    sensb = touchRead(T8);
    volt = adc1_get_raw(ADC1_CHANNEL_3);
    esp_wifi_get_max_tx_power(&power);

    String payload = String("{\"moistfs\":") + sensfs +
                    String(",\"moistfm\":") + sensfm +
                    String(",\"moistfl\":") + sensfl +
                    String(",\"moistb\":") + sensb +
                    String(",\"voltage\":") + volt +
                    String(",\"wifi_power\":") + power +
                    String(",\"uptime\":") + uptime +
                    String(",\"timestamp\":") + nownow +
                    String(",\"wakeup_reason\":") + (int)wakeup_reason +
                    String(",\"bootCount\":") + bootCount +
                    String("}");
    DEBUG_MSG("Read Sensor data done uptime_sens_done:" + DEBUG_UPTIME);

The payload is just a long string, with all the sonsor data packed in it.

    // Sending MQTT message
    DEBUG_MSG("Sending MQTT message started. uptime_send_init:" + DEBUG_UPTIME);
    DEBUG_MSG(String("Sending String:") + payload);
    mqtt->publishTelemetry(payload);
    //DEBUG_MSG("Message Pubished. MQTT Client State: " + client->printmqttstate(client->getMqttState()) + " uptime_send_done:" + DEBUG_UPTIME);

Here the data is send to the google cloud mqtt interface.

    // Disconecting
    DEBUG_MSG("Disconnecting started. uptime_disc_init:" + DEBUG_UPTIME);
    mqttClient->disconnect();

  } else {
    DEBUG_MSG("Init Failed. Going to sleep again.");
  }
  /*
  First we configure the wake up source
  We set our ESP32 to wake up every 5 seconds
  */
  esp_sleep_enable_timer_wakeup(TIME_TO_SLEEP * uS_TO_S_FACTOR);
  DEBUG_MSG("Setup ESP32 to sleep for " + String(TIME_TO_SLEEP) +
            " Seconds");

  /*
  Next we decide what all peripherals to shut down/keep on
  By default, ESP32 will automatically power down the peripherals
  not needed by the wakeup source, but if you want to be a poweruser
  this is for you. Read in detail at the API docs
  http://esp-idf.readthedocs.io/en/latest/api-reference/system/deep_sleep.html
  Left the line commented as an example of how to configure peripherals.
  The line below turns off all RTC peripherals in deep sleep.
  */
  //esp_deep_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_OFF);
  //DEBUG_MSG("Configured all RTC Peripherals to be powered down in sleep");

  /*
  Now that we have setup a wake cause and if needed setup the
  peripherals state in deep sleep, we can now start going to
  deep sleep.
  In the case that no wake up sources were provided but deep
  sleep was started, it will sleep forever unless hardware
  reset occurs.
  */

  DEBUG_MSG("Going to sleep now. uptime_sleep:" + DEBUG_UPTIME);
  DEBUG_MSG("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n");
  Serial.flush();

  // For DEBUGGING:
  //delay(18000);

  uptime = (int32_t)esp_timer_get_time() / 1000;

  esp_deep_sleep_start();
  DEBUG_MSG("This will never be printed");
}

This initialises a device specific feature. It can go into deep sleep for some time and wake up based on different causes.

This code is now compiled inside a docker container running in google cloud. A push to github triggers the following simple cloudbuild script, which copies the binary into a new directory for each version.

steps:
- name: 'gcr.io/plantcare01-223216/quickstart-image:tag1'  
artifacts: 
  objects: 
    location: 'gs://plantcare-firmware/$TAG_NAME'
    paths: ['.pio/build/esp32dev/firmware.bin']