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

This commit is contained in:
admin
2026-04-30 10:54:42 +03:00
commit ab52b7a54a
10 changed files with 1397 additions and 0 deletions
+304
View File
@@ -0,0 +1,304 @@
/**
* FarmBox IoT Bridge
* MQTT → InfluxDB + farm-cmms
*
* Топики MQTT:
* farm/sensors/{device_id} — данные с датчиков
* farm/lora/{device_id} — данные с LoRa датчиков
* farm/status/{device_id} — статус устройства
*
* Пример payload:
* {"id":"uvv_01","mh":1247,"temp":68.5,"current":8.2,"vibr":0.8}
*/
const mqtt = require('mqtt');
const { InfluxDB, Point } = require('@influxdata/influxdb-client');
const fetch = require('node-fetch');
// ─── Конфиг ───────────────────────────────────────────
const MQTT_HOST = process.env.MQTT_HOST || 'localhost';
const MQTT_PORT = process.env.MQTT_PORT || 1883;
const INFLUX_URL = process.env.INFLUX_URL || 'http://localhost:8086';
const INFLUX_TOKEN = process.env.INFLUX_TOKEN;
const INFLUX_ORG = process.env.INFLUX_ORG || 'farm';
const INFLUX_BUCKET= process.env.INFLUX_BUCKET || 'sensors';
const CMMS_URL = process.env.FARM_CMMS_URL || 'http://localhost:3005';
const CMMS_TOKEN = process.env.FARM_CMMS_TOKEN;
// ─── Правила автоматизации ─────────────────────────────
// Если значение превышает порог → автоматически создаётся заявка в farm-cmms
const ALERT_RULES = [
{
field: 'temp',
operator: '>',
threshold: 85,
severity: 'high',
title: (d) => `Перегрев подшипника — ${d.name || d.id}`,
description: (d, val) => `Температура подшипника: ${val}°C (норма < 85°C). Требуется осмотр.`
},
{
field: 'temp',
operator: '>',
threshold: 70,
severity: 'medium',
title: (d) => `Повышенная температура — ${d.name || d.id}`,
description: (d, val) => `Температура: ${val}°C. Рекомендуется проверить смазку.`
},
{
field: 'current',
operator: '>',
threshold: 10,
severity: 'high',
title: (d) => `Перегрузка двигателя — ${d.name || d.id}`,
description: (d, val) => `Ток двигателя: ${val}A (норма < 10A). Возможна механическая перегрузка.`
},
{
field: 'vibr',
operator: '>',
threshold: 2.0,
severity: 'medium',
title: (d) => `Повышенная вибрация — ${d.name || d.id}`,
description: (d, val) => `Вибрация: ${val} мм/с (норма < 2.0). Проверить ремённую передачу.`
}
];
// Пороги моточасов для автосоздания задач ТО
const MOTOHOUR_RULES = [
{ hours: 250, task: 'Проверка натяжения ремня и уровня масла' },
{ hours: 500, task: 'ТО-500ч: замена масла, ревизия уплотнений' },
{ hours: 1000, task: 'ТО-1000ч: полная ревизия, замена подшипников' },
{ hours: 2000, task: 'ТО-2000ч: капитальный осмотр' },
];
// ─── InfluxDB клиент ───────────────────────────────────
const influx = new InfluxDB({ url: INFLUX_URL, token: INFLUX_TOKEN });
const writeApi = influx.getWriteApi(INFLUX_ORG, INFLUX_BUCKET, 'ms');
// ─── Состояние (кэш последних значений) ───────────────
const deviceState = {}; // последние значения по устройству
const alertCooldown = {}; // не спамить заявками: device+field → timestamp
const COOLDOWN_MS = 30 * 60 * 1000; // 30 минут между одинаковыми заявками
// ─── MQTT подключение ──────────────────────────────────
const client = mqtt.connect(`mqtt://${MQTT_HOST}:${MQTT_PORT}`, {
clientId: 'farmbox-bridge',
reconnectPeriod: 5000,
keepalive: 60,
});
client.on('connect', () => {
console.log(`[Bridge] ✅ MQTT подключён к ${MQTT_HOST}:${MQTT_PORT}`);
client.subscribe('farm/sensors/#');
client.subscribe('farm/lora/#');
client.subscribe('farm/status/#');
console.log('[Bridge] Слушаю топики: farm/sensors/#, farm/lora/#, farm/status/#');
});
client.on('error', (err) => console.error('[Bridge] MQTT ошибка:', err.message));
client.on('offline', () => console.warn('[Bridge] MQTT оффлайн, переподключение...'));
// ─── Обработка входящих сообщений ─────────────────────
client.on('message', async (topic, buffer) => {
let payload;
try {
payload = JSON.parse(buffer.toString());
} catch {
console.warn(`[Bridge] Невалидный JSON из топика ${topic}`);
return;
}
const parts = topic.split('/');
const type = parts[1]; // sensors / lora / status
const deviceId = parts[2]; // uvv_01, temp_barn1, etc.
if (!deviceId) return;
console.log(`[Bridge] 📡 ${topic}:`, payload);
// Обновляем состояние устройства
deviceState[deviceId] = { ...deviceState[deviceId], ...payload, lastSeen: Date.now() };
// Записываем в InfluxDB
await writeToInflux(deviceId, type, payload);
// Проверяем правила алертов
await checkAlertRules(deviceId, payload);
// Обновляем моточасы в farm-cmms
if (payload.mh !== undefined) {
await updateMotohours(deviceId, payload.mh);
}
// Обновляем показания воды
if (payload.water !== undefined) {
await updateWaterReading(deviceId, payload.water);
}
});
// ─── Запись в InfluxDB ─────────────────────────────────
async function writeToInflux(deviceId, type, data) {
try {
const point = new Point('sensor_data')
.tag('device_id', deviceId)
.tag('device_type', type)
.tag('equipment_id', data.eq_id || 'unknown');
if (data.temp !== undefined) point.floatField('temperature', data.temp);
if (data.current !== undefined) point.floatField('current', data.current);
if (data.vibr !== undefined) point.floatField('vibration', data.vibr);
if (data.mh !== undefined) point.intField('motohours', data.mh);
if (data.water !== undefined) point.floatField('water_flow', data.water);
if (data.pressure !== undefined) point.floatField('pressure', data.pressure);
if (data.humidity !== undefined) point.floatField('humidity', data.humidity);
if (data.rssi !== undefined) point.intField('rssi', data.rssi);
if (data.battery !== undefined) point.intField('battery', data.battery);
writeApi.writePoint(point);
await writeApi.flush();
} catch (err) {
console.error('[Bridge] InfluxDB ошибка:', err.message);
}
}
// ─── Проверка правил алертов → создание заявок ────────
async function checkAlertRules(deviceId, data) {
for (const rule of ALERT_RULES) {
const val = data[rule.field];
if (val === undefined) continue;
const triggered = rule.operator === '>' ? val > rule.threshold : val < rule.threshold;
if (!triggered) continue;
// Кулдаун — не спамить одинаковыми заявками
const cooldownKey = `${deviceId}:${rule.field}:${rule.threshold}`;
const lastAlert = alertCooldown[cooldownKey] || 0;
if (Date.now() - lastAlert < COOLDOWN_MS) continue;
alertCooldown[cooldownKey] = Date.now();
const device = deviceState[deviceId] || {};
await createEmergencyRequest({
title: rule.title(device),
description: rule.description(device, val),
severity: rule.severity,
equipment_id: data.eq_id,
source: 'iot_sensor',
sensor_data: { device_id: deviceId, field: rule.field, value: val }
});
}
}
// ─── Обновление моточасов в farm-cmms ─────────────────
async function updateMotohours(deviceId, hours) {
try {
const eqId = deviceIdToEquipmentId(deviceId);
if (!eqId) return;
await apiFetch(`/api/iot/motohours`, 'POST', {
equipment_id: eqId,
device_id: deviceId,
motohours: hours,
timestamp: new Date().toISOString()
});
// Проверяем пороги ТО
for (const rule of MOTOHOUR_RULES) {
const prev = deviceState[deviceId]?.mh_prev || 0;
if (prev < rule.hours && hours >= rule.hours) {
await createMaintenanceTask(eqId, rule.task, hours);
}
}
if (deviceState[deviceId]) {
deviceState[deviceId].mh_prev = hours;
}
} catch (err) {
console.error('[Bridge] updateMotohours ошибка:', err.message);
}
}
// ─── Создание аварийной заявки в farm-cmms ─────────────
async function createEmergencyRequest(data) {
try {
await apiFetch('/api/iot/emergency', 'POST', data);
console.log(`[Bridge] 🚨 Аварийная заявка создана: ${data.title}`);
} catch (err) {
console.error('[Bridge] Ошибка создания заявки:', err.message);
}
}
// ─── Создание задачи ТО в farm-cmms ───────────────────
async function createMaintenanceTask(equipmentId, taskTitle, motohours) {
try {
await apiFetch('/api/iot/task', 'POST', {
equipment_id: equipmentId,
title: taskTitle,
description: `Автоматически создано при достижении ${motohours} моточасов`,
source: 'motohour_trigger',
motohours
});
console.log(`[Bridge] 📋 Задача ТО создана: ${taskTitle} (${motohours}ч)`);
} catch (err) {
console.error('[Bridge] Ошибка создания задачи ТО:', err.message);
}
}
// ─── Обновление показаний воды ─────────────────────────
async function updateWaterReading(deviceId, value) {
try {
await apiFetch('/api/iot/water', 'POST', {
device_id: deviceId,
value,
timestamp: new Date().toISOString()
});
} catch (err) {
console.error('[Bridge] updateWaterReading ошибка:', err.message);
}
}
// ─── Вспомогательные функции ──────────────────────────
function deviceIdToEquipmentId(deviceId) {
// Маппинг device_id → equipment_id в farm-cmms
// Настраивается при установке
const mapping = JSON.parse(process.env.DEVICE_MAPPING || '{}');
return mapping[deviceId] || null;
}
async function apiFetch(path, method, body) {
const res = await fetch(`${CMMS_URL}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-IoT-Token': CMMS_TOKEN
},
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return res.json();
}
// ─── Мониторинг оффлайн датчиков ──────────────────────
setInterval(() => {
const now = Date.now();
const OFFLINE_THRESHOLD = 10 * 60 * 1000; // 10 минут без пакетов
for (const [deviceId, state] of Object.entries(deviceState)) {
if (now - state.lastSeen > OFFLINE_THRESHOLD) {
const cooldownKey = `${deviceId}:offline`;
const lastAlert = alertCooldown[cooldownKey] || 0;
if (now - lastAlert > COOLDOWN_MS) {
alertCooldown[cooldownKey] = now;
console.warn(`[Bridge] ⚠️ Нет связи с датчиком ${deviceId}`);
createEmergencyRequest({
title: `Нет связи с датчиком ${deviceId}`,
description: `Датчик не отправлял данные более 10 минут. Проверьте питание и связь.`,
severity: 'medium',
equipment_id: deviceState[deviceId]?.eq_id,
source: 'connectivity_monitor'
});
}
}
}
}, 60 * 1000); // проверяем каждую минуту
console.log('[Bridge] 🚀 FarmBox IoT Bridge запущен');
+9
View File
@@ -0,0 +1,9 @@
{
"name": "farmbox-iot-bridge",
"version": "1.0.0",
"dependencies": {
"mqtt": "^5.3.4",
"@influxdata/influxdb-client": "^1.33.2",
"node-fetch": "^2.7.0"
}
}