From 5554341b49571502d7516293b000b4d0182cb024 Mon Sep 17 00:00:00 2001 From: leehughes Date: Sat, 24 Jan 2026 17:50:53 -0600 Subject: [PATCH] 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 --- CLAUDE.md | 75 +++- docs/LORA_PROTOCOL.md | 409 +++++++++++++++++++++ docs/SETUP_GUIDE.md | 315 ++++++++++++++++ include/config.h | 15 +- include/lora_packet.h | 439 ++++++++++++++++++++++ include/lora_protocol.h | 346 ++++++++++++++++++ remote-node/README.md | 157 ++++++++ remote-node/include/config.h | 154 ++++++++ remote-node/platformio.ini | 120 ++++++ remote-node/src/main.cpp | 690 +++++++++++++++++++++++++++++++++++ src/main.cpp | 513 ++++++++++++++++++++++++-- 11 files changed, 3199 insertions(+), 34 deletions(-) create mode 100644 docs/LORA_PROTOCOL.md create mode 100644 docs/SETUP_GUIDE.md create mode 100644 include/lora_packet.h create mode 100644 include/lora_protocol.h create mode 100644 remote-node/README.md create mode 100644 remote-node/include/config.h create mode 100644 remote-node/platformio.ini create mode 100644 remote-node/src/main.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 5530d80..36a9fb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,12 +130,78 @@ All configuration in `include/config.h`: - [x] Offline buffering when network unavailable - [x] Automatic sensor discovery (1-Wire) -### Future (Phase 3) -- [ ] LoRa receive for remote sensor nodes -- [ ] Battery-powered remote node firmware +### Phase 3: LoRa Network (Complete) +- [x] LoRa packet protocol design (docs/LORA_PROTOCOL.md) +- [x] Protocol header files (include/lora_protocol.h, include/lora_packet.h) +- [x] Battery-powered remote node firmware (remote-node/) +- [x] LoRa receive for gateway (RadioLib SX1262) +- [x] Gateway LoRa → HTTP forwarding +- [x] Automatic node registration and ACK responses + +### Future - [ ] Sensor agreement monitoring - [ ] Flash storage for offline buffer persistence +## Remote Sensor Nodes + +Battery-powered sensor nodes in `remote-node/` that transmit to the gateway via LoRa. + +### Build & Upload +```bash +cd remote-node + +# DHT22 variant (ambient temp/humidity) +pio run -e heltec_v3_dht22 --target upload + +# BME680 variant (temp/humidity/pressure/VOC) +pio run -e heltec_v3_bme680 --target upload +``` + +### Configuration +Edit `remote-node/include/config.h`: +- `NODE_ID` - Unique ID or 0xFFFF for auto-assign +- `NODE_NAME` - Human-readable name (8 chars max) +- `REPORT_INTERVAL_SEC` - How often to transmit (default 60s) + +### Power Consumption +- Deep sleep: ~10 μA +- Average @ 60s interval: ~1 mA +- 1000mAh LiPo battery life: ~1 month + +## LoRa Protocol + +The gateway will receive data from remote sensor nodes via LoRa 915MHz. Full specification in `docs/LORA_PROTOCOL.md`. + +### Key Files +| File | Purpose | +|------|---------| +| `docs/LORA_PROTOCOL.md` | Full protocol specification | +| `include/lora_protocol.h` | Constants, types, CRC functions | +| `include/lora_packet.h` | Packet builder/parser helpers | + +### Supported Sensors +| Type | Code | Payload Size | Data | +|------|------|--------------|------| +| DHT22 | 0x01 | 7 bytes | Temp, Humidity, Battery, RSSI | +| BME680 | 0x02 | 12 bytes | Temp, Humidity, Pressure, Gas, IAQ | +| DS18B20 | 0x03 | 5 bytes | Temp (high precision), Battery, RSSI | + +### Node ID Ranges +| Range | Purpose | +|-------|---------| +| 0x0001-0x00FF | Tank sensors | +| 0x0100-0x01FF | Ambient sensors (DHT22) | +| 0x0200-0x02FF | Environmental (BME680) | +| 0xFFFF | Auto-assign request | + +## Documentation + +| Document | Purpose | +|----------|---------| +| `docs/SETUP_GUIDE.md` | Complete setup guide with wiring diagrams | +| `docs/LORA_PROTOCOL.md` | LoRa packet format specification | +| `remote-node/README.md` | Remote node firmware guide | + ## Related Projects - **ZNET Web**: `~/Nextcloud/Dev/znet-web` - Backend receives temperature data @@ -151,3 +217,6 @@ All configuration in `include/config.h`: | Date | Version | Change | |------|---------|--------| | 2026-01-24 | 1.0.0 | Initial implementation | +| 2026-01-24 | 1.1.0 | LoRa protocol design, header files | +| 2026-01-24 | 1.2.0 | Remote node firmware template (DHT22, BME680, DS18B20) | +| 2026-01-24 | 1.3.0 | Gateway LoRa receive, node registry, HTTP forwarding | diff --git a/docs/LORA_PROTOCOL.md b/docs/LORA_PROTOCOL.md new file mode 100644 index 0000000..675b7ac --- /dev/null +++ b/docs/LORA_PROTOCOL.md @@ -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 | diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md new file mode 100644 index 0000000..edf8b74 --- /dev/null +++ b/docs/SETUP_GUIDE.md @@ -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 diff --git a/include/config.h b/include/config.h index ab2bed3..92d294c 100644 --- a/include/config.h +++ b/include/config.h @@ -56,24 +56,27 @@ #define DISPLAY_UPDATE_INTERVAL_MS 1000 // ============================================================================ -// LORA CONFIGURATION (for future remote sensor nodes) +// LORA CONFIGURATION (for remote sensor nodes) // ============================================================================ -// LoRa frequency (915 MHz for US) +// LoRa frequency (915 MHz for US, 868 MHz for EU) #define LORA_FREQUENCY 915.0 -// LoRa bandwidth (125 kHz) -#define LORA_BANDWIDTH 125.0 +// LoRa bandwidth in kHz (125, 250, or 500) +#define LORA_BANDWIDTH 125000 // 125 kHz // LoRa spreading factor (7-12, higher = longer range but slower) #define LORA_SPREADING_FACTOR 9 -// LoRa coding rate (5-8) +// LoRa coding rate (5-8, higher = more error correction) #define LORA_CODING_RATE 7 -// LoRa sync word (private network) +// LoRa sync word (must match remote nodes: 0x12) #define LORA_SYNC_WORD 0x12 +// Maximum remote nodes to track +#define MAX_LORA_NODES 16 + // ============================================================================ // API RETRY CONFIGURATION // ============================================================================ diff --git a/include/lora_packet.h b/include/lora_packet.h new file mode 100644 index 0000000..b47ff53 --- /dev/null +++ b/include/lora_packet.h @@ -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 + +// ============================================================================ +// 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 diff --git a/include/lora_protocol.h b/include/lora_protocol.h new file mode 100644 index 0000000..f9fb768 --- /dev/null +++ b/include/lora_protocol.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 +#include + +// ============================================================================ +// 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 diff --git a/remote-node/README.md b/remote-node/README.md new file mode 100644 index 0000000..06aaf82 --- /dev/null +++ b/remote-node/README.md @@ -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) diff --git a/remote-node/include/config.h b/remote-node/include/config.h new file mode 100644 index 0000000..c59401a --- /dev/null +++ b/remote-node/include/config.h @@ -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 diff --git a/remote-node/platformio.ini b/remote-node/platformio.ini new file mode 100644 index 0000000..7d58d2c --- /dev/null +++ b/remote-node/platformio.ini @@ -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 diff --git a/remote-node/src/main.cpp b/remote-node/src/main.cpp new file mode 100644 index 0000000..ca64454 --- /dev/null +++ b/remote-node/src/main.cpp @@ -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 +#include + +#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 + #include + SX1262 radio = new Module(LORA_SS, LORA_DIO1, LORA_RST, LORA_BUSY); + SSD1306Wire display(0x3C, OLED_SDA, OLED_SCL); + #define USE_RADIOLIB +#else + #include + #ifdef ENABLE_OLED + #include + SSD1306Wire display(0x3C, OLED_SDA, OLED_SCL); + #endif +#endif + +// ============================================================================ +// SENSOR INCLUDES +// ============================================================================ + +#ifdef SENSOR_TYPE_DHT22 + #include + DHT dht(DHT_PIN, DHT22); +#endif + +#ifdef SENSOR_TYPE_BME680 + #include + #include + Adafruit_BME680 bme; +#endif + +#ifdef SENSOR_TYPE_DS18B20 + #include + #include + 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 +} diff --git a/src/main.cpp b/src/main.cpp index 9caf141..b8146e2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,19 +3,21 @@ * * Reads DS18B20 temperature sensors in the e-coat paint tank and sends * data to ZNET Web for real-time monitoring and ML feature collection. + * Also receives data from remote LoRa sensor nodes (DHT22, BME680). * * Hardware: * - Meshnology ESP32 LoRa V3 (Heltec-compatible) * - 2x DS18B20 waterproof temperature probes (1-Wire) * - Built-in 0.96" OLED display - * - Built-in SX1262 LoRa radio (for future sensor nodes) + * - Built-in SX1262 LoRa radio * * Features: * - Dual sensor reading with validation and averaging * - OLED display for local temperature visibility * - HTTP POST to ZNET Web API * - Offline buffering when network is unavailable - * - LoRa receive for future remote sensor nodes + * - LoRa receive from remote sensor nodes + * - Automatic node registration and ACK responses * - WiFiManager for easy WiFi setup */ @@ -27,13 +29,41 @@ #include #include #include +#include +#include #include "config.h" +#include "lora_protocol.h" +#include "lora_packet.h" // ============================================================================ // GLOBALS // ============================================================================ +// LoRa radio (SX1262) +SX1262 radio = new Module(LORA_SS, LORA_DIO1, LORA_RST, LORA_BUSY); +volatile bool loraReceived = false; +int8_t lastLoraRssi = 0; + +// Remote node registry +#define MAX_REMOTE_NODES 16 +struct RemoteNode { + uint16_t node_id; + uint8_t sensor_type; + char name[9]; + uint8_t last_seq; + uint32_t last_seen; + uint16_t battery_mv; + bool active; +}; +RemoteNode remoteNodes[MAX_REMOTE_NODES]; +int remoteNodeCount = 0; + +// LoRa statistics +uint32_t loraPacketsReceived = 0; +uint32_t loraPacketsForwarded = 0; +uint32_t loraPacketsCrcFail = 0; + // OneWire and DS18B20 OneWire oneWire(ONEWIRE_PIN); DallasTemperature sensors(&oneWire); @@ -79,16 +109,35 @@ int bufferCount = 0; void setupWiFi(); void setupSensors(); void setupDisplay(); +void setupLoRa(); void readTemperatures(); void updateDisplay(); void postToApi(); +void postLoRaReadingToApi(const lora_packet_t* pkt, const char* sensorType, + float temp_f, float humidity, float pressure, + uint16_t gas_kohm, uint8_t iaq, uint16_t battery_mv, + int8_t rssi); void sendBufferedReadings(); +void checkLoRa(); +void processLoRaPacket(uint8_t* data, uint8_t len); +void handleSensorData(const lora_packet_t* pkt); +void handleRegistration(const lora_packet_t* pkt); +void sendAck(uint16_t node_id, uint8_t seq); +void sendRegistrationAck(uint16_t node_id, uint8_t seq, uint16_t assigned_id); +RemoteNode* findNode(uint16_t node_id); +RemoteNode* addNode(uint16_t node_id, uint8_t sensor_type, const char* name); +uint16_t allocateNodeId(uint8_t sensor_type); float celsiusToFahrenheit(float celsius); bool isValidTemperature(float temp_f); String formatAddress(DeviceAddress addr); void displaySplash(); void displayError(const char* message); +// LoRa interrupt handler +ICACHE_RAM_ATTR void onLoRaReceive() { + loraReceived = true; +} + // ============================================================================ // SETUP // ============================================================================ @@ -99,15 +148,21 @@ void setup() { Serial.println("\n\n"); Serial.println("========================================="); - Serial.println(" ZNET Temperature Sensor v1.0"); - Serial.println(" E-Coat Paint Tank Monitor"); + Serial.println(" ZNET Temperature Sensor v1.2"); + Serial.println(" E-Coat Paint Tank Gateway"); Serial.println("========================================="); + // Initialize node registry + memset(remoteNodes, 0, sizeof(remoteNodes)); + // Initialize display first for visual feedback setupDisplay(); displaySplash(); - // Initialize sensors + // Initialize LoRa radio + setupLoRa(); + + // Initialize local sensors setupSensors(); // Connect to WiFi @@ -126,7 +181,10 @@ void loop() { // Check WiFi connection wifiConnected = WiFi.status() == WL_CONNECTED; - // Read temperatures at interval + // Check for LoRa packets (non-blocking) + checkLoRa(); + + // Read local temperatures at interval if (now - lastTempRead >= TEMP_READ_INTERVAL_MS || lastTempRead == 0) { readTemperatures(); lastTempRead = now; @@ -313,11 +371,13 @@ void updateDisplay() { // Title bar display.setTextAlignment(TEXT_ALIGN_LEFT); display.setFont(ArialMT_Plain_10); - display.drawString(0, 0, "ZNET Tank Temp"); + display.drawString(0, 0, "ZNET Gateway"); - // WiFi status indicator + // WiFi and LoRa status display.setTextAlignment(TEXT_ALIGN_RIGHT); - display.drawString(128, 0, wifiConnected ? "WiFi OK" : "NO WiFi"); + char status[24]; + snprintf(status, sizeof(status), "%s L%d", wifiConnected ? "W" : "-", remoteNodeCount); + display.drawString(128, 0, status); // Horizontal line display.drawLine(0, 12, 128, 12); @@ -329,32 +389,39 @@ void updateDisplay() { if (sensor1Valid) { char buf[20]; snprintf(buf, sizeof(buf), "%.1f°F", temperature1_f); - display.drawString(64, 18, buf); + display.drawString(64, 16, buf); } else { - display.drawString(64, 18, "---"); + display.drawString(64, 16, "---"); } display.setFont(ArialMT_Plain_10); - display.drawString(64, 36, "Primary"); + display.drawString(64, 34, "Tank Primary"); // Secondary sensor (smaller, below) - if (sensorCount >= 2) { - display.setFont(ArialMT_Plain_10); - if (sensor2Valid) { - char buf[20]; - snprintf(buf, sizeof(buf), "Backup: %.1f°F", temperature2_f); - display.drawString(64, 50, buf); - } else { - display.drawString(64, 50, "Backup: ---"); - } + if (sensorCount >= 2 && sensor2Valid) { + char buf[20]; + snprintf(buf, sizeof(buf), "Backup: %.1f°F", temperature2_f); + display.drawString(64, 46, buf); } - // Buffer indicator (if offline) + // Status line at bottom + display.setTextAlignment(TEXT_ALIGN_LEFT); + char buf[32]; + if (bufferCount > 0) { - display.setTextAlignment(TEXT_ALIGN_LEFT); - char buf[20]; snprintf(buf, sizeof(buf), "Buf:%d", bufferCount); - display.drawString(0, 54, buf); + } else if (loraPacketsReceived > 0) { + snprintf(buf, sizeof(buf), "RX:%lu", loraPacketsReceived); + } else { + buf[0] = '\0'; + } + display.drawString(0, 54, buf); + + // RSSI of last LoRa packet + if (loraPacketsReceived > 0) { + display.setTextAlignment(TEXT_ALIGN_RIGHT); + snprintf(buf, sizeof(buf), "%ddBm", lastLoraRssi); + display.drawString(128, 54, buf); } display.display(); @@ -438,6 +505,402 @@ void sendBufferedReadings() { bufferHead = 0; } +// ============================================================================ +// LORA SETUP AND RECEIVE +// ============================================================================ + +void setupLoRa() { + Serial.println("Initializing LoRa radio (SX1262)..."); + + // Initialize SPI for LoRa + SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_SS); + + // Initialize radio + // RadioLib SX1262.begin(freq, bw_kHz, sf, cr, syncWord, power, preambleLen, tcxoVoltage) + int state = radio.begin( + LORA_FREQUENCY, + LORA_BANDWIDTH / 1000.0, // Convert Hz to kHz + LORA_SPREADING_FACTOR, + LORA_CODING_RATE, + LORA_SYNC_WORD, + 14, // TX power (dBm) + 8, // Preamble length + 0 // TCXO voltage (0 = use default) + ); + + if (state == RADIOLIB_ERR_NONE) { + Serial.println("LoRa init success!"); + } else { + Serial.printf("LoRa init failed, code %d\n", state); + displayError("LoRa init failed!"); + return; + } + + // Set up receive interrupt + radio.setDio1Action(onLoRaReceive); + + // Start receiving + state = radio.startReceive(); + if (state != RADIOLIB_ERR_NONE) { + Serial.printf("LoRa receive start failed: %d\n", state); + } else { + Serial.println("LoRa listening on 915 MHz..."); + } +} + +void checkLoRa() { + // Check if packet received via interrupt + if (!loraReceived) return; + + loraReceived = false; + + // Read packet + uint8_t rxBuffer[LORA_MAX_PACKET_SIZE]; + size_t rxLen = 0; + + int state = radio.readData(rxBuffer, rxLen); + + if (state == RADIOLIB_ERR_NONE) { + lastLoraRssi = radio.getRSSI(); + loraPacketsReceived++; + + Serial.printf("LoRa RX: %d bytes, RSSI %d dBm\n", rxLen, lastLoraRssi); + + // Process the packet + processLoRaPacket(rxBuffer, rxLen); + } else if (state == RADIOLIB_ERR_CRC_MISMATCH) { + loraPacketsCrcFail++; + Serial.println("LoRa CRC error"); + } else { + Serial.printf("LoRa RX error: %d\n", state); + } + + // Restart receive mode + radio.startReceive(); +} + +void processLoRaPacket(uint8_t* data, uint8_t len) { + lora_packet_t pkt; + + if (!lora_packet_parse(&pkt, data, len)) { + Serial.println("Invalid packet format"); + return; + } + + Serial.printf("Packet from 0x%04X, type %d, seq %d\n", + pkt.node_id, pkt.type, pkt.sequence); + + switch (pkt.type) { + case LORA_PKT_SENSOR_DATA: + handleSensorData(&pkt); + break; + + case LORA_PKT_REGISTER: + handleRegistration(&pkt); + break; + + case LORA_PKT_ALERT: + // TODO: Handle alerts with higher priority + handleSensorData(&pkt); // For now, treat like sensor data + break; + + case LORA_PKT_PONG: + Serial.printf("PONG from 0x%04X\n", pkt.node_id); + break; + + default: + Serial.printf("Unknown packet type: %d\n", pkt.type); + break; + } +} + +void handleSensorData(const lora_packet_t* pkt) { + const uint8_t* payload = lora_packet_payload(pkt); + uint8_t payload_len = lora_packet_payload_len(pkt); + + if (payload_len < 1) { + Serial.println("Empty payload"); + return; + } + + uint8_t sensor_type = payload[0]; + + // Update node registry + RemoteNode* node = findNode(pkt->node_id); + if (node) { + node->last_seen = millis(); + node->last_seq = pkt->sequence; + } + + // Parse based on sensor type and forward to API + switch (sensor_type) { + case SENSOR_TYPE_DHT22: { + lora_dht22_data_t data; + if (lora_parse_dht22(payload, payload_len, &data)) { + float temp_f = celsiusToFahrenheit(data.temp_c); + Serial.printf("DHT22 from 0x%04X: %.1f°F, %.1f%% RH\n", + pkt->node_id, temp_f, data.humidity_pct); + + if (node) node->battery_mv = data.battery_mv; + + postLoRaReadingToApi(pkt, "dht22", temp_f, data.humidity_pct, + 0, 0, 0, data.battery_mv, data.rssi_dbm); + } + break; + } + + case SENSOR_TYPE_BME680: { + lora_bme680_data_t data; + if (lora_parse_bme680(payload, payload_len, &data)) { + float temp_f = celsiusToFahrenheit(data.temp_c); + Serial.printf("BME680 from 0x%04X: %.1f°F, %.1f%%, %.0f hPa, IAQ %d\n", + pkt->node_id, temp_f, data.humidity_pct, + data.pressure_hpa, data.iaq_index); + + if (node) node->battery_mv = data.battery_mv; + + postLoRaReadingToApi(pkt, "bme680", temp_f, data.humidity_pct, + data.pressure_hpa, data.gas_kohm, + data.iaq_index, data.battery_mv, data.rssi_dbm); + } + break; + } + + case SENSOR_TYPE_DS18B20: { + lora_ds18b20_data_t data; + if (lora_parse_ds18b20(payload, payload_len, &data)) { + float temp_f = celsiusToFahrenheit(data.temp_c); + Serial.printf("DS18B20 from 0x%04X: %.2f°F\n", + pkt->node_id, temp_f); + + if (node) node->battery_mv = data.battery_mv; + + postLoRaReadingToApi(pkt, "ds18b20", temp_f, 0, 0, 0, 0, + data.battery_mv, data.rssi_dbm); + } + break; + } + + default: + Serial.printf("Unknown sensor type: 0x%02X\n", sensor_type); + break; + } + + // Send ACK if requested + if (pkt->ack_requested) { + sendAck(pkt->node_id, pkt->sequence); + } +} + +void handleRegistration(const lora_packet_t* pkt) { + const uint8_t* payload = lora_packet_payload(pkt); + uint8_t payload_len = lora_packet_payload_len(pkt); + + lora_register_data_t reg; + if (!lora_parse_register(payload, payload_len, ®)) { + 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(); + JsonObject r = readings.add(); + + // 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 // ============================================================================