Objective and use case
What you’ll build: This project involves creating a low-power agricultural telemetry node using the TTGO LoRa32 (ESP32 + SX1276) along with BME280 and ADS1115 sensors. The node will monitor environmental conditions and transmit data over LoRaWAN.
Why it matters / Use cases
- Real-time monitoring of soil moisture levels to optimize irrigation schedules and conserve water resources.
- Tracking temperature and humidity for better crop management and yield prediction.
- Utilizing low-power modes to extend the operational lifespan of sensor nodes in remote agricultural fields.
- Implementing a scalable solution for farmers to monitor multiple fields simultaneously using LoRaWAN.
Expected outcome
- Data transmission intervals of 15 minutes with a battery life of over 6 months on a single charge.
- Measurement accuracy of ±1°C for temperature, ±3% for humidity, and ±0.1V for battery voltage.
- Successful uplink of sensor data with less than 2 seconds latency to The Things Stack.
- Ability to handle up to 100 packets per hour without data loss.
Audience: Advanced developers and engineers; Level: Advanced
Architecture/flow: The system architecture includes the TTGO LoRa32 microcontroller interfacing with BME280 and ADS1115 sensors via I2C, utilizing deep sleep modes for low power consumption, and sending data through LoRaWAN.
Advanced Hands-on Practical: LoRa Agro Telemetry with Low Power on TTGO LoRa32 (ESP32 + SX1276) + BME280 + ADS1115
Objective: lora-agro-telemetry-lowpower
This advanced, end-to-end project builds a low-power agricultural telemetry node using the EXACT device model “TTGO LoRa32 (ESP32 + SX1276) + BME280 + ADS1115.” It measures environmental conditions (temperature, humidity, pressure) and analog soil moisture/battery voltage, encodes the data as a compact binary payload, and transmits it over LoRaWAN (ABP mode) to The Things Stack (TTN). The firmware emphasizes low power via sensor sleep modes and ESP32 deep sleep.
The workflow uses PlatformIO for reproducibility, pin mapping for the TTGO LoRa32 v1, I2C for BME280/ADS1115, and an event-driven LoRaWAN uplink followed by deep sleep.
Prerequisites
- Proficiency level: Advanced (you should be comfortable with PlatformIO, embedded C++ on ESP32, and LoRaWAN basics).
- OS: Windows 10/11, macOS 12+, or Ubuntu 20.04+.
- PlatformIO Core (CLI) 6.1+ or PlatformIO IDE (VS Code). You will use CLI commands for deterministic builds.
- USB drivers:
- If your TTGO LoRa32 enumerates as CP210x (common): install CP210x USB-to-UART bridge drivers.
- Some clones may use CH340/CH34x: install WCH drivers.
- The Things Stack (TTN) account with an application and ABP device (DevAddr/NwkSKey/AppSKey).
- Basic soldering/wiring, multimeter for current measurement, and if possible, a bench supply with uA resolution.
- Region: This tutorial is set up for EU868. If you need US915 or others, adapt the LMIC region macros as noted.
Materials (with exact model)
- 1x TTGO LoRa32 (ESP32 + SX1276) board (a.k.a. TTGO LoRa32 v1)
- 1x BME280 temperature/humidity/pressure module (I2C, 0x76 or 0x77)
- 1x ADS1115 16-bit ADC module (I2C, default address 0x48)
- 1x Breadboard or terminal strip
- Jumper wires (male-female, male-male)
- Power source and battery (optional but recommended for field testing)
- Optional: Voltage divider (e.g., R1=100 kΩ, R2=100 kΩ) for battery voltage sensing to ADS1115 A1
- Soil moisture probe (analog output) connected to ADS1115 A0
Setup/Connection
We will use the ESP32’s default I2C pins (SDA=21, SCL=22). The LoRa radio (SX1276) is onboard and uses SPI with a standard TTGO pin map.
Notes on board:
– The TTGO LoRa32 v1 typically uses CP2102 USB-to-UART.
– SX1276 is onboard; you do not wire it separately.
– Some variants include an OLED; we will ignore it.
Wiring (text description):
– Connect BME280 VCC to 3V3 on the TTGO.
– Connect BME280 GND to GND.
– Connect BME280 SDA to ESP32 GPIO21.
– Connect BME280 SCL to ESP32 GPIO22.
– Connect ADS1115 VDD to 3V3.
– Connect ADS1115 GND to GND.
– Connect ADS1115 SDA to GPIO21 (shared I2C).
– Connect ADS1115 SCL to GPIO22 (shared I2C).
– Connect soil moisture sensor analog output to ADS1115 A0.
– Connect battery divider mid-point to ADS1115 A1. The divider top goes to battery positive (VBAT), bottom to GND. With R1=R2=100k, scale factor is 2. Ensure VBAT never exceeds ADS1115 max input (respect divider values).
Pin and address summary:
| Subsystem | Function | TTGO LoRa32 Pin | External Module Pin | Notes |
|---|---|---|---|---|
| I2C Bus | SDA | GPIO21 | SDA | Shared by BME280 and ADS1115 |
| I2C Bus | SCL | GPIO22 | SCL | Shared by BME280 and ADS1115 |
| BME280 | Power | 3V3, GND | VCC, GND | Use 3.3 V BME280 variant |
| BME280 | I2C Address | — | — | 0x76 (default) or 0x77 (jumper-selectable) |
| ADS1115 | Power | 3V3, GND | VDD, GND | Default I2C addr is 0x48 |
| ADS1115 | Channel A0 (Soil) | — | A0 | Soil sensor analog output to A0 |
| ADS1115 | Channel A1 (VBAT) | — | A1 | Battery divider midpoint to A1 |
| LoRa SX1276 | NSS (CS) | GPIO18 | (On-board) | SPI CS |
| LoRa SX1276 | RST | GPIO14 | (On-board) | Reset |
| LoRa SX1276 | DIO0 | GPIO26 | (On-board) | IRQ 0 |
| LoRa SX1276 | DIO1 | GPIO33 | (On-board) | IRQ 1 |
| LoRa SX1276 | DIO2 | GPIO32 | (On-board) | IRQ 2 |
| SPI Bus | SCK/MISO/MOSI | 5/19/27 | (On-board) | Mapped by board definition |
If your BME280 enumerates at 0x77, we will detect it in software.
Full Code
We’ll build with PlatformIO (Arduino framework) and the MCCI LoRaWAN LMIC library in ABP mode for reliable deep sleep (no rejoin per boot). The code uses forced-mode BME280 sampling and single-shot ADS1115 to minimize energy, then deep sleeps the ESP32 between uplinks.
Place these files in a new PlatformIO project:
1) platformio.ini
; File: platformio.ini
; Reproducible build pinned to specific platforms and libs.
[env:ttgo-lora32-v1]
platform = espressif32 @ 6.5.0
board = ttgo-lora32-v1
framework = arduino
monitor_speed = 115200
upload_speed = 921600
; Ensure we build for EU868 (adapt for your region if needed).
build_flags =
-DARDUINO_LMIC_PROJECT_CONFIG_H
-Dcfg_eu868=1
-DLMIC_DEBUG_LEVEL=1
-DLMIC_ENABLE_DeviceTimeReq=0
-DLMIC_PRINTF_TO=Serial
-DLMIC_USE_INTERRUPTS
lib_deps =
mcci-catena/MCCI LoRaWAN LMIC library @ 4.1.1
adafruit/Adafruit BME280 Library @ 2.2.4
adafruit/Adafruit Unified Sensor @ 1.1.14
adafruit/Adafruit ADS1X15 @ 2.4.0
2) include/lmic_project_config.h (region config header required by MCCI LMIC)
// File: include/lmic_project_config.h
#pragma once
// Select region - this project uses EU868 by default via build_flags.
// Other options: cfg_us915, cfg_as923, cfg_au915, etc.
// Ensure only one is defined at a time (build_flags sets cfg_eu868=1).
#define DISABLE_PING 1
#define DISABLE_BEACONS 1
#define LMIC_ENABLE_arbitrary_clock_error 1
#define LMIC_ENABLE_DeviceTimeReq 0
3) src/main.cpp
// File: src/main.cpp
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <Adafruit_ADS1X15.h>
#include <lmic.h>
#include <hal/hal.h>
// --------------- User Configuration ---------------
// Deep sleep interval (minutes). Keep TTN duty-cycle and FUP (Fair Use Policy) in mind.
// Example: 15 minutes = 900 seconds
#define SLEEP_MINUTES 15
// Select your EU868 LoRaWAN ABP credentials:
// - DevAddr: 32-bit (big-endian in TTN console, we provide as hex here).
// - NwkSKey: 16-byte key.
// - AppSKey: 16-byte key.
// Replace the 0x.. placeholders with your real ABP keys from The Things Stack.
static const PROGMEM u1_t NWKSKEY[16] = { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00 };
static const PROGMEM u1_t APPSKEY[16] = { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00 };
static const u4_t DEVADDR = 0x26011111; // Example. Replace with your DevAddr.
// Region: EU868 channel setup for TTN (8 channels).
// If you use another region, adapt channel plan accordingly.
// ADC and BME280 settings
#define I2C_SDA 21
#define I2C_SCL 22
// Battery divider scale (e.g., 100k over 100k => x2)
#define VBAT_DIVIDER 2.0f
// ADS1115 gain: +/- 4.096 V => 1 LSB = 125 uV
// Keep within safe range for your wiring.
#define ADS_GAIN GAIN_ONE
// Payload port
#define LORAWAN_PORT 1
// --------------- Globals ---------------
Adafruit_BME280 bme;
Adafruit_ADS1115 ads;
RTC_DATA_ATTR uint32_t rtc_seqnoUp = 0; // Persist uplink counter across deep sleep (ABP requirement).
RTC_DATA_ATTR uint32_t bootCount = 0;
static osjob_t sendjob;
volatile bool txDone = false;
// TTGO LoRa32 v1 pin mapping
const lmic_pinmap lmic_pins = {
.nss = 18,
.rxtx = LMIC_UNUSED_PIN,
.rst = 14,
.dio = { 26, 33, 32 },
.busy = LMIC_UNUSED_PIN,
.spi = { .mosi = 27, .miso = 19, .sck = 5 }
};
// --------------- Utility: low power preparation ---------------
void prepareSensorsLowPower() {
// BME280 goes to sleep when sampling mode is FORCED and no measurement is outstanding.
// For minimum consumption, ensure we are not in NORMAL mode.
// Adafruit library: setSampling allows forced mode configuration; we do that in setup().
// ADS1115: in single-shot mode, chip powers down between conversions automatically.
}
void enterDeepSleep(uint32_t minutes) {
Serial.flush();
// Optionally reduce power draw by configuring GPIO hold or pulldowns if your board benefits.
// Disable Bluetooth/WiFi stacks (usually not active in Arduino unless used).
#ifdef CONFIG_BT_ENABLED
btStop();
#endif
// Sleep timer
uint64_t sleep_us = (uint64_t)minutes * 60ULL * 1000000ULL;
esp_sleep_enable_timer_wakeup(sleep_us);
// Deep sleep
Serial.println(F("Entering deep sleep..."));
delay(50);
esp_deep_sleep_start();
}
// --------------- Sensor Reading ---------------
struct Telemetry {
float tempC;
float humidity;
float pressure_hPa;
float soilVolts;
float vbatVolts;
bool bmeOk;
bool adsOk;
};
Telemetry readSensors() {
Telemetry t{};
t.bmeOk = bme.begin(0x76) || bme.begin(0x77);
if (t.bmeOk) {
// Forced mode: single-shot measurement for minimal power.
bme.setSampling(Adafruit_BME280::MODE_FORCED,
Adafruit_BME280::SAMPLING_X1, // Temp
Adafruit_BME280::SAMPLING_X1, // Pressure
Adafruit_BME280::SAMPLING_X1, // Humidity
Adafruit_BME280::FILTER_OFF,
Adafruit_BME280::STANDBY_MS_0_5);
// Trigger single-shot
bme.takeForcedMeasurement();
t.tempC = bme.readTemperature();
t.humidity = bme.readHumidity();
t.pressure_hPa = bme.readPressure() / 100.0f;
} else {
Serial.println(F("BME280 not found on 0x76/0x77"));
}
t.adsOk = ads.begin(0x48);
if (t.adsOk) {
ads.setGain(ADS_GAIN); // +/-4.096 V FS
// Single-ended readings; library performs single-shot per call.
int16_t rawSoil = ads.readADC_SingleEnded(0);
int16_t rawBat = ads.readADC_SingleEnded(1);
t.soilVolts = ads.computeVolts(rawSoil); // Volts at A0
float vbatDiv = ads.computeVolts(rawBat); // Volts at A1 (divided)
t.vbatVolts = vbatDiv * VBAT_DIVIDER; // Reconstruct battery voltage
} else {
Serial.println(F("ADS1115 not found on 0x48"));
}
return t;
}
// --------------- Payload Encoding ---------------
// Minimal, compact binary payload (12 bytes):
// [0..1] int16 temp_c_x100
// [2..3] uint16 humidity_x2 (0.5% resolution)
// [4..5] uint16 pressure_hPa_x10
// [6..7] uint16 soil_mV
// [8..9] uint16 vbat_mV
// [10] flags bitfield: b0=bmeOk, b1=adsOk
// [11] reserved (0)
uint8_t payload[12];
void buildPayload(const Telemetry& t) {
int16_t temp_x100 = (int16_t)roundf(t.tempC * 100.0f);
uint16_t hum_x2 = (uint16_t)roundf(t.humidity * 2.0f); // 0.5% steps
uint16_t pres_x10 = (uint16_t)roundf(t.pressure_hPa * 10.0f);
uint16_t soil_mV = (uint16_t)roundf(t.soilVolts * 1000.0f);
uint16_t vbat_mV = (uint16_t)roundf(t.vbatVolts * 1000.0f);
payload[0] = (temp_x100 >> 8) & 0xFF;
payload[1] = temp_x100 & 0xFF;
payload[2] = (hum_x2 >> 8) & 0xFF;
payload[3] = hum_x2 & 0xFF;
payload[4] = (pres_x10 >> 8) & 0xFF;
payload[5] = pres_x10 & 0xFF;
payload[6] = (soil_mV >> 8) & 0xFF;
payload[7] = soil_mV & 0xFF;
payload[8] = (vbat_mV >> 8) & 0xFF;
payload[9] = vbat_mV & 0xFF;
uint8_t flags = 0;
if (t.bmeOk) flags |= 0x01;
if (t.adsOk) flags |= 0x02;
payload[10] = flags;
payload[11] = 0x00;
}
// --------------- LMIC Event Handling ---------------
void onEvent(ev_t ev) {
switch (ev) {
case EV_TXCOMPLETE:
Serial.println(F("EV_TXCOMPLETE (RX windows closed)"));
// Persist the current uplink counter.
rtc_seqnoUp = LMIC.seqnoUp;
txDone = true;
break;
default:
// Optional: print additional events during development
break;
}
}
void do_send(osjob_t* j) {
if (LMIC.opmode & OP_TXRXPEND) {
Serial.println(F("OP_TXRXPEND, not sending"));
return;
}
// Build and queue the uplink
Telemetry t = readSensors();
buildPayload(t);
Serial.print(F("Uplink bytes: "));
for (size_t i = 0; i < sizeof(payload); i++) {
if (payload[i] < 16) Serial.print('0');
Serial.print(payload[i], HEX);
Serial.print(' ');
}
Serial.println();
// Unconfirmed uplink on LORAWAN_PORT
LMIC_setTxData2(LORAWAN_PORT, payload, sizeof(payload), 0);
Serial.println(F("Packet queued"));
}
void setupLoraABP() {
// Reset state
os_init();
LMIC_reset();
// Set static session parameters.
// Note: LMIC expects keys in big-endian.
LMIC_setSession (0x1, DEVADDR, (xref2u1_t)NWKSKEY, (xref2u1_t)APPSKEY);
#if defined(CFG_eu868)
// Disable default channels, then use TTN EU868 recommended channels
LMIC_disableChannel(0);
LMIC_disableChannel(1);
LMIC_disableChannel(2);
// Add TTN EU868 channels
LMIC_setupChannel(0, 868100000, DR_RANGE_MAP(DR_SF12, DR_SF7), BAND_CENTI);
LMIC_setupChannel(1, 868300000, DR_RANGE_MAP(DR_SF12, DR_SF7), BAND_CENTI);
LMIC_setupChannel(2, 868500000, DR_RANGE_MAP(DR_SF12, DR_SF7), BAND_CENTI);
LMIC_setupChannel(3, 867100000, DR_RANGE_MAP(DR_SF12, DR_SF7), BAND_CENTI);
LMIC_setupChannel(4, 867300000, DR_RANGE_MAP(DR_SF12, DR_SF7), BAND_CENTI);
LMIC_setupChannel(5, 867500000, DR_RANGE_MAP(DR_SF12, DR_SF7), BAND_CENTI);
LMIC_setupChannel(6, 867700000, DR_RANGE_MAP(DR_SF12, DR_SF7), BAND_CENTI);
LMIC_setupChannel(7, 867900000, DR_RANGE_MAP(DR_SF12, DR_SF7), BAND_CENTI);
// Set Rx2 frequency (TTN default)
LMIC.dn2Dr = DR_SF9;
#endif
// Data rate and TX power
LMIC_setDrTxpow(DR_SF7, 14);
// Enable ADR in field deployments (optional)
LMIC_setAdrMode(1);
// Link check off to save airtime
LMIC_setLinkCheckMode(0);
// Restore uplink counter from RTC to keep ABP counters in sync with TTN
if (rtc_seqnoUp > 0) {
LMIC.seqnoUp = rtc_seqnoUp;
Serial.printf("Restored seqnoUp from RTC: %u\n", rtc_seqnoUp);
} else {
Serial.println(F("First boot: seqnoUp starts at 0"));
}
}
void setup() {
Serial.begin(115200);
delay(100);
++bootCount;
Serial.printf("\nBoot #%u - LoRa Agro Telemetry Low Power (ABP)\n", bootCount);
// Initialize I2C
Wire.begin(I2C_SDA, I2C_SCL);
// Pre-config sensor modes for low power (BME forced, ADS single-shot via reads)
// No extensive setup needed; handled in readSensors().
// LoRa init
setupLoraABP();
// Queue first send
do_send(&sendjob);
}
void loop() {
// Run LMIC tasks
os_runloop_once();
// After TX completes, enter deep sleep
if (txDone) {
prepareSensorsLowPower(); // sensors already in low-power friendly modes
// Update RTC seqno and sleep
Serial.printf("Next wake in %d minutes. Current seqnoUp=%u\n", SLEEP_MINUTES, LMIC.seqnoUp);
// Small delay to flush serial
delay(50);
enterDeepSleep(SLEEP_MINUTES);
}
}
Payload decoding hint (for TTN uplink formatter):
– Temperature = int16(payload[0..1]) / 100.0 (°C)
– Humidity = uint16(payload[2..3]) / 2.0 (%RH)
– Pressure = uint16(payload[4..5]) / 10.0 (hPa)
– Soil voltage = uint16(payload[6..7]) mV / 1000.0 (V)
– Battery voltage = uint16(payload[8..9]) mV / 1000.0 (V)
– Flags: bit0 = BME ok, bit1 = ADS ok
Optional TTN JavaScript/JS decoder (The Things Stack v3 Custom Javascript Formatter):
function decodeUplink(input) {
const b = input.bytes;
if (!b || b.length < 12) {
return { errors: ["payload too short"] };
}
const temp = ((b[0] << 8) | b[1]);
const tempC = (temp >= 0x8000 ? temp - 0x10000 : temp) / 100.0;
const hum = ((b[2] << 8) | b[3]) / 2.0;
const pres = ((b[4] << 8) | b[5]) / 10.0;
const soil = ((b[6] << 8) | b[7]) / 1000.0;
const vbat = ((b[8] << 8) | b[9]) / 1000.0;
const flags = b[10];
return {
data: {
tempC, humidity: hum, pressure_hPa: pres,
soilV: soil, vbatV: vbat,
bmeOk: !!(flags & 0x01),
adsOk: !!(flags & 0x02)
}
};
}
Build/Flash/Run commands
Create the project:
mkdir -p ~/projects/lora-agro-telemetry-lowpower
cd ~/projects/lora-agro-telemetry-lowpower
# 2) Initialize for TTGO LoRa32 v1
pio project init -b ttgo-lora32-v1
# 3) Create folders for includes and source
mkdir -p include src
# 4) Place platformio.ini, include/lmic_project_config.h, and src/main.cpp as shown above
# (Use your editor or copy files into these paths accordingly.)
# 5) Build
pio run
# 6) Upload (board in bootloader mode typically auto-handled; if not, hold BOOT and press EN)
pio run -t upload
# 7) Monitor serial at 115200 baud
pio device monitor -b 115200
Driver notes:
– Windows: Install CP210x drivers if serial device isn’t recognized.
– macOS: Grant terminal access to USB serial and install CP210x package if necessary.
– Linux: Add your user to dialout group and re-login: sudo usermod -a -G dialout $USER
Step-by-step Validation
1) TTN application/device (ABP):
– In The Things Stack console, create an Application (e.g., app-id: agro-telemetry).
– Add an end device with Activation: ABP. Select Frequency plan: Europe 863-870 MHz (TTN) for EU868.
– Record DevAddr, NwkSKey, AppSKey (MSB/hex). Insert them into src/main.cpp in the placeholders.
– Ensure “Frame counter width” and “Frame counter check” are configured sensibly. For production, keep frame counter checks enabled. Our code persists the uplink counter in RTC memory to maintain continuity across deep sleep. Power-cycling will reset the counter, so avoid it between field runs, or resynchronize counters in the console.
2) Wiring sanity check:
– Confirm the I2C devices appear on address scan (optional): you can run a quick I2C scanner sketch to verify 0x76/0x77 and 0x48 appear.
– Ensure soil sensor output is safe (0–3.3 V) for ADS1115 input; if higher, adjust gain/divider.
3) Flash and monitor:
– Build and upload using the commands above.
– Open serial monitor at 115200. First boot prints:
– “First boot: seqnoUp starts at 0” or restored seqnoUp if not first.
– “Uplink bytes: …” with hex dump.
– “Packet queued”
– “EV_TXCOMPLETE …”
– Then “Entering deep sleep…”
– On subsequent wakeups (every SLEEP_MINUTES), you’ll see seqnoUp increasing.
4) TTN data:
– In the TTN application console, open your device live data.
– You should see uplinks at your schedule interval (respect duty cycle and gateway coverage).
– If using the provided decoder script, you will see fields:
– tempC, humidity, pressure_hPa, soilV, vbatV, bmeOk, adsOk.
5) Verify measurements:
– Touch the BME280 to vary temperature slightly and confirm the tempC reading changes.
– Place the soil probe in water vs. air and confirm soilV increases accordingly.
– If a battery is connected via divider to ADS1115 A1, confirm vbatV matches a multimeter measurement within expected tolerance.
6) Validate low power:
– Insert a DMM in series with board supply. After EV_TXCOMPLETE and a few tens of milliseconds, current should drop as ESP32 enters deep sleep.
– Typical values (board-dependent):
– Active TX: tens of mA peak (plus LoRa TX power spike).
– Deep sleep: ~100–300 µA for many TTGO LoRa32 v1 boards (limited by LDO, LoRa, and any pull-ups). Your result may vary.
– If deep sleep is higher than ~1 mA, review Troubleshooting (GPIO states, sensor power rails, pull-ups).
7) Airtime/duty-cycle check (EU868):
– At SF7BW125 with small payload, the airtime is modest; every 15 minutes is within duty-cycle norms. Do not decrease intervals without calculating duty-cycle compliance. Use tools like LoRa airtime calculators.
Troubleshooting
- No serial port appears:
- Install CP210x or CH34x driver as appropriate.
-
Try a different cable/USB port. On Linux, ensure you’re in the dialout group.
-
Upload fails or stalls:
- Hold BOOT (IO0) while tapping EN (RST) to force bootloader mode, then “pio run -t upload”.
-
Reduce upload speed in platformio.ini if necessary (e.g., upload_speed = 460800).
-
No uplinks in TTN console:
- Confirm frequency plan (EU868) matches your location and gateways nearby.
- Verify ABP keys and DevAddr are correct and in MSB order. In MCCI LMIC, keys are big-endian hex arrays as provided by TTN (MSB).
- Ensure channel plan matches TTN EU868. In other regions (e.g., US915), you must change build flags to -Dcfg_us915=1 and configure subband (LMIC_selectSubBand()).
-
Antenna: make sure a proper antenna is attached to the TTGO board.
-
“BME280 not found” or “ADS1115 not found”:
- Check 3V3 and GND continuity.
- Ensure SDA=21, SCL=22; avoid mixing with OLED pins if your board has a display.
-
Scan I2C addresses to confirm presence. If BME280 is at 0x77, the code already tries 0x76 then 0x77.
-
Payload looks wrong:
- Double-check the TTN decoder math and byte order.
-
Confirm units: tempC100, humidity2, pressure*10, mV for soil and vbat.
-
Battery reading off by factor:
- Verify divider ratio. If not 100k/100k, update VBAT_DIVIDER accordingly.
-
Ensure ADS1115 gain (GAIN_ONE for ±4.096 V) doesn’t saturate. If using higher input voltages on divided input, verify FS range.
-
Deep sleep current too high:
- Some TTGO LoRa32 variants have higher quiescent currents due to LDOs and the radio not fully powered down. LMIC should leave SX1276 idle after TX, but its standby current adds overhead.
- Ensure no external pull-up rails keep sensors powered unintentionally.
- Consider powering sensors via a switched rail or Vext (if your board revision supports it) controlled by a GPIO, disabling it before sleep.
-
Use RTC GPIO holds on lines that would otherwise float.
-
Frame counter errors (ABP):
- Our code persists seqnoUp across deep sleep in RTC memory. Power cycles clear RTC memory, breaking continuity.
-
If you power-cycle, either reset frame counters in TTN (not recommended in production) or persist seqnoUp in NVS/flash for full robustness.
-
Duty cycle non-compliance:
- Do not reduce SLEEP_MINUTES without calculating airtime and duty cycle. For EU868, stay within 1% or the specific sub-band limits.
Improvements
- OTAA with session persistence:
-
Switch to OTAA (more secure) and store LMIC session keys, DevNonce, and counters in NVS or RTC memory. On wake, restore the session to avoid rejoins each cycle.
-
Confirmed uplinks with retry backoff:
-
Use confirmed messages occasionally to verify link quality. Balance energy cost and airtime fairness.
-
Adaptive sampling intervals:
-
Increase reporting during rapid environmental change; otherwise, sleep longer to save battery.
-
Battery-backed RTC and time-based features:
-
Use DeviceTimeReq (if enabled) or external RTC for timestamping. Not enabled in this minimal low-power code.
-
Sensor power gating:
-
If your board variant has Vext or you can add a P-MOSFET high-side switch, fully power down BME280/ADS1115 between samples to reduce quiescent draw to microamps.
-
Payload compression and delta encoding:
-
Reduce airtime by sending deltas or using a compact scheme like Cayenne LPP (already compact) or custom 10-byte layouts.
-
Region portability:
-
For US915/AS923, use appropriate LMIC region macros and channel/sub-band setups. For US915, call LMIC_selectSubBand(1) or your specific subband.
-
Field robustness:
- Add brown-out safe guards, watchdog timer, and fallback intervals in case of repeated TX failures.
-
Debounce moisture sensor inputs and calibrate curves to translate volts to volumetric water content (VWC).
-
Security:
- Keep keys in a separate header excluded from version control, and consider hardware secure elements for key storage.
Final Checklist
- Materials:
-
TTGO LoRa32 (ESP32 + SX1276) + BME280 + ADS1115 wired over I2C (SDA=21, SCL=22), soil sensor to ADS1115 A0, battery divider to A1.
-
Software:
- PlatformIO installed; project initialized with board = ttgo-lora32-v1.
- platform = espressif32 @ 6.5.0, Arduino framework.
-
Libraries pinned: MCCI LMIC 4.1.1, Adafruit BME280 2.2.4, Adafruit ADS1X15 2.4.0.
-
Credentials:
-
ABP DevAddr/NwkSKey/AppSKey copied from TTN into main.cpp in MSB order.
-
Region:
-
EU868 macros set via build_flags; TTN EU868 channels configured in code.
-
Build/Flash:
-
pio run and pio run -t upload succeed; serial monitor at 115200 shows EV_TXCOMPLETE before sleep.
-
Payload:
-
TTN live data shows uplinks. Custom JS decoder applied; fields display correctly.
-
Power:
- Deep sleep current measured and within acceptable range for your board (typically 100–300 µA, variant-dependent).
-
Sleep interval SLEEP_MINUTES respects regional duty cycle.
-
Robustness:
- ABP frame counters persist across deep sleep (RTC). Avoid power-cycling, or implement NVS persistence.
With this setup, you have a reproducible, low-power LoRa agro telemetry node on the TTGO LoRa32 (ESP32 + SX1276) + BME280 + ADS1115 platform, suitable for field deployment and further optimization.
Find this product and/or books on this topic on Amazon
As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.



