До дальнего края участка Wi-Fi уже не добивает, а температуру там знать хочется. Попробуем отправить её по радио: один ESP32 останется рядом с датчиком DHT22, второй примет пакет возле дома и передаст показания в InfluxDB. Первый интересный момент — увидеть на приёмнике число, которое только что измерил удалённый датчик.
Это лабораторный проект прямой связи двух устройств. Сначала соберём их рядом, затем проверим радиоканал на участке. Здесь используется собственный обмен по LoRa; для LoRaWAN потребуется другая организация сети. Рабочую дальность и расход аккумулятора определим опытами с нашей сборкой.
Две половины радиомоста
Положите на стол два классических ESP32 DevKit, два совместимых SX1278, подходящие антенны и питание. Добавьте DHT22 к стороне передатчика. Приёмник потом займёт место в зоне Wi-Fi. Таблица подключения рассчитана именно на классический ESP32: сверьте маркировку плат перед монтажом.
SX1278 VCC → питание 3,3 В по документации модуля
SX1278 GND → GND ESP32
SCK → GPIO18; MISO → GPIO19; MOSI → GPIO23
NSS → GPIO5; RESET → GPIO14; DIO0 → GPIO27
DHT22 DATA → GPIO4, подтяжка 10 кОм к 3,3 В
DHT22 VCC → 3,3 В; GND → общая земля
У готового DHT-модуля подтяжка может быть установлена. Сверьте порядок выводов конкретного датчика, допустимые уровни и схему радиомодуля. Антенну подключают до разрешения передачи; питание и развязка должны выдерживать нагрузку всей сборки без перезапуска.
Необязательные входы — GPIO34 для аналогового датчика почвы и GPIO35 для делителя напряжения аккумулятора. Они по умолчанию отключены в программе. soil_raw — отсчёт АЦП, а не процент влажности: для интерпретации нужна отдельная калибровка. Допустимое напряжение на входе ESP32 превышать нельзя.
Для батареи верхний резистор делителя идёт от её плюса к GPIO35, нижний — от GPIO35 к общей земле. Коэффициент равен (Rверх + Rниз) / Rниз. Значение 2,0 в коде — пример для равных сопротивлений; перед включением измерения проверьте максимальное входное напряжение и показания мультиметром. analogReadMilliVolts возвращает калиброванное значение на входе, но не исправляет погрешность внешнего делителя.
Договариваемся, на какой волне слушать
В коде заданы примерные настройки: 433 МГц, SF10, полоса 125 кГц, кодирование 4/8, sync word 0x12 и CRC. Они должны совпадать на обеих сторонах. Не переносите настройки на модуль другого диапазона без проверки его документации.
RADIO_CONFIG_VERIFIED оставлен равным false: до проверки частоты, мощности, антенны и действующих правил по месту эксплуатации радио не запускается. Значения 433 МГц и 2 dBm в примере не являются разрешением на передачу. Оценивают также усиление антенны, ограничения времени работы в эфире и требования к самому устройству.
Показания упакованы в двоичный формат FZ1. Весь пакет занимает 40 байт: 15 байт заголовка, 9 байт зашифрованных данных и 16 байт проверки подлинности. Числа идут старшим байтом вперёд. Одинаковый формат на обеих сторонах позволяет приёмнику разобрать сообщение по полям.
- Заголовок: ASCII FZ1, идентификатор устройства uint32 и счётчик uint64.
- Данные: маска наличия полей uint8, температура int16 в сотых долях градуса, влажность воздуха uint16 в сотых долях процента, отсчёт почвенного датчика uint16 и напряжение uint16 в милливольтах.
- Биты маски 1, 2, 4 и 8 означают соответственно температуру, влажность воздуха, почвенный датчик и батарею. Отсутствующее поле записывается нулём и не попадает в базу.
- AES-256-GCM шифрует данные и проверяет их подлинность. Nonce — идентификатор и счётчик из заголовка; весь заголовок также защищён как дополнительные аутентифицируемые данные.
Приёмник проверяет поля: температура должна попасть в диапазон от −40 до 80 °C, влажность воздуха — от 0 до 100%, почвенный отсчёт — от 0 до 4095, напряжение батареи — до 6000 мВ. Эти границы помогают отсеять некорректный пакет. Подключение батареи через делитель и точность измерений проверяются отдельно.
Готовим общий язык и ключ для двух плат
Установите Arduino-ESP32 3.3.12, LoRa 0.8.0 от Sandeep Mistry и DHT sensor library 1.4.7 с Adafruit Unified Sensor 1.1.15. Создайте два отдельных проекта — transmitter и receiver. В каждый добавьте одинаковую вкладку Packet.h:
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include <mbedtls/gcm.h>
namespace Packet {
constexpr size_t SIZE=40;
struct Reading { uint8_t flags; int16_t temp; uint16_t humidity, soil, battery; };
inline void put(uint8_t* p,uint64_t v,int n) { while(n) { p[--n]=v&255; v>>=8; } }
inline uint64_t get(const uint8_t* p,int n) { uint64_t v=0; while(n--) v=(v<<8)|*p++; return v; }
inline int hex(char c) { if(c>='0'&&c<='9') return c-'0'; if(c>='a'&&c<='f') return c-'a'+10; if(c>='A'&&c<='F') return c-'A'+10; return -1; }
inline bool keyFromHex(const char* s,uint8_t* key) {
if(strlen(s)!=64) return false;
for(int i=0;i<32;i++) { int a=hex(s[2*i]),b=hex(s[2*i+1]); if(a<0||b<0) return false; key[i]=(a<<4)|b; }
return true;
}
inline bool valid(const Reading& r) {
return r.flags && !(r.flags&240)
&& ((r.flags&1)?(r.temp>=-4000&&r.temp<=8000):r.temp==0)
&& ((r.flags&2)?r.humidity<=10000:r.humidity==0)
&& ((r.flags&4)?r.soil<=4095:r.soil==0)
&& ((r.flags&8)?(r.battery>0&&r.battery<=6000):r.battery==0);
}
inline bool seal(const uint8_t* key,uint32_t id,uint64_t seq,const Reading& r,uint8_t* out) {
if(!id||!seq||!valid(r)) return false;
memcpy(out,"FZ1",3); put(out+3,id,4); put(out+7,seq,8);
uint8_t plain[9]={r.flags}; put(plain+1,uint16_t(r.temp),2); put(plain+3,r.humidity,2);
put(plain+5,r.soil,2); put(plain+7,r.battery,2);
mbedtls_gcm_context ctx; mbedtls_gcm_init(&ctx);
int rc=mbedtls_gcm_setkey(&ctx,MBEDTLS_CIPHER_ID_AES,key,256);
if(!rc) rc=mbedtls_gcm_crypt_and_tag(&ctx,MBEDTLS_GCM_ENCRYPT,9,out+3,12,out,15,plain,out+15,16,out+24);
mbedtls_gcm_free(&ctx); return rc==0;
}
inline bool open(const uint8_t* key,uint32_t id,const uint8_t* p,size_t n,uint64_t& seq,Reading& r) {
if(n!=SIZE||memcmp(p,"FZ1",3)||!id||get(p+3,4)!=id||!get(p+7,8)) return false;
uint8_t plain[9]; mbedtls_gcm_context ctx; mbedtls_gcm_init(&ctx);
int rc=mbedtls_gcm_setkey(&ctx,MBEDTLS_CIPHER_ID_AES,key,256);
if(!rc) rc=mbedtls_gcm_auth_decrypt(&ctx,9,p+3,12,p,15,p+24,16,p+15,plain);
mbedtls_gcm_free(&ctx); if(rc) return false;
uint16_t t=get(plain+1,2);
Reading candidate={plain[0],int16_t(t<32768?int32_t(t):int32_t(t)-65536),
uint16_t(get(plain+3,2)),uint16_t(get(plain+5,2)),uint16_t(get(plain+7,2))};
if(!valid(candidate)) return false;
seq=get(p+7,8); r=candidate; return true;
}
}
Создайте свой случайный ключ локальной командой Python:
python3 -c 'import secrets; print(secrets.token_hex(32))'
Полученную строку из 64 шестнадцатеричных символов внесите в KEY_HEX обоих проектов; DEVICE_ID тоже должен совпадать. Ключ из опубликованного примера или теста использовать нельзя. Он хранится в прошивке: этот способ не защищает секрет от физического извлечения из устройства.
Для первого запуска передатчика с новым ключом установите INITIALIZE_COUNTER=true. Программа создаст счётчик в NVS и остановится без передачи. Затем верните false и загрузите прошивку, сохранив NVS. Ошибка «Counter already exists» запрещает повторную инициализацию существующего счётчика.
Одна пара «ключ — nonce» не должна повторяться. Перед каждым шифрованием новый номер сохраняется и перечитывается из NVS; пропуски номеров допустимы. После стирания или восстановления старой копии памяти замените ключ на обеих сторонах, заново настройте счётчики и выполните ввод в работу. Не клонируйте прошивку вместе с ключом, ID и состоянием счётчика на другой передатчик.
У приёмника счётчик защищает от повторного приёма старых пакетов. Его сброс также требует смены ключа; иначе ранее записанный пакет снова может пройти проверку. Для расширения на несколько датчиков нужен отдельный ключ и сохранённый счётчик каждого устройства — приведённый приёмник настроен на одно.
Передатчик просыпается, измеряет и снова засыпает
Сохраните следующий скетч как transmitter.ino. Начните с DHT22: так проще проследить путь одного показания. Когда этот опыт заработает, подключите и проверьте почвенный и батарейный входы, затем включите их флагами. Если допустимых данных вообще нет, передатчик пропустит отправку.
#include <SPI.h>
#include <LoRa.h>
#include <DHT.h>
#include <Preferences.h>
#include <esp_sleep.h>
#include "Packet.h"
// Before enabling RF, check your module, antenna and local radio rules.
constexpr bool RADIO_CONFIG_VERIFIED=false;
constexpr long FREQUENCY=433000000; // Example only, not a permit.
constexpr int TX_DBM=2;
constexpr uint32_t DEVICE_ID=1; // Same ID/key in receiver; unique per device.
const char* KEY_HEX="REPLACE_WITH_64_RANDOM_HEX_CHARACTERS";
// true: provision a NEW key/counter without transmission; then flash false.
constexpr bool INITIALIZE_COUNTER=false;
constexpr bool SOIL_CONNECTED=false, BATTERY_CALIBRATED=false;
constexpr float BATTERY_DIVIDER=2.0f; // Replace after measuring the divider.
DHT dht(4,DHT22);
Preferences nv;
uint8_t key[32];
volatile bool txDone=false;
void onSent() { txDone=true; }
void halt(const char* reason) { Serial.println(reason); while(true) delay(1000); }
bool reserveSequence(uint64_t& seq) {
if(!nv.isKey("seq")) return false;
uint64_t previous=nv.getULong64("seq",UINT64_MAX);
if(previous==UINT64_MAX) return false;
uint64_t next=previous+1;
if(nv.putULong64("seq",next)!=8||nv.getULong64("seq",0)!=next) return false;
seq=next; return true;
}
void setup() {
Serial.begin(115200);
if(!Packet::keyFromHex(KEY_HEX,key)||!DEVICE_ID) halt("Set device ID and a new random key");
if(!nv.begin("lora-tx",false)) halt("NVS unavailable");
if(INITIALIZE_COUNTER) {
if(nv.isKey("seq")) halt("Counter already exists; do not reset it with the same key");
if(nv.putULong64("seq",0)!=8||nv.getULong64("seq",UINT64_MAX)!=0) halt("Provisioning failed");
halt("Provisioned. Set INITIALIZE_COUNTER=false and upload again");
}
if(!nv.isKey("seq")) halt("Missing counter: provision with a NEW key");
if(!RADIO_CONFIG_VERIFIED) halt("Radio configuration not verified");
SPI.begin(18,19,23,5);
LoRa.setPins(5,14,27);
if(!LoRa.begin(FREQUENCY)) halt("Radio init failed");
LoRa.setSpreadingFactor(10); LoRa.setSignalBandwidth(125000);
LoRa.setCodingRate4(8); LoRa.setSyncWord(0x12); LoRa.enableCrc();
LoRa.setTxPower(TX_DBM); LoRa.onTxDone(onSent);
dht.begin(); delay(2000);
Packet::Reading r={};
float t=dht.readTemperature(),h=dht.readHumidity();
if(isfinite(t)&&t>=-40&&t<=80) { r.flags|=1; r.temp=lroundf(t*100); }
if(isfinite(h)&&h>=0&&h<=100) { r.flags|=2; r.humidity=lroundf(h*100); }
analogReadResolution(12);
if(SOIL_CONNECTED) { r.flags|=4; r.soil=analogRead(34); }
if(BATTERY_CALIBRATED) {
analogSetPinAttenuation(35,ADC_11db);
float mv=analogReadMilliVolts(35)*BATTERY_DIVIDER;
if(isfinite(mv)&&mv>=1&&mv<=6000) { r.flags|=8; r.battery=lroundf(mv); }
}
if(Packet::valid(r)) {
uint64_t seq=0;
if(!reserveSequence(seq)) halt("Counter write failed/exhausted: no transmission");
uint8_t packet[Packet::SIZE];
if(!Packet::seal(key,DEVICE_ID,seq,r,packet)) halt("Encryption failed");
delay(esp_random()%5000); // Spreads attempts; does not prevent collisions.
txDone=false;
if(!LoRa.beginPacket()||LoRa.write(packet,sizeof(packet))!=sizeof(packet)||!LoRa.endPacket(true)) halt("TX start failed");
uint32_t started=millis();
while(!txDone&&millis()-started<5000) delay(1);
Serial.println(txDone?"Radio TX complete; delivery NOT confirmed":"TX timeout");
} else Serial.println("No valid readings; no packet sent");
LoRa.sleep(); nv.end();
esp_sleep_enable_timer_wakeup(300000000ULL);
esp_deep_sleep_start();
}
void loop() {}
После измерения плата делает случайную паузу до пяти секунд, передаёт пакет и уходит спать на пять минут. Ожидание завершения передачи ограничено пятью секундами; радио тоже переводится в сон. Засеките время между пакетами: к пяти минутам сна прибавляются запуск, измерение и работа передатчика.
Увидели Radio TX complete — теперь посмотрите на второй ESP32. Эта строка сообщает об окончании передачи, а приём проверяется на другой стороне. В этом проекте обмен идёт в одну сторону, без ACK и повторной отправки измерения. Поэтому полезно сразу сравнивать число отправленных и принятых пакетов.
Ловим показание и отправляем его в историю
Сохраните код как receiver.ino. Укажите сеть, HTTPS-адрес записи InfluxDB v2, организацию, bucket и токен с правом записи только в нужный bucket. Подставляйте URL-кодированные значения параметров адреса. Серверу нужен сертификат, которому доверяет встроенный набор центров сертификации; проверку TLS отключать не следует.
#include <SPI.h>
#include <LoRa.h>
#include <WiFi.h>
#include <NetworkClientSecure.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <time.h>
#include "Packet.h"
constexpr bool RADIO_CONFIG_VERIFIED=false;
constexpr long FREQUENCY=433000000;
constexpr uint32_t DEVICE_ID=1;
const char* KEY_HEX="REPLACE_WITH_64_RANDOM_HEX_CHARACTERS";
const char* SSID="YOUR_WIFI";
const char* PASSWORD="YOUR_PASSWORD";
const char* WRITE_URL="https://YOUR_INFLUX_HOST/api/v2/write?org=farm&bucket=sensors&precision=s";
const char* TOKEN="YOUR_WRITE_ONLY_TOKEN";
struct Item { Packet::Reading r; uint64_t seq; int rssi; float snr; time_t timestamp; };
QueueHandle_t queue;
Preferences nv;
uint8_t key[32];
uint64_t lastSeq=0;
void halt(const char* reason) { Serial.println(reason); while(true) delay(1000); }
String lineFor(const Item& i) {
char sequence[24]; snprintf(sequence,sizeof(sequence),"%llu",(unsigned long long)i.seq);
String s="lora,sensor="+String(DEVICE_ID)+" sequence="+sequence+"u";
if(i.r.flags&1) s+=",temperature="+String(i.r.temp/100.0,2);
if(i.r.flags&2) s+=",humidity="+String(i.r.humidity/100.0,2);
if(i.r.flags&4) s+=",soil_raw="+String(i.r.soil)+"i";
if(i.r.flags&8) s+=",battery="+String(i.r.battery/1000.0,3);
s+=",rssi="+String(i.rssi)+"i,snr="+String(i.snr,2);
return s+" "+String((unsigned long)i.timestamp);
}
void upload(void*) {
WiFi.mode(WIFI_STA); WiFi.setAutoReconnect(true); WiFi.begin(SSID,PASSWORD);
configTime(0,0,"pool.ntp.org","time.nist.gov");
Item item;
while(true) {
if(xQueueReceive(queue,&item,portMAX_DELAY)!=pdTRUE) continue;
bool done=false;
while(!done) {
time_t now=time(nullptr);
if(WiFi.status()==WL_CONNECTED&&now>1700000000) {
if(!item.timestamp) item.timestamp=now;
NetworkClientSecure tls; tls.useBuiltinCACertBundle();
tls.setHandshakeTimeout(5);
HTTPClient http; http.setConnectTimeout(5000); http.setTimeout(5000);
if(http.begin(tls,WRITE_URL)) {
http.addHeader("Authorization",String("Token ")+TOKEN);
http.addHeader("Content-Type","text/plain; charset=utf-8");
int code=http.POST(lineFor(item));
done=(code==204); http.end();
Serial.printf("Influx status %d; %s\n",code,done?"accepted":"pending");
}
}
if(!done) vTaskDelay(pdMS_TO_TICKS(30000));
}
}
}
void setup() {
Serial.begin(115200);
if(!Packet::keyFromHex(KEY_HEX,key)||!DEVICE_ID) halt("Set ID/key");
if(!String(WRITE_URL).startsWith("https://")) halt("HTTPS required");
if(!nv.begin("lora-rx",false)) halt("NVS unavailable");
lastSeq=nv.isKey("seq")?nv.getULong64("seq",UINT64_MAX):0;
if(lastSeq==UINT64_MAX) halt("Replay counter unreadable/exhausted");
queue=xQueueCreate(8,sizeof(Item)); if(!queue) halt("Queue unavailable");
if(!RADIO_CONFIG_VERIFIED) halt("Radio configuration not verified");
SPI.begin(18,19,23,5); LoRa.setPins(5,14,27);
if(!LoRa.begin(FREQUENCY)) halt("Radio init failed");
LoRa.setSpreadingFactor(10); LoRa.setSignalBandwidth(125000);
LoRa.setCodingRate4(8); LoRa.setSyncWord(0x12); LoRa.enableCrc();
if(xTaskCreate(upload,"influx",12288,nullptr,1,nullptr)!=pdPASS) halt("Worker unavailable");
}
void loop() {
int n=LoRa.parsePacket();
if(n) {
uint8_t packet[Packet::SIZE]; size_t used=0;
while(LoRa.available()) { int b=LoRa.read(); if(used<sizeof(packet)) packet[used++]=b; }
Item item={};
if(n==Packet::SIZE&&used==Packet::SIZE&&Packet::open(key,DEVICE_ID,packet,used,item.seq,item.r)&&item.seq>lastSeq) {
item.rssi=LoRa.packetRssi(); item.snr=LoRa.packetSnr();
time_t now=time(nullptr); item.timestamp=now>1700000000?now:0;
if(uxQueueSpacesAvailable(queue)==0) Serial.println("Queue full; packet dropped");
else {
// Only this loop produces items: the consumer can only free space.
if(nv.putULong64("seq",item.seq)!=8||nv.getULong64("seq",0)!=item.seq) halt("Replay counter write failed");
lastSeq=item.seq;
if(xQueueSend(queue,&item,0)!=pdTRUE) halt("Unexpected queue failure");
}
} else Serial.println("Packet rejected");
}
delay(1);
}
Радиопакет проверяется по длине, версии, ID, подлинности, диапазонам и возрастающему номеру. Приёмник сам формирует имена полей InfluxDB; текст из эфира не вставляется в запрос. CRC помогает обнаруживать ошибки передачи, но не заменяет криптографическую проверку.
Для сети выделена отдельная задача: радио продолжает принимать данные, пока она занимается InfluxDB. В очереди есть место для восьми записей, ещё одну может удерживать рабочая задача. Ответ HTTP 204 завершает отправку; при ошибке попытка повторяется через 30 секунд. Если повторы продолжаются, проверьте адрес сервера и токен.
При повторной отправке сохраняются строка и её временная метка. На графике это время приёма по часам базы. Если часы ещё ждали синхронизации, берётся время первой попытки после неё. Учитывайте это при сравнении графика с событиями на участке: собственного времени измерения в пакете пока нет.
Попробуйте ненадолго отключить сеть и наблюдать, как заполняется очередь. При переполнении появится Queue full. Очередь хранится в оперативной памяти, а счётчик сохраняется раньше помещения записи в неё, поэтому перезапуск или сбой между этими действиями может потерять показание. Если хотите накапливать историю во время долгих отключений, следующим проектом станет шлюз с постоянным хранилищем и согласованными подтверждениями.
Выходим с передатчиком на участок
Начните рядом с приёмником, затем перенесите датчик в намеченное место. Сравните долю дошедших пакетов, RSSI и SNR. Повторите опыт с другой высотой и положением антенны: рельеф и препятствия делают эту часть особенно интересной. SF, полоса и кодирование меняют время передачи и устойчивость приёма, а подходящие настройки выбирают по результату на месте.
Когда появится второй датчик, проверьте сеть заново. Перекрывшиеся передачи могут мешать друг другу; случайная задержка уменьшает вероятность совпадения. Оцените время пакета в эфире и понаблюдайте за потерями, прежде чем добавлять узлы или сокращать интервал.
Для питания от аккумулятора измерьте ток всей сборки во сне, при запуске и во время передачи. Учтите длительность этих режимов, стабилизатор и делитель, затем сопоставьте расход с доступной ёмкостью при рабочей температуре. Для солнечного варианта подберите контроллер заряда и защиту, а баланс энергии проверьте для неблагоприятного сезона.
- Проверьте проводку, питание, антенну, ID, ключ и параметры радио. До включения эфира завершите проверку условий эксплуатации.
- Сравните DHT22 с контрольным прибором, отключите датчик и убедитесь, что ошибки не превращаются в правдоподобные числа.
- Проверьте неверный ключ, повреждённый и повторный пакет: они не должны создавать новую запись. После перезапуска счётчики должны сохраняться.
- Отключите Wi-Fi и InfluxDB, дождитесь заполнения очереди, затем восстановите связь. Проверьте журнал потерь и фактические записи в базе.
- На месте установки измерьте долю доставки, ток во сне, просадки питания и работу после полного отключения питания.
Хорошая первая цель — составить небольшую карту связи: место датчика, положение антенны, отправленные и полученные пакеты. Она поможет выбрать точку установки гораздо увереннее, чем наблюдение за одним удачным сообщением.
Документация: LoRa API, Mbed TLS GCM, Preferences и NVS, АЦП ESP32, запись в InfluxDB v2.
Начать знакомство с датчиком поможет Метеостанция на ESP32 с проверкой показаний.
Следующий шаг для удалённой точки — Автономное питание ESP32 от аккумулятора и солнечной панели.






