mirror of
https://github.com/smittix/intercept.git
synced 2026-07-27 01:58:10 -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:
|
||||
|
||||
Reference in New Issue
Block a user