590 lines
21 KiB
JavaScript
590 lines
21 KiB
JavaScript
const express = require('express');
|
||
const router = express.Router();
|
||
const Joi = require('joi');
|
||
const { validateRequest } = require('../middleware/validation');
|
||
|
||
// Use global test models if available, otherwise use regular models
|
||
function getModels() {
|
||
if (global.__TEST_MODELS__) {
|
||
console.log('🔧 DEBUG: Using global test models in detectors route');
|
||
return global.__TEST_MODELS__;
|
||
}
|
||
return require('../models');
|
||
}
|
||
|
||
const AlertService = require('../services/alertService');
|
||
const DroneTrackingService = require('../services/droneTrackingService');
|
||
const { getDroneTypeInfo, getDroneTypeName } = require('../utils/droneTypes');
|
||
|
||
// Configuration for debugging and data storage
|
||
const DEBUG_CONFIG = {
|
||
storeHeartbeats: process.env.STORE_HEARTBEATS === 'true', // Store heartbeat data for debugging
|
||
storeNoneDetections: process.env.STORE_DRONE_TYPE0 === 'true', // Store drone_type 0 for debugging
|
||
logAllDetections: process.env.LOG_ALL_DETECTIONS === 'true', // Log all detection data
|
||
storeRawPayload: process.env.STORE_RAW_PAYLOAD === 'true' // Store complete raw payload for debugging
|
||
};
|
||
|
||
// Initialize services
|
||
const alertService = new AlertService();
|
||
const droneTracker = new DroneTrackingService();
|
||
|
||
// Handle movement alerts from the tracking service
|
||
droneTracker.on('movement_alert', (alertData) => {
|
||
const { io } = require('../index');
|
||
if (io) {
|
||
// Emit to dashboard with detailed movement information
|
||
io.emitToDashboard('drone_movement_alert', {
|
||
...alertData,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
|
||
// Emit to specific device room
|
||
io.emitToDevice(alertData.deviceId, 'drone_movement_alert', {
|
||
...alertData,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
|
||
console.log(`🚨 Movement Alert: ${alertData.analysis.description} (Drone ${alertData.droneId})`);
|
||
}
|
||
});
|
||
|
||
// Simplified unified schema - validate based on payload type
|
||
const detectorSchema = Joi.alternatives().try(
|
||
// Heartbeat schema
|
||
Joi.object({
|
||
type: Joi.string().valid('heartbeat').required(),
|
||
key: Joi.string().required(),
|
||
// Optional heartbeat fields
|
||
device_id: Joi.alternatives().try(
|
||
Joi.number().integer(),
|
||
Joi.string()
|
||
).optional(),
|
||
geo_lat: Joi.number().min(-90).max(90).optional(),
|
||
geo_lon: Joi.number().min(-180).max(180).optional(),
|
||
location_description: Joi.string().optional(),
|
||
uptime: Joi.number().integer().min(0).optional(),
|
||
memory_usage: Joi.number().integer().min(0).max(100).optional(),
|
||
firmware_version: Joi.string().optional()
|
||
}),
|
||
// Detection schema
|
||
Joi.object({
|
||
device_id: Joi.alternatives().try(
|
||
Joi.number().integer(),
|
||
Joi.string()
|
||
).required(),
|
||
geo_lat: Joi.number().min(-90).max(90).required(),
|
||
geo_lon: Joi.number().min(-180).max(180).required(),
|
||
device_timestamp: Joi.number().integer().min(0).required(),
|
||
drone_type: Joi.number().integer().min(0).required(),
|
||
rssi: Joi.number().required(),
|
||
freq: Joi.number().required(),
|
||
drone_id: Joi.number().integer().required(),
|
||
// Optional detection fields
|
||
confidence_level: Joi.number().min(0).max(1).optional(),
|
||
signal_duration: Joi.number().integer().min(0).optional()
|
||
})
|
||
);
|
||
|
||
// POST /api/detectors - Unified endpoint for heartbeats and detections
|
||
router.post('/', validateRequest(detectorSchema), async (req, res) => {
|
||
try {
|
||
// Log the full incoming payload for debugging
|
||
console.log('📦 Full payload received:', JSON.stringify(req.body, null, 2));
|
||
console.log('📡 Request headers:', JSON.stringify({
|
||
'user-agent': req.headers['user-agent'],
|
||
'content-type': req.headers['content-type'],
|
||
'content-length': req.headers['content-length']
|
||
}, null, 2));
|
||
|
||
// Determine if this is a heartbeat or detection based on payload
|
||
if (req.body.type === 'heartbeat') {
|
||
return await handleHeartbeat(req, res);
|
||
} else if (req.body.device_id) {
|
||
return await handleDetection(req, res);
|
||
} else {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Invalid payload: must be either heartbeat or detection format'
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('Error in detectors endpoint:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Internal server error'
|
||
});
|
||
}
|
||
});
|
||
|
||
// Handle heartbeat payload
|
||
async function handleHeartbeat(req, res) {
|
||
const { type, key, device_id, geo_lat, geo_lon, location_description, ...heartbeatData } = req.body;
|
||
|
||
// Get models dynamically (use test models if available)
|
||
const { Device, Heartbeat } = getModels();
|
||
|
||
console.log(`💓 Heartbeat received from device key: ${key}`);
|
||
console.log('💗 Complete heartbeat data:', JSON.stringify(req.body, null, 2));
|
||
|
||
// Use device_id if provided, otherwise use key as device identifier
|
||
let deviceId = device_id || key;
|
||
|
||
// Convert to integer if it's a numeric string
|
||
if (typeof deviceId === 'string' && /^\d+$/.test(deviceId)) {
|
||
deviceId = parseInt(deviceId);
|
||
}
|
||
|
||
console.log(`📌 Using device ID: ${deviceId}`);
|
||
|
||
// Check if device exists and is approved
|
||
let device = await Device.findOne({ where: { id: deviceId } });
|
||
|
||
if (!device) {
|
||
// Device not found - reject heartbeat and require manual registration
|
||
console.log(`<EFBFBD> Heartbeat rejected from unknown device ${deviceId} - device must be manually registered first`);
|
||
|
||
return res.status(404).json({
|
||
success: false,
|
||
error: 'Device not registered',
|
||
message: 'Device not found. Please register the device manually through the UI before sending data.',
|
||
device_id: deviceId,
|
||
registration_required: true
|
||
});
|
||
}
|
||
|
||
if (!device.is_approved) {
|
||
console.log(`🚫 Heartbeat rejected from unapproved device ${deviceId}`);
|
||
|
||
// Emit reminder notification to tenant room only
|
||
if (req.io && device.tenant_id) {
|
||
req.io.to(`tenant_${device.tenant_id}`).emit('device_approval_reminder', {
|
||
device_id: deviceId,
|
||
device_key: key,
|
||
timestamp: new Date().toISOString(),
|
||
message: `Device ${deviceId} (${key}) still awaiting approval`
|
||
});
|
||
}
|
||
|
||
return res.status(403).json({
|
||
success: false,
|
||
error: 'Device not approved',
|
||
message: 'Device requires approval before it can send data',
|
||
device_id: deviceId,
|
||
approval_required: true
|
||
});
|
||
}
|
||
|
||
// Device exists and is approved - continue with heartbeat processing
|
||
|
||
// Update device's last heartbeat
|
||
await device.update({ last_heartbeat: new Date() });
|
||
|
||
// Create heartbeat record with all optional fields
|
||
const heartbeatRecord = {
|
||
device_id: deviceId,
|
||
device_key: key,
|
||
...heartbeatData,
|
||
received_at: new Date()
|
||
};
|
||
|
||
// Add raw payload if debugging is enabled
|
||
if (DEBUG_CONFIG.storeRawPayload) {
|
||
heartbeatRecord.raw_payload = req.body;
|
||
if (DEBUG_CONFIG.logAllDetections) {
|
||
console.log(`🔍 Storing heartbeat raw payload for debugging: ${JSON.stringify(req.body)}`);
|
||
}
|
||
}
|
||
|
||
const heartbeat = await Heartbeat.create(heartbeatRecord);
|
||
|
||
// Emit real-time update via Socket.IO to tenant room only
|
||
if (req.io && device.tenant_id) {
|
||
req.io.to(`tenant_${device.tenant_id}`).emit('device_heartbeat', {
|
||
device_id: deviceId,
|
||
device_key: key,
|
||
timestamp: heartbeat.received_at,
|
||
status: 'online',
|
||
...heartbeatData
|
||
});
|
||
}
|
||
|
||
console.log(`✅ Heartbeat recorded for device ${deviceId}`);
|
||
|
||
return res.status(200).json({
|
||
success: true,
|
||
data: heartbeat,
|
||
message: 'Heartbeat received'
|
||
});
|
||
}
|
||
|
||
// Handle detection payload
|
||
async function handleDetection(req, res) {
|
||
const detectionData = req.body;
|
||
|
||
// Get models dynamically (use test models if available)
|
||
const { Device, DroneDetection } = getModels();
|
||
|
||
// Get drone type information
|
||
const droneTypeInfo = getDroneTypeInfo(detectionData.drone_type);
|
||
|
||
console.log(`🚁 Drone detection received from device ${detectionData.device_id}: drone_id=${detectionData.drone_id}, type=${detectionData.drone_type} (${droneTypeInfo.name}), rssi=${detectionData.rssi}`);
|
||
console.log(`🎯 Drone Type Details: ${droneTypeInfo.category} | Threat: ${droneTypeInfo.threat_level} | ${droneTypeInfo.description}`);
|
||
console.log('🔍 Complete detection data:', JSON.stringify(detectionData, null, 2));
|
||
|
||
// Convert device_id to string for consistent database lookup
|
||
const deviceIdString = String(detectionData.device_id);
|
||
|
||
// Check if device exists and is approved
|
||
console.log(`🔍 DEBUG: Looking for device with ID: ${detectionData.device_id} (type: ${typeof detectionData.device_id}) -> converted to "${deviceIdString}" (string)`);
|
||
console.log(`🔍 DEBUG: Device model being used:`, Device);
|
||
console.log(`🔍 DEBUG: Sequelize instance:`, Device.sequelize.constructor.name);
|
||
|
||
// Get all devices to see what's actually in the database
|
||
const allDevices = await Device.findAll();
|
||
console.log(`🔍 DEBUG: Total devices in database: ${allDevices.length}`);
|
||
allDevices.forEach(d => {
|
||
console.log(` - Device ID: ${d.id} (type: ${typeof d.id}), name: ${d.name}, approved: ${d.is_approved}, active: ${d.is_active}`);
|
||
});
|
||
|
||
let device = await Device.findOne({ where: { id: deviceIdString } });
|
||
console.log(`🔍 DEBUG: Device lookup result:`, device ? `Found device ${device.id}` : 'Device not found');
|
||
|
||
if (!device) {
|
||
// Device not found - reject detection and require manual registration
|
||
console.log(`🚫 Detection rejected from unknown device ${detectionData.device_id} - device must be manually registered first`);
|
||
|
||
return res.status(404).json({
|
||
success: false,
|
||
error: 'Device not registered',
|
||
message: 'Device not found. Please register the device manually through the UI before sending data.',
|
||
device_id: detectionData.device_id,
|
||
registration_required: true
|
||
});
|
||
}
|
||
|
||
if (!device.is_approved) {
|
||
console.log(`🚫 Detection rejected from unapproved device ${detectionData.device_id}`);
|
||
|
||
// Emit reminder notification to tenant room only
|
||
if (req.io && device.tenant_id) {
|
||
req.io.to(`tenant_${device.tenant_id}`).emit('device_approval_reminder', {
|
||
device_id: detectionData.device_id,
|
||
timestamp: new Date().toISOString(),
|
||
message: `Device ${detectionData.device_id} still awaiting approval`
|
||
});
|
||
}
|
||
|
||
return res.status(403).json({
|
||
success: false,
|
||
error: 'Device not approved',
|
||
message: 'Device requires approval before it can send data',
|
||
device_id: detectionData.device_id,
|
||
approval_required: true
|
||
});
|
||
}
|
||
|
||
// Handle drone type 0 (None) - store for debugging but don't trigger alarms
|
||
let isDebugDetection = false;
|
||
if (detectionData.drone_type === 0) {
|
||
// Check environment variable dynamically for testing
|
||
const storeNoneDetections = process.env.STORE_DRONE_TYPE0 === 'true';
|
||
|
||
if (!storeNoneDetections) {
|
||
// When debug mode is disabled, return early for drone_type 0
|
||
console.log(`🔍 Drone type 0 detection skipped - debug mode disabled`);
|
||
return res.status(200).json({
|
||
success: true,
|
||
message: 'Detection skipped - drone type 0 detections not stored when debug mode disabled',
|
||
device_id: detectionData.device_id,
|
||
debug_mode: false
|
||
});
|
||
}
|
||
|
||
if (DEBUG_CONFIG.logAllDetections) {
|
||
console.log(`🔍 Debug: Drone type 0 (None) received from device ${detectionData.device_id} - storing for debug purposes`);
|
||
}
|
||
isDebugDetection = true;
|
||
}
|
||
|
||
// Validate and convert data types to match database schema
|
||
const validateAndConvertDetectionData = (data) => {
|
||
const errors = [];
|
||
const converted = {};
|
||
|
||
// Required fields validation and conversion
|
||
try {
|
||
// device_id - already converted to string above
|
||
converted.device_id = deviceIdString;
|
||
|
||
// drone_id - must be BIGINT (convert to integer)
|
||
if (data.drone_id === undefined || data.drone_id === null) {
|
||
errors.push('drone_id is required');
|
||
} else {
|
||
const droneId = parseInt(data.drone_id);
|
||
if (isNaN(droneId)) {
|
||
errors.push(`drone_id must be a number, received: ${data.drone_id}`);
|
||
} else {
|
||
converted.drone_id = droneId;
|
||
}
|
||
}
|
||
|
||
// drone_type - must be INTEGER
|
||
if (data.drone_type === undefined || data.drone_type === null) {
|
||
errors.push('drone_type is required');
|
||
} else {
|
||
const droneType = parseInt(data.drone_type);
|
||
if (isNaN(droneType) || droneType < 0) {
|
||
errors.push(`drone_type must be a non-negative integer, received: ${data.drone_type}`);
|
||
} else {
|
||
converted.drone_type = droneType;
|
||
}
|
||
}
|
||
|
||
// rssi - must be INTEGER
|
||
if (data.rssi === undefined || data.rssi === null) {
|
||
errors.push('rssi is required');
|
||
} else {
|
||
const rssi = parseInt(data.rssi);
|
||
if (isNaN(rssi)) {
|
||
errors.push(`rssi must be a number, received: ${data.rssi}`);
|
||
} else {
|
||
converted.rssi = rssi;
|
||
}
|
||
}
|
||
|
||
// freq - must be INTEGER (convert from MHz decimal to MHz integer)
|
||
if (data.freq === undefined || data.freq === null) {
|
||
errors.push('freq is required');
|
||
} else {
|
||
let freq = parseFloat(data.freq);
|
||
if (isNaN(freq)) {
|
||
errors.push(`freq must be a number, received: ${data.freq}`);
|
||
} else {
|
||
// Convert GHz to MHz if needed (e.g., 2.4 GHz = 2400 MHz)
|
||
if (freq < 100) {
|
||
// Likely in GHz, convert to MHz
|
||
freq = freq * 1000;
|
||
}
|
||
converted.freq = Math.round(freq); // Round to nearest integer MHz
|
||
}
|
||
}
|
||
|
||
// geo_lat - must be DECIMAL (latitude -90 to 90)
|
||
if (data.geo_lat !== undefined && data.geo_lat !== null) {
|
||
const lat = parseFloat(data.geo_lat);
|
||
if (isNaN(lat) || lat < -90 || lat > 90) {
|
||
errors.push(`geo_lat must be a number between -90 and 90, received: ${data.geo_lat}`);
|
||
} else {
|
||
converted.geo_lat = lat;
|
||
}
|
||
}
|
||
|
||
// geo_lon - must be DECIMAL (longitude -180 to 180)
|
||
if (data.geo_lon !== undefined && data.geo_lon !== null) {
|
||
const lon = parseFloat(data.geo_lon);
|
||
if (isNaN(lon) || lon < -180 || lon > 180) {
|
||
errors.push(`geo_lon must be a number between -180 and 180, received: ${data.geo_lon}`);
|
||
} else {
|
||
converted.geo_lon = lon;
|
||
}
|
||
}
|
||
|
||
// device_timestamp - must be BIGINT (Unix timestamp)
|
||
if (data.device_timestamp !== undefined && data.device_timestamp !== null) {
|
||
const timestamp = parseInt(data.device_timestamp);
|
||
if (isNaN(timestamp) || timestamp < 0) {
|
||
errors.push(`device_timestamp must be a positive integer (Unix timestamp), received: ${data.device_timestamp}`);
|
||
} else {
|
||
converted.device_timestamp = timestamp;
|
||
}
|
||
}
|
||
|
||
// confidence_level - must be DECIMAL between 0 and 1
|
||
if (data.confidence_level !== undefined && data.confidence_level !== null) {
|
||
const confidence = parseFloat(data.confidence_level);
|
||
if (isNaN(confidence) || confidence < 0 || confidence > 1) {
|
||
errors.push(`confidence_level must be a number between 0 and 1, received: ${data.confidence_level}`);
|
||
} else {
|
||
converted.confidence_level = confidence;
|
||
}
|
||
}
|
||
|
||
// signal_duration - must be INTEGER (milliseconds)
|
||
if (data.signal_duration !== undefined && data.signal_duration !== null) {
|
||
const duration = parseInt(data.signal_duration);
|
||
if (isNaN(duration) || duration < 0) {
|
||
errors.push(`signal_duration must be a non-negative integer, received: ${data.signal_duration}`);
|
||
} else {
|
||
converted.signal_duration = duration;
|
||
}
|
||
}
|
||
|
||
// Add server timestamp
|
||
converted.server_timestamp = new Date();
|
||
|
||
} catch (error) {
|
||
errors.push(`Data conversion error: ${error.message}`);
|
||
}
|
||
|
||
return { converted, errors };
|
||
};
|
||
|
||
// Validate and convert the detection data
|
||
const { converted: validatedData, errors: validationErrors } = validateAndConvertDetectionData(detectionData);
|
||
|
||
if (validationErrors.length > 0) {
|
||
console.error(`❌ Validation errors for device ${deviceIdString}:`, validationErrors);
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Validation error',
|
||
message: 'Detection data validation failed',
|
||
details: validationErrors,
|
||
device_id: deviceIdString,
|
||
drone_id: detectionData.drone_id
|
||
});
|
||
}
|
||
|
||
console.log(`🔍 Original data:`, detectionData);
|
||
console.log(`✅ Validated data:`, validatedData);
|
||
|
||
// Create detection record with validated data
|
||
const detectionRecord = validatedData;
|
||
|
||
// Add raw payload if debugging is enabled
|
||
if (DEBUG_CONFIG.storeRawPayload) {
|
||
detectionRecord.raw_payload = req.body;
|
||
if (DEBUG_CONFIG.logAllDetections) {
|
||
console.log(`🔍 Storing raw payload for debugging: ${JSON.stringify(req.body)}`);
|
||
}
|
||
}
|
||
|
||
// Create detection record with proper error handling
|
||
let detection;
|
||
try {
|
||
detection = await DroneDetection.create(detectionRecord);
|
||
console.log(`✅ Detection created successfully: ID ${detection.id}, Device ${deviceIdString}, Drone ${detectionData.drone_id}`);
|
||
} catch (error) {
|
||
console.error(`❌ Failed to create detection for device ${deviceIdString}, drone ${detectionData.drone_id}:`, error.message);
|
||
|
||
// Log to admin/management for monitoring
|
||
console.error(`🚨 ADMIN ALERT: Database error in detection creation - Device: ${deviceIdString}, DroneID: ${detectionData.drone_id}, Error: ${error.message}`);
|
||
|
||
// Check for specific database constraint errors
|
||
if (error.name === 'SequelizeValidationError') {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Validation error',
|
||
message: 'Detection data validation failed',
|
||
details: error.errors?.map(e => e.message) || ['Invalid data format'],
|
||
device_id: deviceIdString,
|
||
drone_id: detectionData.drone_id
|
||
});
|
||
}
|
||
|
||
if (error.name === 'SequelizeUniqueConstraintError') {
|
||
return res.status(409).json({
|
||
success: false,
|
||
error: 'Duplicate detection',
|
||
message: 'Detection with this combination already exists',
|
||
device_id: deviceIdString,
|
||
drone_id: detectionData.drone_id
|
||
});
|
||
}
|
||
|
||
if (error.name === 'SequelizeForeignKeyConstraintError') {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Reference error',
|
||
message: 'Invalid reference to related data',
|
||
device_id: deviceIdString,
|
||
drone_id: detectionData.drone_id
|
||
});
|
||
}
|
||
|
||
// Generic database error
|
||
return res.status(500).json({
|
||
success: false,
|
||
error: 'Database error',
|
||
message: 'Failed to store detection data',
|
||
device_id: deviceIdString,
|
||
drone_id: detectionData.drone_id,
|
||
logged_for_admin_review: true
|
||
});
|
||
}
|
||
|
||
// Process detection through tracking service for movement analysis (from original)
|
||
const movementAnalysis = droneTracker.processDetection({
|
||
...detectionData,
|
||
server_timestamp: detection.server_timestamp
|
||
});
|
||
|
||
// Emit real-time update via Socket.IO with movement analysis (from original)
|
||
// Skip real-time updates for debug detections (drone_type 0)
|
||
if (!isDebugDetection) {
|
||
const detectionPayload = {
|
||
id: detection.id,
|
||
device_id: detection.device_id,
|
||
drone_id: detection.drone_id,
|
||
drone_type: detection.drone_type,
|
||
rssi: detection.rssi,
|
||
freq: detection.freq,
|
||
geo_lat: detection.geo_lat,
|
||
geo_lon: detection.geo_lon,
|
||
server_timestamp: detection.server_timestamp,
|
||
confidence_level: detection.confidence_level,
|
||
signal_duration: detection.signal_duration,
|
||
movement_analysis: movementAnalysis,
|
||
device: {
|
||
id: device.id,
|
||
name: device.name,
|
||
geo_lat: device.geo_lat,
|
||
geo_lon: device.geo_lon
|
||
}
|
||
};
|
||
|
||
// 🔒 SECURITY: Emit only to the tenant's room to prevent cross-tenant data leakage
|
||
if (req.io && device.tenant_id) {
|
||
req.io.to(`tenant_${device.tenant_id}`).emit('drone_detection', detectionPayload);
|
||
console.log(`🔒 Detection emitted to tenant room: tenant_${device.tenant_id}`);
|
||
} else if (req.io) {
|
||
// Fallback for devices without tenant_id (legacy support)
|
||
console.warn(`⚠️ Device ${device.id} has no tenant_id - using global broadcast (security risk)`);
|
||
req.io.emit('drone_detection', detectionPayload);
|
||
} else {
|
||
console.warn(`⚠️ Socket.IO not available - detection will not be broadcast in real-time`);
|
||
}
|
||
|
||
// Process alerts asynchronously (from original)
|
||
alertService.processAlert(detection, req.io).catch(error => {
|
||
console.error('Alert processing error:', error);
|
||
});
|
||
|
||
console.log(`✅ Detection recorded and alert processing initiated for detection ${detection.id}`);
|
||
} else {
|
||
console.log(`🐛 Debug detection stored for device ${detection.device_id} (drone_type 0) - no alerts or real-time updates`);
|
||
}
|
||
|
||
return res.status(201).json({
|
||
success: true,
|
||
data: {
|
||
...detection.toJSON(),
|
||
movement_analysis: movementAnalysis
|
||
},
|
||
message: 'Drone detection recorded successfully'
|
||
});
|
||
}
|
||
|
||
// Helper function for string hashing (if key is not numeric)
|
||
String.prototype.hashCode = function() {
|
||
let hash = 0;
|
||
if (this.length === 0) return hash;
|
||
for (let i = 0; i < this.length; i++) {
|
||
const char = this.charCodeAt(i);
|
||
hash = ((hash << 5) - hash) + char;
|
||
hash = hash & hash; // Convert to 32bit integer
|
||
}
|
||
return Math.abs(hash);
|
||
};
|
||
|
||
module.exports = router;
|