mirror of
https://github.com/smittix/intercept.git
synced 2026-07-06 16:48:12 -07:00
Add Listening Post, improve setup and documentation
- Add Listening Post mode with frequency scanner and audio monitoring - Add dependency warning for aircraft dashboard listen feature - Auto-restart audio when switching frequencies - Fix toolbar overflow on aircraft dashboard custom frequency - Update setup script with full macOS/Debian support - Clean up README and documentation for clarity - Add sox and dump1090 to Dockerfile - Add comprehensive tool reference to HARDWARE.md - Add correlation, settings, and database utilities - Add new test files for routes, validation, correlation, database 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,9 @@ def register_blueprints(app):
|
||||
from .adsb import adsb_bp
|
||||
from .satellite import satellite_bp
|
||||
from .gps import gps_bp
|
||||
from .settings import settings_bp
|
||||
from .correlation import correlation_bp
|
||||
from .listening_post import listening_post_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -17,3 +20,6 @@ def register_blueprints(app):
|
||||
app.register_blueprint(adsb_bp)
|
||||
app.register_blueprint(satellite_bp)
|
||||
app.register_blueprint(gps_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(correlation_bp)
|
||||
app.register_blueprint(listening_post_bp)
|
||||
|
||||
+38
-23
@@ -22,6 +22,19 @@ from utils.validation import (
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
ADSB_SBS_PORT,
|
||||
ADSB_TERMINATE_TIMEOUT,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SBS_SOCKET_TIMEOUT,
|
||||
SBS_RECONNECT_DELAY,
|
||||
SOCKET_BUFFER_SIZE,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SOCKET_CONNECT_TIMEOUT,
|
||||
ADSB_UPDATE_INTERVAL,
|
||||
DUMP1090_START_WAIT,
|
||||
)
|
||||
|
||||
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
||||
|
||||
@@ -63,21 +76,21 @@ def find_dump1090():
|
||||
|
||||
|
||||
def check_dump1090_service():
|
||||
"""Check if dump1090 SBS port (30003) is available."""
|
||||
"""Check if dump1090 SBS port is available."""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(2)
|
||||
result = sock.connect_ex(('localhost', 30003))
|
||||
sock.settimeout(SOCKET_CONNECT_TIMEOUT)
|
||||
result = sock.connect_ex(('localhost', ADSB_SBS_PORT))
|
||||
sock.close()
|
||||
if result == 0:
|
||||
return 'localhost:30003'
|
||||
except Exception:
|
||||
return f'localhost:{ADSB_SBS_PORT}'
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def parse_sbs_stream(service_addr):
|
||||
"""Parse SBS format data from dump1090 port 30003."""
|
||||
"""Parse SBS format data from dump1090 SBS port."""
|
||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time
|
||||
|
||||
host, port = service_addr.split(':')
|
||||
@@ -90,7 +103,7 @@ def parse_sbs_stream(service_addr):
|
||||
while adsb_using_service:
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
sock.settimeout(SBS_SOCKET_TIMEOUT)
|
||||
sock.connect((host, port))
|
||||
adsb_connected = True
|
||||
logger.info("Connected to SBS stream")
|
||||
@@ -101,7 +114,7 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
while adsb_using_service:
|
||||
try:
|
||||
data = sock.recv(4096).decode('utf-8', errors='ignore')
|
||||
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
||||
if not data:
|
||||
break
|
||||
buffer += data
|
||||
@@ -121,7 +134,7 @@ def parse_sbs_stream(service_addr):
|
||||
if not icao:
|
||||
continue
|
||||
|
||||
aircraft = app_module.adsb_aircraft.get(icao, {'icao': icao})
|
||||
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
|
||||
|
||||
if msg_type == '1' and len(parts) > 10:
|
||||
callsign = parts[10].strip()
|
||||
@@ -168,13 +181,13 @@ def parse_sbs_stream(service_addr):
|
||||
if parts[17]:
|
||||
aircraft['squawk'] = parts[17]
|
||||
|
||||
app_module.adsb_aircraft[icao] = aircraft
|
||||
app_module.adsb_aircraft.set(icao, aircraft)
|
||||
pending_updates.add(icao)
|
||||
adsb_messages_received += 1
|
||||
adsb_last_message_time = time.time()
|
||||
|
||||
now = time.time()
|
||||
if now - last_update >= 1.0:
|
||||
if now - last_update >= ADSB_UPDATE_INTERVAL:
|
||||
for update_icao in pending_updates:
|
||||
if update_icao in app_module.adsb_aircraft:
|
||||
app_module.adsb_queue.put({
|
||||
@@ -189,10 +202,10 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
sock.close()
|
||||
adsb_connected = False
|
||||
except Exception as e:
|
||||
except OSError as e:
|
||||
adsb_connected = False
|
||||
logger.warning(f"SBS connection error: {e}, reconnecting...")
|
||||
time.sleep(2)
|
||||
time.sleep(SBS_RECONNECT_DELAY)
|
||||
|
||||
adsb_connected = False
|
||||
logger.info("SBS stream parser stopped")
|
||||
@@ -291,9 +304,12 @@ def start_adsb():
|
||||
if app_module.adsb_process:
|
||||
try:
|
||||
app_module.adsb_process.terminate()
|
||||
app_module.adsb_process.wait(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
app_module.adsb_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
app_module.adsb_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
app_module.adsb_process = None
|
||||
|
||||
# Create device object and build command via abstraction layer
|
||||
@@ -317,13 +333,13 @@ def start_adsb():
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
time.sleep(3)
|
||||
time.sleep(DUMP1090_START_WAIT)
|
||||
|
||||
if app_module.adsb_process.poll() is not None:
|
||||
return jsonify({'status': 'error', 'message': 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'})
|
||||
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=('localhost:30003',), daemon=True)
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||
thread.start()
|
||||
|
||||
return jsonify({'status': 'started', 'message': 'ADS-B tracking started'})
|
||||
@@ -340,13 +356,13 @@ def stop_adsb():
|
||||
if app_module.adsb_process:
|
||||
app_module.adsb_process.terminate()
|
||||
try:
|
||||
app_module.adsb_process.wait(timeout=5)
|
||||
app_module.adsb_process.wait(timeout=ADSB_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.adsb_process.kill()
|
||||
app_module.adsb_process = None
|
||||
adsb_using_service = False
|
||||
|
||||
app_module.adsb_aircraft = {}
|
||||
app_module.adsb_aircraft.clear()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@@ -355,16 +371,15 @@ def stream_adsb():
|
||||
"""SSE stream for ADS-B aircraft."""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.adsb_queue.get(timeout=1)
|
||||
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
|
||||
+18
-3
@@ -23,6 +23,17 @@ from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import format_sse
|
||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||
from utils.constants import (
|
||||
BT_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SUBPROCESS_TIMEOUT_SHORT,
|
||||
SERVICE_ENUM_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
BT_RESET_DELAY,
|
||||
BT_ADAPTER_DOWN_WAIT,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
)
|
||||
|
||||
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
|
||||
|
||||
@@ -113,7 +124,7 @@ def detect_bt_interfaces():
|
||||
|
||||
if platform.system() == 'Linux':
|
||||
try:
|
||||
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=5)
|
||||
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
|
||||
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
|
||||
for block in blocks:
|
||||
if block.strip():
|
||||
@@ -127,8 +138,12 @@ def detect_bt_interfaces():
|
||||
'type': 'hci',
|
||||
'status': 'up' if is_up else 'down'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
logger.debug("hciconfig not found")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("hciconfig timed out")
|
||||
except subprocess.SubprocessError as e:
|
||||
logger.warning(f"Error running hciconfig: {e}")
|
||||
|
||||
elif platform.system() == 'Darwin':
|
||||
interfaces.append({
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Device correlation routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.correlation import get_correlations
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.correlation')
|
||||
|
||||
correlation_bp = Blueprint('correlation', __name__, url_prefix='/correlation')
|
||||
|
||||
|
||||
@correlation_bp.route('', methods=['GET'])
|
||||
def get_device_correlations() -> Response:
|
||||
"""
|
||||
Get device correlations between WiFi and Bluetooth.
|
||||
|
||||
Query params:
|
||||
min_confidence: Minimum confidence threshold (default 0.5)
|
||||
include_historical: Include database correlations (default true)
|
||||
"""
|
||||
min_confidence = request.args.get('min_confidence', 0.5, type=float)
|
||||
include_historical = request.args.get('include_historical', 'true').lower() == 'true'
|
||||
|
||||
try:
|
||||
# Get current device data
|
||||
wifi_devices = dict(app_module.wifi_networks)
|
||||
wifi_devices.update(dict(app_module.wifi_clients))
|
||||
bt_devices = dict(app_module.bt_devices)
|
||||
|
||||
# Calculate correlations
|
||||
correlations = get_correlations(
|
||||
wifi_devices=wifi_devices,
|
||||
bt_devices=bt_devices,
|
||||
min_confidence=min_confidence,
|
||||
include_historical=include_historical
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlations': correlations,
|
||||
'wifi_count': len(wifi_devices),
|
||||
'bt_count': len(bt_devices)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating correlations: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@correlation_bp.route('/analyze', methods=['POST'])
|
||||
def analyze_correlation() -> Response:
|
||||
"""
|
||||
Analyze specific device pair for correlation.
|
||||
|
||||
Request body:
|
||||
wifi_mac: WiFi device MAC address
|
||||
bt_mac: Bluetooth device MAC address
|
||||
"""
|
||||
data = request.json or {}
|
||||
wifi_mac = data.get('wifi_mac')
|
||||
bt_mac = data.get('bt_mac')
|
||||
|
||||
if not wifi_mac or not bt_mac:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'wifi_mac and bt_mac are required'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
# Get device data
|
||||
wifi_device = app_module.wifi_networks.get(wifi_mac)
|
||||
if not wifi_device:
|
||||
wifi_device = app_module.wifi_clients.get(wifi_mac)
|
||||
|
||||
bt_device = app_module.bt_devices.get(bt_mac)
|
||||
|
||||
if not wifi_device:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'WiFi device {wifi_mac} not found'
|
||||
}), 404
|
||||
|
||||
if not bt_device:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Bluetooth device {bt_mac} not found'
|
||||
}), 404
|
||||
|
||||
# Calculate correlation for this specific pair
|
||||
correlations = get_correlations(
|
||||
wifi_devices={wifi_mac: wifi_device},
|
||||
bt_devices={bt_mac: bt_device},
|
||||
min_confidence=0.0, # Show even low confidence for analysis
|
||||
include_historical=True
|
||||
)
|
||||
|
||||
if correlations:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlation': correlations[0]
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlation': None,
|
||||
'message': 'No correlation detected between these devices'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing correlation: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
@@ -0,0 +1,754 @@
|
||||
"""Listening Post routes for radio monitoring and frequency scanning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator, Optional, List, Dict
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.constants import (
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.listening_post')
|
||||
|
||||
listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening')
|
||||
|
||||
# ============================================
|
||||
# GLOBAL STATE
|
||||
# ============================================
|
||||
|
||||
# Audio demodulation state
|
||||
audio_process = None
|
||||
audio_rtl_process = None
|
||||
audio_lock = threading.Lock()
|
||||
audio_running = False
|
||||
audio_frequency = 0.0
|
||||
audio_modulation = 'fm'
|
||||
|
||||
# Scanner state
|
||||
scanner_thread: Optional[threading.Thread] = None
|
||||
scanner_running = False
|
||||
scanner_lock = threading.Lock()
|
||||
scanner_paused = False
|
||||
scanner_current_freq = 0.0
|
||||
scanner_config = {
|
||||
'start_freq': 88.0,
|
||||
'end_freq': 108.0,
|
||||
'step': 0.1,
|
||||
'modulation': 'wfm',
|
||||
'squelch': 20,
|
||||
'dwell_time': 10.0, # Seconds to stay on active frequency
|
||||
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
|
||||
'device': 0,
|
||||
'gain': 40,
|
||||
}
|
||||
|
||||
# Activity log
|
||||
activity_log: List[Dict] = []
|
||||
activity_log_lock = threading.Lock()
|
||||
MAX_LOG_ENTRIES = 500
|
||||
|
||||
# SSE queue for scanner events
|
||||
scanner_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
|
||||
# ============================================
|
||||
# HELPER FUNCTIONS
|
||||
# ============================================
|
||||
|
||||
def find_rtl_fm() -> str | None:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_ffmpeg() -> str | None:
|
||||
"""Find ffmpeg for audio encoding."""
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def find_sox() -> str | None:
|
||||
"""Find sox for audio encoding."""
|
||||
return shutil.which('sox')
|
||||
|
||||
|
||||
def add_activity_log(event_type: str, frequency: float, details: str = ''):
|
||||
"""Add entry to activity log."""
|
||||
with activity_log_lock:
|
||||
entry = {
|
||||
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
||||
'type': event_type,
|
||||
'frequency': frequency,
|
||||
'details': details,
|
||||
}
|
||||
activity_log.insert(0, entry)
|
||||
# Trim log
|
||||
while len(activity_log) > MAX_LOG_ENTRIES:
|
||||
activity_log.pop()
|
||||
|
||||
# Also push to SSE queue
|
||||
try:
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'log',
|
||||
'entry': entry
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
# ============================================
|
||||
# SCANNER IMPLEMENTATION
|
||||
# ============================================
|
||||
|
||||
def scanner_loop():
|
||||
"""Main scanner loop - scans frequencies looking for signals."""
|
||||
global scanner_running, scanner_paused, scanner_current_freq, scanner_skip_signal
|
||||
global audio_process, audio_rtl_process, audio_running, audio_frequency
|
||||
|
||||
logger.info("Scanner thread started")
|
||||
add_activity_log('scanner_start', scanner_config['start_freq'],
|
||||
f"Scanning {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
|
||||
|
||||
rtl_fm_path = find_rtl_fm()
|
||||
|
||||
if not rtl_fm_path:
|
||||
logger.error("rtl_fm not found")
|
||||
add_activity_log('error', 0, 'rtl_fm not found')
|
||||
scanner_running = False
|
||||
return
|
||||
|
||||
current_freq = scanner_config['start_freq']
|
||||
last_signal_time = 0
|
||||
signal_detected = False
|
||||
|
||||
# Convert step from kHz to MHz
|
||||
step_mhz = scanner_config['step'] / 1000.0
|
||||
|
||||
try:
|
||||
while scanner_running:
|
||||
# Check if paused
|
||||
if scanner_paused:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
scanner_current_freq = current_freq
|
||||
|
||||
# Notify clients of frequency change
|
||||
try:
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'freq_change',
|
||||
'frequency': current_freq,
|
||||
'scanning': not signal_detected
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
# Start rtl_fm at this frequency
|
||||
freq_hz = int(current_freq * 1e6)
|
||||
mod = scanner_config['modulation']
|
||||
|
||||
# Sample rates
|
||||
if mod == 'wfm':
|
||||
sample_rate = 170000
|
||||
resample_rate = 32000
|
||||
elif mod in ['usb', 'lsb']:
|
||||
sample_rate = 12000
|
||||
resample_rate = 12000
|
||||
else:
|
||||
sample_rate = 24000
|
||||
resample_rate = 24000
|
||||
|
||||
# Don't use squelch in rtl_fm - we want to analyze raw audio
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-M', mod,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(scanner_config['gain']),
|
||||
'-d', str(scanner_config['device']),
|
||||
]
|
||||
|
||||
try:
|
||||
# Start rtl_fm
|
||||
rtl_proc = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
# Read audio data for analysis
|
||||
audio_data = b''
|
||||
|
||||
# Read audio samples for a short period
|
||||
sample_duration = 0.25 # 250ms - balance between speed and detection
|
||||
bytes_needed = int(resample_rate * 2 * sample_duration) # 16-bit mono
|
||||
|
||||
while len(audio_data) < bytes_needed and scanner_running:
|
||||
chunk = rtl_proc.stdout.read(4096)
|
||||
if not chunk:
|
||||
break
|
||||
audio_data += chunk
|
||||
|
||||
# Clean up rtl_fm
|
||||
rtl_proc.terminate()
|
||||
try:
|
||||
rtl_proc.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
rtl_proc.kill()
|
||||
|
||||
# Analyze audio level
|
||||
audio_detected = False
|
||||
rms = 0
|
||||
threshold = 3000
|
||||
if len(audio_data) > 100:
|
||||
import struct
|
||||
samples = struct.unpack(f'{len(audio_data)//2}h', audio_data)
|
||||
# Calculate RMS level (root mean square)
|
||||
rms = (sum(s*s for s in samples) / len(samples)) ** 0.5
|
||||
|
||||
# WFM (broadcast FM) has much higher audio output - needs higher threshold
|
||||
# AM/NFM have lower output levels
|
||||
if mod == 'wfm':
|
||||
# WFM: threshold 4000-12000 based on squelch
|
||||
threshold = 4000 + (scanner_config['squelch'] * 80)
|
||||
else:
|
||||
# AM/NFM: threshold 1500-8000 based on squelch
|
||||
threshold = 1500 + (scanner_config['squelch'] * 65)
|
||||
|
||||
audio_detected = rms > threshold
|
||||
|
||||
# Send level info to clients
|
||||
try:
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'scan_update',
|
||||
'frequency': current_freq,
|
||||
'level': int(rms),
|
||||
'threshold': int(threshold) if 'threshold' in dir() else 0,
|
||||
'detected': audio_detected
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
if audio_detected and scanner_running:
|
||||
if not signal_detected:
|
||||
# New signal found!
|
||||
signal_detected = True
|
||||
last_signal_time = time.time()
|
||||
add_activity_log('signal_found', current_freq,
|
||||
f'Signal detected on {current_freq:.3f} MHz ({mod.upper()})')
|
||||
logger.info(f"Signal found at {current_freq} MHz")
|
||||
|
||||
# Start audio streaming for user
|
||||
_start_audio_stream(current_freq, mod)
|
||||
|
||||
try:
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'signal_found',
|
||||
'frequency': current_freq,
|
||||
'modulation': mod,
|
||||
'audio_streaming': True
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
# Check for skip signal
|
||||
if scanner_skip_signal:
|
||||
scanner_skip_signal = False
|
||||
signal_detected = False
|
||||
_stop_audio_stream()
|
||||
try:
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'signal_skipped',
|
||||
'frequency': current_freq
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
# Move to next frequency (step is in kHz, convert to MHz)
|
||||
current_freq += step_mhz
|
||||
if current_freq > scanner_config['end_freq']:
|
||||
current_freq = scanner_config['start_freq']
|
||||
continue
|
||||
|
||||
# Stay on this frequency (dwell) but check periodically
|
||||
dwell_start = time.time()
|
||||
while (time.time() - dwell_start) < scanner_config['dwell_time'] and scanner_running:
|
||||
if scanner_skip_signal:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
|
||||
last_signal_time = time.time()
|
||||
|
||||
else:
|
||||
# No signal at this frequency
|
||||
if signal_detected:
|
||||
# Signal lost
|
||||
duration = time.time() - last_signal_time + scanner_config['dwell_time']
|
||||
add_activity_log('signal_lost', current_freq,
|
||||
f'Signal lost after {duration:.1f}s')
|
||||
signal_detected = False
|
||||
|
||||
# Stop audio
|
||||
_stop_audio_stream()
|
||||
|
||||
try:
|
||||
scanner_queue.put_nowait({
|
||||
'type': 'signal_lost',
|
||||
'frequency': current_freq
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
# Move to next frequency (step is in kHz, convert to MHz)
|
||||
current_freq += step_mhz
|
||||
if current_freq > scanner_config['end_freq']:
|
||||
current_freq = scanner_config['start_freq']
|
||||
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
|
||||
|
||||
time.sleep(scanner_config['scan_delay'])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scanner error at {current_freq} MHz: {e}")
|
||||
time.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scanner loop error: {e}")
|
||||
finally:
|
||||
scanner_running = False
|
||||
_stop_audio_stream()
|
||||
add_activity_log('scanner_stop', scanner_current_freq, 'Scanner stopped')
|
||||
logger.info("Scanner thread stopped")
|
||||
|
||||
|
||||
def _start_audio_stream(frequency: float, modulation: str):
|
||||
"""Start audio streaming at given frequency."""
|
||||
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
|
||||
|
||||
with audio_lock:
|
||||
# Stop any existing stream
|
||||
_stop_audio_stream_internal()
|
||||
|
||||
rtl_fm_path = find_rtl_fm()
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
|
||||
if not rtl_fm_path or not ffmpeg_path:
|
||||
return
|
||||
|
||||
freq_hz = int(frequency * 1e6)
|
||||
|
||||
if modulation == 'wfm':
|
||||
sample_rate = 170000
|
||||
resample_rate = 32000
|
||||
elif modulation in ['usb', 'lsb']:
|
||||
sample_rate = 12000
|
||||
resample_rate = 12000
|
||||
else:
|
||||
sample_rate = 24000
|
||||
resample_rate = 24000
|
||||
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-M', modulation,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(scanner_config['gain']),
|
||||
'-d', str(scanner_config['device']),
|
||||
'-l', str(scanner_config['squelch']),
|
||||
]
|
||||
|
||||
encoder_cmd = [
|
||||
ffmpeg_path,
|
||||
'-f', 's16le',
|
||||
'-ar', str(resample_rate),
|
||||
'-ac', '1',
|
||||
'-i', 'pipe:0',
|
||||
'-f', 'mp3',
|
||||
'-b:a', '64k',
|
||||
'-flush_packets', '1',
|
||||
'pipe:1'
|
||||
]
|
||||
|
||||
try:
|
||||
audio_rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
audio_process = subprocess.Popen(
|
||||
encoder_cmd,
|
||||
stdin=audio_rtl_process.stdout,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=0
|
||||
)
|
||||
|
||||
audio_rtl_process.stdout.close()
|
||||
audio_running = True
|
||||
audio_frequency = frequency
|
||||
audio_modulation = modulation
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start audio stream: {e}")
|
||||
|
||||
|
||||
def _stop_audio_stream():
|
||||
"""Stop audio streaming."""
|
||||
with audio_lock:
|
||||
_stop_audio_stream_internal()
|
||||
|
||||
|
||||
def _stop_audio_stream_internal():
|
||||
"""Internal stop (must hold lock)."""
|
||||
global audio_process, audio_rtl_process, audio_running, audio_frequency
|
||||
|
||||
if audio_process:
|
||||
try:
|
||||
audio_process.terminate()
|
||||
audio_process.wait(timeout=1)
|
||||
except:
|
||||
try:
|
||||
audio_process.kill()
|
||||
except:
|
||||
pass
|
||||
audio_process = None
|
||||
|
||||
if audio_rtl_process:
|
||||
try:
|
||||
audio_rtl_process.terminate()
|
||||
audio_rtl_process.wait(timeout=1)
|
||||
except:
|
||||
try:
|
||||
audio_rtl_process.kill()
|
||||
except:
|
||||
pass
|
||||
audio_rtl_process = None
|
||||
|
||||
audio_running = False
|
||||
audio_frequency = 0.0
|
||||
|
||||
|
||||
# ============================================
|
||||
# API ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@listening_post_bp.route('/tools')
|
||||
def check_tools() -> Response:
|
||||
"""Check for required tools."""
|
||||
rtl_fm = find_rtl_fm()
|
||||
ffmpeg = find_ffmpeg()
|
||||
sox = find_sox()
|
||||
can_stream = ffmpeg is not None or sox is not None
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': rtl_fm is not None,
|
||||
'ffmpeg': ffmpeg is not None,
|
||||
'sox': sox is not None,
|
||||
'can_stream': can_stream,
|
||||
'available': rtl_fm is not None and can_stream
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/start', methods=['POST'])
|
||||
def start_scanner() -> Response:
|
||||
"""Start the frequency scanner."""
|
||||
global scanner_thread, scanner_running, scanner_config
|
||||
|
||||
with scanner_lock:
|
||||
if scanner_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Scanner already running'
|
||||
}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Update scanner config
|
||||
try:
|
||||
scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
|
||||
scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
|
||||
scanner_config['step'] = float(data.get('step', 0.1))
|
||||
scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower()
|
||||
scanner_config['squelch'] = int(data.get('squelch', 20))
|
||||
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
|
||||
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
|
||||
scanner_config['device'] = int(data.get('device', 0))
|
||||
scanner_config['gain'] = int(data.get('gain', 40))
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid parameter: {e}'
|
||||
}), 400
|
||||
|
||||
# Validate
|
||||
if scanner_config['start_freq'] >= scanner_config['end_freq']:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'start_freq must be less than end_freq'
|
||||
}), 400
|
||||
|
||||
# Check tools
|
||||
if not find_rtl_fm():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install rtl-sdr tools.'
|
||||
}), 503
|
||||
|
||||
# Start scanner thread
|
||||
scanner_running = True
|
||||
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
||||
scanner_thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'config': scanner_config
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/stop', methods=['POST'])
|
||||
def stop_scanner() -> Response:
|
||||
"""Stop the frequency scanner."""
|
||||
global scanner_running
|
||||
|
||||
scanner_running = False
|
||||
_stop_audio_stream()
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/pause', methods=['POST'])
|
||||
def pause_scanner() -> Response:
|
||||
"""Pause/resume the scanner."""
|
||||
global scanner_paused
|
||||
|
||||
scanner_paused = not scanner_paused
|
||||
|
||||
if scanner_paused:
|
||||
add_activity_log('scanner_pause', scanner_current_freq, 'Scanner paused')
|
||||
else:
|
||||
add_activity_log('scanner_resume', scanner_current_freq, 'Scanner resumed')
|
||||
|
||||
return jsonify({
|
||||
'status': 'paused' if scanner_paused else 'resumed',
|
||||
'paused': scanner_paused
|
||||
})
|
||||
|
||||
|
||||
# Flag to trigger skip from API
|
||||
scanner_skip_signal = False
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/skip', methods=['POST'])
|
||||
def skip_signal() -> Response:
|
||||
"""Skip current signal and continue scanning."""
|
||||
global scanner_skip_signal
|
||||
|
||||
if not scanner_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Scanner not running'
|
||||
}), 400
|
||||
|
||||
scanner_skip_signal = True
|
||||
add_activity_log('signal_skip', scanner_current_freq, f'Skipped signal at {scanner_current_freq:.3f} MHz')
|
||||
|
||||
return jsonify({
|
||||
'status': 'skipped',
|
||||
'frequency': scanner_current_freq
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/status')
|
||||
def scanner_status() -> Response:
|
||||
"""Get scanner status."""
|
||||
return jsonify({
|
||||
'running': scanner_running,
|
||||
'paused': scanner_paused,
|
||||
'current_freq': scanner_current_freq,
|
||||
'config': scanner_config,
|
||||
'audio_streaming': audio_running,
|
||||
'audio_frequency': audio_frequency
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/stream')
|
||||
def stream_scanner_events() -> Response:
|
||||
"""SSE stream for scanner events."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/log')
|
||||
def get_activity_log() -> Response:
|
||||
"""Get activity log."""
|
||||
limit = request.args.get('limit', 100, type=int)
|
||||
with activity_log_lock:
|
||||
return jsonify({
|
||||
'log': activity_log[:limit],
|
||||
'total': len(activity_log)
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/log/clear', methods=['POST'])
|
||||
def clear_activity_log() -> Response:
|
||||
"""Clear activity log."""
|
||||
with activity_log_lock:
|
||||
activity_log.clear()
|
||||
return jsonify({'status': 'cleared'})
|
||||
|
||||
|
||||
@listening_post_bp.route('/presets')
|
||||
def get_presets() -> Response:
|
||||
"""Get scanner presets."""
|
||||
presets = [
|
||||
{'name': 'FM Broadcast', 'start': 88.0, 'end': 108.0, 'step': 0.2, 'mod': 'wfm'},
|
||||
{'name': 'Air Band', 'start': 118.0, 'end': 137.0, 'step': 0.025, 'mod': 'am'},
|
||||
{'name': 'Marine VHF', 'start': 156.0, 'end': 163.0, 'step': 0.025, 'mod': 'fm'},
|
||||
{'name': 'Amateur 2m', 'start': 144.0, 'end': 148.0, 'step': 0.0125, 'mod': 'fm'},
|
||||
{'name': 'Amateur 70cm', 'start': 430.0, 'end': 440.0, 'step': 0.025, 'mod': 'fm'},
|
||||
{'name': 'PMR446', 'start': 446.0, 'end': 446.2, 'step': 0.0125, 'mod': 'fm'},
|
||||
{'name': 'FRS/GMRS', 'start': 462.5, 'end': 467.7, 'step': 0.025, 'mod': 'fm'},
|
||||
{'name': 'Weather Radio', 'start': 162.4, 'end': 162.55, 'step': 0.025, 'mod': 'fm'},
|
||||
]
|
||||
return jsonify({'presets': presets})
|
||||
|
||||
|
||||
# ============================================
|
||||
# MANUAL AUDIO ENDPOINTS (for direct listening)
|
||||
# ============================================
|
||||
|
||||
@listening_post_bp.route('/audio/start', methods=['POST'])
|
||||
def start_audio() -> Response:
|
||||
"""Start audio at specific frequency (manual mode)."""
|
||||
global scanner_running
|
||||
|
||||
# Stop scanner if running
|
||||
if scanner_running:
|
||||
scanner_running = False
|
||||
time.sleep(0.5)
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
frequency = float(data.get('frequency', 0))
|
||||
modulation = str(data.get('modulation', 'wfm')).lower()
|
||||
squelch = int(data.get('squelch', 0))
|
||||
gain = int(data.get('gain', 40))
|
||||
device = int(data.get('device', 0))
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid parameter: {e}'
|
||||
}), 400
|
||||
|
||||
if frequency <= 0:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'frequency is required'
|
||||
}), 400
|
||||
|
||||
valid_mods = ['fm', 'wfm', 'am', 'usb', 'lsb']
|
||||
if modulation not in valid_mods:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid modulation. Use: {", ".join(valid_mods)}'
|
||||
}), 400
|
||||
|
||||
# Update config for audio
|
||||
scanner_config['squelch'] = squelch
|
||||
scanner_config['gain'] = gain
|
||||
scanner_config['device'] = device
|
||||
|
||||
_start_audio_stream(frequency, modulation)
|
||||
|
||||
if audio_running:
|
||||
add_activity_log('manual_tune', frequency, f'Manual tune to {frequency} MHz ({modulation.upper()})')
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': modulation,
|
||||
'stream_url': '/listening/audio/stream'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start audio'
|
||||
}), 500
|
||||
|
||||
|
||||
@listening_post_bp.route('/audio/stop', methods=['POST'])
|
||||
def stop_audio() -> Response:
|
||||
"""Stop audio."""
|
||||
_stop_audio_stream()
|
||||
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/stream')
|
||||
def stream_audio() -> Response:
|
||||
"""Stream MP3 audio."""
|
||||
if not audio_running or not audio_process:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Audio not running'
|
||||
}), 400
|
||||
|
||||
def generate():
|
||||
chunk_size = 4096
|
||||
try:
|
||||
while audio_running and audio_process and audio_process.poll() is None:
|
||||
chunk = audio_process.stdout.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
logger.error(f"Audio stream error: {e}")
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
mimetype='audio/mpeg',
|
||||
headers={
|
||||
'Content-Type': 'audio/mpeg',
|
||||
'Cache-Control': 'no-cache, no-store',
|
||||
'X-Accel-Buffering': 'no',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Settings management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.database import (
|
||||
get_setting,
|
||||
set_setting,
|
||||
delete_setting,
|
||||
get_all_settings,
|
||||
get_signal_history,
|
||||
add_signal_reading,
|
||||
get_correlations,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.settings')
|
||||
|
||||
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
|
||||
|
||||
|
||||
@settings_bp.route('', methods=['GET'])
|
||||
def get_settings() -> Response:
|
||||
"""Get all settings."""
|
||||
try:
|
||||
settings = get_all_settings()
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'settings': settings
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting settings: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@settings_bp.route('', methods=['POST'])
|
||||
def save_settings() -> Response:
|
||||
"""Save one or more settings."""
|
||||
data = request.json or {}
|
||||
|
||||
if not data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No settings provided'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
saved = []
|
||||
for key, value in data.items():
|
||||
# Validate key (alphanumeric, underscores, dots, hyphens)
|
||||
if not key or not all(c.isalnum() or c in '_.-' for c in key):
|
||||
continue
|
||||
|
||||
set_setting(key, value)
|
||||
saved.append(key)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'saved': saved
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving settings: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['GET'])
|
||||
def get_single_setting(key: str) -> Response:
|
||||
"""Get a single setting by key."""
|
||||
try:
|
||||
value = get_setting(key)
|
||||
if value is None:
|
||||
return jsonify({
|
||||
'status': 'not_found',
|
||||
'key': key
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['PUT'])
|
||||
def update_single_setting(key: str) -> Response:
|
||||
"""Update a single setting."""
|
||||
data = request.json or {}
|
||||
value = data.get('value')
|
||||
|
||||
if value is None and 'value' not in data:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Value is required'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
set_setting(key, value)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'value': value
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@settings_bp.route('/<key>', methods=['DELETE'])
|
||||
def delete_single_setting(key: str) -> Response:
|
||||
"""Delete a setting."""
|
||||
try:
|
||||
deleted = delete_setting(key)
|
||||
if deleted:
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'key': key,
|
||||
'deleted': True
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'not_found',
|
||||
'key': key
|
||||
}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting setting {key}: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Signal History Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@settings_bp.route('/signal-history/<mode>/<device_id>', methods=['GET'])
|
||||
def get_device_signal_history(mode: str, device_id: str) -> Response:
|
||||
"""Get signal strength history for a device."""
|
||||
limit = request.args.get('limit', 100, type=int)
|
||||
since_minutes = request.args.get('since', 60, type=int)
|
||||
|
||||
# Validate mode
|
||||
valid_modes = ['wifi', 'bluetooth', 'adsb', 'pager', 'sensor']
|
||||
if mode not in valid_modes:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid mode. Valid modes: {valid_modes}'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
history = get_signal_history(mode, device_id, limit, since_minutes)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'mode': mode,
|
||||
'device_id': device_id,
|
||||
'history': history
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting signal history: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@settings_bp.route('/signal-history', methods=['POST'])
|
||||
def add_signal_history() -> Response:
|
||||
"""Add a signal strength reading (for internal use)."""
|
||||
data = request.json or {}
|
||||
|
||||
mode = data.get('mode')
|
||||
device_id = data.get('device_id')
|
||||
signal_strength = data.get('signal_strength')
|
||||
|
||||
if not all([mode, device_id, signal_strength is not None]):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'mode, device_id, and signal_strength are required'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
add_signal_reading(mode, device_id, signal_strength, data.get('metadata'))
|
||||
return jsonify({'status': 'success'})
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding signal reading: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Device Correlation Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@settings_bp.route('/correlations', methods=['GET'])
|
||||
def get_device_correlations() -> Response:
|
||||
"""Get device correlations between WiFi and Bluetooth."""
|
||||
min_confidence = request.args.get('min_confidence', 0.5, type=float)
|
||||
|
||||
try:
|
||||
correlations = get_correlations(min_confidence)
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'correlations': correlations
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting correlations: {e}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}), 500
|
||||
+43
-10
@@ -22,6 +22,26 @@ from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address
|
||||
from utils.sse import format_sse
|
||||
from data.oui import get_manufacturer
|
||||
from utils.constants import (
|
||||
WIFI_TERMINATE_TIMEOUT,
|
||||
PMKID_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
WIFI_CSV_PARSE_INTERVAL,
|
||||
WIFI_CSV_TIMEOUT_WARNING,
|
||||
SUBPROCESS_TIMEOUT_SHORT,
|
||||
SUBPROCESS_TIMEOUT_MEDIUM,
|
||||
SUBPROCESS_TIMEOUT_LONG,
|
||||
DEAUTH_TIMEOUT,
|
||||
MIN_DEAUTH_COUNT,
|
||||
MAX_DEAUTH_COUNT,
|
||||
DEFAULT_DEAUTH_COUNT,
|
||||
PROCESS_START_WAIT,
|
||||
MONITOR_MODE_DELAY,
|
||||
WIFI_CAPTURE_PATH_PREFIX,
|
||||
HANDSHAKE_CAPTURE_PATH_PREFIX,
|
||||
PMKID_CAPTURE_PATH_PREFIX,
|
||||
)
|
||||
|
||||
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
|
||||
|
||||
@@ -37,7 +57,7 @@ def detect_wifi_interfaces():
|
||||
if platform.system() == 'Darwin': # macOS
|
||||
try:
|
||||
result = subprocess.run(['networksetup', '-listallhardwareports'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
|
||||
lines = result.stdout.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if 'Wi-Fi' in line or 'AirPort' in line:
|
||||
@@ -51,12 +71,16 @@ def detect_wifi_interfaces():
|
||||
'status': 'up'
|
||||
})
|
||||
break
|
||||
except Exception as e:
|
||||
except FileNotFoundError:
|
||||
logger.debug("networksetup not found")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("networksetup timed out")
|
||||
except subprocess.SubprocessError as e:
|
||||
logger.error(f"Error detecting macOS interfaces: {e}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(['system_profiler', 'SPUSBDataType'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_MEDIUM)
|
||||
if 'Wireless' in result.stdout or 'WLAN' in result.stdout or '802.11' in result.stdout:
|
||||
interfaces.append({
|
||||
'name': 'USB WiFi Adapter',
|
||||
@@ -64,12 +88,16 @@ def detect_wifi_interfaces():
|
||||
'monitor_capable': True,
|
||||
'status': 'detected'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
logger.debug("system_profiler not found")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.debug("system_profiler timed out")
|
||||
except subprocess.SubprocessError as e:
|
||||
logger.debug(f"Error running system_profiler: {e}")
|
||||
|
||||
else: # Linux
|
||||
try:
|
||||
result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=5)
|
||||
result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
|
||||
current_iface = None
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
@@ -85,8 +113,9 @@ def detect_wifi_interfaces():
|
||||
})
|
||||
current_iface = None
|
||||
except FileNotFoundError:
|
||||
# Fall back to iwconfig if iw is not available
|
||||
try:
|
||||
result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5)
|
||||
result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'IEEE 802.11' in line:
|
||||
iface = line.split()[0]
|
||||
@@ -96,9 +125,13 @@ def detect_wifi_interfaces():
|
||||
'monitor_capable': True,
|
||||
'status': 'up'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
except FileNotFoundError:
|
||||
logger.debug("Neither iw nor iwconfig found")
|
||||
except subprocess.SubprocessError as e:
|
||||
logger.debug(f"Error running iwconfig: {e}")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("iw command timed out")
|
||||
except subprocess.SubprocessError as e:
|
||||
logger.error(f"Error detecting Linux interfaces: {e}")
|
||||
|
||||
return interfaces
|
||||
|
||||
Reference in New Issue
Block a user