Been testing the LoRa scanner I have set up: at work travelling to Cheltenham and Devon. Seems
to work a charm. I have the server app working great too. The captured data can be seen at
https://ttn.supergalley.com that will be used to show the
weather data on the Chirk airfield website.
on the downside: the LoRa gateway that I was hoping to use at Chirk seemed to be offline today as I
drove past. I hope this is an anomaly and at there is another one working at the Airfield. Failing that
we can probably set up a gateway at Chirk so it never goes down. Here is the C++ code created for the LoRa Scanner:
View the C++ code of the scanner
/* Heltec WiFi LoRa 32 V3 (ESP32-S3 + SX1262)
OLED always-on, GPS (UART1 on GPIO1/2), and robust LoRaWAN join/send
- EU868 / OTAA / FPort 2 / ~30 s
- OLED initialised ONCE; never re-inited; Vext never toggled after boot
- Page flip every 3 s (GPS / LoRa+System), 2 Hz draw
- PRG button: first press freezes auto-cycle; next presses toggle page
- Join watchdog: re-init if still not joined after 180 s
- Auto pixel-invert for 120 s after a successful uplink
- OLED keepalive + lowPowerHandler override so the screen never naps
*/
#include <Arduino.h>
#include <Wire.h>
#include "HT_SSD1306Wire.h" // Heltec’s SSD1306 OLED driver
#include "HT_TinyGPS++.h" // Lightweight GPS parser
#include "LoRaWan_APP.h" // Heltec LoRaWAN state machine
#include <limits.h> // for ULONG_MAX
// -----------------------------
// PMU (AXP2101)
// -----------------------------
// The Heltec V3 board uses an AXP2101 PMU. Some revisions map it at 0x34,
// others at 0x35. We probe both. We only use it to read battery voltage.
//
// Relevant registers (datasheet naming):
// - BAT_VOL_H/L: 12-bit battery voltage ADC result
// - ADC_EN1: enables VBAT/VBUS ADC channels
static uint8_t PMU_ADDR = 0x34;
#define AXP2101_BAT_VOL_H 0x78
#define AXP2101_BAT_VOL_L 0x79
#define AXP2101_ADC_EN1 0x58 // enable bits for ADC channels
// ---- minimal I2C helpers ----
static bool i2cWrite8(uint8_t addr, uint8_t reg, uint8_t val) {
Wire.beginTransmission(addr);
Wire.write(reg);
Wire.write(val);
return Wire.endTransmission() == 0;
}
static bool i2cRead8(uint8_t addr, uint8_t reg, uint8_t &val) {
Wire.beginTransmission(addr);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return false; // keep bus active
if (Wire.requestFrom(addr, (uint8_t)1) != 1) return false; // read 1 byte
val = Wire.read();
return true;
}
// Try 0x34 then 0x35. If neither ACKs, we skip battery features.
static bool pmuProbe() {
Wire.beginTransmission(0x34);
if (Wire.endTransmission() == 0) { PMU_ADDR = 0x34; return true; }
Wire.beginTransmission(0x35);
if (Wire.endTransmission() == 0) { PMU_ADDR = 0x35; return true; }
return false;
}
// Flip on ADC channels for VBAT/VBUS so reads return real numbers.
static bool axpEnableAdcBatt() {
uint8_t v = 0;
if (!i2cRead8(PMU_ADDR, AXP2101_ADC_EN1, v)) return false;
v |= (1 << 7); // BAT_VOLT_EN (bit position per typical AXP2101 map)
v |= (1 << 5); // VBUS_VOLT_EN
return i2cWrite8(PMU_ADDR, AXP2101_ADC_EN1, v);
}
// Read 12-bit battery voltage. Datasheet scale: 1 LSB = 1.1 mV.
static float readBatteryVoltageAXP2101() {
Wire.beginTransmission(PMU_ADDR);
Wire.write(AXP2101_BAT_VOL_H);
if (Wire.endTransmission(false) != 0) return 0.0f; // restart
if (Wire.requestFrom(PMU_ADDR, (uint8_t)2) != 2) return 0.0f;
uint8_t hi = Wire.read();
uint8_t lo = Wire.read();
uint16_t raw = ((uint16_t)hi << 4) | (lo >> 4); // pack 12 bits
return (raw * 1.1f) / 1000.0f; // volts
}
// -----------------------------
// OLED
// -----------------------------
// Heltec wires the OLED to SDA_OLED/SCL_OLED and powers it from Vext.
// On this board: Vext LOW = enabled, HIGH = disabled.
static SSD1306Wire display(0x3c, 400000, SDA_OLED, SCL_OLED,
GEOMETRY_128_64, RST_OLED);
static inline void VextON() { pinMode(Vext, OUTPUT); digitalWrite(Vext, LOW); }
static inline void VextOFF() { pinMode(Vext, OUTPUT); digitalWrite(Vext, HIGH); }
static bool oledInverted = false; // track invert state for minimal I2C chatter
static uint32_t lastOledKA = 0; // last OLED keepalive tick
// Change pixel polarity only when state actually changes.
static inline void oledSetInversion(bool inv) {
if (inv == oledInverted) return;
oledInverted = inv;
if (inv) display.invertDisplay(); else display.normalDisplay();
}
// Initialise the OLED once at boot and never re-initialize again.
// Re-initting OLEDs mid-flight leads to flicker and bus glitches.
static void oledInitOnce() {
VextON(); // power the display rail
delay(2);
Wire.begin(SDA_OLED, SCL_OLED, 400000); // fast I2C for snappier draws
display.init();
display.setTextAlignment(TEXT_ALIGN_LEFT);
display.setFont(ArialMT_Plain_10);
display.displayOn(); // ensure panel is awake
oledSetInversion(false);
display.clear();
display.drawString(0,0,"OLED ready");
display.drawString(0,12,"Init GPS @115200 (RX1=GPIO1 TX1=GPIO2)");
display.drawString(0,24,"Init LoRaWAN EU868 OTAA…");
display.display();
}
// Keep the OLED rail alive and poke the controller once a second so
// firmware “sleep” hooks don’t blank the panel.
static inline void oledKeepAlive() {
pinMode(Vext, OUTPUT);
digitalWrite(Vext, LOW); // rail on
uint32_t now = millis();
if (now - lastOledKA >= 1000) {
display.displayOn(); // sends 0xAF wake
lastOledKA = now;
}
}
// Convenience draw with 5 lines at 12 px spacing.
static void oledDraw(const String &l0, const String &l1="",
const String &l2="", const String &l3="",
const String &l4="") {
display.clear();
if (l0.length()) display.drawString(0, 0, l0);
if (l1.length()) display.drawString(0, 12, l1);
if (l2.length()) display.drawString(0, 24, l2);
if (l3.length()) display.drawString(0, 36, l3);
if (l4.length()) display.drawString(0, 48, l4);
display.display();
display.displayOn(); // extra nudge to stay awake
}
// -----------------------------
// LoRaWAN (OTAA)
// -----------------------------
// All keys are MSB order as expected by Heltec’s wrapper.
// appEui often left zero for TTN v3 console-created devices.
uint8_t appEui[] = { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00 };
uint8_t devEui[] = { 0x9A,0x84,0x81,0xB0,0x00,0x00,0xEC,0x87 };
uint8_t appKey[] = { 0xE5,0xD8,0x35,0xA7,0x9E,0x80,0x89,0xED,0xC3,0x73,0xF1,0x06,0xF6,0x62,0x4C,0xAC };
// Library expects these even with OTAA; they’re ignored until join success.
uint16_t userChannelsMask[6] = { 0x00FF, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000 };
uint8_t nwkSKey[16] = {0};
uint8_t appSKey[16] = {0};
uint32_t devAddr = 0;
// Region and behavior knobs
LoRaMacRegion_t loraWanRegion = LORAMAC_REGION_EU868;
DeviceClass_t loraWanClass = CLASS_A;
bool overTheAirActivation = true; // OTAA
bool loraWanAdr = true; // let network tune DR
bool isTxConfirmed = false; // unconfirmed uplinks
uint8_t appPort = 2; // TTNMapper default
uint8_t confirmedNbTrials = 1; // unused (unconfirmed)
uint32_t appTxDutyCycle = 30000; // 30 s between frames (plus jitter)
// Track the DR we set; Heltec wrapper doesn’t expose a getter.
static int8_t currentDR = 2; // EU868 DR2 ≈ SF10/125
// -----------------------------
// GPS
// -----------------------------
// GPS is wired to UART1 on GPIO1/2. TinyGPS++ ingests raw NMEA and gives
// parsed fields with validity bits and “age” timing.
TinyGPSPlus GPS;
static const uint32_t GPS_BAUD = 115200;
static const int GPS_RX = 1; // ESP32 pin receiving data from GPS TX
static const int GPS_TX = 2; // ESP32 pin to GPS RX (rarely used)
static uint32_t lastCharCount=0, lastCharTick=0;
static uint16_t bytesPerSec=0; // rough NMEA throughput for debugging
// Feed TinyGPS++ from Serial1 for up to max_ms. Also compute bytes/sec.
static inline void feedGPS(uint32_t max_ms = 5) {
uint32_t start = millis();
while (Serial1.available() && (millis()-start) < max_ms) {
GPS.encode((char)Serial1.read());
}
if (millis() - lastCharTick >= 1000) {
uint32_t cur = GPS.charsProcessed();
bytesPerSec = (uint16_t)(cur - lastCharCount);
lastCharCount = cur; lastCharTick = millis();
}
}
// -----------------------------
// Battery
// -----------------------------
// Wrap PMU read for the app. If PMU missing, returns 0.00 V.
static float readBatteryV() {
return readBatteryVoltageAXP2101();
}
// Crude VBAT→% mapping with soft knee. Clamped 3.20–4.18 V -> 0–100 %.
static int batteryPercent(float v) {
if (v <= 3.20f) return 0;
if (v >= 4.18f) return 100;
float x = (v - 3.20f) / (4.18f - 3.20f);
x = x * (1.1f - 0.1f * x); // gentle ease curve
int p = (int)(x * 100.0f + 0.5f);
if (p < 0) p = 0; if (p > 100) p = 100;
return p;
}
// -----------------------------
// State / Telemetry UI
// -----------------------------
static bool joined = false; // latched once first TX happens
static uint32_t joinStartMs = 0; // for join watchdog
static uint32_t uplinkCount = 0; // total successful sends
static uint32_t lastTxMs = 0; // for OLED inversion window
// Page/UI handling
static bool showGPS = true; // which page is visible
static bool autoCycle = true; // auto-toggle every 3 s
static uint32_t nextFlip = 0;
static uint32_t nextFrame = 0; // 2 Hz drawing cadence
static const uint32_t FRAME_MS = 500;
static const uint32_t JOIN_WATCHDOG_MS = 180000; // re-init if join stalls
static const uint32_t INVERT_MS = 120000; // invert after TX
// PRG button is on GPIO0 and pulled up when idle.
static const int PIN_PRG = 0;
static bool lastBtn = true; // HIGH = idle
static uint32_t lastBtnMs = 0;
static const uint16_t DEBOUNCE_MS = 150;
// -----------------------------
// Uplink payload
// -----------------------------
// Heltec wrapper uses global appData/appDataSize for send buffer.
extern uint8_t appData[];
extern uint8_t appDataSize;
// TTNMapper expects 12 bytes: lat(4) lon(4) alt(2) hdop*10(1) sats(1).
static void prepareTxFrame(uint8_t /*port*/) {
int32_t lat = 0, lon = 0; int16_t alt = 0; uint8_t hd=255, sats=0;
bool hasFix = GPS.location.isValid();
if (hasFix) { lat = (int32_t)(GPS.location.lat()*1e7); lon = (int32_t)(GPS.location.lng()*1e7); }
if (GPS.altitude.isValid()) alt = (int16_t)GPS.altitude.meters();
if (GPS.hdop.isValid()) hd = (uint8_t)min(255.0, GPS.hdop.hdop()*10.0);
if (GPS.satellites.isValid()) sats = (uint8_t)GPS.satellites.value();
uint8_t p[12]; int i=0;
p[i++]=(lat>>24)&0xFF; p[i++]=(lat>>16)&0xFF; p[i++]=(lat>>8)&0xFF; p[i++]=lat&0xFF;
p[i++]=(lon>>24)&0xFF; p[i++]=(lon>>16)&0xFF; p[i++]=(lon>>8)&0xFF; p[i++]=lon&0xFF;
p[i++]=(alt>>8)&0xFF; p[i++]=alt&0xFF; p[i++]=hd; p[i++]=sats;
memcpy(appData, p, sizeof(p)); appDataSize = sizeof(p); appPort = 2;
}
// -----------------------------
// Pages
// -----------------------------
static void drawGpsPage() {
bool hasFix = GPS.location.isValid();
uint8_t sats = GPS.satellites.isValid()? GPS.satellites.value():0;
float hdop = GPS.hdop.isValid()? GPS.hdop.hdop():NAN;
uint32_t age = GPS.location.age(); // ms, or ULONG_MAX if invalid
String l0 = hasFix ? "GPS: FIX" : "GPS: searching…";
if (!autoCycle) l0 += " [MAN]";
String l1 = "Sats " + String(sats);
if (!isnan(hdop)) l1 += " HDOP " + String(hdop,1);
String l2, l3, l4;
if (hasFix) {
l2 = "Lat " + String(GPS.location.lat(),6);
l3 = "Lon " + String(GPS.location.lng(),6);
if (GPS.altitude.isValid()) l4 = "Alt " + String(GPS.altitude.meters(),0) + " m";
else l4 = "Fix age " + String(age/1000.0,1) + "s";
} else {
l2 = "NMEA " + String(bytesPerSec) + " B/s";
l3 = (age==ULONG_MAX) ? "Fix age ?" : ("Fix age " + String(age/1000.0,1) + "s");
l4 = "Waiting for sky view…";
}
oledDraw(l0,l1,l2,l3,l4);
}
static void drawLoRaPage() {
String l0 = joined ? "LoRa: JOINED" : "LoRa: joining…";
l0 += " DR"; l0 += String(currentDR);
l0 += loraWanAdr ? " ADR:on" : " ADR:off";
if (!autoCycle) l0 += " [MAN]";
String l1 = "Uplink Count: " + String(uplinkCount);
if (lastTxMs) l1 += " TX " + String((millis()-lastTxMs)/1000) + "s ago";
if (deviceState==DEVICE_STATE_CYCLE) {
uint32_t eta = (txDutyCycleTime>millis()) ? ((txDutyCycleTime - millis())/1000) : 0;
l1 += " next " + String(eta) + "s";
}
float vb = readBatteryV();
int pc = batteryPercent(vb);
String l2;
if (vb < 2.5f) l2 = "Batt USB-only 0%"; // PMU not reporting
else l2 = "Batt " + String(vb,2) + "V " + String(pc) + "%";
String l3 = "EU868 FP:" + String(appPort) + " duty~" + String(appTxDutyCycle/1000) + "s";
String l4 = "PRG: freeze/toggle pages";
oledDraw(l0,l1,l2,l3,l4);
}
// -----------------------------
// Button handling
// -----------------------------
// Single button: first press freezes auto-cycle; subsequent presses toggle pages.
static void handleButton() {
bool now = digitalRead(PIN_PRG); // HIGH idle, LOW pressed
uint32_t t = millis();
if (now != lastBtn && (t - lastBtnMs) > DEBOUNCE_MS) {
lastBtnMs = t;
if (now == LOW) {
if (autoCycle) autoCycle = false;
else showGPS = !showGPS;
}
lastBtn = now;
}
}
// -----------------------------
// Low-power hook override
// -----------------------------
// Heltec’s LoRaWAN state machine idles via sleep calls. We override the
// weak lowPowerHandler() so the OLED rail never naps while LoRa waits.
extern "C" void lowPowerHandler(void) {
oledKeepAlive(); // keep rail high and panel awake
delay(1); // tiny yield
}
// -----------------------------
// setup() / loop()
// -----------------------------
void setup() {
Serial.begin(115200);
delay(100);
pinMode(PIN_PRG, INPUT_PULLUP);
oledInitOnce(); // powers and configures OLED, starts I2C
// GPS on UART1. We rarely transmit to the GPS but wire TX in case.
Serial1.begin(GPS_BAUD, SERIAL_8N1, GPS_RX, GPS_TX);
lastCharTick = millis(); lastCharCount = GPS.charsProcessed();
// Heltec board init. Note their macro typo: SLOW_CLK_TPYE…
Mcu.begin(HELTEC_BOARD, SLOW_CLK_TPYE);
// LoRa defaults and UI timers
currentDR = 2; // DR2 EU868
nextFlip = millis() + 3000; // first auto flip in 3 s
nextFrame = millis() + FRAME_MS;
// PMU bring-up: optional. If probing fails we still operate.
if (!pmuProbe()) {
Serial.println("PMU not found at 0x34/0x35; VBAT will read 0.00V");
} else {
if (!axpEnableAdcBatt()) {
Serial.println("PMU found but ADC enable failed; VBAT may be 0.00V");
} else {
Serial.printf("PMU on 0x%02X, ADC enabled.\n", PMU_ADDR);
}
}
}
void loop() {
// Keep OLED alive first each tick so any later sleeps don't blank it.
oledKeepAlive();
feedGPS(8); // parse up to ~8 ms of NMEA this iteration
handleButton(); // debounce and act on PRG presses
// Invert display for a visual “TX success” window.
bool recentTx = (lastTxMs != 0) && ((millis() - lastTxMs) <= INVERT_MS);
oledSetInversion(recentTx);
// Auto flip page every 3 s while autoCycle is true.
if (autoCycle && millis() >= nextFlip) {
showGPS = !showGPS;
nextFlip = millis() + 3000;
}
// Draw UI at 2 Hz regardless of LoRa state machine.
if (millis() >= nextFrame) {
if (showGPS) drawGpsPage(); else drawLoRaPage();
nextFrame += FRAME_MS;
}
// -------------------------
// LoRaWAN state machine
// -------------------------
switch (deviceState) {
case DEVICE_STATE_INIT:
// Configure radio and set starting data rate
LoRaWAN.init(loraWanClass, loraWanRegion);
LoRaWAN.setDefaultDR(currentDR);
joinStartMs = millis();
deviceState = DEVICE_STATE_JOIN;
break;
case DEVICE_STATE_JOIN:
// Attempt OTAA join. Library handles retries.
LoRaWAN.join();
// If we’ve been trying for > 3 minutes, re-init radio and try again.
if (!joined && (millis() - joinStartMs) > JOIN_WATCHDOG_MS) {
LoRaWAN.init(loraWanClass, loraWanRegion);
LoRaWAN.setDefaultDR(currentDR);
joinStartMs = millis();
}
break;
case DEVICE_STATE_SEND:
// We only get here when the wrapper says it’s time to send.
if (!joined) joined = true; // latch first TX as "joined"
prepareTxFrame(appPort); // fill appData/appDataSize
LoRaWAN.send(); // unconfirmed uplink
uplinkCount++;
lastTxMs = millis(); // start OLED invert window
deviceState = DEVICE_STATE_CYCLE; // schedule next send
break;
case DEVICE_STATE_CYCLE:
// Add random jitter to avoid gateway sync collisions.
txDutyCycleTime = appTxDutyCycle +
randr(-APP_TX_DUTYCYCLE_RND, APP_TX_DUTYCYCLE_RND);
LoRaWAN.cycle(txDutyCycleTime); // tell wrapper when to wake for SEND
deviceState = DEVICE_STATE_SLEEP; // fall into sleep handler
break;
case DEVICE_STATE_SLEEP:
// Wrapper will idle the MCU; our lowPowerHandler keeps OLED alive.
LoRaWAN.sleep(loraWanClass);
break;
default:
// Safety net: reboot state machine.
deviceState = DEVICE_STATE_INIT;
break;
}
}