Greenhouse ambient conditions with Ruuvi, ESP32 and Google sheets

Hi all,

I would like to show you my first project with an ESP32. This year, I have built a garden shed with a small greenhouse attached to it:


I thought, it would be nice to somehow see how warm or cold it gets over the year. As I can get Ruuvi tags for free from work, I was looking for a convenient way to read their data without having to walk out with my phone and then learned about ESP32 microcontrollers.
Here is the finished device in it's place:

The hardware is wired as follows - I am using a 5 Ah LiPo battery that I also got from company trash, which I can charge via a TP4056 charging module/USB C. The ESP32 itself is powered by a Pololu 3.3V voltage regulator:


I can check on the battery level by measuring the battery voltage on an analog IN over a voltage divider on the ESP32.

The wiring is a bit bonkers, but it works:

There are two Ruuvi tags - one inside and one just outside the greenhouse. The ESP32 is scanning BLE until both Ruuvi's data is received, then sends the raw Hex strings over my WiFi to a google sheet. Then it deep sleeps for 15 minutes. Here is the code:

#include <HTTPClient.h> // version 2.2.0
#include <WiFi.h>
#include <NimBLEDevice.h> // version 1.4.3
#include <esp_sleep.h>

#define uS_TO_S_FACTOR 1000000  // Conversion factor for micro seconds to seconds
#define TIME_TO_SLEEP  890       // Time ESP32 will go to sleep (in seconds) - 15 minutes minus the average runtime.

HTTPClient http; // Declare HTTPClient object globally
BLEScan* pBLEScan; 


const int analogPin = 32; // ADC pin
int adcValue;

const char* ssid = "SSID";
const char* password = "PASSWORD";

String googleURL = "https://script.google.com/macros/s/AKfycbxxxxxxxMIfZjTdIYfxw46fAEJ5PWxxxxxxx/exec?";
String googleURL_with_parameters = "";

const char* MAC_inside_sensor = "E8:B9:9A:E5:54:BE";
const char* MAC_outside_sensor = "CE:6A:CC:E0:7E:1E";
char HEX_inside_sensor[128] = "";
char HEX_outside_sensor[128] = "";
char macAddress[128] = "";

int BLEscanTime = 15; // In seconds. Long scan time will be stopped once both sensors are found.

// Function to convert a string to uppercase. Used for MAC Addresses and Data of Ruuvi sensors.
void toUpperCase(char* str) {
    for (int i = 0; str[i]; i++) {
        str[i] = toupper(str[i]);
    }
}

void connectToWiFi() {
    delay(1000); //before setting up WiFi
    WiFi.disconnect();
    delay(1000);
    WiFi.begin(ssid, password);
    unsigned long startAttemptTime = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - startAttemptTime < 10000) { // 10 seconds timeout
        delay(1000);
        Serial.println("Connecting to WiFi...");
    }
    if (WiFi.status() == WL_CONNECTED) {
        Serial.println("Connected to WiFi");
    } else {
        Serial.println("Failed to connect to WiFi. Reconnecting...");
        // Retry mechanism
        for (int i = 0; i < 5; i++) {
            WiFi.begin(ssid, password);
            delay(2000);
            if (WiFi.status() == WL_CONNECTED) {
                Serial.println("Connected to WiFi");
                break;
            }
        }
        if (WiFi.status() != WL_CONNECTED) {
            Serial.println("Failed to connect to WiFi after multiple attempts.");
        }
    }
}

class MyAdvertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks {
    void onResult(BLEAdvertisedDevice* advertisedDevice) {
        strncpy(macAddress, advertisedDevice->getAddress().toString().c_str(), sizeof(macAddress) - 1);
        macAddress[sizeof(macAddress) - 1] = '\0'; // Ensure null-termination
        toUpperCase(macAddress);
        if (strcmp(MAC_inside_sensor, macAddress) == 0 && HEX_inside_sensor[0] == '\0') {
            char* hexData = BLEUtils::buildHexData(nullptr, (uint8_t*)advertisedDevice->getManufacturerData().c_str(), advertisedDevice->getManufacturerData().length());
            snprintf(HEX_inside_sensor, sizeof(HEX_inside_sensor), "%s", hexData);
            free(hexData); // Free the allocated memory
            toUpperCase(HEX_inside_sensor);
            Serial.println("Found inside sensor: " + String(macAddress));
        }
        if (strcmp(MAC_outside_sensor, macAddress) == 0 && HEX_outside_sensor[0] == '\0') {
            char* hexData = BLEUtils::buildHexData(nullptr, (uint8_t*)advertisedDevice->getManufacturerData().c_str(), advertisedDevice->getManufacturerData().length());
            snprintf(HEX_outside_sensor, sizeof(HEX_outside_sensor), "%s", hexData);
            free(hexData); // Free the allocated memory
            toUpperCase(HEX_outside_sensor);
            Serial.println("Found outside sensor: " + String(macAddress));
        }
        if (HEX_inside_sensor[0] != '\0' && HEX_outside_sensor[0] != '\0') {
            Serial.println("Found both sensors");
            pBLEScan->stop();
            Serial.println("STOPPING SCAN");
        }
    }
};

void transmitData() {
    if (HEX_inside_sensor[0] != '\0' && HEX_outside_sensor[0] != '\0'){
        googleURL_with_parameters = googleURL + "&HEXin=" + HEX_inside_sensor + "&HEXout=" + HEX_outside_sensor + "&SRCPOW=" + adcValue;
        Serial.println("URL: " + googleURL_with_parameters);
        Serial.println("Data transmission starting... Heap memory: " + String(esp_get_free_heap_size()) + " bytes");
        if (WiFi.status() == WL_CONNECTED) {
            http.begin(googleURL_with_parameters);
            int httpResponseCode = http.GET();
            if (httpResponseCode > 0) {
                String response = http.getString();
                Serial.println(httpResponseCode);
                //Serial.println(response);
            } else {
                Serial.print("Error on HTTP request: ");
                Serial.println(httpResponseCode);
                connectToWiFi();
                http.end();
                http.begin(googleURL_with_parameters);// try again if first try did not succeed.
            }
            http.end();
        } else {
            Serial.println("Error in WiFi connection. Re-initializing WiFi...");
            connectToWiFi();
        }
        HEX_inside_sensor[0] = '\0';
        HEX_outside_sensor[0] = '\0';
        Serial.println("Hex values resetted.");
        Serial.println("Data transmission done. Heap memory: " + String(esp_get_free_heap_size()) + " bytes");
    }
}

void setup() {
    Serial.begin(115200);
    delay(2000);
    // Configure wake up source
    esp_sleep_enable_timer_wakeup(TIME_TO_SLEEP * uS_TO_S_FACTOR);
    Serial.println("Setup ESP32 to sleep for every " + String(TIME_TO_SLEEP) + " Seconds");
    //read the analog pin for the battery voltage
    adcValue = analogRead(analogPin);
    Serial.println("ADC Value for PIN32: " + String(adcValue));
    //BLE
    BLEDevice::init("");
    pBLEScan = BLEDevice::getScan(); // create new scan
    pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
    pBLEScan->setActiveScan(true); // active scan uses more power, but gets results faster
    pBLEScan->setInterval(100);
    pBLEScan->setWindow(99);  // less or equal setInterval value
    connectToWiFi();
    http.setTimeout(20000); // Set timeout to 20 seconds
    while (HEX_inside_sensor[0] == '\0' || HEX_outside_sensor[0] == '\0'){
        Serial.println("STARTING NEW SCAN");
        BLEScanResults foundDevices = pBLEScan->start(BLEscanTime, false);
        pBLEScan->clearResults();  // delete results from BLEScan buffer to release memory
        delay(1000);
    }
    transmitData(); 
    Serial.println("Going to sleep now");
    esp_deep_sleep_start();
}

void loop() {
    //not needed here
}

The raw Hex values get converted and processed by a google apps script:

function doGet(e) {
  var dataSheetName = 'data';
  var sheet = SpreadsheetApp.openById('1kZG9-848u-M0Ixxxxx').getSheetByName(dataSheetName);
  
  // Extract and log each parameter
  var in_HEX = e.parameter.HEXin || "IN not found";
  var out_HEX = e.parameter.HEXout || "OUT not found";
  var batteryRaw = e.parameter.SRCPOW || "BATTERY not found";

  // Append the data to the sheet
  var in_data = decodeRuuvi(in_HEX);
  var out_data = decodeRuuvi(out_HEX);
  var batteryVoltage = convertPower(batteryRaw);
  // Insert a new row at position 4
  sheet.insertRowBefore(4);

  sheet.getRange(4, 1, 1, 12).setValues([[new Date(), in_data.temp, in_data.hum, in_data.pressure, in_data.txpower, in_data.voltage,
                   out_data.temp, out_data.hum, out_data.pressure, out_data.txpower, out_data.voltage, batteryVoltage]]);

  return ContentService.createTextOutput('Success');
}

function convertPower(rawValue){
  var correctionFactor = 1.3628; //derived from the voltage divider with R1=32,6k and R2=11,86k
  var convertedValue = 3.3 * rawValue / 4095.0 * correctionFactor;
  return convertedValue;
}

function decodeRuuvi(hex_data) {
  var data = {}; // Create an object to hold the decoded data

  if (hex_data.substring(4, 6) == "05") { // Check if the data format is supported
    data.temp = decodeTemperature(hex_data.substring(6, 10)); // Decode temperature
    data.hum = hexadecimalToDecimal(hex_data.substring(10, 14)) * 0.0025; // Decode humidity
    data.pressure = hexadecimalToDecimal(hex_data.substring(14, 18)) * 1 + 50000; // Decode pressure

    data.ax = hexadecimalToDecimal(hex_data.substring(18, 22)); // Decode acceleration in X-axis
    data.ay = hexadecimalToDecimal(hex_data.substring(22, 26)); // Decode acceleration in Y-axis
    data.az = hexadecimalToDecimal(hex_data.substring(26, 30)); // Decode acceleration in Z-axis

    if (data.ax > 0xF000) { // Adjust for negative values in X-axis
      data.ax = data.ax - (1 << 16);
    }
    if (data.ay > 0xF000) { // Adjust for negative values in Y-axis
      data.ay = data.ay - (1 << 16);
    }
    if (data.az > 0xF000) { // Adjust for negative values in Z-axis
      data.az = data.az - (1 << 16);
    }

    var voltage_power = hexadecimalToDecimal(hex_data.substring(30, 34)); // Decode voltage and TX power

    var voltage_mask = 0b1111111111100000; // Mask for the first 11 bits (voltage)
    var voltage_bits = voltage_power & voltage_mask; // Apply mask to get voltage bits
    voltage_bits = voltage_bits >> 5; // Align the bits after masking
    data.voltage = voltage_bits + 1600; // Voltage in millivolts
    data.voltage = data.voltage / 1000.0; // Convert to volts

    var txpower_mask = 0b0000000000011111; // Mask for the last 5 bits (TX power)
    data.txpower = (voltage_power & txpower_mask) - 40; // TX power in dBm

  } else {
    Logger.log("Error: Unsupported data format"); // Log error if data format is unsupported
  }
  return data; // Return the decoded data
}

function decodeTemperature(hexVal) {
  var temp = hexadecimalToDecimal(hexVal);
  if (temp > 0x7FFF) { // Check if the MSB is set (indicating a negative value)
    temp = temp - (1 << 16); // Convert from two's complement to negative decimal
  }
  return temp * 0.005; // Apply the scaling factor
}

// Function to convert hexadecimal string to decimal
function hexadecimalToDecimal(hexVal) {
  var len = hexVal.length; // Get the length of the hexadecimal string
  var base = 1; // Initialize base value to 1 (16^0)
  var dec_val = 0; // Initialize decimal value to 0

  for (var i = len - 1; i >= 0; i--) { // Loop through each character of the hexadecimal string
    var char = hexVal[i];
    if (char >= '0' && char <= '9') { // If the character is a digit
      dec_val += (char.charCodeAt(0) - 48) * base; // Convert to decimal and add to dec_val
      base = base * 16; // Update base to next power of 16
    } else if (char >= 'A' && char <= 'F') { // If the character is a letter (A-F)
      dec_val += (char.charCodeAt(0) - 55) * base; // Convert to decimal and add to dec_val
      base = base * 16; // Update base to next power of 16
    }
  }
  return dec_val; // Return the decimal value
}

function lowVoltageWarning(){
  var dataSheetName = 'data';
  var sheet = SpreadsheetApp.openById('1kZG9-848u-M0I6bekcFO8ZPDLU0Ta8Cr_sLxEPXinOI').getSheetByName(dataSheetName);
  var cellValue = sheet.getRange("L4").getValue();
  
  if (cellValue < 3.5) {
      var emailAddress = "x@gmail.com"; // Replace with your email address
  var subject = "AMBIENT CONTROL Alert: Battery Voltage Below 3.5 V";
  var message = "The value in cell L4 on the 'data' sheet is below 3.5.";
  
  MailApp.sendEmail(emailAddress, subject, message);
  }
}

Then I am displaying the humidity and temperature for both in- and outside on a dashboard, day- and week-charts as well as a battery level chart:

I am happy how this all turned out for doing all that stuff for the first time. Here I would like give a big T H A N K Y O U ! ! ! to @kmin and @kenb4 for helping me to get this working!

regards
Jochen

3 Likes

Nice job!
What was the reason/benefit for using NimBLE instead of stock BLE?
Ps. Your voltage divider seems to be grounded on both ends on the scheme.

Hey kmin, if you recall the other thread: I had this strange memory leak with every cycle of the BLE connection with the standard BLE library. nimBLE just worked fine.
Now with using deep sleep, I could probably switch back to the standard BLE library, but why bother as long as it just works :slight_smile:

And yes, the scheme is wrong :expressionless:
The divider should connect to OUT+ instead of OUT-, the actual wiring of course is correct.

Thanks again :slight_smile:

This topic was automatically closed 180 days after the last reply. New replies are no longer allowed.