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:
co-authored by
Claude Opus 4.5
parent
265a53768e
commit
5554341b49
@@ -130,12 +130,78 @@ All configuration in `include/config.h`:
|
||||
- [x] Offline buffering when network unavailable
|
||||
- [x] Automatic sensor discovery (1-Wire)
|
||||
|
||||
### Future (Phase 3)
|
||||
- [ ] LoRa receive for remote sensor nodes
|
||||
- [ ] Battery-powered remote node firmware
|
||||
### Phase 3: LoRa Network (Complete)
|
||||
- [x] LoRa packet protocol design (docs/LORA_PROTOCOL.md)
|
||||
- [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
|
||||
- [ ] 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
|
||||
|
||||
- **ZNET Web**: `~/Nextcloud/Dev/znet-web` - Backend receives temperature data
|
||||
@@ -151,3 +217,6 @@ All configuration in `include/config.h`:
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 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 |
|
||||
|
||||
@@ -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 |
|
||||
@@ -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
@@ -56,24 +56,27 @@
|
||||
#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
|
||||
|
||||
// LoRa bandwidth (125 kHz)
|
||||
#define LORA_BANDWIDTH 125.0
|
||||
// LoRa bandwidth in kHz (125, 250, or 500)
|
||||
#define LORA_BANDWIDTH 125000 // 125 kHz
|
||||
|
||||
// LoRa spreading factor (7-12, higher = longer range but slower)
|
||||
#define LORA_SPREADING_FACTOR 9
|
||||
|
||||
// LoRa coding rate (5-8)
|
||||
// LoRa coding rate (5-8, higher = more error correction)
|
||||
#define LORA_CODING_RATE 7
|
||||
|
||||
// LoRa sync word (private network)
|
||||
// LoRa sync word (must match remote nodes: 0x12)
|
||||
#define LORA_SYNC_WORD 0x12
|
||||
|
||||
// Maximum remote nodes to track
|
||||
#define MAX_LORA_NODES 16
|
||||
|
||||
// ============================================================================
|
||||
// API RETRY CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -3,19 +3,21 @@
|
||||
*
|
||||
* Reads DS18B20 temperature sensors in the e-coat paint tank and sends
|
||||
* data to ZNET Web for real-time monitoring and ML feature collection.
|
||||
* Also receives data from remote LoRa sensor nodes (DHT22, BME680).
|
||||
*
|
||||
* Hardware:
|
||||
* - Meshnology ESP32 LoRa V3 (Heltec-compatible)
|
||||
* - 2x DS18B20 waterproof temperature probes (1-Wire)
|
||||
* - Built-in 0.96" OLED display
|
||||
* - Built-in SX1262 LoRa radio (for future sensor nodes)
|
||||
* - Built-in SX1262 LoRa radio
|
||||
*
|
||||
* Features:
|
||||
* - Dual sensor reading with validation and averaging
|
||||
* - OLED display for local temperature visibility
|
||||
* - HTTP POST to ZNET Web API
|
||||
* - Offline buffering when network is unavailable
|
||||
* - LoRa receive for future remote sensor nodes
|
||||
* - LoRa receive from remote sensor nodes
|
||||
* - Automatic node registration and ACK responses
|
||||
* - WiFiManager for easy WiFi setup
|
||||
*/
|
||||
|
||||
@@ -27,13 +29,41 @@
|
||||
#include <DallasTemperature.h>
|
||||
#include <SSD1306Wire.h>
|
||||
#include <WiFiManager.h>
|
||||
#include <RadioLib.h>
|
||||
#include <SPI.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "lora_protocol.h"
|
||||
#include "lora_packet.h"
|
||||
|
||||
// ============================================================================
|
||||
// GLOBALS
|
||||
// ============================================================================
|
||||
|
||||
// LoRa radio (SX1262)
|
||||
SX1262 radio = new Module(LORA_SS, LORA_DIO1, LORA_RST, LORA_BUSY);
|
||||
volatile bool loraReceived = false;
|
||||
int8_t lastLoraRssi = 0;
|
||||
|
||||
// Remote node registry
|
||||
#define MAX_REMOTE_NODES 16
|
||||
struct RemoteNode {
|
||||
uint16_t node_id;
|
||||
uint8_t sensor_type;
|
||||
char name[9];
|
||||
uint8_t last_seq;
|
||||
uint32_t last_seen;
|
||||
uint16_t battery_mv;
|
||||
bool active;
|
||||
};
|
||||
RemoteNode remoteNodes[MAX_REMOTE_NODES];
|
||||
int remoteNodeCount = 0;
|
||||
|
||||
// LoRa statistics
|
||||
uint32_t loraPacketsReceived = 0;
|
||||
uint32_t loraPacketsForwarded = 0;
|
||||
uint32_t loraPacketsCrcFail = 0;
|
||||
|
||||
// OneWire and DS18B20
|
||||
OneWire oneWire(ONEWIRE_PIN);
|
||||
DallasTemperature sensors(&oneWire);
|
||||
@@ -79,16 +109,35 @@ int bufferCount = 0;
|
||||
void setupWiFi();
|
||||
void setupSensors();
|
||||
void setupDisplay();
|
||||
void setupLoRa();
|
||||
void readTemperatures();
|
||||
void updateDisplay();
|
||||
void postToApi();
|
||||
void postLoRaReadingToApi(const lora_packet_t* pkt, const char* sensorType,
|
||||
float temp_f, float humidity, float pressure,
|
||||
uint16_t gas_kohm, uint8_t iaq, uint16_t battery_mv,
|
||||
int8_t rssi);
|
||||
void sendBufferedReadings();
|
||||
void checkLoRa();
|
||||
void processLoRaPacket(uint8_t* data, uint8_t len);
|
||||
void handleSensorData(const lora_packet_t* pkt);
|
||||
void handleRegistration(const lora_packet_t* pkt);
|
||||
void sendAck(uint16_t node_id, uint8_t seq);
|
||||
void sendRegistrationAck(uint16_t node_id, uint8_t seq, uint16_t assigned_id);
|
||||
RemoteNode* findNode(uint16_t node_id);
|
||||
RemoteNode* addNode(uint16_t node_id, uint8_t sensor_type, const char* name);
|
||||
uint16_t allocateNodeId(uint8_t sensor_type);
|
||||
float celsiusToFahrenheit(float celsius);
|
||||
bool isValidTemperature(float temp_f);
|
||||
String formatAddress(DeviceAddress addr);
|
||||
void displaySplash();
|
||||
void displayError(const char* message);
|
||||
|
||||
// LoRa interrupt handler
|
||||
ICACHE_RAM_ATTR void onLoRaReceive() {
|
||||
loraReceived = true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SETUP
|
||||
// ============================================================================
|
||||
@@ -99,15 +148,21 @@ void setup() {
|
||||
|
||||
Serial.println("\n\n");
|
||||
Serial.println("=========================================");
|
||||
Serial.println(" ZNET Temperature Sensor v1.0");
|
||||
Serial.println(" E-Coat Paint Tank Monitor");
|
||||
Serial.println(" ZNET Temperature Sensor v1.2");
|
||||
Serial.println(" E-Coat Paint Tank Gateway");
|
||||
Serial.println("=========================================");
|
||||
|
||||
// Initialize node registry
|
||||
memset(remoteNodes, 0, sizeof(remoteNodes));
|
||||
|
||||
// Initialize display first for visual feedback
|
||||
setupDisplay();
|
||||
displaySplash();
|
||||
|
||||
// Initialize sensors
|
||||
// Initialize LoRa radio
|
||||
setupLoRa();
|
||||
|
||||
// Initialize local sensors
|
||||
setupSensors();
|
||||
|
||||
// Connect to WiFi
|
||||
@@ -126,7 +181,10 @@ void loop() {
|
||||
// Check WiFi connection
|
||||
wifiConnected = WiFi.status() == WL_CONNECTED;
|
||||
|
||||
// Read temperatures at interval
|
||||
// Check for LoRa packets (non-blocking)
|
||||
checkLoRa();
|
||||
|
||||
// Read local temperatures at interval
|
||||
if (now - lastTempRead >= TEMP_READ_INTERVAL_MS || lastTempRead == 0) {
|
||||
readTemperatures();
|
||||
lastTempRead = now;
|
||||
@@ -313,11 +371,13 @@ void updateDisplay() {
|
||||
// Title bar
|
||||
display.setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display.setFont(ArialMT_Plain_10);
|
||||
display.drawString(0, 0, "ZNET Tank Temp");
|
||||
display.drawString(0, 0, "ZNET Gateway");
|
||||
|
||||
// WiFi status indicator
|
||||
// WiFi and LoRa status
|
||||
display.setTextAlignment(TEXT_ALIGN_RIGHT);
|
||||
display.drawString(128, 0, wifiConnected ? "WiFi OK" : "NO WiFi");
|
||||
char status[24];
|
||||
snprintf(status, sizeof(status), "%s L%d", wifiConnected ? "W" : "-", remoteNodeCount);
|
||||
display.drawString(128, 0, status);
|
||||
|
||||
// Horizontal line
|
||||
display.drawLine(0, 12, 128, 12);
|
||||
@@ -329,32 +389,39 @@ void updateDisplay() {
|
||||
if (sensor1Valid) {
|
||||
char buf[20];
|
||||
snprintf(buf, sizeof(buf), "%.1f°F", temperature1_f);
|
||||
display.drawString(64, 18, buf);
|
||||
display.drawString(64, 16, buf);
|
||||
} else {
|
||||
display.drawString(64, 18, "---");
|
||||
display.drawString(64, 16, "---");
|
||||
}
|
||||
|
||||
display.setFont(ArialMT_Plain_10);
|
||||
display.drawString(64, 36, "Primary");
|
||||
display.drawString(64, 34, "Tank Primary");
|
||||
|
||||
// Secondary sensor (smaller, below)
|
||||
if (sensorCount >= 2) {
|
||||
display.setFont(ArialMT_Plain_10);
|
||||
if (sensor2Valid) {
|
||||
if (sensorCount >= 2 && sensor2Valid) {
|
||||
char buf[20];
|
||||
snprintf(buf, sizeof(buf), "Backup: %.1f°F", temperature2_f);
|
||||
display.drawString(64, 50, buf);
|
||||
} else {
|
||||
display.drawString(64, 50, "Backup: ---");
|
||||
}
|
||||
display.drawString(64, 46, buf);
|
||||
}
|
||||
|
||||
// Buffer indicator (if offline)
|
||||
if (bufferCount > 0) {
|
||||
// Status line at bottom
|
||||
display.setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
char buf[20];
|
||||
char buf[32];
|
||||
|
||||
if (bufferCount > 0) {
|
||||
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);
|
||||
|
||||
// RSSI of last LoRa packet
|
||||
if (loraPacketsReceived > 0) {
|
||||
display.setTextAlignment(TEXT_ALIGN_RIGHT);
|
||||
snprintf(buf, sizeof(buf), "%ddBm", lastLoraRssi);
|
||||
display.drawString(128, 54, buf);
|
||||
}
|
||||
|
||||
display.display();
|
||||
@@ -438,6 +505,402 @@ void sendBufferedReadings() {
|
||||
bufferHead = 0;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LORA SETUP AND RECEIVE
|
||||
// ============================================================================
|
||||
|
||||
void setupLoRa() {
|
||||
Serial.println("Initializing LoRa radio (SX1262)...");
|
||||
|
||||
// Initialize SPI for LoRa
|
||||
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_SS);
|
||||
|
||||
// Initialize radio
|
||||
// RadioLib SX1262.begin(freq, bw_kHz, sf, cr, syncWord, power, preambleLen, tcxoVoltage)
|
||||
int state = radio.begin(
|
||||
LORA_FREQUENCY,
|
||||
LORA_BANDWIDTH / 1000.0, // Convert Hz to kHz
|
||||
LORA_SPREADING_FACTOR,
|
||||
LORA_CODING_RATE,
|
||||
LORA_SYNC_WORD,
|
||||
14, // TX power (dBm)
|
||||
8, // Preamble length
|
||||
0 // TCXO voltage (0 = use default)
|
||||
);
|
||||
|
||||
if (state == RADIOLIB_ERR_NONE) {
|
||||
Serial.println("LoRa init success!");
|
||||
} else {
|
||||
Serial.printf("LoRa init failed, code %d\n", state);
|
||||
displayError("LoRa init failed!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up receive interrupt
|
||||
radio.setDio1Action(onLoRaReceive);
|
||||
|
||||
// Start receiving
|
||||
state = radio.startReceive();
|
||||
if (state != RADIOLIB_ERR_NONE) {
|
||||
Serial.printf("LoRa receive start failed: %d\n", state);
|
||||
} else {
|
||||
Serial.println("LoRa listening on 915 MHz...");
|
||||
}
|
||||
}
|
||||
|
||||
void checkLoRa() {
|
||||
// Check if packet received via interrupt
|
||||
if (!loraReceived) return;
|
||||
|
||||
loraReceived = false;
|
||||
|
||||
// Read packet
|
||||
uint8_t rxBuffer[LORA_MAX_PACKET_SIZE];
|
||||
size_t rxLen = 0;
|
||||
|
||||
int state = radio.readData(rxBuffer, rxLen);
|
||||
|
||||
if (state == RADIOLIB_ERR_NONE) {
|
||||
lastLoraRssi = radio.getRSSI();
|
||||
loraPacketsReceived++;
|
||||
|
||||
Serial.printf("LoRa RX: %d bytes, RSSI %d dBm\n", rxLen, lastLoraRssi);
|
||||
|
||||
// Process the packet
|
||||
processLoRaPacket(rxBuffer, rxLen);
|
||||
} else if (state == RADIOLIB_ERR_CRC_MISMATCH) {
|
||||
loraPacketsCrcFail++;
|
||||
Serial.println("LoRa CRC error");
|
||||
} else {
|
||||
Serial.printf("LoRa RX error: %d\n", state);
|
||||
}
|
||||
|
||||
// Restart receive mode
|
||||
radio.startReceive();
|
||||
}
|
||||
|
||||
void processLoRaPacket(uint8_t* data, uint8_t len) {
|
||||
lora_packet_t pkt;
|
||||
|
||||
if (!lora_packet_parse(&pkt, data, len)) {
|
||||
Serial.println("Invalid packet format");
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("Packet from 0x%04X, type %d, seq %d\n",
|
||||
pkt.node_id, pkt.type, pkt.sequence);
|
||||
|
||||
switch (pkt.type) {
|
||||
case LORA_PKT_SENSOR_DATA:
|
||||
handleSensorData(&pkt);
|
||||
break;
|
||||
|
||||
case LORA_PKT_REGISTER:
|
||||
handleRegistration(&pkt);
|
||||
break;
|
||||
|
||||
case LORA_PKT_ALERT:
|
||||
// TODO: Handle alerts with higher priority
|
||||
handleSensorData(&pkt); // For now, treat like sensor data
|
||||
break;
|
||||
|
||||
case LORA_PKT_PONG:
|
||||
Serial.printf("PONG from 0x%04X\n", pkt.node_id);
|
||||
break;
|
||||
|
||||
default:
|
||||
Serial.printf("Unknown packet type: %d\n", pkt.type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void handleSensorData(const lora_packet_t* pkt) {
|
||||
const uint8_t* payload = lora_packet_payload(pkt);
|
||||
uint8_t payload_len = lora_packet_payload_len(pkt);
|
||||
|
||||
if (payload_len < 1) {
|
||||
Serial.println("Empty payload");
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t sensor_type = payload[0];
|
||||
|
||||
// Update node registry
|
||||
RemoteNode* node = findNode(pkt->node_id);
|
||||
if (node) {
|
||||
node->last_seen = millis();
|
||||
node->last_seq = pkt->sequence;
|
||||
}
|
||||
|
||||
// Parse based on sensor type and forward to API
|
||||
switch (sensor_type) {
|
||||
case SENSOR_TYPE_DHT22: {
|
||||
lora_dht22_data_t data;
|
||||
if (lora_parse_dht22(payload, payload_len, &data)) {
|
||||
float temp_f = celsiusToFahrenheit(data.temp_c);
|
||||
Serial.printf("DHT22 from 0x%04X: %.1f°F, %.1f%% RH\n",
|
||||
pkt->node_id, temp_f, data.humidity_pct);
|
||||
|
||||
if (node) node->battery_mv = data.battery_mv;
|
||||
|
||||
postLoRaReadingToApi(pkt, "dht22", temp_f, data.humidity_pct,
|
||||
0, 0, 0, data.battery_mv, data.rssi_dbm);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case SENSOR_TYPE_BME680: {
|
||||
lora_bme680_data_t data;
|
||||
if (lora_parse_bme680(payload, payload_len, &data)) {
|
||||
float temp_f = celsiusToFahrenheit(data.temp_c);
|
||||
Serial.printf("BME680 from 0x%04X: %.1f°F, %.1f%%, %.0f hPa, IAQ %d\n",
|
||||
pkt->node_id, temp_f, data.humidity_pct,
|
||||
data.pressure_hpa, data.iaq_index);
|
||||
|
||||
if (node) node->battery_mv = data.battery_mv;
|
||||
|
||||
postLoRaReadingToApi(pkt, "bme680", temp_f, data.humidity_pct,
|
||||
data.pressure_hpa, data.gas_kohm,
|
||||
data.iaq_index, data.battery_mv, data.rssi_dbm);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case SENSOR_TYPE_DS18B20: {
|
||||
lora_ds18b20_data_t data;
|
||||
if (lora_parse_ds18b20(payload, payload_len, &data)) {
|
||||
float temp_f = celsiusToFahrenheit(data.temp_c);
|
||||
Serial.printf("DS18B20 from 0x%04X: %.2f°F\n",
|
||||
pkt->node_id, temp_f);
|
||||
|
||||
if (node) node->battery_mv = data.battery_mv;
|
||||
|
||||
postLoRaReadingToApi(pkt, "ds18b20", temp_f, 0, 0, 0, 0,
|
||||
data.battery_mv, data.rssi_dbm);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
Serial.printf("Unknown sensor type: 0x%02X\n", sensor_type);
|
||||
break;
|
||||
}
|
||||
|
||||
// Send ACK if requested
|
||||
if (pkt->ack_requested) {
|
||||
sendAck(pkt->node_id, pkt->sequence);
|
||||
}
|
||||
}
|
||||
|
||||
void handleRegistration(const lora_packet_t* pkt) {
|
||||
const uint8_t* payload = lora_packet_payload(pkt);
|
||||
uint8_t payload_len = lora_packet_payload_len(pkt);
|
||||
|
||||
lora_register_data_t reg;
|
||||
if (!lora_parse_register(payload, payload_len, ®)) {
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user