522 lines
19 KiB
Arduino
522 lines
19 KiB
Arduino
/**
|
||
* FarmBox Sensor Node v2.0
|
||
* Lilygo TTGO LoRa32 V2.1_1.6 — 868 МГц
|
||
*
|
||
* Железо на плате:
|
||
* - ESP32 (WiFi + Bluetooth)
|
||
* - SX1276 LoRa 868 МГц (SMA антенна)
|
||
* - OLED SSD1306 0.96" (128×64)
|
||
* - SD-карта (SPI)
|
||
*
|
||
* Внешние датчики (докупить):
|
||
* - SCT-013-030: токовый трансформатор (30А) — моточасы + ток
|
||
* - DS18B20: температура подшипника
|
||
*
|
||
* Установка библиотек в Arduino IDE (Tools → Manage Libraries):
|
||
* - "LoRa" by Sandeep Mistry
|
||
* - "Adafruit SSD1306" by Adafruit
|
||
* - "Adafruit GFX Library" by Adafruit
|
||
* - "OneWire" by Jim Studt
|
||
* - "DallasTemperature" by Miles Burton
|
||
* - "ArduinoJson" by Benoit Blanchon
|
||
*
|
||
* Board в Arduino IDE:
|
||
* Tools → Board → ESP32 Arduino → "TTGO LoRa32-OLED"
|
||
* (или ESP32 Dev Module если нет в списке)
|
||
*
|
||
* ─────────────────────────────────────────────
|
||
* НАСТРОЙ ЭТИ ПАРАМЕТРЫ ПОД КАЖДУЮ УСТАНОВКУ:
|
||
* ─────────────────────────────────────────────
|
||
*/
|
||
|
||
// === КОНФИГУРАЦИЯ УСТРОЙСТВА (МЕНЯТЬ ДЛЯ КАЖДОЙ УВВ) ===
|
||
const char* DEVICE_ID = "uvv_01"; // уникальный ID (uvv_01..uvv_13)
|
||
const char* DEVICE_NAME = "УВВ-75-2 №1"; // отображается на дисплее
|
||
const int EQUIPMENT_ID = 101; // ID оборудования в farm-cmms
|
||
const float CURRENT_RATED = 7.5; // номинальный ток двигателя (А)
|
||
// =======================================================
|
||
|
||
// ─── Пины Lilygo LoRa32 V2.1_1.6 ─────────────────────
|
||
// LoRa SX1276
|
||
#define LORA_SCK 5
|
||
#define LORA_MISO 19
|
||
#define LORA_MOSI 27
|
||
#define LORA_SS 18
|
||
#define LORA_RST 23
|
||
#define LORA_DIO0 26
|
||
|
||
// OLED SSD1306
|
||
#define OLED_SDA 21
|
||
#define OLED_SCL 22
|
||
#define OLED_RST 16 // на V2.1_1.6 есть аппаратный сброс OLED
|
||
|
||
// SD карта
|
||
#define SD_MOSI 15
|
||
#define SD_MISO 2
|
||
#define SD_SCK 14
|
||
#define SD_CS 13
|
||
|
||
// Встроенный светодиод
|
||
#define LED_PIN 25
|
||
|
||
// ─── Внешние датчики ──────────────────────────────────
|
||
#define SCT_PIN 34 // токовый трансформатор (только ADC1!)
|
||
#define DS18B20_PIN 17 // температура (можно любой GPIO)
|
||
|
||
// ─── LoRa 868 МГц ─────────────────────────────────────
|
||
#define LORA_BAND 868E6
|
||
#define LORA_SF 9 // Spreading Factor: 7=быстро/близко, 12=медленно/далеко
|
||
#define LORA_BW 125E3 // Bandwidth: 125 кГц стандарт
|
||
#define LORA_CR 5 // Coding Rate 4/5
|
||
|
||
// ─── Интервалы ────────────────────────────────────────
|
||
#define SEND_INTERVAL_MS 60000 // отправка по LoRa раз в 1 мин
|
||
#define DISPLAY_UPDATE_MS 1000 // обновление дисплея раз в 1 сек
|
||
#define TEMP_READ_MS 5000 // температура раз в 5 сек
|
||
#define SD_SAVE_MS 30000 // запись на SD раз в 30 сек
|
||
|
||
// ─── Библиотеки ───────────────────────────────────────
|
||
#include <Arduino.h>
|
||
#include <SPI.h>
|
||
#include <Wire.h>
|
||
#include <LoRa.h>
|
||
#include <Adafruit_GFX.h>
|
||
#include <Adafruit_SSD1306.h>
|
||
#include <OneWire.h>
|
||
#include <DallasTemperature.h>
|
||
#include <ArduinoJson.h>
|
||
#include <SD.h>
|
||
#include <FS.h>
|
||
|
||
// ─── Объекты ──────────────────────────────────────────
|
||
Adafruit_SSD1306 display(128, 64, &Wire, OLED_RST);
|
||
OneWire oneWire(DS18B20_PIN);
|
||
DallasTemperature tempSensor(&oneWire);
|
||
|
||
// ─── Состояние системы ────────────────────────────────
|
||
struct State {
|
||
float current = 0; // ток двигателя (А)
|
||
float temperature = 0; // температура подшипника (°C)
|
||
uint32_t motoHoursMs = 0; // накопленные миллисекунды работы
|
||
bool motorOn = false; // двигатель работает?
|
||
int packetsSent = 0; // отправлено пакетов
|
||
int lastRSSI = 0; // RSSI последнего пакета
|
||
bool loraOk = false; // LoRa инициализирован?
|
||
bool sdOk = false; // SD карта доступна?
|
||
bool tempOk = false; // DS18B20 найден?
|
||
uint8_t displayPage = 0; // текущая страница дисплея
|
||
} state;
|
||
|
||
// ─── Таймеры ──────────────────────────────────────────
|
||
uint32_t lastSendMs = 0;
|
||
uint32_t lastDisplayMs = 0;
|
||
uint32_t lastTempMs = 0;
|
||
uint32_t lastSdMs = 0;
|
||
uint32_t motorStartMs = 0; // когда двигатель запустился
|
||
|
||
// ─── Буфер для SD (накапливаем пока нет LoRa связи) ──
|
||
#define SD_BUFFER_MAX 120 // максимум строк в буфере
|
||
int sdBufferCount = 0;
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// SETUP
|
||
// ══════════════════════════════════════════════════════
|
||
void setup() {
|
||
Serial.begin(115200);
|
||
delay(500);
|
||
Serial.println("\n=== FarmBox Sensor Node v2.0 ===");
|
||
Serial.printf("Device: %s | %s\n", DEVICE_ID, DEVICE_NAME);
|
||
|
||
pinMode(LED_PIN, OUTPUT);
|
||
blink(2, 100);
|
||
|
||
initOLED();
|
||
showBootScreen("Инициализация...");
|
||
delay(500);
|
||
|
||
initLoRa();
|
||
initSD();
|
||
initTempSensor();
|
||
|
||
// Загружаем сохранённые моточасы с SD карты
|
||
loadMotoHours();
|
||
|
||
showBootScreen("Готов!");
|
||
delay(800);
|
||
|
||
Serial.println("=== Запуск основного цикла ===");
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// LOOP
|
||
// ══════════════════════════════════════════════════════
|
||
void loop() {
|
||
uint32_t now = millis();
|
||
|
||
// ── Читаем ток и считаем моточасы ─────────────────
|
||
state.current = readCurrent();
|
||
bool running = state.current > 0.8; // > 0.8А = двигатель работает
|
||
|
||
if (running) {
|
||
if (!state.motorOn) {
|
||
motorStartMs = now;
|
||
state.motorOn = true;
|
||
Serial.println("▶ Двигатель запустился");
|
||
}
|
||
state.motoHoursMs += now - motorStartMs;
|
||
motorStartMs = now;
|
||
} else {
|
||
if (state.motorOn) {
|
||
state.motorOn = false;
|
||
Serial.printf("⏹ Двигатель остановился. Итого: %lu ч\n", getMotoHours());
|
||
saveMotoHours(); // сохраняем на SD при каждой остановке
|
||
}
|
||
}
|
||
|
||
// ── Температура ────────────────────────────────────
|
||
if (now - lastTempMs >= TEMP_READ_MS) {
|
||
tempSensor.requestTemperatures();
|
||
float t = tempSensor.getTempCByIndex(0);
|
||
if (t != DEVICE_DISCONNECTED_C && t > -50) {
|
||
state.temperature = t;
|
||
state.tempOk = true;
|
||
}
|
||
lastTempMs = now;
|
||
}
|
||
|
||
// ── Отправка по LoRa ───────────────────────────────
|
||
if (now - lastSendMs >= SEND_INTERVAL_MS) {
|
||
sendLoRaPacket();
|
||
lastSendMs = now;
|
||
}
|
||
|
||
// ── Запись на SD ───────────────────────────────────
|
||
if (now - lastSdMs >= SD_SAVE_MS) {
|
||
saveToSD();
|
||
lastSdMs = now;
|
||
}
|
||
|
||
// ── Обновление дисплея ────────────────────────────
|
||
if (now - lastDisplayMs >= DISPLAY_UPDATE_MS) {
|
||
updateDisplay();
|
||
lastDisplayMs = now;
|
||
}
|
||
|
||
delay(50);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// LORA
|
||
// ══════════════════════════════════════════════════════
|
||
void sendLoRaPacket() {
|
||
if (!state.loraOk) {
|
||
Serial.println("[LoRa] Не инициализирован, пропускаем отправку");
|
||
return;
|
||
}
|
||
|
||
StaticJsonDocument<256> doc;
|
||
doc["id"] = DEVICE_ID;
|
||
doc["eq"] = EQUIPMENT_ID;
|
||
doc["mh"] = getMotoHours();
|
||
doc["temp"] = roundf(state.temperature * 10) / 10.0;
|
||
doc["curr"] = roundf(state.current * 10) / 10.0;
|
||
doc["run"] = state.motorOn ? 1 : 0;
|
||
doc["pkt"] = state.packetsSent;
|
||
|
||
String json;
|
||
serializeJson(doc, json);
|
||
|
||
LoRa.beginPacket();
|
||
LoRa.print(json);
|
||
int result = LoRa.endPacket();
|
||
|
||
if (result) {
|
||
state.packetsSent++;
|
||
state.lastRSSI = LoRa.packetRssi();
|
||
Serial.printf("[LoRa TX] ✅ #%d %s\n", state.packetsSent, json.c_str());
|
||
blink(1, 50);
|
||
} else {
|
||
Serial.println("[LoRa TX] ❌ Ошибка отправки");
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// OLED ДИСПЛЕЙ
|
||
// ══════════════════════════════════════════════════════
|
||
void updateDisplay() {
|
||
display.clearDisplay();
|
||
display.setTextColor(SSD1306_WHITE);
|
||
|
||
// ── Заголовок ─────────────────────────────────────
|
||
display.setTextSize(1);
|
||
display.setCursor(0, 0);
|
||
display.println(DEVICE_NAME);
|
||
|
||
// Разделитель
|
||
display.drawFastHLine(0, 10, 128, SSD1306_WHITE);
|
||
|
||
// ── Основные данные ───────────────────────────────
|
||
display.setTextSize(1);
|
||
|
||
// Ток
|
||
display.setCursor(0, 14);
|
||
display.print("Ток: ");
|
||
display.setTextSize(2);
|
||
display.setCursor(30, 12);
|
||
char buf[16];
|
||
sprintf(buf, "%.1fA", state.current);
|
||
display.print(buf);
|
||
display.setTextSize(1);
|
||
if (state.motorOn) {
|
||
display.setCursor(105, 14);
|
||
display.print("ON");
|
||
}
|
||
|
||
// Температура
|
||
display.setCursor(0, 32);
|
||
display.print("Темп: ");
|
||
if (state.tempOk) {
|
||
sprintf(buf, "%.1f C", state.temperature);
|
||
display.print(buf);
|
||
// Предупреждение о перегреве
|
||
if (state.temperature > 80) {
|
||
display.setCursor(100, 32);
|
||
display.print("!");
|
||
}
|
||
} else {
|
||
display.print("-- C");
|
||
}
|
||
|
||
// Моточасы
|
||
display.setCursor(0, 44);
|
||
display.print("Мч: ");
|
||
sprintf(buf, "%lu ч", getMotoHours());
|
||
display.print(buf);
|
||
|
||
// ── Строка статуса внизу ──────────────────────────
|
||
display.drawFastHLine(0, 54, 128, SSD1306_WHITE);
|
||
display.setCursor(0, 56);
|
||
|
||
// LoRa статус
|
||
if (state.loraOk) {
|
||
sprintf(buf, "LoRa #%d", state.packetsSent);
|
||
display.print(buf);
|
||
} else {
|
||
display.print("LoRa ERR");
|
||
}
|
||
|
||
// SD статус
|
||
display.setCursor(80, 56);
|
||
display.print(state.sdOk ? "SD OK" : "NO SD");
|
||
|
||
display.display();
|
||
}
|
||
|
||
void showBootScreen(const char* msg) {
|
||
display.clearDisplay();
|
||
display.setTextSize(1);
|
||
display.setTextColor(SSD1306_WHITE);
|
||
|
||
display.setCursor(0, 0);
|
||
display.println("FarmBox v2.0");
|
||
display.drawFastHLine(0, 10, 128, SSD1306_WHITE);
|
||
|
||
display.setCursor(0, 16);
|
||
display.println(DEVICE_ID);
|
||
display.println(DEVICE_NAME);
|
||
|
||
display.setCursor(0, 48);
|
||
display.println(msg);
|
||
display.display();
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// ДАТЧИК ТОКА SCT-013
|
||
// ══════════════════════════════════════════════════════
|
||
float readCurrent() {
|
||
// SCT-013-030: 30А / 1В, нагрузочный резистор 68 Ом
|
||
// ESP32 ADC: 12 бит, 3.3В, смещение на 1.65В через делитель
|
||
const int SAMPLES = 1000;
|
||
const float ADC_VREF = 3.3;
|
||
const float SCT_RATIO = 30.0; // 30А/В для SCT-013-030
|
||
const float BURDEN_OHMS = 68.0; // нагрузочный резистор
|
||
|
||
float sum = 0;
|
||
for (int i = 0; i < SAMPLES; i++) {
|
||
int raw = analogRead(SCT_PIN);
|
||
float v = (raw / 4095.0) * ADC_VREF;
|
||
float centered = v - (ADC_VREF / 2.0);
|
||
sum += centered * centered;
|
||
delayMicroseconds(150);
|
||
}
|
||
float rmsV = sqrt(sum / SAMPLES);
|
||
float current = (rmsV / BURDEN_OHMS) * SCT_RATIO * 1000.0;
|
||
|
||
// Убираем шум
|
||
if (current < 0.5) current = 0;
|
||
|
||
return current;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// МОТОЧАСЫ
|
||
// ══════════════════════════════════════════════════════
|
||
uint32_t getMotoHours() {
|
||
return state.motoHoursMs / 3600000UL;
|
||
}
|
||
|
||
uint32_t getMotoHoursRaw() {
|
||
return state.motoHoursMs;
|
||
}
|
||
|
||
void saveMotoHours() {
|
||
if (!state.sdOk) return;
|
||
File f = SD.open("/motohours.txt", FILE_WRITE);
|
||
if (f) {
|
||
f.println(state.motoHoursMs);
|
||
f.close();
|
||
Serial.printf("[SD] Моточасы сохранены: %lu мс (%lu ч)\n",
|
||
state.motoHoursMs, getMotoHours());
|
||
}
|
||
}
|
||
|
||
void loadMotoHours() {
|
||
if (!state.sdOk) return;
|
||
if (!SD.exists("/motohours.txt")) return;
|
||
File f = SD.open("/motohours.txt");
|
||
if (f) {
|
||
String line = f.readStringUntil('\n');
|
||
state.motoHoursMs = line.toInt();
|
||
f.close();
|
||
Serial.printf("[SD] Загружены моточасы: %lu ч\n", getMotoHours());
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// SD КАРТА — буфер данных
|
||
// ══════════════════════════════════════════════════════
|
||
void saveToSD() {
|
||
if (!state.sdOk) return;
|
||
|
||
// Имя файла по device_id
|
||
char filename[32];
|
||
sprintf(filename, "/%s.csv", DEVICE_ID);
|
||
|
||
bool exists = SD.exists(filename);
|
||
File f = SD.open(filename, FILE_APPEND);
|
||
if (!f) return;
|
||
|
||
// Заголовок если файл новый
|
||
if (!exists) {
|
||
f.println("timestamp_ms,motohours,temperature,current,motor_on");
|
||
}
|
||
|
||
// Строка данных
|
||
char line[128];
|
||
sprintf(line, "%lu,%lu,%.1f,%.1f,%d",
|
||
millis(),
|
||
getMotoHours(),
|
||
state.temperature,
|
||
state.current,
|
||
state.motorOn ? 1 : 0
|
||
);
|
||
f.println(line);
|
||
f.close();
|
||
|
||
sdBufferCount++;
|
||
|
||
// Каждые 100 записей — сохраняем моточасы
|
||
if (sdBufferCount % 100 == 0) saveMotoHours();
|
||
|
||
Serial.printf("[SD] Записано: %s | строк: %d\n", line, sdBufferCount);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// ИНИЦИАЛИЗАЦИЯ
|
||
// ══════════════════════════════════════════════════════
|
||
void initOLED() {
|
||
// Сброс OLED
|
||
pinMode(OLED_RST, OUTPUT);
|
||
digitalWrite(OLED_RST, LOW);
|
||
delay(20);
|
||
digitalWrite(OLED_RST, HIGH);
|
||
|
||
Wire.begin(OLED_SDA, OLED_SCL);
|
||
|
||
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
|
||
Serial.println("[OLED] ❌ Не найден! Проверь подключение SDA/SCL");
|
||
// Продолжаем без дисплея
|
||
return;
|
||
}
|
||
display.clearDisplay();
|
||
display.display();
|
||
Serial.println("[OLED] ✅ OK");
|
||
}
|
||
|
||
void initLoRa() {
|
||
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_SS);
|
||
LoRa.setPins(LORA_SS, LORA_RST, LORA_DIO0);
|
||
|
||
showBootScreen("LoRa 868 МГц...");
|
||
|
||
if (!LoRa.begin(LORA_BAND)) {
|
||
Serial.println("[LoRa] ❌ Ошибка инициализации!");
|
||
showBootScreen("LoRa ERROR!");
|
||
state.loraOk = false;
|
||
// Не вешаемся — работаем на SD
|
||
return;
|
||
}
|
||
|
||
LoRa.setSpreadingFactor(LORA_SF);
|
||
LoRa.setSignalBandwidth(LORA_BW);
|
||
LoRa.setCodingRate4(LORA_CR);
|
||
LoRa.enableCrc();
|
||
// Мощность передатчика: 2-20 dBm
|
||
// Внутри помещения хватит 14, на улицу можно 20
|
||
LoRa.setTxPower(14);
|
||
|
||
state.loraOk = true;
|
||
Serial.printf("[LoRa] ✅ OK — 868 МГц, SF%d, BW%.0fkHz\n",
|
||
LORA_SF, LORA_BW / 1000);
|
||
}
|
||
|
||
void initSD() {
|
||
SPIClass sdSPI(HSPI);
|
||
sdSPI.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
|
||
|
||
showBootScreen("SD карта...");
|
||
|
||
if (!SD.begin(SD_CS, sdSPI)) {
|
||
Serial.println("[SD] ⚠️ Карта не найдена — работаем без SD");
|
||
state.sdOk = false;
|
||
return;
|
||
}
|
||
|
||
uint64_t cardSize = SD.cardSize() / (1024 * 1024);
|
||
Serial.printf("[SD] ✅ OK — %llu МБ\n", cardSize);
|
||
state.sdOk = true;
|
||
}
|
||
|
||
void initTempSensor() {
|
||
showBootScreen("DS18B20...");
|
||
tempSensor.begin();
|
||
int count = tempSensor.getDeviceCount();
|
||
if (count > 0) {
|
||
state.tempOk = true;
|
||
Serial.printf("[DS18B20] ✅ Найдено датчиков: %d\n", count);
|
||
} else {
|
||
Serial.println("[DS18B20] ⚠️ Датчик не найден");
|
||
state.tempOk = false;
|
||
}
|
||
}
|
||
|
||
// ── Мигание LED ───────────────────────────────────────
|
||
void blink(int times, int ms) {
|
||
for (int i = 0; i < times; i++) {
|
||
digitalWrite(LED_PIN, HIGH);
|
||
delay(ms);
|
||
digitalWrite(LED_PIN, LOW);
|
||
delay(ms);
|
||
}
|
||
}
|