mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
feat: Add cross-mode analytics dashboard with geofencing, correlations, and data export
Adds a unified analytics mode under the Security nav group that aggregates data across all signal modes. Includes emergency squawk alerting (7700/7600/7500), vertical rate anomaly detection, ACARS/VDL2-to-ADS-B flight correlation, geofence zones with enter/exit detection for aircraft/vessels/APRS stations, temporal pattern detection, RSSI history tracking, Meshtastic topology mapping, and JSON/CSV data export. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ def register_blueprints(app):
|
||||
from .recordings import recordings_bp
|
||||
from .subghz import subghz_bp
|
||||
from .bt_locate import bt_locate_bp
|
||||
from .analytics import analytics_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -69,6 +70,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(recordings_bp) # Session recordings
|
||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||
app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
@@ -129,6 +129,13 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
|
||||
app_module.acars_queue.put(data)
|
||||
|
||||
# Feed flight correlator
|
||||
try:
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
get_flight_correlator().add_acars_message(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
|
||||
@@ -439,6 +439,12 @@ def parse_sbs_stream(service_addr):
|
||||
if parts[16]:
|
||||
try:
|
||||
aircraft['vertical_rate'] = int(float(parts[16]))
|
||||
if abs(aircraft['vertical_rate']) > 4000:
|
||||
process_event('adsb', {
|
||||
'type': 'vertical_rate_anomaly', 'icao': icao,
|
||||
'callsign': aircraft.get('callsign', ''),
|
||||
'vertical_rate': aircraft['vertical_rate'],
|
||||
}, 'vertical_rate_anomaly')
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -456,6 +462,14 @@ def parse_sbs_stream(service_addr):
|
||||
elif msg_type == '6' and len(parts) > 17:
|
||||
if parts[17]:
|
||||
aircraft['squawk'] = parts[17]
|
||||
sq = parts[17].strip()
|
||||
_EMERGENCY_SQUAWKS = {'7700': 'General Emergency', '7600': 'Comms Failure', '7500': 'Hijack'}
|
||||
if sq in _EMERGENCY_SQUAWKS:
|
||||
process_event('adsb', {
|
||||
'type': 'squawk_emergency', 'icao': icao,
|
||||
'callsign': aircraft.get('callsign', ''),
|
||||
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
|
||||
}, 'squawk_emergency')
|
||||
|
||||
app_module.adsb_aircraft.set(icao, aircraft)
|
||||
pending_updates.add(icao)
|
||||
@@ -488,6 +502,19 @@ def parse_sbs_stream(service_addr):
|
||||
'source_host': service_addr,
|
||||
'snapshot': snapshot,
|
||||
})
|
||||
# Geofence check
|
||||
_gf_lat = snapshot.get('lat')
|
||||
_gf_lon = snapshot.get('lon')
|
||||
if _gf_lat and _gf_lon:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
update_icao, 'aircraft', _gf_lat, _gf_lon,
|
||||
{'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')}
|
||||
):
|
||||
process_event('adsb', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
@@ -1103,3 +1130,17 @@ def aircraft_photo(registration: str):
|
||||
except Exception as e:
|
||||
logger.debug(f"Error fetching aircraft photo: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft/<icao>/messages')
|
||||
def get_aircraft_messages(icao: str):
|
||||
"""Get correlated ACARS/VDL2 messages for an aircraft."""
|
||||
if not icao or not all(c in '0123456789ABCDEFabcdef' for c in icao):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid ICAO'}), 400
|
||||
|
||||
aircraft = app_module.adsb_aircraft.get(icao.upper())
|
||||
callsign = aircraft.get('callsign') if aircraft else None
|
||||
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
messages = get_flight_correlator().get_messages_for_aircraft(icao=icao.upper(), callsign=callsign)
|
||||
return jsonify({'status': 'success', 'icao': icao.upper(), **messages})
|
||||
|
||||
+42
-1
@@ -124,13 +124,27 @@ def parse_ais_stream(port: int):
|
||||
if now - last_update >= AIS_UPDATE_INTERVAL:
|
||||
for mmsi in pending_updates:
|
||||
if mmsi in app_module.ais_vessels:
|
||||
_vessel_snap = app_module.ais_vessels[mmsi]
|
||||
try:
|
||||
app_module.ais_queue.put_nowait({
|
||||
'type': 'vessel',
|
||||
**app_module.ais_vessels[mmsi]
|
||||
**_vessel_snap
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
# Geofence check
|
||||
_v_lat = _vessel_snap.get('lat')
|
||||
_v_lon = _vessel_snap.get('lon')
|
||||
if _v_lat and _v_lon:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
mmsi, 'vessel', _v_lat, _v_lon,
|
||||
{'name': _vessel_snap.get('name'), 'ship_type': _vessel_snap.get('ship_type_text')}
|
||||
):
|
||||
process_event('ais', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
@@ -282,6 +296,16 @@ def process_ais_message(msg: dict) -> dict | None:
|
||||
# Timestamp
|
||||
vessel['last_seen'] = time.time()
|
||||
|
||||
# Check for DSC DISTRESS matching this MMSI
|
||||
try:
|
||||
for _dsc_key, _dsc_msg in app_module.dsc_messages.items():
|
||||
if (str(_dsc_msg.get('source_mmsi', '')) == mmsi
|
||||
and _dsc_msg.get('category', '').upper() == 'DISTRESS'):
|
||||
vessel['dsc_distress'] = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return vessel
|
||||
|
||||
|
||||
@@ -502,6 +526,23 @@ def stream_ais():
|
||||
return response
|
||||
|
||||
|
||||
@ais_bp.route('/vessel/<mmsi>/dsc')
|
||||
def get_vessel_dsc(mmsi: str):
|
||||
"""Get DSC messages associated with a vessel MMSI."""
|
||||
if not mmsi or not mmsi.isdigit():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid MMSI'}), 400
|
||||
|
||||
matches = []
|
||||
try:
|
||||
for key, msg in app_module.dsc_messages.items():
|
||||
if str(msg.get('source_mmsi', '')) == mmsi:
|
||||
matches.append(dict(msg))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches})
|
||||
|
||||
|
||||
@ais_bp.route('/dashboard')
|
||||
def ais_dashboard():
|
||||
"""Popout AIS dashboard."""
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Analytics dashboard: cross-mode summary, activity sparklines, export, geofence CRUD."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request
|
||||
|
||||
import app as app_module
|
||||
from utils.analytics import (
|
||||
get_activity_tracker,
|
||||
get_cross_mode_summary,
|
||||
get_emergency_squawks,
|
||||
get_mode_health,
|
||||
)
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
from utils.geofence import get_geofence_manager
|
||||
from utils.temporal_patterns import get_pattern_detector
|
||||
|
||||
analytics_bp = Blueprint('analytics', __name__, url_prefix='/analytics')
|
||||
|
||||
|
||||
# Map mode names to DataStore attribute(s)
|
||||
MODE_STORES: dict[str, list[str]] = {
|
||||
'adsb': ['adsb_aircraft'],
|
||||
'ais': ['ais_vessels'],
|
||||
'wifi': ['wifi_networks', 'wifi_clients'],
|
||||
'bluetooth': ['bt_devices'],
|
||||
'dsc': ['dsc_messages'],
|
||||
}
|
||||
|
||||
|
||||
@analytics_bp.route('/summary')
|
||||
def analytics_summary():
|
||||
"""Return cross-mode counts, health, and emergency squawks."""
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'counts': get_cross_mode_summary(),
|
||||
'health': get_mode_health(),
|
||||
'squawks': get_emergency_squawks(),
|
||||
'flight_messages': {
|
||||
'acars': get_flight_correlator().acars_count,
|
||||
'vdl2': get_flight_correlator().vdl2_count,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@analytics_bp.route('/activity')
|
||||
def analytics_activity():
|
||||
"""Return sparkline arrays for each mode."""
|
||||
tracker = get_activity_tracker()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'sparklines': tracker.get_all_sparklines(),
|
||||
})
|
||||
|
||||
|
||||
@analytics_bp.route('/squawks')
|
||||
def analytics_squawks():
|
||||
"""Return current emergency squawk codes from ADS-B."""
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'squawks': get_emergency_squawks(),
|
||||
})
|
||||
|
||||
|
||||
@analytics_bp.route('/patterns')
|
||||
def analytics_patterns():
|
||||
"""Return detected temporal patterns."""
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'patterns': get_pattern_detector().get_all_patterns(),
|
||||
})
|
||||
|
||||
|
||||
@analytics_bp.route('/export/<mode>')
|
||||
def analytics_export(mode: str):
|
||||
"""Export current DataStore contents as JSON or CSV."""
|
||||
fmt = request.args.get('format', 'json').lower()
|
||||
|
||||
if mode == 'sensor':
|
||||
# Sensor doesn't use DataStore; return recent queue-based data
|
||||
return jsonify({'status': 'success', 'data': [], 'message': 'Sensor data is stream-only'})
|
||||
|
||||
store_names = MODE_STORES.get(mode)
|
||||
if not store_names:
|
||||
return jsonify({'status': 'error', 'message': f'Unknown mode: {mode}'}), 400
|
||||
|
||||
all_items: list[dict] = []
|
||||
for store_name in store_names:
|
||||
store = getattr(app_module, store_name, None)
|
||||
if store is None:
|
||||
continue
|
||||
for key, value in store.items():
|
||||
item = dict(value) if isinstance(value, dict) else {'id': key, 'value': value}
|
||||
item.setdefault('_store', store_name)
|
||||
all_items.append(item)
|
||||
|
||||
if fmt == 'csv':
|
||||
if not all_items:
|
||||
output = ''
|
||||
else:
|
||||
# Collect all keys across items
|
||||
fieldnames: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in all_items:
|
||||
for k in item:
|
||||
if k not in seen:
|
||||
fieldnames.append(k)
|
||||
seen.add(k)
|
||||
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
for item in all_items:
|
||||
# Serialize non-scalar values
|
||||
row = {}
|
||||
for k in fieldnames:
|
||||
v = item.get(k)
|
||||
if isinstance(v, (dict, list)):
|
||||
row[k] = json.dumps(v)
|
||||
else:
|
||||
row[k] = v
|
||||
writer.writerow(row)
|
||||
output = buf.getvalue()
|
||||
|
||||
response = Response(output, mimetype='text/csv')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename={mode}_export.csv'
|
||||
return response
|
||||
|
||||
# Default: JSON
|
||||
return jsonify({'status': 'success', 'mode': mode, 'count': len(all_items), 'data': all_items})
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Geofence CRUD
|
||||
# =========================================================================
|
||||
|
||||
@analytics_bp.route('/geofences')
|
||||
def list_geofences():
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'zones': get_geofence_manager().list_zones(),
|
||||
})
|
||||
|
||||
|
||||
@analytics_bp.route('/geofences', methods=['POST'])
|
||||
def create_geofence():
|
||||
data = request.get_json() or {}
|
||||
name = data.get('name')
|
||||
lat = data.get('lat')
|
||||
lon = data.get('lon')
|
||||
radius_m = data.get('radius_m')
|
||||
|
||||
if not all([name, lat is not None, lon is not None, radius_m is not None]):
|
||||
return jsonify({'status': 'error', 'message': 'name, lat, lon, radius_m are required'}), 400
|
||||
|
||||
try:
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
radius_m = float(radius_m)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'status': 'error', 'message': 'lat, lon, radius_m must be numbers'}), 400
|
||||
|
||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
||||
if radius_m <= 0:
|
||||
return jsonify({'status': 'error', 'message': 'radius_m must be positive'}), 400
|
||||
|
||||
alert_on = data.get('alert_on', 'enter_exit')
|
||||
zone_id = get_geofence_manager().add_zone(name, lat, lon, radius_m, alert_on)
|
||||
return jsonify({'status': 'success', 'zone_id': zone_id})
|
||||
|
||||
|
||||
@analytics_bp.route('/geofences/<int:zone_id>', methods=['DELETE'])
|
||||
def delete_geofence(zone_id: int):
|
||||
ok = get_geofence_manager().delete_zone(zone_id)
|
||||
if not ok:
|
||||
return jsonify({'status': 'error', 'message': 'Zone not found'}), 404
|
||||
return jsonify({'status': 'success'})
|
||||
+114
-101
@@ -19,16 +19,16 @@ from typing import Generator, Optional
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
|
||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||
@@ -73,19 +73,19 @@ def find_multimon_ng() -> Optional[str]:
|
||||
return shutil.which('multimon-ng')
|
||||
|
||||
|
||||
def find_rtl_fm() -> Optional[str]:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> Optional[str]:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_rtl_power() -> Optional[str]:
|
||||
"""Find rtl_power binary for spectrum scanning."""
|
||||
return shutil.which('rtl_power')
|
||||
def find_rtl_fm() -> Optional[str]:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> Optional[str]:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_rtl_power() -> Optional[str]:
|
||||
"""Find rtl_power binary for spectrum scanning."""
|
||||
return shutil.which('rtl_power')
|
||||
|
||||
|
||||
# Path to direwolf config file
|
||||
@@ -1378,6 +1378,19 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'packet_type': packet.get('packet_type'),
|
||||
}
|
||||
# Geofence check
|
||||
_aprs_lat = packet.get('lat')
|
||||
_aprs_lon = packet.get('lon')
|
||||
if _aprs_lat and _aprs_lon:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
|
||||
{'callsign': callsign}
|
||||
):
|
||||
process_event('aprs', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
# Evict oldest stations when limit is exceeded
|
||||
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||
oldest = min(
|
||||
@@ -1420,22 +1433,22 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
|
||||
|
||||
@aprs_bp.route('/tools')
|
||||
def check_aprs_tools() -> Response:
|
||||
"""Check for APRS decoding tools."""
|
||||
has_rtl_fm = find_rtl_fm() is not None
|
||||
has_rx_fm = find_rx_fm() is not None
|
||||
has_direwolf = find_direwolf() is not None
|
||||
has_multimon = find_multimon_ng() is not None
|
||||
has_fm_demod = has_rtl_fm or has_rx_fm
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': has_rtl_fm,
|
||||
'rx_fm': has_rx_fm,
|
||||
'direwolf': has_direwolf,
|
||||
'multimon_ng': has_multimon,
|
||||
'ready': has_fm_demod and (has_direwolf or has_multimon),
|
||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||
})
|
||||
def check_aprs_tools() -> Response:
|
||||
"""Check for APRS decoding tools."""
|
||||
has_rtl_fm = find_rtl_fm() is not None
|
||||
has_rx_fm = find_rx_fm() is not None
|
||||
has_direwolf = find_direwolf() is not None
|
||||
has_multimon = find_multimon_ng() is not None
|
||||
has_fm_demod = has_rtl_fm or has_rx_fm
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': has_rtl_fm,
|
||||
'rx_fm': has_rx_fm,
|
||||
'direwolf': has_direwolf,
|
||||
'multimon_ng': has_multimon,
|
||||
'ready': has_fm_demod and (has_direwolf or has_multimon),
|
||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||
})
|
||||
|
||||
|
||||
@aprs_bp.route('/status')
|
||||
@@ -1476,12 +1489,12 @@ def start_aprs() -> Response:
|
||||
'message': 'APRS decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||
direwolf_path = find_direwolf()
|
||||
multimon_path = find_multimon_ng()
|
||||
|
||||
if not direwolf_path and not multimon_path:
|
||||
return jsonify({
|
||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||
direwolf_path = find_direwolf()
|
||||
multimon_path = find_multimon_ng()
|
||||
|
||||
if not direwolf_path and not multimon_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
||||
}), 400
|
||||
@@ -1489,31 +1502,31 @@ def start_aprs() -> Response:
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if find_rtl_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
else:
|
||||
if find_rx_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 400
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if find_rtl_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
else:
|
||||
if find_rx_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 400
|
||||
|
||||
# Reserve SDR device to prevent conflicts with other modes
|
||||
error = app_module.claim_sdr_device(device, 'aprs')
|
||||
@@ -1545,29 +1558,29 @@ def start_aprs() -> Response:
|
||||
aprs_last_packet_time = None
|
||||
aprs_stations = {}
|
||||
|
||||
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||
try:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=float(frequency),
|
||||
sample_rate=22050,
|
||||
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
|
||||
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
|
||||
squelch=None,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
|
||||
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||
except Exception as e:
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||
try:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=float(frequency),
|
||||
sample_rate=22050,
|
||||
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
|
||||
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
|
||||
squelch=None,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
|
||||
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||
except Exception as e:
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
|
||||
# Build decoder command
|
||||
if direwolf_path:
|
||||
@@ -1690,14 +1703,14 @@ def start_aprs() -> Response:
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'region': region,
|
||||
'device': device,
|
||||
'sdr_type': sdr_type.value,
|
||||
'decoder': decoder_name
|
||||
})
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'region': region,
|
||||
'device': device,
|
||||
'sdr_type': sdr_type.value,
|
||||
'decoder': decoder_name
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start APRS decoder: {e}")
|
||||
|
||||
@@ -1051,3 +1051,19 @@ def request_store_forward():
|
||||
'status': 'error',
|
||||
'message': error or 'Failed to request S&F history'
|
||||
}), 500
|
||||
|
||||
|
||||
@meshtastic_bp.route('/topology')
|
||||
def mesh_topology():
|
||||
"""Return mesh network topology graph."""
|
||||
if not is_meshtastic_available():
|
||||
return jsonify({'status': 'error', 'message': 'Meshtastic SDK not installed'}), 400
|
||||
|
||||
client = get_meshtastic_client()
|
||||
if not client or not client.is_running:
|
||||
return jsonify({'status': 'error', 'message': 'Not connected'}), 400
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'topology': client.get_topology(),
|
||||
})
|
||||
|
||||
@@ -28,6 +28,10 @@ sensor_bp = Blueprint('sensor', __name__)
|
||||
# Track which device is being used
|
||||
sensor_active_device: int | None = None
|
||||
|
||||
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
||||
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
||||
_MAX_RSSI_HISTORY = 60
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
@@ -45,6 +49,17 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Track RSSI history per device
|
||||
_model = data.get('model', '')
|
||||
_dev_id = data.get('id', '')
|
||||
_rssi_val = data.get('rssi')
|
||||
if _rssi_val is not None and _model:
|
||||
_hist_key = f"{_model}_{_dev_id}"
|
||||
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||
hist.append((time.time(), float(_rssi_val)))
|
||||
if len(hist) > _MAX_RSSI_HISTORY:
|
||||
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||
|
||||
# Push scope event when signal level data is present
|
||||
rssi = data.get('rssi')
|
||||
snr = data.get('snr')
|
||||
@@ -283,3 +298,12 @@ def stream_sensor() -> Response:
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/rssi_history')
|
||||
def get_rssi_history() -> Response:
|
||||
"""Return RSSI history for all tracked sensor devices."""
|
||||
result = {}
|
||||
for key, entries in sensor_rssi_history.items():
|
||||
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||
return jsonify({'status': 'success', 'devices': result})
|
||||
|
||||
@@ -76,6 +76,13 @@ def stream_vdl2_output(process: subprocess.Popen) -> None:
|
||||
|
||||
app_module.vdl2_queue.put(data)
|
||||
|
||||
# Feed flight correlator
|
||||
try:
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
get_flight_correlator().add_vdl2_message(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/* Analytics Dashboard Styles */
|
||||
|
||||
.analytics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--space-3, 12px);
|
||||
margin-bottom: var(--space-4, 16px);
|
||||
}
|
||||
|
||||
.analytics-card {
|
||||
background: var(--bg-card, #151f2b);
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--space-3, 12px);
|
||||
text-align: center;
|
||||
transition: var(--transition-fast, 150ms ease);
|
||||
}
|
||||
|
||||
.analytics-card:hover {
|
||||
border-color: var(--accent-cyan, #4aa3ff);
|
||||
}
|
||||
|
||||
.analytics-card .card-count {
|
||||
font-size: var(--text-2xl, 24px);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.analytics-card .card-label {
|
||||
font-size: var(--text-xs, 10px);
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: var(--space-1, 4px);
|
||||
}
|
||||
|
||||
.analytics-card .card-sparkline {
|
||||
height: 24px;
|
||||
margin-top: var(--space-2, 8px);
|
||||
}
|
||||
|
||||
.analytics-card .card-sparkline svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.analytics-card .card-sparkline polyline {
|
||||
fill: none;
|
||||
stroke: var(--accent-cyan, #4aa3ff);
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Health indicators */
|
||||
.analytics-health {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2, 8px);
|
||||
margin-bottom: var(--space-4, 16px);
|
||||
}
|
||||
|
||||
.health-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1, 4px);
|
||||
font-size: var(--text-xs, 10px);
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.health-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-red, #e25d5d);
|
||||
}
|
||||
|
||||
.health-dot.running {
|
||||
background: var(--accent-green, #38c180);
|
||||
}
|
||||
|
||||
/* Emergency squawk panel */
|
||||
.squawk-emergency {
|
||||
background: rgba(226, 93, 93, 0.1);
|
||||
border: 1px solid var(--accent-red, #e25d5d);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--space-3, 12px);
|
||||
margin-bottom: var(--space-3, 12px);
|
||||
}
|
||||
|
||||
.squawk-emergency .squawk-title {
|
||||
color: var(--accent-red, #e25d5d);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-sm, 12px);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: var(--space-2, 8px);
|
||||
}
|
||||
|
||||
.squawk-emergency .squawk-item {
|
||||
font-size: var(--text-sm, 12px);
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
padding: var(--space-1, 4px) 0;
|
||||
border-bottom: 1px solid rgba(226, 93, 93, 0.2);
|
||||
}
|
||||
|
||||
.squawk-emergency .squawk-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Alert feed */
|
||||
.analytics-alert-feed {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: var(--space-4, 16px);
|
||||
}
|
||||
|
||||
.analytics-alert-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2, 8px);
|
||||
padding: var(--space-2, 8px);
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
font-size: var(--text-xs, 10px);
|
||||
}
|
||||
|
||||
.analytics-alert-item .alert-severity {
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alert-severity.critical { background: var(--accent-red, #e25d5d); color: #fff; }
|
||||
.alert-severity.high { background: var(--accent-orange, #d6a85e); color: #000; }
|
||||
.alert-severity.medium { background: var(--accent-cyan, #4aa3ff); color: #fff; }
|
||||
.alert-severity.low { background: var(--border-color, #1e2d3d); color: var(--text-dim, #5a6a7a); }
|
||||
|
||||
/* Correlation panel */
|
||||
.analytics-correlation-pair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2, 8px);
|
||||
padding: var(--space-2, 8px);
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
font-size: var(--text-xs, 10px);
|
||||
}
|
||||
|
||||
.analytics-correlation-pair .confidence-bar {
|
||||
height: 4px;
|
||||
background: var(--bg-secondary, #101823);
|
||||
border-radius: 2px;
|
||||
flex: 1;
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
.analytics-correlation-pair .confidence-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-green, #38c180);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Geofence zone list */
|
||||
.geofence-zone-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2, 8px);
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
font-size: var(--text-xs, 10px);
|
||||
}
|
||||
|
||||
.geofence-zone-item .zone-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
}
|
||||
|
||||
.geofence-zone-item .zone-radius {
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
}
|
||||
|
||||
.geofence-zone-item .zone-delete {
|
||||
cursor: pointer;
|
||||
color: var(--accent-red, #e25d5d);
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--accent-red, #e25d5d);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: transparent;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* Export controls */
|
||||
.export-controls {
|
||||
display: flex;
|
||||
gap: var(--space-2, 8px);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.export-controls select,
|
||||
.export-controls button {
|
||||
font-size: var(--text-xs, 10px);
|
||||
padding: var(--space-1, 4px) var(--space-2, 8px);
|
||||
background: var(--bg-card, #151f2b);
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.export-controls button {
|
||||
cursor: pointer;
|
||||
background: var(--accent-cyan, #4aa3ff);
|
||||
color: #fff;
|
||||
border-color: var(--accent-cyan, #4aa3ff);
|
||||
}
|
||||
|
||||
.export-controls button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Section headers */
|
||||
.analytics-section-header {
|
||||
font-size: var(--text-xs, 10px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-2, 8px);
|
||||
padding-bottom: var(--space-1, 4px);
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.analytics-empty {
|
||||
text-align: center;
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
font-size: var(--text-xs, 10px);
|
||||
padding: var(--space-4, 16px);
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Analytics Dashboard Module
|
||||
* Cross-mode summary, sparklines, alerts, correlations, geofence management, export.
|
||||
*/
|
||||
const Analytics = (function () {
|
||||
'use strict';
|
||||
|
||||
let refreshTimer = null;
|
||||
|
||||
function init() {
|
||||
refresh();
|
||||
if (!refreshTimer) {
|
||||
refreshTimer = setInterval(refresh, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
Promise.all([
|
||||
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
|
||||
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
|
||||
fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null),
|
||||
fetch('/correlation').then(r => r.json()).catch(() => null),
|
||||
fetch('/analytics/geofences').then(r => r.json()).catch(() => null),
|
||||
]).then(([summary, activity, alerts, correlations, geofences]) => {
|
||||
if (summary) renderSummary(summary);
|
||||
if (activity) renderSparklines(activity.sparklines || {});
|
||||
if (alerts) renderAlerts(alerts.events || []);
|
||||
if (correlations) renderCorrelations(correlations);
|
||||
if (geofences) renderGeofences(geofences.zones || []);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSummary(data) {
|
||||
const counts = data.counts || {};
|
||||
_setText('analyticsCountAdsb', counts.adsb || 0);
|
||||
_setText('analyticsCountAis', counts.ais || 0);
|
||||
_setText('analyticsCountWifi', counts.wifi || 0);
|
||||
_setText('analyticsCountBt', counts.bluetooth || 0);
|
||||
_setText('analyticsCountDsc', counts.dsc || 0);
|
||||
|
||||
// Health
|
||||
const health = data.health || {};
|
||||
const container = document.getElementById('analyticsHealth');
|
||||
if (container) {
|
||||
let html = '';
|
||||
const modeLabels = {
|
||||
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
|
||||
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
|
||||
bluetooth: 'BT', dsc: 'DSC'
|
||||
};
|
||||
for (const [mode, info] of Object.entries(health)) {
|
||||
if (mode === 'sdr_devices') continue;
|
||||
const running = info && info.running;
|
||||
const label = modeLabels[mode] || mode;
|
||||
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Squawks
|
||||
const squawks = data.squawks || [];
|
||||
const sqSection = document.getElementById('analyticsSquawkSection');
|
||||
const sqList = document.getElementById('analyticsSquawkList');
|
||||
if (sqSection && sqList) {
|
||||
if (squawks.length > 0) {
|
||||
sqSection.style.display = '';
|
||||
sqList.innerHTML = squawks.map(s =>
|
||||
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
|
||||
_esc(s.meaning) + ' — ' + _esc(s.callsign || s.icao) + '</div>'
|
||||
).join('');
|
||||
} else {
|
||||
sqSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderSparklines(sparklines) {
|
||||
const map = {
|
||||
adsb: 'analyticsSparkAdsb',
|
||||
ais: 'analyticsSparkAis',
|
||||
wifi: 'analyticsSparkWifi',
|
||||
bluetooth: 'analyticsSparkBt',
|
||||
dsc: 'analyticsSparkDsc',
|
||||
};
|
||||
|
||||
for (const [mode, elId] of Object.entries(map)) {
|
||||
const el = document.getElementById(elId);
|
||||
if (!el) continue;
|
||||
const data = sparklines[mode] || [];
|
||||
if (data.length < 2) {
|
||||
el.innerHTML = '';
|
||||
continue;
|
||||
}
|
||||
const max = Math.max(...data, 1);
|
||||
const w = 100;
|
||||
const h = 24;
|
||||
const step = w / (data.length - 1);
|
||||
const points = data.map((v, i) =>
|
||||
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
|
||||
).join(' ');
|
||||
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAlerts(events) {
|
||||
const container = document.getElementById('analyticsAlertFeed');
|
||||
if (!container) return;
|
||||
if (!events || events.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = events.slice(0, 20).map(e => {
|
||||
const sev = e.severity || 'medium';
|
||||
const title = e.title || e.event_type || 'Alert';
|
||||
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
|
||||
return '<div class="analytics-alert-item">' +
|
||||
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
|
||||
'<span>' + _esc(title) + '</span>' +
|
||||
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderCorrelations(data) {
|
||||
const container = document.getElementById('analyticsCorrelations');
|
||||
if (!container) return;
|
||||
const pairs = (data && data.correlations) || [];
|
||||
if (pairs.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = pairs.slice(0, 20).map(p => {
|
||||
const conf = Math.round((p.confidence || 0) * 100);
|
||||
return '<div class="analytics-correlation-pair">' +
|
||||
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
|
||||
'<span style="color:var(--text-dim)">↔</span>' +
|
||||
'<span>' + _esc(p.bt_mac || '') + '</span>' +
|
||||
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
|
||||
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderGeofences(zones) {
|
||||
const container = document.getElementById('analyticsGeofenceList');
|
||||
if (!container) return;
|
||||
if (!zones || zones.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = zones.map(z =>
|
||||
'<div class="geofence-zone-item">' +
|
||||
'<span class="zone-name">' + _esc(z.name) + '</span>' +
|
||||
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
|
||||
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function addGeofence() {
|
||||
const name = prompt('Zone name:');
|
||||
if (!name) return;
|
||||
const lat = parseFloat(prompt('Latitude:', '0'));
|
||||
const lon = parseFloat(prompt('Longitude:', '0'));
|
||||
const radius = parseFloat(prompt('Radius (meters):', '1000'));
|
||||
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
|
||||
alert('Invalid input');
|
||||
return;
|
||||
}
|
||||
fetch('/analytics/geofences', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => refresh());
|
||||
}
|
||||
|
||||
function deleteGeofence(id) {
|
||||
if (!confirm('Delete this geofence zone?')) return;
|
||||
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(() => refresh());
|
||||
}
|
||||
|
||||
function exportData(mode) {
|
||||
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
|
||||
const f = (document.getElementById('exportFormat') || {}).value || 'json';
|
||||
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function _setText(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (typeof s !== 'string') s = String(s == null ? '' : s);
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
return { init, destroy, refresh, addGeofence, deleteGeofence, exportData };
|
||||
})();
|
||||
+22
-6
@@ -52,6 +52,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/aprs.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/tscm.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/analytics.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
|
||||
@@ -554,6 +555,8 @@
|
||||
|
||||
{% include 'partials/modes/tscm.html' %}
|
||||
|
||||
{% include 'partials/modes/analytics.html' %}
|
||||
|
||||
{% include 'partials/modes/ais.html' %}
|
||||
|
||||
{% include 'partials/modes/spy-stations.html' %}
|
||||
@@ -3024,6 +3027,7 @@
|
||||
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate2"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/analytics.js') }}"></script>
|
||||
|
||||
<script>
|
||||
// ============================================
|
||||
@@ -3159,7 +3163,8 @@
|
||||
const validModes = new Set([
|
||||
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
|
||||
'spystations', 'meshtastic', 'wifi', 'bluetooth', 'bt_locate',
|
||||
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'gps', 'websdr', 'subghz'
|
||||
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'gps', 'websdr', 'subghz',
|
||||
'analytics'
|
||||
]);
|
||||
|
||||
function getModeFromQuery() {
|
||||
@@ -3616,7 +3621,7 @@
|
||||
'pager': 'sdr', 'sensor': 'sdr',
|
||||
'aprs': 'sdr', 'listening': 'sdr',
|
||||
'wifi': 'wireless', 'bluetooth': 'wireless', 'bt_locate': 'wireless',
|
||||
'tscm': 'security',
|
||||
'tscm': 'security', 'analytics': 'security',
|
||||
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
|
||||
'meshtastic': 'sdr',
|
||||
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space', 'gps': 'space',
|
||||
@@ -3695,7 +3700,8 @@
|
||||
'pager': 'pager', 'sensor': '433',
|
||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth', 'bt_locate': 'bt locate',
|
||||
'listening': 'listening', 'aprs': 'aprs', 'tscm': 'tscm', 'meshtastic': 'meshtastic',
|
||||
'dmr': 'dmr', 'websdr': 'websdr', 'sstv_general': 'hf sstv'
|
||||
'dmr': 'dmr', 'websdr': 'websdr', 'sstv_general': 'hf sstv',
|
||||
'analytics': 'analytics'
|
||||
};
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||
const label = btn.querySelector('.nav-label');
|
||||
@@ -3723,6 +3729,7 @@
|
||||
document.getElementById('dmrMode')?.classList.toggle('active', mode === 'dmr');
|
||||
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
||||
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
|
||||
document.getElementById('analyticsMode')?.classList.toggle('active', mode === 'analytics');
|
||||
|
||||
|
||||
const pagerStats = document.getElementById('pagerStats');
|
||||
@@ -3765,7 +3772,8 @@
|
||||
'meshtastic': 'MESHTASTIC',
|
||||
'dmr': 'DIGITAL VOICE',
|
||||
'websdr': 'WEBSDR',
|
||||
'subghz': 'SUBGHZ'
|
||||
'subghz': 'SUBGHZ',
|
||||
'analytics': 'ANALYTICS'
|
||||
};
|
||||
const activeModeIndicator = document.getElementById('activeModeIndicator');
|
||||
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
|
||||
@@ -3839,7 +3847,8 @@
|
||||
'meshtastic': 'Meshtastic Mesh Monitor',
|
||||
'dmr': 'Digital Voice Decoder',
|
||||
'websdr': 'HF/Shortwave WebSDR',
|
||||
'subghz': 'SubGHz Transceiver'
|
||||
'subghz': 'SubGHz Transceiver',
|
||||
'analytics': 'Cross-Mode Analytics'
|
||||
};
|
||||
const outputTitle = document.getElementById('outputTitle');
|
||||
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
|
||||
@@ -3853,11 +3862,18 @@
|
||||
refreshTscmDevices();
|
||||
}
|
||||
|
||||
// Initialize/destroy Analytics mode
|
||||
if (mode === 'analytics') {
|
||||
if (typeof Analytics !== 'undefined') Analytics.init();
|
||||
} else {
|
||||
if (typeof Analytics !== 'undefined' && Analytics.destroy) Analytics.destroy();
|
||||
}
|
||||
|
||||
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
const reconPanel = document.getElementById('reconPanel');
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz') {
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics') {
|
||||
if (reconPanel) reconPanel.style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<!-- ANALYTICS MODE -->
|
||||
<div id="analyticsMode" class="mode-content">
|
||||
{# Analytics Dashboard Sidebar Panel #}
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Summary</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="analytics-grid" id="analyticsSummaryCards">
|
||||
<div class="analytics-card" data-mode="adsb">
|
||||
<div class="card-count" id="analyticsCountAdsb">0</div>
|
||||
<div class="card-label">Aircraft</div>
|
||||
<div class="card-sparkline" id="analyticsSparkAdsb"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="ais">
|
||||
<div class="card-count" id="analyticsCountAis">0</div>
|
||||
<div class="card-label">Vessels</div>
|
||||
<div class="card-sparkline" id="analyticsSparkAis"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="wifi">
|
||||
<div class="card-count" id="analyticsCountWifi">0</div>
|
||||
<div class="card-label">WiFi</div>
|
||||
<div class="card-sparkline" id="analyticsSparkWifi"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="bluetooth">
|
||||
<div class="card-count" id="analyticsCountBt">0</div>
|
||||
<div class="card-label">Bluetooth</div>
|
||||
<div class="card-sparkline" id="analyticsSparkBt"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="dsc">
|
||||
<div class="card-count" id="analyticsCountDsc">0</div>
|
||||
<div class="card-label">DSC</div>
|
||||
<div class="card-sparkline" id="analyticsSparkDsc"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Mode Health</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="analytics-health" id="analyticsHealth"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="analyticsSquawkSection" style="display:none;">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Emergency Squawks</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="squawk-emergency" id="analyticsSquawkPanel">
|
||||
<div class="squawk-title">Active Emergency Codes</div>
|
||||
<div id="analyticsSquawkList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Recent Alerts</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="analytics-alert-feed" id="analyticsAlertFeed">
|
||||
<div class="analytics-empty">No recent alerts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Correlations</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div id="analyticsCorrelations">
|
||||
<div class="analytics-empty">No correlations detected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Geofences</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div id="analyticsGeofenceList"></div>
|
||||
<button class="btn btn-sm" onclick="Analytics.addGeofence()" style="margin-top:8px; font-size:10px; padding:4px 10px; background:var(--accent-cyan); color:#fff; border:none; border-radius:4px; cursor:pointer;">
|
||||
+ Add Zone
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Export Data</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="export-controls">
|
||||
<select id="exportMode">
|
||||
<option value="adsb">ADS-B</option>
|
||||
<option value="ais">AIS</option>
|
||||
<option value="wifi">WiFi</option>
|
||||
<option value="bluetooth">Bluetooth</option>
|
||||
<option value="dsc">DSC</option>
|
||||
</select>
|
||||
<select id="exportFormat">
|
||||
<option value="json">JSON</option>
|
||||
<option value="csv">CSV</option>
|
||||
</select>
|
||||
<button onclick="Analytics.exportData()">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -103,6 +103,7 @@
|
||||
|
||||
<div class="mode-nav-dropdown-menu">
|
||||
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||
{{ mode_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,6 +188,7 @@
|
||||
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
||||
{{ mobile_item('bt_locate', 'Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||
{{ mobile_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
||||
{% if is_index_page %}
|
||||
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
|
||||
{% else %}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Tests for analytics endpoints, export, and squawk detection."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def app():
|
||||
"""Create application for testing."""
|
||||
import app as app_module
|
||||
import utils.database as db_mod
|
||||
from routes import register_blueprints
|
||||
|
||||
app_module.app.config['TESTING'] = True
|
||||
|
||||
# Use temp directory for test database
|
||||
tmp_dir = Path(tempfile.mkdtemp())
|
||||
db_mod.DB_DIR = tmp_dir
|
||||
db_mod.DB_PATH = tmp_dir / 'test_intercept.db'
|
||||
# Reset thread-local connection so it picks up new path
|
||||
if hasattr(db_mod._local, 'connection') and db_mod._local.connection:
|
||||
db_mod._local.connection.close()
|
||||
db_mod._local.connection = None
|
||||
|
||||
db_mod.init_db()
|
||||
|
||||
if 'pager' not in app_module.app.blueprints:
|
||||
register_blueprints(app_module.app)
|
||||
|
||||
return app_module.app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
client = app.test_client()
|
||||
# Set session login to bypass require_login before_request hook
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
return client
|
||||
|
||||
|
||||
class TestAnalyticsSummary:
|
||||
"""Tests for /analytics/summary endpoint."""
|
||||
|
||||
def test_summary_returns_json(self, client):
|
||||
response = client.get('/analytics/summary')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'counts' in data
|
||||
assert 'health' in data
|
||||
assert 'squawks' in data
|
||||
|
||||
def test_summary_counts_structure(self, client):
|
||||
response = client.get('/analytics/summary')
|
||||
data = json.loads(response.data)
|
||||
counts = data['counts']
|
||||
assert 'adsb' in counts
|
||||
assert 'ais' in counts
|
||||
assert 'wifi' in counts
|
||||
assert 'bluetooth' in counts
|
||||
assert 'dsc' in counts
|
||||
# All should be integers
|
||||
for val in counts.values():
|
||||
assert isinstance(val, int)
|
||||
|
||||
def test_summary_health_structure(self, client):
|
||||
response = client.get('/analytics/summary')
|
||||
data = json.loads(response.data)
|
||||
health = data['health']
|
||||
# Should have process statuses
|
||||
assert 'pager' in health
|
||||
assert 'sensor' in health
|
||||
assert 'adsb' in health
|
||||
# Each should have a running flag
|
||||
for mode_info in health.values():
|
||||
if isinstance(mode_info, dict) and 'running' in mode_info:
|
||||
assert isinstance(mode_info['running'], bool)
|
||||
|
||||
|
||||
class TestAnalyticsExport:
|
||||
"""Tests for /analytics/export/<mode> endpoint."""
|
||||
|
||||
def test_export_adsb_json(self, client):
|
||||
response = client.get('/analytics/export/adsb?format=json')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['mode'] == 'adsb'
|
||||
assert 'data' in data
|
||||
assert isinstance(data['data'], list)
|
||||
|
||||
def test_export_adsb_csv(self, client):
|
||||
response = client.get('/analytics/export/adsb?format=csv')
|
||||
assert response.status_code == 200
|
||||
assert response.content_type.startswith('text/csv')
|
||||
assert 'Content-Disposition' in response.headers
|
||||
|
||||
def test_export_invalid_mode(self, client):
|
||||
response = client.get('/analytics/export/invalid_mode')
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'error'
|
||||
|
||||
def test_export_wifi_json(self, client):
|
||||
response = client.get('/analytics/export/wifi?format=json')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['mode'] == 'wifi'
|
||||
|
||||
|
||||
class TestAnalyticsSquawks:
|
||||
"""Tests for squawk detection."""
|
||||
|
||||
def test_squawks_endpoint(self, client):
|
||||
response = client.get('/analytics/squawks')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert isinstance(data['squawks'], list)
|
||||
|
||||
def test_get_emergency_squawks_detects_7700(self):
|
||||
from utils.analytics import get_emergency_squawks
|
||||
|
||||
# Mock the adsb_aircraft DataStore
|
||||
mock_store = MagicMock()
|
||||
mock_store.items.return_value = [
|
||||
('ABC123', {'squawk': '7700', 'callsign': 'TEST01', 'altitude': 35000}),
|
||||
('DEF456', {'squawk': '1200', 'callsign': 'TEST02'}),
|
||||
]
|
||||
|
||||
with patch('utils.analytics.app_module') as mock_app:
|
||||
mock_app.adsb_aircraft = mock_store
|
||||
squawks = get_emergency_squawks()
|
||||
|
||||
assert len(squawks) == 1
|
||||
assert squawks[0]['squawk'] == '7700'
|
||||
assert squawks[0]['meaning'] == 'General Emergency'
|
||||
assert squawks[0]['icao'] == 'ABC123'
|
||||
|
||||
|
||||
class TestGeofenceCRUD:
|
||||
"""Tests for geofence CRUD endpoints."""
|
||||
|
||||
def test_list_geofences(self, client):
|
||||
response = client.get('/analytics/geofences')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert isinstance(data['zones'], list)
|
||||
|
||||
def test_create_geofence(self, client):
|
||||
response = client.post('/analytics/geofences',
|
||||
data=json.dumps({
|
||||
'name': 'Test Zone',
|
||||
'lat': 51.5074,
|
||||
'lon': -0.1278,
|
||||
'radius_m': 500,
|
||||
}),
|
||||
content_type='application/json')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'zone_id' in data
|
||||
|
||||
def test_create_geofence_missing_fields(self, client):
|
||||
response = client.post('/analytics/geofences',
|
||||
data=json.dumps({'name': 'No coords'}),
|
||||
content_type='application/json')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_create_geofence_invalid_coords(self, client):
|
||||
response = client.post('/analytics/geofences',
|
||||
data=json.dumps({
|
||||
'name': 'Bad',
|
||||
'lat': 100,
|
||||
'lon': 0,
|
||||
'radius_m': 100,
|
||||
}),
|
||||
content_type='application/json')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_delete_geofence_not_found(self, client):
|
||||
response = client.delete('/analytics/geofences/99999')
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestAnalyticsActivity:
|
||||
"""Tests for /analytics/activity endpoint."""
|
||||
|
||||
def test_activity_returns_sparklines(self, client):
|
||||
response = client.get('/analytics/activity')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'sparklines' in data
|
||||
assert isinstance(data['sparklines'], dict)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for FlightCorrelator: ACARS/VDL2 message matching."""
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.flight_correlator import FlightCorrelator
|
||||
|
||||
|
||||
class TestFlightCorrelator:
|
||||
"""Test ACARS/VDL2 message matching by callsign."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self):
|
||||
self.correlator = FlightCorrelator(max_messages=100)
|
||||
|
||||
def test_add_acars_message(self):
|
||||
self.correlator.add_acars_message({
|
||||
'flight': 'BAW123', 'tail': 'G-ABCD', 'text': 'Hello',
|
||||
})
|
||||
assert self.correlator.acars_count == 1
|
||||
|
||||
def test_add_vdl2_message(self):
|
||||
self.correlator.add_vdl2_message({
|
||||
'flight': 'DLH456', 'text': 'World',
|
||||
})
|
||||
assert self.correlator.vdl2_count == 1
|
||||
|
||||
def test_match_by_callsign(self):
|
||||
self.correlator.add_acars_message({
|
||||
'flight': 'BAW123', 'text': 'msg1',
|
||||
})
|
||||
self.correlator.add_acars_message({
|
||||
'flight': 'DLH456', 'text': 'msg2',
|
||||
})
|
||||
|
||||
result = self.correlator.get_messages_for_aircraft(callsign='BAW123')
|
||||
assert len(result['acars']) == 1
|
||||
assert result['acars'][0]['text'] == 'msg1'
|
||||
|
||||
def test_match_by_icao(self):
|
||||
self.correlator.add_vdl2_message({
|
||||
'icao': 'ABC123', 'text': 'vdl2 msg',
|
||||
})
|
||||
|
||||
result = self.correlator.get_messages_for_aircraft(icao='ABC123')
|
||||
assert len(result['vdl2']) == 1
|
||||
assert result['vdl2'][0]['text'] == 'vdl2 msg'
|
||||
|
||||
def test_no_match_returns_empty(self):
|
||||
self.correlator.add_acars_message({'flight': 'BAW123', 'text': 'msg'})
|
||||
|
||||
result = self.correlator.get_messages_for_aircraft(callsign='NOMATCH')
|
||||
assert result['acars'] == []
|
||||
assert result['vdl2'] == []
|
||||
|
||||
def test_empty_search_returns_empty(self):
|
||||
result = self.correlator.get_messages_for_aircraft()
|
||||
assert result == {'acars': [], 'vdl2': []}
|
||||
|
||||
def test_ring_buffer_limit(self):
|
||||
correlator = FlightCorrelator(max_messages=5)
|
||||
for i in range(10):
|
||||
correlator.add_acars_message({'flight': f'FL{i}', 'text': f'msg{i}'})
|
||||
|
||||
assert correlator.acars_count == 5
|
||||
# First 5 messages should have been evicted
|
||||
result = correlator.get_messages_for_aircraft(callsign='FL0')
|
||||
assert len(result['acars']) == 0
|
||||
# Last message should still be there
|
||||
result = correlator.get_messages_for_aircraft(callsign='FL9')
|
||||
assert len(result['acars']) == 1
|
||||
|
||||
def test_case_insensitive_matching(self):
|
||||
self.correlator.add_acars_message({'flight': 'baw123', 'text': 'lowercase'})
|
||||
|
||||
result = self.correlator.get_messages_for_aircraft(callsign='BAW123')
|
||||
assert len(result['acars']) == 1
|
||||
|
||||
def test_match_by_tail_field(self):
|
||||
self.correlator.add_acars_message({
|
||||
'tail': 'G-ABCD', 'text': 'tail match',
|
||||
})
|
||||
|
||||
result = self.correlator.get_messages_for_aircraft(callsign='G-ABCD')
|
||||
assert len(result['acars']) == 1
|
||||
|
||||
def test_internal_fields_not_returned(self):
|
||||
self.correlator.add_acars_message({'flight': 'TEST', 'text': 'msg'})
|
||||
|
||||
result = self.correlator.get_messages_for_aircraft(callsign='TEST')
|
||||
msg = result['acars'][0]
|
||||
assert '_corr_time' not in msg
|
||||
|
||||
def test_both_acars_and_vdl2_returned(self):
|
||||
self.correlator.add_acars_message({'flight': 'UAL789', 'text': 'acars'})
|
||||
self.correlator.add_vdl2_message({'flight': 'UAL789', 'text': 'vdl2'})
|
||||
|
||||
result = self.correlator.get_messages_for_aircraft(callsign='UAL789')
|
||||
assert len(result['acars']) == 1
|
||||
assert len(result['vdl2']) == 1
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Tests for geofence haversine, enter/exit detection, and persistence."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestHaversineDistance:
|
||||
"""Test haversine_distance accuracy."""
|
||||
|
||||
def test_same_point_zero_distance(self):
|
||||
from utils.geofence import haversine_distance
|
||||
assert haversine_distance(51.5, -0.1, 51.5, -0.1) == 0.0
|
||||
|
||||
def test_known_distance_london_paris(self):
|
||||
from utils.geofence import haversine_distance
|
||||
# London to Paris ~340km
|
||||
dist = haversine_distance(51.5074, -0.1278, 48.8566, 2.3522)
|
||||
assert 340_000 < dist < 345_000
|
||||
|
||||
def test_short_distance(self):
|
||||
from utils.geofence import haversine_distance
|
||||
# Two points ~111m apart (0.001 degrees latitude at equator)
|
||||
dist = haversine_distance(0.0, 0.0, 0.001, 0.0)
|
||||
assert 100 < dist < 120
|
||||
|
||||
def test_antipodal_distance(self):
|
||||
from utils.geofence import haversine_distance
|
||||
# North pole to south pole ~20015km
|
||||
dist = haversine_distance(90.0, 0.0, -90.0, 0.0)
|
||||
assert 20_000_000 < dist < 20_050_000
|
||||
|
||||
|
||||
class TestGeofenceManager:
|
||||
"""Test enter/exit detection logic."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self):
|
||||
"""Provide a fresh GeofenceManager with mocked DB."""
|
||||
from utils.geofence import GeofenceManager
|
||||
|
||||
with patch('utils.geofence._ensure_table'), patch('utils.geofence.get_db') as mock_db:
|
||||
# Mock the context manager
|
||||
mock_conn = MagicMock()
|
||||
mock_db.return_value.__enter__ = MagicMock(return_value=mock_conn)
|
||||
mock_db.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
self.manager = GeofenceManager()
|
||||
# Override list_zones to return test data
|
||||
self._zones = []
|
||||
self.manager.list_zones = lambda: self._zones
|
||||
|
||||
def test_no_zones_returns_empty(self):
|
||||
events = self.manager.check_position('TEST1', 'aircraft', 51.5, -0.1)
|
||||
assert events == []
|
||||
|
||||
def test_enter_event(self):
|
||||
self._zones = [{
|
||||
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
|
||||
'radius_m': 10000, 'alert_on': 'enter_exit',
|
||||
}]
|
||||
# First position inside zone
|
||||
events = self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
|
||||
assert len(events) == 1
|
||||
assert events[0]['type'] == 'geofence_enter'
|
||||
assert events[0]['zone_name'] == 'London'
|
||||
|
||||
def test_no_duplicate_enter(self):
|
||||
self._zones = [{
|
||||
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
|
||||
'radius_m': 10000, 'alert_on': 'enter_exit',
|
||||
}]
|
||||
# First enter
|
||||
self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
|
||||
# Second check still inside - should not fire enter again
|
||||
events = self.manager.check_position('AC1', 'aircraft', 51.508, -0.128)
|
||||
assert len(events) == 0
|
||||
|
||||
def test_exit_event(self):
|
||||
self._zones = [{
|
||||
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
|
||||
'radius_m': 1000, 'alert_on': 'enter_exit',
|
||||
}]
|
||||
# Enter
|
||||
self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
|
||||
# Exit (far away)
|
||||
events = self.manager.check_position('AC1', 'aircraft', 52.0, 0.0)
|
||||
assert len(events) == 1
|
||||
assert events[0]['type'] == 'geofence_exit'
|
||||
|
||||
def test_enter_only_mode(self):
|
||||
self._zones = [{
|
||||
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
|
||||
'radius_m': 1000, 'alert_on': 'enter',
|
||||
}]
|
||||
# Enter
|
||||
events = self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
|
||||
assert len(events) == 1
|
||||
assert events[0]['type'] == 'geofence_enter'
|
||||
# Exit should not fire
|
||||
events = self.manager.check_position('AC1', 'aircraft', 52.0, 0.0)
|
||||
assert len(events) == 0
|
||||
|
||||
def test_metadata_included_in_event(self):
|
||||
self._zones = [{
|
||||
'id': 1, 'name': 'Zone', 'lat': 0.0, 'lon': 0.0,
|
||||
'radius_m': 100000, 'alert_on': 'enter_exit',
|
||||
}]
|
||||
events = self.manager.check_position(
|
||||
'AC1', 'aircraft', 0.0, 0.0,
|
||||
metadata={'callsign': 'TEST01', 'altitude': 35000}
|
||||
)
|
||||
assert events[0]['callsign'] == 'TEST01'
|
||||
assert events[0]['altitude'] == 35000
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Cross-mode analytics: activity tracking, summaries, and emergency squawk detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
import app as app_module
|
||||
|
||||
|
||||
class ModeActivityTracker:
|
||||
"""Track device counts per mode in time-bucketed ring buffer for sparklines."""
|
||||
|
||||
def __init__(self, max_buckets: int = 60, bucket_interval: float = 5.0):
|
||||
self._max_buckets = max_buckets
|
||||
self._bucket_interval = bucket_interval
|
||||
self._history: dict[str, deque] = {}
|
||||
self._last_record_time = 0.0
|
||||
|
||||
def record(self) -> None:
|
||||
"""Snapshot current counts for all modes."""
|
||||
now = time.time()
|
||||
if now - self._last_record_time < self._bucket_interval:
|
||||
return
|
||||
self._last_record_time = now
|
||||
|
||||
counts = _get_mode_counts()
|
||||
for mode, count in counts.items():
|
||||
if mode not in self._history:
|
||||
self._history[mode] = deque(maxlen=self._max_buckets)
|
||||
self._history[mode].append(count)
|
||||
|
||||
def get_sparkline(self, mode: str) -> list[int]:
|
||||
"""Return sparkline array for a mode."""
|
||||
self.record()
|
||||
return list(self._history.get(mode, []))
|
||||
|
||||
def get_all_sparklines(self) -> dict[str, list[int]]:
|
||||
"""Return sparkline arrays for all tracked modes."""
|
||||
self.record()
|
||||
return {mode: list(values) for mode, values in self._history.items()}
|
||||
|
||||
|
||||
# Singleton
|
||||
_tracker: ModeActivityTracker | None = None
|
||||
|
||||
|
||||
def get_activity_tracker() -> ModeActivityTracker:
|
||||
global _tracker
|
||||
if _tracker is None:
|
||||
_tracker = ModeActivityTracker()
|
||||
return _tracker
|
||||
|
||||
|
||||
def _get_mode_counts() -> dict[str, int]:
|
||||
"""Read current entity counts from app_module DataStores."""
|
||||
counts: dict[str, int] = {}
|
||||
try:
|
||||
counts['adsb'] = len(app_module.adsb_aircraft)
|
||||
except Exception:
|
||||
counts['adsb'] = 0
|
||||
try:
|
||||
counts['ais'] = len(app_module.ais_vessels)
|
||||
except Exception:
|
||||
counts['ais'] = 0
|
||||
try:
|
||||
counts['wifi'] = len(app_module.wifi_networks)
|
||||
except Exception:
|
||||
counts['wifi'] = 0
|
||||
try:
|
||||
counts['bluetooth'] = len(app_module.bt_devices)
|
||||
except Exception:
|
||||
counts['bluetooth'] = 0
|
||||
try:
|
||||
counts['dsc'] = len(app_module.dsc_messages)
|
||||
except Exception:
|
||||
counts['dsc'] = 0
|
||||
return counts
|
||||
|
||||
|
||||
def get_cross_mode_summary() -> dict[str, Any]:
|
||||
"""Return counts dict for all active DataStores."""
|
||||
counts = _get_mode_counts()
|
||||
try:
|
||||
counts['wifi_clients'] = len(app_module.wifi_clients)
|
||||
except Exception:
|
||||
counts['wifi_clients'] = 0
|
||||
return counts
|
||||
|
||||
|
||||
def get_mode_health() -> dict[str, dict]:
|
||||
"""Check process refs and SDR status for each mode."""
|
||||
health: dict[str, dict] = {}
|
||||
|
||||
process_map = {
|
||||
'pager': 'current_process',
|
||||
'sensor': 'sensor_process',
|
||||
'adsb': 'adsb_process',
|
||||
'ais': 'ais_process',
|
||||
'acars': 'acars_process',
|
||||
'vdl2': 'vdl2_process',
|
||||
'aprs': 'aprs_process',
|
||||
'wifi': 'wifi_process',
|
||||
'bluetooth': 'bt_process',
|
||||
'dsc': 'dsc_process',
|
||||
}
|
||||
|
||||
for mode, attr in process_map.items():
|
||||
proc = getattr(app_module, attr, None)
|
||||
running = proc is not None and (proc.poll() is None if proc else False)
|
||||
health[mode] = {'running': running}
|
||||
|
||||
try:
|
||||
sdr_status = app_module.get_sdr_device_status()
|
||||
health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()}
|
||||
except Exception:
|
||||
health['sdr_devices'] = {}
|
||||
|
||||
return health
|
||||
|
||||
|
||||
EMERGENCY_SQUAWKS = {
|
||||
'7700': 'General Emergency',
|
||||
'7600': 'Comms Failure',
|
||||
'7500': 'Hijack',
|
||||
}
|
||||
|
||||
|
||||
def get_emergency_squawks() -> list[dict]:
|
||||
"""Iterate adsb_aircraft DataStore for emergency squawk codes."""
|
||||
emergencies: list[dict] = []
|
||||
try:
|
||||
for icao, aircraft in app_module.adsb_aircraft.items():
|
||||
sq = str(aircraft.get('squawk', '')).strip()
|
||||
if sq in EMERGENCY_SQUAWKS:
|
||||
emergencies.append({
|
||||
'icao': icao,
|
||||
'callsign': aircraft.get('callsign', ''),
|
||||
'squawk': sq,
|
||||
'meaning': EMERGENCY_SQUAWKS[sq],
|
||||
'altitude': aircraft.get('altitude'),
|
||||
'lat': aircraft.get('lat'),
|
||||
'lon': aircraft.get('lon'),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return emergencies
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Match ACARS/VDL2 messages to ADS-B aircraft by callsign."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
|
||||
class FlightCorrelator:
|
||||
"""Correlate ACARS and VDL2 messages with ADS-B aircraft."""
|
||||
|
||||
def __init__(self, max_messages: int = 1000):
|
||||
self._acars_messages: deque[dict] = deque(maxlen=max_messages)
|
||||
self._vdl2_messages: deque[dict] = deque(maxlen=max_messages)
|
||||
|
||||
def add_acars_message(self, msg: dict) -> None:
|
||||
self._acars_messages.append({
|
||||
**msg,
|
||||
'_corr_time': time.time(),
|
||||
})
|
||||
|
||||
def add_vdl2_message(self, msg: dict) -> None:
|
||||
self._vdl2_messages.append({
|
||||
**msg,
|
||||
'_corr_time': time.time(),
|
||||
})
|
||||
|
||||
def get_messages_for_aircraft(
|
||||
self, icao: str | None = None, callsign: str | None = None
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Match ACARS/VDL2 messages by callsign, flight, or registration fields."""
|
||||
if not icao and not callsign:
|
||||
return {'acars': [], 'vdl2': []}
|
||||
|
||||
search_terms: set[str] = set()
|
||||
if callsign:
|
||||
search_terms.add(callsign.strip().upper())
|
||||
if icao:
|
||||
search_terms.add(icao.strip().upper())
|
||||
|
||||
acars = []
|
||||
for msg in self._acars_messages:
|
||||
if self._msg_matches(msg, search_terms):
|
||||
acars.append(self._clean_msg(msg))
|
||||
|
||||
vdl2 = []
|
||||
for msg in self._vdl2_messages:
|
||||
if self._msg_matches(msg, search_terms):
|
||||
vdl2.append(self._clean_msg(msg))
|
||||
|
||||
return {'acars': acars, 'vdl2': vdl2}
|
||||
|
||||
@staticmethod
|
||||
def _msg_matches(msg: dict, terms: set[str]) -> bool:
|
||||
"""Check if any identifying field in msg matches the search terms."""
|
||||
for field in ('flight', 'tail', 'reg', 'callsign', 'icao', 'addr'):
|
||||
val = msg.get(field)
|
||||
if val and str(val).strip().upper() in terms:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _clean_msg(msg: dict) -> dict:
|
||||
"""Return message without internal correlation fields."""
|
||||
return {k: v for k, v in msg.items() if not k.startswith('_corr_')}
|
||||
|
||||
@property
|
||||
def acars_count(self) -> int:
|
||||
return len(self._acars_messages)
|
||||
|
||||
@property
|
||||
def vdl2_count(self) -> int:
|
||||
return len(self._vdl2_messages)
|
||||
|
||||
|
||||
# Singleton
|
||||
_correlator: FlightCorrelator | None = None
|
||||
|
||||
|
||||
def get_flight_correlator() -> FlightCorrelator:
|
||||
global _correlator
|
||||
if _correlator is None:
|
||||
_correlator = FlightCorrelator()
|
||||
return _correlator
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Geofence zones with haversine distance, enter/exit detection, and SQLite persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from utils.database import get_db
|
||||
|
||||
|
||||
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Return distance in meters between two lat/lon points."""
|
||||
R = 6_371_000 # Earth radius in meters
|
||||
phi1 = math.radians(lat1)
|
||||
phi2 = math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlam = math.radians(lon2 - lon1)
|
||||
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||||
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
|
||||
def _ensure_table() -> None:
|
||||
"""Create geofence_zones table if it doesn't exist."""
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS geofence_zones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
radius_m REAL NOT NULL,
|
||||
alert_on TEXT DEFAULT 'enter_exit',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
|
||||
class GeofenceManager:
|
||||
"""Manages geofence zones with enter/exit detection."""
|
||||
|
||||
def __init__(self):
|
||||
self._inside: dict[str, set[int]] = {} # entity_id -> set of zone_ids inside
|
||||
_ensure_table()
|
||||
|
||||
def list_zones(self) -> list[dict]:
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'SELECT id, name, lat, lon, radius_m, alert_on, created_at FROM geofence_zones ORDER BY id'
|
||||
)
|
||||
return [dict(row) for row in cursor]
|
||||
|
||||
def add_zone(self, name: str, lat: float, lon: float, radius_m: float,
|
||||
alert_on: str = 'enter_exit') -> int:
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'INSERT INTO geofence_zones (name, lat, lon, radius_m, alert_on) VALUES (?, ?, ?, ?, ?)',
|
||||
(name, lat, lon, radius_m, alert_on),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
def delete_zone(self, zone_id: int) -> bool:
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('DELETE FROM geofence_zones WHERE id = ?', (zone_id,))
|
||||
# Clean up inside tracking
|
||||
for entity_zones in self._inside.values():
|
||||
entity_zones.discard(zone_id)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def check_position(self, entity_id: str, entity_type: str,
|
||||
lat: float, lon: float,
|
||||
metadata: dict[str, Any] | None = None) -> list[dict]:
|
||||
"""Check entity position against all zones. Returns list of events."""
|
||||
zones = self.list_zones()
|
||||
if not zones:
|
||||
return []
|
||||
|
||||
events: list[dict] = []
|
||||
prev_inside = self._inside.get(entity_id, set())
|
||||
curr_inside: set[int] = set()
|
||||
|
||||
for zone in zones:
|
||||
dist = haversine_distance(lat, lon, zone['lat'], zone['lon'])
|
||||
zid = zone['id']
|
||||
if dist <= zone['radius_m']:
|
||||
curr_inside.add(zid)
|
||||
|
||||
if zid not in prev_inside and zone['alert_on'] in ('enter', 'enter_exit'):
|
||||
events.append({
|
||||
'type': 'geofence_enter',
|
||||
'zone_id': zid,
|
||||
'zone_name': zone['name'],
|
||||
'entity_id': entity_id,
|
||||
'entity_type': entity_type,
|
||||
'distance_m': round(dist, 1),
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
**(metadata or {}),
|
||||
})
|
||||
else:
|
||||
if zid in prev_inside and zone['alert_on'] in ('exit', 'enter_exit'):
|
||||
events.append({
|
||||
'type': 'geofence_exit',
|
||||
'zone_id': zid,
|
||||
'zone_name': zone['name'],
|
||||
'entity_id': entity_id,
|
||||
'entity_type': entity_type,
|
||||
'distance_m': round(dist, 1),
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
**(metadata or {}),
|
||||
})
|
||||
|
||||
self._inside[entity_id] = curr_inside
|
||||
return events
|
||||
|
||||
|
||||
# Singleton
|
||||
_manager: GeofenceManager | None = None
|
||||
|
||||
|
||||
def get_geofence_manager() -> GeofenceManager:
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = GeofenceManager()
|
||||
return _manager
|
||||
@@ -306,6 +306,9 @@ class MeshtasticClient:
|
||||
self._range_test_running: bool = False
|
||||
self._range_test_results: list[dict] = []
|
||||
|
||||
# Topology tracking: node_id -> {neighbors, hop_count, msg_count, last_seen}
|
||||
self._topology: dict[str, dict] = {}
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
@@ -326,6 +329,35 @@ class MeshtasticClient:
|
||||
"""Set callback for received messages."""
|
||||
self._callback = callback
|
||||
|
||||
def record_message_route(self, from_node: str, to_node: str, hops: int | None = None) -> None:
|
||||
"""Record a message route for topology tracking."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for node_id in (from_node, to_node):
|
||||
if node_id not in self._topology:
|
||||
self._topology[node_id] = {
|
||||
'neighbors': set(),
|
||||
'hop_count': hops,
|
||||
'msg_count': 0,
|
||||
'last_seen': now,
|
||||
}
|
||||
entry = self._topology[node_id]
|
||||
entry['msg_count'] += 1
|
||||
entry['last_seen'] = now
|
||||
self._topology[from_node]['neighbors'].add(to_node)
|
||||
self._topology[to_node]['neighbors'].add(from_node)
|
||||
|
||||
def get_topology(self) -> dict:
|
||||
"""Return topology dict with serializable sets."""
|
||||
result = {}
|
||||
for node_id, data in self._topology.items():
|
||||
result[node_id] = {
|
||||
'neighbors': list(data.get('neighbors', set())),
|
||||
'hop_count': data.get('hop_count'),
|
||||
'msg_count': data.get('msg_count', 0),
|
||||
'last_seen': data.get('last_seen'),
|
||||
}
|
||||
return result
|
||||
|
||||
def connect(self, device: str | None = None, connection_type: str = 'serial',
|
||||
hostname: str | None = None) -> bool:
|
||||
"""
|
||||
@@ -463,6 +495,14 @@ class MeshtasticClient:
|
||||
# Track node from packet (always, even for filtered messages)
|
||||
self._track_node_from_packet(packet, decoded, portnum)
|
||||
|
||||
# Record topology route
|
||||
if from_num and to_num:
|
||||
self.record_message_route(
|
||||
self._format_node_id(from_num),
|
||||
self._format_node_id(to_num),
|
||||
packet.get('hopLimit'),
|
||||
)
|
||||
|
||||
# Parse traceroute responses
|
||||
if portnum == 'TRACEROUTE_APP':
|
||||
self._handle_traceroute_response(packet, decoded)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Periodic pattern detection via interval analysis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class TemporalPatternDetector:
|
||||
"""Detect periodic patterns from event timestamps per device."""
|
||||
|
||||
def __init__(self, max_timestamps: int = 200):
|
||||
self._timestamps: dict[str, list[float]] = defaultdict(list)
|
||||
self._max_timestamps = max_timestamps
|
||||
|
||||
def record_event(self, device_id: str, mode: str, timestamp: float | None = None) -> None:
|
||||
key = f"{mode}:{device_id}"
|
||||
ts = timestamp or time.time()
|
||||
buf = self._timestamps[key]
|
||||
buf.append(ts)
|
||||
if len(buf) > self._max_timestamps:
|
||||
del buf[: len(buf) - self._max_timestamps]
|
||||
|
||||
def detect_patterns(self, device_id: str, mode: str | None = None) -> dict | None:
|
||||
"""Detect periodic patterns for a device.
|
||||
|
||||
Returns dict with period_seconds, confidence, occurrences or None.
|
||||
"""
|
||||
keys = []
|
||||
if mode:
|
||||
keys.append(f"{mode}:{device_id}")
|
||||
else:
|
||||
keys = [k for k in self._timestamps if k.endswith(f":{device_id}")]
|
||||
|
||||
for key in keys:
|
||||
result = self._analyze_intervals(self._timestamps.get(key, []))
|
||||
if result:
|
||||
result['device_id'] = device_id
|
||||
result['mode'] = key.split(':')[0]
|
||||
return result
|
||||
return None
|
||||
|
||||
def _analyze_intervals(self, timestamps: list[float]) -> dict | None:
|
||||
if len(timestamps) < 4:
|
||||
return None
|
||||
|
||||
intervals = [timestamps[i + 1] - timestamps[i] for i in range(len(timestamps) - 1)]
|
||||
|
||||
# Find the median interval
|
||||
sorted_intervals = sorted(intervals)
|
||||
median = sorted_intervals[len(sorted_intervals) // 2]
|
||||
|
||||
if median < 1.0:
|
||||
return None
|
||||
|
||||
# Count how many intervals are within 20% of the median
|
||||
tolerance = median * 0.2
|
||||
matching = sum(1 for iv in intervals if abs(iv - median) <= tolerance)
|
||||
confidence = matching / len(intervals)
|
||||
|
||||
if confidence < 0.5:
|
||||
return None
|
||||
|
||||
return {
|
||||
'period_seconds': round(median, 1),
|
||||
'confidence': round(confidence, 3),
|
||||
'occurrences': len(timestamps),
|
||||
}
|
||||
|
||||
def get_all_patterns(self) -> list[dict]:
|
||||
"""Return all detected patterns across all devices."""
|
||||
results = []
|
||||
seen = set()
|
||||
for key in self._timestamps:
|
||||
mode, device_id = key.split(':', 1)
|
||||
if device_id in seen:
|
||||
continue
|
||||
pattern = self.detect_patterns(device_id, mode)
|
||||
if pattern:
|
||||
results.append(pattern)
|
||||
seen.add(device_id)
|
||||
return results
|
||||
|
||||
|
||||
# Singleton
|
||||
_detector: TemporalPatternDetector | None = None
|
||||
|
||||
|
||||
def get_pattern_detector() -> TemporalPatternDetector:
|
||||
global _detector
|
||||
if _detector is None:
|
||||
_detector = TemporalPatternDetector()
|
||||
return _detector
|
||||
Reference in New Issue
Block a user