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] Offline buffering when network unavailable
|
||||||
- [x] Automatic sensor discovery (1-Wire)
|
- [x] Automatic sensor discovery (1-Wire)
|
||||||
|
|
||||||
### Future (Phase 3)
|
### Phase 3: LoRa Network (Complete)
|
||||||
- [ ] LoRa receive for remote sensor nodes
|
- [x] LoRa packet protocol design (docs/LORA_PROTOCOL.md)
|
||||||
- [ ] Battery-powered remote node firmware
|
- [x] Protocol header files (include/lora_protocol.h, include/lora_packet.h)
|
||||||
|
- [x] Battery-powered remote node firmware (remote-node/)
|
||||||
|
- [x] LoRa receive for gateway (RadioLib SX1262)
|
||||||
|
- [x] Gateway LoRa → HTTP forwarding
|
||||||
|
- [x] Automatic node registration and ACK responses
|
||||||
|
|
||||||
|
### Future
|
||||||
- [ ] Sensor agreement monitoring
|
- [ ] Sensor agreement monitoring
|
||||||
- [ ] Flash storage for offline buffer persistence
|
- [ ] Flash storage for offline buffer persistence
|
||||||
|
|
||||||
|
## Remote Sensor Nodes
|
||||||
|
|
||||||
|
Battery-powered sensor nodes in `remote-node/` that transmit to the gateway via LoRa.
|
||||||
|
|
||||||
|
### Build & Upload
|
||||||
|
```bash
|
||||||
|
cd remote-node
|
||||||
|
|
||||||
|
# DHT22 variant (ambient temp/humidity)
|
||||||
|
pio run -e heltec_v3_dht22 --target upload
|
||||||
|
|
||||||
|
# BME680 variant (temp/humidity/pressure/VOC)
|
||||||
|
pio run -e heltec_v3_bme680 --target upload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
Edit `remote-node/include/config.h`:
|
||||||
|
- `NODE_ID` - Unique ID or 0xFFFF for auto-assign
|
||||||
|
- `NODE_NAME` - Human-readable name (8 chars max)
|
||||||
|
- `REPORT_INTERVAL_SEC` - How often to transmit (default 60s)
|
||||||
|
|
||||||
|
### Power Consumption
|
||||||
|
- Deep sleep: ~10 μA
|
||||||
|
- Average @ 60s interval: ~1 mA
|
||||||
|
- 1000mAh LiPo battery life: ~1 month
|
||||||
|
|
||||||
|
## LoRa Protocol
|
||||||
|
|
||||||
|
The gateway will receive data from remote sensor nodes via LoRa 915MHz. Full specification in `docs/LORA_PROTOCOL.md`.
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `docs/LORA_PROTOCOL.md` | Full protocol specification |
|
||||||
|
| `include/lora_protocol.h` | Constants, types, CRC functions |
|
||||||
|
| `include/lora_packet.h` | Packet builder/parser helpers |
|
||||||
|
|
||||||
|
### Supported Sensors
|
||||||
|
| Type | Code | Payload Size | Data |
|
||||||
|
|------|------|--------------|------|
|
||||||
|
| DHT22 | 0x01 | 7 bytes | Temp, Humidity, Battery, RSSI |
|
||||||
|
| BME680 | 0x02 | 12 bytes | Temp, Humidity, Pressure, Gas, IAQ |
|
||||||
|
| DS18B20 | 0x03 | 5 bytes | Temp (high precision), Battery, RSSI |
|
||||||
|
|
||||||
|
### Node ID Ranges
|
||||||
|
| Range | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| 0x0001-0x00FF | Tank sensors |
|
||||||
|
| 0x0100-0x01FF | Ambient sensors (DHT22) |
|
||||||
|
| 0x0200-0x02FF | Environmental (BME680) |
|
||||||
|
| 0xFFFF | Auto-assign request |
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Document | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `docs/SETUP_GUIDE.md` | Complete setup guide with wiring diagrams |
|
||||||
|
| `docs/LORA_PROTOCOL.md` | LoRa packet format specification |
|
||||||
|
| `remote-node/README.md` | Remote node firmware guide |
|
||||||
|
|
||||||
## Related Projects
|
## Related Projects
|
||||||
|
|
||||||
- **ZNET Web**: `~/Nextcloud/Dev/znet-web` - Backend receives temperature data
|
- **ZNET Web**: `~/Nextcloud/Dev/znet-web` - Backend receives temperature data
|
||||||
@@ -151,3 +217,6 @@ All configuration in `include/config.h`:
|
|||||||
| Date | Version | Change |
|
| Date | Version | Change |
|
||||||
|------|---------|--------|
|
|------|---------|--------|
|
||||||
| 2026-01-24 | 1.0.0 | Initial implementation |
|
| 2026-01-24 | 1.0.0 | Initial implementation |
|
||||||
|
| 2026-01-24 | 1.1.0 | LoRa protocol design, header files |
|
||||||
|
| 2026-01-24 | 1.2.0 | Remote node firmware template (DHT22, BME680, DS18B20) |
|
||||||
|
| 2026-01-24 | 1.3.0 | Gateway LoRa receive, node registry, HTTP forwarding |
|
||||||
|
|||||||
@@ -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
|
#define DISPLAY_UPDATE_INTERVAL_MS 1000
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// LORA CONFIGURATION (for future remote sensor nodes)
|
// LORA CONFIGURATION (for remote sensor nodes)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// LoRa frequency (915 MHz for US)
|
// LoRa frequency (915 MHz for US, 868 MHz for EU)
|
||||||
#define LORA_FREQUENCY 915.0
|
#define LORA_FREQUENCY 915.0
|
||||||
|
|
||||||
// LoRa bandwidth (125 kHz)
|
// LoRa bandwidth in kHz (125, 250, or 500)
|
||||||
#define LORA_BANDWIDTH 125.0
|
#define LORA_BANDWIDTH 125000 // 125 kHz
|
||||||
|
|
||||||
// LoRa spreading factor (7-12, higher = longer range but slower)
|
// LoRa spreading factor (7-12, higher = longer range but slower)
|
||||||
#define LORA_SPREADING_FACTOR 9
|
#define LORA_SPREADING_FACTOR 9
|
||||||
|
|
||||||
// LoRa coding rate (5-8)
|
// LoRa coding rate (5-8, higher = more error correction)
|
||||||
#define LORA_CODING_RATE 7
|
#define LORA_CODING_RATE 7
|
||||||
|
|
||||||
// LoRa sync word (private network)
|
// LoRa sync word (must match remote nodes: 0x12)
|
||||||
#define LORA_SYNC_WORD 0x12
|
#define LORA_SYNC_WORD 0x12
|
||||||
|
|
||||||
|
// Maximum remote nodes to track
|
||||||
|
#define MAX_LORA_NODES 16
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API RETRY CONFIGURATION
|
// API RETRY CONFIGURATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+488
-25
@@ -3,19 +3,21 @@
|
|||||||
*
|
*
|
||||||
* Reads DS18B20 temperature sensors in the e-coat paint tank and sends
|
* Reads DS18B20 temperature sensors in the e-coat paint tank and sends
|
||||||
* data to ZNET Web for real-time monitoring and ML feature collection.
|
* data to ZNET Web for real-time monitoring and ML feature collection.
|
||||||
|
* Also receives data from remote LoRa sensor nodes (DHT22, BME680).
|
||||||
*
|
*
|
||||||
* Hardware:
|
* Hardware:
|
||||||
* - Meshnology ESP32 LoRa V3 (Heltec-compatible)
|
* - Meshnology ESP32 LoRa V3 (Heltec-compatible)
|
||||||
* - 2x DS18B20 waterproof temperature probes (1-Wire)
|
* - 2x DS18B20 waterproof temperature probes (1-Wire)
|
||||||
* - Built-in 0.96" OLED display
|
* - Built-in 0.96" OLED display
|
||||||
* - Built-in SX1262 LoRa radio (for future sensor nodes)
|
* - Built-in SX1262 LoRa radio
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Dual sensor reading with validation and averaging
|
* - Dual sensor reading with validation and averaging
|
||||||
* - OLED display for local temperature visibility
|
* - OLED display for local temperature visibility
|
||||||
* - HTTP POST to ZNET Web API
|
* - HTTP POST to ZNET Web API
|
||||||
* - Offline buffering when network is unavailable
|
* - Offline buffering when network is unavailable
|
||||||
* - LoRa receive for future remote sensor nodes
|
* - LoRa receive from remote sensor nodes
|
||||||
|
* - Automatic node registration and ACK responses
|
||||||
* - WiFiManager for easy WiFi setup
|
* - WiFiManager for easy WiFi setup
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -27,13 +29,41 @@
|
|||||||
#include <DallasTemperature.h>
|
#include <DallasTemperature.h>
|
||||||
#include <SSD1306Wire.h>
|
#include <SSD1306Wire.h>
|
||||||
#include <WiFiManager.h>
|
#include <WiFiManager.h>
|
||||||
|
#include <RadioLib.h>
|
||||||
|
#include <SPI.h>
|
||||||
|
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
#include "lora_protocol.h"
|
||||||
|
#include "lora_packet.h"
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// GLOBALS
|
// GLOBALS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// LoRa radio (SX1262)
|
||||||
|
SX1262 radio = new Module(LORA_SS, LORA_DIO1, LORA_RST, LORA_BUSY);
|
||||||
|
volatile bool loraReceived = false;
|
||||||
|
int8_t lastLoraRssi = 0;
|
||||||
|
|
||||||
|
// Remote node registry
|
||||||
|
#define MAX_REMOTE_NODES 16
|
||||||
|
struct RemoteNode {
|
||||||
|
uint16_t node_id;
|
||||||
|
uint8_t sensor_type;
|
||||||
|
char name[9];
|
||||||
|
uint8_t last_seq;
|
||||||
|
uint32_t last_seen;
|
||||||
|
uint16_t battery_mv;
|
||||||
|
bool active;
|
||||||
|
};
|
||||||
|
RemoteNode remoteNodes[MAX_REMOTE_NODES];
|
||||||
|
int remoteNodeCount = 0;
|
||||||
|
|
||||||
|
// LoRa statistics
|
||||||
|
uint32_t loraPacketsReceived = 0;
|
||||||
|
uint32_t loraPacketsForwarded = 0;
|
||||||
|
uint32_t loraPacketsCrcFail = 0;
|
||||||
|
|
||||||
// OneWire and DS18B20
|
// OneWire and DS18B20
|
||||||
OneWire oneWire(ONEWIRE_PIN);
|
OneWire oneWire(ONEWIRE_PIN);
|
||||||
DallasTemperature sensors(&oneWire);
|
DallasTemperature sensors(&oneWire);
|
||||||
@@ -79,16 +109,35 @@ int bufferCount = 0;
|
|||||||
void setupWiFi();
|
void setupWiFi();
|
||||||
void setupSensors();
|
void setupSensors();
|
||||||
void setupDisplay();
|
void setupDisplay();
|
||||||
|
void setupLoRa();
|
||||||
void readTemperatures();
|
void readTemperatures();
|
||||||
void updateDisplay();
|
void updateDisplay();
|
||||||
void postToApi();
|
void postToApi();
|
||||||
|
void postLoRaReadingToApi(const lora_packet_t* pkt, const char* sensorType,
|
||||||
|
float temp_f, float humidity, float pressure,
|
||||||
|
uint16_t gas_kohm, uint8_t iaq, uint16_t battery_mv,
|
||||||
|
int8_t rssi);
|
||||||
void sendBufferedReadings();
|
void sendBufferedReadings();
|
||||||
|
void checkLoRa();
|
||||||
|
void processLoRaPacket(uint8_t* data, uint8_t len);
|
||||||
|
void handleSensorData(const lora_packet_t* pkt);
|
||||||
|
void handleRegistration(const lora_packet_t* pkt);
|
||||||
|
void sendAck(uint16_t node_id, uint8_t seq);
|
||||||
|
void sendRegistrationAck(uint16_t node_id, uint8_t seq, uint16_t assigned_id);
|
||||||
|
RemoteNode* findNode(uint16_t node_id);
|
||||||
|
RemoteNode* addNode(uint16_t node_id, uint8_t sensor_type, const char* name);
|
||||||
|
uint16_t allocateNodeId(uint8_t sensor_type);
|
||||||
float celsiusToFahrenheit(float celsius);
|
float celsiusToFahrenheit(float celsius);
|
||||||
bool isValidTemperature(float temp_f);
|
bool isValidTemperature(float temp_f);
|
||||||
String formatAddress(DeviceAddress addr);
|
String formatAddress(DeviceAddress addr);
|
||||||
void displaySplash();
|
void displaySplash();
|
||||||
void displayError(const char* message);
|
void displayError(const char* message);
|
||||||
|
|
||||||
|
// LoRa interrupt handler
|
||||||
|
ICACHE_RAM_ATTR void onLoRaReceive() {
|
||||||
|
loraReceived = true;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// SETUP
|
// SETUP
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -99,15 +148,21 @@ void setup() {
|
|||||||
|
|
||||||
Serial.println("\n\n");
|
Serial.println("\n\n");
|
||||||
Serial.println("=========================================");
|
Serial.println("=========================================");
|
||||||
Serial.println(" ZNET Temperature Sensor v1.0");
|
Serial.println(" ZNET Temperature Sensor v1.2");
|
||||||
Serial.println(" E-Coat Paint Tank Monitor");
|
Serial.println(" E-Coat Paint Tank Gateway");
|
||||||
Serial.println("=========================================");
|
Serial.println("=========================================");
|
||||||
|
|
||||||
|
// Initialize node registry
|
||||||
|
memset(remoteNodes, 0, sizeof(remoteNodes));
|
||||||
|
|
||||||
// Initialize display first for visual feedback
|
// Initialize display first for visual feedback
|
||||||
setupDisplay();
|
setupDisplay();
|
||||||
displaySplash();
|
displaySplash();
|
||||||
|
|
||||||
// Initialize sensors
|
// Initialize LoRa radio
|
||||||
|
setupLoRa();
|
||||||
|
|
||||||
|
// Initialize local sensors
|
||||||
setupSensors();
|
setupSensors();
|
||||||
|
|
||||||
// Connect to WiFi
|
// Connect to WiFi
|
||||||
@@ -126,7 +181,10 @@ void loop() {
|
|||||||
// Check WiFi connection
|
// Check WiFi connection
|
||||||
wifiConnected = WiFi.status() == WL_CONNECTED;
|
wifiConnected = WiFi.status() == WL_CONNECTED;
|
||||||
|
|
||||||
// Read temperatures at interval
|
// Check for LoRa packets (non-blocking)
|
||||||
|
checkLoRa();
|
||||||
|
|
||||||
|
// Read local temperatures at interval
|
||||||
if (now - lastTempRead >= TEMP_READ_INTERVAL_MS || lastTempRead == 0) {
|
if (now - lastTempRead >= TEMP_READ_INTERVAL_MS || lastTempRead == 0) {
|
||||||
readTemperatures();
|
readTemperatures();
|
||||||
lastTempRead = now;
|
lastTempRead = now;
|
||||||
@@ -313,11 +371,13 @@ void updateDisplay() {
|
|||||||
// Title bar
|
// Title bar
|
||||||
display.setTextAlignment(TEXT_ALIGN_LEFT);
|
display.setTextAlignment(TEXT_ALIGN_LEFT);
|
||||||
display.setFont(ArialMT_Plain_10);
|
display.setFont(ArialMT_Plain_10);
|
||||||
display.drawString(0, 0, "ZNET Tank Temp");
|
display.drawString(0, 0, "ZNET Gateway");
|
||||||
|
|
||||||
// WiFi status indicator
|
// WiFi and LoRa status
|
||||||
display.setTextAlignment(TEXT_ALIGN_RIGHT);
|
display.setTextAlignment(TEXT_ALIGN_RIGHT);
|
||||||
display.drawString(128, 0, wifiConnected ? "WiFi OK" : "NO WiFi");
|
char status[24];
|
||||||
|
snprintf(status, sizeof(status), "%s L%d", wifiConnected ? "W" : "-", remoteNodeCount);
|
||||||
|
display.drawString(128, 0, status);
|
||||||
|
|
||||||
// Horizontal line
|
// Horizontal line
|
||||||
display.drawLine(0, 12, 128, 12);
|
display.drawLine(0, 12, 128, 12);
|
||||||
@@ -329,32 +389,39 @@ void updateDisplay() {
|
|||||||
if (sensor1Valid) {
|
if (sensor1Valid) {
|
||||||
char buf[20];
|
char buf[20];
|
||||||
snprintf(buf, sizeof(buf), "%.1f°F", temperature1_f);
|
snprintf(buf, sizeof(buf), "%.1f°F", temperature1_f);
|
||||||
display.drawString(64, 18, buf);
|
display.drawString(64, 16, buf);
|
||||||
} else {
|
} else {
|
||||||
display.drawString(64, 18, "---");
|
display.drawString(64, 16, "---");
|
||||||
}
|
}
|
||||||
|
|
||||||
display.setFont(ArialMT_Plain_10);
|
display.setFont(ArialMT_Plain_10);
|
||||||
display.drawString(64, 36, "Primary");
|
display.drawString(64, 34, "Tank Primary");
|
||||||
|
|
||||||
// Secondary sensor (smaller, below)
|
// Secondary sensor (smaller, below)
|
||||||
if (sensorCount >= 2) {
|
if (sensorCount >= 2 && sensor2Valid) {
|
||||||
display.setFont(ArialMT_Plain_10);
|
char buf[20];
|
||||||
if (sensor2Valid) {
|
snprintf(buf, sizeof(buf), "Backup: %.1f°F", temperature2_f);
|
||||||
char buf[20];
|
display.drawString(64, 46, buf);
|
||||||
snprintf(buf, sizeof(buf), "Backup: %.1f°F", temperature2_f);
|
|
||||||
display.drawString(64, 50, buf);
|
|
||||||
} else {
|
|
||||||
display.drawString(64, 50, "Backup: ---");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buffer indicator (if offline)
|
// Status line at bottom
|
||||||
|
display.setTextAlignment(TEXT_ALIGN_LEFT);
|
||||||
|
char buf[32];
|
||||||
|
|
||||||
if (bufferCount > 0) {
|
if (bufferCount > 0) {
|
||||||
display.setTextAlignment(TEXT_ALIGN_LEFT);
|
|
||||||
char buf[20];
|
|
||||||
snprintf(buf, sizeof(buf), "Buf:%d", bufferCount);
|
snprintf(buf, sizeof(buf), "Buf:%d", bufferCount);
|
||||||
display.drawString(0, 54, buf);
|
} else if (loraPacketsReceived > 0) {
|
||||||
|
snprintf(buf, sizeof(buf), "RX:%lu", loraPacketsReceived);
|
||||||
|
} else {
|
||||||
|
buf[0] = '\0';
|
||||||
|
}
|
||||||
|
display.drawString(0, 54, buf);
|
||||||
|
|
||||||
|
// RSSI of last LoRa packet
|
||||||
|
if (loraPacketsReceived > 0) {
|
||||||
|
display.setTextAlignment(TEXT_ALIGN_RIGHT);
|
||||||
|
snprintf(buf, sizeof(buf), "%ddBm", lastLoraRssi);
|
||||||
|
display.drawString(128, 54, buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
display.display();
|
display.display();
|
||||||
@@ -438,6 +505,402 @@ void sendBufferedReadings() {
|
|||||||
bufferHead = 0;
|
bufferHead = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LORA SETUP AND RECEIVE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
void setupLoRa() {
|
||||||
|
Serial.println("Initializing LoRa radio (SX1262)...");
|
||||||
|
|
||||||
|
// Initialize SPI for LoRa
|
||||||
|
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_SS);
|
||||||
|
|
||||||
|
// Initialize radio
|
||||||
|
// RadioLib SX1262.begin(freq, bw_kHz, sf, cr, syncWord, power, preambleLen, tcxoVoltage)
|
||||||
|
int state = radio.begin(
|
||||||
|
LORA_FREQUENCY,
|
||||||
|
LORA_BANDWIDTH / 1000.0, // Convert Hz to kHz
|
||||||
|
LORA_SPREADING_FACTOR,
|
||||||
|
LORA_CODING_RATE,
|
||||||
|
LORA_SYNC_WORD,
|
||||||
|
14, // TX power (dBm)
|
||||||
|
8, // Preamble length
|
||||||
|
0 // TCXO voltage (0 = use default)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (state == RADIOLIB_ERR_NONE) {
|
||||||
|
Serial.println("LoRa init success!");
|
||||||
|
} else {
|
||||||
|
Serial.printf("LoRa init failed, code %d\n", state);
|
||||||
|
displayError("LoRa init failed!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up receive interrupt
|
||||||
|
radio.setDio1Action(onLoRaReceive);
|
||||||
|
|
||||||
|
// Start receiving
|
||||||
|
state = radio.startReceive();
|
||||||
|
if (state != RADIOLIB_ERR_NONE) {
|
||||||
|
Serial.printf("LoRa receive start failed: %d\n", state);
|
||||||
|
} else {
|
||||||
|
Serial.println("LoRa listening on 915 MHz...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkLoRa() {
|
||||||
|
// Check if packet received via interrupt
|
||||||
|
if (!loraReceived) return;
|
||||||
|
|
||||||
|
loraReceived = false;
|
||||||
|
|
||||||
|
// Read packet
|
||||||
|
uint8_t rxBuffer[LORA_MAX_PACKET_SIZE];
|
||||||
|
size_t rxLen = 0;
|
||||||
|
|
||||||
|
int state = radio.readData(rxBuffer, rxLen);
|
||||||
|
|
||||||
|
if (state == RADIOLIB_ERR_NONE) {
|
||||||
|
lastLoraRssi = radio.getRSSI();
|
||||||
|
loraPacketsReceived++;
|
||||||
|
|
||||||
|
Serial.printf("LoRa RX: %d bytes, RSSI %d dBm\n", rxLen, lastLoraRssi);
|
||||||
|
|
||||||
|
// Process the packet
|
||||||
|
processLoRaPacket(rxBuffer, rxLen);
|
||||||
|
} else if (state == RADIOLIB_ERR_CRC_MISMATCH) {
|
||||||
|
loraPacketsCrcFail++;
|
||||||
|
Serial.println("LoRa CRC error");
|
||||||
|
} else {
|
||||||
|
Serial.printf("LoRa RX error: %d\n", state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart receive mode
|
||||||
|
radio.startReceive();
|
||||||
|
}
|
||||||
|
|
||||||
|
void processLoRaPacket(uint8_t* data, uint8_t len) {
|
||||||
|
lora_packet_t pkt;
|
||||||
|
|
||||||
|
if (!lora_packet_parse(&pkt, data, len)) {
|
||||||
|
Serial.println("Invalid packet format");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("Packet from 0x%04X, type %d, seq %d\n",
|
||||||
|
pkt.node_id, pkt.type, pkt.sequence);
|
||||||
|
|
||||||
|
switch (pkt.type) {
|
||||||
|
case LORA_PKT_SENSOR_DATA:
|
||||||
|
handleSensorData(&pkt);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LORA_PKT_REGISTER:
|
||||||
|
handleRegistration(&pkt);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LORA_PKT_ALERT:
|
||||||
|
// TODO: Handle alerts with higher priority
|
||||||
|
handleSensorData(&pkt); // For now, treat like sensor data
|
||||||
|
break;
|
||||||
|
|
||||||
|
case LORA_PKT_PONG:
|
||||||
|
Serial.printf("PONG from 0x%04X\n", pkt.node_id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
Serial.printf("Unknown packet type: %d\n", pkt.type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleSensorData(const lora_packet_t* pkt) {
|
||||||
|
const uint8_t* payload = lora_packet_payload(pkt);
|
||||||
|
uint8_t payload_len = lora_packet_payload_len(pkt);
|
||||||
|
|
||||||
|
if (payload_len < 1) {
|
||||||
|
Serial.println("Empty payload");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t sensor_type = payload[0];
|
||||||
|
|
||||||
|
// Update node registry
|
||||||
|
RemoteNode* node = findNode(pkt->node_id);
|
||||||
|
if (node) {
|
||||||
|
node->last_seen = millis();
|
||||||
|
node->last_seq = pkt->sequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse based on sensor type and forward to API
|
||||||
|
switch (sensor_type) {
|
||||||
|
case SENSOR_TYPE_DHT22: {
|
||||||
|
lora_dht22_data_t data;
|
||||||
|
if (lora_parse_dht22(payload, payload_len, &data)) {
|
||||||
|
float temp_f = celsiusToFahrenheit(data.temp_c);
|
||||||
|
Serial.printf("DHT22 from 0x%04X: %.1f°F, %.1f%% RH\n",
|
||||||
|
pkt->node_id, temp_f, data.humidity_pct);
|
||||||
|
|
||||||
|
if (node) node->battery_mv = data.battery_mv;
|
||||||
|
|
||||||
|
postLoRaReadingToApi(pkt, "dht22", temp_f, data.humidity_pct,
|
||||||
|
0, 0, 0, data.battery_mv, data.rssi_dbm);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SENSOR_TYPE_BME680: {
|
||||||
|
lora_bme680_data_t data;
|
||||||
|
if (lora_parse_bme680(payload, payload_len, &data)) {
|
||||||
|
float temp_f = celsiusToFahrenheit(data.temp_c);
|
||||||
|
Serial.printf("BME680 from 0x%04X: %.1f°F, %.1f%%, %.0f hPa, IAQ %d\n",
|
||||||
|
pkt->node_id, temp_f, data.humidity_pct,
|
||||||
|
data.pressure_hpa, data.iaq_index);
|
||||||
|
|
||||||
|
if (node) node->battery_mv = data.battery_mv;
|
||||||
|
|
||||||
|
postLoRaReadingToApi(pkt, "bme680", temp_f, data.humidity_pct,
|
||||||
|
data.pressure_hpa, data.gas_kohm,
|
||||||
|
data.iaq_index, data.battery_mv, data.rssi_dbm);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SENSOR_TYPE_DS18B20: {
|
||||||
|
lora_ds18b20_data_t data;
|
||||||
|
if (lora_parse_ds18b20(payload, payload_len, &data)) {
|
||||||
|
float temp_f = celsiusToFahrenheit(data.temp_c);
|
||||||
|
Serial.printf("DS18B20 from 0x%04X: %.2f°F\n",
|
||||||
|
pkt->node_id, temp_f);
|
||||||
|
|
||||||
|
if (node) node->battery_mv = data.battery_mv;
|
||||||
|
|
||||||
|
postLoRaReadingToApi(pkt, "ds18b20", temp_f, 0, 0, 0, 0,
|
||||||
|
data.battery_mv, data.rssi_dbm);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
Serial.printf("Unknown sensor type: 0x%02X\n", sensor_type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send ACK if requested
|
||||||
|
if (pkt->ack_requested) {
|
||||||
|
sendAck(pkt->node_id, pkt->sequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleRegistration(const lora_packet_t* pkt) {
|
||||||
|
const uint8_t* payload = lora_packet_payload(pkt);
|
||||||
|
uint8_t payload_len = lora_packet_payload_len(pkt);
|
||||||
|
|
||||||
|
lora_register_data_t reg;
|
||||||
|
if (!lora_parse_register(payload, payload_len, ®)) {
|
||||||
|
Serial.println("Invalid registration packet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("Registration from 0x%04X: '%s', type %d, FW %d.%d\n",
|
||||||
|
pkt->node_id, reg.node_name, reg.sensor_type,
|
||||||
|
reg.fw_major, reg.fw_minor);
|
||||||
|
|
||||||
|
// Check if node already registered
|
||||||
|
RemoteNode* existing = findNode(pkt->node_id);
|
||||||
|
if (existing && pkt->node_id != NODE_ID_AUTO_REQUEST) {
|
||||||
|
// Re-registration, update info
|
||||||
|
existing->last_seen = millis();
|
||||||
|
strncpy(existing->name, reg.node_name, 8);
|
||||||
|
existing->name[8] = '\0';
|
||||||
|
|
||||||
|
sendRegistrationAck(pkt->node_id, pkt->sequence, pkt->node_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate new node ID if requested
|
||||||
|
uint16_t assigned_id = reg.proposed_id;
|
||||||
|
if (assigned_id == NODE_ID_AUTO_REQUEST) {
|
||||||
|
assigned_id = allocateNodeId(reg.sensor_type);
|
||||||
|
Serial.printf("Auto-assigned ID: 0x%04X\n", assigned_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to registry
|
||||||
|
RemoteNode* node = addNode(assigned_id, reg.sensor_type, reg.node_name);
|
||||||
|
if (node) {
|
||||||
|
sendRegistrationAck(pkt->node_id, pkt->sequence, assigned_id);
|
||||||
|
Serial.printf("Node 0x%04X registered as '%s'\n", assigned_id, node->name);
|
||||||
|
} else {
|
||||||
|
Serial.println("Node registry full!");
|
||||||
|
// TODO: Send NAK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void postLoRaReadingToApi(const lora_packet_t* pkt, const char* sensorType,
|
||||||
|
float temp_f, float humidity, float pressure,
|
||||||
|
uint16_t gas_kohm, uint8_t iaq, uint16_t battery_mv,
|
||||||
|
int8_t rssi) {
|
||||||
|
if (!wifiConnected) {
|
||||||
|
Serial.println("No WiFi - can't forward LoRa reading");
|
||||||
|
// TODO: Buffer LoRa readings
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTPClient http;
|
||||||
|
http.begin(ZNET_API_URL);
|
||||||
|
http.addHeader("Content-Type", "application/json");
|
||||||
|
http.setTimeout(HTTP_TIMEOUT_MS);
|
||||||
|
|
||||||
|
// Build JSON payload
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["device_id"] = DEVICE_ID;
|
||||||
|
doc["device_name"] = DEVICE_NAME;
|
||||||
|
|
||||||
|
JsonArray readings = doc["readings"].to<JsonArray>();
|
||||||
|
JsonObject r = readings.add<JsonObject>();
|
||||||
|
|
||||||
|
// Sensor ID includes LoRa prefix and node ID
|
||||||
|
char sensor_id[32];
|
||||||
|
snprintf(sensor_id, sizeof(sensor_id), "lora_0x%04X", pkt->node_id);
|
||||||
|
|
||||||
|
r["sensor_id"] = sensor_id;
|
||||||
|
r["sensor_type"] = sensorType;
|
||||||
|
r["temperature_f"] = round(temp_f * 100) / 100.0;
|
||||||
|
r["is_valid"] = true;
|
||||||
|
r["rssi_dbm"] = rssi;
|
||||||
|
r["battery_mv"] = battery_mv;
|
||||||
|
|
||||||
|
// Add optional fields based on sensor type
|
||||||
|
if (humidity > 0) {
|
||||||
|
r["humidity_pct"] = round(humidity * 10) / 10.0;
|
||||||
|
}
|
||||||
|
if (pressure > 0) {
|
||||||
|
r["pressure_hpa"] = round(pressure * 10) / 10.0;
|
||||||
|
}
|
||||||
|
if (gas_kohm > 0) {
|
||||||
|
r["gas_resistance_kohm"] = gas_kohm;
|
||||||
|
}
|
||||||
|
if (iaq > 0) {
|
||||||
|
r["iaq_index"] = iaq;
|
||||||
|
}
|
||||||
|
|
||||||
|
String jsonString;
|
||||||
|
serializeJson(doc, jsonString);
|
||||||
|
|
||||||
|
int httpCode = http.POST(jsonString);
|
||||||
|
|
||||||
|
if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_CREATED) {
|
||||||
|
loraPacketsForwarded++;
|
||||||
|
Serial.printf("LoRa reading forwarded (0x%04X)\n", pkt->node_id);
|
||||||
|
} else {
|
||||||
|
Serial.printf("LoRa forward failed: %d\n", httpCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
http.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendAck(uint16_t node_id, uint8_t seq) {
|
||||||
|
lora_packet_t ack;
|
||||||
|
lora_packet_init(&ack, LORA_PKT_ACK, node_id, seq, false, false, false);
|
||||||
|
lora_packet_finalize(&ack);
|
||||||
|
|
||||||
|
int state = radio.transmit(ack.data, ack.length);
|
||||||
|
if (state == RADIOLIB_ERR_NONE) {
|
||||||
|
Serial.printf("ACK sent to 0x%04X seq %d\n", node_id, seq);
|
||||||
|
} else {
|
||||||
|
Serial.printf("ACK send failed: %d\n", state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return to receive mode
|
||||||
|
radio.startReceive();
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendRegistrationAck(uint16_t node_id, uint8_t seq, uint16_t assigned_id) {
|
||||||
|
lora_packet_t ack;
|
||||||
|
lora_packet_init(&ack, LORA_PKT_REGISTER_ACK, node_id, seq, false, false, false);
|
||||||
|
lora_packet_add_register_ack(&ack, assigned_id, 0, TEMP_READ_INTERVAL_MS / 1000);
|
||||||
|
lora_packet_finalize(&ack);
|
||||||
|
|
||||||
|
int state = radio.transmit(ack.data, ack.length);
|
||||||
|
if (state == RADIOLIB_ERR_NONE) {
|
||||||
|
Serial.printf("Registration ACK sent to 0x%04X -> 0x%04X\n", node_id, assigned_id);
|
||||||
|
} else {
|
||||||
|
Serial.printf("Registration ACK send failed: %d\n", state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return to receive mode
|
||||||
|
radio.startReceive();
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteNode* findNode(uint16_t node_id) {
|
||||||
|
for (int i = 0; i < remoteNodeCount; i++) {
|
||||||
|
if (remoteNodes[i].active && remoteNodes[i].node_id == node_id) {
|
||||||
|
return &remoteNodes[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteNode* addNode(uint16_t node_id, uint8_t sensor_type, const char* name) {
|
||||||
|
// Find empty slot
|
||||||
|
RemoteNode* node = nullptr;
|
||||||
|
for (int i = 0; i < MAX_REMOTE_NODES; i++) {
|
||||||
|
if (!remoteNodes[i].active) {
|
||||||
|
node = &remoteNodes[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node) return nullptr;
|
||||||
|
|
||||||
|
node->node_id = node_id;
|
||||||
|
node->sensor_type = sensor_type;
|
||||||
|
strncpy(node->name, name, 8);
|
||||||
|
node->name[8] = '\0';
|
||||||
|
node->last_seq = 0;
|
||||||
|
node->last_seen = millis();
|
||||||
|
node->battery_mv = 0;
|
||||||
|
node->active = true;
|
||||||
|
|
||||||
|
remoteNodeCount++;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t allocateNodeId(uint8_t sensor_type) {
|
||||||
|
// Determine ID range based on sensor type
|
||||||
|
uint16_t base_id;
|
||||||
|
switch (sensor_type) {
|
||||||
|
case SENSOR_TYPE_DHT22:
|
||||||
|
base_id = NODE_ID_AMBIENT_START;
|
||||||
|
break;
|
||||||
|
case SENSOR_TYPE_BME680:
|
||||||
|
base_id = NODE_ID_ENVIRO_START;
|
||||||
|
break;
|
||||||
|
case SENSOR_TYPE_DS18B20:
|
||||||
|
base_id = NODE_ID_TANK_START;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
base_id = NODE_ID_AUTO_START;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find next available ID in range
|
||||||
|
for (uint16_t id = base_id; id < base_id + 0xFF; id++) {
|
||||||
|
if (!findNode(id)) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to auto range
|
||||||
|
for (uint16_t id = NODE_ID_AUTO_START; id < NODE_ID_AUTO_END; id++) {
|
||||||
|
if (!findNode(id)) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0xFFFE; // Last resort
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// UTILITY FUNCTIONS
|
// UTILITY FUNCTIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user