Add LoRa sensor network support

- Design LoRa packet protocol with CRC16 validation
- Add protocol header files (lora_protocol.h, lora_packet.h)
- Create battery-powered remote node firmware template
  - Support for DHT22, BME680, DS18B20 sensors
  - Deep sleep for battery conservation
  - Automatic gateway registration
- Add LoRa receive to gateway firmware
  - RadioLib SX1262 integration
  - Node registry for tracking up to 16 nodes
  - HTTP forwarding of received readings
  - ACK responses to remote nodes
- Create comprehensive setup guide with wiring diagrams

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
leehughes
2026-01-24 17:50:53 -06:00
co-authored by Claude Opus 4.5
parent 265a53768e
commit 5554341b49
11 changed files with 3199 additions and 34 deletions
+488 -25
View File
@@ -3,19 +3,21 @@
*
* Reads DS18B20 temperature sensors in the e-coat paint tank and sends
* data to ZNET Web for real-time monitoring and ML feature collection.
* Also receives data from remote LoRa sensor nodes (DHT22, BME680).
*
* Hardware:
* - Meshnology ESP32 LoRa V3 (Heltec-compatible)
* - 2x DS18B20 waterproof temperature probes (1-Wire)
* - Built-in 0.96" OLED display
* - Built-in SX1262 LoRa radio (for future sensor nodes)
* - Built-in SX1262 LoRa radio
*
* Features:
* - Dual sensor reading with validation and averaging
* - OLED display for local temperature visibility
* - HTTP POST to ZNET Web API
* - Offline buffering when network is unavailable
* - LoRa receive for future remote sensor nodes
* - LoRa receive from remote sensor nodes
* - Automatic node registration and ACK responses
* - WiFiManager for easy WiFi setup
*/
@@ -27,13 +29,41 @@
#include <DallasTemperature.h>
#include <SSD1306Wire.h>
#include <WiFiManager.h>
#include <RadioLib.h>
#include <SPI.h>
#include "config.h"
#include "lora_protocol.h"
#include "lora_packet.h"
// ============================================================================
// GLOBALS
// ============================================================================
// LoRa radio (SX1262)
SX1262 radio = new Module(LORA_SS, LORA_DIO1, LORA_RST, LORA_BUSY);
volatile bool loraReceived = false;
int8_t lastLoraRssi = 0;
// Remote node registry
#define MAX_REMOTE_NODES 16
struct RemoteNode {
uint16_t node_id;
uint8_t sensor_type;
char name[9];
uint8_t last_seq;
uint32_t last_seen;
uint16_t battery_mv;
bool active;
};
RemoteNode remoteNodes[MAX_REMOTE_NODES];
int remoteNodeCount = 0;
// LoRa statistics
uint32_t loraPacketsReceived = 0;
uint32_t loraPacketsForwarded = 0;
uint32_t loraPacketsCrcFail = 0;
// OneWire and DS18B20
OneWire oneWire(ONEWIRE_PIN);
DallasTemperature sensors(&oneWire);
@@ -79,16 +109,35 @@ int bufferCount = 0;
void setupWiFi();
void setupSensors();
void setupDisplay();
void setupLoRa();
void readTemperatures();
void updateDisplay();
void postToApi();
void postLoRaReadingToApi(const lora_packet_t* pkt, const char* sensorType,
float temp_f, float humidity, float pressure,
uint16_t gas_kohm, uint8_t iaq, uint16_t battery_mv,
int8_t rssi);
void sendBufferedReadings();
void checkLoRa();
void processLoRaPacket(uint8_t* data, uint8_t len);
void handleSensorData(const lora_packet_t* pkt);
void handleRegistration(const lora_packet_t* pkt);
void sendAck(uint16_t node_id, uint8_t seq);
void sendRegistrationAck(uint16_t node_id, uint8_t seq, uint16_t assigned_id);
RemoteNode* findNode(uint16_t node_id);
RemoteNode* addNode(uint16_t node_id, uint8_t sensor_type, const char* name);
uint16_t allocateNodeId(uint8_t sensor_type);
float celsiusToFahrenheit(float celsius);
bool isValidTemperature(float temp_f);
String formatAddress(DeviceAddress addr);
void displaySplash();
void displayError(const char* message);
// LoRa interrupt handler
ICACHE_RAM_ATTR void onLoRaReceive() {
loraReceived = true;
}
// ============================================================================
// SETUP
// ============================================================================
@@ -99,15 +148,21 @@ void setup() {
Serial.println("\n\n");
Serial.println("=========================================");
Serial.println(" ZNET Temperature Sensor v1.0");
Serial.println(" E-Coat Paint Tank Monitor");
Serial.println(" ZNET Temperature Sensor v1.2");
Serial.println(" E-Coat Paint Tank Gateway");
Serial.println("=========================================");
// Initialize node registry
memset(remoteNodes, 0, sizeof(remoteNodes));
// Initialize display first for visual feedback
setupDisplay();
displaySplash();
// Initialize sensors
// Initialize LoRa radio
setupLoRa();
// Initialize local sensors
setupSensors();
// Connect to WiFi
@@ -126,7 +181,10 @@ void loop() {
// Check WiFi connection
wifiConnected = WiFi.status() == WL_CONNECTED;
// Read temperatures at interval
// Check for LoRa packets (non-blocking)
checkLoRa();
// Read local temperatures at interval
if (now - lastTempRead >= TEMP_READ_INTERVAL_MS || lastTempRead == 0) {
readTemperatures();
lastTempRead = now;
@@ -313,11 +371,13 @@ void updateDisplay() {
// Title bar
display.setTextAlignment(TEXT_ALIGN_LEFT);
display.setFont(ArialMT_Plain_10);
display.drawString(0, 0, "ZNET Tank Temp");
display.drawString(0, 0, "ZNET Gateway");
// WiFi status indicator
// WiFi and LoRa status
display.setTextAlignment(TEXT_ALIGN_RIGHT);
display.drawString(128, 0, wifiConnected ? "WiFi OK" : "NO WiFi");
char status[24];
snprintf(status, sizeof(status), "%s L%d", wifiConnected ? "W" : "-", remoteNodeCount);
display.drawString(128, 0, status);
// Horizontal line
display.drawLine(0, 12, 128, 12);
@@ -329,32 +389,39 @@ void updateDisplay() {
if (sensor1Valid) {
char buf[20];
snprintf(buf, sizeof(buf), "%.1f°F", temperature1_f);
display.drawString(64, 18, buf);
display.drawString(64, 16, buf);
} else {
display.drawString(64, 18, "---");
display.drawString(64, 16, "---");
}
display.setFont(ArialMT_Plain_10);
display.drawString(64, 36, "Primary");
display.drawString(64, 34, "Tank Primary");
// Secondary sensor (smaller, below)
if (sensorCount >= 2) {
display.setFont(ArialMT_Plain_10);
if (sensor2Valid) {
char buf[20];
snprintf(buf, sizeof(buf), "Backup: %.1f°F", temperature2_f);
display.drawString(64, 50, buf);
} else {
display.drawString(64, 50, "Backup: ---");
}
if (sensorCount >= 2 && sensor2Valid) {
char buf[20];
snprintf(buf, sizeof(buf), "Backup: %.1f°F", temperature2_f);
display.drawString(64, 46, buf);
}
// Buffer indicator (if offline)
// Status line at bottom
display.setTextAlignment(TEXT_ALIGN_LEFT);
char buf[32];
if (bufferCount > 0) {
display.setTextAlignment(TEXT_ALIGN_LEFT);
char buf[20];
snprintf(buf, sizeof(buf), "Buf:%d", bufferCount);
display.drawString(0, 54, buf);
} else if (loraPacketsReceived > 0) {
snprintf(buf, sizeof(buf), "RX:%lu", loraPacketsReceived);
} else {
buf[0] = '\0';
}
display.drawString(0, 54, buf);
// RSSI of last LoRa packet
if (loraPacketsReceived > 0) {
display.setTextAlignment(TEXT_ALIGN_RIGHT);
snprintf(buf, sizeof(buf), "%ddBm", lastLoraRssi);
display.drawString(128, 54, buf);
}
display.display();
@@ -438,6 +505,402 @@ void sendBufferedReadings() {
bufferHead = 0;
}
// ============================================================================
// LORA SETUP AND RECEIVE
// ============================================================================
void setupLoRa() {
Serial.println("Initializing LoRa radio (SX1262)...");
// Initialize SPI for LoRa
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_SS);
// Initialize radio
// RadioLib SX1262.begin(freq, bw_kHz, sf, cr, syncWord, power, preambleLen, tcxoVoltage)
int state = radio.begin(
LORA_FREQUENCY,
LORA_BANDWIDTH / 1000.0, // Convert Hz to kHz
LORA_SPREADING_FACTOR,
LORA_CODING_RATE,
LORA_SYNC_WORD,
14, // TX power (dBm)
8, // Preamble length
0 // TCXO voltage (0 = use default)
);
if (state == RADIOLIB_ERR_NONE) {
Serial.println("LoRa init success!");
} else {
Serial.printf("LoRa init failed, code %d\n", state);
displayError("LoRa init failed!");
return;
}
// Set up receive interrupt
radio.setDio1Action(onLoRaReceive);
// Start receiving
state = radio.startReceive();
if (state != RADIOLIB_ERR_NONE) {
Serial.printf("LoRa receive start failed: %d\n", state);
} else {
Serial.println("LoRa listening on 915 MHz...");
}
}
void checkLoRa() {
// Check if packet received via interrupt
if (!loraReceived) return;
loraReceived = false;
// Read packet
uint8_t rxBuffer[LORA_MAX_PACKET_SIZE];
size_t rxLen = 0;
int state = radio.readData(rxBuffer, rxLen);
if (state == RADIOLIB_ERR_NONE) {
lastLoraRssi = radio.getRSSI();
loraPacketsReceived++;
Serial.printf("LoRa RX: %d bytes, RSSI %d dBm\n", rxLen, lastLoraRssi);
// Process the packet
processLoRaPacket(rxBuffer, rxLen);
} else if (state == RADIOLIB_ERR_CRC_MISMATCH) {
loraPacketsCrcFail++;
Serial.println("LoRa CRC error");
} else {
Serial.printf("LoRa RX error: %d\n", state);
}
// Restart receive mode
radio.startReceive();
}
void processLoRaPacket(uint8_t* data, uint8_t len) {
lora_packet_t pkt;
if (!lora_packet_parse(&pkt, data, len)) {
Serial.println("Invalid packet format");
return;
}
Serial.printf("Packet from 0x%04X, type %d, seq %d\n",
pkt.node_id, pkt.type, pkt.sequence);
switch (pkt.type) {
case LORA_PKT_SENSOR_DATA:
handleSensorData(&pkt);
break;
case LORA_PKT_REGISTER:
handleRegistration(&pkt);
break;
case LORA_PKT_ALERT:
// TODO: Handle alerts with higher priority
handleSensorData(&pkt); // For now, treat like sensor data
break;
case LORA_PKT_PONG:
Serial.printf("PONG from 0x%04X\n", pkt.node_id);
break;
default:
Serial.printf("Unknown packet type: %d\n", pkt.type);
break;
}
}
void handleSensorData(const lora_packet_t* pkt) {
const uint8_t* payload = lora_packet_payload(pkt);
uint8_t payload_len = lora_packet_payload_len(pkt);
if (payload_len < 1) {
Serial.println("Empty payload");
return;
}
uint8_t sensor_type = payload[0];
// Update node registry
RemoteNode* node = findNode(pkt->node_id);
if (node) {
node->last_seen = millis();
node->last_seq = pkt->sequence;
}
// Parse based on sensor type and forward to API
switch (sensor_type) {
case SENSOR_TYPE_DHT22: {
lora_dht22_data_t data;
if (lora_parse_dht22(payload, payload_len, &data)) {
float temp_f = celsiusToFahrenheit(data.temp_c);
Serial.printf("DHT22 from 0x%04X: %.1f°F, %.1f%% RH\n",
pkt->node_id, temp_f, data.humidity_pct);
if (node) node->battery_mv = data.battery_mv;
postLoRaReadingToApi(pkt, "dht22", temp_f, data.humidity_pct,
0, 0, 0, data.battery_mv, data.rssi_dbm);
}
break;
}
case SENSOR_TYPE_BME680: {
lora_bme680_data_t data;
if (lora_parse_bme680(payload, payload_len, &data)) {
float temp_f = celsiusToFahrenheit(data.temp_c);
Serial.printf("BME680 from 0x%04X: %.1f°F, %.1f%%, %.0f hPa, IAQ %d\n",
pkt->node_id, temp_f, data.humidity_pct,
data.pressure_hpa, data.iaq_index);
if (node) node->battery_mv = data.battery_mv;
postLoRaReadingToApi(pkt, "bme680", temp_f, data.humidity_pct,
data.pressure_hpa, data.gas_kohm,
data.iaq_index, data.battery_mv, data.rssi_dbm);
}
break;
}
case SENSOR_TYPE_DS18B20: {
lora_ds18b20_data_t data;
if (lora_parse_ds18b20(payload, payload_len, &data)) {
float temp_f = celsiusToFahrenheit(data.temp_c);
Serial.printf("DS18B20 from 0x%04X: %.2f°F\n",
pkt->node_id, temp_f);
if (node) node->battery_mv = data.battery_mv;
postLoRaReadingToApi(pkt, "ds18b20", temp_f, 0, 0, 0, 0,
data.battery_mv, data.rssi_dbm);
}
break;
}
default:
Serial.printf("Unknown sensor type: 0x%02X\n", sensor_type);
break;
}
// Send ACK if requested
if (pkt->ack_requested) {
sendAck(pkt->node_id, pkt->sequence);
}
}
void handleRegistration(const lora_packet_t* pkt) {
const uint8_t* payload = lora_packet_payload(pkt);
uint8_t payload_len = lora_packet_payload_len(pkt);
lora_register_data_t reg;
if (!lora_parse_register(payload, payload_len, &reg)) {
Serial.println("Invalid registration packet");
return;
}
Serial.printf("Registration from 0x%04X: '%s', type %d, FW %d.%d\n",
pkt->node_id, reg.node_name, reg.sensor_type,
reg.fw_major, reg.fw_minor);
// Check if node already registered
RemoteNode* existing = findNode(pkt->node_id);
if (existing && pkt->node_id != NODE_ID_AUTO_REQUEST) {
// Re-registration, update info
existing->last_seen = millis();
strncpy(existing->name, reg.node_name, 8);
existing->name[8] = '\0';
sendRegistrationAck(pkt->node_id, pkt->sequence, pkt->node_id);
return;
}
// Allocate new node ID if requested
uint16_t assigned_id = reg.proposed_id;
if (assigned_id == NODE_ID_AUTO_REQUEST) {
assigned_id = allocateNodeId(reg.sensor_type);
Serial.printf("Auto-assigned ID: 0x%04X\n", assigned_id);
}
// Add to registry
RemoteNode* node = addNode(assigned_id, reg.sensor_type, reg.node_name);
if (node) {
sendRegistrationAck(pkt->node_id, pkt->sequence, assigned_id);
Serial.printf("Node 0x%04X registered as '%s'\n", assigned_id, node->name);
} else {
Serial.println("Node registry full!");
// TODO: Send NAK
}
}
void postLoRaReadingToApi(const lora_packet_t* pkt, const char* sensorType,
float temp_f, float humidity, float pressure,
uint16_t gas_kohm, uint8_t iaq, uint16_t battery_mv,
int8_t rssi) {
if (!wifiConnected) {
Serial.println("No WiFi - can't forward LoRa reading");
// TODO: Buffer LoRa readings
return;
}
HTTPClient http;
http.begin(ZNET_API_URL);
http.addHeader("Content-Type", "application/json");
http.setTimeout(HTTP_TIMEOUT_MS);
// Build JSON payload
JsonDocument doc;
doc["device_id"] = DEVICE_ID;
doc["device_name"] = DEVICE_NAME;
JsonArray readings = doc["readings"].to<JsonArray>();
JsonObject r = readings.add<JsonObject>();
// Sensor ID includes LoRa prefix and node ID
char sensor_id[32];
snprintf(sensor_id, sizeof(sensor_id), "lora_0x%04X", pkt->node_id);
r["sensor_id"] = sensor_id;
r["sensor_type"] = sensorType;
r["temperature_f"] = round(temp_f * 100) / 100.0;
r["is_valid"] = true;
r["rssi_dbm"] = rssi;
r["battery_mv"] = battery_mv;
// Add optional fields based on sensor type
if (humidity > 0) {
r["humidity_pct"] = round(humidity * 10) / 10.0;
}
if (pressure > 0) {
r["pressure_hpa"] = round(pressure * 10) / 10.0;
}
if (gas_kohm > 0) {
r["gas_resistance_kohm"] = gas_kohm;
}
if (iaq > 0) {
r["iaq_index"] = iaq;
}
String jsonString;
serializeJson(doc, jsonString);
int httpCode = http.POST(jsonString);
if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_CREATED) {
loraPacketsForwarded++;
Serial.printf("LoRa reading forwarded (0x%04X)\n", pkt->node_id);
} else {
Serial.printf("LoRa forward failed: %d\n", httpCode);
}
http.end();
}
void sendAck(uint16_t node_id, uint8_t seq) {
lora_packet_t ack;
lora_packet_init(&ack, LORA_PKT_ACK, node_id, seq, false, false, false);
lora_packet_finalize(&ack);
int state = radio.transmit(ack.data, ack.length);
if (state == RADIOLIB_ERR_NONE) {
Serial.printf("ACK sent to 0x%04X seq %d\n", node_id, seq);
} else {
Serial.printf("ACK send failed: %d\n", state);
}
// Return to receive mode
radio.startReceive();
}
void sendRegistrationAck(uint16_t node_id, uint8_t seq, uint16_t assigned_id) {
lora_packet_t ack;
lora_packet_init(&ack, LORA_PKT_REGISTER_ACK, node_id, seq, false, false, false);
lora_packet_add_register_ack(&ack, assigned_id, 0, TEMP_READ_INTERVAL_MS / 1000);
lora_packet_finalize(&ack);
int state = radio.transmit(ack.data, ack.length);
if (state == RADIOLIB_ERR_NONE) {
Serial.printf("Registration ACK sent to 0x%04X -> 0x%04X\n", node_id, assigned_id);
} else {
Serial.printf("Registration ACK send failed: %d\n", state);
}
// Return to receive mode
radio.startReceive();
}
RemoteNode* findNode(uint16_t node_id) {
for (int i = 0; i < remoteNodeCount; i++) {
if (remoteNodes[i].active && remoteNodes[i].node_id == node_id) {
return &remoteNodes[i];
}
}
return nullptr;
}
RemoteNode* addNode(uint16_t node_id, uint8_t sensor_type, const char* name) {
// Find empty slot
RemoteNode* node = nullptr;
for (int i = 0; i < MAX_REMOTE_NODES; i++) {
if (!remoteNodes[i].active) {
node = &remoteNodes[i];
break;
}
}
if (!node) return nullptr;
node->node_id = node_id;
node->sensor_type = sensor_type;
strncpy(node->name, name, 8);
node->name[8] = '\0';
node->last_seq = 0;
node->last_seen = millis();
node->battery_mv = 0;
node->active = true;
remoteNodeCount++;
return node;
}
uint16_t allocateNodeId(uint8_t sensor_type) {
// Determine ID range based on sensor type
uint16_t base_id;
switch (sensor_type) {
case SENSOR_TYPE_DHT22:
base_id = NODE_ID_AMBIENT_START;
break;
case SENSOR_TYPE_BME680:
base_id = NODE_ID_ENVIRO_START;
break;
case SENSOR_TYPE_DS18B20:
base_id = NODE_ID_TANK_START;
break;
default:
base_id = NODE_ID_AUTO_START;
break;
}
// Find next available ID in range
for (uint16_t id = base_id; id < base_id + 0xFF; id++) {
if (!findNode(id)) {
return id;
}
}
// Fallback to auto range
for (uint16_t id = NODE_ID_AUTO_START; id < NODE_ID_AUTO_END; id++) {
if (!findNode(id)) {
return id;
}
}
return 0xFFFE; // Last resort
}
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================