Files
farmbox/iot-bridge/bridge.js
T

305 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 запущен');