mirror of
https://github.com/smittix/intercept.git
synced 2026-06-18 10:29:46 -07:00
feat: ship waterfall receiver overhaul and platform mode updates
This commit is contained in:
+9
-9
@@ -32,10 +32,10 @@ def register_blueprints(app):
|
||||
from .websdr import websdr_bp
|
||||
from .alerts import alerts_bp
|
||||
from .recordings import recordings_bp
|
||||
from .subghz import subghz_bp
|
||||
from .bt_locate import bt_locate_bp
|
||||
from .analytics import analytics_bp
|
||||
from .space_weather import space_weather_bp
|
||||
from .subghz import subghz_bp
|
||||
from .bt_locate import bt_locate_bp
|
||||
from .space_weather import space_weather_bp
|
||||
from .fingerprint import fingerprint_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -68,11 +68,11 @@ def register_blueprints(app):
|
||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||
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
|
||||
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||
app.register_blueprint(fingerprint_bp) # RF fingerprinting
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
|
||||
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
"""Analytics dashboard: cross-mode summary, activity sparklines, export, geofence CRUD."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
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.alerts import get_alert_manager
|
||||
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('/target')
|
||||
def analytics_target():
|
||||
"""Search entities across multiple modes for a target-centric view."""
|
||||
query = (request.args.get('q') or '').strip()
|
||||
requested_limit = request.args.get('limit', default=120, type=int) or 120
|
||||
limit = max(1, min(500, requested_limit))
|
||||
|
||||
if not query:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'query': '',
|
||||
'results': [],
|
||||
'mode_counts': {},
|
||||
})
|
||||
|
||||
needle = query.lower()
|
||||
results: list[dict[str, Any]] = []
|
||||
mode_counts: dict[str, int] = {}
|
||||
|
||||
def push(mode: str, entity_id: str, title: str, subtitle: str, last_seen: str | None = None) -> None:
|
||||
if len(results) >= limit:
|
||||
return
|
||||
results.append({
|
||||
'mode': mode,
|
||||
'id': entity_id,
|
||||
'title': title,
|
||||
'subtitle': subtitle,
|
||||
'last_seen': last_seen,
|
||||
})
|
||||
mode_counts[mode] = mode_counts.get(mode, 0) + 1
|
||||
|
||||
# ADS-B
|
||||
for icao, aircraft in app_module.adsb_aircraft.items():
|
||||
if not isinstance(aircraft, dict):
|
||||
continue
|
||||
fields = [
|
||||
icao,
|
||||
aircraft.get('icao'),
|
||||
aircraft.get('hex'),
|
||||
aircraft.get('callsign'),
|
||||
aircraft.get('registration'),
|
||||
aircraft.get('flight'),
|
||||
]
|
||||
if not _matches_query(needle, fields):
|
||||
continue
|
||||
title = str(aircraft.get('callsign') or icao or 'Aircraft').strip()
|
||||
subtitle = f"ICAO {aircraft.get('icao') or icao} | Alt {aircraft.get('altitude', '--')} | Speed {aircraft.get('speed', '--')}"
|
||||
push('adsb', str(icao), title, subtitle, aircraft.get('lastSeen') or aircraft.get('last_seen'))
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
# AIS
|
||||
if len(results) < limit:
|
||||
for mmsi, vessel in app_module.ais_vessels.items():
|
||||
if not isinstance(vessel, dict):
|
||||
continue
|
||||
fields = [
|
||||
mmsi,
|
||||
vessel.get('mmsi'),
|
||||
vessel.get('name'),
|
||||
vessel.get('shipname'),
|
||||
vessel.get('callsign'),
|
||||
vessel.get('imo'),
|
||||
]
|
||||
if not _matches_query(needle, fields):
|
||||
continue
|
||||
vessel_name = vessel.get('name') or vessel.get('shipname') or mmsi or 'Vessel'
|
||||
subtitle = f"MMSI {vessel.get('mmsi') or mmsi} | Type {vessel.get('ship_type') or vessel.get('type') or '--'}"
|
||||
push('ais', str(mmsi), str(vessel_name), subtitle, vessel.get('lastSeen') or vessel.get('last_seen'))
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
# WiFi networks and clients
|
||||
if len(results) < limit:
|
||||
for bssid, net in app_module.wifi_networks.items():
|
||||
if not isinstance(net, dict):
|
||||
continue
|
||||
fields = [bssid, net.get('bssid'), net.get('ssid'), net.get('vendor')]
|
||||
if not _matches_query(needle, fields):
|
||||
continue
|
||||
title = str(net.get('ssid') or net.get('bssid') or bssid or 'WiFi Network')
|
||||
subtitle = f"BSSID {net.get('bssid') or bssid} | CH {net.get('channel', '--')} | RSSI {net.get('signal', '--')}"
|
||||
push('wifi', str(bssid), title, subtitle, net.get('lastSeen') or net.get('last_seen'))
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
if len(results) < limit:
|
||||
for client_mac, client in app_module.wifi_clients.items():
|
||||
if not isinstance(client, dict):
|
||||
continue
|
||||
fields = [client_mac, client.get('mac'), client.get('bssid'), client.get('ssid'), client.get('vendor')]
|
||||
if not _matches_query(needle, fields):
|
||||
continue
|
||||
title = str(client.get('mac') or client_mac or 'WiFi Client')
|
||||
subtitle = f"BSSID {client.get('bssid') or '--'} | Probe {client.get('ssid') or '--'}"
|
||||
push('wifi', str(client_mac), title, subtitle, client.get('lastSeen') or client.get('last_seen'))
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
# Bluetooth
|
||||
if len(results) < limit:
|
||||
for address, dev in app_module.bt_devices.items():
|
||||
if not isinstance(dev, dict):
|
||||
continue
|
||||
fields = [
|
||||
address,
|
||||
dev.get('address'),
|
||||
dev.get('mac'),
|
||||
dev.get('name'),
|
||||
dev.get('manufacturer'),
|
||||
dev.get('vendor'),
|
||||
]
|
||||
if not _matches_query(needle, fields):
|
||||
continue
|
||||
title = str(dev.get('name') or dev.get('address') or address or 'Bluetooth Device')
|
||||
subtitle = f"MAC {dev.get('address') or address} | RSSI {dev.get('rssi', '--')} | Vendor {dev.get('manufacturer') or dev.get('vendor') or '--'}"
|
||||
push('bluetooth', str(address), title, subtitle, dev.get('lastSeen') or dev.get('last_seen'))
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
# DSC recent messages
|
||||
if len(results) < limit:
|
||||
for msg_id, msg in app_module.dsc_messages.items():
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
fields = [
|
||||
msg_id,
|
||||
msg.get('mmsi'),
|
||||
msg.get('from_mmsi'),
|
||||
msg.get('to_mmsi'),
|
||||
msg.get('from_callsign'),
|
||||
msg.get('to_callsign'),
|
||||
msg.get('category'),
|
||||
]
|
||||
if not _matches_query(needle, fields):
|
||||
continue
|
||||
title = str(msg.get('from_mmsi') or msg.get('mmsi') or msg_id or 'DSC Message')
|
||||
subtitle = f"To {msg.get('to_mmsi') or '--'} | Cat {msg.get('category') or '--'} | Freq {msg.get('frequency') or '--'}"
|
||||
push('dsc', str(msg_id), title, subtitle, msg.get('timestamp') or msg.get('lastSeen') or msg.get('last_seen'))
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'query': query,
|
||||
'results': results,
|
||||
'mode_counts': mode_counts,
|
||||
})
|
||||
|
||||
|
||||
@analytics_bp.route('/insights')
|
||||
def analytics_insights():
|
||||
"""Return actionable insight cards and top changes."""
|
||||
counts = get_cross_mode_summary()
|
||||
tracker = get_activity_tracker()
|
||||
sparklines = tracker.get_all_sparklines()
|
||||
squawks = get_emergency_squawks()
|
||||
patterns = get_pattern_detector().get_all_patterns()
|
||||
alerts = get_alert_manager().list_events(limit=120)
|
||||
|
||||
top_changes = _compute_mode_changes(sparklines)
|
||||
busiest_mode, busiest_count = _get_busiest_mode(counts)
|
||||
critical_1h = _count_recent_alerts(alerts, severities={'critical', 'high'}, max_age_seconds=3600)
|
||||
recurring_emitters = sum(1 for p in patterns if float(p.get('confidence') or 0.0) >= 0.7)
|
||||
|
||||
cards = []
|
||||
if top_changes:
|
||||
lead = top_changes[0]
|
||||
direction = 'up' if lead['delta'] >= 0 else 'down'
|
||||
cards.append({
|
||||
'id': 'fastest_change',
|
||||
'title': 'Fastest Change',
|
||||
'value': f"{lead['mode_label']} ({lead['signed_delta']})",
|
||||
'label': 'last window vs prior',
|
||||
'severity': 'high' if lead['delta'] > 0 else 'low',
|
||||
'detail': f"Traffic is trending {direction} in {lead['mode_label']}.",
|
||||
})
|
||||
else:
|
||||
cards.append({
|
||||
'id': 'fastest_change',
|
||||
'title': 'Fastest Change',
|
||||
'value': 'Insufficient data',
|
||||
'label': 'wait for activity history',
|
||||
'severity': 'low',
|
||||
'detail': 'Sparklines need more samples to score momentum.',
|
||||
})
|
||||
|
||||
cards.append({
|
||||
'id': 'busiest_mode',
|
||||
'title': 'Busiest Mode',
|
||||
'value': f"{busiest_mode} ({busiest_count})",
|
||||
'label': 'current observed entities',
|
||||
'severity': 'medium' if busiest_count > 0 else 'low',
|
||||
'detail': 'Highest live entity count across monitoring modes.',
|
||||
})
|
||||
cards.append({
|
||||
'id': 'critical_alerts',
|
||||
'title': 'Critical Alerts (1h)',
|
||||
'value': str(critical_1h),
|
||||
'label': 'critical/high severities',
|
||||
'severity': 'critical' if critical_1h > 0 else 'low',
|
||||
'detail': 'Prioritize triage if this count is non-zero.',
|
||||
})
|
||||
cards.append({
|
||||
'id': 'emergency_squawks',
|
||||
'title': 'Emergency Squawks',
|
||||
'value': str(len(squawks)),
|
||||
'label': 'active ADS-B emergency codes',
|
||||
'severity': 'critical' if squawks else 'low',
|
||||
'detail': 'Immediate aviation anomalies currently visible.',
|
||||
})
|
||||
cards.append({
|
||||
'id': 'recurring_emitters',
|
||||
'title': 'Recurring Emitters',
|
||||
'value': str(recurring_emitters),
|
||||
'label': 'pattern confidence >= 0.70',
|
||||
'severity': 'medium' if recurring_emitters > 0 else 'low',
|
||||
'detail': 'Potentially stationary or periodic emitters detected.',
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'generated_at': datetime.now(timezone.utc).isoformat(),
|
||||
'cards': cards,
|
||||
'top_changes': top_changes[:5],
|
||||
})
|
||||
|
||||
|
||||
def _compute_mode_changes(sparklines: dict[str, list[int]]) -> list[dict]:
|
||||
mode_labels = {
|
||||
'adsb': 'ADS-B',
|
||||
'ais': 'AIS',
|
||||
'wifi': 'WiFi',
|
||||
'bluetooth': 'Bluetooth',
|
||||
'dsc': 'DSC',
|
||||
'acars': 'ACARS',
|
||||
'vdl2': 'VDL2',
|
||||
'aprs': 'APRS',
|
||||
'meshtastic': 'Meshtastic',
|
||||
}
|
||||
rows = []
|
||||
for mode, samples in (sparklines or {}).items():
|
||||
if not isinstance(samples, list) or len(samples) < 4:
|
||||
continue
|
||||
|
||||
window = max(2, min(12, len(samples) // 2))
|
||||
recent = samples[-window:]
|
||||
previous = samples[-(window * 2):-window]
|
||||
if not previous:
|
||||
continue
|
||||
|
||||
recent_avg = sum(recent) / len(recent)
|
||||
prev_avg = sum(previous) / len(previous)
|
||||
delta = round(recent_avg - prev_avg, 1)
|
||||
rows.append({
|
||||
'mode': mode,
|
||||
'mode_label': mode_labels.get(mode, mode.upper()),
|
||||
'delta': delta,
|
||||
'signed_delta': ('+' if delta >= 0 else '') + str(delta),
|
||||
'recent_avg': round(recent_avg, 1),
|
||||
'previous_avg': round(prev_avg, 1),
|
||||
'direction': 'up' if delta > 0 else ('down' if delta < 0 else 'flat'),
|
||||
})
|
||||
|
||||
rows.sort(key=lambda r: abs(r['delta']), reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def _matches_query(needle: str, values: list[Any]) -> bool:
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if needle in str(value).lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _count_recent_alerts(alerts: list[dict], severities: set[str], max_age_seconds: int) -> int:
|
||||
now = datetime.now(timezone.utc)
|
||||
count = 0
|
||||
for event in alerts:
|
||||
sev = str(event.get('severity') or '').lower()
|
||||
if sev not in severities:
|
||||
continue
|
||||
created_raw = event.get('created_at')
|
||||
if not created_raw:
|
||||
continue
|
||||
try:
|
||||
created = datetime.fromisoformat(str(created_raw).replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
continue
|
||||
if created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
age = (now - created).total_seconds()
|
||||
if 0 <= age <= max_age_seconds:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _get_busiest_mode(counts: dict[str, int]) -> tuple[str, int]:
|
||||
mode_labels = {
|
||||
'adsb': 'ADS-B',
|
||||
'ais': 'AIS',
|
||||
'wifi': 'WiFi',
|
||||
'bluetooth': 'Bluetooth',
|
||||
'dsc': 'DSC',
|
||||
'acars': 'ACARS',
|
||||
'vdl2': 'VDL2',
|
||||
'aprs': 'APRS',
|
||||
'meshtastic': 'Meshtastic',
|
||||
}
|
||||
filtered = {k: int(v or 0) for k, v in (counts or {}).items() if k in mode_labels}
|
||||
if not filtered:
|
||||
return ('None', 0)
|
||||
mode = max(filtered, key=filtered.get)
|
||||
return (mode_labels.get(mode, mode.upper()), filtered[mode])
|
||||
|
||||
|
||||
@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] = []
|
||||
|
||||
# Try v2 scanners first for wifi/bluetooth
|
||||
if mode == 'wifi':
|
||||
try:
|
||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||
if wifi_scanner is not None:
|
||||
for ap in wifi_scanner.access_points:
|
||||
all_items.append(ap.to_dict())
|
||||
for client in wifi_scanner.clients:
|
||||
item = client.to_dict()
|
||||
item['_store'] = 'wifi_clients'
|
||||
all_items.append(item)
|
||||
except Exception:
|
||||
pass
|
||||
elif mode == 'bluetooth':
|
||||
try:
|
||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||
if bt_scanner is not None:
|
||||
for dev in bt_scanner.get_devices():
|
||||
all_items.append(dev.to_dict())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fall back to legacy DataStores if v2 scanners yielded nothing
|
||||
if not all_items:
|
||||
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'})
|
||||
@@ -0,0 +1,113 @@
|
||||
"""RF Fingerprinting CRUD + compare API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
fingerprint_bp = Blueprint("fingerprint", __name__, url_prefix="/fingerprint")
|
||||
|
||||
_fingerprinter = None
|
||||
_fingerprinter_lock = threading.Lock()
|
||||
|
||||
_active_session_id: int | None = None
|
||||
_session_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_fingerprinter():
|
||||
global _fingerprinter
|
||||
if _fingerprinter is None:
|
||||
with _fingerprinter_lock:
|
||||
if _fingerprinter is None:
|
||||
from utils.rf_fingerprint import RFFingerprinter
|
||||
db_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "instance", "rf_fingerprints.db"
|
||||
)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
_fingerprinter = RFFingerprinter(db_path)
|
||||
return _fingerprinter
|
||||
|
||||
|
||||
@fingerprint_bp.route("/start", methods=["POST"])
|
||||
def start_session():
|
||||
global _active_session_id
|
||||
data = request.get_json(force=True) or {}
|
||||
name = data.get("name", "Unnamed Session")
|
||||
location = data.get("location")
|
||||
fp = _get_fingerprinter()
|
||||
with _session_lock:
|
||||
if _active_session_id is not None:
|
||||
return jsonify({"error": "Session already active", "session_id": _active_session_id}), 409
|
||||
session_id = fp.start_session(name, location)
|
||||
_active_session_id = session_id
|
||||
return jsonify({"session_id": session_id, "name": name})
|
||||
|
||||
|
||||
@fingerprint_bp.route("/stop", methods=["POST"])
|
||||
def stop_session():
|
||||
global _active_session_id
|
||||
fp = _get_fingerprinter()
|
||||
with _session_lock:
|
||||
if _active_session_id is None:
|
||||
return jsonify({"error": "No active session"}), 400
|
||||
session_id = _active_session_id
|
||||
result = fp.finalize(session_id)
|
||||
_active_session_id = None
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@fingerprint_bp.route("/observation", methods=["POST"])
|
||||
def add_observation():
|
||||
global _active_session_id
|
||||
fp = _get_fingerprinter()
|
||||
data = request.get_json(force=True) or {}
|
||||
observations = data.get("observations", [])
|
||||
with _session_lock:
|
||||
session_id = _active_session_id
|
||||
if session_id is None:
|
||||
return jsonify({"error": "No active session"}), 400
|
||||
if not observations:
|
||||
return jsonify({"added": 0})
|
||||
fp.add_observations_batch(session_id, observations)
|
||||
return jsonify({"added": len(observations)})
|
||||
|
||||
|
||||
@fingerprint_bp.route("/list", methods=["GET"])
|
||||
def list_sessions():
|
||||
fp = _get_fingerprinter()
|
||||
sessions = fp.list_sessions()
|
||||
with _session_lock:
|
||||
active_id = _active_session_id
|
||||
return jsonify({"sessions": sessions, "active_session_id": active_id})
|
||||
|
||||
|
||||
@fingerprint_bp.route("/compare", methods=["POST"])
|
||||
def compare():
|
||||
fp = _get_fingerprinter()
|
||||
data = request.get_json(force=True) or {}
|
||||
baseline_id = data.get("baseline_id")
|
||||
observations = data.get("observations", [])
|
||||
if not baseline_id:
|
||||
return jsonify({"error": "baseline_id required"}), 400
|
||||
anomalies = fp.compare(int(baseline_id), observations)
|
||||
bands = fp.get_baseline_bands(int(baseline_id))
|
||||
return jsonify({"anomalies": anomalies, "baseline_bands": bands})
|
||||
|
||||
|
||||
@fingerprint_bp.route("/<int:session_id>", methods=["DELETE"])
|
||||
def delete_session(session_id: int):
|
||||
global _active_session_id
|
||||
fp = _get_fingerprinter()
|
||||
with _session_lock:
|
||||
if _active_session_id == session_id:
|
||||
_active_session_id = None
|
||||
fp.delete_session(session_id)
|
||||
return jsonify({"deleted": session_id})
|
||||
|
||||
|
||||
@fingerprint_bp.route("/status", methods=["GET"])
|
||||
def session_status():
|
||||
with _session_lock:
|
||||
active_id = _active_session_id
|
||||
return jsonify({"active_session_id": active_id})
|
||||
+409
-203
@@ -5,15 +5,16 @@ from __future__ import annotations
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import select
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import queue
|
||||
import select
|
||||
import signal
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator, Optional, List, Dict
|
||||
from typing import Any, Dict, Generator, List, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
@@ -40,9 +41,10 @@ listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening
|
||||
audio_process = None
|
||||
audio_rtl_process = None
|
||||
audio_lock = threading.Lock()
|
||||
audio_running = False
|
||||
audio_frequency = 0.0
|
||||
audio_modulation = 'fm'
|
||||
audio_running = False
|
||||
audio_frequency = 0.0
|
||||
audio_modulation = 'fm'
|
||||
audio_source = 'process'
|
||||
|
||||
# Scanner state
|
||||
scanner_thread: Optional[threading.Thread] = None
|
||||
@@ -117,6 +119,22 @@ def _rtl_fm_demod_mode(modulation: str) -> str:
|
||||
"""Map UI modulation names to rtl_fm demod tokens."""
|
||||
mod = str(modulation or '').lower().strip()
|
||||
return 'wbfm' if mod == 'wfm' else mod
|
||||
|
||||
|
||||
def _wav_header(sample_rate: int = 48000, bits_per_sample: int = 16, channels: int = 1) -> bytes:
|
||||
"""Create a streaming WAV header with unknown data length."""
|
||||
bytes_per_sample = bits_per_sample // 8
|
||||
byte_rate = sample_rate * channels * bytes_per_sample
|
||||
block_align = channels * bytes_per_sample
|
||||
return (
|
||||
b'RIFF'
|
||||
+ struct.pack('<I', 0xFFFFFFFF)
|
||||
+ b'WAVE'
|
||||
+ b'fmt '
|
||||
+ struct.pack('<IHHIIHH', 16, 1, channels, sample_rate, byte_rate, block_align, bits_per_sample)
|
||||
+ b'data'
|
||||
+ struct.pack('<I', 0xFFFFFFFF)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -694,11 +712,11 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
'-g', str(scanner_config['gain']),
|
||||
'-d', str(scanner_config['device']),
|
||||
'-l', str(scanner_config['squelch']),
|
||||
]
|
||||
if scanner_config.get('bias_t', False):
|
||||
sdr_cmd.append('-T')
|
||||
# Explicitly output to stdout (some rtl_fm versions need this)
|
||||
sdr_cmd.append('-')
|
||||
]
|
||||
if scanner_config.get('bias_t', False):
|
||||
sdr_cmd.append('-T')
|
||||
# Omit explicit filename: rtl_fm defaults to stdout.
|
||||
# (Some builds intermittently stall when '-' is passed explicitly.)
|
||||
else:
|
||||
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
||||
rx_fm_path = find_rx_fm()
|
||||
@@ -842,22 +860,22 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
# Pipeline started successfully
|
||||
break
|
||||
|
||||
# Validate that audio is producing data quickly
|
||||
try:
|
||||
ready, _, _ = select.select([audio_process.stdout], [], [], 4.0)
|
||||
if not ready:
|
||||
logger.warning("Audio pipeline produced no data in startup window — killing stalled pipeline")
|
||||
_stop_audio_stream_internal()
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Audio startup check failed: {e}")
|
||||
_stop_audio_stream_internal()
|
||||
return
|
||||
|
||||
audio_running = True
|
||||
audio_frequency = frequency
|
||||
audio_modulation = modulation
|
||||
logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {sdr_type.value}")
|
||||
# Keep monitor startup tolerant: some demod chains can take
|
||||
# several seconds before producing stream bytes.
|
||||
if (
|
||||
not audio_process
|
||||
or not audio_rtl_process
|
||||
or audio_process.poll() is not None
|
||||
or audio_rtl_process.poll() is not None
|
||||
):
|
||||
logger.warning("Audio pipeline did not remain alive after startup")
|
||||
_stop_audio_stream_internal()
|
||||
return
|
||||
|
||||
audio_running = True
|
||||
audio_frequency = frequency
|
||||
audio_modulation = modulation
|
||||
logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {sdr_type.value}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start audio stream: {e}")
|
||||
@@ -869,15 +887,25 @@ def _stop_audio_stream():
|
||||
_stop_audio_stream_internal()
|
||||
|
||||
|
||||
def _stop_audio_stream_internal():
|
||||
"""Internal stop (must hold lock)."""
|
||||
global audio_process, audio_rtl_process, audio_running, audio_frequency
|
||||
|
||||
# Set flag first to stop any streaming
|
||||
audio_running = False
|
||||
audio_frequency = 0.0
|
||||
|
||||
had_processes = audio_process is not None or audio_rtl_process is not None
|
||||
def _stop_audio_stream_internal():
|
||||
"""Internal stop (must hold lock)."""
|
||||
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_source
|
||||
|
||||
# Set flag first to stop any streaming
|
||||
audio_running = False
|
||||
audio_frequency = 0.0
|
||||
previous_source = audio_source
|
||||
audio_source = 'process'
|
||||
|
||||
if previous_source == 'waterfall':
|
||||
try:
|
||||
from routes.waterfall_websocket import stop_shared_monitor_from_capture
|
||||
|
||||
stop_shared_monitor_from_capture()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
had_processes = audio_process is not None or audio_rtl_process is not None
|
||||
|
||||
# Kill the pipeline processes and their groups
|
||||
if audio_process:
|
||||
@@ -1240,9 +1268,10 @@ def get_presets() -> Response:
|
||||
# ============================================
|
||||
|
||||
@listening_post_bp.route('/audio/start', methods=['POST'])
|
||||
def start_audio() -> Response:
|
||||
"""Start audio at specific frequency (manual mode)."""
|
||||
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
|
||||
def start_audio() -> Response:
|
||||
"""Start audio at specific frequency (manual mode)."""
|
||||
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
|
||||
global audio_running, audio_frequency, audio_modulation, audio_source
|
||||
|
||||
# Stop scanner if running
|
||||
if scanner_running:
|
||||
@@ -1273,18 +1302,23 @@ def start_audio() -> Response:
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
frequency = float(data.get('frequency', 0))
|
||||
modulation = normalize_modulation(data.get('modulation', 'wfm'))
|
||||
squelch = int(data.get('squelch', 0))
|
||||
gain = int(data.get('gain', 40))
|
||||
device = int(data.get('device', 0))
|
||||
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid parameter: {e}'
|
||||
}), 400
|
||||
try:
|
||||
frequency = float(data.get('frequency', 0))
|
||||
modulation = normalize_modulation(data.get('modulation', 'wfm'))
|
||||
squelch = int(data.get('squelch', 0))
|
||||
gain = int(data.get('gain', 40))
|
||||
device = int(data.get('device', 0))
|
||||
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False))
|
||||
if isinstance(bias_t_raw, str):
|
||||
bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||
else:
|
||||
bias_t = bool(bias_t_raw)
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid parameter: {e}'
|
||||
}), 400
|
||||
|
||||
if frequency <= 0:
|
||||
return jsonify({
|
||||
@@ -1301,14 +1335,51 @@ def start_audio() -> Response:
|
||||
|
||||
# Update config for audio
|
||||
scanner_config['squelch'] = squelch
|
||||
scanner_config['gain'] = gain
|
||||
scanner_config['device'] = device
|
||||
scanner_config['sdr_type'] = sdr_type
|
||||
|
||||
# Stop waterfall if it's using the same SDR (SSE path)
|
||||
if waterfall_running and waterfall_active_device == device:
|
||||
_stop_waterfall_internal()
|
||||
time.sleep(0.2)
|
||||
scanner_config['gain'] = gain
|
||||
scanner_config['device'] = device
|
||||
scanner_config['sdr_type'] = sdr_type
|
||||
scanner_config['bias_t'] = bias_t
|
||||
|
||||
# Preferred path: when waterfall WebSocket is active on the same SDR,
|
||||
# derive monitor audio from that IQ stream instead of spawning rtl_fm.
|
||||
try:
|
||||
from routes.waterfall_websocket import (
|
||||
get_shared_capture_status,
|
||||
start_shared_monitor_from_capture,
|
||||
)
|
||||
|
||||
shared = get_shared_capture_status()
|
||||
if shared.get('running') and shared.get('device') == device:
|
||||
_stop_audio_stream()
|
||||
ok, msg = start_shared_monitor_from_capture(
|
||||
device=device,
|
||||
frequency_mhz=frequency,
|
||||
modulation=modulation,
|
||||
squelch=squelch,
|
||||
)
|
||||
if ok:
|
||||
audio_running = True
|
||||
audio_frequency = frequency
|
||||
audio_modulation = modulation
|
||||
audio_source = 'waterfall'
|
||||
# Shared monitor uses the waterfall's existing SDR claim.
|
||||
if listening_active_device is not None:
|
||||
app_module.release_sdr_device(listening_active_device)
|
||||
listening_active_device = None
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': modulation,
|
||||
'source': 'waterfall',
|
||||
})
|
||||
logger.warning(f"Shared waterfall monitor unavailable: {msg}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Shared waterfall monitor probe failed: {e}")
|
||||
|
||||
# Stop waterfall if it's using the same SDR (SSE path)
|
||||
if waterfall_running and waterfall_active_device == device:
|
||||
_stop_waterfall_internal()
|
||||
time.sleep(0.2)
|
||||
|
||||
# Claim device for listening audio. The WebSocket waterfall handler
|
||||
# may still be tearing down its IQ capture process (thread join +
|
||||
@@ -1319,22 +1390,15 @@ def start_audio() -> Response:
|
||||
app_module.release_sdr_device(listening_active_device)
|
||||
listening_active_device = None
|
||||
|
||||
error = None
|
||||
max_claim_attempts = 6
|
||||
for attempt in range(max_claim_attempts):
|
||||
# Force-release a stale waterfall registry entry on each
|
||||
# attempt — the WebSocket handler may not have finished
|
||||
# cleanup yet.
|
||||
device_status = app_module.get_sdr_device_status()
|
||||
if device_status.get(device) == 'waterfall':
|
||||
app_module.release_sdr_device(device)
|
||||
|
||||
error = app_module.claim_sdr_device(device, 'listening')
|
||||
if not error:
|
||||
break
|
||||
if attempt < max_claim_attempts - 1:
|
||||
logger.debug(
|
||||
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
|
||||
error = None
|
||||
max_claim_attempts = 6
|
||||
for attempt in range(max_claim_attempts):
|
||||
error = app_module.claim_sdr_device(device, 'listening')
|
||||
if not error:
|
||||
break
|
||||
if attempt < max_claim_attempts - 1:
|
||||
logger.debug(
|
||||
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
|
||||
f"failed, retrying in 0.5s: {error}"
|
||||
)
|
||||
time.sleep(0.5)
|
||||
@@ -1347,19 +1411,40 @@ def start_audio() -> Response:
|
||||
}), 409
|
||||
listening_active_device = device
|
||||
|
||||
_start_audio_stream(frequency, modulation)
|
||||
|
||||
if audio_running:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': modulation
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start audio. Check SDR device.'
|
||||
}), 500
|
||||
_start_audio_stream(frequency, modulation)
|
||||
|
||||
if audio_running:
|
||||
audio_source = 'process'
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': modulation,
|
||||
'source': 'process',
|
||||
})
|
||||
else:
|
||||
# Avoid leaving a stale device claim after startup failure.
|
||||
if listening_active_device is not None:
|
||||
app_module.release_sdr_device(listening_active_device)
|
||||
listening_active_device = None
|
||||
|
||||
start_error = ''
|
||||
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
|
||||
try:
|
||||
with open(log_path, 'r') as handle:
|
||||
content = handle.read().strip()
|
||||
if content:
|
||||
start_error = content.splitlines()[-1]
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
message = 'Failed to start audio. Check SDR device.'
|
||||
if start_error:
|
||||
message = f'Failed to start audio: {start_error}'
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': message
|
||||
}), 500
|
||||
|
||||
|
||||
@listening_post_bp.route('/audio/stop', methods=['POST'])
|
||||
@@ -1373,19 +1458,30 @@ def stop_audio() -> Response:
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@listening_post_bp.route('/audio/status')
|
||||
def audio_status() -> Response:
|
||||
"""Get audio status."""
|
||||
return jsonify({
|
||||
'running': audio_running,
|
||||
'frequency': audio_frequency,
|
||||
'modulation': audio_modulation
|
||||
})
|
||||
@listening_post_bp.route('/audio/status')
|
||||
def audio_status() -> Response:
|
||||
"""Get audio status."""
|
||||
running = audio_running
|
||||
if audio_source == 'waterfall':
|
||||
try:
|
||||
from routes.waterfall_websocket import get_shared_capture_status
|
||||
|
||||
shared = get_shared_capture_status()
|
||||
running = bool(shared.get('running') and shared.get('monitor_enabled'))
|
||||
except Exception:
|
||||
running = False
|
||||
|
||||
return jsonify({
|
||||
'running': running,
|
||||
'frequency': audio_frequency,
|
||||
'modulation': audio_modulation,
|
||||
'source': audio_source,
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/audio/debug')
|
||||
def audio_debug() -> Response:
|
||||
"""Get audio debug status and recent stderr logs."""
|
||||
def audio_debug() -> Response:
|
||||
"""Get audio debug status and recent stderr logs."""
|
||||
rtl_log_path = '/tmp/rtl_fm_stderr.log'
|
||||
ffmpeg_log_path = '/tmp/ffmpeg_stderr.log'
|
||||
sample_path = '/tmp/audio_probe.bin'
|
||||
@@ -1397,28 +1493,53 @@ def audio_debug() -> Response:
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
return jsonify({
|
||||
'running': audio_running,
|
||||
'frequency': audio_frequency,
|
||||
'modulation': audio_modulation,
|
||||
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
|
||||
'device': scanner_config.get('device', 0),
|
||||
'gain': scanner_config.get('gain', 0),
|
||||
'squelch': scanner_config.get('squelch', 0),
|
||||
'audio_process_alive': bool(audio_process and audio_process.poll() is None),
|
||||
'rtl_fm_stderr': _read_log(rtl_log_path),
|
||||
'ffmpeg_stderr': _read_log(ffmpeg_log_path),
|
||||
'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
|
||||
})
|
||||
shared = {}
|
||||
if audio_source == 'waterfall':
|
||||
try:
|
||||
from routes.waterfall_websocket import get_shared_capture_status
|
||||
|
||||
shared = get_shared_capture_status()
|
||||
except Exception:
|
||||
shared = {}
|
||||
|
||||
return jsonify({
|
||||
'running': audio_running,
|
||||
'frequency': audio_frequency,
|
||||
'modulation': audio_modulation,
|
||||
'source': audio_source,
|
||||
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
|
||||
'device': scanner_config.get('device', 0),
|
||||
'gain': scanner_config.get('gain', 0),
|
||||
'squelch': scanner_config.get('squelch', 0),
|
||||
'audio_process_alive': bool(audio_process and audio_process.poll() is None),
|
||||
'shared_capture': shared,
|
||||
'rtl_fm_stderr': _read_log(rtl_log_path),
|
||||
'ffmpeg_stderr': _read_log(ffmpeg_log_path),
|
||||
'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/audio/probe')
|
||||
def audio_probe() -> Response:
|
||||
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
|
||||
global audio_process
|
||||
|
||||
if not audio_process or not audio_process.stdout:
|
||||
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
|
||||
@listening_post_bp.route('/audio/probe')
|
||||
def audio_probe() -> Response:
|
||||
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
|
||||
global audio_process
|
||||
|
||||
if audio_source == 'waterfall':
|
||||
try:
|
||||
from routes.waterfall_websocket import read_shared_monitor_audio_chunk
|
||||
|
||||
data = read_shared_monitor_audio_chunk(timeout=2.0)
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'no shared audio data available'}), 504
|
||||
sample_path = '/tmp/audio_probe.bin'
|
||||
with open(sample_path, 'wb') as handle:
|
||||
handle.write(data)
|
||||
return jsonify({'status': 'ok', 'bytes': len(data), 'source': 'waterfall'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
if not audio_process or not audio_process.stdout:
|
||||
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
|
||||
|
||||
sample_path = '/tmp/audio_probe.bin'
|
||||
size = 0
|
||||
@@ -1438,17 +1559,61 @@ def audio_probe() -> Response:
|
||||
return jsonify({'status': 'ok', 'bytes': size})
|
||||
|
||||
|
||||
@listening_post_bp.route('/audio/stream')
|
||||
def stream_audio() -> Response:
|
||||
"""Stream WAV audio."""
|
||||
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
||||
for _ in range(40):
|
||||
if audio_running and audio_process:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
if not audio_running or not audio_process:
|
||||
return Response(b'', mimetype='audio/mpeg', status=204)
|
||||
@listening_post_bp.route('/audio/stream')
|
||||
def stream_audio() -> Response:
|
||||
"""Stream WAV audio."""
|
||||
if audio_source == 'waterfall':
|
||||
for _ in range(40):
|
||||
if audio_running:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
if not audio_running:
|
||||
return Response(b'', mimetype='audio/wav', status=204)
|
||||
|
||||
def generate_shared():
|
||||
global audio_running, audio_source
|
||||
try:
|
||||
from routes.waterfall_websocket import (
|
||||
get_shared_capture_status,
|
||||
read_shared_monitor_audio_chunk,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# Browser expects an immediate WAV header.
|
||||
yield _wav_header(sample_rate=48000)
|
||||
|
||||
while audio_running and audio_source == 'waterfall':
|
||||
chunk = read_shared_monitor_audio_chunk(timeout=1.0)
|
||||
if chunk:
|
||||
yield chunk
|
||||
continue
|
||||
shared = get_shared_capture_status()
|
||||
if not shared.get('running') or not shared.get('monitor_enabled'):
|
||||
audio_running = False
|
||||
audio_source = 'process'
|
||||
break
|
||||
|
||||
return Response(
|
||||
generate_shared(),
|
||||
mimetype='audio/wav',
|
||||
headers={
|
||||
'Content-Type': 'audio/wav',
|
||||
'Cache-Control': 'no-cache, no-store',
|
||||
'X-Accel-Buffering': 'no',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
}
|
||||
)
|
||||
|
||||
# Wait for audio process to be ready (up to 2 seconds).
|
||||
for _ in range(40):
|
||||
if audio_running and audio_process:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
if not audio_running or not audio_process:
|
||||
return Response(b'', mimetype='audio/wav', status=204)
|
||||
|
||||
def generate():
|
||||
# Capture local reference to avoid race condition with stop
|
||||
@@ -1473,25 +1638,29 @@ def stream_audio() -> Response:
|
||||
if header_chunk:
|
||||
yield header_chunk
|
||||
|
||||
# Stream real-time audio
|
||||
first_chunk_deadline = time.time() + 3.0
|
||||
while audio_running and proc.poll() is None:
|
||||
# Use select to avoid blocking forever
|
||||
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
||||
if ready:
|
||||
chunk = proc.stdout.read(8192)
|
||||
if chunk:
|
||||
yield chunk
|
||||
else:
|
||||
break
|
||||
else:
|
||||
# If no data arrives shortly after start, exit so caller can retry
|
||||
if time.time() > first_chunk_deadline:
|
||||
logger.warning("Audio stream timed out waiting for first chunk")
|
||||
break
|
||||
# Timeout - check if process died
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
# Stream real-time audio
|
||||
first_chunk_deadline = time.time() + 20.0
|
||||
warned_wait = False
|
||||
while audio_running and proc.poll() is None:
|
||||
# Use select to avoid blocking forever
|
||||
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
||||
if ready:
|
||||
chunk = proc.stdout.read(8192)
|
||||
if chunk:
|
||||
warned_wait = False
|
||||
yield chunk
|
||||
else:
|
||||
break
|
||||
else:
|
||||
# Keep connection open while demodulator settles.
|
||||
if time.time() > first_chunk_deadline:
|
||||
if not warned_wait:
|
||||
logger.warning("Audio stream still waiting for first chunk")
|
||||
warned_wait = True
|
||||
continue
|
||||
# Timeout - check if process died
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except Exception as e:
|
||||
@@ -1617,15 +1786,26 @@ def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float |
|
||||
return timestamp, None, None, []
|
||||
|
||||
|
||||
def _waterfall_loop():
|
||||
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
||||
global waterfall_running, waterfall_process
|
||||
|
||||
rtl_power_path = find_rtl_power()
|
||||
if not rtl_power_path:
|
||||
logger.error("rtl_power not found for waterfall")
|
||||
waterfall_running = False
|
||||
return
|
||||
def _waterfall_loop():
|
||||
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
||||
global waterfall_running, waterfall_process
|
||||
|
||||
def _queue_waterfall_error(message: str) -> None:
|
||||
try:
|
||||
waterfall_queue.put_nowait({
|
||||
'type': 'waterfall_error',
|
||||
'message': message,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
rtl_power_path = find_rtl_power()
|
||||
if not rtl_power_path:
|
||||
logger.error("rtl_power not found for waterfall")
|
||||
_queue_waterfall_error('rtl_power not found')
|
||||
waterfall_running = False
|
||||
return
|
||||
|
||||
start_hz = int(waterfall_config['start_freq'] * 1e6)
|
||||
end_hz = int(waterfall_config['end_freq'] * 1e6)
|
||||
@@ -1643,32 +1823,49 @@ def _waterfall_loop():
|
||||
]
|
||||
|
||||
try:
|
||||
waterfall_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=1,
|
||||
text=True,
|
||||
)
|
||||
|
||||
current_ts = None
|
||||
all_bins: list[float] = []
|
||||
sweep_start_hz = start_hz
|
||||
sweep_end_hz = end_hz
|
||||
|
||||
if not waterfall_process.stdout:
|
||||
return
|
||||
|
||||
for line in waterfall_process.stdout:
|
||||
if not waterfall_running:
|
||||
break
|
||||
|
||||
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
||||
if ts is None or not bins:
|
||||
continue
|
||||
|
||||
if current_ts is None:
|
||||
current_ts = ts
|
||||
waterfall_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Detect immediate startup failures (e.g. device busy / no device).
|
||||
time.sleep(0.35)
|
||||
if waterfall_process.poll() is not None:
|
||||
stderr_text = ''
|
||||
try:
|
||||
if waterfall_process.stderr:
|
||||
stderr_text = waterfall_process.stderr.read().strip()
|
||||
except Exception:
|
||||
stderr_text = ''
|
||||
msg = stderr_text or f'rtl_power exited early (code {waterfall_process.returncode})'
|
||||
logger.error(f"Waterfall startup failed: {msg}")
|
||||
_queue_waterfall_error(msg)
|
||||
return
|
||||
|
||||
current_ts = None
|
||||
all_bins: list[float] = []
|
||||
sweep_start_hz = start_hz
|
||||
sweep_end_hz = end_hz
|
||||
received_any = False
|
||||
|
||||
if not waterfall_process.stdout:
|
||||
_queue_waterfall_error('rtl_power stdout unavailable')
|
||||
return
|
||||
|
||||
for line in waterfall_process.stdout:
|
||||
if not waterfall_running:
|
||||
break
|
||||
|
||||
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
||||
if ts is None or not bins:
|
||||
continue
|
||||
received_any = True
|
||||
|
||||
if current_ts is None:
|
||||
current_ts = ts
|
||||
|
||||
if ts != current_ts and all_bins:
|
||||
max_bins = int(waterfall_config.get('max_bins') or 0)
|
||||
@@ -1706,11 +1903,11 @@ def _waterfall_loop():
|
||||
sweep_end_hz = max(sweep_end_hz, seg_end)
|
||||
|
||||
# Flush any remaining bins
|
||||
if all_bins and waterfall_running:
|
||||
max_bins = int(waterfall_config.get('max_bins') or 0)
|
||||
bins_to_send = all_bins
|
||||
if max_bins > 0 and len(bins_to_send) > max_bins:
|
||||
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
||||
if all_bins and waterfall_running:
|
||||
max_bins = int(waterfall_config.get('max_bins') or 0)
|
||||
bins_to_send = all_bins
|
||||
if max_bins > 0 and len(bins_to_send) > max_bins:
|
||||
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
||||
msg = {
|
||||
'type': 'waterfall_sweep',
|
||||
'start_freq': sweep_start_hz / 1e6,
|
||||
@@ -1718,13 +1915,17 @@ def _waterfall_loop():
|
||||
'bins': bins_to_send,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
}
|
||||
try:
|
||||
waterfall_queue.put_nowait(msg)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Waterfall loop error: {e}")
|
||||
try:
|
||||
waterfall_queue.put_nowait(msg)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
if waterfall_running and not received_any:
|
||||
_queue_waterfall_error('No waterfall FFT data received from rtl_power')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Waterfall loop error: {e}")
|
||||
_queue_waterfall_error(f"Waterfall loop error: {e}")
|
||||
finally:
|
||||
waterfall_running = False
|
||||
if waterfall_process and waterfall_process.poll() is None:
|
||||
@@ -1766,9 +1967,14 @@ def start_waterfall() -> Response:
|
||||
"""Start the waterfall/spectrogram display."""
|
||||
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device
|
||||
|
||||
with waterfall_lock:
|
||||
if waterfall_running:
|
||||
return jsonify({'status': 'error', 'message': 'Waterfall already running'}), 409
|
||||
with waterfall_lock:
|
||||
if waterfall_running:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'already_running': True,
|
||||
'message': 'Waterfall already running',
|
||||
'config': waterfall_config,
|
||||
})
|
||||
|
||||
if not find_rtl_power():
|
||||
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
|
||||
|
||||
+650
-284
@@ -1,13 +1,16 @@
|
||||
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
||||
|
||||
import json
|
||||
import queue
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from flask import Flask
|
||||
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
||||
|
||||
import json
|
||||
import queue
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from flask import Flask
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
@@ -16,31 +19,277 @@ except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
Sock = None
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.waterfall_fft import (
|
||||
build_binary_frame,
|
||||
compute_power_spectrum,
|
||||
cu8_to_complex,
|
||||
quantize_to_uint8,
|
||||
)
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sdr.base import SDRCapabilities, SDRDevice
|
||||
from utils.logging import get_logger
|
||||
from utils.process import register_process, safe_terminate, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sdr.base import SDRCapabilities, SDRDevice
|
||||
from utils.waterfall_fft import (
|
||||
build_binary_frame,
|
||||
compute_power_spectrum,
|
||||
cu8_to_complex,
|
||||
quantize_to_uint8,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.waterfall_ws')
|
||||
|
||||
AUDIO_SAMPLE_RATE = 48000
|
||||
_shared_state_lock = threading.Lock()
|
||||
_shared_audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=80)
|
||||
_shared_state: dict[str, Any] = {
|
||||
'running': False,
|
||||
'device': None,
|
||||
'center_mhz': 0.0,
|
||||
'span_mhz': 0.0,
|
||||
'sample_rate': 0,
|
||||
'monitor_enabled': False,
|
||||
'monitor_freq_mhz': 0.0,
|
||||
'monitor_modulation': 'wfm',
|
||||
'monitor_squelch': 0,
|
||||
}
|
||||
|
||||
logger = get_logger('intercept.waterfall_ws')
|
||||
|
||||
# Maximum bandwidth per SDR type (Hz)
|
||||
MAX_BANDWIDTH = {
|
||||
SDRType.RTL_SDR: 2400000,
|
||||
SDRType.HACKRF: 20000000,
|
||||
SDRType.LIME_SDR: 20000000,
|
||||
SDRType.AIRSPY: 10000000,
|
||||
SDRType.SDRPLAY: 2000000,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
||||
"""Convert client sdr_type string to SDRType enum."""
|
||||
# Maximum bandwidth per SDR type (Hz)
|
||||
MAX_BANDWIDTH = {
|
||||
SDRType.RTL_SDR: 2400000,
|
||||
SDRType.HACKRF: 20000000,
|
||||
SDRType.LIME_SDR: 20000000,
|
||||
SDRType.AIRSPY: 10000000,
|
||||
SDRType.SDRPLAY: 2000000,
|
||||
}
|
||||
|
||||
|
||||
def _clear_shared_audio_queue() -> None:
|
||||
while True:
|
||||
try:
|
||||
_shared_audio_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
|
||||
def _set_shared_capture_state(
|
||||
*,
|
||||
running: bool,
|
||||
device: int | None = None,
|
||||
center_mhz: float | None = None,
|
||||
span_mhz: float | None = None,
|
||||
sample_rate: int | None = None,
|
||||
) -> None:
|
||||
with _shared_state_lock:
|
||||
_shared_state['running'] = bool(running)
|
||||
_shared_state['device'] = device if running else None
|
||||
if center_mhz is not None:
|
||||
_shared_state['center_mhz'] = float(center_mhz)
|
||||
if span_mhz is not None:
|
||||
_shared_state['span_mhz'] = float(span_mhz)
|
||||
if sample_rate is not None:
|
||||
_shared_state['sample_rate'] = int(sample_rate)
|
||||
if not running:
|
||||
_shared_state['monitor_enabled'] = False
|
||||
if not running:
|
||||
_clear_shared_audio_queue()
|
||||
|
||||
|
||||
def _set_shared_monitor(
|
||||
*,
|
||||
enabled: bool,
|
||||
frequency_mhz: float | None = None,
|
||||
modulation: str | None = None,
|
||||
squelch: int | None = None,
|
||||
) -> None:
|
||||
was_enabled = False
|
||||
with _shared_state_lock:
|
||||
was_enabled = bool(_shared_state.get('monitor_enabled'))
|
||||
_shared_state['monitor_enabled'] = bool(enabled)
|
||||
if frequency_mhz is not None:
|
||||
_shared_state['monitor_freq_mhz'] = float(frequency_mhz)
|
||||
if modulation is not None:
|
||||
_shared_state['monitor_modulation'] = str(modulation).lower().strip()
|
||||
if squelch is not None:
|
||||
_shared_state['monitor_squelch'] = max(0, min(100, int(squelch)))
|
||||
if was_enabled and not enabled:
|
||||
_clear_shared_audio_queue()
|
||||
|
||||
|
||||
def get_shared_capture_status() -> dict[str, Any]:
|
||||
with _shared_state_lock:
|
||||
return {
|
||||
'running': bool(_shared_state['running']),
|
||||
'device': _shared_state['device'],
|
||||
'center_mhz': float(_shared_state.get('center_mhz', 0.0) or 0.0),
|
||||
'span_mhz': float(_shared_state.get('span_mhz', 0.0) or 0.0),
|
||||
'sample_rate': int(_shared_state.get('sample_rate', 0) or 0),
|
||||
'monitor_enabled': bool(_shared_state.get('monitor_enabled')),
|
||||
'monitor_freq_mhz': float(_shared_state.get('monitor_freq_mhz', 0.0) or 0.0),
|
||||
'monitor_modulation': str(_shared_state.get('monitor_modulation', 'wfm')),
|
||||
'monitor_squelch': int(_shared_state.get('monitor_squelch', 0) or 0),
|
||||
}
|
||||
|
||||
|
||||
def start_shared_monitor_from_capture(
|
||||
*,
|
||||
device: int,
|
||||
frequency_mhz: float,
|
||||
modulation: str,
|
||||
squelch: int,
|
||||
) -> tuple[bool, str]:
|
||||
with _shared_state_lock:
|
||||
if not _shared_state['running']:
|
||||
return False, 'Waterfall IQ stream not active'
|
||||
if _shared_state['device'] != device:
|
||||
return False, 'Waterfall stream is using a different SDR device'
|
||||
_shared_state['monitor_enabled'] = True
|
||||
_shared_state['monitor_freq_mhz'] = float(frequency_mhz)
|
||||
_shared_state['monitor_modulation'] = str(modulation).lower().strip()
|
||||
_shared_state['monitor_squelch'] = max(0, min(100, int(squelch)))
|
||||
_clear_shared_audio_queue()
|
||||
return True, 'started'
|
||||
|
||||
|
||||
def stop_shared_monitor_from_capture() -> None:
|
||||
_set_shared_monitor(enabled=False)
|
||||
|
||||
|
||||
def read_shared_monitor_audio_chunk(timeout: float = 1.0) -> bytes | None:
|
||||
with _shared_state_lock:
|
||||
if not _shared_state['running'] or not _shared_state['monitor_enabled']:
|
||||
return None
|
||||
try:
|
||||
return _shared_audio_queue.get(timeout=max(0.0, float(timeout)))
|
||||
except queue.Empty:
|
||||
return None
|
||||
|
||||
|
||||
def _snapshot_monitor_config() -> dict[str, Any] | None:
|
||||
with _shared_state_lock:
|
||||
if not (_shared_state['running'] and _shared_state['monitor_enabled']):
|
||||
return None
|
||||
return {
|
||||
'center_mhz': float(_shared_state['center_mhz']),
|
||||
'monitor_freq_mhz': float(_shared_state['monitor_freq_mhz']),
|
||||
'modulation': str(_shared_state['monitor_modulation']),
|
||||
'squelch': int(_shared_state['monitor_squelch']),
|
||||
}
|
||||
|
||||
|
||||
def _push_shared_audio_chunk(chunk: bytes) -> None:
|
||||
if not chunk:
|
||||
return
|
||||
if _shared_audio_queue.full():
|
||||
with suppress(queue.Empty):
|
||||
_shared_audio_queue.get_nowait()
|
||||
with suppress(queue.Full):
|
||||
_shared_audio_queue.put_nowait(chunk)
|
||||
|
||||
|
||||
def _demodulate_monitor_audio(
|
||||
samples: np.ndarray,
|
||||
sample_rate: int,
|
||||
center_mhz: float,
|
||||
monitor_freq_mhz: float,
|
||||
modulation: str,
|
||||
squelch: int,
|
||||
) -> bytes | None:
|
||||
if samples.size < 32 or sample_rate <= 0:
|
||||
return None
|
||||
|
||||
fs = float(sample_rate)
|
||||
freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6
|
||||
nyquist = fs * 0.5
|
||||
if abs(freq_offset_hz) > nyquist * 0.98:
|
||||
return None
|
||||
|
||||
n = np.arange(samples.size, dtype=np.float32)
|
||||
rotator = np.exp(-1j * (2.0 * np.pi * freq_offset_hz / fs) * n)
|
||||
shifted = samples * rotator
|
||||
|
||||
mod = str(modulation or 'wfm').lower().strip()
|
||||
target_bb = 220000.0 if mod == 'wfm' else 48000.0
|
||||
pre_decim = max(1, int(fs // target_bb))
|
||||
if pre_decim > 1:
|
||||
usable = (shifted.size // pre_decim) * pre_decim
|
||||
if usable < pre_decim:
|
||||
return None
|
||||
shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1)
|
||||
fs1 = fs / pre_decim
|
||||
if shifted.size < 16:
|
||||
return None
|
||||
|
||||
if mod in ('wfm', 'fm'):
|
||||
audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32)
|
||||
elif mod == 'am':
|
||||
envelope = np.abs(shifted).astype(np.float32)
|
||||
audio = envelope - float(np.mean(envelope))
|
||||
elif mod == 'usb':
|
||||
audio = np.real(shifted).astype(np.float32)
|
||||
elif mod == 'lsb':
|
||||
audio = -np.real(shifted).astype(np.float32)
|
||||
else:
|
||||
audio = np.real(shifted).astype(np.float32)
|
||||
|
||||
if audio.size < 8:
|
||||
return None
|
||||
|
||||
audio = audio - float(np.mean(audio))
|
||||
|
||||
if mod in ('fm', 'am', 'usb', 'lsb'):
|
||||
taps = int(max(1, min(31, fs1 / 12000.0)))
|
||||
if taps > 1:
|
||||
kernel = np.ones(taps, dtype=np.float32) / float(taps)
|
||||
audio = np.convolve(audio, kernel, mode='same')
|
||||
|
||||
out_len = int(audio.size * AUDIO_SAMPLE_RATE / fs1)
|
||||
if out_len < 32:
|
||||
return None
|
||||
x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float32)
|
||||
x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float32)
|
||||
audio = np.interp(x_new, x_old, audio).astype(np.float32)
|
||||
|
||||
rms = float(np.sqrt(np.mean(audio * audio) + 1e-12))
|
||||
level = min(100.0, rms * 450.0)
|
||||
if squelch > 0 and level < float(squelch):
|
||||
audio.fill(0.0)
|
||||
|
||||
peak = float(np.max(np.abs(audio))) if audio.size else 0.0
|
||||
if peak > 0:
|
||||
audio = audio * min(20.0, 0.85 / peak)
|
||||
|
||||
pcm = np.clip(audio, -1.0, 1.0)
|
||||
return (pcm * 32767.0).astype(np.int16).tobytes()
|
||||
|
||||
|
||||
def _parse_center_freq_mhz(payload: dict[str, Any]) -> float:
|
||||
"""Parse center frequency from mixed legacy/new payload formats."""
|
||||
if payload.get('center_freq_mhz') is not None:
|
||||
return float(payload['center_freq_mhz'])
|
||||
|
||||
if payload.get('center_freq_hz') is not None:
|
||||
return float(payload['center_freq_hz']) / 1e6
|
||||
|
||||
raw = float(payload.get('center_freq', 100.0))
|
||||
# Backward compatibility: some clients still send center_freq in Hz.
|
||||
if raw > 100000:
|
||||
return raw / 1e6
|
||||
return raw
|
||||
|
||||
|
||||
def _parse_span_mhz(payload: dict[str, Any]) -> float:
|
||||
"""Parse display span in MHz from mixed payload formats."""
|
||||
if payload.get('span_hz') is not None:
|
||||
return float(payload['span_hz']) / 1e6
|
||||
return float(payload.get('span_mhz', 2.0))
|
||||
|
||||
|
||||
def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> int:
|
||||
"""Pick a valid hardware sample rate nearest the requested span."""
|
||||
valid_rates = sorted({int(r) for r in caps.sample_rates if int(r) > 0})
|
||||
if valid_rates:
|
||||
return min(valid_rates, key=lambda rate: abs(rate - span_hz))
|
||||
|
||||
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
||||
return max(62500, min(span_hz, max_bw))
|
||||
|
||||
|
||||
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
||||
"""Convert client sdr_type string to SDRType enum."""
|
||||
mapping = {
|
||||
'rtlsdr': SDRType.RTL_SDR,
|
||||
'rtl_sdr': SDRType.RTL_SDR,
|
||||
@@ -83,12 +332,16 @@ def init_waterfall_websocket(app: Flask):
|
||||
# Import app module for device claiming
|
||||
import app as app_module
|
||||
|
||||
iq_process = None
|
||||
reader_thread = None
|
||||
stop_event = threading.Event()
|
||||
claimed_device = None
|
||||
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||
send_queue = queue.Queue(maxsize=120)
|
||||
iq_process = None
|
||||
reader_thread = None
|
||||
stop_event = threading.Event()
|
||||
claimed_device = None
|
||||
capture_center_mhz = 0.0
|
||||
capture_start_freq = 0.0
|
||||
capture_end_freq = 0.0
|
||||
capture_span_mhz = 0.0
|
||||
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||
send_queue = queue.Queue(maxsize=120)
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -105,7 +358,7 @@ def init_waterfall_websocket(app: Flask):
|
||||
break
|
||||
|
||||
try:
|
||||
msg = ws.receive(timeout=0.1)
|
||||
msg = ws.receive(timeout=0.01)
|
||||
except Exception as e:
|
||||
err = str(e).lower()
|
||||
if "closed" in err:
|
||||
@@ -130,257 +383,370 @@ def init_waterfall_websocket(app: Flask):
|
||||
|
||||
cmd = data.get('cmd')
|
||||
|
||||
if cmd == 'start':
|
||||
# Stop any existing capture
|
||||
was_restarting = iq_process is not None
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
if iq_process:
|
||||
if cmd == 'start':
|
||||
# Stop any existing capture
|
||||
was_restarting = iq_process is not None
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
claimed_device = None
|
||||
_set_shared_capture_state(running=False)
|
||||
stop_event.clear()
|
||||
# Flush stale frames from previous capture
|
||||
while not send_queue.empty():
|
||||
try:
|
||||
send_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
# Allow USB device to be released by the kernel
|
||||
if was_restarting:
|
||||
time.sleep(0.5)
|
||||
|
||||
# Parse config
|
||||
try:
|
||||
center_freq_mhz = _parse_center_freq_mhz(data)
|
||||
span_mhz = _parse_span_mhz(data)
|
||||
gain_raw = data.get('gain')
|
||||
if gain_raw is None or str(gain_raw).lower() == 'auto':
|
||||
gain = None
|
||||
else:
|
||||
gain = float(gain_raw)
|
||||
device_index = int(data.get('device', 0))
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
fft_size = int(data.get('fft_size', 1024))
|
||||
fps = int(data.get('fps', 25))
|
||||
avg_count = int(data.get('avg_count', 4))
|
||||
ppm = data.get('ppm')
|
||||
if ppm is not None:
|
||||
ppm = int(ppm)
|
||||
bias_t = bool(data.get('bias_t', False))
|
||||
db_min = data.get('db_min')
|
||||
db_max = data.get('db_max')
|
||||
if db_min is not None:
|
||||
db_min = float(db_min)
|
||||
if db_max is not None:
|
||||
db_max = float(db_max)
|
||||
except (TypeError, ValueError) as exc:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Invalid waterfall configuration: {exc}',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Clamp and normalize runtime settings
|
||||
fft_size = max(256, min(8192, fft_size))
|
||||
fps = max(2, min(60, fps))
|
||||
avg_count = max(1, min(32, avg_count))
|
||||
if center_freq_mhz <= 0 or span_mhz <= 0:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': 'center_freq_mhz and span_mhz must be > 0',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Resolve SDR type and choose a valid sample rate
|
||||
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
caps = builder.get_capabilities()
|
||||
requested_span_hz = max(1000, int(span_mhz * 1e6))
|
||||
sample_rate = _pick_sample_rate(requested_span_hz, caps, sdr_type)
|
||||
|
||||
# Compute effective frequency range
|
||||
effective_span_mhz = sample_rate / 1e6
|
||||
start_freq = center_freq_mhz - effective_span_mhz / 2
|
||||
end_freq = center_freq_mhz + effective_span_mhz / 2
|
||||
|
||||
# Claim the device
|
||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
||||
if claim_err:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': claim_err,
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
}))
|
||||
continue
|
||||
claimed_device = device_index
|
||||
|
||||
# Build I/Q capture command
|
||||
try:
|
||||
device = _build_dummy_device(device_index, sdr_type)
|
||||
iq_cmd = builder.build_iq_capture_command(
|
||||
device=device,
|
||||
frequency_mhz=center_freq_mhz,
|
||||
sample_rate=sample_rate,
|
||||
gain=gain,
|
||||
ppm=ppm,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
app_module.release_sdr_device(device_index)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
}))
|
||||
continue
|
||||
|
||||
# Spawn I/Q capture process (retry to handle USB release lag)
|
||||
max_attempts = 3 if was_restarting else 1
|
||||
try:
|
||||
for attempt in range(max_attempts):
|
||||
logger.info(
|
||||
f"Starting I/Q capture: {center_freq_mhz:.6f} MHz, "
|
||||
f"span={effective_span_mhz:.1f} MHz, "
|
||||
f"sr={sample_rate}, fft={fft_size}"
|
||||
)
|
||||
iq_process = subprocess.Popen(
|
||||
iq_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=0,
|
||||
)
|
||||
register_process(iq_process)
|
||||
|
||||
# Brief check that process started
|
||||
time.sleep(0.3)
|
||||
if iq_process.poll() is not None:
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if attempt < max_attempts - 1:
|
||||
logger.info(
|
||||
f"I/Q process exited immediately, "
|
||||
f"retrying ({attempt + 1}/{max_attempts})..."
|
||||
)
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
raise RuntimeError(
|
||||
"I/Q capture process exited immediately"
|
||||
)
|
||||
break # Process started successfully
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start I/Q capture: {e}")
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
app_module.release_sdr_device(device_index)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start I/Q capture: {e}',
|
||||
}))
|
||||
continue
|
||||
|
||||
capture_center_mhz = center_freq_mhz
|
||||
capture_start_freq = start_freq
|
||||
capture_end_freq = end_freq
|
||||
capture_span_mhz = effective_span_mhz
|
||||
|
||||
_set_shared_capture_state(
|
||||
running=True,
|
||||
device=device_index,
|
||||
center_mhz=center_freq_mhz,
|
||||
span_mhz=effective_span_mhz,
|
||||
sample_rate=sample_rate,
|
||||
)
|
||||
_set_shared_monitor(
|
||||
enabled=False,
|
||||
frequency_mhz=center_freq_mhz,
|
||||
modulation='wfm',
|
||||
squelch=0,
|
||||
)
|
||||
|
||||
# Send started confirmation
|
||||
ws.send(json.dumps({
|
||||
'status': 'started',
|
||||
'center_mhz': center_freq_mhz,
|
||||
'start_freq': start_freq,
|
||||
'end_freq': end_freq,
|
||||
'fft_size': fft_size,
|
||||
'sample_rate': sample_rate,
|
||||
'effective_span_mhz': effective_span_mhz,
|
||||
'db_min': db_min,
|
||||
'db_max': db_max,
|
||||
'vfo_freq_mhz': center_freq_mhz,
|
||||
}))
|
||||
|
||||
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||
def fft_reader(
|
||||
proc, _send_q, stop_evt,
|
||||
_fft_size, _avg_count, _fps, _sample_rate,
|
||||
_start_freq, _end_freq, _center_mhz,
|
||||
_db_min=None, _db_max=None,
|
||||
):
|
||||
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
||||
required_fft_samples = _fft_size * _avg_count
|
||||
timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps)))
|
||||
bytes_per_frame = timeslice_samples * 2
|
||||
frame_interval = 1.0 / _fps
|
||||
|
||||
try:
|
||||
while not stop_evt.is_set():
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
frame_start = time.monotonic()
|
||||
|
||||
# Read raw I/Q bytes
|
||||
raw = b''
|
||||
remaining = bytes_per_frame
|
||||
while remaining > 0 and not stop_evt.is_set():
|
||||
chunk = proc.stdout.read(min(remaining, 65536))
|
||||
if not chunk:
|
||||
break
|
||||
raw += chunk
|
||||
remaining -= len(chunk)
|
||||
|
||||
if len(raw) < _fft_size * 2:
|
||||
break
|
||||
|
||||
# Process FFT pipeline
|
||||
samples = cu8_to_complex(raw)
|
||||
fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples
|
||||
power_db = compute_power_spectrum(
|
||||
fft_samples,
|
||||
fft_size=_fft_size,
|
||||
avg_count=_avg_count,
|
||||
)
|
||||
quantized = quantize_to_uint8(
|
||||
power_db,
|
||||
db_min=_db_min,
|
||||
db_max=_db_max,
|
||||
)
|
||||
frame = build_binary_frame(
|
||||
_start_freq, _end_freq, quantized,
|
||||
)
|
||||
|
||||
# Drop frame if main loop cannot keep up.
|
||||
with suppress(queue.Full):
|
||||
_send_q.put_nowait(frame)
|
||||
|
||||
monitor_cfg = _snapshot_monitor_config()
|
||||
if monitor_cfg:
|
||||
audio_chunk = _demodulate_monitor_audio(
|
||||
samples=samples,
|
||||
sample_rate=_sample_rate,
|
||||
center_mhz=monitor_cfg.get('center_mhz', _center_mhz),
|
||||
monitor_freq_mhz=monitor_cfg.get('monitor_freq_mhz', _center_mhz),
|
||||
modulation=monitor_cfg.get('modulation', 'wfm'),
|
||||
squelch=int(monitor_cfg.get('squelch', 0)),
|
||||
)
|
||||
if audio_chunk:
|
||||
_push_shared_audio_chunk(audio_chunk)
|
||||
|
||||
# Pace to target FPS
|
||||
elapsed = time.monotonic() - frame_start
|
||||
sleep_time = frame_interval - elapsed
|
||||
if sleep_time > 0:
|
||||
stop_evt.wait(sleep_time)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"FFT reader stopped: {e}")
|
||||
|
||||
reader_thread = threading.Thread(
|
||||
target=fft_reader,
|
||||
args=(
|
||||
iq_process, send_queue, stop_event,
|
||||
fft_size, avg_count, fps, sample_rate,
|
||||
start_freq, end_freq, center_freq_mhz,
|
||||
db_min, db_max,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
reader_thread.start()
|
||||
|
||||
elif cmd in ('tune', 'set_vfo'):
|
||||
if not iq_process or claimed_device is None or iq_process.poll() is not None:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': 'Waterfall capture is not running',
|
||||
}))
|
||||
continue
|
||||
try:
|
||||
shared = get_shared_capture_status()
|
||||
vfo_freq_mhz = float(
|
||||
data.get(
|
||||
'vfo_freq_mhz',
|
||||
data.get('frequency_mhz', data.get('center_freq_mhz', capture_center_mhz)),
|
||||
)
|
||||
)
|
||||
squelch = int(data.get('squelch', shared.get('monitor_squelch', 0)))
|
||||
modulation = str(data.get('modulation', shared.get('monitor_modulation', 'wfm')))
|
||||
except (TypeError, ValueError) as exc:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Invalid tune request: {exc}',
|
||||
}))
|
||||
continue
|
||||
|
||||
if not (capture_start_freq <= vfo_freq_mhz <= capture_end_freq):
|
||||
ws.send(json.dumps({
|
||||
'status': 'retune_required',
|
||||
'message': 'Frequency outside current capture span',
|
||||
'capture_start_freq': capture_start_freq,
|
||||
'capture_end_freq': capture_end_freq,
|
||||
'vfo_freq_mhz': vfo_freq_mhz,
|
||||
}))
|
||||
continue
|
||||
|
||||
monitor_enabled = bool(shared.get('monitor_enabled'))
|
||||
_set_shared_monitor(
|
||||
enabled=monitor_enabled,
|
||||
frequency_mhz=vfo_freq_mhz,
|
||||
modulation=modulation,
|
||||
squelch=squelch,
|
||||
)
|
||||
ws.send(json.dumps({
|
||||
'status': 'tuned',
|
||||
'vfo_freq_mhz': vfo_freq_mhz,
|
||||
'start_freq': capture_start_freq,
|
||||
'end_freq': capture_end_freq,
|
||||
'center_mhz': capture_center_mhz,
|
||||
}))
|
||||
|
||||
elif cmd == 'stop':
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
reader_thread = None
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
claimed_device = None
|
||||
stop_event.clear()
|
||||
# Flush stale frames from previous capture
|
||||
while not send_queue.empty():
|
||||
try:
|
||||
send_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
# Allow USB device to be released by the kernel
|
||||
if was_restarting:
|
||||
time.sleep(0.5)
|
||||
|
||||
# Parse config
|
||||
center_freq = float(data.get('center_freq', 100.0))
|
||||
span_mhz = float(data.get('span_mhz', 2.0))
|
||||
gain = data.get('gain')
|
||||
if gain is not None:
|
||||
gain = float(gain)
|
||||
device_index = int(data.get('device', 0))
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
fft_size = int(data.get('fft_size', 1024))
|
||||
fps = int(data.get('fps', 25))
|
||||
avg_count = int(data.get('avg_count', 4))
|
||||
ppm = data.get('ppm')
|
||||
if ppm is not None:
|
||||
ppm = int(ppm)
|
||||
bias_t = bool(data.get('bias_t', False))
|
||||
|
||||
# Clamp FFT size to valid powers of 2
|
||||
fft_size = max(256, min(8192, fft_size))
|
||||
|
||||
# Resolve SDR type and bandwidth
|
||||
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
||||
span_hz = int(span_mhz * 1e6)
|
||||
sample_rate = min(span_hz, max_bw)
|
||||
|
||||
# Compute effective frequency range
|
||||
effective_span_mhz = sample_rate / 1e6
|
||||
start_freq = center_freq - effective_span_mhz / 2
|
||||
end_freq = center_freq + effective_span_mhz / 2
|
||||
|
||||
# Claim the device
|
||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
||||
if claim_err:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': claim_err,
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
}))
|
||||
continue
|
||||
claimed_device = device_index
|
||||
|
||||
# Build I/Q capture command
|
||||
try:
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
device = _build_dummy_device(device_index, sdr_type)
|
||||
iq_cmd = builder.build_iq_capture_command(
|
||||
device=device,
|
||||
frequency_mhz=center_freq,
|
||||
sample_rate=sample_rate,
|
||||
gain=gain,
|
||||
ppm=ppm,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
app_module.release_sdr_device(device_index)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
}))
|
||||
continue
|
||||
|
||||
# Spawn I/Q capture process (retry to handle USB release lag)
|
||||
max_attempts = 3 if was_restarting else 1
|
||||
try:
|
||||
for attempt in range(max_attempts):
|
||||
logger.info(
|
||||
f"Starting I/Q capture: {center_freq} MHz, "
|
||||
f"span={effective_span_mhz:.1f} MHz, "
|
||||
f"sr={sample_rate}, fft={fft_size}"
|
||||
)
|
||||
iq_process = subprocess.Popen(
|
||||
iq_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=0,
|
||||
)
|
||||
register_process(iq_process)
|
||||
|
||||
# Brief check that process started
|
||||
time.sleep(0.3)
|
||||
if iq_process.poll() is not None:
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if attempt < max_attempts - 1:
|
||||
logger.info(
|
||||
f"I/Q process exited immediately, "
|
||||
f"retrying ({attempt + 1}/{max_attempts})..."
|
||||
)
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
raise RuntimeError(
|
||||
"I/Q capture process exited immediately"
|
||||
)
|
||||
break # Process started successfully
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start I/Q capture: {e}")
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
app_module.release_sdr_device(device_index)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start I/Q capture: {e}',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Send started confirmation
|
||||
ws.send(json.dumps({
|
||||
'status': 'started',
|
||||
'start_freq': start_freq,
|
||||
'end_freq': end_freq,
|
||||
'fft_size': fft_size,
|
||||
'sample_rate': sample_rate,
|
||||
}))
|
||||
|
||||
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||
def fft_reader(
|
||||
proc, _send_q, stop_evt,
|
||||
_fft_size, _avg_count, _fps,
|
||||
_start_freq, _end_freq,
|
||||
):
|
||||
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
||||
bytes_per_frame = _fft_size * _avg_count * 2
|
||||
frame_interval = 1.0 / _fps
|
||||
|
||||
try:
|
||||
while not stop_evt.is_set():
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
frame_start = time.monotonic()
|
||||
|
||||
# Read raw I/Q bytes
|
||||
raw = b''
|
||||
remaining = bytes_per_frame
|
||||
while remaining > 0 and not stop_evt.is_set():
|
||||
chunk = proc.stdout.read(min(remaining, 65536))
|
||||
if not chunk:
|
||||
break
|
||||
raw += chunk
|
||||
remaining -= len(chunk)
|
||||
|
||||
if len(raw) < _fft_size * 2:
|
||||
break
|
||||
|
||||
# Process FFT pipeline
|
||||
samples = cu8_to_complex(raw)
|
||||
power_db = compute_power_spectrum(
|
||||
samples,
|
||||
fft_size=_fft_size,
|
||||
avg_count=_avg_count,
|
||||
)
|
||||
quantized = quantize_to_uint8(power_db)
|
||||
frame = build_binary_frame(
|
||||
_start_freq, _end_freq, quantized,
|
||||
)
|
||||
|
||||
try:
|
||||
_send_q.put_nowait(frame)
|
||||
except queue.Full:
|
||||
# Drop frame if main loop can't keep up
|
||||
pass
|
||||
|
||||
# Pace to target FPS
|
||||
elapsed = time.monotonic() - frame_start
|
||||
sleep_time = frame_interval - elapsed
|
||||
if sleep_time > 0:
|
||||
stop_evt.wait(sleep_time)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"FFT reader stopped: {e}")
|
||||
|
||||
reader_thread = threading.Thread(
|
||||
target=fft_reader,
|
||||
args=(
|
||||
iq_process, send_queue, stop_event,
|
||||
fft_size, avg_count, fps,
|
||||
start_freq, end_freq,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
reader_thread.start()
|
||||
|
||||
elif cmd == 'stop':
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
reader_thread = None
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
claimed_device = None
|
||||
stop_event.clear()
|
||||
ws.send(json.dumps({'status': 'stopped'}))
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
claimed_device = None
|
||||
_set_shared_capture_state(running=False)
|
||||
stop_event.clear()
|
||||
ws.send(json.dumps({'status': 'stopped'}))
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"WebSocket waterfall closed: {e}")
|
||||
finally:
|
||||
# Cleanup
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
finally:
|
||||
# Cleanup
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
# Complete WebSocket close handshake, then shut down the
|
||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
_set_shared_capture_state(running=False)
|
||||
# Complete WebSocket close handshake, then shut down the
|
||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||
# on top of the WebSocket stream (which browsers see as
|
||||
# "Invalid frame header").
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ws.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("WebSocket waterfall client disconnected")
|
||||
with suppress(Exception):
|
||||
ws.close()
|
||||
with suppress(Exception):
|
||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||
with suppress(Exception):
|
||||
ws.sock.close()
|
||||
logger.info("WebSocket waterfall client disconnected")
|
||||
|
||||
Reference in New Issue
Block a user