Arduino Low Power - Creating a LoRa IoT Node That Runs off Batteries
2019-08-19 | By ShawnHymel
License: Attribution Arduino Raspberry Pi
Previously, we looked at creating a simple breadboard prototype with an Arduino to sample from a BME280 temperature, humidity, and pressure sensor and send that data over a LoRa radio to a Raspberry Pi. Check out that tutorial here.
In my "Arduino Project to Product" video series, I talk about what steps you should take to lower the power consumption of the whole system so that you could, in theory, run off 2x AAA batteries for 1 year. Based on our initial calculations from the second episode, we found that we should average 100 μA to accomplish this goal.
After systematically putting the RFM95, BME280, and 328p to sleep, we are finally able to calculate how long we need to sleep between sample-and-transmit sessions. You can also watch me go over these calculations in video form here:
Burning Fuses
Connect your bare 328p to an Arduino as shown:
In the Arduino software, go to File > Preferences, and add the following to Additional Board Manager URLs:
https://mcudude.github.io/MiniCore/package_MCUdude_MiniCore_index.json
In Tools > Board > Board Manager, add MiniCore to your boards.
From Tools, select the following attributes:
- Board: ATmega328p
- Clock: 1 MHz Internal
- BOD: Disabled
- Compiler LTO: LTO Disabled
- Variant: 328P / 328PA
- Bootloader: Yes (UART0)
- Port: <the serial port your computer assigned to the Arduino>
- Programmer: Arduino as ISP
Click Tools > Burn Bootloader. This will set the hardware fuses on your 328p and add a bootloader so that it acts like an Arduino.
Hardware Hookup
Remove the Arduino UNO (or other Arduino board that you used to upload the bootloader). Connect the 328p to the BME280 and RFM95 as shown:
Note that we are using a USB-to-Serial converter to upload new programs to the 328p.
Important! Once the program has been updated, you will want to remove the USB-to-Serial converter. Only then should you try to power your device off batteries.
The Code
Upload the following to your 328p:
/**
* LoRa Weather Client
*
* Author: Shawn Hymel
* Date: March 23, 2019
* Updated: July 25, 2019
*
* Transmits temperature, humidity, and pressure data over raw
* LoRa radio. Reads data from BME280 sensor and transmits with
* following packet:
*
* | 1B From Addr | 1B To Addr | 2B Temp | 2B Humd | 2B Pres |
*
* Note that temperature, humidity, and pressure values are
* scaled up by 10 and rounded to nearest integer before being
* sent. The server will need to scale received values by 1/10.
* This is to avoid sending full floating point values.
*
* Required libraries:
* - http://www.airspayce.com/mikem/arduino/RadioHead/ (v1.59)
* - https://github.com/adafruit/Adafruit_Sensor
* - https://github.com/adafruit/Adafruit_BME280_Library
*
* License: Beerware
*/
#include <Wire.h>
#include <SPI.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <RH_RF95.h>
#define DEBUG 0
// Parameters
const uint8_t LORA_NODE_ADDR = 0x01; // This node's address
const uint8_t LORA_SERVER_ADDR = 0x00; // LoRa receiving address
const int WAIT_TIME = 30000; // ms
const int TX_BUF_SIZE = 8; // Transmit buffer size
const float RFM_FREQ = 915.0; // Frequency for RFM95W
const int RFM_TX_POWER = 17; // 5..23 dBm, 13 dBm is default
const uint16_t CUTOFF_ADC = 505; // Cutoff voltage for device (1.9V)
const uint8_t WAKEUP_CYCLES = 7; // 8 Sleep cycles
// Pins
// SPI:
// MOSI = 11
// MISO = 12
// SCK = 13
const int RFM_RST_PIN = 2;
const int RFM_INT_PIN = 3;
const int RFM_CS_PIN = 4;
const int BME_CS_PIN = 10;
const int V_EN_PIN = 8;
const int V_DIV_PIN = A0;
// Wakeup counter
uint8_t wakeup_count = WAKEUP_CYCLES;
// Instance of radio driver over SPI
RH_RF95 rfm(RFM_CS_PIN, RFM_INT_PIN);
// Communicate with BME280 over SPI
Adafruit_BME280 bme(BME_CS_PIN);
// Transmit buffer
uint8_t tx_buf[TX_BUF_SIZE];
void setup() {
#if DEBUG
Serial.begin(9600);
#endif
// Voltage divider enable
pinMode(V_EN_PIN, OUTPUT);
// Disable ADC (must be before writing to PRR or ADC will be stuck on)
ADCSRA = 0;
// Disable power to I2C, TIM2, TIM1, and ADC
PRR = (1 << PRTWI) | // TWI (I2C)
(1 << PRTIM2) | // Timer/Counter2
(1 << PRTIM1) | // Timer/Counter1
(1 << PRADC); // ADC*/
// Initialize BME280
if ( !bme.begin() ) {
#if DEBUG
Serial.println("Could not find BME280 on SPI bus");
#endif
while(1);
}
#if DEBUG
Serial.println("BME280 initialized");
#endif
// Manually reset RFM95W
pinMode(RFM_RST_PIN, OUTPUT);
digitalWrite(RFM_RST_PIN, HIGH);
delay(100);
digitalWrite(RFM_RST_PIN, LOW);
delay(10);
digitalWrite(RFM_RST_PIN, HIGH);
delay(10);
// Initialize RFM95W
if ( !rfm.init() ) {
#if DEBUG
Serial.println("Could not initialize RFM95");
#endif
while(1);
}
#if DEBUG
Serial.println("RFM95 initialized");
#endif
// Set RFM95W frequency
if ( !rfm.setFrequency(RFM_FREQ) ) {
#if DEBUG
Serial.println("Could not set frequency on RFM95");
#endif
while(1);
}
#if DEBUG
Serial.print("RFM95 frequency set to ");
Serial.print(RFM_FREQ);
Serial.println(" MHz");
#endif
// Set RFM95W transmit power from PA_BOOST pin
rfm.setTxPower(RFM_TX_POWER, false);
// Set BME280 parameters for low power, forced mode
bme.setSampling(bme.MODE_FORCED,
bme.SAMPLING_X1,
bme.SAMPLING_X1,
bme.SAMPLING_X1,
bme.FILTER_OFF);
}
void loop() {
// Check counter
wakeup_count++;
if ( wakeup_count > WAKEUP_CYCLES ) {
wakeup_count = 0;
// Turn power on to ADC
PRR &= ~(1 << PRADC);
// Enable ADC
ADCSRA |= (1 << ADEN);
// Discard first ADC reading
analogRead(V_DIV_PIN);
// Only take measurements and transmit if over cutoff voltage
digitalWrite(V_EN_PIN, HIGH);
uint16_t v_batt = analogRead(V_DIV_PIN);
digitalWrite(V_EN_PIN, LOW);
if ( v_batt > CUTOFF_ADC ) {
// Perform forced measurement, then go back to sleep
bme.takeForcedMeasurement();
// Read data
float temp = bme.readTemperature();
float humd = bme.readHumidity();
float pres = bme.readPressure() / 100.0;
// Scale (x10) and round data
int16_t tempt = (int16_t)((temp * 10.0) + 0.5);
int16_t humdt = (int16_t)((humd * 10.0) + 0.5);
int16_t prest = (int16_t)((pres * 10.0) + 0.5);
#if DEBUG
Serial.print("Temperature: ");
Serial.print(temp, 1);
Serial.println(" C");
Serial.print("Humidity: ");
Serial.print(humd, 1);
Serial.println("%");
Serial.print("Pressure: ");
Serial.print(pres, 1);
Serial.println(" hPa");
#endif
// Stuff buffer
tx_buf[0] = LORA_NODE_ADDR; // From address (this node) [1 byte]
tx_buf[1] = LORA_SERVER_ADDR; // To address (server) [1 byte]
tx_buf[2] = (0xff & tempt); // Temperature [2 bytes] little-endian
tx_buf[3] = (0xff & (tempt >> 8));
tx_buf[4] = (0xff & humdt); // Humidity [2 bytes] little-endian
tx_buf[5] = (0xff & (humdt >> 8));
tx_buf[6] = (0xff & prest); // Pressure [2 bytes] little-endian
tx_buf[7] = (0xff & (prest >> 8));
#if DEBUG
Serial.print("Sending buffer:");
for ( int i = 0; i < TX_BUF_SIZE; i++) {
Serial.print(" 0x");
Serial.print(tx_buf[i], HEX);
}
Serial.println();
Serial.println();
#endif
// Send data to server
rfm.send(tx_buf, TX_BUF_SIZE);
rfm.waitPacketSent();
}
// Disable ADC (must be before writing to PRR or ADC will be stuck on)
ADCSRA = 0;
// Disable power to ADC
PRR |= (1 << PRADC);
// Put RFM95 to sleep
rfm.sleep();
}
// Put 328p to sleep
goToSleep();
}
// Interrupt Service Routine (Watchdog Timer)
ISR(WDT_vect) {
// Disable Watchdog Timer
asm("wdr"); // Reset WDT
WDTCSR |= (1 << WDCE) | (1 << WDE); // Special operation to change WDT config
WDTCSR = 0x00; // Turn off WDT
}
// Set the processor to power-down sleep mode
void goToSleep() {
// Disable interrupts while we configure sleep
asm("cli");
// Configure Watchdog Timer
uint8_t wdt_timeout = (1 << WDP3) | (1 << WDP0); // 8.0 s timeout
asm("wdr"); // Reset WDT
WDTCSR |= (1 << WDCE) | (1 << WDE); // Special operation to change WDT config
WDTCSR = (1 << WDIE) | wdt_timeout; // Enable WDT interrupts, set timeout
// Sleep sequence (call right before sleeping)
SMCR |= (1 << SM1); // Power-down sleep mode
SMCR |= (1 << SE); // Enable sleep
// Re-enable interrupts and call sleep instruction
asm("sei"); // Enable interrupts
asm("sleep"); // Go to sleep
// -> Wake up here <-
// Disable sleeping as a precaution
SMCR &= ~(1 << SE); // Disable sleep
}
Note that we are making direct register reads and writes throughout the code along with some assembly calls. The reasons for such low-level code can be found throughout the Project to Product Video Series.
If you would like to test receiving data from the 328p and LoRa radio, you will need to build a LoRa receiver. The hardware connections and Python code to make this happen on a Raspberry Pi can be found in the Arduino LoRa Weather Sensor tutorial.
When you run the code, you should see temperature, humidity, and pressure data show up on your receiver.
Measuring Battery Voltage
In previous episodes, we found that as the battery voltage dipped below 1.9 V, the low-dropout (LDO) regulator would dip below 1.8 V, causing the 328p to reset (thanks to the brown-out detection we enabled). The 328p would continue to reset continuously while sending out (potentially corrupted) data over the LoRa radio. To prevent this, we disable the brown-out detection completely, take an analog reading of the battery voltage, and only transmit if that voltage is over an acceptable level (we’ll say 1.9 V).
An LDO is a linear regulator, which means that it can only output a lower voltage than its input. As a result, the battery voltage will always be higher than the operating voltage of the Arduino. Additionally, the battery voltage will slowly decay over time, leaving us without a good reference voltage to make analog-to-digital (ADC) readings.
To combat this, we construct a voltage divider circuit to lower the raw battery voltage by about one half. To prevent the voltage divider from constantly draining current (even during sleep cycles), we use a simple NPN-PNP transistor switch to enable the divider only when we are about to sample the battery voltage. It requires a few extra components and another Arduino pin, but that’s OK.
Credit for this circuit goes to James Whong from Mooshimeter and James Lewis from Bald Engineer.
You can see how we connected this circuit to the 328p in the Hardware Hookup section above.
Measuring Current Consumption
In episode 1 of the Project to Product series, we talk about measuring current using a simple shunt resistor inline with the return bath back to our power supply (or batteries). With a 1 Ω shunt resistor, we can use an oscilloscope measure how much current is being drawn by the project during sensor reading and transmitting:
The sleep and tiny wakeup currents being drawn are much tougher to measure, as we hit the limit of our scope. To measure those, we replace the low-side shunt resistor with the high-side INA212 current sense circuit we developed in episode 7. Note that we start with a 1 Ω shunt resistor for our current sense circuit.
Even with the voltage multiplier, we are unable to measure the current draw when all our components (328p, RFM95, ADP171) are put to sleep. To accomplish that, we replace the 1 Ω resistor with a 10 Ω resistor. This gives us another order of magnitude of voltage multiplication in our measurements. Just note that your supply voltage should be 2.9-3.0 V to make this measurement, as 0.1 A (during transmit) across the 10 Ω resistor can result in a 1 V drop.
With these numbers, we can construct a basic diagram of how the current consumption looks across time:
Calculating Total Required Sleep Time
To calculate the number of times we need to sleep (in 8 second increments), we start by calculating the average current draw of the transmit (xmit) period. We find that the total transmit time is around 73.6 ms. By multiplying the proportion of time by the measured current draw in each part, we see that the average current draw of the whole period is around 53.6 mA.
We perform the same steps for the sleep period. We see that the wakeup periods (to increment the counter) have little effect on the sleep current, which we calculate to be around 38.2 μA.
From there, we need to figure out what percentage of the total cycle (transmit and sleep periods) we're allowed to transmit in order to have a total average current equal to 0.1 mA. We call that transmit percentage p. We find that p must be equal to or less than 0.115%.
Finally, we use the calculated p to find our percentage of time that we need to sleep. If p is 0.115%, we see that the total cycle time must be 64,000 ms. We subtract out the transmit time (73.6 ms) to find our required sleep time: 63,926.4 ms.
Since we sleep in increments of 8 s, we see that we must sleep for 7.99 cycles. We can go over that amount but not below it, so we round up to 8 cycles. We plug those values back in to our average current equation to find our total expected current draw (averaged over time).
From this, you can see that we expect to average around 0.1 mA if we wake up to take a measurement and transmit every 64 s. If we sleep for longer, we can obviously get more battery life.
Conclusion
From our math above, you can see that we need to sleep around 64 seconds between sample-and-transmit bursts in order to average 0.1 mA. We’ve set the Watchdog Timer timeout in our code to sleep as long as possible, which is 8 seconds in the 328p. From this, we use a simple global counter variable to count out 8 sleep cycles before waking up to sample and transmit. It costs us a tiny amount of power to wake up and increment a counter, but it’s the best we have on the 328p.
By averaging 0.1 mA, we should, in theory, last for 1 year on 2x AAA batteries arranged in series.
Resources and Going Further
The code and breakout board designs (in KiCad) can be found in this repository: https://github.com/ShawnHymel/lora-weather
The Arduino Project to Product video series can be found here: https://www.youtube.com/playlist?list=PLEBQazB0HUySNug4eRm-73hNyMcCRViRB
While this wraps up the Project to Product series, you can easily take this project further in many ways. The first step would be creating a printed circuit board (PCB) to house all the components. I recommend checking out this video series, if you’d like to learn KiCad. If you plan to put your device in an enclosure and sell it in the United States, you will need FCC certification. Finally, you would need to consider manufacturing it, either on your own or with a contract manufacturer.
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.
Visit TechForum