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
+72 -3
View File
@@ -130,12 +130,78 @@ All configuration in `include/config.h`:
- [x] Offline buffering when network unavailable - [x] Offline buffering when network unavailable
- [x] Automatic sensor discovery (1-Wire) - [x] Automatic sensor discovery (1-Wire)
### Future (Phase 3) ### Phase 3: LoRa Network (Complete)
- [ ] LoRa receive for remote sensor nodes - [x] LoRa packet protocol design (docs/LORA_PROTOCOL.md)
- [ ] Battery-powered remote node firmware - [x] Protocol header files (include/lora_protocol.h, include/lora_packet.h)
- [x] Battery-powered remote node firmware (remote-node/)
- [x] LoRa receive for gateway (RadioLib SX1262)
- [x] Gateway LoRa → HTTP forwarding
- [x] Automatic node registration and ACK responses
### Future
- [ ] Sensor agreement monitoring - [ ] Sensor agreement monitoring
- [ ] Flash storage for offline buffer persistence - [ ] Flash storage for offline buffer persistence
## Remote Sensor Nodes
Battery-powered sensor nodes in `remote-node/` that transmit to the gateway via LoRa.
### Build & Upload
```bash
cd remote-node
# DHT22 variant (ambient temp/humidity)
pio run -e heltec_v3_dht22 --target upload
# BME680 variant (temp/humidity/pressure/VOC)
pio run -e heltec_v3_bme680 --target upload
```
### Configuration
Edit `remote-node/include/config.h`:
- `NODE_ID` - Unique ID or 0xFFFF for auto-assign
- `NODE_NAME` - Human-readable name (8 chars max)
- `REPORT_INTERVAL_SEC` - How often to transmit (default 60s)
### Power Consumption
- Deep sleep: ~10 μA
- Average @ 60s interval: ~1 mA
- 1000mAh LiPo battery life: ~1 month
## LoRa Protocol
The gateway will receive data from remote sensor nodes via LoRa 915MHz. Full specification in `docs/LORA_PROTOCOL.md`.
### Key Files
| File | Purpose |
|------|---------|
| `docs/LORA_PROTOCOL.md` | Full protocol specification |
| `include/lora_protocol.h` | Constants, types, CRC functions |
| `include/lora_packet.h` | Packet builder/parser helpers |
### Supported Sensors
| Type | Code | Payload Size | Data |
|------|------|--------------|------|
| DHT22 | 0x01 | 7 bytes | Temp, Humidity, Battery, RSSI |
| BME680 | 0x02 | 12 bytes | Temp, Humidity, Pressure, Gas, IAQ |
| DS18B20 | 0x03 | 5 bytes | Temp (high precision), Battery, RSSI |
### Node ID Ranges
| Range | Purpose |
|-------|---------|
| 0x0001-0x00FF | Tank sensors |
| 0x0100-0x01FF | Ambient sensors (DHT22) |
| 0x0200-0x02FF | Environmental (BME680) |
| 0xFFFF | Auto-assign request |
## Documentation
| Document | Purpose |
|----------|---------|
| `docs/SETUP_GUIDE.md` | Complete setup guide with wiring diagrams |
| `docs/LORA_PROTOCOL.md` | LoRa packet format specification |
| `remote-node/README.md` | Remote node firmware guide |
## Related Projects ## Related Projects
- **ZNET Web**: `~/Nextcloud/Dev/znet-web` - Backend receives temperature data - **ZNET Web**: `~/Nextcloud/Dev/znet-web` - Backend receives temperature data
@@ -151,3 +217,6 @@ All configuration in `include/config.h`:
| Date | Version | Change | | Date | Version | Change |
|------|---------|--------| |------|---------|--------|
| 2026-01-24 | 1.0.0 | Initial implementation | | 2026-01-24 | 1.0.0 | Initial implementation |
| 2026-01-24 | 1.1.0 | LoRa protocol design, header files |
| 2026-01-24 | 1.2.0 | Remote node firmware template (DHT22, BME680, DS18B20) |
| 2026-01-24 | 1.3.0 | Gateway LoRa receive, node registry, HTTP forwarding |
+409
View File
@@ -0,0 +1,409 @@
# ZNET LoRa Sensor Network Protocol
## Overview
This document specifies the LoRa packet protocol for communication between remote sensor nodes and the ZNET gateway. The protocol is designed for:
- **Efficiency**: Minimal packet size for LoRa's limited bandwidth
- **Reliability**: CRC16 checksums and sequence numbers for error detection
- **Flexibility**: Support for multiple sensor types
- **Battery life**: Low power design with configurable sleep intervals
## Network Architecture
```
┌─────────────────┐ LoRa 915MHz ┌─────────────────┐
│ Remote Node 1 │◄───────────────────►│ │
│ (DHT22) │ │ Gateway │
├─────────────────┤ LoRa 915MHz │ ESP32 LoRa V3 │ WiFi ┌──────────┐
│ Remote Node 2 │◄───────────────────►│ │◄─────────────►│ ZNET Web │
│ (BME680) │ │ tank_gateway_1│ │ Backend │
├─────────────────┤ LoRa 915MHz │ │ └──────────┘
│ Remote Node 3 │◄───────────────────►│ │
│ (DHT22) │ └─────────────────┘
└─────────────────┘
```
## Packet Format
### General Structure
All packets follow this structure:
```
┌───────┬────────┬──────────┬─────────┬────────────┬──────┐
│ SYNC │ HEADER │ NODE ID │ SEQ │ PAYLOAD │ CRC │
│ 2B │ 1B │ 2B │ 1B │ 0-64B │ 2B │
└───────┴────────┴──────────┴─────────┴────────────┴──────┘
```
| Field | Size | Description |
|-------|------|-------------|
| SYNC | 2 bytes | Magic bytes `0x5A 0x4E` ("ZN" for ZNET) |
| HEADER | 1 byte | Packet type and flags |
| NODE ID | 2 bytes | Unique node identifier (0x0001-0xFFFE) |
| SEQ | 1 byte | Sequence number (0-255, wraps) |
| PAYLOAD | 0-64 bytes | Type-specific data |
| CRC | 2 bytes | CRC16-CCITT of all preceding bytes |
### Header Byte
```
Bit 7-4: Packet Type (0-15)
Bit 3: ACK Request (1 = wants acknowledgment)
Bit 2: Battery Low (1 = battery < 20%)
Bit 1: First Boot (1 = node just powered on)
Bit 0: Reserved (0)
```
### Packet Types
| Type | Value | Direction | Description |
|------|-------|-----------|-------------|
| SENSOR_DATA | 0x0 | Node → Gateway | Sensor readings |
| ACK | 0x1 | Gateway → Node | Acknowledgment |
| NAK | 0x2 | Gateway → Node | Negative ack (resend) |
| CONFIG_REQ | 0x3 | Node → Gateway | Request configuration |
| CONFIG_RESP | 0x4 | Gateway → Node | Configuration response |
| PING | 0x5 | Gateway → Node | Check if node alive |
| PONG | 0x6 | Node → Gateway | Response to ping |
| ALERT | 0x7 | Node → Gateway | Critical alert (immediate) |
| TIME_SYNC | 0x8 | Gateway → Node | Time synchronization |
| FIRMWARE_INFO | 0x9 | Node → Gateway | Firmware version info |
| REGISTER | 0xA | Node → Gateway | Node registration |
| REGISTER_ACK | 0xB | Gateway → Node | Registration accepted |
| Reserved | 0xC-0xF | - | Future use |
## Sensor Data Payload
### DHT22 Sensor (Type 0x01)
Temperature and humidity sensor for ambient conditions.
```
Offset Size Field
0 1 Sensor Type = 0x01
1 2 Temperature (°C × 10, signed int16, big-endian)
3 2 Humidity (% × 10, uint16, big-endian)
5 1 Battery Voltage (mV ÷ 20, 0-255 = 0-5100mV)
6 1 RSSI (signed int8, dBm)
─────────────
Total: 7 bytes
```
**Example**: 25.5°C, 65.0% humidity, 3.7V battery, -45 dBm RSSI
```
01 00 FF 02 8A B9 D3
│ └──┴── └──┴── │ └─ RSSI: -45 dBm
│ │ │ └─── Battery: 185 × 20 = 3700mV
│ │ └──────── Humidity: 650 / 10 = 65.0%
│ └────────────── Temperature: 255 / 10 = 25.5°C
└──────────────────── Sensor type: DHT22
```
### BME680 Sensor (Type 0x02)
Environmental sensor with gas resistance (VOC proxy).
```
Offset Size Field
0 1 Sensor Type = 0x02
1 2 Temperature (°C × 10, signed int16, big-endian)
3 2 Humidity (% × 10, uint16, big-endian)
5 2 Pressure (hPa - 900, uint16, big-endian)
7 2 Gas Resistance (kΩ, uint16, big-endian)
9 1 IAQ Index (0-500 air quality index, uint8)
10 1 Battery Voltage (mV ÷ 20)
11 1 RSSI (signed int8, dBm)
─────────────
Total: 12 bytes
```
**Notes**:
- Pressure is stored as offset from 900 hPa (range 900-965 hPa typical)
- IAQ Index: 0-50 = Good, 51-100 = Moderate, 101-150 = Poor, 151+ = Unhealthy
- Gas resistance correlates inversely with VOC presence
### DS18B20 Sensor (Type 0x03)
Waterproof temperature probe (if remote nodes need them).
```
Offset Size Field
0 1 Sensor Type = 0x03
1 2 Temperature (°C × 100, signed int16, big-endian)
3 1 Battery Voltage (mV ÷ 20)
4 1 RSSI (signed int8, dBm)
─────────────
Total: 5 bytes
```
**Note**: DS18B20 has 0.0625°C resolution, so we use × 100 for precision.
### Multi-Sensor Payload (Type 0x10)
For nodes with multiple sensors attached.
```
Offset Size Field
0 1 Sensor Type = 0x10
1 1 Sensor Count (1-4)
2 1 Sensor 1 Type
3 N Sensor 1 Data (type-specific, without type byte)
... (repeat for each sensor)
Last 1 Battery Voltage (mV ÷ 20)
Last+1 1 RSSI
```
## Configuration Payload
### CONFIG_REQ (Node → Gateway)
```
Offset Size Field
0 2 Current interval (seconds)
2 1 Firmware version major
3 1 Firmware version minor
4 8 Node name (null-padded ASCII)
```
### CONFIG_RESP (Gateway → Node)
```
Offset Size Field
0 2 Report interval (seconds, 0 = use default)
2 2 Warning threshold high (°C × 10)
4 2 Alert threshold high (°C × 10)
6 2 Warning threshold low (°C × 10, 0x8000 = disabled)
8 2 Alert threshold low (°C × 10, 0x8000 = disabled)
10 1 Flags:
Bit 0: Alerts enabled
Bit 1: ACK required
Bit 2-7: Reserved
```
## Registration Process
When a node powers on, it should register with the gateway:
```
1. Node sends REGISTER packet:
┌────────────────────────────────────────┐
│ Payload: │
│ 0-1: Proposed Node ID (or 0xFFFF) │
│ 2: Sensor Type │
│ 3: Firmware Version Major │
│ 4: Firmware Version Minor │
│ 5-12: Node Name (8 chars, null-pad) │
└────────────────────────────────────────┘
2. Gateway responds with REGISTER_ACK:
┌────────────────────────────────────────┐
│ Payload: │
│ 0-1: Assigned Node ID │
│ 2: Status (0=OK, 1=ID conflict) │
│ 3-4: Report interval (seconds) │
└────────────────────────────────────────┘
```
## Alert Packet
For critical conditions requiring immediate attention:
```
Offset Size Field
0 1 Alert Type:
0x01 = Temperature high
0x02 = Temperature low
0x03 = Humidity high
0x04 = Humidity low
0x05 = Battery critical
0x06 = Sensor failure
0x07 = VOC alert
1 2 Alert Value (type-specific)
3 2 Threshold Value
5 1 Duration (seconds this condition persisted)
```
## CRC16-CCITT Calculation
**Polynomial**: 0x1021
**Initial value**: 0xFFFF
**No final XOR**
```c
uint16_t crc16_ccitt(const uint8_t* data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; i++) {
crc ^= (uint16_t)data[i] << 8;
for (int j = 0; j < 8; j++) {
if (crc & 0x8000) {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
}
}
return crc;
}
```
## Timing and Duty Cycle
### LoRa Parameters (US 915 MHz)
| Parameter | Value | Notes |
|-----------|-------|-------|
| Frequency | 915.0 MHz | US ISM band |
| Bandwidth | 125 kHz | Standard LoRa |
| Spreading Factor | 9 | Balance of range/speed |
| Coding Rate | 4/7 | Forward error correction |
| Preamble | 8 symbols | Standard |
| Sync Word | 0x12 | Private network |
| TX Power | 14 dBm | Legal limit |
### Transmission Timing
| Packet Size | Air Time (SF9) | Duty Cycle @ 1% |
|-------------|----------------|-----------------|
| 15 bytes | ~51 ms | 1 packet / 5.1 sec |
| 20 bytes | ~67 ms | 1 packet / 6.7 sec |
| 30 bytes | ~97 ms | 1 packet / 9.7 sec |
**Recommended intervals**:
- Normal operation: 30-60 seconds
- Battery saving: 5-10 minutes
- Alert condition: 10 seconds (temporary)
### Node Sleep Schedule
```
┌────────────────────────────────────────────────────────┐
│ Wake Read TX RX Window Sleep │
│ (5ms) (100ms) (100ms) (500ms) (59s) │
│ ├──────────┼──────────┼──────────┼────────────┤ │
│ │ │ │ │ │ │
└───┴──────────┴──────────┴──────────┴────────────┴──────┘
```
## Node ID Assignment
| Range | Purpose |
|-------|---------|
| 0x0000 | Reserved (broadcast) |
| 0x0001-0x00FF | Tank sensors (DS18B20) |
| 0x0100-0x01FF | Ambient sensors (DHT22) |
| 0x0200-0x02FF | Environmental (BME680) |
| 0x0300-0x0FFF | Reserved for expansion |
| 0x1000-0xFFFE | Auto-assigned |
| 0xFFFF | Reserved (request auto-assign) |
## Example Packets
### DHT22 Sensor Data
Node 0x0101 reports 23.5°C, 55.0% humidity, 3.8V battery:
```
Hex: 5A 4E 08 01 01 42 01 00 EB 02 26 BE D3 XX XX
└──┴── │ └──┴── │ └─────────────────┴── CRC
│ │ └─ Seq: 0x42 (66)
│ └───── Node ID: 0x0101
└───────── Header: Type=0 (SENSOR_DATA), ACK_REQ=1
Payload breakdown:
01 - DHT22 type
00 EB - Temperature: 235 / 10 = 23.5°C
02 26 - Humidity: 550 / 10 = 55.0%
BE - Battery: 190 × 20 = 3800mV
D3 - RSSI: -45 dBm
```
### Gateway ACK
Gateway acknowledges sequence 0x42 from node 0x0101:
```
Hex: 5A 4E 10 01 01 42 XX XX
└──┴── │ └──┴── │ └── CRC
│ │ └─ Seq: 0x42 (echoed)
│ └───── Node ID: 0x0101
└───────── Header: Type=1 (ACK)
```
### BME680 Alert (High VOC)
Node 0x0201 sends VOC alert (IAQ = 175):
```
Hex: 5A 4E 78 02 01 15 07 00 AF 00 96 05 XX XX
└──┴── │ └──┴── │ │ └──┴── └──┴── └── CRC
│ │ │ │ │ └─ Threshold: 150
│ │ │ │ └──────── Alert value: 175
│ │ │ └────────────── Alert type: 0x07 (VOC)
│ │ └───────────────── Seq: 0x15 (21)
│ └───────────────────── Node ID: 0x0201
└─────────────────────────── Header: Type=7 (ALERT), ACK_REQ=1, BAT_LOW=1
```
## Implementation Notes
### Gateway Responsibilities
1. **Receive and decode** all incoming LoRa packets
2. **Validate CRC** and discard corrupt packets
3. **Track sequence numbers** per node for duplicate detection
4. **Send ACKs** for packets with ACK_REQ flag
5. **Forward data** to ZNET Web via HTTP POST
6. **Store node registry** with last-seen timestamps
7. **Send alerts** for nodes not reporting (timeout)
### Remote Node Responsibilities
1. **Sleep** between readings to conserve battery
2. **Read sensors** and validate readings
3. **Build and transmit** LoRa packet
4. **Listen for ACK** in RX window (if ACK_REQ set)
5. **Retry** up to 3 times if no ACK received
6. **Track battery** voltage and set BAT_LOW flag
7. **Register** with gateway on first boot
## Backend API Integration
The gateway should POST remote sensor data to the same endpoint as local sensors:
```json
{
"device_id": "tank_gateway_1",
"device_name": "E-Coat Tank Gateway",
"readings": [
{
"sensor_id": "lora_0x0101",
"sensor_type": "dht22",
"temperature_f": 74.3,
"humidity_pct": 55.0,
"is_valid": true,
"rssi_dbm": -45,
"battery_mv": 3800
},
{
"sensor_id": "lora_0x0201",
"sensor_type": "bme680",
"temperature_f": 76.1,
"humidity_pct": 48.5,
"pressure_hpa": 1013.2,
"gas_resistance_kohm": 150.5,
"iaq_index": 85,
"is_valid": true,
"rssi_dbm": -52,
"battery_mv": 3650
}
]
}
```
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2026-01-24 | Initial protocol specification |
+315
View File
@@ -0,0 +1,315 @@
# ZNET Temperature Sensor System - Setup Guide
Complete guide for setting up the ZNET temperature monitoring system with gateway and remote sensor nodes.
## System Overview
```
┌──────────────────────────────────────────────────────────────────────────┐
│ ZNET Temperature System │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ LoRa 915MHz ┌────────────┐ │
│ │ Remote #1 │◄──────────────────────────►│ │ │
│ │ DHT22 │ │ Gateway │ WiFi │
│ │ ambient │ LoRa 915MHz │ ESP32 │◄──────┐ │
│ └────────────┘◄──────────────────────────►│ LoRa V3 │ │ │
│ ┌────────────┐ │ │ │ │
│ │ Remote #2 │ LoRa 915MHz │ DS18B20 x2 │ │ │
│ │ BME680 │◄──────────────────────────►│ (in tank) │ │ │
│ │ VOC/air │ └─────┬──────┘ │ │
│ └────────────┘ │ │ │
│ │ OLED │ │
│ │ Display │ │
│ ▼ ▼ │
│ ┌──────────────────────────┐ │
│ │ ZNET Web Backend │ │
│ │ (FastAPI + PostgreSQL)│ │
│ └──────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
```
## Hardware Required
### Gateway (1x per tank)
| Part | Model | Qty | Purpose | Notes |
|------|-------|-----|---------|-------|
| MCU | Meshnology ESP32 LoRa V3 | 1 | Main controller | Built-in OLED, LoRa |
| Sensor | DS18B20 waterproof | 2 | Tank temperature | Stainless steel, 1m cable |
| Resistor | 4.7kΩ 1/4W | 1 | 1-Wire pullup | Between DATA and VCC |
| Enclosure | IP65 junction box | 1 | Protection | At least 120x80x50mm |
| Cable gland | PG9 | 2 | Wire entry | For sensor cables |
| Power | 5V 2A USB adapter | 1 | Gateway power | Or 3.7V LiPo with USB charging |
### Remote Nodes (optional, 0-16 per gateway)
| Part | Model | Qty | Purpose | Notes |
|------|-------|-----|---------|-------|
| MCU | Heltec WiFi LoRa 32 V3 | 1 | Node controller | Or TTGO LoRa32 |
| Sensor | DHT22 or BME680 | 1 | Temp/humidity/VOC | Choose based on need |
| Battery | 3.7V LiPo 1000mAh | 1 | Power | With JST connector |
| Enclosure | Weatherproof box | 1 | Protection | IP54 minimum |
## Wiring Diagrams
### Gateway Wiring (ESP32 LoRa V3 + DS18B20 x2)
```
ESP32 LoRa V3
┌─────────────────────┐
│ │
│ GPIO7 ─────┬───────┼──► DS18B20 #1 DATA (yellow)
│ │ │
│ └───────┼──► DS18B20 #2 DATA (yellow)
│ │
│ 3.3V ──────┬───────┼──► DS18B20 #1 VCC (red)
│ │ │
│ └───────┼──► DS18B20 #2 VCC (red)
│ │
│ GND ───────┬───────┼──► DS18B20 #1 GND (black)
│ │ │
│ └───────┼──► DS18B20 #2 GND (black)
│ │
└─────────────────────┘
Add 4.7kΩ resistor between GPIO7 and 3.3V (pullup)
DS18B20 Color Code:
- Red: VCC (3.3V)
- Black: GND
- Yellow: DATA (1-Wire)
```
### Remote Node Wiring (ESP32 LoRa V3 + DHT22)
```
ESP32 LoRa V3
┌─────────────────────┐
│ │ DHT22 Module
│ GPIO7 ─────────────┼──────► DATA
│ │
│ 3.3V ──────────────┼──────► VCC
│ │
│ GND ───────────────┼──────► GND
│ │
│ │
│ VBAT ◄─────────────┼────── LiPo + (red)
│ │
│ GND ◄──────────────┼────── LiPo - (black)
│ │
└─────────────────────┘
DHT22 Module (4-pin with PCB):
- VCC: 3.3V
- DATA: GPIO7 (10kΩ pullup usually built-in)
- NC: Not connected
- GND: Ground
```
### Remote Node Wiring (ESP32 + BME680 via I2C)
```
ESP32 LoRa V3
┌─────────────────────┐
│ │ BME680 Module
│ GPIO21 (SDA)───────┼──────► SDA
│ │
│ GPIO22 (SCL)───────┼──────► SCL
│ │
│ 3.3V ──────────────┼──────► VCC
│ │
│ GND ───────────────┼──────► GND
│ │
│ VBAT ◄─────────────┼────── LiPo + (red)
│ │
│ GND ◄──────────────┼────── LiPo - (black)
│ │
└─────────────────────┘
BME680 I2C Address: 0x76 (default) or 0x77
```
## Firmware Installation
### Prerequisites
```bash
# Install PlatformIO
pip install platformio
# Clone firmware repository
cd ~/Nextcloud/Dev
git clone https://git.ecoat.us/leehughes/znet-temp-sensor.git
cd znet-temp-sensor
```
### Flash Gateway
1. **Connect ESP32 via USB**
2. **Configure settings** - Edit `include/config.h`:
```cpp
// Set your ZNET Web API endpoint
#define ZNET_API_URL "http://192.168.1.100:8000/api/v1/sensors/readings"
// Set unique device ID
#define DEVICE_ID "tank_gateway_tulsa"
#define DEVICE_NAME "Tulsa E-Coat Tank Gateway"
```
3. **Build and upload**:
```bash
pio run --target upload
```
4. **Monitor serial output**:
```bash
pio device monitor
```
5. **Configure WiFi**:
- On first boot, gateway creates WiFi network: `ZNET-TempSensor`
- Connect to it with password: `znettemp123`
- Browser opens captive portal - enter your WiFi credentials
- Gateway reboots and connects
### Flash Remote Node
1. **Connect ESP32 via USB**
2. **Configure node** - Edit `remote-node/include/config.h`:
```cpp
// Set unique node ID (or 0xFFFF for auto-assign)
#define NODE_ID 0x0101
// Human-readable name (max 8 chars)
#define NODE_NAME "Ambient1"
// Report interval (seconds)
#define REPORT_INTERVAL_SEC 60
```
3. **Build and upload** (choose variant):
```bash
cd remote-node
# For DHT22 sensor:
pio run -e heltec_v3_dht22 --target upload
# For BME680 sensor:
pio run -e heltec_v3_bme680 --target upload
```
4. **Verify operation**:
- Check serial output for successful registration
- Gateway should show increased node count on OLED
## Backend Setup
### Database Migration
```bash
cd ~/Nextcloud/Dev/znet-web/backend
# Run migration to create temperature tables
uv run alembic upgrade head
```
### Verify API Endpoint
```bash
# Check sensors endpoint is working
curl http://localhost:8000/api/v1/sensors/
# Test posting a reading
curl -X POST http://localhost:8000/api/v1/sensors/readings \
-H "Content-Type: application/json" \
-d '{
"device_id": "test_gateway",
"device_name": "Test Gateway",
"readings": [
{
"sensor_id": "tank_primary",
"temperature_f": 85.5,
"is_valid": true
}
]
}'
```
## Testing
### Test Gateway Locally
1. Power on gateway
2. Verify OLED shows temperature readings
3. Check serial output for API POST success:
```
Reading temperatures...
Sensor 1: 85.50°F (valid)
Sensor 2: 85.30°F (valid)
Posting to ZNET Web API...
API POST success (code 200)
```
### Test Remote Node
1. Power on remote node
2. Watch gateway serial output:
```
LoRa RX: 15 bytes, RSSI -45 dBm
Packet from 0x0101, type 0, seq 42
DHT22 from 0x0101: 74.3°F, 55.0% RH
LoRa reading forwarded (0x0101)
ACK sent to 0x0101 seq 42
```
### Test WebSocket Updates
1. Open ZNET Web dashboard in browser
2. Verify temperature displays update without page refresh
3. Check browser console for WebSocket messages
## Troubleshooting
### Gateway Issues
| Problem | Cause | Solution |
|---------|-------|----------|
| "No sensors found" | Wiring issue | Check DATA line, pullup resistor |
| "WiFi failed" | Wrong credentials | Reset and reconfigure via portal |
| "API POST failed" | Network/server issue | Check URL, firewall, server logs |
| OLED blank | I2C issue | Check SDA/SCL connections |
### Remote Node Issues
| Problem | Cause | Solution |
|---------|-------|----------|
| "LoRa init failed" | SPI issue | Check pin definitions match board |
| No ACK received | Out of range | Move closer, increase SF |
| Short battery life | Wake too often | Increase REPORT_INTERVAL_SEC |
| Sensor read fails | Bad wiring | Check connections, I2C address |
### LoRa Range Issues
| Symptom | Solution |
|---------|----------|
| RSSI < -100 dBm | Move nodes closer or add gain antenna |
| Many CRC errors | Reduce spreading factor (SF) |
| ACK timeouts | Increase ACK_TIMEOUT_MS |
## Production Deployment Checklist
- [ ] Gateway powered via stable 5V supply (not USB from laptop)
- [ ] Tank sensors fully submerged in paint bath
- [ ] Gateway enclosure sealed against moisture/fumes
- [ ] WiFi signal strength verified at gateway location
- [ ] API endpoint accessible from gateway network
- [ ] Remote nodes battery charged and secured
- [ ] Remote node enclosures sealed
- [ ] All sensors reading within expected range
- [ ] Alert thresholds configured by lab manager
- [ ] WebSocket updates verified in browser
- [ ] Backup sensors agree within 5°F
+9 -6
View File
@@ -56,24 +56,27 @@
#define DISPLAY_UPDATE_INTERVAL_MS 1000 #define DISPLAY_UPDATE_INTERVAL_MS 1000
// ============================================================================ // ============================================================================
// LORA CONFIGURATION (for future remote sensor nodes) // LORA CONFIGURATION (for remote sensor nodes)
// ============================================================================ // ============================================================================
// LoRa frequency (915 MHz for US) // LoRa frequency (915 MHz for US, 868 MHz for EU)
#define LORA_FREQUENCY 915.0 #define LORA_FREQUENCY 915.0
// LoRa bandwidth (125 kHz) // LoRa bandwidth in kHz (125, 250, or 500)
#define LORA_BANDWIDTH 125.0 #define LORA_BANDWIDTH 125000 // 125 kHz
// LoRa spreading factor (7-12, higher = longer range but slower) // LoRa spreading factor (7-12, higher = longer range but slower)
#define LORA_SPREADING_FACTOR 9 #define LORA_SPREADING_FACTOR 9
// LoRa coding rate (5-8) // LoRa coding rate (5-8, higher = more error correction)
#define LORA_CODING_RATE 7 #define LORA_CODING_RATE 7
// LoRa sync word (private network) // LoRa sync word (must match remote nodes: 0x12)
#define LORA_SYNC_WORD 0x12 #define LORA_SYNC_WORD 0x12
// Maximum remote nodes to track
#define MAX_LORA_NODES 16
// ============================================================================ // ============================================================================
// API RETRY CONFIGURATION // API RETRY CONFIGURATION
// ============================================================================ // ============================================================================
+439
View File
@@ -0,0 +1,439 @@
/**
* ZNET LoRa Packet Builder and Parser
*
* High-level functions for building and parsing LoRa packets.
* Use these in both gateway and remote node firmware.
*/
#ifndef LORA_PACKET_H
#define LORA_PACKET_H
#include "lora_protocol.h"
#include <string.h>
// ============================================================================
// PACKET BUFFER
// ============================================================================
typedef struct {
uint8_t data[LORA_MAX_PACKET_SIZE];
uint8_t length;
uint16_t node_id;
uint8_t sequence;
lora_packet_type_t type;
bool ack_requested;
bool battery_low;
bool first_boot;
bool valid;
} lora_packet_t;
// ============================================================================
// PACKET BUILDING
// ============================================================================
/**
* Initialize packet buffer with header
*
* @param pkt Packet buffer to initialize
* @param type Packet type
* @param node_id Source/destination node ID
* @param seq Sequence number
* @param ack_req Request acknowledgment
* @param bat_low Battery low flag
* @param first First boot flag
*/
static inline void lora_packet_init(lora_packet_t* pkt, lora_packet_type_t type,
uint16_t node_id, uint8_t seq,
bool ack_req, bool bat_low, bool first) {
pkt->data[0] = LORA_SYNC_BYTE_1;
pkt->data[1] = LORA_SYNC_BYTE_2;
pkt->data[2] = lora_make_header(type, ack_req, bat_low, first);
lora_write_be16(&pkt->data[3], node_id);
pkt->data[5] = seq;
pkt->length = 6; // Header without payload or CRC
pkt->node_id = node_id;
pkt->sequence = seq;
pkt->type = type;
pkt->ack_requested = ack_req;
pkt->battery_low = bat_low;
pkt->first_boot = first;
pkt->valid = true;
}
/**
* Add DHT22 payload to packet
*/
static inline void lora_packet_add_dht22(lora_packet_t* pkt, float temp_c,
float humidity_pct, uint16_t battery_mv,
int8_t rssi_dbm) {
uint8_t* payload = &pkt->data[pkt->length];
payload[0] = SENSOR_TYPE_DHT22;
lora_write_be16s(&payload[1], lora_encode_temp_c10(temp_c));
lora_write_be16(&payload[3], lora_encode_humidity(humidity_pct));
payload[5] = lora_encode_battery(battery_mv);
payload[6] = (uint8_t)rssi_dbm;
pkt->length += 7;
}
/**
* Add BME680 payload to packet
*/
static inline void lora_packet_add_bme680(lora_packet_t* pkt, float temp_c,
float humidity_pct, float pressure_hpa,
uint16_t gas_kohm, uint8_t iaq_index,
uint16_t battery_mv, int8_t rssi_dbm) {
uint8_t* payload = &pkt->data[pkt->length];
payload[0] = SENSOR_TYPE_BME680;
lora_write_be16s(&payload[1], lora_encode_temp_c10(temp_c));
lora_write_be16(&payload[3], lora_encode_humidity(humidity_pct));
// Pressure offset from 900 hPa
uint16_t press_offset = (uint16_t)(pressure_hpa - 900.0f);
if (pressure_hpa < 900.0f) press_offset = 0;
lora_write_be16(&payload[5], press_offset);
lora_write_be16(&payload[7], gas_kohm);
payload[9] = iaq_index;
payload[10] = lora_encode_battery(battery_mv);
payload[11] = (uint8_t)rssi_dbm;
pkt->length += 12;
}
/**
* Add DS18B20 payload to packet (high precision)
*/
static inline void lora_packet_add_ds18b20(lora_packet_t* pkt, float temp_c,
uint16_t battery_mv, int8_t rssi_dbm) {
uint8_t* payload = &pkt->data[pkt->length];
payload[0] = SENSOR_TYPE_DS18B20;
lora_write_be16s(&payload[1], lora_encode_temp_c100(temp_c));
payload[3] = lora_encode_battery(battery_mv);
payload[4] = (uint8_t)rssi_dbm;
pkt->length += 5;
}
/**
* Add alert payload to packet
*/
static inline void lora_packet_add_alert(lora_packet_t* pkt, lora_alert_type_t type,
int16_t value, int16_t threshold,
uint8_t duration_sec) {
uint8_t* payload = &pkt->data[pkt->length];
payload[0] = type;
lora_write_be16s(&payload[1], value);
lora_write_be16s(&payload[3], threshold);
payload[5] = duration_sec;
pkt->length += 6;
}
/**
* Add registration payload to packet
*/
static inline void lora_packet_add_register(lora_packet_t* pkt, uint16_t proposed_id,
lora_sensor_type_t sensor_type,
uint8_t fw_major, uint8_t fw_minor,
const char* node_name) {
uint8_t* payload = &pkt->data[pkt->length];
lora_write_be16(&payload[0], proposed_id);
payload[2] = sensor_type;
payload[3] = fw_major;
payload[4] = fw_minor;
// Copy node name, null-padded
memset(&payload[5], 0, 8);
if (node_name) {
size_t len = strlen(node_name);
if (len > 8) len = 8;
memcpy(&payload[5], node_name, len);
}
pkt->length += 13;
}
/**
* Add registration ACK payload to packet
*/
static inline void lora_packet_add_register_ack(lora_packet_t* pkt, uint16_t assigned_id,
uint8_t status, uint16_t interval_sec) {
uint8_t* payload = &pkt->data[pkt->length];
lora_write_be16(&payload[0], assigned_id);
payload[2] = status;
lora_write_be16(&payload[3], interval_sec);
pkt->length += 5;
}
/**
* Finalize packet by adding CRC
*
* Call this after adding all payloads and before transmitting.
*/
static inline void lora_packet_finalize(lora_packet_t* pkt) {
uint16_t crc = lora_crc16(pkt->data, pkt->length);
lora_write_be16(&pkt->data[pkt->length], crc);
pkt->length += 2;
}
// ============================================================================
// PACKET PARSING
// ============================================================================
/**
* Parse received packet
*
* @param pkt Output packet structure
* @param data Raw received data
* @param len Length of received data
* @return true if packet is valid
*/
static inline bool lora_packet_parse(lora_packet_t* pkt, const uint8_t* data, uint8_t len) {
pkt->valid = false;
// Minimum size check
if (len < LORA_MIN_PACKET_SIZE) {
return false;
}
// Sync bytes check
if (data[0] != LORA_SYNC_BYTE_1 || data[1] != LORA_SYNC_BYTE_2) {
return false;
}
// CRC check
uint16_t received_crc = lora_read_be16(&data[len - 2]);
uint16_t computed_crc = lora_crc16(data, len - 2);
if (received_crc != computed_crc) {
return false;
}
// Copy data
memcpy(pkt->data, data, len);
pkt->length = len;
// Parse header
pkt->type = lora_get_type(data[2]);
pkt->ack_requested = lora_ack_requested(data[2]);
pkt->battery_low = lora_battery_low(data[2]);
pkt->first_boot = (data[2] & LORA_HDR_FIRST_BOOT) != 0;
pkt->node_id = lora_read_be16(&data[3]);
pkt->sequence = data[5];
pkt->valid = true;
return true;
}
/**
* Get pointer to payload data
*/
static inline const uint8_t* lora_packet_payload(const lora_packet_t* pkt) {
return &pkt->data[6]; // After header
}
/**
* Get payload length (excluding header and CRC)
*/
static inline uint8_t lora_packet_payload_len(const lora_packet_t* pkt) {
if (pkt->length < LORA_MIN_PACKET_SIZE) return 0;
return pkt->length - 8; // Total - header(6) - CRC(2)
}
// ============================================================================
// PAYLOAD PARSING
// ============================================================================
/**
* Parse DHT22 payload
*/
typedef struct {
float temp_c;
float humidity_pct;
uint16_t battery_mv;
int8_t rssi_dbm;
bool valid;
} lora_dht22_data_t;
static inline bool lora_parse_dht22(const uint8_t* payload, uint8_t len,
lora_dht22_data_t* out) {
if (len < 7 || payload[0] != SENSOR_TYPE_DHT22) {
out->valid = false;
return false;
}
out->temp_c = lora_decode_temp_c10(lora_read_be16s(&payload[1]));
out->humidity_pct = lora_decode_humidity(lora_read_be16(&payload[3]));
out->battery_mv = lora_decode_battery(payload[5]);
out->rssi_dbm = (int8_t)payload[6];
out->valid = true;
return true;
}
/**
* Parse BME680 payload
*/
typedef struct {
float temp_c;
float humidity_pct;
float pressure_hpa;
uint16_t gas_kohm;
uint8_t iaq_index;
uint16_t battery_mv;
int8_t rssi_dbm;
bool valid;
} lora_bme680_data_t;
static inline bool lora_parse_bme680(const uint8_t* payload, uint8_t len,
lora_bme680_data_t* out) {
if (len < 12 || payload[0] != SENSOR_TYPE_BME680) {
out->valid = false;
return false;
}
out->temp_c = lora_decode_temp_c10(lora_read_be16s(&payload[1]));
out->humidity_pct = lora_decode_humidity(lora_read_be16(&payload[3]));
out->pressure_hpa = 900.0f + (float)lora_read_be16(&payload[5]);
out->gas_kohm = lora_read_be16(&payload[7]);
out->iaq_index = payload[9];
out->battery_mv = lora_decode_battery(payload[10]);
out->rssi_dbm = (int8_t)payload[11];
out->valid = true;
return true;
}
/**
* Parse DS18B20 payload
*/
typedef struct {
float temp_c;
uint16_t battery_mv;
int8_t rssi_dbm;
bool valid;
} lora_ds18b20_data_t;
static inline bool lora_parse_ds18b20(const uint8_t* payload, uint8_t len,
lora_ds18b20_data_t* out) {
if (len < 5 || payload[0] != SENSOR_TYPE_DS18B20) {
out->valid = false;
return false;
}
out->temp_c = lora_decode_temp_c100(lora_read_be16s(&payload[1]));
out->battery_mv = lora_decode_battery(payload[3]);
out->rssi_dbm = (int8_t)payload[4];
out->valid = true;
return true;
}
/**
* Parse alert payload
*/
typedef struct {
lora_alert_type_t type;
int16_t value;
int16_t threshold;
uint8_t duration_sec;
bool valid;
} lora_alert_data_t;
static inline bool lora_parse_alert(const uint8_t* payload, uint8_t len,
lora_alert_data_t* out) {
if (len < 6) {
out->valid = false;
return false;
}
out->type = (lora_alert_type_t)payload[0];
out->value = lora_read_be16s(&payload[1]);
out->threshold = lora_read_be16s(&payload[3]);
out->duration_sec = payload[5];
out->valid = true;
return true;
}
/**
* Parse registration payload
*/
typedef struct {
uint16_t proposed_id;
lora_sensor_type_t sensor_type;
uint8_t fw_major;
uint8_t fw_minor;
char node_name[9]; // 8 chars + null
bool valid;
} lora_register_data_t;
static inline bool lora_parse_register(const uint8_t* payload, uint8_t len,
lora_register_data_t* out) {
if (len < 13) {
out->valid = false;
return false;
}
out->proposed_id = lora_read_be16(&payload[0]);
out->sensor_type = (lora_sensor_type_t)payload[2];
out->fw_major = payload[3];
out->fw_minor = payload[4];
memcpy(out->node_name, &payload[5], 8);
out->node_name[8] = '\0';
out->valid = true;
return true;
}
/**
* Parse registration ACK payload
*/
typedef struct {
uint16_t assigned_id;
uint8_t status;
uint16_t interval_sec;
bool valid;
} lora_register_ack_data_t;
static inline bool lora_parse_register_ack(const uint8_t* payload, uint8_t len,
lora_register_ack_data_t* out) {
if (len < 5) {
out->valid = false;
return false;
}
out->assigned_id = lora_read_be16(&payload[0]);
out->status = payload[2];
out->interval_sec = lora_read_be16(&payload[3]);
out->valid = true;
return true;
}
// ============================================================================
// ACK/NAK HELPERS
// ============================================================================
/**
* Build ACK packet in response to received packet
*/
static inline void lora_build_ack(lora_packet_t* ack, const lora_packet_t* received) {
lora_packet_init(ack, LORA_PKT_ACK, received->node_id, received->sequence,
false, false, false);
lora_packet_finalize(ack);
}
/**
* Build NAK packet requesting retransmission
*/
static inline void lora_build_nak(lora_packet_t* nak, uint16_t node_id,
uint8_t expected_seq) {
lora_packet_init(nak, LORA_PKT_NAK, node_id, expected_seq,
false, false, false);
lora_packet_finalize(nak);
}
#endif // LORA_PACKET_H
+346
View File
@@ -0,0 +1,346 @@
/**
* ZNET LoRa Protocol Definition
*
* Packet format and constants for communication between remote
* sensor nodes and the ZNET gateway.
*
* See docs/LORA_PROTOCOL.md for full specification.
*/
#ifndef LORA_PROTOCOL_H
#define LORA_PROTOCOL_H
#include <stdint.h>
#include <stdbool.h>
// ============================================================================
// PROTOCOL CONSTANTS
// ============================================================================
// Sync bytes (magic number)
#define LORA_SYNC_BYTE_1 0x5A // 'Z'
#define LORA_SYNC_BYTE_2 0x4E // 'N'
// Maximum packet size
#define LORA_MAX_PACKET_SIZE 72 // 8 header + 64 payload max
#define LORA_MAX_PAYLOAD_SIZE 64
#define LORA_MIN_PACKET_SIZE 8 // Sync + Header + NodeID + Seq + CRC
// Header byte masks
#define LORA_HDR_TYPE_MASK 0xF0 // Upper nibble = packet type
#define LORA_HDR_TYPE_SHIFT 4
#define LORA_HDR_ACK_REQ 0x08 // Bit 3: Request acknowledgment
#define LORA_HDR_BAT_LOW 0x04 // Bit 2: Battery low warning
#define LORA_HDR_FIRST_BOOT 0x02 // Bit 1: First boot since power
#define LORA_HDR_RESERVED 0x01 // Bit 0: Reserved
// ============================================================================
// PACKET TYPES
// ============================================================================
typedef enum {
LORA_PKT_SENSOR_DATA = 0x0, // Node → Gateway: Sensor readings
LORA_PKT_ACK = 0x1, // Gateway → Node: Acknowledgment
LORA_PKT_NAK = 0x2, // Gateway → Node: Request resend
LORA_PKT_CONFIG_REQ = 0x3, // Node → Gateway: Request config
LORA_PKT_CONFIG_RESP = 0x4, // Gateway → Node: Configuration
LORA_PKT_PING = 0x5, // Gateway → Node: Alive check
LORA_PKT_PONG = 0x6, // Node → Gateway: Alive response
LORA_PKT_ALERT = 0x7, // Node → Gateway: Critical alert
LORA_PKT_TIME_SYNC = 0x8, // Gateway → Node: Time sync
LORA_PKT_FIRMWARE_INFO = 0x9, // Node → Gateway: FW version
LORA_PKT_REGISTER = 0xA, // Node → Gateway: Registration
LORA_PKT_REGISTER_ACK = 0xB, // Gateway → Node: Reg accepted
} lora_packet_type_t;
// ============================================================================
// SENSOR TYPES
// ============================================================================
typedef enum {
SENSOR_TYPE_DHT22 = 0x01, // Temperature + Humidity
SENSOR_TYPE_BME680 = 0x02, // Temp + Humidity + Pressure + Gas
SENSOR_TYPE_DS18B20 = 0x03, // Temperature only (high precision)
SENSOR_TYPE_MULTI = 0x10, // Multiple sensors
} lora_sensor_type_t;
// ============================================================================
// ALERT TYPES
// ============================================================================
typedef enum {
ALERT_TEMP_HIGH = 0x01,
ALERT_TEMP_LOW = 0x02,
ALERT_HUMIDITY_HIGH = 0x03,
ALERT_HUMIDITY_LOW = 0x04,
ALERT_BATTERY_CRITICAL = 0x05,
ALERT_SENSOR_FAILURE = 0x06,
ALERT_VOC_HIGH = 0x07,
} lora_alert_type_t;
// ============================================================================
// NODE ID RANGES
// ============================================================================
#define NODE_ID_BROADCAST 0x0000
#define NODE_ID_TANK_START 0x0001
#define NODE_ID_TANK_END 0x00FF
#define NODE_ID_AMBIENT_START 0x0100
#define NODE_ID_AMBIENT_END 0x01FF
#define NODE_ID_ENVIRO_START 0x0200
#define NODE_ID_ENVIRO_END 0x02FF
#define NODE_ID_AUTO_START 0x1000
#define NODE_ID_AUTO_END 0xFFFE
#define NODE_ID_AUTO_REQUEST 0xFFFF
// ============================================================================
// PACKET STRUCTURES
// ============================================================================
// Packet header (common to all packets)
typedef struct __attribute__((packed)) {
uint8_t sync[2]; // 0x5A 0x4E
uint8_t header; // Type (4 bits) + Flags (4 bits)
uint16_t node_id; // Big-endian
uint8_t sequence; // Sequence number
} lora_packet_header_t;
// DHT22 sensor payload (7 bytes)
typedef struct __attribute__((packed)) {
uint8_t sensor_type; // SENSOR_TYPE_DHT22
int16_t temp_c_x10; // Temperature °C × 10 (big-endian)
uint16_t humidity_x10; // Humidity % × 10 (big-endian)
uint8_t battery_mv20; // Battery mV ÷ 20
int8_t rssi_dbm; // RSSI in dBm
} lora_payload_dht22_t;
// BME680 sensor payload (12 bytes)
typedef struct __attribute__((packed)) {
uint8_t sensor_type; // SENSOR_TYPE_BME680
int16_t temp_c_x10; // Temperature °C × 10 (big-endian)
uint16_t humidity_x10; // Humidity % × 10 (big-endian)
uint16_t pressure_offset;// Pressure - 900 hPa (big-endian)
uint16_t gas_kohm; // Gas resistance kΩ (big-endian)
uint8_t iaq_index; // Air quality index 0-255
uint8_t battery_mv20; // Battery mV ÷ 20
int8_t rssi_dbm; // RSSI in dBm
} lora_payload_bme680_t;
// DS18B20 sensor payload (5 bytes)
typedef struct __attribute__((packed)) {
uint8_t sensor_type; // SENSOR_TYPE_DS18B20
int16_t temp_c_x100; // Temperature °C × 100 (big-endian)
uint8_t battery_mv20; // Battery mV ÷ 20
int8_t rssi_dbm; // RSSI in dBm
} lora_payload_ds18b20_t;
// Alert payload (6 bytes)
typedef struct __attribute__((packed)) {
uint8_t alert_type; // lora_alert_type_t
int16_t alert_value; // Type-specific value (big-endian)
int16_t threshold; // Threshold that was exceeded (big-endian)
uint8_t duration_sec; // How long condition persisted
} lora_payload_alert_t;
// Registration request payload (13 bytes)
typedef struct __attribute__((packed)) {
uint16_t proposed_id; // Proposed ID or 0xFFFF for auto
uint8_t sensor_type; // Primary sensor type
uint8_t fw_major; // Firmware major version
uint8_t fw_minor; // Firmware minor version
char node_name[8]; // Node name (null-padded)
} lora_payload_register_t;
// Registration ACK payload (5 bytes)
typedef struct __attribute__((packed)) {
uint16_t assigned_id; // Assigned node ID
uint8_t status; // 0=OK, 1=ID conflict, 2=rejected
uint16_t interval_sec; // Recommended report interval
} lora_payload_register_ack_t;
// Configuration request payload (12 bytes)
typedef struct __attribute__((packed)) {
uint16_t current_interval; // Current interval in seconds
uint8_t fw_major;
uint8_t fw_minor;
char node_name[8];
} lora_payload_config_req_t;
// Configuration response payload (11 bytes)
typedef struct __attribute__((packed)) {
uint16_t interval_sec; // 0 = use default
int16_t warn_high_c_x10; // Warning high threshold
int16_t alert_high_c_x10; // Alert high threshold
int16_t warn_low_c_x10; // 0x8000 = disabled
int16_t alert_low_c_x10; // 0x8000 = disabled
uint8_t flags; // Bit 0: alerts enabled, Bit 1: ACK required
} lora_payload_config_resp_t;
// ============================================================================
// CRC16-CCITT CALCULATION
// ============================================================================
/**
* Calculate CRC16-CCITT checksum
*
* @param data Pointer to data buffer
* @param len Length of data
* @return 16-bit CRC
*/
static inline uint16_t lora_crc16(const uint8_t* data, size_t len) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < len; i++) {
crc ^= (uint16_t)data[i] << 8;
for (int j = 0; j < 8; j++) {
if (crc & 0x8000) {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
}
}
return crc;
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Build packet header byte
*/
static inline uint8_t lora_make_header(lora_packet_type_t type, bool ack_req,
bool bat_low, bool first_boot) {
uint8_t hdr = (type << LORA_HDR_TYPE_SHIFT) & LORA_HDR_TYPE_MASK;
if (ack_req) hdr |= LORA_HDR_ACK_REQ;
if (bat_low) hdr |= LORA_HDR_BAT_LOW;
if (first_boot) hdr |= LORA_HDR_FIRST_BOOT;
return hdr;
}
/**
* Extract packet type from header
*/
static inline lora_packet_type_t lora_get_type(uint8_t header) {
return (lora_packet_type_t)((header & LORA_HDR_TYPE_MASK) >> LORA_HDR_TYPE_SHIFT);
}
/**
* Check if ACK requested
*/
static inline bool lora_ack_requested(uint8_t header) {
return (header & LORA_HDR_ACK_REQ) != 0;
}
/**
* Check if battery low
*/
static inline bool lora_battery_low(uint8_t header) {
return (header & LORA_HDR_BAT_LOW) != 0;
}
/**
* Convert battery voltage to encoded value (mV ÷ 20)
*/
static inline uint8_t lora_encode_battery(uint16_t mv) {
if (mv > 5100) mv = 5100;
return (uint8_t)(mv / 20);
}
/**
* Decode battery value to mV
*/
static inline uint16_t lora_decode_battery(uint8_t encoded) {
return (uint16_t)encoded * 20;
}
/**
* Convert temperature to encoded value (°C × 10)
*/
static inline int16_t lora_encode_temp_c10(float temp_c) {
return (int16_t)(temp_c * 10.0f);
}
/**
* Decode temperature from encoded value
*/
static inline float lora_decode_temp_c10(int16_t encoded) {
return (float)encoded / 10.0f;
}
/**
* Convert temperature to high-precision encoded value (°C × 100)
*/
static inline int16_t lora_encode_temp_c100(float temp_c) {
return (int16_t)(temp_c * 100.0f);
}
/**
* Decode high-precision temperature
*/
static inline float lora_decode_temp_c100(int16_t encoded) {
return (float)encoded / 100.0f;
}
/**
* Convert humidity to encoded value (% × 10)
*/
static inline uint16_t lora_encode_humidity(float humidity_pct) {
return (uint16_t)(humidity_pct * 10.0f);
}
/**
* Decode humidity
*/
static inline float lora_decode_humidity(uint16_t encoded) {
return (float)encoded / 10.0f;
}
/**
* Convert Celsius to Fahrenheit
*/
static inline float lora_c_to_f(float celsius) {
return celsius * 9.0f / 5.0f + 32.0f;
}
/**
* Convert Fahrenheit to Celsius
*/
static inline float lora_f_to_c(float fahrenheit) {
return (fahrenheit - 32.0f) * 5.0f / 9.0f;
}
// ============================================================================
// BYTE ORDER HELPERS (for big-endian protocol)
// ============================================================================
/**
* Write uint16_t as big-endian
*/
static inline void lora_write_be16(uint8_t* buf, uint16_t val) {
buf[0] = (val >> 8) & 0xFF;
buf[1] = val & 0xFF;
}
/**
* Read uint16_t from big-endian
*/
static inline uint16_t lora_read_be16(const uint8_t* buf) {
return ((uint16_t)buf[0] << 8) | buf[1];
}
/**
* Write int16_t as big-endian
*/
static inline void lora_write_be16s(uint8_t* buf, int16_t val) {
lora_write_be16(buf, (uint16_t)val);
}
/**
* Read int16_t from big-endian
*/
static inline int16_t lora_read_be16s(const uint8_t* buf) {
return (int16_t)lora_read_be16(buf);
}
#endif // LORA_PROTOCOL_H
+157
View File
@@ -0,0 +1,157 @@
# ZNET LoRa Remote Sensor Node
Battery-powered sensor nodes that transmit temperature, humidity, and air quality data to the ZNET gateway via LoRa.
## Supported Hardware
| Board | Chip | LoRa | Notes |
|-------|------|------|-------|
| Heltec WiFi LoRa 32 V3 | ESP32-S3 | SX1262 | Recommended - built-in OLED |
| TTGO LoRa32 V2.1 | ESP32 | SX1276 | Budget option |
| DIY ESP32 + SX1276 | ESP32 | SX1276 | Full customization |
## Supported Sensors
| Sensor | Data | Use Case |
|--------|------|----------|
| DHT22 | Temp + Humidity | Ambient monitoring |
| BME680 | Temp + Humidity + Pressure + Gas | Air quality monitoring |
| DS18B20 | Temperature (high precision) | Remote tank monitoring |
## Quick Start
### 1. Install PlatformIO
```bash
pip install platformio
```
### 2. Configure Node
Edit `include/config.h`:
```cpp
// Set unique node ID or use 0xFFFF for auto-assign
#define NODE_ID 0x0101
// Node name (max 8 chars)
#define NODE_NAME "Ambient1"
// Report interval (seconds)
#define REPORT_INTERVAL_SEC 60
```
### 3. Build & Upload
```bash
cd remote-node
# For Heltec V3 with DHT22:
pio run -e heltec_v3_dht22 --target upload
# For Heltec V3 with BME680:
pio run -e heltec_v3_bme680 --target upload
# For TTGO board:
pio run -e ttgo_lora32_v21 --target upload
```
### 4. Monitor Output
```bash
pio device monitor
```
## Wiring
### DHT22 Sensor
```
ESP32 GPIO7 ─────────── DHT22 DATA
ESP32 3.3V ──────────── DHT22 VCC
ESP32 GND ───────────── DHT22 GND
10kΩ pullup between DATA and VCC
```
### BME680 Sensor (I2C)
```
ESP32 SDA (21) ──────── BME680 SDA
ESP32 SCL (22) ──────── BME680 SCL
ESP32 3.3V ──────────── BME680 VCC
ESP32 GND ───────────── BME680 GND
```
### Battery Connection
```
LiPo + ─────┬───────── ESP32 VBAT
│
┌┴┐
│ │ 100kΩ
└┬┘
├───────── ADC Pin (Battery monitoring)
┌┴┐
│ │ 100kΩ
└┬┘
│
LiPo - ─────┴───────── ESP32 GND
```
## Power Consumption
| State | Current | Notes |
|-------|---------|-------|
| Deep Sleep | ~10 μA | ESP32 deep sleep |
| Active (reading) | ~15 mA | Sensor reading |
| TX | ~80 mA | LoRa transmission |
| **Average @ 60s** | **~1 mA** | Typical |
### Battery Life Estimates (1000mAh LiPo)
| Interval | Battery Life |
|----------|--------------|
| 30 sec | ~2 weeks |
| 60 sec | ~1 month |
| 5 min | ~6 months |
| 10 min | ~1 year |
## Protocol
See [../docs/LORA_PROTOCOL.md](../docs/LORA_PROTOCOL.md) for full LoRa packet specification.
### Node ID Ranges
| Range | Purpose |
|-------|---------|
| 0x0001-0x00FF | Tank sensors |
| 0x0100-0x01FF | Ambient (DHT22) |
| 0x0200-0x02FF | Environmental (BME680) |
| 0xFFFF | Auto-assign |
## Troubleshooting
### No LoRa transmission
1. Check LoRa frequency matches gateway (915 MHz for US)
2. Verify SPI pins are correct for your board
3. Check serial monitor for "LoRa init failed" errors
### Sensor read fails
1. Check wiring connections
2. Verify correct GPIO pins in config.h
3. For I2C sensors, run I2C scanner to verify address
### Short battery life
1. Increase `REPORT_INTERVAL_SEC`
2. Disable OLED (`ENABLE_OLED 0`)
3. Disable serial debug in production (`ENABLE_SERIAL_DEBUG 0`)
### Not receiving ACKs
1. Verify gateway is running and receiving
2. Check sync word matches (0x12)
3. Reduce spreading factor if range is short (better reliability)
+154
View File
@@ -0,0 +1,154 @@
/**
* ZNET LoRa Remote Sensor Node Configuration
*
* Edit these values for your specific node deployment.
*/
#ifndef CONFIG_H
#define CONFIG_H
// ============================================================================
// NODE IDENTIFICATION
// ============================================================================
// Unique node ID (see docs/LORA_PROTOCOL.md for ID ranges)
// - 0x0100-0x01FF: Ambient sensors (DHT22)
// - 0x0200-0x02FF: Environmental (BME680)
// - 0xFFFF: Request auto-assignment from gateway
#ifndef NODE_ID
#define NODE_ID 0xFFFF // Auto-assign
#endif
// Human-readable node name (max 8 chars)
#ifndef NODE_NAME
#define NODE_NAME "Remote1"
#endif
// Firmware version
#define FW_VERSION_MAJOR 1
#define FW_VERSION_MINOR 0
// ============================================================================
// SENSOR CONFIGURATION
// ============================================================================
// Sensor type is set by platformio.ini build flags:
// -DSENSOR_TYPE_DHT22 or -DSENSOR_TYPE_BME680
// DHT22 data pin (if not set by build flags)
#ifndef DHT_PIN
#define DHT_PIN 7
#endif
// BME680 I2C address (0x76 or 0x77)
#ifndef BME680_I2C_ADDR
#define BME680_I2C_ADDR 0x76
#endif
// DS18B20 1-Wire pin (if using DS18B20 variant)
#ifndef ONEWIRE_PIN
#define ONEWIRE_PIN 7
#endif
// ============================================================================
// TIMING CONFIGURATION
// ============================================================================
// Report interval in seconds (default: 60 sec)
// Can be overridden by gateway via CONFIG_RESP packet
#ifndef REPORT_INTERVAL_SEC
#define REPORT_INTERVAL_SEC 60
#endif
// How long to wait for ACK (milliseconds)
#define ACK_TIMEOUT_MS 3000
// Maximum retries if no ACK received
#define MAX_RETRIES 3
// How long to listen for incoming packets after TX (milliseconds)
#define RX_WINDOW_MS 500
// ============================================================================
// LORA CONFIGURATION
// ============================================================================
// Frequency (US: 915 MHz, EU: 868 MHz, AS: 923 MHz)
#ifndef LORA_FREQUENCY
#define LORA_FREQUENCY 915.0
#endif
// Spreading factor (7-12, higher = longer range, slower)
#define LORA_SPREADING_FACTOR 9
// Bandwidth (kHz)
#define LORA_BANDWIDTH 125E3
// Coding rate (5-8)
#define LORA_CODING_RATE 7
// Sync word (must match gateway: 0x12)
#define LORA_SYNC_WORD 0x12
// TX power (dBm, max 20 for US)
#define LORA_TX_POWER 14
// ============================================================================
// BATTERY MONITORING
// ============================================================================
// Battery voltage thresholds (millivolts)
#define BATTERY_FULL_MV 4200 // Fully charged LiPo
#define BATTERY_NOMINAL_MV 3700 // Nominal LiPo
#define BATTERY_LOW_MV 3400 // Low battery warning threshold
#define BATTERY_CRITICAL_MV 3200 // Critical - send alert
// ADC calibration
// Voltage divider ratio (if using voltage divider for ADC)
// Set to 1.0 if reading directly (not recommended for LiPo)
#define BATTERY_DIVIDER_RATIO 2.0
// ============================================================================
// ALERT THRESHOLDS (can be overridden by gateway)
// ============================================================================
// Temperature thresholds (Celsius)
#define ALERT_TEMP_HIGH_C 35.0 // Too hot
#define ALERT_TEMP_LOW_C 10.0 // Too cold
// Humidity thresholds (percent)
#define ALERT_HUMIDITY_HIGH 85.0 // Too humid
#define ALERT_HUMIDITY_LOW 20.0 // Too dry
// IAQ threshold (BME680 only)
#define ALERT_IAQ_THRESHOLD 150 // Unhealthy air
// Duration before alert is sent (seconds)
#define ALERT_PERSIST_SEC 30
// ============================================================================
// DEEP SLEEP CONFIGURATION
// ============================================================================
// Use deep sleep between readings (recommended for battery)
#define ENABLE_DEEP_SLEEP 1
// Wake up sources
#define WAKE_ON_TIMER 1 // Wake on interval timer
#define WAKE_ON_GPIO 0 // Wake on external GPIO (for alerts)
#define WAKE_GPIO_PIN 0 // GPIO pin for external wake
// ============================================================================
// DEBUG CONFIGURATION
// ============================================================================
// Enable serial debug output (disable in production to save power)
#define ENABLE_SERIAL_DEBUG 1
// Enable OLED display (disable to save power if not needed)
#define ENABLE_OLED 1
// Blink LED on TX (for debugging)
#define ENABLE_TX_LED 1
#endif // CONFIG_H
+120
View File
@@ -0,0 +1,120 @@
; ZNET LoRa Remote Sensor Node
; Battery-powered sensor nodes for temperature/humidity/VOC monitoring
;
; Supported boards:
; - Heltec WiFi LoRa 32 V3
; - TTGO LoRa32 (SX1276)
; - DIY ESP32 + SX1262/SX1276 modules
[platformio]
default_envs = heltec_v3
[env]
platform = espressif32
framework = arduino
monitor_speed = 115200
upload_speed = 921600
; Common libraries for all variants
lib_deps =
sandeepmistry/LoRa@^0.8.0
adafruit/Adafruit Unified Sensor@^1.1.14
adafruit/DHT sensor library@^1.4.6
adafruit/Adafruit BME680 Library@^2.0.4
paulstoffregen/OneWire@^2.3.8
milesburton/DallasTemperature@^3.11.0
build_flags =
-DCORE_DEBUG_LEVEL=1
-DARDUINO_USB_CDC_ON_BOOT=1
; Common settings for battery optimization
board_build.partitions = min_spiffs.csv
; ============================================================================
; Heltec WiFi LoRa 32 V3 (Recommended - built-in OLED + SX1262)
; ============================================================================
[env:heltec_v3]
board = heltec_wifi_lora_32_V3
build_flags =
${env.build_flags}
-DLORA_BOARD_HELTEC_V3
-DLORA_FREQUENCY=915.0
; SX1262 pins for Heltec V3
-DLORA_SCK=9
-DLORA_MISO=11
-DLORA_MOSI=10
-DLORA_SS=8
-DLORA_RST=12
-DLORA_DIO1=14
-DLORA_BUSY=13
; OLED pins
-DOLED_SDA=17
-DOLED_SCL=18
-DOLED_RST=21
; Battery ADC
-DBATTERY_ADC_PIN=1
-DBATTERY_ADC_EN_PIN=37
lib_deps =
${env.lib_deps}
thingpulse/ESP8266 and ESP32 OLED driver for SSD1306 displays@^4.4.0
jgromes/RadioLib@^6.4.0
; ============================================================================
; TTGO LoRa32 V2.1 (SX1276)
; ============================================================================
[env:ttgo_lora32_v21]
board = ttgo-lora32-v21
build_flags =
${env.build_flags}
-DLORA_BOARD_TTGO_V21
-DLORA_FREQUENCY=915.0
; SX1276 pins for TTGO
-DLORA_SCK=5
-DLORA_MISO=19
-DLORA_MOSI=27
-DLORA_SS=18
-DLORA_RST=23
-DLORA_DIO0=26
; OLED pins
-DOLED_SDA=21
-DOLED_SCL=22
-DOLED_RST=16
; Battery ADC
-DBATTERY_ADC_PIN=35
lib_deps =
${env.lib_deps}
thingpulse/ESP8266 and ESP32 OLED driver for SSD1306 displays@^4.4.0
; ============================================================================
; Generic ESP32 + SX1276 (DIY builds)
; ============================================================================
[env:esp32_sx1276]
board = esp32dev
build_flags =
${env.build_flags}
-DLORA_BOARD_GENERIC
-DLORA_FREQUENCY=915.0
; Define your own pins in config.h
; ============================================================================
; DHT22 sensor variant (ambient temp/humidity)
; ============================================================================
[env:heltec_v3_dht22]
extends = env:heltec_v3
build_flags =
${env:heltec_v3.build_flags}
-DSENSOR_TYPE_DHT22
-DDHT_PIN=7
; ============================================================================
; BME680 sensor variant (temp/humidity/pressure/VOC)
; ============================================================================
[env:heltec_v3_bme680]
extends = env:heltec_v3
build_flags =
${env:heltec_v3.build_flags}
-DSENSOR_TYPE_BME680
-DBME680_I2C_ADDR=0x76
+690
View File
@@ -0,0 +1,690 @@
/**
* ZNET LoRa Remote Sensor Node
*
* Battery-powered sensor node that transmits readings to the ZNET gateway
* via LoRa. Supports DHT22, BME680, and DS18B20 sensors.
*
* Operation:
* 1. Wake from deep sleep
* 2. Read sensor(s)
* 3. Build and transmit LoRa packet
* 4. Wait for ACK (if requested)
* 5. Enter deep sleep until next interval
*
* Power consumption targets:
* - Active: ~80mA (during TX)
* - Sleep: ~10uA (ESP32 deep sleep)
* - Average: ~1mA at 60-second interval
*/
#include <Arduino.h>
#include <SPI.h>
#include "config.h"
// Include shared protocol headers from parent project
#include "../include/lora_protocol.h"
#include "../include/lora_packet.h"
// ============================================================================
// BOARD-SPECIFIC INCLUDES
// ============================================================================
#ifdef LORA_BOARD_HELTEC_V3
#include <RadioLib.h>
#include <SSD1306Wire.h>
SX1262 radio = new Module(LORA_SS, LORA_DIO1, LORA_RST, LORA_BUSY);
SSD1306Wire display(0x3C, OLED_SDA, OLED_SCL);
#define USE_RADIOLIB
#else
#include <LoRa.h>
#ifdef ENABLE_OLED
#include <SSD1306Wire.h>
SSD1306Wire display(0x3C, OLED_SDA, OLED_SCL);
#endif
#endif
// ============================================================================
// SENSOR INCLUDES
// ============================================================================
#ifdef SENSOR_TYPE_DHT22
#include <DHT.h>
DHT dht(DHT_PIN, DHT22);
#endif
#ifdef SENSOR_TYPE_BME680
#include <Wire.h>
#include <Adafruit_BME680.h>
Adafruit_BME680 bme;
#endif
#ifdef SENSOR_TYPE_DS18B20
#include <OneWire.h>
#include <DallasTemperature.h>
OneWire oneWire(ONEWIRE_PIN);
DallasTemperature ds18b20(&oneWire);
#endif
// ============================================================================
// GLOBALS
// ============================================================================
// RTC memory survives deep sleep
RTC_DATA_ATTR uint8_t sequence_number = 0;
RTC_DATA_ATTR uint16_t assigned_node_id = NODE_ID;
RTC_DATA_ATTR bool is_registered = false;
RTC_DATA_ATTR uint16_t configured_interval = REPORT_INTERVAL_SEC;
RTC_DATA_ATTR uint32_t boot_count = 0;
// Current sensor readings
float current_temp_c = 0;
float current_humidity = 0;
float current_pressure = 0;
uint16_t current_gas_kohm = 0;
uint8_t current_iaq = 0;
uint16_t battery_mv = 0;
int8_t last_rssi = 0;
bool sensor_valid = false;
// Flags
bool first_boot = false;
bool battery_low = false;
bool ack_received = false;
// ============================================================================
// FUNCTION DECLARATIONS
// ============================================================================
void setupLoRa();
void setupSensor();
void setupDisplay();
void readSensor();
void readBattery();
void sendReading();
void sendRegistration();
void waitForAck();
void processIncoming(uint8_t* data, uint8_t len);
void enterDeepSleep();
void updateDisplay();
void debugPrint(const char* msg);
void debugPrintf(const char* fmt, ...);
// ============================================================================
// SETUP
// ============================================================================
void setup() {
boot_count++;
first_boot = (boot_count == 1);
#if ENABLE_SERIAL_DEBUG
Serial.begin(115200);
delay(100);
debugPrintf("\n=== ZNET Remote Node v%d.%d ===", FW_VERSION_MAJOR, FW_VERSION_MINOR);
debugPrintf("Boot #%d, Node ID: 0x%04X", boot_count, assigned_node_id);
#endif
// Initialize display first for visual feedback
#if ENABLE_OLED
setupDisplay();
#endif
// Initialize LoRa radio
setupLoRa();
// Initialize sensor
setupSensor();
// Read battery voltage
readBattery();
// Check if we need to register
if (!is_registered || first_boot) {
sendRegistration();
}
// Read sensor
readSensor();
// Send reading
if (sensor_valid) {
sendReading();
} else {
debugPrint("Sensor read failed - not sending");
}
// Update display
#if ENABLE_OLED
updateDisplay();
delay(2000); // Show reading for 2 seconds
#endif
// Enter deep sleep
#if ENABLE_DEEP_SLEEP
enterDeepSleep();
#else
// If deep sleep disabled, wait for next interval
debugPrintf("Waiting %d seconds...", configured_interval);
delay(configured_interval * 1000);
ESP.restart();
#endif
}
void loop() {
// Should never reach here with deep sleep enabled
delay(1000);
}
// ============================================================================
// LORA SETUP
// ============================================================================
void setupLoRa() {
debugPrint("Initializing LoRa...");
#ifdef USE_RADIOLIB
// RadioLib for SX1262 (Heltec V3)
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_SS);
int state = radio.begin(LORA_FREQUENCY, LORA_BANDWIDTH / 1000.0,
LORA_SPREADING_FACTOR, LORA_CODING_RATE,
LORA_SYNC_WORD, LORA_TX_POWER);
if (state == RADIOLIB_ERR_NONE) {
debugPrint("LoRa init success (SX1262)");
} else {
debugPrintf("LoRa init failed: %d", state);
// Continue anyway, maybe it will work
}
#else
// Classic LoRa library for SX1276
LoRa.setPins(LORA_SS, LORA_RST, LORA_DIO0);
if (!LoRa.begin(LORA_FREQUENCY * 1E6)) {
debugPrint("LoRa init failed!");
return;
}
LoRa.setSpreadingFactor(LORA_SPREADING_FACTOR);
LoRa.setSignalBandwidth(LORA_BANDWIDTH);
LoRa.setCodingRate4(LORA_CODING_RATE);
LoRa.setSyncWord(LORA_SYNC_WORD);
LoRa.setTxPower(LORA_TX_POWER);
debugPrint("LoRa init success (SX1276)");
#endif
}
// ============================================================================
// SENSOR SETUP
// ============================================================================
void setupSensor() {
debugPrint("Initializing sensor...");
#ifdef SENSOR_TYPE_DHT22
dht.begin();
debugPrint("DHT22 initialized");
#endif
#ifdef SENSOR_TYPE_BME680
Wire.begin();
if (!bme.begin(BME680_I2C_ADDR)) {
debugPrint("BME680 init failed!");
return;
}
// Configure BME680
bme.setTemperatureOversampling(BME680_OS_8X);
bme.setHumidityOversampling(BME680_OS_2X);
bme.setPressureOversampling(BME680_OS_4X);
bme.setIIRFilterSize(BME680_FILTER_SIZE_3);
bme.setGasHeater(320, 150); // 320°C for 150ms
debugPrint("BME680 initialized");
#endif
#ifdef SENSOR_TYPE_DS18B20
ds18b20.begin();
ds18b20.setResolution(12); // 12-bit for max precision
debugPrintf("DS18B20 initialized, found %d device(s)", ds18b20.getDeviceCount());
#endif
}
// ============================================================================
// DISPLAY SETUP
// ============================================================================
#if ENABLE_OLED
void setupDisplay() {
#ifdef OLED_RST
pinMode(OLED_RST, OUTPUT);
digitalWrite(OLED_RST, LOW);
delay(20);
digitalWrite(OLED_RST, HIGH);
delay(20);
#endif
display.init();
display.flipScreenVertically();
display.setFont(ArialMT_Plain_10);
display.clear();
display.setTextAlignment(TEXT_ALIGN_CENTER);
display.drawString(64, 20, "ZNET Remote");
display.drawString(64, 35, "Starting...");
display.display();
}
#endif
// ============================================================================
// READ SENSOR
// ============================================================================
void readSensor() {
debugPrint("Reading sensor...");
sensor_valid = false;
#ifdef SENSOR_TYPE_DHT22
// DHT22 needs time to stabilize
delay(2000);
float h = dht.readHumidity();
float t = dht.readTemperature(); // Celsius
if (isnan(h) || isnan(t)) {
debugPrint("DHT22 read failed!");
return;
}
current_temp_c = t;
current_humidity = h;
sensor_valid = true;
debugPrintf("DHT22: %.1f°C, %.1f%%", current_temp_c, current_humidity);
#endif
#ifdef SENSOR_TYPE_BME680
if (!bme.performReading()) {
debugPrint("BME680 read failed!");
return;
}
current_temp_c = bme.temperature;
current_humidity = bme.humidity;
current_pressure = bme.pressure / 100.0; // Convert to hPa
current_gas_kohm = bme.gas_resistance / 1000; // Convert to kOhm
// Simple IAQ calculation (proper calculation requires BSEC library)
// This is a rough approximation based on gas resistance
if (current_gas_kohm > 300) {
current_iaq = 50; // Excellent
} else if (current_gas_kohm > 200) {
current_iaq = 100; // Good
} else if (current_gas_kohm > 100) {
current_iaq = 150; // Moderate
} else if (current_gas_kohm > 50) {
current_iaq = 200; // Poor
} else {
current_iaq = 250; // Very poor
}
sensor_valid = true;
debugPrintf("BME680: %.1f°C, %.1f%%, %.1f hPa, %d kOhm, IAQ %d",
current_temp_c, current_humidity, current_pressure,
current_gas_kohm, current_iaq);
#endif
#ifdef SENSOR_TYPE_DS18B20
ds18b20.requestTemperatures();
delay(750); // Wait for 12-bit conversion
current_temp_c = ds18b20.getTempCByIndex(0);
if (current_temp_c == DEVICE_DISCONNECTED_C) {
debugPrint("DS18B20 read failed!");
return;
}
sensor_valid = true;
debugPrintf("DS18B20: %.2f°C", current_temp_c);
#endif
}
// ============================================================================
// READ BATTERY
// ============================================================================
void readBattery() {
#ifdef BATTERY_ADC_PIN
#ifdef BATTERY_ADC_EN_PIN
// Enable ADC (some boards have enable pin)
pinMode(BATTERY_ADC_EN_PIN, OUTPUT);
digitalWrite(BATTERY_ADC_EN_PIN, HIGH);
delay(10);
#endif
// Read ADC (12-bit, 0-4095)
uint32_t adc_raw = 0;
for (int i = 0; i < 10; i++) {
adc_raw += analogRead(BATTERY_ADC_PIN);
delay(5);
}
adc_raw /= 10;
// Convert to millivolts
// ESP32 ADC: 0-3.3V maps to 0-4095
// With voltage divider, multiply by ratio
float voltage = (adc_raw / 4095.0) * 3.3 * BATTERY_DIVIDER_RATIO;
battery_mv = (uint16_t)(voltage * 1000);
#ifdef BATTERY_ADC_EN_PIN
digitalWrite(BATTERY_ADC_EN_PIN, LOW);
#endif
// Check thresholds
battery_low = (battery_mv < BATTERY_LOW_MV);
debugPrintf("Battery: %d mV %s", battery_mv, battery_low ? "(LOW!)" : "");
#else
// No ADC configured, assume full battery
battery_mv = BATTERY_NOMINAL_MV;
battery_low = false;
#endif
}
// ============================================================================
// SEND READING
// ============================================================================
void sendReading() {
debugPrint("Building packet...");
lora_packet_t pkt;
#ifdef SENSOR_TYPE_DHT22
lora_packet_init(&pkt, LORA_PKT_SENSOR_DATA, assigned_node_id,
sequence_number++, true, battery_low, first_boot);
lora_packet_add_dht22(&pkt, current_temp_c, current_humidity,
battery_mv, last_rssi);
#endif
#ifdef SENSOR_TYPE_BME680
lora_packet_init(&pkt, LORA_PKT_SENSOR_DATA, assigned_node_id,
sequence_number++, true, battery_low, first_boot);
lora_packet_add_bme680(&pkt, current_temp_c, current_humidity,
current_pressure, current_gas_kohm, current_iaq,
battery_mv, last_rssi);
#endif
#ifdef SENSOR_TYPE_DS18B20
lora_packet_init(&pkt, LORA_PKT_SENSOR_DATA, assigned_node_id,
sequence_number++, true, battery_low, first_boot);
lora_packet_add_ds18b20(&pkt, current_temp_c, battery_mv, last_rssi);
#endif
lora_packet_finalize(&pkt);
debugPrintf("Sending %d bytes, seq %d...", pkt.length, sequence_number - 1);
// Transmit
#ifdef USE_RADIOLIB
int state = radio.transmit(pkt.data, pkt.length);
if (state == RADIOLIB_ERR_NONE) {
debugPrint("TX success");
} else {
debugPrintf("TX failed: %d", state);
}
#else
LoRa.beginPacket();
LoRa.write(pkt.data, pkt.length);
LoRa.endPacket();
debugPrint("TX complete");
#endif
// Wait for ACK
waitForAck();
}
// ============================================================================
// SEND REGISTRATION
// ============================================================================
void sendRegistration() {
debugPrint("Sending registration...");
lora_packet_t pkt;
lora_packet_init(&pkt, LORA_PKT_REGISTER, NODE_ID, sequence_number++,
true, battery_low, true);
#ifdef SENSOR_TYPE_DHT22
lora_packet_add_register(&pkt, NODE_ID, SENSOR_TYPE_DHT22,
FW_VERSION_MAJOR, FW_VERSION_MINOR, NODE_NAME);
#endif
#ifdef SENSOR_TYPE_BME680
lora_packet_add_register(&pkt, NODE_ID, SENSOR_TYPE_BME680,
FW_VERSION_MAJOR, FW_VERSION_MINOR, NODE_NAME);
#endif
#ifdef SENSOR_TYPE_DS18B20
lora_packet_add_register(&pkt, NODE_ID, SENSOR_TYPE_DS18B20,
FW_VERSION_MAJOR, FW_VERSION_MINOR, NODE_NAME);
#endif
lora_packet_finalize(&pkt);
// Transmit
#ifdef USE_RADIOLIB
radio.transmit(pkt.data, pkt.length);
#else
LoRa.beginPacket();
LoRa.write(pkt.data, pkt.length);
LoRa.endPacket();
#endif
// Wait for registration ACK
waitForAck();
}
// ============================================================================
// WAIT FOR ACK
// ============================================================================
void waitForAck() {
debugPrintf("Waiting for ACK (%d ms)...", ACK_TIMEOUT_MS);
unsigned long start = millis();
ack_received = false;
#ifdef USE_RADIOLIB
// RadioLib receive
uint8_t rxBuffer[LORA_MAX_PACKET_SIZE];
size_t rxLen = 0;
while (millis() - start < ACK_TIMEOUT_MS) {
int state = radio.receive(rxBuffer, rxLen);
if (state == RADIOLIB_ERR_NONE) {
last_rssi = radio.getRSSI();
processIncoming(rxBuffer, rxLen);
if (ack_received) break;
}
delay(10);
}
#else
// Classic LoRa receive
LoRa.receive();
while (millis() - start < ACK_TIMEOUT_MS) {
int packetSize = LoRa.parsePacket();
if (packetSize > 0) {
uint8_t rxBuffer[LORA_MAX_PACKET_SIZE];
int i = 0;
while (LoRa.available() && i < LORA_MAX_PACKET_SIZE) {
rxBuffer[i++] = LoRa.read();
}
last_rssi = LoRa.packetRssi();
processIncoming(rxBuffer, i);
if (ack_received) break;
}
delay(10);
}
#endif
if (ack_received) {
debugPrint("ACK received!");
} else {
debugPrint("ACK timeout");
}
}
// ============================================================================
// PROCESS INCOMING PACKET
// ============================================================================
void processIncoming(uint8_t* data, uint8_t len) {
lora_packet_t pkt;
if (!lora_packet_parse(&pkt, data, len)) {
debugPrint("Invalid packet received");
return;
}
debugPrintf("RX type=%d from 0x%04X seq=%d", pkt.type, pkt.node_id, pkt.sequence);
switch (pkt.type) {
case LORA_PKT_ACK:
if (pkt.node_id == assigned_node_id) {
ack_received = true;
}
break;
case LORA_PKT_REGISTER_ACK: {
const uint8_t* payload = lora_packet_payload(&pkt);
lora_register_ack_data_t ack_data;
if (lora_parse_register_ack(payload, lora_packet_payload_len(&pkt), &ack_data)) {
if (ack_data.status == 0) {
assigned_node_id = ack_data.assigned_id;
configured_interval = ack_data.interval_sec;
is_registered = true;
ack_received = true;
debugPrintf("Registered as 0x%04X, interval %ds",
assigned_node_id, configured_interval);
}
}
break;
}
case LORA_PKT_CONFIG_RESP: {
// Update configuration from gateway
const uint8_t* payload = lora_packet_payload(&pkt);
if (lora_packet_payload_len(&pkt) >= 2) {
uint16_t new_interval = lora_read_be16(payload);
if (new_interval > 0 && new_interval != configured_interval) {
configured_interval = new_interval;
debugPrintf("Interval updated to %d s", configured_interval);
}
}
ack_received = true;
break;
}
default:
break;
}
}
// ============================================================================
// ENTER DEEP SLEEP
// ============================================================================
void enterDeepSleep() {
debugPrintf("Entering deep sleep for %d seconds...", configured_interval);
#if ENABLE_OLED
display.displayOff();
#endif
// Configure wake timer
esp_sleep_enable_timer_wakeup(configured_interval * 1000000ULL);
#if WAKE_ON_GPIO
esp_sleep_enable_ext0_wakeup((gpio_num_t)WAKE_GPIO_PIN, 1);
#endif
// Enter deep sleep
esp_deep_sleep_start();
}
// ============================================================================
// UPDATE DISPLAY
// ============================================================================
#if ENABLE_OLED
void updateDisplay() {
display.clear();
// Title
display.setTextAlignment(TEXT_ALIGN_LEFT);
display.setFont(ArialMT_Plain_10);
display.drawString(0, 0, "ZNET Remote");
// Node ID
display.setTextAlignment(TEXT_ALIGN_RIGHT);
char buf[16];
snprintf(buf, sizeof(buf), "0x%04X", assigned_node_id);
display.drawString(128, 0, buf);
// Line
display.drawLine(0, 12, 128, 12);
// Temperature
display.setTextAlignment(TEXT_ALIGN_CENTER);
display.setFont(ArialMT_Plain_16);
if (sensor_valid) {
float temp_f = lora_c_to_f(current_temp_c);
snprintf(buf, sizeof(buf), "%.1f F", temp_f);
display.drawString(64, 16, buf);
} else {
display.drawString(64, 16, "---");
}
// Humidity (if available)
#if defined(SENSOR_TYPE_DHT22) || defined(SENSOR_TYPE_BME680)
display.setFont(ArialMT_Plain_10);
if (sensor_valid) {
snprintf(buf, sizeof(buf), "%.0f%% RH", current_humidity);
display.drawString(64, 36, buf);
}
#endif
// Battery
display.setTextAlignment(TEXT_ALIGN_LEFT);
snprintf(buf, sizeof(buf), "%dmV", battery_mv);
display.drawString(0, 54, buf);
// RSSI
display.setTextAlignment(TEXT_ALIGN_RIGHT);
snprintf(buf, sizeof(buf), "%ddBm", last_rssi);
display.drawString(128, 54, buf);
display.display();
}
#endif
// ============================================================================
// DEBUG HELPERS
// ============================================================================
void debugPrint(const char* msg) {
#if ENABLE_SERIAL_DEBUG
Serial.println(msg);
#endif
}
void debugPrintf(const char* fmt, ...) {
#if ENABLE_SERIAL_DEBUG
char buf[128];
va_list args;
va_start(args, fmt);
vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
Serial.println(buf);
#endif
}
+485 -22
View File
@@ -3,19 +3,21 @@
* *
* Reads DS18B20 temperature sensors in the e-coat paint tank and sends * Reads DS18B20 temperature sensors in the e-coat paint tank and sends
* data to ZNET Web for real-time monitoring and ML feature collection. * data to ZNET Web for real-time monitoring and ML feature collection.
* Also receives data from remote LoRa sensor nodes (DHT22, BME680).
* *
* Hardware: * Hardware:
* - Meshnology ESP32 LoRa V3 (Heltec-compatible) * - Meshnology ESP32 LoRa V3 (Heltec-compatible)
* - 2x DS18B20 waterproof temperature probes (1-Wire) * - 2x DS18B20 waterproof temperature probes (1-Wire)
* - Built-in 0.96" OLED display * - Built-in 0.96" OLED display
* - Built-in SX1262 LoRa radio (for future sensor nodes) * - Built-in SX1262 LoRa radio
* *
* Features: * Features:
* - Dual sensor reading with validation and averaging * - Dual sensor reading with validation and averaging
* - OLED display for local temperature visibility * - OLED display for local temperature visibility
* - HTTP POST to ZNET Web API * - HTTP POST to ZNET Web API
* - Offline buffering when network is unavailable * - 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 * - WiFiManager for easy WiFi setup
*/ */
@@ -27,13 +29,41 @@
#include <DallasTemperature.h> #include <DallasTemperature.h>
#include <SSD1306Wire.h> #include <SSD1306Wire.h>
#include <WiFiManager.h> #include <WiFiManager.h>
#include <RadioLib.h>
#include <SPI.h>
#include "config.h" #include "config.h"
#include "lora_protocol.h"
#include "lora_packet.h"
// ============================================================================ // ============================================================================
// GLOBALS // 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 and DS18B20
OneWire oneWire(ONEWIRE_PIN); OneWire oneWire(ONEWIRE_PIN);
DallasTemperature sensors(&oneWire); DallasTemperature sensors(&oneWire);
@@ -79,16 +109,35 @@ int bufferCount = 0;
void setupWiFi(); void setupWiFi();
void setupSensors(); void setupSensors();
void setupDisplay(); void setupDisplay();
void setupLoRa();
void readTemperatures(); void readTemperatures();
void updateDisplay(); void updateDisplay();
void postToApi(); 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 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); float celsiusToFahrenheit(float celsius);
bool isValidTemperature(float temp_f); bool isValidTemperature(float temp_f);
String formatAddress(DeviceAddress addr); String formatAddress(DeviceAddress addr);
void displaySplash(); void displaySplash();
void displayError(const char* message); void displayError(const char* message);
// LoRa interrupt handler
ICACHE_RAM_ATTR void onLoRaReceive() {
loraReceived = true;
}
// ============================================================================ // ============================================================================
// SETUP // SETUP
// ============================================================================ // ============================================================================
@@ -99,15 +148,21 @@ void setup() {
Serial.println("\n\n"); Serial.println("\n\n");
Serial.println("========================================="); Serial.println("=========================================");
Serial.println(" ZNET Temperature Sensor v1.0"); Serial.println(" ZNET Temperature Sensor v1.2");
Serial.println(" E-Coat Paint Tank Monitor"); Serial.println(" E-Coat Paint Tank Gateway");
Serial.println("========================================="); Serial.println("=========================================");
// Initialize node registry
memset(remoteNodes, 0, sizeof(remoteNodes));
// Initialize display first for visual feedback // Initialize display first for visual feedback
setupDisplay(); setupDisplay();
displaySplash(); displaySplash();
// Initialize sensors // Initialize LoRa radio
setupLoRa();
// Initialize local sensors
setupSensors(); setupSensors();
// Connect to WiFi // Connect to WiFi
@@ -126,7 +181,10 @@ void loop() {
// Check WiFi connection // Check WiFi connection
wifiConnected = WiFi.status() == WL_CONNECTED; 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) { if (now - lastTempRead >= TEMP_READ_INTERVAL_MS || lastTempRead == 0) {
readTemperatures(); readTemperatures();
lastTempRead = now; lastTempRead = now;
@@ -313,11 +371,13 @@ void updateDisplay() {
// Title bar // Title bar
display.setTextAlignment(TEXT_ALIGN_LEFT); display.setTextAlignment(TEXT_ALIGN_LEFT);
display.setFont(ArialMT_Plain_10); 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.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 // Horizontal line
display.drawLine(0, 12, 128, 12); display.drawLine(0, 12, 128, 12);
@@ -329,32 +389,39 @@ void updateDisplay() {
if (sensor1Valid) { if (sensor1Valid) {
char buf[20]; char buf[20];
snprintf(buf, sizeof(buf), "%.1f°F", temperature1_f); snprintf(buf, sizeof(buf), "%.1f°F", temperature1_f);
display.drawString(64, 18, buf); display.drawString(64, 16, buf);
} else { } else {
display.drawString(64, 18, "---"); display.drawString(64, 16, "---");
} }
display.setFont(ArialMT_Plain_10); display.setFont(ArialMT_Plain_10);
display.drawString(64, 36, "Primary"); display.drawString(64, 34, "Tank Primary");
// Secondary sensor (smaller, below) // Secondary sensor (smaller, below)
if (sensorCount >= 2) { if (sensorCount >= 2 && sensor2Valid) {
display.setFont(ArialMT_Plain_10);
if (sensor2Valid) {
char buf[20]; char buf[20];
snprintf(buf, sizeof(buf), "Backup: %.1f°F", temperature2_f); snprintf(buf, sizeof(buf), "Backup: %.1f°F", temperature2_f);
display.drawString(64, 50, buf); display.drawString(64, 46, buf);
} else {
display.drawString(64, 50, "Backup: ---");
}
} }
// Buffer indicator (if offline) // Status line at bottom
if (bufferCount > 0) {
display.setTextAlignment(TEXT_ALIGN_LEFT); display.setTextAlignment(TEXT_ALIGN_LEFT);
char buf[20]; char buf[32];
if (bufferCount > 0) {
snprintf(buf, sizeof(buf), "Buf:%d", bufferCount); snprintf(buf, sizeof(buf), "Buf:%d", bufferCount);
} else if (loraPacketsReceived > 0) {
snprintf(buf, sizeof(buf), "RX:%lu", loraPacketsReceived);
} else {
buf[0] = '\0';
}
display.drawString(0, 54, buf); 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(); display.display();
@@ -438,6 +505,402 @@ void sendBufferedReadings() {
bufferHead = 0; 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 // UTILITY FUNCTIONS
// ============================================================================ // ============================================================================