Initial commit: FarmBox edge stack (ESP32 firmware + MQTT bridge + sync agent)

This commit is contained in:
admin
2026-04-30 10:54:42 +03:00
commit ab52b7a54a
10 changed files with 1397 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Node
node_modules/
npm-debug.log*
# Env
.env
.env.local
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
# Mosquitto runtime
mosquitto/data/
mosquitto/log/
# Build artifacts
*.bin
*.elf
build/
+21
View File
@@ -0,0 +1,21 @@
# FarmBox
Локальный edge-стек для фермы. Разворачивается на Mini PC или Raspberry Pi прямо на объекте, работает полностью без интернета. Принимает данные от ESP32-датчиков по LoRa, агрегирует через MQTT и синхронизирует в облако когда есть связь.
## Состав
- `firmware/lora_gateway/` — прошивка ESP32 LoRa-шлюза (приёмник)
- `firmware/sensor_node/` — прошивка ESP32-датчика (передатчик)
- `iot-bridge/` — Node.js мост между MQTT и облачным API
- `sync-agent/` — агент синхронизации накопленных данных
- `mosquitto/` — конфигурация MQTT-брокера
- `docker-compose.yml` — локальный стек (Mosquitto + bridge + sync)
- `install.sh` — инсталлятор для Mini PC / RPi
## Установка на устройство
```bash
curl -fsSL https://to.zeroday.su/install.sh | bash
```
Или вручную: `chmod +x install.sh && ./install.sh`
+145
View File
@@ -0,0 +1,145 @@
version: '3.8'
# ╔═══════════════════════════════════════════════════════╗
# ║ FarmBox — локальный стек ║
# ║ Разворачивается на Mini PC / RPi на ферме ║
# ║ Работает полностью без интернета ║
# ╚═══════════════════════════════════════════════════════╝
services:
# ─── MQTT брокер — принимает данные от всех датчиков ───
mosquitto:
image: eclipse-mosquitto:2.0
container_name: farmbox-mqtt
restart: unless-stopped
ports:
- "1883:1883" # MQTT (датчики)
- "9001:9001" # WebSocket (браузер)
volumes:
- ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf
- mosquitto-data:/mosquitto/data
- mosquitto-logs:/mosquitto/log
networks:
- farmbox-net
# ─── InfluxDB — хранение временных рядов датчиков ───
influxdb:
image: influxdb:2.7
container_name: farmbox-influx
restart: unless-stopped
ports:
- "8086:8086"
environment:
- DOCKER_INFLUXDB_INIT_MODE=setup
- DOCKER_INFLUXDB_INIT_USERNAME=farmbox
- DOCKER_INFLUXDB_INIT_PASSWORD=farmbox2024
- DOCKER_INFLUXDB_INIT_ORG=farm
- DOCKER_INFLUXDB_INIT_BUCKET=sensors
- DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=farmbox-super-secret-token
volumes:
- influxdb-data:/var/lib/influxdb2
networks:
- farmbox-net
# ─── Node-RED — автоматизация и логика правил ───
nodered:
image: nodered/node-red:3.1
container_name: farmbox-nodered
restart: unless-stopped
ports:
- "1880:1880"
environment:
- TZ=Europe/Moscow
volumes:
- nodered-data:/data
- ./nodered/flows.json:/data/flows.json
depends_on:
- mosquitto
- influxdb
networks:
- farmbox-net
# ─── Grafana — дашборды и графики ───
grafana:
image: grafana/grafana:10.3.0
container_name: farmbox-grafana
restart: unless-stopped
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=farm2024
- GF_SERVER_ROOT_URL=http://localhost:3001
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning
depends_on:
- influxdb
networks:
- farmbox-net
# ─── IoT Bridge — мост MQTT → PostgreSQL + farm-cmms ───
iot-bridge:
image: node:20-alpine
container_name: farmbox-iot-bridge
restart: unless-stopped
working_dir: /app
command: node bridge.js
volumes:
- ./iot-bridge:/app
environment:
- MQTT_HOST=mosquitto
- MQTT_PORT=1883
- INFLUX_URL=http://influxdb:8086
- INFLUX_TOKEN=farmbox-super-secret-token
- INFLUX_ORG=farm
- INFLUX_BUCKET=sensors
- FARM_CMMS_URL=http://host.docker.internal:3005
- FARM_CMMS_TOKEN=farmbox-iot-internal-token
- TZ=Europe/Moscow
depends_on:
- mosquitto
- influxdb
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- farmbox-net
# ─── Sync Agent — синхронизация с облаком ───
sync-agent:
image: node:20-alpine
container_name: farmbox-sync
restart: unless-stopped
working_dir: /app
command: node sync.js
volumes:
- ./sync-agent:/app
- sync-queue:/app/queue
environment:
- LOCAL_CMMS_URL=http://host.docker.internal:3005
- CLOUD_CMMS_URL=https://to.zeroday.su
- SYNC_INTERVAL_SEC=30
- FARM_ID=${FARM_ID:-farm_001}
- FARM_NAME=${FARM_NAME:-Ферма 1}
- SYNC_TOKEN=${SYNC_TOKEN:-changeme}
- TZ=Europe/Moscow
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- farmbox-net
# ─── Тома для хранения данных ───
volumes:
mosquitto-data:
mosquitto-logs:
influxdb-data:
nodered-data:
grafana-data:
sync-queue:
# ─── Внутренняя сеть ───
networks:
farmbox-net:
driver: bridge
+110
View File
@@ -0,0 +1,110 @@
/**
* FarmBox LoRa Gateway
* ESP32 или Raspberry Pi + LoRa HAT
* Принимает пакеты от всех датчиков → публикует в MQTT
*
* Для ESP32: подключить к WiFi → слать в Mosquitto
* Для RPi: запустить как Python скрипт
*/
#include <Arduino.h>
#include <SPI.h>
#include <LoRa.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
// ─── WiFi (локальная сеть фермы) ──────────────────────
const char* WIFI_SSID = "FARM_WIFI"; // ← изменить
const char* WIFI_PASS = "farm_password"; // ← изменить
// ─── MQTT (Mosquitto на Raspberry Pi / Mini PC) ────────
const char* MQTT_HOST = "192.168.1.100"; // ← IP FarmBox
const int MQTT_PORT = 1883;
// ─── LoRa пины (TTGO LoRa32) ──────────────────────────
#define LORA_SCK 5
#define LORA_MISO 19
#define LORA_MOSI 27
#define LORA_SS 18
#define LORA_RST 23
#define LORA_DI0 26
#define LORA_BAND 868E6
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
void setup() {
Serial.begin(115200);
Serial.println("FarmBox LoRa Gateway v1.0");
// WiFi
WiFi.begin(WIFI_SSID, WIFI_PASS);
Serial.print("WiFi...");
while (WiFi.status() != WL_CONNECTED) {
delay(500); Serial.print(".");
}
Serial.printf("\nWiFi OK: %s\n", WiFi.localIP().toString().c_str());
// MQTT
mqtt.setServer(MQTT_HOST, MQTT_PORT);
connectMqtt();
// LoRa
SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_SS);
LoRa.setPins(LORA_SS, LORA_RST, LORA_DI0);
if (!LoRa.begin(LORA_BAND)) {
Serial.println("LoRa ОШИБКА!");
while (true);
}
LoRa.setSpreadingFactor(9);
LoRa.setSignalBandwidth(125E3);
LoRa.enableCrc();
Serial.println("LoRa Gateway готов. Слушаю...");
}
void loop() {
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
int packetSize = LoRa.parsePacket();
if (packetSize) {
String received = "";
while (LoRa.available()) received += (char)LoRa.read();
int rssi = LoRa.packetRssi();
float snr = LoRa.packetSnr();
Serial.printf("[RX] RSSI:%d SNR:%.1f | %s\n", rssi, snr, received.c_str());
// Парсим JSON и добавляем метаданные шлюза
StaticJsonDocument<512> doc;
if (deserializeJson(doc, received) == DeserializationError::Ok) {
doc["gw_rssi"] = rssi;
doc["gw_snr"] = snr;
String enhanced;
serializeJson(doc, enhanced);
// Публикуем в MQTT топик farm/lora/{device_id}
const char* deviceId = doc["id"] | "unknown";
String topic = "farm/lora/" + String(deviceId);
mqtt.publish(topic.c_str(), enhanced.c_str());
Serial.printf("[MQTT] → %s\n", topic.c_str());
}
}
}
void connectMqtt() {
while (!mqtt.connected()) {
Serial.print("MQTT...");
if (mqtt.connect("farmbox-gateway")) {
Serial.println("OK");
// Публикуем статус шлюза
mqtt.publish("farm/status/gateway", "{\"status\":\"online\"}");
} else {
Serial.printf("FAIL rc=%d, retry 5s\n", mqtt.state());
delay(5000);
}
}
}
+521
View File
@@ -0,0 +1,521 @@
/**
* 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);
}
}
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# FarmBox — скрипт установки на Mini PC / Raspberry Pi
# Запускать: curl -fsSL https://to.zeroday.su/install.sh | bash
# Или: chmod +x install.sh && ./install.sh
set -e
echo "╔══════════════════════════════════════════╗"
echo "║ FarmBox — Установка ║"
echo "╚══════════════════════════════════════════╝"
# Проверка Docker
if ! command -v docker &> /dev/null; then
echo "Установка Docker..."
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
fi
if ! command -v docker-compose &> /dev/null; then
echo "Установка Docker Compose..."
sudo apt-get install -y docker-compose-plugin
fi
# Запрос данных фермы
echo ""
read -p "Название фермы (пример: ООО Молоко): " FARM_NAME
read -p "ID фермы (пример: farm_001): " FARM_ID
read -p "Токен синхронизации (получить у поставщика): " SYNC_TOKEN
# Создаём .env
cat > .env << EOF
FARM_ID=${FARM_ID}
FARM_NAME=${FARM_NAME}
SYNC_TOKEN=${SYNC_TOKEN}
IOT_TOKEN=farmbox-iot-$(openssl rand -hex 8)
TZ=Europe/Moscow
EOF
echo ".env создан"
# Устанавливаем зависимости iot-bridge
cd iot-bridge && npm install --production && cd ..
cd sync-agent && npm install --production && cd ..
# Запускаем стек
docker compose up -d
echo ""
echo "✅ FarmBox запущен!"
echo ""
echo "Доступные сервисы:"
echo " farm-cmms: http://localhost:3005"
echo " Grafana: http://localhost:3001 (admin / farm2024)"
echo " Node-RED: http://localhost:1880"
echo " MQTT: localhost:1883"
echo ""
echo "Для просмотра логов: docker compose logs -f"
+304
View File
@@ -0,0 +1,304 @@
/**
* FarmBox IoT Bridge
* MQTT → InfluxDB + farm-cmms
*
* Топики MQTT:
* farm/sensors/{device_id} — данные с датчиков
* farm/lora/{device_id} — данные с LoRa датчиков
* farm/status/{device_id} — статус устройства
*
* Пример payload:
* {"id":"uvv_01","mh":1247,"temp":68.5,"current":8.2,"vibr":0.8}
*/
const mqtt = require('mqtt');
const { InfluxDB, Point } = require('@influxdata/influxdb-client');
const fetch = require('node-fetch');
// ─── Конфиг ───────────────────────────────────────────
const MQTT_HOST = process.env.MQTT_HOST || 'localhost';
const MQTT_PORT = process.env.MQTT_PORT || 1883;
const INFLUX_URL = process.env.INFLUX_URL || 'http://localhost:8086';
const INFLUX_TOKEN = process.env.INFLUX_TOKEN;
const INFLUX_ORG = process.env.INFLUX_ORG || 'farm';
const INFLUX_BUCKET= process.env.INFLUX_BUCKET || 'sensors';
const CMMS_URL = process.env.FARM_CMMS_URL || 'http://localhost:3005';
const CMMS_TOKEN = process.env.FARM_CMMS_TOKEN;
// ─── Правила автоматизации ─────────────────────────────
// Если значение превышает порог → автоматически создаётся заявка в farm-cmms
const ALERT_RULES = [
{
field: 'temp',
operator: '>',
threshold: 85,
severity: 'high',
title: (d) => `Перегрев подшипника — ${d.name || d.id}`,
description: (d, val) => `Температура подшипника: ${val}°C (норма < 85°C). Требуется осмотр.`
},
{
field: 'temp',
operator: '>',
threshold: 70,
severity: 'medium',
title: (d) => `Повышенная температура — ${d.name || d.id}`,
description: (d, val) => `Температура: ${val}°C. Рекомендуется проверить смазку.`
},
{
field: 'current',
operator: '>',
threshold: 10,
severity: 'high',
title: (d) => `Перегрузка двигателя — ${d.name || d.id}`,
description: (d, val) => `Ток двигателя: ${val}A (норма < 10A). Возможна механическая перегрузка.`
},
{
field: 'vibr',
operator: '>',
threshold: 2.0,
severity: 'medium',
title: (d) => `Повышенная вибрация — ${d.name || d.id}`,
description: (d, val) => `Вибрация: ${val} мм/с (норма < 2.0). Проверить ремённую передачу.`
}
];
// Пороги моточасов для автосоздания задач ТО
const MOTOHOUR_RULES = [
{ hours: 250, task: 'Проверка натяжения ремня и уровня масла' },
{ hours: 500, task: 'ТО-500ч: замена масла, ревизия уплотнений' },
{ hours: 1000, task: 'ТО-1000ч: полная ревизия, замена подшипников' },
{ hours: 2000, task: 'ТО-2000ч: капитальный осмотр' },
];
// ─── InfluxDB клиент ───────────────────────────────────
const influx = new InfluxDB({ url: INFLUX_URL, token: INFLUX_TOKEN });
const writeApi = influx.getWriteApi(INFLUX_ORG, INFLUX_BUCKET, 'ms');
// ─── Состояние (кэш последних значений) ───────────────
const deviceState = {}; // последние значения по устройству
const alertCooldown = {}; // не спамить заявками: device+field → timestamp
const COOLDOWN_MS = 30 * 60 * 1000; // 30 минут между одинаковыми заявками
// ─── MQTT подключение ──────────────────────────────────
const client = mqtt.connect(`mqtt://${MQTT_HOST}:${MQTT_PORT}`, {
clientId: 'farmbox-bridge',
reconnectPeriod: 5000,
keepalive: 60,
});
client.on('connect', () => {
console.log(`[Bridge] ✅ MQTT подключён к ${MQTT_HOST}:${MQTT_PORT}`);
client.subscribe('farm/sensors/#');
client.subscribe('farm/lora/#');
client.subscribe('farm/status/#');
console.log('[Bridge] Слушаю топики: farm/sensors/#, farm/lora/#, farm/status/#');
});
client.on('error', (err) => console.error('[Bridge] MQTT ошибка:', err.message));
client.on('offline', () => console.warn('[Bridge] MQTT оффлайн, переподключение...'));
// ─── Обработка входящих сообщений ─────────────────────
client.on('message', async (topic, buffer) => {
let payload;
try {
payload = JSON.parse(buffer.toString());
} catch {
console.warn(`[Bridge] Невалидный JSON из топика ${topic}`);
return;
}
const parts = topic.split('/');
const type = parts[1]; // sensors / lora / status
const deviceId = parts[2]; // uvv_01, temp_barn1, etc.
if (!deviceId) return;
console.log(`[Bridge] 📡 ${topic}:`, payload);
// Обновляем состояние устройства
deviceState[deviceId] = { ...deviceState[deviceId], ...payload, lastSeen: Date.now() };
// Записываем в InfluxDB
await writeToInflux(deviceId, type, payload);
// Проверяем правила алертов
await checkAlertRules(deviceId, payload);
// Обновляем моточасы в farm-cmms
if (payload.mh !== undefined) {
await updateMotohours(deviceId, payload.mh);
}
// Обновляем показания воды
if (payload.water !== undefined) {
await updateWaterReading(deviceId, payload.water);
}
});
// ─── Запись в InfluxDB ─────────────────────────────────
async function writeToInflux(deviceId, type, data) {
try {
const point = new Point('sensor_data')
.tag('device_id', deviceId)
.tag('device_type', type)
.tag('equipment_id', data.eq_id || 'unknown');
if (data.temp !== undefined) point.floatField('temperature', data.temp);
if (data.current !== undefined) point.floatField('current', data.current);
if (data.vibr !== undefined) point.floatField('vibration', data.vibr);
if (data.mh !== undefined) point.intField('motohours', data.mh);
if (data.water !== undefined) point.floatField('water_flow', data.water);
if (data.pressure !== undefined) point.floatField('pressure', data.pressure);
if (data.humidity !== undefined) point.floatField('humidity', data.humidity);
if (data.rssi !== undefined) point.intField('rssi', data.rssi);
if (data.battery !== undefined) point.intField('battery', data.battery);
writeApi.writePoint(point);
await writeApi.flush();
} catch (err) {
console.error('[Bridge] InfluxDB ошибка:', err.message);
}
}
// ─── Проверка правил алертов → создание заявок ────────
async function checkAlertRules(deviceId, data) {
for (const rule of ALERT_RULES) {
const val = data[rule.field];
if (val === undefined) continue;
const triggered = rule.operator === '>' ? val > rule.threshold : val < rule.threshold;
if (!triggered) continue;
// Кулдаун — не спамить одинаковыми заявками
const cooldownKey = `${deviceId}:${rule.field}:${rule.threshold}`;
const lastAlert = alertCooldown[cooldownKey] || 0;
if (Date.now() - lastAlert < COOLDOWN_MS) continue;
alertCooldown[cooldownKey] = Date.now();
const device = deviceState[deviceId] || {};
await createEmergencyRequest({
title: rule.title(device),
description: rule.description(device, val),
severity: rule.severity,
equipment_id: data.eq_id,
source: 'iot_sensor',
sensor_data: { device_id: deviceId, field: rule.field, value: val }
});
}
}
// ─── Обновление моточасов в farm-cmms ─────────────────
async function updateMotohours(deviceId, hours) {
try {
const eqId = deviceIdToEquipmentId(deviceId);
if (!eqId) return;
await apiFetch(`/api/iot/motohours`, 'POST', {
equipment_id: eqId,
device_id: deviceId,
motohours: hours,
timestamp: new Date().toISOString()
});
// Проверяем пороги ТО
for (const rule of MOTOHOUR_RULES) {
const prev = deviceState[deviceId]?.mh_prev || 0;
if (prev < rule.hours && hours >= rule.hours) {
await createMaintenanceTask(eqId, rule.task, hours);
}
}
if (deviceState[deviceId]) {
deviceState[deviceId].mh_prev = hours;
}
} catch (err) {
console.error('[Bridge] updateMotohours ошибка:', err.message);
}
}
// ─── Создание аварийной заявки в farm-cmms ─────────────
async function createEmergencyRequest(data) {
try {
await apiFetch('/api/iot/emergency', 'POST', data);
console.log(`[Bridge] 🚨 Аварийная заявка создана: ${data.title}`);
} catch (err) {
console.error('[Bridge] Ошибка создания заявки:', err.message);
}
}
// ─── Создание задачи ТО в farm-cmms ───────────────────
async function createMaintenanceTask(equipmentId, taskTitle, motohours) {
try {
await apiFetch('/api/iot/task', 'POST', {
equipment_id: equipmentId,
title: taskTitle,
description: `Автоматически создано при достижении ${motohours} моточасов`,
source: 'motohour_trigger',
motohours
});
console.log(`[Bridge] 📋 Задача ТО создана: ${taskTitle} (${motohours}ч)`);
} catch (err) {
console.error('[Bridge] Ошибка создания задачи ТО:', err.message);
}
}
// ─── Обновление показаний воды ─────────────────────────
async function updateWaterReading(deviceId, value) {
try {
await apiFetch('/api/iot/water', 'POST', {
device_id: deviceId,
value,
timestamp: new Date().toISOString()
});
} catch (err) {
console.error('[Bridge] updateWaterReading ошибка:', err.message);
}
}
// ─── Вспомогательные функции ──────────────────────────
function deviceIdToEquipmentId(deviceId) {
// Маппинг device_id → equipment_id в farm-cmms
// Настраивается при установке
const mapping = JSON.parse(process.env.DEVICE_MAPPING || '{}');
return mapping[deviceId] || null;
}
async function apiFetch(path, method, body) {
const res = await fetch(`${CMMS_URL}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-IoT-Token': CMMS_TOKEN
},
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return res.json();
}
// ─── Мониторинг оффлайн датчиков ──────────────────────
setInterval(() => {
const now = Date.now();
const OFFLINE_THRESHOLD = 10 * 60 * 1000; // 10 минут без пакетов
for (const [deviceId, state] of Object.entries(deviceState)) {
if (now - state.lastSeen > OFFLINE_THRESHOLD) {
const cooldownKey = `${deviceId}:offline`;
const lastAlert = alertCooldown[cooldownKey] || 0;
if (now - lastAlert > COOLDOWN_MS) {
alertCooldown[cooldownKey] = now;
console.warn(`[Bridge] ⚠️ Нет связи с датчиком ${deviceId}`);
createEmergencyRequest({
title: `Нет связи с датчиком ${deviceId}`,
description: `Датчик не отправлял данные более 10 минут. Проверьте питание и связь.`,
severity: 'medium',
equipment_id: deviceState[deviceId]?.eq_id,
source: 'connectivity_monitor'
});
}
}
}
}, 60 * 1000); // проверяем каждую минуту
console.log('[Bridge] 🚀 FarmBox IoT Bridge запущен');
+9
View File
@@ -0,0 +1,9 @@
{
"name": "farmbox-iot-bridge",
"version": "1.0.0",
"dependencies": {
"mqtt": "^5.3.4",
"@influxdata/influxdb-client": "^1.33.2",
"node-fetch": "^2.7.0"
}
}
+24
View File
@@ -0,0 +1,24 @@
# Mosquitto MQTT Broker — FarmBox
listener 1883
listener 9001
protocol websockets
# Без авторизации для локальной сети (можно включить позже)
allow_anonymous true
# Логирование
log_dest file /mosquitto/log/mosquitto.log
log_type error
log_type warning
log_type notice
log_type information
log_timestamp true
# Персистентность — данные не теряются при перезапуске
persistence true
persistence_location /mosquitto/data/
# Лимиты
max_connections 100
max_packet_size 65536
+181
View File
@@ -0,0 +1,181 @@
/**
* FarmBox Sync Agent
* Синхронизация локального farm-cmms с облаком
* Работает в фоне, не блокирует локальную работу
*/
const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const LOCAL_URL = process.env.LOCAL_CMMS_URL || 'http://localhost:3005';
const CLOUD_URL = process.env.CLOUD_CMMS_URL || 'https://to.zeroday.su';
const INTERVAL_SEC = parseInt(process.env.SYNC_INTERVAL_SEC || '30');
const FARM_ID = process.env.FARM_ID || 'farm_001';
const FARM_NAME = process.env.FARM_NAME || 'Ферма 1';
const SYNC_TOKEN = process.env.SYNC_TOKEN || 'changeme';
const QUEUE_DIR = path.join(__dirname, 'queue');
// Очередь изменений (персистентная — пережидает перезапуск)
if (!fs.existsSync(QUEUE_DIR)) fs.mkdirSync(QUEUE_DIR, { recursive: true });
let isOnline = false;
let lastSyncAt = null;
let syncStats = { success: 0, failed: 0, queued: 0 };
// ─── Проверка интернета ────────────────────────────────
async function checkOnline() {
try {
const res = await fetch(`${CLOUD_URL}/api/health`, { timeout: 5000 });
return res.ok;
} catch {
return false;
}
}
// ─── Получить изменения с локального сервера ──────────
async function getLocalChanges(since) {
try {
const res = await fetch(
`${LOCAL_URL}/api/sync/changes?since=${encodeURIComponent(since)}&farm_id=${FARM_ID}`,
{ headers: { 'X-Sync-Token': SYNC_TOKEN }, timeout: 10000 }
);
if (!res.ok) return [];
return res.json();
} catch (err) {
console.error('[Sync] getLocalChanges:', err.message);
return [];
}
}
// ─── Отправить изменения в облако ─────────────────────
async function pushToCloud(changes) {
const res = await fetch(`${CLOUD_URL}/api/sync/receive`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Sync-Token': SYNC_TOKEN,
'X-Farm-Id': FARM_ID,
'X-Farm-Name': FARM_NAME
},
body: JSON.stringify({ changes, farm_id: FARM_ID, farm_name: FARM_NAME }),
timeout: 15000
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// ─── Получить обновления из облака ────────────────────
async function pullFromCloud() {
try {
const res = await fetch(
`${CLOUD_URL}/api/sync/push?farm_id=${FARM_ID}`,
{ headers: { 'X-Sync-Token': SYNC_TOKEN }, timeout: 10000 }
);
if (!res.ok) return [];
return res.json();
} catch {
return [];
}
}
// ─── Применить обновления из облака локально ──────────
async function applyCloudUpdates(updates) {
if (!updates?.length) return;
try {
await fetch(`${LOCAL_URL}/api/sync/apply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Sync-Token': SYNC_TOKEN
},
body: JSON.stringify({ updates }),
timeout: 10000
});
} catch (err) {
console.error('[Sync] applyCloudUpdates:', err.message);
}
}
// ─── Сохранить в очередь если нет интернета ───────────
function saveToQueue(changes) {
if (!changes?.length) return;
const filename = path.join(QUEUE_DIR, `${Date.now()}.json`);
fs.writeFileSync(filename, JSON.stringify(changes));
syncStats.queued += changes.length;
console.log(`[Sync] 💾 Сохранено в очередь: ${changes.length} изменений`);
}
// ─── Отправить очередь когда появился интернет ────────
async function flushQueue() {
const files = fs.readdirSync(QUEUE_DIR).filter(f => f.endsWith('.json')).sort();
if (!files.length) return;
console.log(`[Sync] 📤 Отправка очереди: ${files.length} файлов`);
for (const file of files) {
try {
const changes = JSON.parse(fs.readFileSync(path.join(QUEUE_DIR, file)));
await pushToCloud(changes);
fs.unlinkSync(path.join(QUEUE_DIR, file));
syncStats.success += changes.length;
} catch (err) {
console.error(`[Sync] Ошибка отправки ${file}:`, err.message);
syncStats.failed++;
break; // Пробуем в следующем цикле
}
}
}
// ─── Основной цикл синхронизации ─────────────────────
let lastChangeTime = new Date(Date.now() - 60 * 60 * 1000).toISOString(); // -1 час при старте
async function syncCycle() {
const online = await checkOnline();
if (online && !isOnline) {
console.log('[Sync] 🟢 Интернет появился — отправляем очередь');
await flushQueue();
} else if (!online && isOnline) {
console.log('[Sync] 🔴 Интернет пропал — работаем локально');
}
isOnline = online;
try {
// Получаем локальные изменения
const changes = await getLocalChanges(lastChangeTime);
if (changes.length > 0) {
if (isOnline) {
await pushToCloud(changes);
lastSyncAt = new Date().toISOString();
syncStats.success += changes.length;
console.log(`[Sync] ✅ Отправлено в облако: ${changes.length} изменений`);
} else {
saveToQueue(changes);
}
lastChangeTime = new Date().toISOString();
}
// Получаем обновления из облака
if (isOnline) {
const updates = await pullFromCloud();
if (updates?.length > 0) {
await applyCloudUpdates(updates);
console.log(`[Sync] ⬇️ Получено из облака: ${updates.length} обновлений`);
}
}
} catch (err) {
console.error('[Sync] Ошибка цикла:', err.message);
}
}
// ─── Запуск ───────────────────────────────────────────
console.log(`[Sync] 🚀 Агент синхронизации запущен`);
console.log(`[Sync] Ферма: ${FARM_NAME} (${FARM_ID})`);
console.log(`[Sync] Локальный: ${LOCAL_URL}`);
console.log(`[Sync] Облако: ${CLOUD_URL}`);
console.log(`[Sync] Интервал: ${INTERVAL_SEC}с`);
syncCycle();
setInterval(syncCycle, INTERVAL_SEC * 1000);