From ab52b7a54a3add06e421a0401a1439fe91570359 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 30 Apr 2026 10:54:42 +0300 Subject: [PATCH] Initial commit: FarmBox edge stack (ESP32 firmware + MQTT bridge + sync agent) --- .gitignore | 25 ++ README.md | 21 + docker-compose.yml | 145 +++++++ firmware/lora_gateway/lora_gateway.ino | 110 ++++++ firmware/sensor_node/sensor_node.ino | 521 +++++++++++++++++++++++++ install.sh | 57 +++ iot-bridge/bridge.js | 304 +++++++++++++++ iot-bridge/package.json | 9 + mosquitto/mosquitto.conf | 24 ++ sync-agent/sync.js | 181 +++++++++ 10 files changed, 1397 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 firmware/lora_gateway/lora_gateway.ino create mode 100644 firmware/sensor_node/sensor_node.ino create mode 100644 install.sh create mode 100644 iot-bridge/bridge.js create mode 100644 iot-bridge/package.json create mode 100644 mosquitto/mosquitto.conf create mode 100644 sync-agent/sync.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c68280b --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..35caf7e --- /dev/null +++ b/README.md @@ -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` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7247460 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/firmware/lora_gateway/lora_gateway.ino b/firmware/lora_gateway/lora_gateway.ino new file mode 100644 index 0000000..d3be6f6 --- /dev/null +++ b/firmware/lora_gateway/lora_gateway.ino @@ -0,0 +1,110 @@ +/** + * FarmBox LoRa Gateway + * ESP32 или Raspberry Pi + LoRa HAT + * Принимает пакеты от всех датчиков → публикует в MQTT + * + * Для ESP32: подключить к WiFi → слать в Mosquitto + * Для RPi: запустить как Python скрипт + */ + +#include +#include +#include +#include +#include +#include + +// ─── 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); + } + } +} diff --git a/firmware/sensor_node/sensor_node.ino b/firmware/sensor_node/sensor_node.ino new file mode 100644 index 0000000..1786546 --- /dev/null +++ b/firmware/sensor_node/sensor_node.ino @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ─── Объекты ────────────────────────────────────────── +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); + } +} diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..6c0498a --- /dev/null +++ b/install.sh @@ -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" diff --git a/iot-bridge/bridge.js b/iot-bridge/bridge.js new file mode 100644 index 0000000..5c4ee79 --- /dev/null +++ b/iot-bridge/bridge.js @@ -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 запущен'); diff --git a/iot-bridge/package.json b/iot-bridge/package.json new file mode 100644 index 0000000..1217006 --- /dev/null +++ b/iot-bridge/package.json @@ -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" + } +} diff --git a/mosquitto/mosquitto.conf b/mosquitto/mosquitto.conf new file mode 100644 index 0000000..6daf344 --- /dev/null +++ b/mosquitto/mosquitto.conf @@ -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 diff --git a/sync-agent/sync.js b/sync-agent/sync.js new file mode 100644 index 0000000..64b6547 --- /dev/null +++ b/sync-agent/sync.js @@ -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);