486 lines
15 KiB
JavaScript
486 lines
15 KiB
JavaScript
const EventEmitter = require('events');
|
|
|
|
class DroneTrackingService extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this.droneHistory = new Map(); // Map of drone_id -> detection history
|
|
this.activeDrones = new Map(); // Map of drone_id -> current tracking data (for tests)
|
|
this.droneProximityAlerts = new Map(); // Map of drone_id -> current status
|
|
this.proximityThresholds = {
|
|
VERY_CLOSE: -40, // < -40dBm
|
|
CLOSE: -50, // -40 to -50dBm
|
|
MEDIUM: -60, // -50 to -60dBm
|
|
FAR: -70, // -60 to -70dBm
|
|
VERY_FAR: -80 // -70 to -80dBm
|
|
};
|
|
|
|
// Clean up old history every 30 minutes
|
|
setInterval(() => this.cleanupOldHistory(), 30 * 60 * 1000);
|
|
}
|
|
|
|
// Add method expected by tests
|
|
trackDetection(detection) {
|
|
const droneId = detection.drone_id;
|
|
|
|
// Update activeDrones map for test compatibility
|
|
const currentTracking = this.activeDrones.get(droneId) || {
|
|
droneId: droneId,
|
|
currentPosition: { lat: 0, lon: 0 },
|
|
lastSeen: null,
|
|
detectionHistory: []
|
|
};
|
|
|
|
// Update current position
|
|
if (detection.geo_lat && detection.geo_lon) {
|
|
currentTracking.currentPosition.lat = detection.geo_lat;
|
|
currentTracking.currentPosition.lon = detection.geo_lon;
|
|
}
|
|
|
|
currentTracking.lastSeen = new Date();
|
|
currentTracking.detectionHistory.push({
|
|
timestamp: new Date(),
|
|
rssi: detection.rssi,
|
|
position: { lat: detection.geo_lat, lon: detection.geo_lon }
|
|
});
|
|
|
|
// Keep only last 50 detections
|
|
if (currentTracking.detectionHistory.length > 50) {
|
|
currentTracking.detectionHistory.splice(0, currentTracking.detectionHistory.length - 50);
|
|
}
|
|
|
|
this.activeDrones.set(droneId, currentTracking);
|
|
|
|
// Also call the original processing method
|
|
return this.processDetection(detection);
|
|
}
|
|
|
|
processDetection(detection) {
|
|
const droneId = detection.drone_id;
|
|
const deviceId = detection.device_id;
|
|
const droneKey = `${droneId}_${deviceId}`;
|
|
|
|
// Get or create history for this drone-device pair
|
|
if (!this.droneHistory.has(droneKey)) {
|
|
this.droneHistory.set(droneKey, []);
|
|
}
|
|
|
|
const history = this.droneHistory.get(droneKey);
|
|
const now = Date.now();
|
|
|
|
// Add current detection to history
|
|
const detectionRecord = {
|
|
timestamp: now,
|
|
rssi: detection.rssi,
|
|
geo_lat: detection.geo_lat,
|
|
geo_lon: detection.geo_lon,
|
|
confidence: detection.confidence_level,
|
|
freq: detection.freq,
|
|
signal_duration: detection.signal_duration
|
|
};
|
|
|
|
history.push(detectionRecord);
|
|
|
|
// Keep only last 50 detections per drone-device pair
|
|
if (history.length > 50) {
|
|
history.splice(0, history.length - 50);
|
|
}
|
|
|
|
// Analyze movement and proximity trends
|
|
const analysis = this.analyzeMovementTrend(history, detection);
|
|
|
|
// Emit movement alerts if significant changes detected
|
|
if (analysis.alertLevel > 0) {
|
|
this.emit('movement_alert', {
|
|
droneId,
|
|
deviceId,
|
|
detection,
|
|
analysis,
|
|
history: history.slice(-5) // Last 5 detections for context
|
|
});
|
|
}
|
|
|
|
return analysis;
|
|
}
|
|
|
|
analyzeMovementTrend(history, currentDetection) {
|
|
if (history.length < 2) {
|
|
return this.createAnalysis('INITIAL', 0, 'First detection');
|
|
}
|
|
|
|
const recent = history.slice(-5); // Last 5 detections
|
|
const current = recent[recent.length - 1];
|
|
const previous = recent[recent.length - 2];
|
|
|
|
// Calculate RSSI trend
|
|
const rssiTrend = this.calculateRSSITrend(recent);
|
|
const proximityLevel = this.getProximityLevel(current.rssi);
|
|
const previousProximityLevel = this.getProximityLevel(previous.rssi);
|
|
|
|
// Calculate distance trend (if we have coordinates)
|
|
let distanceTrend = null;
|
|
if (recent.length >= 2 && this.hasValidCoordinates(recent)) {
|
|
distanceTrend = this.calculateDistanceTrend(recent);
|
|
}
|
|
|
|
// Calculate movement speed and direction
|
|
const movement = this.calculateMovementMetrics(recent);
|
|
|
|
// Determine alert level and type
|
|
let alertLevel = 0;
|
|
let alertType = 'MONITORING';
|
|
let description = 'Drone being tracked';
|
|
|
|
// High priority alerts
|
|
if (proximityLevel === 'VERY_CLOSE' && rssiTrend.trend === 'STRENGTHENING') {
|
|
alertLevel = 3;
|
|
alertType = 'CRITICAL_APPROACH';
|
|
description = `🚨 CRITICAL: Drone very close and approaching (${current.rssi}dBm)`;
|
|
} else if (proximityLevel === 'CLOSE' && rssiTrend.trend === 'STRENGTHENING') {
|
|
alertLevel = 2;
|
|
alertType = 'HIGH_APPROACH';
|
|
description = `⚠️ HIGH: Drone approaching detector (${current.rssi}dBm, trend: +${rssiTrend.change.toFixed(1)}dB)`;
|
|
} else if (rssiTrend.change > 10 && rssiTrend.trend === 'STRENGTHENING') {
|
|
alertLevel = 2;
|
|
alertType = 'RAPID_APPROACH';
|
|
description = `📈 RAPID: Drone rapidly approaching (+${rssiTrend.change.toFixed(1)}dB in ${rssiTrend.timeSpan}s)`;
|
|
}
|
|
|
|
// Medium priority alerts
|
|
else if (proximityLevel !== previousProximityLevel) {
|
|
alertLevel = 1;
|
|
alertType = 'PROXIMITY_CHANGE';
|
|
description = `📍 Drone moved from ${previousProximityLevel} to ${proximityLevel} range`;
|
|
} else if (Math.abs(rssiTrend.change) > 5) {
|
|
alertLevel = 1;
|
|
alertType = rssiTrend.trend === 'STRENGTHENING' ? 'APPROACHING' : 'DEPARTING';
|
|
description = `${rssiTrend.trend === 'STRENGTHENING' ? '🔴' : '🟢'} Drone ${rssiTrend.trend.toLowerCase()} (${rssiTrend.change > 0 ? '+' : ''}${rssiTrend.change.toFixed(1)}dB)`;
|
|
}
|
|
|
|
return this.createAnalysis(alertType, alertLevel, description, {
|
|
rssiTrend,
|
|
proximityLevel,
|
|
previousProximityLevel,
|
|
distanceTrend,
|
|
movement,
|
|
detectionCount: history.length,
|
|
timeTracked: (current.timestamp - history[0].timestamp) / 1000
|
|
});
|
|
}
|
|
|
|
calculateRSSITrend(detections) {
|
|
if (detections.length < 2) return { trend: 'STABLE', change: 0, timeSpan: 0 };
|
|
|
|
const latest = detections[detections.length - 1];
|
|
const oldest = detections[0];
|
|
const change = latest.rssi - oldest.rssi;
|
|
const timeSpan = (latest.timestamp - oldest.timestamp) / 1000;
|
|
|
|
let trend = 'STABLE';
|
|
if (change > 2) trend = 'STRENGTHENING';
|
|
else if (change < -2) trend = 'WEAKENING';
|
|
|
|
return { trend, change, timeSpan, rate: change / Math.max(timeSpan, 1) };
|
|
}
|
|
|
|
calculateDistanceTrend(detections) {
|
|
if (detections.length < 2) return null;
|
|
|
|
const distances = detections.map((d, i) => {
|
|
if (i === 0) return 0;
|
|
return this.calculateDistance(
|
|
detections[i-1].geo_lat, detections[i-1].geo_lon,
|
|
d.geo_lat, d.geo_lon
|
|
);
|
|
}).filter(d => d > 0);
|
|
|
|
if (distances.length === 0) return null;
|
|
|
|
const totalDistance = distances.reduce((sum, d) => sum + d, 0);
|
|
const avgSpeed = totalDistance / ((detections[detections.length - 1].timestamp - detections[0].timestamp) / 1000);
|
|
|
|
return { totalDistance, avgSpeed, movementPoints: distances.length };
|
|
}
|
|
|
|
calculateMovementMetrics(detections) {
|
|
if (detections.length < 3) return { speed: 0, direction: null, pattern: 'INSUFFICIENT_DATA' };
|
|
|
|
const movements = [];
|
|
for (let i = 1; i < detections.length; i++) {
|
|
const prev = detections[i - 1];
|
|
const curr = detections[i];
|
|
const timeDiff = (curr.timestamp - prev.timestamp) / 1000;
|
|
|
|
if (timeDiff > 0 && this.hasValidCoordinates([prev, curr])) {
|
|
const distance = this.calculateDistance(prev.geo_lat, prev.geo_lon, curr.geo_lat, curr.geo_lon);
|
|
const speed = (distance * 1000) / timeDiff; // m/s
|
|
movements.push({ distance, speed, timeDiff });
|
|
}
|
|
}
|
|
|
|
if (movements.length === 0) return { speed: 0, direction: null, pattern: 'STATIONARY' };
|
|
|
|
const avgSpeed = movements.reduce((sum, m) => sum + m.speed, 0) / movements.length;
|
|
const totalDistance = movements.reduce((sum, m) => sum + m.distance, 0);
|
|
|
|
// Determine movement pattern
|
|
let pattern = 'MOVING';
|
|
if (avgSpeed < 1) pattern = 'HOVERING';
|
|
else if (avgSpeed > 10) pattern = 'FAST_MOVING';
|
|
else if (totalDistance < 0.1) pattern = 'CIRCLING';
|
|
|
|
return { speed: avgSpeed, totalDistance, pattern, movements: movements.length };
|
|
}
|
|
|
|
getProximityLevel(rssi) {
|
|
if (rssi >= this.proximityThresholds.VERY_CLOSE) return 'VERY_CLOSE';
|
|
if (rssi >= this.proximityThresholds.CLOSE) return 'CLOSE';
|
|
if (rssi >= this.proximityThresholds.MEDIUM) return 'MEDIUM';
|
|
if (rssi >= this.proximityThresholds.FAR) return 'FAR';
|
|
return 'VERY_FAR';
|
|
}
|
|
|
|
calculateDistance(lat1, lon1, lat2, lon2) {
|
|
const R = 6371; // Earth radius in km
|
|
const dLat = this.toRad(lat2 - lat1);
|
|
const dLon = this.toRad(lon2 - lon1);
|
|
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
|
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) *
|
|
Math.sin(dLon/2) * Math.sin(dLon/2);
|
|
return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)) * R * 1000; // Return in meters
|
|
}
|
|
|
|
toRad(deg) {
|
|
return deg * (Math.PI/180);
|
|
}
|
|
|
|
hasValidCoordinates(detections) {
|
|
return detections.every(d => d.geo_lat && d.geo_lon &&
|
|
Math.abs(d.geo_lat) <= 90 &&
|
|
Math.abs(d.geo_lon) <= 180);
|
|
}
|
|
|
|
createAnalysis(alertType, alertLevel, description, details = {}) {
|
|
return {
|
|
alertType,
|
|
alertLevel,
|
|
description,
|
|
timestamp: Date.now(),
|
|
...details
|
|
};
|
|
}
|
|
|
|
cleanupOldHistory() {
|
|
const cutoffTime = Date.now() - (2 * 60 * 60 * 1000); // 2 hours ago
|
|
|
|
for (const [key, history] of this.droneHistory.entries()) {
|
|
const filtered = history.filter(record => record.timestamp > cutoffTime);
|
|
if (filtered.length === 0) {
|
|
this.droneHistory.delete(key);
|
|
} else {
|
|
this.droneHistory.set(key, filtered);
|
|
}
|
|
}
|
|
}
|
|
|
|
getDroneStatus(droneId, deviceId) {
|
|
const droneKey = `${droneId}_${deviceId}`;
|
|
const history = this.droneHistory.get(droneKey) || [];
|
|
|
|
if (history.length === 0) return null;
|
|
|
|
const latest = history[history.length - 1];
|
|
const analysis = this.analyzeMovementTrend(history, { rssi: latest.rssi });
|
|
|
|
return {
|
|
droneId,
|
|
deviceId,
|
|
lastSeen: latest.timestamp,
|
|
currentRSSI: latest.rssi,
|
|
proximityLevel: this.getProximityLevel(latest.rssi),
|
|
detectionCount: history.length,
|
|
analysis,
|
|
recentHistory: history.slice(-10)
|
|
};
|
|
}
|
|
|
|
getAllActiveTracking() {
|
|
const activeTracking = [];
|
|
const fiveMinutesAgo = Date.now() - (5 * 60 * 1000);
|
|
|
|
for (const [key, history] of this.droneHistory.entries()) {
|
|
const latest = history[history.length - 1];
|
|
if (latest.timestamp > fiveMinutesAgo) {
|
|
const [droneId, deviceId] = key.split('_');
|
|
activeTracking.push(this.getDroneStatus(droneId, deviceId));
|
|
}
|
|
}
|
|
|
|
return activeTracking;
|
|
}
|
|
|
|
/**
|
|
* Clear all tracking data
|
|
*/
|
|
clear() {
|
|
this.droneHistory.clear();
|
|
this.activeDrones.clear();
|
|
this.droneProximityAlerts.clear();
|
|
}
|
|
|
|
/**
|
|
* Analyze movement patterns from position data
|
|
*/
|
|
analyzeMovement(positions) {
|
|
if (positions.length < 2) {
|
|
return { pattern: 'insufficient_data', speed: 0, bearing: null };
|
|
}
|
|
|
|
const speeds = [];
|
|
const bearings = [];
|
|
|
|
for (let i = 1; i < positions.length; i++) {
|
|
const prev = positions[i - 1];
|
|
const curr = positions[i];
|
|
|
|
const distance = this.calculateDistance(prev.lat, prev.lon, curr.lat, curr.lon);
|
|
const timeDiff = (curr.timestamp - prev.timestamp) / 1000; // seconds
|
|
const speed = timeDiff > 0 ? distance / timeDiff : 0;
|
|
|
|
speeds.push(speed);
|
|
bearings.push(this.calculateBearing(prev.lat, prev.lon, curr.lat, curr.lon));
|
|
}
|
|
|
|
const avgSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
|
|
|
|
// Detect circular patterns
|
|
const bearingVariance = this.calculateBearingVariance(bearings);
|
|
let pattern = 'linear';
|
|
if (bearingVariance > 180) {
|
|
pattern = 'circular';
|
|
} else if (avgSpeed < 1) {
|
|
pattern = 'hovering';
|
|
}
|
|
|
|
return {
|
|
pattern,
|
|
speed: avgSpeed,
|
|
bearing: bearings[bearings.length - 1],
|
|
avgSpeed
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clean up old tracking data
|
|
*/
|
|
cleanupOldTracks() {
|
|
const cutoffTime = Date.now() - (24 * 60 * 60 * 1000); // 24 hours ago
|
|
|
|
for (const [droneId, data] of this.activeDrones.entries()) {
|
|
if (data.lastSeen && data.lastSeen.getTime() < cutoffTime) {
|
|
this.activeDrones.delete(droneId);
|
|
}
|
|
}
|
|
|
|
for (const [key, history] of this.droneHistory.entries()) {
|
|
// Remove old detections from history
|
|
const filteredHistory = history.filter(detection => detection.timestamp > cutoffTime);
|
|
if (filteredHistory.length === 0) {
|
|
this.droneHistory.delete(key);
|
|
} else {
|
|
this.droneHistory.set(key, filteredHistory);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all active tracking data
|
|
*/
|
|
getActiveTracking() {
|
|
const result = [];
|
|
for (const [droneId, data] of this.activeDrones.entries()) {
|
|
result.push({
|
|
droneId: parseInt(droneId),
|
|
...data,
|
|
movement: this.analyzeMovement(data.detectionHistory || [])
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get detection history for a specific drone
|
|
*/
|
|
getDroneHistory(droneId) {
|
|
const data = this.activeDrones.get(droneId);
|
|
return data ? data.detectionHistory : null;
|
|
}
|
|
|
|
/**
|
|
* Calculate bearing between two points
|
|
*/
|
|
calculateBearing(lat1, lon1, lat2, lon2) {
|
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
const lat1Rad = lat1 * Math.PI / 180;
|
|
const lat2Rad = lat2 * Math.PI / 180;
|
|
|
|
const y = Math.sin(dLon) * Math.cos(lat2Rad);
|
|
const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) - Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon);
|
|
|
|
const bearing = Math.atan2(y, x) * 180 / Math.PI;
|
|
return (bearing + 360) % 360;
|
|
}
|
|
|
|
/**
|
|
* Calculate speed between two positions
|
|
*/
|
|
calculateSpeed(pos1, pos2) {
|
|
const distance = this.calculateDistance(pos1.lat, pos1.lon, pos2.lat, pos2.lon);
|
|
const timeDiff = (pos2.timestamp - pos1.timestamp) / 1000; // seconds
|
|
return timeDiff > 0 ? distance / timeDiff : 0;
|
|
}
|
|
|
|
/**
|
|
* Calculate bearing variance for pattern detection
|
|
*/
|
|
calculateBearingVariance(bearings) {
|
|
if (bearings.length < 2) return 0;
|
|
|
|
let totalVariance = 0;
|
|
for (let i = 1; i < bearings.length; i++) {
|
|
let diff = Math.abs(bearings[i] - bearings[i-1]);
|
|
if (diff > 180) diff = 360 - diff; // Handle wraparound
|
|
totalVariance += diff;
|
|
}
|
|
|
|
return totalVariance / (bearings.length - 1);
|
|
}
|
|
|
|
/**
|
|
* Cleanup method for tests - can accept a cutoff time parameter
|
|
*/
|
|
cleanup(cutoffTime = null) {
|
|
if (cutoffTime) {
|
|
// Use provided cutoff time
|
|
for (const [droneId, data] of this.activeDrones.entries()) {
|
|
if (data.lastSeen && data.lastSeen.getTime() < cutoffTime) {
|
|
this.activeDrones.delete(droneId);
|
|
}
|
|
}
|
|
|
|
for (const [key, history] of this.droneHistory.entries()) {
|
|
const filteredHistory = history.filter(detection => detection.timestamp > cutoffTime);
|
|
if (filteredHistory.length === 0) {
|
|
this.droneHistory.delete(key);
|
|
} else {
|
|
this.droneHistory.set(key, filteredHistory);
|
|
}
|
|
}
|
|
} else {
|
|
// Default cleanup behavior
|
|
this.cleanupOldTracks();
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = DroneTrackingService;
|