Initial commit: FarmBox edge stack (ESP32 firmware + MQTT bridge + sync agent)
This commit is contained in:
+25
@@ -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/
|
||||||
@@ -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`
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
@@ -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 запущен');
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user