Initial commit: ZNET Temperature Sensor ESP32 firmware

- PlatformIO project for Meshnology ESP32 LoRa V3
- DS18B20 dual sensor support for tank verification
- OLED display for local temperature visibility
- HTTP POST to ZNET Web API
- WiFiManager for easy WiFi configuration
- Offline buffering when network unavailable

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
leehughes
2026-01-24 16:48:12 -06:00
co-authored by Claude Opus 4.5
commit 265a53768e
5 changed files with 819 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# PlatformIO
.pio/
.pioenvs/
.piolibdeps/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Compiled files
*.o
*.a
*.elf
*.bin
*.hex
# OS files
.DS_Store
Thumbs.db
# Build artifacts
build/
# Credentials (if stored locally)
credentials.h
secrets.h
+153
View File
@@ -0,0 +1,153 @@
# ZNET Temperature Sensor - ESP32 LoRa Gateway
## Project Overview
ESP32-based temperature monitoring system for e-coat paint tanks. Reads DS18B20 waterproof temperature probes immersed in the paint tank and sends data to ZNET Web for real-time monitoring and ML feature collection.
## Hardware
| Component | Model | Purpose |
|-----------|-------|---------|
| MCU | Meshnology ESP32 LoRa V3 | Main controller with WiFi, LoRa, OLED |
| Temp Sensor | DS18B20 (x2) | Waterproof stainless steel probes in paint tank |
| Display | Built-in 0.96" OLED | Local temperature display for operators |
| Radio | Built-in SX1262 | LoRa 915MHz for future remote sensors |
## Quick Start
### Build & Upload
```bash
# Install PlatformIO CLI
pip install platformio
# Build
cd ~/Nextcloud/Dev/znet-temp-sensor
pio run
# Upload to ESP32 (connect via USB)
pio run --target upload
# Monitor serial output
pio device monitor
```
### Configure WiFi
1. Power on the ESP32
2. Connect to WiFi network: `ZNET-TempSensor` (password: `znettemp123`)
3. Browser opens captive portal - enter your WiFi credentials
4. Device connects and starts posting to ZNET Web
### Configure API Endpoint
Edit `include/config.h`:
```cpp
#define ZNET_API_URL "http://192.168.1.100:8000/api/v1/sensors/readings"
```
## Wiring
### DS18B20 Connection (1-Wire bus, both sensors on same pin)
```
ESP32 GPIO7 ─────┬───────────── DS18B20 #1 DATA (yellow)
│
└───────────── DS18B20 #2 DATA (yellow)
ESP32 3.3V ──────┬───────────── DS18B20 #1 VCC (red)
│
└───────────── DS18B20 #2 VCC (red)
ESP32 GND ───────┬───────────── DS18B20 #1 GND (black)
│
└───────────── DS18B20 #2 GND (black)
4.7kΩ pullup resistor between DATA and VCC
```
### GPIO Pinout (Heltec V3 / Meshnology)
| Function | GPIO |
|----------|------|
| OneWire Data | 7 |
| OLED SDA | 17 |
| OLED SCL | 18 |
| OLED RST | 21 |
| LoRa SCK | 9 |
| LoRa MISO | 11 |
| LoRa MOSI | 10 |
| LoRa SS | 8 |
| LoRa RST | 12 |
| LoRa DIO1 | 14 |
| LoRa BUSY | 13 |
## Configuration
All configuration in `include/config.h`:
| Setting | Default | Description |
|---------|---------|-------------|
| `ZNET_API_URL` | - | ZNET Web API endpoint |
| `DEVICE_ID` | `tank_gateway_1` | Unique device identifier |
| `TEMP_READ_INTERVAL_MS` | 30000 | Reading interval (30 sec) |
| `TEMP_MIN_VALID_F` | 32.0 | Minimum valid temperature |
| `TEMP_MAX_VALID_F` | 150.0 | Maximum valid temperature |
## API Integration
### POST /api/v1/sensors/readings
```json
{
"device_id": "tank_gateway_1",
"device_name": "E-Coat Tank Gateway",
"readings": [
{
"sensor_id": "tank_primary",
"temperature_f": 85.5,
"is_valid": true
},
{
"sensor_id": "tank_secondary",
"temperature_f": 85.3,
"is_valid": true
}
]
}
```
### Response
```json
{
"success": true,
"readings_stored": 2,
"alerts_generated": 0
}
```
## Features
### Implemented
- [x] Dual DS18B20 sensor reading with validation
- [x] OLED display showing current temperature
- [x] WiFiManager for easy WiFi setup
- [x] HTTP POST to ZNET Web API
- [x] Offline buffering when network unavailable
- [x] Automatic sensor discovery (1-Wire)
### Future (Phase 3)
- [ ] LoRa receive for remote sensor nodes
- [ ] Battery-powered remote node firmware
- [ ] Sensor agreement monitoring
- [ ] Flash storage for offline buffer persistence
## Related Projects
- **ZNET Web**: `~/Nextcloud/Dev/znet-web` - Backend receives temperature data
- **Baserow**: Table for lab temperature logs (to be created)
## Git Repository
- **Remote:** https://git.ecoat.us/leehughes/znet-temp-sensor.git
- **Main branch:** `main`
## Change Log
| Date | Version | Change |
|------|---------|--------|
| 2026-01-24 | 1.0.0 | Initial implementation |
+100
View File
@@ -0,0 +1,100 @@
/**
* ZNET Temperature Sensor Configuration
*
* Edit these values for your installation.
*/
#ifndef CONFIG_H
#define CONFIG_H
// ============================================================================
// NETWORK CONFIGURATION
// ============================================================================
// WiFi credentials (can also be set via WiFiManager portal)
#define WIFI_SSID "" // Leave empty to use WiFiManager
#define WIFI_PASSWORD ""
// ZNET Web API endpoint
#define ZNET_API_URL "http://192.168.1.100:8000/api/v1/sensors/readings"
// Device identification
#define DEVICE_ID "tank_gateway_1"
#define DEVICE_NAME "E-Coat Tank Gateway"
// ============================================================================
// SENSOR CONFIGURATION
// ============================================================================
// DS18B20 1-Wire data pin
#ifndef ONEWIRE_PIN
#define ONEWIRE_PIN 7
#endif
// Temperature reading interval (milliseconds)
#define TEMP_READ_INTERVAL_MS 30000 // 30 seconds
// Number of readings to average for stability
#define TEMP_AVG_READINGS 3
// Sensor IDs (auto-detected, but can be named)
#define SENSOR_1_NAME "tank_primary"
#define SENSOR_2_NAME "tank_secondary"
// Temperature validation range (Fahrenheit)
#define TEMP_MIN_VALID_F 32.0
#define TEMP_MAX_VALID_F 150.0
// ============================================================================
// DISPLAY CONFIGURATION
// ============================================================================
// OLED I2C address
#define OLED_ADDRESS 0x3C
// Display update interval (milliseconds)
#define DISPLAY_UPDATE_INTERVAL_MS 1000
// ============================================================================
// LORA CONFIGURATION (for future remote sensor nodes)
// ============================================================================
// LoRa frequency (915 MHz for US)
#define LORA_FREQUENCY 915.0
// LoRa bandwidth (125 kHz)
#define LORA_BANDWIDTH 125.0
// LoRa spreading factor (7-12, higher = longer range but slower)
#define LORA_SPREADING_FACTOR 9
// LoRa coding rate (5-8)
#define LORA_CODING_RATE 7
// LoRa sync word (private network)
#define LORA_SYNC_WORD 0x12
// ============================================================================
// API RETRY CONFIGURATION
// ============================================================================
// Maximum retries for API calls
#define API_MAX_RETRIES 3
// Retry delay (milliseconds)
#define API_RETRY_DELAY_MS 5000
// HTTP timeout (milliseconds)
#define HTTP_TIMEOUT_MS 10000
// ============================================================================
// BUFFER CONFIGURATION (for offline operation)
// ============================================================================
// Maximum readings to buffer when offline
#define OFFLINE_BUFFER_SIZE 100
// Save buffer to flash on reboot
#define BUFFER_PERSIST_TO_FLASH true
#endif // CONFIG_H
+51
View File
@@ -0,0 +1,51 @@
; PlatformIO Project Configuration File
; ZNET Temperature Sensor - ESP32 LoRa Gateway
;
; Board: Meshnology ESP32 LoRa V3 (Heltec-compatible)
; Features: DS18B20 temp sensors, OLED display, LoRa RX, WiFi TX
[env:heltec_wifi_lora_32_V3]
platform = espressif32
board = heltec_wifi_lora_32_V3
framework = arduino
; Serial monitor
monitor_speed = 115200
; Build flags
build_flags =
-DCORE_DEBUG_LEVEL=3
-DARDUINO_USB_MODE=1
; OLED display pins (Heltec V3)
-DOLED_SDA=17
-DOLED_SCL=18
-DOLED_RST=21
; LoRa pins (SX1262)
-DLORA_SCK=9
-DLORA_MISO=11
-DLORA_MOSI=10
-DLORA_SS=8
-DLORA_RST=12
-DLORA_DIO1=14
-DLORA_BUSY=13
; DS18B20 data pin
-DONEWIRE_PIN=7
; Libraries
lib_deps =
; OneWire for DS18B20
paulstoffregen/OneWire@^2.3.8
; Dallas Temperature library
milesburton/DallasTemperature@^3.11.0
; OLED display (SSD1306)
thingpulse/ESP8266 and ESP32 OLED driver for SSD1306 displays@^4.6.1
; HTTP client (built-in, but explicit)
; JSON serialization
bblanchon/ArduinoJson@^7.2.1
; LoRa radio (RadioLib for SX1262)
jgromes/RadioLib@^7.1.2
; WiFi Manager for easy setup
tzapu/WiFiManager@^2.0.17
; Upload settings
upload_speed = 921600
+486
View File
@@ -0,0 +1,486 @@
/**
* ZNET Temperature Sensor - ESP32 LoRa Gateway
*
* Reads DS18B20 temperature sensors in the e-coat paint tank and sends
* data to ZNET Web for real-time monitoring and ML feature collection.
*
* 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)
*
* 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
* - WiFiManager for easy WiFi setup
*/
#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <SSD1306Wire.h>
#include <WiFiManager.h>
#include "config.h"
// ============================================================================
// GLOBALS
// ============================================================================
// OneWire and DS18B20
OneWire oneWire(ONEWIRE_PIN);
DallasTemperature sensors(&oneWire);
// Sensor addresses (discovered at startup)
DeviceAddress sensor1Address;
DeviceAddress sensor2Address;
int sensorCount = 0;
// OLED display
SSD1306Wire display(OLED_ADDRESS, OLED_SDA, OLED_SCL);
// Timing
unsigned long lastTempRead = 0;
unsigned long lastDisplayUpdate = 0;
unsigned long lastApiPost = 0;
// Current readings
float temperature1_f = 0.0;
float temperature2_f = 0.0;
bool sensor1Valid = false;
bool sensor2Valid = false;
// Network status
bool wifiConnected = false;
int apiFailCount = 0;
// Offline buffer (circular)
struct TempReading {
unsigned long timestamp;
float temp1_f;
float temp2_f;
bool sent;
};
TempReading offlineBuffer[OFFLINE_BUFFER_SIZE];
int bufferHead = 0;
int bufferCount = 0;
// ============================================================================
// FUNCTION DECLARATIONS
// ============================================================================
void setupWiFi();
void setupSensors();
void setupDisplay();
void readTemperatures();
void updateDisplay();
void postToApi();
void sendBufferedReadings();
float celsiusToFahrenheit(float celsius);
bool isValidTemperature(float temp_f);
String formatAddress(DeviceAddress addr);
void displaySplash();
void displayError(const char* message);
// ============================================================================
// SETUP
// ============================================================================
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n\n");
Serial.println("=========================================");
Serial.println(" ZNET Temperature Sensor v1.0");
Serial.println(" E-Coat Paint Tank Monitor");
Serial.println("=========================================");
// Initialize display first for visual feedback
setupDisplay();
displaySplash();
// Initialize sensors
setupSensors();
// Connect to WiFi
setupWiFi();
Serial.println("\nSetup complete. Starting main loop...\n");
}
// ============================================================================
// MAIN LOOP
// ============================================================================
void loop() {
unsigned long now = millis();
// Check WiFi connection
wifiConnected = WiFi.status() == WL_CONNECTED;
// Read temperatures at interval
if (now - lastTempRead >= TEMP_READ_INTERVAL_MS || lastTempRead == 0) {
readTemperatures();
lastTempRead = now;
// Try to post to API
if (wifiConnected) {
postToApi();
// Also try to send any buffered readings
if (bufferCount > 0) {
sendBufferedReadings();
}
} else {
// Buffer the reading for later
if (bufferCount < OFFLINE_BUFFER_SIZE) {
offlineBuffer[bufferHead].timestamp = now;
offlineBuffer[bufferHead].temp1_f = temperature1_f;
offlineBuffer[bufferHead].temp2_f = temperature2_f;
offlineBuffer[bufferHead].sent = false;
bufferHead = (bufferHead + 1) % OFFLINE_BUFFER_SIZE;
bufferCount++;
Serial.printf("Buffered reading (count: %d)\n", bufferCount);
}
}
}
// Update display more frequently
if (now - lastDisplayUpdate >= DISPLAY_UPDATE_INTERVAL_MS) {
updateDisplay();
lastDisplayUpdate = now;
}
// Small delay to prevent watchdog issues
delay(10);
}
// ============================================================================
// WIFI SETUP
// ============================================================================
void setupWiFi() {
display.clear();
display.setTextAlignment(TEXT_ALIGN_CENTER);
display.drawString(64, 20, "Connecting to WiFi...");
display.display();
// Check if we have hardcoded credentials
if (strlen(WIFI_SSID) > 0) {
Serial.printf("Connecting to WiFi: %s\n", WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nWiFi connected!");
Serial.printf("IP: %s\n", WiFi.localIP().toString().c_str());
wifiConnected = true;
return;
}
}
// Use WiFiManager for captive portal setup
Serial.println("Starting WiFiManager...");
display.clear();
display.drawString(64, 10, "WiFi Setup");
display.drawString(64, 30, "Connect to:");
display.drawString(64, 45, "ZNET-TempSensor");
display.display();
WiFiManager wm;
wm.setConfigPortalTimeout(180); // 3 minute timeout
if (!wm.autoConnect("ZNET-TempSensor", "znettemp123")) {
Serial.println("Failed to connect - restarting...");
delay(3000);
ESP.restart();
}
Serial.println("WiFi connected via WiFiManager!");
Serial.printf("IP: %s\n", WiFi.localIP().toString().c_str());
wifiConnected = true;
}
// ============================================================================
// SENSOR SETUP
// ============================================================================
void setupSensors() {
Serial.println("Initializing DS18B20 sensors...");
sensors.begin();
sensorCount = sensors.getDeviceCount();
Serial.printf("Found %d sensor(s)\n", sensorCount);
if (sensorCount == 0) {
Serial.println("ERROR: No sensors found!");
displayError("No sensors found!");
return;
}
// Get addresses
if (sensorCount >= 1) {
if (sensors.getAddress(sensor1Address, 0)) {
Serial.printf("Sensor 1: %s\n", formatAddress(sensor1Address).c_str());
}
}
if (sensorCount >= 2) {
if (sensors.getAddress(sensor2Address, 1)) {
Serial.printf("Sensor 2: %s\n", formatAddress(sensor2Address).c_str());
}
}
// Set resolution (12-bit = 0.0625°C precision)
sensors.setResolution(12);
// Don't wait for conversion (we'll handle timing ourselves)
sensors.setWaitForConversion(false);
}
// ============================================================================
// TEMPERATURE READING
// ============================================================================
void readTemperatures() {
Serial.println("Reading temperatures...");
// Request temperatures from all sensors
sensors.requestTemperatures();
// Wait for conversion (750ms for 12-bit)
delay(750);
// Read sensor 1
if (sensorCount >= 1) {
float tempC = sensors.getTempC(sensor1Address);
if (tempC != DEVICE_DISCONNECTED_C) {
temperature1_f = celsiusToFahrenheit(tempC);
sensor1Valid = isValidTemperature(temperature1_f);
Serial.printf("Sensor 1: %.2f°F %s\n", temperature1_f,
sensor1Valid ? "(valid)" : "(INVALID)");
} else {
sensor1Valid = false;
Serial.println("Sensor 1: DISCONNECTED");
}
}
// Read sensor 2
if (sensorCount >= 2) {
float tempC = sensors.getTempC(sensor2Address);
if (tempC != DEVICE_DISCONNECTED_C) {
temperature2_f = celsiusToFahrenheit(tempC);
sensor2Valid = isValidTemperature(temperature2_f);
Serial.printf("Sensor 2: %.2f°F %s\n", temperature2_f,
sensor2Valid ? "(valid)" : "(INVALID)");
} else {
sensor2Valid = false;
Serial.println("Sensor 2: DISCONNECTED");
}
}
// Check for sensor agreement (if both valid)
if (sensor1Valid && sensor2Valid) {
float diff = abs(temperature1_f - temperature2_f);
if (diff > 5.0) {
Serial.printf("WARNING: Sensors disagree by %.1f°F\n", diff);
}
}
}
// ============================================================================
// DISPLAY UPDATE
// ============================================================================
void updateDisplay() {
display.clear();
// Title bar
display.setTextAlignment(TEXT_ALIGN_LEFT);
display.setFont(ArialMT_Plain_10);
display.drawString(0, 0, "ZNET Tank Temp");
// WiFi status indicator
display.setTextAlignment(TEXT_ALIGN_RIGHT);
display.drawString(128, 0, wifiConnected ? "WiFi OK" : "NO WiFi");
// Horizontal line
display.drawLine(0, 12, 128, 12);
// Temperature readings
display.setTextAlignment(TEXT_ALIGN_CENTER);
display.setFont(ArialMT_Plain_16);
if (sensor1Valid) {
char buf[20];
snprintf(buf, sizeof(buf), "%.1f°F", temperature1_f);
display.drawString(64, 18, buf);
} else {
display.drawString(64, 18, "---");
}
display.setFont(ArialMT_Plain_10);
display.drawString(64, 36, "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: ---");
}
}
// Buffer indicator (if offline)
if (bufferCount > 0) {
display.setTextAlignment(TEXT_ALIGN_LEFT);
char buf[20];
snprintf(buf, sizeof(buf), "Buf:%d", bufferCount);
display.drawString(0, 54, buf);
}
display.display();
}
// ============================================================================
// API POSTING
// ============================================================================
void postToApi() {
if (!wifiConnected) {
Serial.println("No WiFi - skipping API post");
return;
}
Serial.println("Posting to ZNET Web API...");
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;
doc["timestamp"] = millis(); // Server will use its own timestamp
JsonArray readings = doc["readings"].to<JsonArray>();
// Sensor 1
if (sensor1Valid) {
JsonObject r1 = readings.add<JsonObject>();
r1["sensor_id"] = SENSOR_1_NAME;
r1["temperature_f"] = round(temperature1_f * 100) / 100.0; // 2 decimal places
r1["is_valid"] = true;
}
// Sensor 2
if (sensor2Valid) {
JsonObject r2 = readings.add<JsonObject>();
r2["sensor_id"] = SENSOR_2_NAME;
r2["temperature_f"] = round(temperature2_f * 100) / 100.0;
r2["is_valid"] = true;
}
String jsonString;
serializeJson(doc, jsonString);
Serial.printf("Payload: %s\n", jsonString.c_str());
int httpCode = http.POST(jsonString);
if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_CREATED) {
Serial.printf("API POST success (code %d)\n", httpCode);
apiFailCount = 0;
// Parse response for any config updates
String response = http.getString();
Serial.printf("Response: %s\n", response.c_str());
} else {
Serial.printf("API POST failed (code %d)\n", httpCode);
apiFailCount++;
if (httpCode > 0) {
String response = http.getString();
Serial.printf("Error response: %s\n", response.c_str());
}
}
http.end();
}
void sendBufferedReadings() {
// TODO: Implement batch sending of buffered readings
// For now, just clear the buffer on successful connection
Serial.printf("Would send %d buffered readings\n", bufferCount);
// Clear buffer (proper implementation would send each reading)
bufferCount = 0;
bufferHead = 0;
}
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
float celsiusToFahrenheit(float celsius) {
return celsius * 9.0 / 5.0 + 32.0;
}
bool isValidTemperature(float temp_f) {
return temp_f >= TEMP_MIN_VALID_F && temp_f <= TEMP_MAX_VALID_F;
}
String formatAddress(DeviceAddress addr) {
char buf[20];
snprintf(buf, sizeof(buf), "%02X%02X%02X%02X%02X%02X%02X%02X",
addr[0], addr[1], addr[2], addr[3],
addr[4], addr[5], addr[6], addr[7]);
return String(buf);
}
void setupDisplay() {
display.init();
display.flipScreenVertically();
display.setFont(ArialMT_Plain_10);
display.setTextAlignment(TEXT_ALIGN_CENTER);
}
void displaySplash() {
display.clear();
display.setFont(ArialMT_Plain_16);
display.drawString(64, 10, "ZNET");
display.setFont(ArialMT_Plain_10);
display.drawString(64, 30, "Tank Temperature");
display.drawString(64, 45, "v1.0");
display.display();
delay(2000);
}
void displayError(const char* message) {
display.clear();
display.setFont(ArialMT_Plain_10);
display.setTextAlignment(TEXT_ALIGN_CENTER);
display.drawString(64, 20, "ERROR");
display.drawString(64, 35, message);
display.display();
}