305 lines
12 KiB
JavaScript
305 lines
12 KiB
JavaScript
/**
|
||
* 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 запущен');
|