mirror of
https://github.com/smittix/intercept.git
synced 2026-06-14 00:33:35 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec19d4b55e |
@@ -41,8 +41,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
soapysdr-module-rtlsdr \
|
soapysdr-module-rtlsdr \
|
||||||
soapysdr-module-hackrf \
|
soapysdr-module-hackrf \
|
||||||
soapysdr-module-lms7 \
|
soapysdr-module-lms7 \
|
||||||
soapysdr-module-airspy \
|
|
||||||
airspy \
|
|
||||||
limesuite \
|
limesuite \
|
||||||
hackrf \
|
hackrf \
|
||||||
# Utilities
|
# Utilities
|
||||||
|
|||||||
@@ -737,7 +737,7 @@ def kill_all() -> Response:
|
|||||||
# Reset Bluetooth v2 scanner
|
# Reset Bluetooth v2 scanner
|
||||||
try:
|
try:
|
||||||
reset_bluetooth_scanner()
|
reset_bluetooth_scanner()
|
||||||
killed.append('bluetooth')
|
killed.append('bluetooth_scanner')
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -869,14 +869,6 @@ def main() -> None:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"KiwiSDR audio proxy disabled: {e}")
|
print(f"KiwiSDR audio proxy disabled: {e}")
|
||||||
|
|
||||||
# Initialize WebSocket for waterfall streaming
|
|
||||||
try:
|
|
||||||
from routes.waterfall_websocket import init_waterfall_websocket
|
|
||||||
init_waterfall_websocket(app)
|
|
||||||
print("WebSocket waterfall streaming enabled")
|
|
||||||
except ImportError as e:
|
|
||||||
print(f"WebSocket waterfall disabled: {e}")
|
|
||||||
|
|
||||||
print(f"Open http://localhost:{args.port} in your browser")
|
print(f"Open http://localhost:{args.port} in your browser")
|
||||||
print()
|
print()
|
||||||
print("Press Ctrl+C to stop")
|
print("Press Ctrl+C to stop")
|
||||||
|
|||||||
@@ -209,11 +209,6 @@ GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
|||||||
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||||
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
UPDATE_CHECK_INTERVAL_HOURS = _get_env_int('UPDATE_CHECK_INTERVAL_HOURS', 6)
|
||||||
|
|
||||||
# Alerting
|
|
||||||
ALERT_WEBHOOK_URL = _get_env('ALERT_WEBHOOK_URL', '')
|
|
||||||
ALERT_WEBHOOK_SECRET = _get_env('ALERT_WEBHOOK_SECRET', '')
|
|
||||||
ALERT_WEBHOOK_TIMEOUT = _get_env_int('ALERT_WEBHOOK_TIMEOUT', 5)
|
|
||||||
|
|
||||||
# Admin credentials
|
# Admin credentials
|
||||||
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
ADMIN_USERNAME = _get_env('ADMIN_USERNAME', 'admin')
|
||||||
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
ADMIN_PASSWORD = _get_env('ADMIN_PASSWORD', 'admin')
|
||||||
|
|||||||
+5
-87
@@ -843,7 +843,6 @@ class ModeManager:
|
|||||||
'anomalies': getattr(self, 'tscm_anomalies', []),
|
'anomalies': getattr(self, 'tscm_anomalies', []),
|
||||||
'baseline': getattr(self, 'tscm_baseline', {}),
|
'baseline': getattr(self, 'tscm_baseline', {}),
|
||||||
'wifi_devices': list(self.wifi_networks.values()),
|
'wifi_devices': list(self.wifi_networks.values()),
|
||||||
'wifi_clients': list(getattr(self, 'tscm_wifi_clients', {}).values()),
|
|
||||||
'bt_devices': list(self.bluetooth_devices.values()),
|
'bt_devices': list(self.bluetooth_devices.values()),
|
||||||
'rf_signals': getattr(self, 'tscm_rf_signals', []),
|
'rf_signals': getattr(self, 'tscm_rf_signals', []),
|
||||||
}
|
}
|
||||||
@@ -1117,7 +1116,6 @@ class ModeManager:
|
|||||||
self.tscm_anomalies = []
|
self.tscm_anomalies = []
|
||||||
self.tscm_baseline = {}
|
self.tscm_baseline = {}
|
||||||
self.tscm_rf_signals = []
|
self.tscm_rf_signals = []
|
||||||
self.tscm_wifi_clients = {}
|
|
||||||
# Clear reported threat tracking sets
|
# Clear reported threat tracking sets
|
||||||
if hasattr(self, '_tscm_reported_wifi'):
|
if hasattr(self, '_tscm_reported_wifi'):
|
||||||
self._tscm_reported_wifi.clear()
|
self._tscm_reported_wifi.clear()
|
||||||
@@ -1543,7 +1541,6 @@ class ModeManager:
|
|||||||
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
|
"""Start WiFi scanning using Intercept's UnifiedWiFiScanner."""
|
||||||
interface = params.get('interface')
|
interface = params.get('interface')
|
||||||
channel = params.get('channel')
|
channel = params.get('channel')
|
||||||
channels = params.get('channels')
|
|
||||||
band = params.get('band', 'abg')
|
band = params.get('band', 'abg')
|
||||||
scan_type = params.get('scan_type', 'deep')
|
scan_type = params.get('scan_type', 'deep')
|
||||||
|
|
||||||
@@ -1574,21 +1571,8 @@ class ModeManager:
|
|||||||
else:
|
else:
|
||||||
scan_band = 'all'
|
scan_band = 'all'
|
||||||
|
|
||||||
channel_list = None
|
|
||||||
if channels:
|
|
||||||
if isinstance(channels, str):
|
|
||||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
|
||||||
elif isinstance(channels, (list, tuple, set)):
|
|
||||||
channel_list = list(channels)
|
|
||||||
else:
|
|
||||||
channel_list = [channels]
|
|
||||||
try:
|
|
||||||
channel_list = [int(c) for c in channel_list]
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return {'status': 'error', 'message': 'Invalid channels'}
|
|
||||||
|
|
||||||
# Start deep scan
|
# Start deep scan
|
||||||
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel, channels=channel_list):
|
if scanner.start_deep_scan(interface=interface, band=scan_band, channel=channel):
|
||||||
# Start thread to sync data to agent's dictionaries
|
# Start thread to sync data to agent's dictionaries
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=self._wifi_data_sync,
|
target=self._wifi_data_sync,
|
||||||
@@ -1609,7 +1593,7 @@ class ModeManager:
|
|||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback to direct airodump-ng
|
# Fallback to direct airodump-ng
|
||||||
return self._start_wifi_fallback(interface, channel, band, channels)
|
return self._start_wifi_fallback(interface, channel, band)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"WiFi scanner error: {e}")
|
logger.error(f"WiFi scanner error: {e}")
|
||||||
return {'status': 'error', 'message': str(e)}
|
return {'status': 'error', 'message': str(e)}
|
||||||
@@ -1646,13 +1630,7 @@ class ModeManager:
|
|||||||
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
|
if hasattr(self, '_wifi_scanner_instance') and self._wifi_scanner_instance:
|
||||||
self._wifi_scanner_instance.stop_deep_scan()
|
self._wifi_scanner_instance.stop_deep_scan()
|
||||||
|
|
||||||
def _start_wifi_fallback(
|
def _start_wifi_fallback(self, interface: str | None, channel: int | None, band: str) -> dict:
|
||||||
self,
|
|
||||||
interface: str | None,
|
|
||||||
channel: int | None,
|
|
||||||
band: str,
|
|
||||||
channels: list[int] | str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Fallback WiFi deep scan using airodump-ng directly."""
|
"""Fallback WiFi deep scan using airodump-ng directly."""
|
||||||
if not interface:
|
if not interface:
|
||||||
return {'status': 'error', 'message': 'WiFi interface required'}
|
return {'status': 'error', 'message': 'WiFi interface required'}
|
||||||
@@ -1680,22 +1658,7 @@ class ModeManager:
|
|||||||
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
|
cmd = [airodump_path, '-w', csv_path, '--output-format', output_formats, '--band', band]
|
||||||
if gps_manager.is_running:
|
if gps_manager.is_running:
|
||||||
cmd.append('--gpsd')
|
cmd.append('--gpsd')
|
||||||
channel_list = None
|
if channel:
|
||||||
if channels:
|
|
||||||
if isinstance(channels, str):
|
|
||||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
|
||||||
elif isinstance(channels, (list, tuple, set)):
|
|
||||||
channel_list = list(channels)
|
|
||||||
else:
|
|
||||||
channel_list = [channels]
|
|
||||||
try:
|
|
||||||
channel_list = [int(c) for c in channel_list]
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return {'status': 'error', 'message': 'Invalid channels'}
|
|
||||||
|
|
||||||
if channel_list:
|
|
||||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
|
||||||
elif channel:
|
|
||||||
cmd.extend(['-c', str(channel)])
|
cmd.extend(['-c', str(channel)])
|
||||||
cmd.append(interface)
|
cmd.append(interface)
|
||||||
|
|
||||||
@@ -3150,10 +3113,7 @@ class ModeManager:
|
|||||||
self.tscm_anomalies = []
|
self.tscm_anomalies = []
|
||||||
if not hasattr(self, 'tscm_rf_signals'):
|
if not hasattr(self, 'tscm_rf_signals'):
|
||||||
self.tscm_rf_signals = []
|
self.tscm_rf_signals = []
|
||||||
if not hasattr(self, 'tscm_wifi_clients'):
|
|
||||||
self.tscm_wifi_clients = {}
|
|
||||||
self.tscm_anomalies.clear()
|
self.tscm_anomalies.clear()
|
||||||
self.tscm_wifi_clients.clear()
|
|
||||||
|
|
||||||
# Get params for what to scan
|
# Get params for what to scan
|
||||||
scan_wifi = params.get('wifi', True)
|
scan_wifi = params.get('wifi', True)
|
||||||
@@ -3208,7 +3168,7 @@ class ModeManager:
|
|||||||
stop_event = self.stop_events.get(mode)
|
stop_event = self.stop_events.get(mode)
|
||||||
|
|
||||||
# Import existing Intercept TSCM functions
|
# Import existing Intercept TSCM functions
|
||||||
from routes.tscm import _scan_wifi_networks, _scan_wifi_clients, _scan_bluetooth_devices, _scan_rf_signals
|
from routes.tscm import _scan_wifi_networks, _scan_bluetooth_devices, _scan_rf_signals
|
||||||
logger.info("TSCM imports successful")
|
logger.info("TSCM imports successful")
|
||||||
|
|
||||||
sweep_ranges = None
|
sweep_ranges = None
|
||||||
@@ -3243,7 +3203,6 @@ class ModeManager:
|
|||||||
|
|
||||||
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
|
# Track devices seen during this sweep (like local mode's all_wifi/all_bt dicts)
|
||||||
seen_wifi = {}
|
seen_wifi = {}
|
||||||
seen_wifi_clients = {}
|
|
||||||
seen_bt = {}
|
seen_bt = {}
|
||||||
|
|
||||||
last_rf_scan = 0
|
last_rf_scan = 0
|
||||||
@@ -3304,47 +3263,6 @@ class ModeManager:
|
|||||||
enriched['recommended_action'] = profile.recommended_action
|
enriched['recommended_action'] = profile.recommended_action
|
||||||
|
|
||||||
self.wifi_networks[bssid] = enriched
|
self.wifi_networks[bssid] = enriched
|
||||||
|
|
||||||
# WiFi clients (monitor mode only)
|
|
||||||
try:
|
|
||||||
wifi_clients = _scan_wifi_clients(wifi_interface or '')
|
|
||||||
for client in wifi_clients:
|
|
||||||
mac = (client.get('mac') or '').upper()
|
|
||||||
if not mac or mac in seen_wifi_clients:
|
|
||||||
continue
|
|
||||||
seen_wifi_clients[mac] = client
|
|
||||||
|
|
||||||
rssi_val = client.get('rssi_current')
|
|
||||||
if rssi_val is None:
|
|
||||||
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
|
|
||||||
|
|
||||||
client_device = {
|
|
||||||
'mac': mac,
|
|
||||||
'vendor': client.get('vendor'),
|
|
||||||
'name': client.get('vendor') or 'WiFi Client',
|
|
||||||
'rssi': rssi_val,
|
|
||||||
'associated_bssid': client.get('associated_bssid'),
|
|
||||||
'probed_ssids': client.get('probed_ssids', []),
|
|
||||||
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
|
|
||||||
'is_client': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
if self._tscm_correlation:
|
|
||||||
profile = self._tscm_correlation.analyze_wifi_device(client_device)
|
|
||||||
client_device['classification'] = profile.risk_level.value
|
|
||||||
client_device['score'] = profile.total_score
|
|
||||||
client_device['score_modifier'] = profile.score_modifier
|
|
||||||
client_device['known_device'] = profile.known_device
|
|
||||||
client_device['known_device_name'] = profile.known_device_name
|
|
||||||
client_device['indicators'] = [
|
|
||||||
{'type': i.type.value, 'desc': i.description}
|
|
||||||
for i in profile.indicators
|
|
||||||
]
|
|
||||||
client_device['recommended_action'] = profile.recommended_action
|
|
||||||
|
|
||||||
self.tscm_wifi_clients[mac] = client_device
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"WiFi client scan error: {e}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"WiFi scan error: {e}")
|
logger.debug(f"WiFi scan error: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ def register_blueprints(app):
|
|||||||
from .sstv_general import sstv_general_bp
|
from .sstv_general import sstv_general_bp
|
||||||
from .dmr import dmr_bp
|
from .dmr import dmr_bp
|
||||||
from .websdr import websdr_bp
|
from .websdr import websdr_bp
|
||||||
from .alerts import alerts_bp
|
|
||||||
from .recordings import recordings_bp
|
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -59,8 +57,6 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
||||||
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
|
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
|
||||||
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
||||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
|
||||||
app.register_blueprint(recordings_bp) # Session recordings
|
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import app as app_module
|
|||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
@@ -394,10 +393,6 @@ def stream_acars() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('acars', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ from utils.validation import (
|
|||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
ADSB_SBS_PORT,
|
ADSB_SBS_PORT,
|
||||||
@@ -844,10 +843,6 @@ def stream_adsb():
|
|||||||
try:
|
try:
|
||||||
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('adsb', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from config import SHARED_OBSERVER_LOCATION_ENABLED
|
|||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.validation import validate_device_index, validate_gain
|
from utils.validation import validate_device_index, validate_gain
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
AIS_TCP_PORT,
|
AIS_TCP_PORT,
|
||||||
@@ -485,10 +484,6 @@ def stream_ais():
|
|||||||
try:
|
try:
|
||||||
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('ais', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
"""Alerting API endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import queue
|
|
||||||
import time
|
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
|
||||||
|
|
||||||
from utils.alerts import get_alert_manager
|
|
||||||
from utils.sse import format_sse
|
|
||||||
|
|
||||||
alerts_bp = Blueprint('alerts', __name__, url_prefix='/alerts')
|
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/rules', methods=['GET'])
|
|
||||||
def list_rules():
|
|
||||||
manager = get_alert_manager()
|
|
||||||
include_disabled = request.args.get('all') in ('1', 'true', 'yes')
|
|
||||||
return jsonify({'status': 'success', 'rules': manager.list_rules(include_disabled=include_disabled)})
|
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/rules', methods=['POST'])
|
|
||||||
def create_rule():
|
|
||||||
data = request.get_json() or {}
|
|
||||||
if not isinstance(data.get('match', {}), dict):
|
|
||||||
return jsonify({'status': 'error', 'message': 'match must be a JSON object'}), 400
|
|
||||||
|
|
||||||
manager = get_alert_manager()
|
|
||||||
rule_id = manager.add_rule(data)
|
|
||||||
return jsonify({'status': 'success', 'rule_id': rule_id})
|
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/rules/<int:rule_id>', methods=['PUT', 'PATCH'])
|
|
||||||
def update_rule(rule_id: int):
|
|
||||||
data = request.get_json() or {}
|
|
||||||
manager = get_alert_manager()
|
|
||||||
ok = manager.update_rule(rule_id, data)
|
|
||||||
if not ok:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Rule not found or no changes'}), 404
|
|
||||||
return jsonify({'status': 'success'})
|
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/rules/<int:rule_id>', methods=['DELETE'])
|
|
||||||
def delete_rule(rule_id: int):
|
|
||||||
manager = get_alert_manager()
|
|
||||||
ok = manager.delete_rule(rule_id)
|
|
||||||
if not ok:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Rule not found'}), 404
|
|
||||||
return jsonify({'status': 'success'})
|
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/events', methods=['GET'])
|
|
||||||
def list_events():
|
|
||||||
manager = get_alert_manager()
|
|
||||||
limit = request.args.get('limit', default=100, type=int)
|
|
||||||
mode = request.args.get('mode')
|
|
||||||
severity = request.args.get('severity')
|
|
||||||
events = manager.list_events(limit=limit, mode=mode, severity=severity)
|
|
||||||
return jsonify({'status': 'success', 'events': events})
|
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.route('/stream', methods=['GET'])
|
|
||||||
def stream_alerts() -> Response:
|
|
||||||
manager = get_alert_manager()
|
|
||||||
|
|
||||||
def generate() -> Generator[str, None, None]:
|
|
||||||
for event in manager.stream_events(timeout=1.0):
|
|
||||||
yield format_sse(event)
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
response.headers['Connection'] = 'keep-alive'
|
|
||||||
return response
|
|
||||||
@@ -22,7 +22,6 @@ import app as app_module
|
|||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
@@ -1728,10 +1727,6 @@ def stream_aprs() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('aprs', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import app as app_module
|
|||||||
from utils.dependencies import check_tool
|
from utils.dependencies import check_tool
|
||||||
from utils.logging import bluetooth_logger as logger
|
from utils.logging import bluetooth_logger as logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.validation import validate_bluetooth_interface
|
from utils.validation import validate_bluetooth_interface
|
||||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||||
@@ -564,10 +563,6 @@ def stream_bt():
|
|||||||
try:
|
try:
|
||||||
msg = app_module.bt_queue.get(timeout=1)
|
msg = app_module.bt_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('bluetooth', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+3
-105
@@ -11,8 +11,6 @@ import csv
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
@@ -30,18 +28,12 @@ from utils.bluetooth import (
|
|||||||
)
|
)
|
||||||
from utils.database import get_db
|
from utils.database import get_db
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.bluetooth_v2')
|
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||||
|
|
||||||
# Blueprint
|
# Blueprint
|
||||||
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
||||||
|
|
||||||
# Seen-before tracking
|
|
||||||
_bt_seen_cache: set[str] = set()
|
|
||||||
_bt_session_seen: set[str] = set()
|
|
||||||
_bt_seen_lock = threading.Lock()
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# DATABASE FUNCTIONS
|
# DATABASE FUNCTIONS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -181,13 +173,6 @@ def save_observation_history(device: BTDeviceAggregate) -> None:
|
|||||||
''', (device.device_id, device.rssi_current, device.seen_count))
|
''', (device.device_id, device.rssi_current, device.seen_count))
|
||||||
|
|
||||||
|
|
||||||
def load_seen_device_ids() -> set[str]:
|
|
||||||
"""Load distinct device IDs from history for seen-before tracking."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cursor = conn.execute('SELECT DISTINCT device_id FROM bt_observation_history')
|
|
||||||
return {row['device_id'] for row in cursor}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# API ENDPOINTS
|
# API ENDPOINTS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -236,28 +221,6 @@ def start_scan():
|
|||||||
# Get scanner instance
|
# Get scanner instance
|
||||||
scanner = get_bluetooth_scanner(adapter_id)
|
scanner = get_bluetooth_scanner(adapter_id)
|
||||||
|
|
||||||
# Initialize database tables if needed
|
|
||||||
init_bt_tables()
|
|
||||||
|
|
||||||
def _handle_seen_before(device: BTDeviceAggregate) -> None:
|
|
||||||
try:
|
|
||||||
with _bt_seen_lock:
|
|
||||||
device.seen_before = device.device_id in _bt_seen_cache
|
|
||||||
if device.device_id not in _bt_session_seen:
|
|
||||||
save_observation_history(device)
|
|
||||||
_bt_session_seen.add(device.device_id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"BT seen-before update failed: {e}")
|
|
||||||
|
|
||||||
# Setup seen-before callback
|
|
||||||
if scanner._on_device_updated is None:
|
|
||||||
scanner._on_device_updated = _handle_seen_before
|
|
||||||
|
|
||||||
# Ensure cache is initialized
|
|
||||||
with _bt_seen_lock:
|
|
||||||
if not _bt_seen_cache:
|
|
||||||
_bt_seen_cache.update(load_seen_device_ids())
|
|
||||||
|
|
||||||
# Check if already scanning
|
# Check if already scanning
|
||||||
if scanner.is_scanning:
|
if scanner.is_scanning:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -265,11 +228,8 @@ def start_scan():
|
|||||||
'scan_status': scanner.get_status().to_dict()
|
'scan_status': scanner.get_status().to_dict()
|
||||||
})
|
})
|
||||||
|
|
||||||
# Refresh seen-before cache and reset session set for a new scan
|
# Initialize database tables if needed
|
||||||
with _bt_seen_lock:
|
init_bt_tables()
|
||||||
_bt_seen_cache.clear()
|
|
||||||
_bt_seen_cache.update(load_seen_device_ids())
|
|
||||||
_bt_session_seen.clear()
|
|
||||||
|
|
||||||
# Load active baseline if exists
|
# Load active baseline if exists
|
||||||
baseline_id = get_active_baseline_id()
|
baseline_id = get_active_baseline_id()
|
||||||
@@ -900,10 +860,6 @@ def stream_events():
|
|||||||
"""Generate SSE events from scanner."""
|
"""Generate SSE events from scanner."""
|
||||||
for event in scanner.stream_events(timeout=1.0):
|
for event in scanner.stream_events(timeout=1.0):
|
||||||
event_name, event_data = map_event_type(event)
|
event_name, event_data = map_event_type(event)
|
||||||
try:
|
|
||||||
process_event('bluetooth', event_data, event_name)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(event_data, event=event_name)
|
yield format_sse(event_data, event=event_name)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
@@ -991,17 +947,6 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
|||||||
# Convert to TSCM format with tracker detection data
|
# Convert to TSCM format with tracker detection data
|
||||||
tscm_devices = []
|
tscm_devices = []
|
||||||
for device in devices:
|
for device in devices:
|
||||||
manufacturer_name = device.manufacturer_name
|
|
||||||
if (not manufacturer_name) or str(manufacturer_name).lower().startswith('unknown'):
|
|
||||||
if device.address and not device.is_randomized_mac:
|
|
||||||
try:
|
|
||||||
from data.oui import get_manufacturer
|
|
||||||
oui_vendor = get_manufacturer(device.address)
|
|
||||||
if oui_vendor and oui_vendor != 'Unknown':
|
|
||||||
manufacturer_name = oui_vendor
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
device_data = {
|
device_data = {
|
||||||
'mac': device.address,
|
'mac': device.address,
|
||||||
'address_type': device.address_type,
|
'address_type': device.address_type,
|
||||||
@@ -1011,7 +956,7 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
|||||||
'rssi_median': device.rssi_median,
|
'rssi_median': device.rssi_median,
|
||||||
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||||
'type': _classify_device_type(device),
|
'type': _classify_device_type(device),
|
||||||
'manufacturer': manufacturer_name,
|
'manufacturer': device.manufacturer_name,
|
||||||
'manufacturer_id': device.manufacturer_id,
|
'manufacturer_id': device.manufacturer_id,
|
||||||
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
'manufacturer_data': device.manufacturer_bytes.hex() if device.manufacturer_bytes else None,
|
||||||
'protocol': device.protocol,
|
'protocol': device.protocol,
|
||||||
@@ -1233,30 +1178,6 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
|
|||||||
"""Classify device type from available data."""
|
"""Classify device type from available data."""
|
||||||
name_lower = (device.name or '').lower()
|
name_lower = (device.name or '').lower()
|
||||||
manufacturer_lower = (device.manufacturer_name or '').lower()
|
manufacturer_lower = (device.manufacturer_name or '').lower()
|
||||||
service_uuids = device.service_uuids or []
|
|
||||||
|
|
||||||
if (not manufacturer_lower) or manufacturer_lower.startswith('unknown'):
|
|
||||||
if device.address and not device.is_randomized_mac:
|
|
||||||
try:
|
|
||||||
from data.oui import get_manufacturer
|
|
||||||
oui_vendor = get_manufacturer(device.address)
|
|
||||||
if oui_vendor and oui_vendor != 'Unknown':
|
|
||||||
manufacturer_lower = oui_vendor.lower()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def normalize_uuid(uuid: str) -> str:
|
|
||||||
if not uuid:
|
|
||||||
return ''
|
|
||||||
value = str(uuid).lower().strip()
|
|
||||||
if value.startswith('0x'):
|
|
||||||
value = value[2:]
|
|
||||||
# Bluetooth Base UUID normalization (16-bit UUIDs)
|
|
||||||
if value.endswith('-0000-1000-8000-00805f9b34fb') and len(value) >= 8:
|
|
||||||
return value[4:8]
|
|
||||||
if len(value) == 4:
|
|
||||||
return value
|
|
||||||
return value
|
|
||||||
|
|
||||||
# Check by name patterns
|
# Check by name patterns
|
||||||
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
||||||
@@ -1276,29 +1197,6 @@ def _classify_device_type(device: BTDeviceAggregate) -> str:
|
|||||||
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
|
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
|
||||||
return 'media'
|
return 'media'
|
||||||
|
|
||||||
# Tracker signals (metadata or Find My service)
|
|
||||||
if getattr(device, 'is_tracker', False) or getattr(device, 'tracker_type', None):
|
|
||||||
return 'tracker'
|
|
||||||
|
|
||||||
normalized_uuids = {normalize_uuid(u) for u in service_uuids if u}
|
|
||||||
if 'fd6f' in normalized_uuids:
|
|
||||||
return 'tracker'
|
|
||||||
|
|
||||||
# Service UUIDs (GATT / classic)
|
|
||||||
audio_uuids = {'110b', '110a', '111e', '111f', '1108', '1203'}
|
|
||||||
wearable_uuids = {'180d', '1814', '1816'}
|
|
||||||
hid_uuids = {'1812'}
|
|
||||||
beacon_uuids = {'feaa', 'feab', 'feb1', 'febe'}
|
|
||||||
|
|
||||||
if normalized_uuids & audio_uuids:
|
|
||||||
return 'audio'
|
|
||||||
if normalized_uuids & hid_uuids:
|
|
||||||
return 'peripheral'
|
|
||||||
if normalized_uuids & wearable_uuids:
|
|
||||||
return 'wearable'
|
|
||||||
if normalized_uuids & beacon_uuids:
|
|
||||||
return 'beacon'
|
|
||||||
|
|
||||||
# Check by manufacturer
|
# Check by manufacturer
|
||||||
if 'apple' in manufacturer_lower:
|
if 'apple' in manufacturer_lower:
|
||||||
return 'apple_device'
|
return 'apple_device'
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.process import register_process, unregister_process
|
from utils.process import register_process, unregister_process
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
@@ -496,10 +495,6 @@ def stream_dmr() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('dmr', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ from utils.database import (
|
|||||||
)
|
)
|
||||||
from utils.dsc.parser import parse_dsc_message
|
from utils.dsc.parser import parse_dsc_message
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.validation import validate_device_index, validate_gain
|
from utils.validation import validate_device_index, validate_gain
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.dependencies import get_tool_path
|
from utils.dependencies import get_tool_path
|
||||||
@@ -526,10 +525,6 @@ def stream() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.dsc_queue.get(timeout=1)
|
msg = app_module.dsc_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('dsc', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+91
-191
@@ -20,7 +20,6 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
@@ -840,13 +839,9 @@ def _start_audio_stream(frequency: float, modulation: str):
|
|||||||
try:
|
try:
|
||||||
ready, _, _ = select.select([audio_process.stdout], [], [], 4.0)
|
ready, _, _ = select.select([audio_process.stdout], [], [], 4.0)
|
||||||
if not ready:
|
if not ready:
|
||||||
logger.warning("Audio pipeline produced no data in startup window — killing stalled pipeline")
|
logger.warning("Audio pipeline produced no data in startup window")
|
||||||
_stop_audio_stream_internal()
|
|
||||||
return
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Audio startup check failed: {e}")
|
logger.warning(f"Audio startup check failed: {e}")
|
||||||
_stop_audio_stream_internal()
|
|
||||||
return
|
|
||||||
|
|
||||||
audio_running = True
|
audio_running = True
|
||||||
audio_frequency = frequency
|
audio_frequency = frequency
|
||||||
@@ -871,8 +866,6 @@ def _stop_audio_stream_internal():
|
|||||||
audio_running = False
|
audio_running = False
|
||||||
audio_frequency = 0.0
|
audio_frequency = 0.0
|
||||||
|
|
||||||
had_processes = audio_process is not None or audio_rtl_process is not None
|
|
||||||
|
|
||||||
# Kill the pipeline processes and their groups
|
# Kill the pipeline processes and their groups
|
||||||
if audio_process:
|
if audio_process:
|
||||||
try:
|
try:
|
||||||
@@ -899,8 +892,7 @@ def _stop_audio_stream_internal():
|
|||||||
audio_rtl_process = None
|
audio_rtl_process = None
|
||||||
|
|
||||||
# Pause for SDR device USB interface to be released by kernel
|
# Pause for SDR device USB interface to be released by kernel
|
||||||
if had_processes:
|
time.sleep(1.0)
|
||||||
time.sleep(1.0)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -1183,10 +1175,6 @@ def stream_scanner_events() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('listening_scanner', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -1305,11 +1293,6 @@ def start_audio() -> Response:
|
|||||||
scanner_config['device'] = device
|
scanner_config['device'] = device
|
||||||
scanner_config['sdr_type'] = sdr_type
|
scanner_config['sdr_type'] = sdr_type
|
||||||
|
|
||||||
# Stop waterfall if it's using the same SDR
|
|
||||||
if waterfall_running and waterfall_active_device == device:
|
|
||||||
_stop_waterfall_internal()
|
|
||||||
time.sleep(0.2)
|
|
||||||
|
|
||||||
# Claim device for listening audio
|
# Claim device for listening audio
|
||||||
if listening_active_device is None or listening_active_device != device:
|
if listening_active_device is None or listening_active_device != device:
|
||||||
if listening_active_device is not None:
|
if listening_active_device is not None:
|
||||||
@@ -1417,6 +1400,13 @@ def audio_probe() -> Response:
|
|||||||
@listening_post_bp.route('/audio/stream')
|
@listening_post_bp.route('/audio/stream')
|
||||||
def stream_audio() -> Response:
|
def stream_audio() -> Response:
|
||||||
"""Stream WAV audio."""
|
"""Stream WAV audio."""
|
||||||
|
# Optionally restart pipeline so the stream starts with a fresh header
|
||||||
|
if request.args.get('fresh') == '1' and audio_running:
|
||||||
|
try:
|
||||||
|
_start_audio_stream(audio_frequency or 0.0, audio_modulation or 'fm')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Audio stream restart failed: {e}")
|
||||||
|
|
||||||
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
||||||
for _ in range(40):
|
for _ in range(40):
|
||||||
if audio_running and audio_process:
|
if audio_running and audio_process:
|
||||||
@@ -1531,51 +1521,9 @@ waterfall_config = {
|
|||||||
'bin_size': 10000,
|
'bin_size': 10000,
|
||||||
'gain': 40,
|
'gain': 40,
|
||||||
'device': 0,
|
'device': 0,
|
||||||
'max_bins': 1024,
|
|
||||||
'interval': 0.4,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | None, list[float]]:
|
|
||||||
"""Parse a single rtl_power CSV line into bins."""
|
|
||||||
if not line or line.startswith('#'):
|
|
||||||
return None, None, None, []
|
|
||||||
|
|
||||||
parts = [p.strip() for p in line.split(',')]
|
|
||||||
if len(parts) < 6:
|
|
||||||
return None, None, None, []
|
|
||||||
|
|
||||||
# Timestamp in first two fields (YYYY-MM-DD, HH:MM:SS)
|
|
||||||
timestamp = f"{parts[0]} {parts[1]}" if len(parts) >= 2 else parts[0]
|
|
||||||
|
|
||||||
start_idx = None
|
|
||||||
for i, tok in enumerate(parts):
|
|
||||||
try:
|
|
||||||
val = float(tok)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if val > 1e5:
|
|
||||||
start_idx = i
|
|
||||||
break
|
|
||||||
if start_idx is None or len(parts) < start_idx + 4:
|
|
||||||
return timestamp, None, None, []
|
|
||||||
|
|
||||||
try:
|
|
||||||
seg_start = float(parts[start_idx])
|
|
||||||
seg_end = float(parts[start_idx + 1])
|
|
||||||
raw_values = []
|
|
||||||
for v in parts[start_idx + 3:]:
|
|
||||||
try:
|
|
||||||
raw_values.append(float(v))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
|
||||||
raw_values = raw_values[1:]
|
|
||||||
return timestamp, seg_start, seg_end, raw_values
|
|
||||||
except ValueError:
|
|
||||||
return timestamp, None, None, []
|
|
||||||
|
|
||||||
|
|
||||||
def _waterfall_loop():
|
def _waterfall_loop():
|
||||||
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
||||||
global waterfall_running, waterfall_process
|
global waterfall_running, waterfall_process
|
||||||
@@ -1586,59 +1534,84 @@ def _waterfall_loop():
|
|||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
return
|
return
|
||||||
|
|
||||||
start_hz = int(waterfall_config['start_freq'] * 1e6)
|
|
||||||
end_hz = int(waterfall_config['end_freq'] * 1e6)
|
|
||||||
bin_hz = int(waterfall_config['bin_size'])
|
|
||||||
gain = waterfall_config['gain']
|
|
||||||
device = waterfall_config['device']
|
|
||||||
interval = float(waterfall_config.get('interval', 0.4))
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
rtl_power_path,
|
|
||||||
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
|
||||||
'-i', str(interval),
|
|
||||||
'-g', str(gain),
|
|
||||||
'-d', str(device),
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
waterfall_process = subprocess.Popen(
|
while waterfall_running:
|
||||||
cmd,
|
start_hz = int(waterfall_config['start_freq'] * 1e6)
|
||||||
stdout=subprocess.PIPE,
|
end_hz = int(waterfall_config['end_freq'] * 1e6)
|
||||||
stderr=subprocess.DEVNULL,
|
bin_hz = int(waterfall_config['bin_size'])
|
||||||
bufsize=1,
|
gain = waterfall_config['gain']
|
||||||
text=True,
|
device = waterfall_config['device']
|
||||||
)
|
|
||||||
|
|
||||||
current_ts = None
|
cmd = [
|
||||||
all_bins: list[float] = []
|
rtl_power_path,
|
||||||
sweep_start_hz = start_hz
|
'-f', f'{start_hz}:{end_hz}:{bin_hz}',
|
||||||
sweep_end_hz = end_hz
|
'-i', '0.5',
|
||||||
|
'-1',
|
||||||
|
'-g', str(gain),
|
||||||
|
'-d', str(device),
|
||||||
|
]
|
||||||
|
|
||||||
if not waterfall_process.stdout:
|
try:
|
||||||
return
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||||
|
waterfall_process = proc
|
||||||
|
stdout, _ = proc.communicate(timeout=15)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
stdout = b''
|
||||||
|
finally:
|
||||||
|
waterfall_process = None
|
||||||
|
|
||||||
for line in waterfall_process.stdout:
|
|
||||||
if not waterfall_running:
|
if not waterfall_running:
|
||||||
break
|
break
|
||||||
|
|
||||||
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
if not stdout:
|
||||||
if ts is None or not bins:
|
time.sleep(0.2)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if current_ts is None:
|
# Parse rtl_power CSV output
|
||||||
current_ts = ts
|
all_bins = []
|
||||||
|
sweep_start_hz = start_hz
|
||||||
|
sweep_end_hz = end_hz
|
||||||
|
|
||||||
if ts != current_ts and all_bins:
|
for line in stdout.decode(errors='ignore').splitlines():
|
||||||
max_bins = int(waterfall_config.get('max_bins') or 0)
|
if not line or line.startswith('#'):
|
||||||
bins_to_send = all_bins
|
continue
|
||||||
if max_bins > 0 and len(bins_to_send) > max_bins:
|
parts = [p.strip() for p in line.split(',')]
|
||||||
bins_to_send = _downsample_bins(bins_to_send, max_bins)
|
start_idx = None
|
||||||
|
for i, tok in enumerate(parts):
|
||||||
|
try:
|
||||||
|
val = float(tok)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if val > 1e5:
|
||||||
|
start_idx = i
|
||||||
|
break
|
||||||
|
if start_idx is None or len(parts) < start_idx + 4:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
seg_start = float(parts[start_idx])
|
||||||
|
seg_end = float(parts[start_idx + 1])
|
||||||
|
seg_bin = float(parts[start_idx + 2])
|
||||||
|
raw_values = []
|
||||||
|
for v in parts[start_idx + 3:]:
|
||||||
|
try:
|
||||||
|
raw_values.append(float(v))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if raw_values and raw_values[0] >= 0 and any(val < 0 for val in raw_values[1:]):
|
||||||
|
raw_values = raw_values[1:]
|
||||||
|
all_bins.extend(raw_values)
|
||||||
|
sweep_start_hz = min(sweep_start_hz, seg_start)
|
||||||
|
sweep_end_hz = max(sweep_end_hz, seg_end)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if all_bins:
|
||||||
msg = {
|
msg = {
|
||||||
'type': 'waterfall_sweep',
|
'type': 'waterfall_sweep',
|
||||||
'start_freq': sweep_start_hz / 1e6,
|
'start_freq': sweep_start_hz / 1e6,
|
||||||
'end_freq': sweep_end_hz / 1e6,
|
'end_freq': sweep_end_hz / 1e6,
|
||||||
'bins': bins_to_send,
|
'bins': all_bins,
|
||||||
'timestamp': datetime.now().isoformat(),
|
'timestamp': datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
@@ -1653,73 +1626,15 @@ def _waterfall_loop():
|
|||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
all_bins = []
|
time.sleep(0.1)
|
||||||
sweep_start_hz = start_hz
|
|
||||||
sweep_end_hz = end_hz
|
|
||||||
current_ts = ts
|
|
||||||
|
|
||||||
all_bins.extend(bins)
|
|
||||||
if seg_start is not None:
|
|
||||||
sweep_start_hz = min(sweep_start_hz, seg_start)
|
|
||||||
if seg_end is not None:
|
|
||||||
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)
|
|
||||||
msg = {
|
|
||||||
'type': 'waterfall_sweep',
|
|
||||||
'start_freq': sweep_start_hz / 1e6,
|
|
||||||
'end_freq': sweep_end_hz / 1e6,
|
|
||||||
'bins': bins_to_send,
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
waterfall_queue.put_nowait(msg)
|
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Waterfall loop error: {e}")
|
logger.error(f"Waterfall loop error: {e}")
|
||||||
finally:
|
finally:
|
||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
if waterfall_process and waterfall_process.poll() is None:
|
|
||||||
try:
|
|
||||||
waterfall_process.terminate()
|
|
||||||
waterfall_process.wait(timeout=1)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
waterfall_process.kill()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
waterfall_process = None
|
|
||||||
logger.info("Waterfall loop stopped")
|
logger.info("Waterfall loop stopped")
|
||||||
|
|
||||||
|
|
||||||
def _stop_waterfall_internal() -> None:
|
|
||||||
"""Stop the waterfall display and release resources."""
|
|
||||||
global waterfall_running, waterfall_process, waterfall_active_device
|
|
||||||
|
|
||||||
waterfall_running = False
|
|
||||||
if waterfall_process and waterfall_process.poll() is None:
|
|
||||||
try:
|
|
||||||
waterfall_process.terminate()
|
|
||||||
waterfall_process.wait(timeout=1)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
waterfall_process.kill()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
waterfall_process = None
|
|
||||||
|
|
||||||
if waterfall_active_device is not None:
|
|
||||||
app_module.release_sdr_device(waterfall_active_device)
|
|
||||||
waterfall_active_device = None
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/waterfall/start', methods=['POST'])
|
@listening_post_bp.route('/waterfall/start', methods=['POST'])
|
||||||
def start_waterfall() -> Response:
|
def start_waterfall() -> Response:
|
||||||
"""Start the waterfall/spectrogram display."""
|
"""Start the waterfall/spectrogram display."""
|
||||||
@@ -1740,16 +1655,6 @@ def start_waterfall() -> Response:
|
|||||||
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
waterfall_config['bin_size'] = int(data.get('bin_size', 10000))
|
||||||
waterfall_config['gain'] = int(data.get('gain', 40))
|
waterfall_config['gain'] = int(data.get('gain', 40))
|
||||||
waterfall_config['device'] = int(data.get('device', 0))
|
waterfall_config['device'] = int(data.get('device', 0))
|
||||||
if data.get('interval') is not None:
|
|
||||||
interval = float(data.get('interval', waterfall_config['interval']))
|
|
||||||
if interval < 0.1 or interval > 5:
|
|
||||||
return jsonify({'status': 'error', 'message': 'interval must be between 0.1 and 5 seconds'}), 400
|
|
||||||
waterfall_config['interval'] = interval
|
|
||||||
if data.get('max_bins') is not None:
|
|
||||||
max_bins = int(data.get('max_bins', waterfall_config['max_bins']))
|
|
||||||
if max_bins < 64 or max_bins > 4096:
|
|
||||||
return jsonify({'status': 'error', 'message': 'max_bins must be between 64 and 4096'}), 400
|
|
||||||
waterfall_config['max_bins'] = max_bins
|
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
||||||
|
|
||||||
@@ -1779,7 +1684,23 @@ def start_waterfall() -> Response:
|
|||||||
@listening_post_bp.route('/waterfall/stop', methods=['POST'])
|
@listening_post_bp.route('/waterfall/stop', methods=['POST'])
|
||||||
def stop_waterfall() -> Response:
|
def stop_waterfall() -> Response:
|
||||||
"""Stop the waterfall display."""
|
"""Stop the waterfall display."""
|
||||||
_stop_waterfall_internal()
|
global waterfall_running, waterfall_process, waterfall_active_device
|
||||||
|
|
||||||
|
waterfall_running = False
|
||||||
|
if waterfall_process and waterfall_process.poll() is None:
|
||||||
|
try:
|
||||||
|
waterfall_process.terminate()
|
||||||
|
waterfall_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
waterfall_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
waterfall_process = None
|
||||||
|
|
||||||
|
if waterfall_active_device is not None:
|
||||||
|
app_module.release_sdr_device(waterfall_active_device)
|
||||||
|
waterfall_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
@@ -1793,10 +1714,6 @@ def stream_waterfall() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('waterfall', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -1808,20 +1725,3 @@ def stream_waterfall() -> Response:
|
|||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
def _downsample_bins(values: list[float], target: int) -> list[float]:
|
|
||||||
"""Downsample bins to a target length using simple averaging."""
|
|
||||||
if target <= 0 or len(values) <= target:
|
|
||||||
return values
|
|
||||||
|
|
||||||
out: list[float] = []
|
|
||||||
step = len(values) / target
|
|
||||||
for i in range(target):
|
|
||||||
start = int(i * step)
|
|
||||||
end = int((i + 1) * step)
|
|
||||||
if end <= start:
|
|
||||||
end = min(start + 1, len(values))
|
|
||||||
chunk = values[start:end]
|
|
||||||
if not chunk:
|
|
||||||
continue
|
|
||||||
out.append(sum(chunk) / len(chunk))
|
|
||||||
return out
|
|
||||||
|
|||||||
+2
-85
@@ -2,14 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import pty
|
import pty
|
||||||
import queue
|
import queue
|
||||||
import select
|
import select
|
||||||
import struct
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -25,7 +23,6 @@ from utils.validation import (
|
|||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||||
from utils.dependencies import get_tool_path
|
from utils.dependencies import get_tool_path
|
||||||
@@ -108,62 +105,6 @@ def log_message(msg: dict[str, Any]) -> None:
|
|||||||
logger.error(f"Failed to log message: {e}")
|
logger.error(f"Failed to log message: {e}")
|
||||||
|
|
||||||
|
|
||||||
def audio_relay_thread(
|
|
||||||
rtl_stdout,
|
|
||||||
multimon_stdin,
|
|
||||||
output_queue: queue.Queue,
|
|
||||||
stop_event: threading.Event,
|
|
||||||
) -> None:
|
|
||||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
|
||||||
|
|
||||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
|
||||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
|
||||||
event onto *output_queue*.
|
|
||||||
"""
|
|
||||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
|
||||||
INTERVAL = 0.1 # seconds between scope updates
|
|
||||||
last_scope = time.monotonic()
|
|
||||||
|
|
||||||
try:
|
|
||||||
while not stop_event.is_set():
|
|
||||||
data = rtl_stdout.read(CHUNK)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Forward audio untouched
|
|
||||||
try:
|
|
||||||
multimon_stdin.write(data)
|
|
||||||
multimon_stdin.flush()
|
|
||||||
except (BrokenPipeError, OSError):
|
|
||||||
break
|
|
||||||
|
|
||||||
# Compute scope levels every ~100 ms
|
|
||||||
now = time.monotonic()
|
|
||||||
if now - last_scope >= INTERVAL:
|
|
||||||
last_scope = now
|
|
||||||
try:
|
|
||||||
n_samples = len(data) // 2
|
|
||||||
if n_samples == 0:
|
|
||||||
continue
|
|
||||||
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
|
||||||
peak = max(abs(s) for s in samples)
|
|
||||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
|
||||||
output_queue.put_nowait({
|
|
||||||
'type': 'scope',
|
|
||||||
'rms': rms,
|
|
||||||
'peak': peak,
|
|
||||||
})
|
|
||||||
except (struct.error, ValueError, queue.Full):
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Audio relay error: {e}")
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
multimon_stdin.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream decoder output to queue using PTY for unbuffered output."""
|
"""Stream decoder output to queue using PTY for unbuffered output."""
|
||||||
try:
|
try:
|
||||||
@@ -210,11 +151,6 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
|||||||
os.close(master_fd)
|
os.close(master_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
# Signal relay thread to stop
|
|
||||||
with app_module.process_lock:
|
|
||||||
stop_relay = getattr(app_module.current_process, '_stop_relay', None)
|
|
||||||
if stop_relay:
|
|
||||||
stop_relay.set()
|
|
||||||
# Cleanup companion rtl_fm process and decoder
|
# Cleanup companion rtl_fm process and decoder
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
|
rtl_proc = getattr(app_module.current_process, '_rtl_process', None)
|
||||||
@@ -382,7 +318,7 @@ def start_decoding() -> Response:
|
|||||||
|
|
||||||
multimon_process = subprocess.Popen(
|
multimon_process = subprocess.Popen(
|
||||||
multimon_cmd,
|
multimon_cmd,
|
||||||
stdin=subprocess.PIPE,
|
stdin=rtl_process.stdout,
|
||||||
stdout=slave_fd,
|
stdout=slave_fd,
|
||||||
stderr=slave_fd,
|
stderr=slave_fd,
|
||||||
close_fds=True
|
close_fds=True
|
||||||
@@ -390,22 +326,11 @@ def start_decoding() -> Response:
|
|||||||
register_process(multimon_process)
|
register_process(multimon_process)
|
||||||
|
|
||||||
os.close(slave_fd)
|
os.close(slave_fd)
|
||||||
|
rtl_process.stdout.close()
|
||||||
# Spawn audio relay thread between rtl_fm and multimon-ng
|
|
||||||
stop_relay = threading.Event()
|
|
||||||
relay = threading.Thread(
|
|
||||||
target=audio_relay_thread,
|
|
||||||
args=(rtl_process.stdout, multimon_process.stdin,
|
|
||||||
app_module.output_queue, stop_relay),
|
|
||||||
)
|
|
||||||
relay.daemon = True
|
|
||||||
relay.start()
|
|
||||||
|
|
||||||
app_module.current_process = multimon_process
|
app_module.current_process = multimon_process
|
||||||
app_module.current_process._rtl_process = rtl_process
|
app_module.current_process._rtl_process = rtl_process
|
||||||
app_module.current_process._master_fd = master_fd
|
app_module.current_process._master_fd = master_fd
|
||||||
app_module.current_process._stop_relay = stop_relay
|
|
||||||
app_module.current_process._relay_thread = relay
|
|
||||||
|
|
||||||
# Start output thread with PTY master fd
|
# Start output thread with PTY master fd
|
||||||
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
||||||
@@ -454,10 +379,6 @@ def stop_decoding() -> Response:
|
|||||||
|
|
||||||
with app_module.process_lock:
|
with app_module.process_lock:
|
||||||
if app_module.current_process:
|
if app_module.current_process:
|
||||||
# Signal audio relay thread to stop
|
|
||||||
if hasattr(app_module.current_process, '_stop_relay'):
|
|
||||||
app_module.current_process._stop_relay.set()
|
|
||||||
|
|
||||||
# Kill rtl_fm process first
|
# Kill rtl_fm process first
|
||||||
if hasattr(app_module.current_process, '_rtl_process'):
|
if hasattr(app_module.current_process, '_rtl_process'):
|
||||||
try:
|
try:
|
||||||
@@ -550,10 +471,6 @@ def stream() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.output_queue.get(timeout=1)
|
msg = app_module.output_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('pager', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
"""Session recording API endpoints."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, send_file
|
|
||||||
|
|
||||||
from utils.recording import get_recording_manager, RECORDING_ROOT
|
|
||||||
|
|
||||||
recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings')
|
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route('/start', methods=['POST'])
|
|
||||||
def start_recording():
|
|
||||||
data = request.get_json() or {}
|
|
||||||
mode = (data.get('mode') or '').strip()
|
|
||||||
if not mode:
|
|
||||||
return jsonify({'status': 'error', 'message': 'mode is required'}), 400
|
|
||||||
|
|
||||||
label = data.get('label')
|
|
||||||
metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {}
|
|
||||||
|
|
||||||
manager = get_recording_manager()
|
|
||||||
session = manager.start_recording(mode=mode, label=label, metadata=metadata)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'session': {
|
|
||||||
'id': session.id,
|
|
||||||
'mode': session.mode,
|
|
||||||
'label': session.label,
|
|
||||||
'started_at': session.started_at.isoformat(),
|
|
||||||
'file_path': str(session.file_path),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route('/stop', methods=['POST'])
|
|
||||||
def stop_recording():
|
|
||||||
data = request.get_json() or {}
|
|
||||||
mode = data.get('mode')
|
|
||||||
session_id = data.get('id')
|
|
||||||
|
|
||||||
manager = get_recording_manager()
|
|
||||||
session = manager.stop_recording(mode=mode, session_id=session_id)
|
|
||||||
if not session:
|
|
||||||
return jsonify({'status': 'error', 'message': 'No active recording found'}), 404
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'session': {
|
|
||||||
'id': session.id,
|
|
||||||
'mode': session.mode,
|
|
||||||
'label': session.label,
|
|
||||||
'started_at': session.started_at.isoformat(),
|
|
||||||
'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None,
|
|
||||||
'event_count': session.event_count,
|
|
||||||
'size_bytes': session.size_bytes,
|
|
||||||
'file_path': str(session.file_path),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route('', methods=['GET'])
|
|
||||||
def list_recordings():
|
|
||||||
manager = get_recording_manager()
|
|
||||||
limit = request.args.get('limit', default=50, type=int)
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'recordings': manager.list_recordings(limit=limit),
|
|
||||||
'active': manager.get_active(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route('/<session_id>', methods=['GET'])
|
|
||||||
def get_recording(session_id: str):
|
|
||||||
manager = get_recording_manager()
|
|
||||||
rec = manager.get_recording(session_id)
|
|
||||||
if not rec:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
|
||||||
return jsonify({'status': 'success', 'recording': rec})
|
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route('/<session_id>/download', methods=['GET'])
|
|
||||||
def download_recording(session_id: str):
|
|
||||||
manager = get_recording_manager()
|
|
||||||
rec = manager.get_recording(session_id)
|
|
||||||
if not rec:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
|
||||||
|
|
||||||
file_path = Path(rec['file_path'])
|
|
||||||
try:
|
|
||||||
resolved_root = RECORDING_ROOT.resolve()
|
|
||||||
resolved_file = file_path.resolve()
|
|
||||||
if resolved_root not in resolved_file.parents:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
|
||||||
except Exception:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
|
||||||
|
|
||||||
if not file_path.exists():
|
|
||||||
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
|
|
||||||
|
|
||||||
return send_file(
|
|
||||||
file_path,
|
|
||||||
mimetype='application/x-ndjson',
|
|
||||||
as_attachment=True,
|
|
||||||
download_name=file_path.name,
|
|
||||||
)
|
|
||||||
@@ -18,7 +18,6 @@ from utils.validation import (
|
|||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
|
|
||||||
rtlamr_bp = Blueprint('rtlamr', __name__)
|
rtlamr_bp = Blueprint('rtlamr', __name__)
|
||||||
@@ -296,10 +295,6 @@ def stream_rtlamr() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.rtlamr_queue.get(timeout=1)
|
msg = app_module.rtlamr_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('rtlamr', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from utils.validation import (
|
|||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
|
||||||
@@ -45,21 +44,6 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
data['type'] = 'sensor'
|
data['type'] = 'sensor'
|
||||||
app_module.sensor_queue.put(data)
|
app_module.sensor_queue.put(data)
|
||||||
|
|
||||||
# Push scope event when signal level data is present
|
|
||||||
rssi = data.get('rssi')
|
|
||||||
snr = data.get('snr')
|
|
||||||
noise = data.get('noise')
|
|
||||||
if rssi is not None or snr is not None:
|
|
||||||
try:
|
|
||||||
app_module.sensor_queue.put_nowait({
|
|
||||||
'type': 'scope',
|
|
||||||
'rssi': rssi if rssi is not None else 0,
|
|
||||||
'snr': snr if snr is not None else 0,
|
|
||||||
'noise': noise if noise is not None else 0,
|
|
||||||
})
|
|
||||||
except queue.Full:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
try:
|
try:
|
||||||
@@ -173,9 +157,6 @@ def start_sensor() -> Response:
|
|||||||
full_cmd = ' '.join(cmd)
|
full_cmd = ' '.join(cmd)
|
||||||
logger.info(f"Running: {full_cmd}")
|
logger.info(f"Running: {full_cmd}")
|
||||||
|
|
||||||
# Add signal level metadata so the frontend scope can display RSSI/SNR
|
|
||||||
cmd.extend(['-M', 'level'])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_module.sensor_process = subprocess.Popen(
|
app_module.sensor_process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -252,10 +233,6 @@ def stream_sensor() -> Response:
|
|||||||
try:
|
try:
|
||||||
msg = app_module.sensor_queue.get(timeout=1)
|
msg = app_module.sensor_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('sensor', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+6
-9
@@ -16,11 +16,12 @@ from flask import Blueprint, jsonify, request, Response, send_file
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
get_sstv_decoder,
|
get_sstv_decoder,
|
||||||
is_sstv_available,
|
is_sstv_available,
|
||||||
ISS_SSTV_FREQ,
|
ISS_SSTV_FREQ,
|
||||||
|
DecodeProgress,
|
||||||
|
DopplerInfo,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger('intercept.sstv')
|
logger = get_logger('intercept.sstv')
|
||||||
@@ -34,14 +35,14 @@ _sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
|||||||
sstv_active_device: int | None = None
|
sstv_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def _progress_callback(data: dict) -> None:
|
def _progress_callback(progress: DecodeProgress) -> None:
|
||||||
"""Callback to queue progress/scope updates for SSE stream."""
|
"""Callback to queue progress updates for SSE stream."""
|
||||||
try:
|
try:
|
||||||
_sstv_queue.put_nowait(data)
|
_sstv_queue.put_nowait(progress.to_dict())
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
try:
|
||||||
_sstv_queue.get_nowait()
|
_sstv_queue.get_nowait()
|
||||||
_sstv_queue.put_nowait(data)
|
_sstv_queue.put_nowait(progress.to_dict())
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -400,10 +401,6 @@ def stream_progress():
|
|||||||
try:
|
try:
|
||||||
progress = _sstv_queue.get(timeout=1)
|
progress = _sstv_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('sstv', progress, progress.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(progress)
|
yield format_sse(progress)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ from flask import Blueprint, Response, jsonify, request, send_file
|
|||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
|
DecodeProgress,
|
||||||
get_general_sstv_decoder,
|
get_general_sstv_decoder,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,14 +48,14 @@ SSTV_FREQUENCIES = [
|
|||||||
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
|
_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES}
|
||||||
|
|
||||||
|
|
||||||
def _progress_callback(data: dict) -> None:
|
def _progress_callback(progress: DecodeProgress) -> None:
|
||||||
"""Callback to queue progress/scope updates for SSE stream."""
|
"""Callback to queue progress updates for SSE stream."""
|
||||||
try:
|
try:
|
||||||
_sstv_general_queue.put_nowait(data)
|
_sstv_general_queue.put_nowait(progress.to_dict())
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
try:
|
try:
|
||||||
_sstv_general_queue.get_nowait()
|
_sstv_general_queue.get_nowait()
|
||||||
_sstv_general_queue.put_nowait(data)
|
_sstv_general_queue.put_nowait(progress.to_dict())
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -274,10 +274,6 @@ def stream_progress():
|
|||||||
try:
|
try:
|
||||||
progress = _sstv_general_queue.get(timeout=1)
|
progress = _sstv_general_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('sstv_general', progress, progress.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(progress)
|
yield format_sse(progress)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+1
-144
@@ -60,7 +60,6 @@ from utils.tscm.device_identity import (
|
|||||||
ingest_ble_dict,
|
ingest_ble_dict,
|
||||||
ingest_wifi_dict,
|
ingest_wifi_dict,
|
||||||
)
|
)
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
|
|
||||||
# Import unified Bluetooth scanner helper for TSCM integration
|
# Import unified Bluetooth scanner helper for TSCM integration
|
||||||
try:
|
try:
|
||||||
@@ -628,10 +627,6 @@ def sweep_stream():
|
|||||||
try:
|
try:
|
||||||
if tscm_queue:
|
if tscm_queue:
|
||||||
msg = tscm_queue.get(timeout=1)
|
msg = tscm_queue.get(timeout=1)
|
||||||
try:
|
|
||||||
process_event('tscm', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield f"data: {json.dumps(msg)}\n\n"
|
yield f"data: {json.dumps(msg)}\n\n"
|
||||||
else:
|
else:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
@@ -1077,32 +1072,6 @@ def _scan_wifi_networks(interface: str) -> list[dict]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _scan_wifi_clients(interface: str) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Get WiFi client observations from the unified WiFi scanner.
|
|
||||||
|
|
||||||
Clients are only available when monitor-mode scanning is active.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from utils.wifi import get_wifi_scanner
|
|
||||||
|
|
||||||
scanner = get_wifi_scanner()
|
|
||||||
if interface:
|
|
||||||
try:
|
|
||||||
if not scanner._is_monitor_mode_interface(interface):
|
|
||||||
return []
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
return [client.to_dict() for client in scanner.clients]
|
|
||||||
except ImportError as e:
|
|
||||||
logger.error(f"Failed to import wifi scanner: {e}")
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"WiFi client scan failed: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]:
|
def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Scan for Bluetooth devices with manufacturer data detection.
|
Scan for Bluetooth devices with manufacturer data detection.
|
||||||
@@ -1637,7 +1606,6 @@ def _run_sweep(
|
|||||||
threats_found = 0
|
threats_found = 0
|
||||||
severity_counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
|
severity_counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0}
|
||||||
all_wifi = {} # Use dict for deduplication by BSSID
|
all_wifi = {} # Use dict for deduplication by BSSID
|
||||||
all_wifi_clients = {} # Use dict for deduplication by client MAC
|
|
||||||
all_bt = {} # Use dict for deduplication by MAC
|
all_bt = {} # Use dict for deduplication by MAC
|
||||||
all_rf = []
|
all_rf = []
|
||||||
|
|
||||||
@@ -1734,7 +1702,6 @@ def _run_sweep(
|
|||||||
'channel': network.get('channel', ''),
|
'channel': network.get('channel', ''),
|
||||||
'signal': network.get('power', ''),
|
'signal': network.get('power', ''),
|
||||||
'security': network.get('privacy', ''),
|
'security': network.get('privacy', ''),
|
||||||
'vendor': network.get('vendor'),
|
|
||||||
'is_threat': is_threat,
|
'is_threat': is_threat,
|
||||||
'is_new': not classification.get('in_baseline', False),
|
'is_new': not classification.get('in_baseline', False),
|
||||||
'classification': profile.risk_level.value,
|
'classification': profile.risk_level.value,
|
||||||
@@ -1748,77 +1715,6 @@ def _run_sweep(
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"WiFi device processing error for {network.get('bssid', '?')}: {e}")
|
logger.error(f"WiFi device processing error for {network.get('bssid', '?')}: {e}")
|
||||||
|
|
||||||
# WiFi clients (monitor mode only)
|
|
||||||
try:
|
|
||||||
wifi_clients = _scan_wifi_clients(wifi_interface)
|
|
||||||
for client in wifi_clients:
|
|
||||||
mac = (client.get('mac') or '').upper()
|
|
||||||
if not mac or mac in all_wifi_clients:
|
|
||||||
continue
|
|
||||||
all_wifi_clients[mac] = client
|
|
||||||
|
|
||||||
rssi_val = client.get('rssi_current')
|
|
||||||
if rssi_val is None:
|
|
||||||
rssi_val = client.get('rssi_median') or client.get('rssi_ema')
|
|
||||||
|
|
||||||
client_device = {
|
|
||||||
'mac': mac,
|
|
||||||
'vendor': client.get('vendor'),
|
|
||||||
'name': client.get('vendor') or 'WiFi Client',
|
|
||||||
'rssi': rssi_val,
|
|
||||||
'associated_bssid': client.get('associated_bssid'),
|
|
||||||
'probed_ssids': client.get('probed_ssids', []),
|
|
||||||
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
|
|
||||||
'is_client': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
timeline_manager.add_observation(
|
|
||||||
identifier=mac,
|
|
||||||
protocol='wifi',
|
|
||||||
rssi=rssi_val,
|
|
||||||
name=client_device.get('vendor') or f'WiFi Client {mac[-5:]}',
|
|
||||||
attributes={'client': True, 'associated_bssid': client_device.get('associated_bssid')}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"WiFi client timeline observation error: {e}")
|
|
||||||
_maybe_store_timeline(
|
|
||||||
identifier=mac,
|
|
||||||
protocol='wifi',
|
|
||||||
rssi=rssi_val,
|
|
||||||
attributes={'client': True, 'associated_bssid': client_device.get('associated_bssid')}
|
|
||||||
)
|
|
||||||
|
|
||||||
profile = correlation.analyze_wifi_device(client_device)
|
|
||||||
client_device['classification'] = profile.risk_level.value
|
|
||||||
client_device['score'] = profile.total_score
|
|
||||||
client_device['score_modifier'] = profile.score_modifier
|
|
||||||
client_device['known_device'] = profile.known_device
|
|
||||||
client_device['known_device_name'] = profile.known_device_name
|
|
||||||
client_device['indicators'] = [
|
|
||||||
{'type': i.type.value, 'desc': i.description}
|
|
||||||
for i in profile.indicators
|
|
||||||
]
|
|
||||||
client_device['recommended_action'] = profile.recommended_action
|
|
||||||
|
|
||||||
# Feed to identity engine for MAC-randomization resistant clustering
|
|
||||||
try:
|
|
||||||
wifi_obs = {
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
'src_mac': mac,
|
|
||||||
'bssid': client_device.get('associated_bssid'),
|
|
||||||
'rssi': rssi_val,
|
|
||||||
'frame_type': 'probe_request',
|
|
||||||
'probed_ssids': client_device.get('probed_ssids', []),
|
|
||||||
}
|
|
||||||
ingest_wifi_dict(wifi_obs)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Identity engine WiFi client ingest error: {e}")
|
|
||||||
|
|
||||||
_emit_event('wifi_client', client_device)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"WiFi client scan error: {e}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_wifi_scan = current_time
|
last_wifi_scan = current_time
|
||||||
logger.error(f"WiFi scan error: {e}")
|
logger.error(f"WiFi scan error: {e}")
|
||||||
@@ -1897,9 +1793,6 @@ def _run_sweep(
|
|||||||
'name': device.get('name', 'Unknown'),
|
'name': device.get('name', 'Unknown'),
|
||||||
'device_type': device.get('type', ''),
|
'device_type': device.get('type', ''),
|
||||||
'rssi': device.get('rssi', ''),
|
'rssi': device.get('rssi', ''),
|
||||||
'manufacturer': device.get('manufacturer'),
|
|
||||||
'tracker': device.get('tracker'),
|
|
||||||
'tracker_type': device.get('tracker_type'),
|
|
||||||
'is_threat': is_threat,
|
'is_threat': is_threat,
|
||||||
'is_new': not classification.get('in_baseline', False),
|
'is_new': not classification.get('in_baseline', False),
|
||||||
'classification': profile.risk_level.value,
|
'classification': profile.risk_level.value,
|
||||||
@@ -2028,7 +1921,6 @@ def _run_sweep(
|
|||||||
comparator = BaselineComparator(baseline)
|
comparator = BaselineComparator(baseline)
|
||||||
baseline_comparison = comparator.compare_all(
|
baseline_comparison = comparator.compare_all(
|
||||||
wifi_devices=list(all_wifi.values()),
|
wifi_devices=list(all_wifi.values()),
|
||||||
wifi_clients=list(all_wifi_clients.values()),
|
|
||||||
bt_devices=list(all_bt.values()),
|
bt_devices=list(all_bt.values()),
|
||||||
rf_signals=all_rf
|
rf_signals=all_rf
|
||||||
)
|
)
|
||||||
@@ -2044,7 +1936,6 @@ def _run_sweep(
|
|||||||
|
|
||||||
if verbose_results:
|
if verbose_results:
|
||||||
wifi_payload = list(all_wifi.values())
|
wifi_payload = list(all_wifi.values())
|
||||||
wifi_client_payload = list(all_wifi_clients.values())
|
|
||||||
bt_payload = list(all_bt.values())
|
bt_payload = list(all_bt.values())
|
||||||
rf_payload = list(all_rf)
|
rf_payload = list(all_rf)
|
||||||
else:
|
else:
|
||||||
@@ -2060,28 +1951,6 @@ def _run_sweep(
|
|||||||
}
|
}
|
||||||
for d in all_wifi.values()
|
for d in all_wifi.values()
|
||||||
]
|
]
|
||||||
wifi_client_payload = []
|
|
||||||
for client in all_wifi_clients.values():
|
|
||||||
mac = client.get('mac') or client.get('address')
|
|
||||||
if isinstance(mac, str):
|
|
||||||
mac = mac.upper()
|
|
||||||
probed_ssids = client.get('probed_ssids') or []
|
|
||||||
rssi = client.get('rssi')
|
|
||||||
if rssi is None:
|
|
||||||
rssi = client.get('rssi_current')
|
|
||||||
if rssi is None:
|
|
||||||
rssi = client.get('rssi_median')
|
|
||||||
if rssi is None:
|
|
||||||
rssi = client.get('rssi_ema')
|
|
||||||
wifi_client_payload.append({
|
|
||||||
'mac': mac,
|
|
||||||
'vendor': client.get('vendor'),
|
|
||||||
'rssi': rssi,
|
|
||||||
'associated_bssid': client.get('associated_bssid'),
|
|
||||||
'is_associated': client.get('is_associated'),
|
|
||||||
'probed_ssids': probed_ssids,
|
|
||||||
'probe_count': client.get('probe_count', len(probed_ssids)),
|
|
||||||
})
|
|
||||||
bt_payload = [
|
bt_payload = [
|
||||||
{
|
{
|
||||||
'mac': d.get('mac') or d.get('address'),
|
'mac': d.get('mac') or d.get('address'),
|
||||||
@@ -2106,11 +1975,9 @@ def _run_sweep(
|
|||||||
status='completed',
|
status='completed',
|
||||||
results={
|
results={
|
||||||
'wifi_devices': wifi_payload,
|
'wifi_devices': wifi_payload,
|
||||||
'wifi_clients': wifi_client_payload,
|
|
||||||
'bt_devices': bt_payload,
|
'bt_devices': bt_payload,
|
||||||
'rf_signals': rf_payload,
|
'rf_signals': rf_payload,
|
||||||
'wifi_count': len(all_wifi),
|
'wifi_count': len(all_wifi),
|
||||||
'wifi_client_count': len(all_wifi_clients),
|
|
||||||
'bt_count': len(all_bt),
|
'bt_count': len(all_bt),
|
||||||
'rf_count': len(all_rf),
|
'rf_count': len(all_rf),
|
||||||
'severity_counts': severity_counts,
|
'severity_counts': severity_counts,
|
||||||
@@ -2138,7 +2005,6 @@ def _run_sweep(
|
|||||||
'total_new': baseline_comparison['total_new'],
|
'total_new': baseline_comparison['total_new'],
|
||||||
'total_missing': baseline_comparison['total_missing'],
|
'total_missing': baseline_comparison['total_missing'],
|
||||||
'wifi': baseline_comparison.get('wifi'),
|
'wifi': baseline_comparison.get('wifi'),
|
||||||
'wifi_clients': baseline_comparison.get('wifi_clients'),
|
|
||||||
'bluetooth': baseline_comparison.get('bluetooth'),
|
'bluetooth': baseline_comparison.get('bluetooth'),
|
||||||
'rf': baseline_comparison.get('rf'),
|
'rf': baseline_comparison.get('rf'),
|
||||||
})
|
})
|
||||||
@@ -2156,7 +2022,6 @@ def _run_sweep(
|
|||||||
'sweep_id': _current_sweep_id,
|
'sweep_id': _current_sweep_id,
|
||||||
'threats_found': threats_found,
|
'threats_found': threats_found,
|
||||||
'wifi_count': len(all_wifi),
|
'wifi_count': len(all_wifi),
|
||||||
'wifi_client_count': len(all_wifi_clients),
|
|
||||||
'bt_count': len(all_bt),
|
'bt_count': len(all_bt),
|
||||||
'rf_count': len(all_rf),
|
'rf_count': len(all_rf),
|
||||||
'severity_counts': severity_counts,
|
'severity_counts': severity_counts,
|
||||||
@@ -2304,7 +2169,6 @@ def compare_against_baseline():
|
|||||||
|
|
||||||
Expects JSON body with:
|
Expects JSON body with:
|
||||||
- wifi_devices: list of WiFi devices (optional)
|
- wifi_devices: list of WiFi devices (optional)
|
||||||
- wifi_clients: list of WiFi clients (optional)
|
|
||||||
- bt_devices: list of Bluetooth devices (optional)
|
- bt_devices: list of Bluetooth devices (optional)
|
||||||
- rf_signals: list of RF signals (optional)
|
- rf_signals: list of RF signals (optional)
|
||||||
|
|
||||||
@@ -2313,14 +2177,12 @@ def compare_against_baseline():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
|
||||||
wifi_devices = data.get('wifi_devices')
|
wifi_devices = data.get('wifi_devices')
|
||||||
wifi_clients = data.get('wifi_clients')
|
|
||||||
bt_devices = data.get('bt_devices')
|
bt_devices = data.get('bt_devices')
|
||||||
rf_signals = data.get('rf_signals')
|
rf_signals = data.get('rf_signals')
|
||||||
|
|
||||||
# Use the convenience function that gets active baseline
|
# Use the convenience function that gets active baseline
|
||||||
comparison = get_comparison_for_active_baseline(
|
comparison = get_comparison_for_active_baseline(
|
||||||
wifi_devices=wifi_devices,
|
wifi_devices=wifi_devices,
|
||||||
wifi_clients=wifi_clients,
|
|
||||||
bt_devices=bt_devices,
|
bt_devices=bt_devices,
|
||||||
rf_signals=rf_signals
|
rf_signals=rf_signals
|
||||||
)
|
)
|
||||||
@@ -2414,10 +2276,7 @@ def feed_wifi():
|
|||||||
"""Feed WiFi device data for baseline recording."""
|
"""Feed WiFi device data for baseline recording."""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if data:
|
if data:
|
||||||
if data.get('is_client'):
|
_baseline_recorder.add_wifi_device(data)
|
||||||
_baseline_recorder.add_wifi_client(data)
|
|
||||||
else:
|
|
||||||
_baseline_recorder.add_wifi_device(data)
|
|
||||||
return jsonify({'status': 'success'})
|
return jsonify({'status': 'success'})
|
||||||
|
|
||||||
|
|
||||||
@@ -3069,14 +2928,12 @@ def get_baseline_diff(baseline_id: int, sweep_id: int):
|
|||||||
results = json.loads(results)
|
results = json.loads(results)
|
||||||
|
|
||||||
current_wifi = results.get('wifi_devices', [])
|
current_wifi = results.get('wifi_devices', [])
|
||||||
current_wifi_clients = results.get('wifi_clients', [])
|
|
||||||
current_bt = results.get('bt_devices', [])
|
current_bt = results.get('bt_devices', [])
|
||||||
current_rf = results.get('rf_signals', [])
|
current_rf = results.get('rf_signals', [])
|
||||||
|
|
||||||
diff = calculate_baseline_diff(
|
diff = calculate_baseline_diff(
|
||||||
baseline=baseline,
|
baseline=baseline,
|
||||||
current_wifi=current_wifi,
|
current_wifi=current_wifi,
|
||||||
current_wifi_clients=current_wifi_clients,
|
|
||||||
current_bt=current_bt,
|
current_bt=current_bt,
|
||||||
current_rf=current_rf,
|
current_rf=current_rf,
|
||||||
sweep_id=sweep_id
|
sweep_id=sweep_id
|
||||||
|
|||||||
@@ -1,326 +0,0 @@
|
|||||||
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
from flask import Flask
|
|
||||||
|
|
||||||
try:
|
|
||||||
from flask_sock import Sock
|
|
||||||
WEBSOCKET_AVAILABLE = True
|
|
||||||
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
|
|
||||||
|
|
||||||
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."""
|
|
||||||
mapping = {
|
|
||||||
'rtlsdr': SDRType.RTL_SDR,
|
|
||||||
'rtl_sdr': SDRType.RTL_SDR,
|
|
||||||
'hackrf': SDRType.HACKRF,
|
|
||||||
'limesdr': SDRType.LIME_SDR,
|
|
||||||
'lime_sdr': SDRType.LIME_SDR,
|
|
||||||
'airspy': SDRType.AIRSPY,
|
|
||||||
'sdrplay': SDRType.SDRPLAY,
|
|
||||||
}
|
|
||||||
return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice:
|
|
||||||
"""Build a minimal SDRDevice for command building."""
|
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
|
||||||
caps = builder.get_capabilities()
|
|
||||||
return SDRDevice(
|
|
||||||
sdr_type=sdr_type,
|
|
||||||
index=device_index,
|
|
||||||
name=f'{sdr_type.value}-{device_index}',
|
|
||||||
serial='N/A',
|
|
||||||
driver=sdr_type.value,
|
|
||||||
capabilities=caps,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def init_waterfall_websocket(app: Flask):
|
|
||||||
"""Initialize WebSocket waterfall streaming."""
|
|
||||||
if not WEBSOCKET_AVAILABLE:
|
|
||||||
logger.warning("flask-sock not installed, WebSocket waterfall disabled")
|
|
||||||
return
|
|
||||||
|
|
||||||
sock = Sock(app)
|
|
||||||
|
|
||||||
@sock.route('/ws/waterfall')
|
|
||||||
def waterfall_stream(ws):
|
|
||||||
"""WebSocket endpoint for real-time waterfall streaming."""
|
|
||||||
logger.info("WebSocket waterfall client connected")
|
|
||||||
|
|
||||||
# Import app module for device claiming
|
|
||||||
import app as app_module
|
|
||||||
|
|
||||||
iq_process = None
|
|
||||||
reader_thread = None
|
|
||||||
stop_event = threading.Event()
|
|
||||||
claimed_device = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
msg = ws.receive(timeout=0.1)
|
|
||||||
except TimeoutError:
|
|
||||||
if stop_event.is_set():
|
|
||||||
break
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
if "closed" in str(e).lower():
|
|
||||||
break
|
|
||||||
if "timed out" not in str(e).lower():
|
|
||||||
logger.error(f"WebSocket receive error: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if msg is None:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(msg)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
cmd = data.get('cmd')
|
|
||||||
|
|
||||||
if cmd == 'start':
|
|
||||||
# Stop any existing capture
|
|
||||||
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
|
|
||||||
stop_event.clear()
|
|
||||||
|
|
||||||
# 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
|
|
||||||
try:
|
|
||||||
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.2)
|
|
||||||
if iq_process.poll() is not None:
|
|
||||||
raise RuntimeError("I/Q capture process exited immediately")
|
|
||||||
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
|
|
||||||
def fft_reader(
|
|
||||||
proc, ws_ref, stop_evt,
|
|
||||||
_fft_size, _avg_count, _fps,
|
|
||||||
_start_freq, _end_freq,
|
|
||||||
):
|
|
||||||
"""Read I/Q from subprocess, compute FFT, send 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:
|
|
||||||
ws_ref.send(frame)
|
|
||||||
except Exception:
|
|
||||||
break
|
|
||||||
|
|
||||||
# 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, ws, 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'}))
|
|
||||||
|
|
||||||
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)
|
|
||||||
if iq_process:
|
|
||||||
safe_terminate(iq_process)
|
|
||||||
unregister_process(iq_process)
|
|
||||||
if claimed_device is not None:
|
|
||||||
app_module.release_sdr_device(claimed_device)
|
|
||||||
logger.info("WebSocket waterfall client disconnected")
|
|
||||||
+5
-66
@@ -21,7 +21,6 @@ from utils.logging import wifi_logger as logger
|
|||||||
from utils.process import is_valid_mac, is_valid_channel
|
from utils.process import is_valid_mac, is_valid_channel
|
||||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
from data.oui import get_manufacturer
|
from data.oui import get_manufacturer
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
WIFI_TERMINATE_TIMEOUT,
|
WIFI_TERMINATE_TIMEOUT,
|
||||||
@@ -51,31 +50,6 @@ pmkid_process = None
|
|||||||
pmkid_lock = threading.Lock()
|
pmkid_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _parse_channel_list(raw_channels: Any) -> list[int] | None:
|
|
||||||
"""Parse a channel list from string/list input."""
|
|
||||||
if raw_channels in (None, '', []):
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(raw_channels, str):
|
|
||||||
parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()]
|
|
||||||
elif isinstance(raw_channels, (list, tuple, set)):
|
|
||||||
parts = list(raw_channels)
|
|
||||||
else:
|
|
||||||
parts = [raw_channels]
|
|
||||||
|
|
||||||
channels: list[int] = []
|
|
||||||
seen = set()
|
|
||||||
for part in parts:
|
|
||||||
if part in (None, ''):
|
|
||||||
continue
|
|
||||||
ch = validate_wifi_channel(part)
|
|
||||||
if ch not in seen:
|
|
||||||
channels.append(ch)
|
|
||||||
seen.add(ch)
|
|
||||||
|
|
||||||
return channels or None
|
|
||||||
|
|
||||||
|
|
||||||
def detect_wifi_interfaces():
|
def detect_wifi_interfaces():
|
||||||
"""Detect available WiFi interfaces."""
|
"""Detect available WiFi interfaces."""
|
||||||
interfaces = []
|
interfaces = []
|
||||||
@@ -634,7 +608,6 @@ def start_wifi_scan():
|
|||||||
|
|
||||||
data = request.json
|
data = request.json
|
||||||
channel = data.get('channel')
|
channel = data.get('channel')
|
||||||
channels = data.get('channels')
|
|
||||||
band = data.get('band', 'abg')
|
band = data.get('band', 'abg')
|
||||||
|
|
||||||
# Use provided interface or fall back to stored monitor interface
|
# Use provided interface or fall back to stored monitor interface
|
||||||
@@ -685,16 +658,7 @@ def start_wifi_scan():
|
|||||||
interface
|
interface
|
||||||
]
|
]
|
||||||
|
|
||||||
channel_list = None
|
if channel:
|
||||||
if channels:
|
|
||||||
try:
|
|
||||||
channel_list = _parse_channel_list(channels)
|
|
||||||
except ValueError as e:
|
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
|
||||||
|
|
||||||
if channel_list:
|
|
||||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
|
||||||
elif channel:
|
|
||||||
cmd.extend(['-c', str(channel)])
|
cmd.extend(['-c', str(channel)])
|
||||||
|
|
||||||
logger.info(f"Running: {' '.join(cmd)}")
|
logger.info(f"Running: {' '.join(cmd)}")
|
||||||
@@ -888,9 +852,6 @@ def check_handshake_status():
|
|||||||
|
|
||||||
file_size = os.path.getsize(capture_file)
|
file_size = os.path.getsize(capture_file)
|
||||||
handshake_found = False
|
handshake_found = False
|
||||||
handshake_valid: bool | None = None
|
|
||||||
handshake_checked = False
|
|
||||||
handshake_reason: str | None = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if target_bssid and is_valid_mac(target_bssid):
|
if target_bssid and is_valid_mac(target_bssid):
|
||||||
@@ -901,38 +862,20 @@ def check_handshake_status():
|
|||||||
capture_output=True, text=True, timeout=10
|
capture_output=True, text=True, timeout=10
|
||||||
)
|
)
|
||||||
output = result.stdout + result.stderr
|
output = result.stdout + result.stderr
|
||||||
output_lower = output.lower()
|
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
||||||
handshake_checked = True
|
if '0 handshake' not in output:
|
||||||
|
handshake_found = True
|
||||||
if 'no valid wpa handshakes found' in output_lower:
|
|
||||||
handshake_valid = False
|
|
||||||
handshake_reason = 'No valid WPA handshake found'
|
|
||||||
elif '0 handshake' in output_lower:
|
|
||||||
handshake_valid = False
|
|
||||||
elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower):
|
|
||||||
handshake_valid = True
|
|
||||||
else:
|
|
||||||
handshake_valid = False
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking handshake: {e}")
|
logger.error(f"Error checking handshake: {e}")
|
||||||
|
|
||||||
if handshake_valid:
|
|
||||||
handshake_found = True
|
|
||||||
normalized_bssid = target_bssid.upper() if target_bssid else None
|
|
||||||
if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes:
|
|
||||||
app_module.wifi_handshakes.append(normalized_bssid)
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||||
'file_exists': True,
|
'file_exists': True,
|
||||||
'file_size': file_size,
|
'file_size': file_size,
|
||||||
'file': capture_file,
|
'file': capture_file,
|
||||||
'handshake_found': handshake_found,
|
'handshake_found': handshake_found
|
||||||
'handshake_valid': handshake_valid,
|
|
||||||
'handshake_checked': handshake_checked,
|
|
||||||
'handshake_reason': handshake_reason
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1143,10 +1086,6 @@ def stream_wifi():
|
|||||||
try:
|
try:
|
||||||
msg = app_module.wifi_queue.get(timeout=1)
|
msg = app_module.wifi_queue.get(timeout=1)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
|
||||||
process_event('wifi', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
+1
-23
@@ -24,8 +24,6 @@ from utils.wifi import (
|
|||||||
SCAN_MODE_DEEP,
|
SCAN_MODE_DEEP,
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.validation import validate_wifi_channel
|
|
||||||
from utils.event_pipeline import process_event
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -91,30 +89,15 @@ def start_deep_scan():
|
|||||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||||
band: Band to scan ('2.4', '5', 'all')
|
band: Band to scan ('2.4', '5', 'all')
|
||||||
channel: Optional specific channel to monitor
|
channel: Optional specific channel to monitor
|
||||||
channels: Optional list or comma-separated channels to monitor
|
|
||||||
"""
|
"""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
interface = data.get('interface')
|
interface = data.get('interface')
|
||||||
band = data.get('band', 'all')
|
band = data.get('band', 'all')
|
||||||
channel = data.get('channel')
|
channel = data.get('channel')
|
||||||
channels = data.get('channels')
|
|
||||||
|
|
||||||
channel_list = None
|
|
||||||
if channels:
|
|
||||||
if isinstance(channels, str):
|
|
||||||
channel_list = [c.strip() for c in channels.split(',') if c.strip()]
|
|
||||||
elif isinstance(channels, (list, tuple, set)):
|
|
||||||
channel_list = list(channels)
|
|
||||||
else:
|
|
||||||
channel_list = [channels]
|
|
||||||
try:
|
|
||||||
channel_list = [validate_wifi_channel(c) for c in channel_list]
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return jsonify({'error': 'Invalid channels'}), 400
|
|
||||||
|
|
||||||
if channel:
|
if channel:
|
||||||
try:
|
try:
|
||||||
channel = validate_wifi_channel(channel)
|
channel = int(channel)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({'error': 'Invalid channel'}), 400
|
return jsonify({'error': 'Invalid channel'}), 400
|
||||||
|
|
||||||
@@ -123,7 +106,6 @@ def start_deep_scan():
|
|||||||
interface=interface,
|
interface=interface,
|
||||||
band=band,
|
band=band,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
channels=channel_list,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
@@ -409,10 +391,6 @@ def event_stream():
|
|||||||
scanner = get_wifi_scanner()
|
scanner = get_wifi_scanner()
|
||||||
|
|
||||||
for event in scanner.get_event_stream():
|
for event in scanner.get_event_stream():
|
||||||
try:
|
|
||||||
process_event('wifi', event, event.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(event)
|
yield format_sse(event)
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
|||||||
@@ -4201,12 +4201,6 @@ header h1 .tagline {
|
|||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-detail-btn.active {
|
|
||||||
background: rgba(34, 197, 94, 0.2);
|
|
||||||
border-color: rgba(34, 197, 94, 0.6);
|
|
||||||
color: #9fffd1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selected device highlight */
|
/* Selected device highlight */
|
||||||
.bt-device-row.selected {
|
.bt-device-row.selected {
|
||||||
background: rgba(0, 212, 255, 0.1);
|
background: rgba(0, 212, 255, 0.1);
|
||||||
@@ -4398,17 +4392,6 @@ header h1 .tagline {
|
|||||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-history-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 1px 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
background: rgba(34, 197, 94, 0.15);
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bt-device-name {
|
.bt-device-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -196,28 +196,6 @@
|
|||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.tracker-badge {
|
|
||||||
margin-left: 6px;
|
|
||||||
font-size: 9px;
|
|
||||||
padding: 1px 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: rgba(255, 51, 102, 0.2);
|
|
||||||
color: #ff3366;
|
|
||||||
border: 1px solid rgba(255, 51, 102, 0.4);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
}
|
|
||||||
.client-badge {
|
|
||||||
margin-left: 6px;
|
|
||||||
font-size: 9px;
|
|
||||||
padding: 1px 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: rgba(74, 158, 255, 0.2);
|
|
||||||
color: #4a9eff;
|
|
||||||
border: 1px solid rgba(74, 158, 255, 0.4);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
}
|
|
||||||
.known-badge {
|
.known-badge {
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
|
|||||||
@@ -163,47 +163,6 @@
|
|||||||
color: var(--text-muted, #666);
|
color: var(--text-muted, #666);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings Feed Lists */
|
|
||||||
.settings-feed {
|
|
||||||
background: var(--bg-tertiary, #12121f);
|
|
||||||
border: 1px solid var(--border-color, #1a1a2e);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px;
|
|
||||||
max-height: 240px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-feed-item {
|
|
||||||
padding: 8px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-feed-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-feed-title {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #e0e0e0);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-feed-meta {
|
|
||||||
color: var(--text-muted, #666);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-feed-empty {
|
|
||||||
color: var(--text-dim, #666);
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toggle Switch */
|
/* Toggle Switch */
|
||||||
.toggle-switch {
|
.toggle-switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
const AlertCenter = (function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
let alerts = [];
|
|
||||||
let rules = [];
|
|
||||||
let eventSource = null;
|
|
||||||
|
|
||||||
const TRACKER_RULE_NAME = 'Tracker Detected';
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
loadRules();
|
|
||||||
loadFeed();
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
if (eventSource) {
|
|
||||||
eventSource.close();
|
|
||||||
}
|
|
||||||
eventSource = new EventSource('/alerts/stream');
|
|
||||||
eventSource.onmessage = function(e) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.data);
|
|
||||||
if (data.type === 'keepalive') return;
|
|
||||||
handleAlert(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Alerts] SSE parse error', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
eventSource.onerror = function() {
|
|
||||||
console.warn('[Alerts] SSE connection error');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAlert(alert) {
|
|
||||||
alerts.unshift(alert);
|
|
||||||
alerts = alerts.slice(0, 50);
|
|
||||||
updateFeedUI();
|
|
||||||
|
|
||||||
if (typeof showNotification === 'function') {
|
|
||||||
const severity = (alert.severity || '').toLowerCase();
|
|
||||||
if (['high', 'critical'].includes(severity)) {
|
|
||||||
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFeedUI() {
|
|
||||||
const list = document.getElementById('alertsFeedList');
|
|
||||||
const countEl = document.getElementById('alertsFeedCount');
|
|
||||||
if (countEl) countEl.textContent = `(${alerts.length})`;
|
|
||||||
if (!list) return;
|
|
||||||
|
|
||||||
if (alerts.length === 0) {
|
|
||||||
list.innerHTML = '<div class="settings-feed-empty">No alerts yet</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
list.innerHTML = alerts.map(alert => {
|
|
||||||
const title = escapeHtml(alert.title || 'Alert');
|
|
||||||
const message = escapeHtml(alert.message || '');
|
|
||||||
const severity = escapeHtml(alert.severity || 'medium');
|
|
||||||
const createdAt = alert.created_at ? new Date(alert.created_at).toLocaleString() : '';
|
|
||||||
return `
|
|
||||||
<div class="settings-feed-item">
|
|
||||||
<div class="settings-feed-title">
|
|
||||||
<span>${title}</span>
|
|
||||||
<span style="color: var(--text-dim);">${severity.toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="settings-feed-meta">${message}</div>
|
|
||||||
<div class="settings-feed-meta" style="margin-top: 4px;">${createdAt}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadFeed() {
|
|
||||||
fetch('/alerts/events?limit=20')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'success') {
|
|
||||||
alerts = data.events || [];
|
|
||||||
updateFeedUI();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => console.error('[Alerts] Load feed failed', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadRules() {
|
|
||||||
fetch('/alerts/rules?all=1')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'success') {
|
|
||||||
rules = data.rules || [];
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => console.error('[Alerts] Load rules failed', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
function enableTrackerAlerts() {
|
|
||||||
ensureTrackerRule(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableTrackerAlerts() {
|
|
||||||
ensureTrackerRule(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureTrackerRule(enabled) {
|
|
||||||
loadRules();
|
|
||||||
setTimeout(() => {
|
|
||||||
const existing = rules.find(r => r.name === TRACKER_RULE_NAME);
|
|
||||||
if (existing) {
|
|
||||||
fetch(`/alerts/rules/${existing.id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled })
|
|
||||||
}).then(() => loadRules());
|
|
||||||
} else if (enabled) {
|
|
||||||
fetch('/alerts/rules', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: TRACKER_RULE_NAME,
|
|
||||||
mode: 'bluetooth',
|
|
||||||
event_type: 'device_update',
|
|
||||||
match: { is_tracker: true },
|
|
||||||
severity: 'high',
|
|
||||||
enabled: true,
|
|
||||||
notify: { webhook: true }
|
|
||||||
})
|
|
||||||
}).then(() => loadRules());
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addBluetoothWatchlist(address, name) {
|
|
||||||
if (!address) return;
|
|
||||||
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
|
||||||
if (existing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetch('/alerts/rules', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: name ? `Watchlist ${name}` : `Watchlist ${address}`,
|
|
||||||
mode: 'bluetooth',
|
|
||||||
event_type: 'device_update',
|
|
||||||
match: { address: address },
|
|
||||||
severity: 'medium',
|
|
||||||
enabled: true,
|
|
||||||
notify: { webhook: true }
|
|
||||||
})
|
|
||||||
}).then(() => loadRules());
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeBluetoothWatchlist(address) {
|
|
||||||
if (!address) return;
|
|
||||||
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
|
||||||
if (!existing) return;
|
|
||||||
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
|
||||||
.then(() => loadRules());
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWatchlisted(address) {
|
|
||||||
return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
init,
|
|
||||||
loadFeed,
|
|
||||||
enableTrackerAlerts,
|
|
||||||
disableTrackerAlerts,
|
|
||||||
addBluetoothWatchlist,
|
|
||||||
removeBluetoothWatchlist,
|
|
||||||
isWatchlisted,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
if (typeof AlertCenter !== 'undefined') {
|
|
||||||
AlertCenter.init();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
const RecordingUI = (function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
let recordings = [];
|
|
||||||
let active = [];
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
fetch('/recordings')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.status !== 'success') return;
|
|
||||||
recordings = data.recordings || [];
|
|
||||||
active = data.active || [];
|
|
||||||
renderActive();
|
|
||||||
renderRecordings();
|
|
||||||
})
|
|
||||||
.catch(err => console.error('[Recording] Load failed', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
|
||||||
const modeSelect = document.getElementById('recordingModeSelect');
|
|
||||||
const labelInput = document.getElementById('recordingLabelInput');
|
|
||||||
const mode = modeSelect ? modeSelect.value : '';
|
|
||||||
const label = labelInput ? labelInput.value : '';
|
|
||||||
if (!mode) return;
|
|
||||||
|
|
||||||
fetch('/recordings/start', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ mode, label })
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(() => {
|
|
||||||
refresh();
|
|
||||||
})
|
|
||||||
.catch(err => console.error('[Recording] Start failed', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop() {
|
|
||||||
const modeSelect = document.getElementById('recordingModeSelect');
|
|
||||||
const mode = modeSelect ? modeSelect.value : '';
|
|
||||||
if (!mode) return;
|
|
||||||
|
|
||||||
fetch('/recordings/stop', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ mode })
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(() => refresh())
|
|
||||||
.catch(err => console.error('[Recording] Stop failed', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopById(sessionId) {
|
|
||||||
fetch('/recordings/stop', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ id: sessionId })
|
|
||||||
}).then(() => refresh());
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderActive() {
|
|
||||||
const container = document.getElementById('recordingActiveList');
|
|
||||||
if (!container) return;
|
|
||||||
if (!active.length) {
|
|
||||||
container.innerHTML = '<div class="settings-feed-empty">No active recordings</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = active.map(session => {
|
|
||||||
return `
|
|
||||||
<div class="settings-feed-item">
|
|
||||||
<div class="settings-feed-title">
|
|
||||||
<span>${escapeHtml(session.mode)}</span>
|
|
||||||
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.stopById('${session.id}')">Stop</button>
|
|
||||||
</div>
|
|
||||||
<div class="settings-feed-meta">Started: ${new Date(session.started_at).toLocaleString()}</div>
|
|
||||||
<div class="settings-feed-meta">Events: ${session.event_count || 0}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRecordings() {
|
|
||||||
const container = document.getElementById('recordingList');
|
|
||||||
if (!container) return;
|
|
||||||
if (!recordings.length) {
|
|
||||||
container.innerHTML = '<div class="settings-feed-empty">No recordings yet</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = recordings.map(rec => {
|
|
||||||
return `
|
|
||||||
<div class="settings-feed-item">
|
|
||||||
<div class="settings-feed-title">
|
|
||||||
<span>${escapeHtml(rec.mode)}${rec.label ? ` • ${escapeHtml(rec.label)}` : ''}</span>
|
|
||||||
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
|
|
||||||
</div>
|
|
||||||
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? ` → ${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
|
|
||||||
<div class="settings-feed-meta">Events: ${rec.event_count || 0} • ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function download(sessionId) {
|
|
||||||
window.open(`/recordings/${sessionId}/download`, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
init,
|
|
||||||
refresh,
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
stopById,
|
|
||||||
download,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
if (typeof RecordingUI !== 'undefined') {
|
|
||||||
RecordingUI.init();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -922,13 +922,5 @@ function switchSettingsTab(tabName) {
|
|||||||
loadUpdateStatus();
|
loadUpdateStatus();
|
||||||
} else if (tabName === 'location') {
|
} else if (tabName === 'location') {
|
||||||
loadObserverLocation();
|
loadObserverLocation();
|
||||||
} else if (tabName === 'alerts') {
|
|
||||||
if (typeof AlertCenter !== 'undefined') {
|
|
||||||
AlertCenter.loadFeed();
|
|
||||||
}
|
|
||||||
} else if (tabName === 'recording') {
|
|
||||||
if (typeof RecordingUI !== 'undefined') {
|
|
||||||
RecordingUI.refresh();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,9 +367,6 @@ const BluetoothMode = (function() {
|
|||||||
const badgesEl = document.getElementById('btDetailBadges');
|
const badgesEl = document.getElementById('btDetailBadges');
|
||||||
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
|
let badgesHtml = `<span class="bt-detail-badge ${protocol}">${protocol.toUpperCase()}</span>`;
|
||||||
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
|
badgesHtml += `<span class="bt-detail-badge ${device.in_baseline ? 'baseline' : 'new'}">${device.in_baseline ? '✓ KNOWN' : '● NEW'}</span>`;
|
||||||
if (device.seen_before) {
|
|
||||||
badgesHtml += `<span class="bt-detail-badge flag">SEEN BEFORE</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tracker badge
|
// Tracker badge
|
||||||
if (device.is_tracker) {
|
if (device.is_tracker) {
|
||||||
@@ -458,8 +455,6 @@ const BluetoothMode = (function() {
|
|||||||
? new Date(device.last_seen).toLocaleTimeString()
|
? new Date(device.last_seen).toLocaleTimeString()
|
||||||
: '--';
|
: '--';
|
||||||
|
|
||||||
updateWatchlistButton(device);
|
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
const servicesContainer = document.getElementById('btDetailServices');
|
const servicesContainer = document.getElementById('btDetailServices');
|
||||||
const servicesList = document.getElementById('btDetailServicesList');
|
const servicesList = document.getElementById('btDetailServicesList');
|
||||||
@@ -478,22 +473,6 @@ const BluetoothMode = (function() {
|
|||||||
highlightSelectedDevice(deviceId);
|
highlightSelectedDevice(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update watchlist button state
|
|
||||||
*/
|
|
||||||
function updateWatchlistButton(device) {
|
|
||||||
const btn = document.getElementById('btDetailWatchBtn');
|
|
||||||
if (!btn) return;
|
|
||||||
if (typeof AlertCenter === 'undefined') {
|
|
||||||
btn.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
btn.style.display = '';
|
|
||||||
const watchlisted = AlertCenter.isWatchlisted(device.address);
|
|
||||||
btn.textContent = watchlisted ? 'Watching' : 'Watchlist';
|
|
||||||
btn.classList.toggle('active', watchlisted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear device selection
|
* Clear device selection
|
||||||
*/
|
*/
|
||||||
@@ -552,7 +531,7 @@ const BluetoothMode = (function() {
|
|||||||
if (!device) return;
|
if (!device) return;
|
||||||
|
|
||||||
navigator.clipboard.writeText(device.address).then(() => {
|
navigator.clipboard.writeText(device.address).then(() => {
|
||||||
const btn = document.getElementById('btDetailCopyBtn');
|
const btn = document.querySelector('.bt-detail-btn');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
const originalText = btn.textContent;
|
const originalText = btn.textContent;
|
||||||
btn.textContent = 'Copied!';
|
btn.textContent = 'Copied!';
|
||||||
@@ -565,25 +544,6 @@ const BluetoothMode = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle Bluetooth watchlist for selected device
|
|
||||||
*/
|
|
||||||
function toggleWatchlist() {
|
|
||||||
if (!selectedDeviceId) return;
|
|
||||||
const device = devices.get(selectedDeviceId);
|
|
||||||
if (!device || typeof AlertCenter === 'undefined') return;
|
|
||||||
|
|
||||||
if (AlertCenter.isWatchlisted(device.address)) {
|
|
||||||
AlertCenter.removeBluetoothWatchlist(device.address);
|
|
||||||
showInfo('Removed from watchlist');
|
|
||||||
} else {
|
|
||||||
AlertCenter.addBluetoothWatchlist(device.address, device.name || device.address);
|
|
||||||
showInfo('Added to watchlist');
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => updateWatchlistButton(device), 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a device - opens modal with details
|
* Select a device - opens modal with details
|
||||||
*/
|
*/
|
||||||
@@ -1134,7 +1094,6 @@ const BluetoothMode = (function() {
|
|||||||
const trackerConfidence = device.tracker_confidence;
|
const trackerConfidence = device.tracker_confidence;
|
||||||
const riskScore = device.risk_score || 0;
|
const riskScore = device.risk_score || 0;
|
||||||
const agentName = device._agent || 'Local';
|
const agentName = device._agent || 'Local';
|
||||||
const seenBefore = device.seen_before === true;
|
|
||||||
|
|
||||||
// Calculate RSSI bar width (0-100%)
|
// Calculate RSSI bar width (0-100%)
|
||||||
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
||||||
@@ -1188,7 +1147,6 @@ const BluetoothMode = (function() {
|
|||||||
let secondaryParts = [addr];
|
let secondaryParts = [addr];
|
||||||
if (mfr) secondaryParts.push(mfr);
|
if (mfr) secondaryParts.push(mfr);
|
||||||
secondaryParts.push('Seen ' + seenCount + '×');
|
secondaryParts.push('Seen ' + seenCount + '×');
|
||||||
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
|
||||||
// Add agent name if not Local
|
// Add agent name if not Local
|
||||||
if (agentName !== 'Local') {
|
if (agentName !== 'Local') {
|
||||||
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
|
secondaryParts.push('<span class="agent-badge agent-remote" style="font-size:8px;padding:1px 4px;">' + escapeHtml(agentName) + '</span>');
|
||||||
@@ -1403,7 +1361,6 @@ const BluetoothMode = (function() {
|
|||||||
selectDevice,
|
selectDevice,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
copyAddress,
|
copyAddress,
|
||||||
toggleWatchlist,
|
|
||||||
|
|
||||||
// Agent handling
|
// Agent handling
|
||||||
handleAgentChange,
|
handleAgentChange,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,18 +11,6 @@ const SSTVGeneral = (function() {
|
|||||||
let currentMode = null;
|
let currentMode = null;
|
||||||
let progress = 0;
|
let progress = 0;
|
||||||
|
|
||||||
// Signal scope state
|
|
||||||
let sstvGeneralScopeCtx = null;
|
|
||||||
let sstvGeneralScopeAnim = null;
|
|
||||||
let sstvGeneralScopeHistory = [];
|
|
||||||
const SSTV_GENERAL_SCOPE_LEN = 200;
|
|
||||||
let sstvGeneralScopeRms = 0;
|
|
||||||
let sstvGeneralScopePeak = 0;
|
|
||||||
let sstvGeneralScopeTargetRms = 0;
|
|
||||||
let sstvGeneralScopeTargetPeak = 0;
|
|
||||||
let sstvGeneralScopeMsgBurst = 0;
|
|
||||||
let sstvGeneralScopeTone = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the SSTV General mode
|
* Initialize the SSTV General mode
|
||||||
*/
|
*/
|
||||||
@@ -202,136 +190,6 @@ const SSTVGeneral = (function() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize signal scope canvas
|
|
||||||
*/
|
|
||||||
function initSstvGeneralScope() {
|
|
||||||
const canvas = document.getElementById('sstvGeneralScopeCanvas');
|
|
||||||
if (!canvas) return;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
|
||||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
|
||||||
sstvGeneralScopeCtx = canvas.getContext('2d');
|
|
||||||
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
|
|
||||||
sstvGeneralScopeRms = 0;
|
|
||||||
sstvGeneralScopePeak = 0;
|
|
||||||
sstvGeneralScopeTargetRms = 0;
|
|
||||||
sstvGeneralScopeTargetPeak = 0;
|
|
||||||
sstvGeneralScopeMsgBurst = 0;
|
|
||||||
sstvGeneralScopeTone = null;
|
|
||||||
drawSstvGeneralScope();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draw signal scope animation frame
|
|
||||||
*/
|
|
||||||
function drawSstvGeneralScope() {
|
|
||||||
const ctx = sstvGeneralScopeCtx;
|
|
||||||
if (!ctx) return;
|
|
||||||
const W = ctx.canvas.width;
|
|
||||||
const H = ctx.canvas.height;
|
|
||||||
const midY = H / 2;
|
|
||||||
|
|
||||||
// Phosphor persistence
|
|
||||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
|
|
||||||
// Smooth towards target
|
|
||||||
sstvGeneralScopeRms += (sstvGeneralScopeTargetRms - sstvGeneralScopeRms) * 0.25;
|
|
||||||
sstvGeneralScopePeak += (sstvGeneralScopeTargetPeak - sstvGeneralScopePeak) * 0.15;
|
|
||||||
|
|
||||||
// Push to history
|
|
||||||
sstvGeneralScopeHistory.push(Math.min(sstvGeneralScopeRms / 32768, 1.0));
|
|
||||||
if (sstvGeneralScopeHistory.length > SSTV_GENERAL_SCOPE_LEN) sstvGeneralScopeHistory.shift();
|
|
||||||
|
|
||||||
// Grid lines
|
|
||||||
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
|
|
||||||
ctx.lineWidth = 0.5;
|
|
||||||
for (let i = 1; i < 4; i++) {
|
|
||||||
const y = (H / 4) * i;
|
|
||||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
|
||||||
}
|
|
||||||
for (let i = 1; i < 8; i++) {
|
|
||||||
const x = (W / 8) * i;
|
|
||||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Waveform
|
|
||||||
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
|
|
||||||
ctx.strokeStyle = '#c080ff';
|
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
ctx.shadowColor = '#c080ff';
|
|
||||||
ctx.shadowBlur = 4;
|
|
||||||
|
|
||||||
// Upper half
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
|
||||||
const x = i * stepX;
|
|
||||||
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
|
||||||
const y = midY - amp;
|
|
||||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Lower half (mirror)
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
|
||||||
const x = i * stepX;
|
|
||||||
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
|
||||||
const y = midY + amp;
|
|
||||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
|
|
||||||
// Peak indicator
|
|
||||||
const peakNorm = Math.min(sstvGeneralScopePeak / 32768, 1.0);
|
|
||||||
if (peakNorm > 0.01) {
|
|
||||||
const peakY = midY - peakNorm * midY * 0.9;
|
|
||||||
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.setLineDash([4, 4]);
|
|
||||||
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image decode flash
|
|
||||||
if (sstvGeneralScopeMsgBurst > 0.01) {
|
|
||||||
ctx.fillStyle = `rgba(0, 255, 100, ${sstvGeneralScopeMsgBurst * 0.15})`;
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
sstvGeneralScopeMsgBurst *= 0.88;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update labels
|
|
||||||
const rmsLabel = document.getElementById('sstvGeneralScopeRmsLabel');
|
|
||||||
const peakLabel = document.getElementById('sstvGeneralScopePeakLabel');
|
|
||||||
const toneLabel = document.getElementById('sstvGeneralScopeToneLabel');
|
|
||||||
const statusLabel = document.getElementById('sstvGeneralScopeStatusLabel');
|
|
||||||
if (rmsLabel) rmsLabel.textContent = Math.round(sstvGeneralScopeRms);
|
|
||||||
if (peakLabel) peakLabel.textContent = Math.round(sstvGeneralScopePeak);
|
|
||||||
if (toneLabel) {
|
|
||||||
if (sstvGeneralScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
|
|
||||||
else if (sstvGeneralScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
|
|
||||||
else if (sstvGeneralScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
|
|
||||||
else if (sstvGeneralScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
|
|
||||||
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
|
||||||
}
|
|
||||||
if (statusLabel) {
|
|
||||||
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
|
|
||||||
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop signal scope
|
|
||||||
*/
|
|
||||||
function stopSstvGeneralScope() {
|
|
||||||
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
|
|
||||||
sstvGeneralScopeCtx = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start SSE stream
|
* Start SSE stream
|
||||||
*/
|
*/
|
||||||
@@ -340,11 +198,6 @@ const SSTVGeneral = (function() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show and init scope
|
|
||||||
const scopePanel = document.getElementById('sstvGeneralScopePanel');
|
|
||||||
if (scopePanel) scopePanel.style.display = 'block';
|
|
||||||
initSstvGeneralScope();
|
|
||||||
|
|
||||||
eventSource = new EventSource('/sstv-general/stream');
|
eventSource = new EventSource('/sstv-general/stream');
|
||||||
|
|
||||||
eventSource.onmessage = (e) => {
|
eventSource.onmessage = (e) => {
|
||||||
@@ -352,10 +205,6 @@ const SSTVGeneral = (function() {
|
|||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.type === 'sstv_progress') {
|
if (data.type === 'sstv_progress') {
|
||||||
handleProgress(data);
|
handleProgress(data);
|
||||||
} else if (data.type === 'sstv_scope') {
|
|
||||||
sstvGeneralScopeTargetRms = data.rms;
|
|
||||||
sstvGeneralScopeTargetPeak = data.peak;
|
|
||||||
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE message:', err);
|
console.error('Failed to parse SSE message:', err);
|
||||||
@@ -378,9 +227,6 @@ const SSTVGeneral = (function() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
stopSstvGeneralScope();
|
|
||||||
const scopePanel = document.getElementById('sstvGeneralScopePanel');
|
|
||||||
if (scopePanel) scopePanel.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -399,7 +245,6 @@ const SSTVGeneral = (function() {
|
|||||||
renderGallery();
|
renderGallery();
|
||||||
showNotification('SSTV', 'New image decoded!');
|
showNotification('SSTV', 'New image decoded!');
|
||||||
updateStatusUI('listening', 'Listening...');
|
updateStatusUI('listening', 'Listening...');
|
||||||
sstvGeneralScopeMsgBurst = 1.0;
|
|
||||||
// Clear decode progress so signal monitor can take over
|
// Clear decode progress so signal monitor can take over
|
||||||
const liveContent = document.getElementById('sstvGeneralLiveContent');
|
const liveContent = document.getElementById('sstvGeneralLiveContent');
|
||||||
if (liveContent) liveContent.innerHTML = '';
|
if (liveContent) liveContent.innerHTML = '';
|
||||||
|
|||||||
@@ -21,18 +21,6 @@ const SSTV = (function() {
|
|||||||
// ISS frequency
|
// ISS frequency
|
||||||
const ISS_FREQ = 145.800;
|
const ISS_FREQ = 145.800;
|
||||||
|
|
||||||
// Signal scope state
|
|
||||||
let sstvScopeCtx = null;
|
|
||||||
let sstvScopeAnim = null;
|
|
||||||
let sstvScopeHistory = [];
|
|
||||||
const SSTV_SCOPE_LEN = 200;
|
|
||||||
let sstvScopeRms = 0;
|
|
||||||
let sstvScopePeak = 0;
|
|
||||||
let sstvScopeTargetRms = 0;
|
|
||||||
let sstvScopeTargetPeak = 0;
|
|
||||||
let sstvScopeMsgBurst = 0;
|
|
||||||
let sstvScopeTone = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the SSTV mode
|
* Initialize the SSTV mode
|
||||||
*/
|
*/
|
||||||
@@ -646,136 +634,6 @@ const SSTV = (function() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize signal scope canvas
|
|
||||||
*/
|
|
||||||
function initSstvScope() {
|
|
||||||
const canvas = document.getElementById('sstvScopeCanvas');
|
|
||||||
if (!canvas) return;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
|
||||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
|
||||||
sstvScopeCtx = canvas.getContext('2d');
|
|
||||||
sstvScopeHistory = new Array(SSTV_SCOPE_LEN).fill(0);
|
|
||||||
sstvScopeRms = 0;
|
|
||||||
sstvScopePeak = 0;
|
|
||||||
sstvScopeTargetRms = 0;
|
|
||||||
sstvScopeTargetPeak = 0;
|
|
||||||
sstvScopeMsgBurst = 0;
|
|
||||||
sstvScopeTone = null;
|
|
||||||
drawSstvScope();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draw signal scope animation frame
|
|
||||||
*/
|
|
||||||
function drawSstvScope() {
|
|
||||||
const ctx = sstvScopeCtx;
|
|
||||||
if (!ctx) return;
|
|
||||||
const W = ctx.canvas.width;
|
|
||||||
const H = ctx.canvas.height;
|
|
||||||
const midY = H / 2;
|
|
||||||
|
|
||||||
// Phosphor persistence
|
|
||||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
|
|
||||||
// Smooth towards target
|
|
||||||
sstvScopeRms += (sstvScopeTargetRms - sstvScopeRms) * 0.25;
|
|
||||||
sstvScopePeak += (sstvScopeTargetPeak - sstvScopePeak) * 0.15;
|
|
||||||
|
|
||||||
// Push to history
|
|
||||||
sstvScopeHistory.push(Math.min(sstvScopeRms / 32768, 1.0));
|
|
||||||
if (sstvScopeHistory.length > SSTV_SCOPE_LEN) sstvScopeHistory.shift();
|
|
||||||
|
|
||||||
// Grid lines
|
|
||||||
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
|
|
||||||
ctx.lineWidth = 0.5;
|
|
||||||
for (let i = 1; i < 4; i++) {
|
|
||||||
const y = (H / 4) * i;
|
|
||||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
|
||||||
}
|
|
||||||
for (let i = 1; i < 8; i++) {
|
|
||||||
const x = (W / 8) * i;
|
|
||||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Waveform
|
|
||||||
const stepX = W / (SSTV_SCOPE_LEN - 1);
|
|
||||||
ctx.strokeStyle = '#c080ff';
|
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
ctx.shadowColor = '#c080ff';
|
|
||||||
ctx.shadowBlur = 4;
|
|
||||||
|
|
||||||
// Upper half
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < sstvScopeHistory.length; i++) {
|
|
||||||
const x = i * stepX;
|
|
||||||
const amp = sstvScopeHistory[i] * midY * 0.9;
|
|
||||||
const y = midY - amp;
|
|
||||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Lower half (mirror)
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < sstvScopeHistory.length; i++) {
|
|
||||||
const x = i * stepX;
|
|
||||||
const amp = sstvScopeHistory[i] * midY * 0.9;
|
|
||||||
const y = midY + amp;
|
|
||||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
|
|
||||||
// Peak indicator
|
|
||||||
const peakNorm = Math.min(sstvScopePeak / 32768, 1.0);
|
|
||||||
if (peakNorm > 0.01) {
|
|
||||||
const peakY = midY - peakNorm * midY * 0.9;
|
|
||||||
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.setLineDash([4, 4]);
|
|
||||||
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image decode flash
|
|
||||||
if (sstvScopeMsgBurst > 0.01) {
|
|
||||||
ctx.fillStyle = `rgba(0, 255, 100, ${sstvScopeMsgBurst * 0.15})`;
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
sstvScopeMsgBurst *= 0.88;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update labels
|
|
||||||
const rmsLabel = document.getElementById('sstvScopeRmsLabel');
|
|
||||||
const peakLabel = document.getElementById('sstvScopePeakLabel');
|
|
||||||
const toneLabel = document.getElementById('sstvScopeToneLabel');
|
|
||||||
const statusLabel = document.getElementById('sstvScopeStatusLabel');
|
|
||||||
if (rmsLabel) rmsLabel.textContent = Math.round(sstvScopeRms);
|
|
||||||
if (peakLabel) peakLabel.textContent = Math.round(sstvScopePeak);
|
|
||||||
if (toneLabel) {
|
|
||||||
if (sstvScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
|
|
||||||
else if (sstvScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
|
|
||||||
else if (sstvScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
|
|
||||||
else if (sstvScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
|
|
||||||
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
|
||||||
}
|
|
||||||
if (statusLabel) {
|
|
||||||
if (sstvScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
|
|
||||||
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
sstvScopeAnim = requestAnimationFrame(drawSstvScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop signal scope
|
|
||||||
*/
|
|
||||||
function stopSstvScope() {
|
|
||||||
if (sstvScopeAnim) { cancelAnimationFrame(sstvScopeAnim); sstvScopeAnim = null; }
|
|
||||||
sstvScopeCtx = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start SSE stream
|
* Start SSE stream
|
||||||
*/
|
*/
|
||||||
@@ -784,11 +642,6 @@ const SSTV = (function() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show and init scope
|
|
||||||
const scopePanel = document.getElementById('sstvScopePanel');
|
|
||||||
if (scopePanel) scopePanel.style.display = 'block';
|
|
||||||
initSstvScope();
|
|
||||||
|
|
||||||
eventSource = new EventSource('/sstv/stream');
|
eventSource = new EventSource('/sstv/stream');
|
||||||
|
|
||||||
eventSource.onmessage = (e) => {
|
eventSource.onmessage = (e) => {
|
||||||
@@ -796,10 +649,6 @@ const SSTV = (function() {
|
|||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.type === 'sstv_progress') {
|
if (data.type === 'sstv_progress') {
|
||||||
handleProgress(data);
|
handleProgress(data);
|
||||||
} else if (data.type === 'sstv_scope') {
|
|
||||||
sstvScopeTargetRms = data.rms;
|
|
||||||
sstvScopeTargetPeak = data.peak;
|
|
||||||
if (data.tone !== undefined) sstvScopeTone = data.tone;
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE message:', err);
|
console.error('Failed to parse SSE message:', err);
|
||||||
@@ -822,9 +671,6 @@ const SSTV = (function() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
stopSstvScope();
|
|
||||||
const scopePanel = document.getElementById('sstvScopePanel');
|
|
||||||
if (scopePanel) scopePanel.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -845,7 +691,6 @@ const SSTV = (function() {
|
|||||||
renderGallery();
|
renderGallery();
|
||||||
showNotification('SSTV', 'New image decoded!');
|
showNotification('SSTV', 'New image decoded!');
|
||||||
updateStatusUI('listening', 'Listening...');
|
updateStatusUI('listening', 'Listening...');
|
||||||
sstvScopeMsgBurst = 1.0;
|
|
||||||
// Clear decode progress so signal monitor can take over
|
// Clear decode progress so signal monitor can take over
|
||||||
const liveContent = document.getElementById('sstvLiveContent');
|
const liveContent = document.getElementById('sstvLiveContent');
|
||||||
if (liveContent) liveContent.innerHTML = '';
|
if (liveContent) liveContent.innerHTML = '';
|
||||||
|
|||||||
+3
-39
@@ -69,40 +69,6 @@ const WiFiMode = (function() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChannelPresetList(preset) {
|
|
||||||
switch (preset) {
|
|
||||||
case '2.4-common':
|
|
||||||
return '1,6,11';
|
|
||||||
case '2.4-all':
|
|
||||||
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
|
|
||||||
case '5-low':
|
|
||||||
return '36,40,44,48';
|
|
||||||
case '5-mid':
|
|
||||||
return '52,56,60,64';
|
|
||||||
case '5-high':
|
|
||||||
return '149,153,157,161,165';
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildChannelConfig() {
|
|
||||||
const preset = document.getElementById('wifiChannelPreset')?.value || '';
|
|
||||||
const listInput = document.getElementById('wifiChannelList')?.value || '';
|
|
||||||
const singleInput = document.getElementById('wifiChannel')?.value || '';
|
|
||||||
|
|
||||||
const listValue = listInput.trim();
|
|
||||||
const presetValue = getChannelPresetList(preset);
|
|
||||||
|
|
||||||
const channels = listValue || presetValue || '';
|
|
||||||
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
|
|
||||||
|
|
||||||
return {
|
|
||||||
channels: channels || null,
|
|
||||||
channel: Number.isFinite(channel) ? channel : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// State
|
// State
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -497,7 +463,7 @@ const WiFiMode = (function() {
|
|||||||
try {
|
try {
|
||||||
const iface = elements.interfaceSelect?.value || null;
|
const iface = elements.interfaceSelect?.value || null;
|
||||||
const band = document.getElementById('wifiBand')?.value || 'all';
|
const band = document.getElementById('wifiBand')?.value || 'all';
|
||||||
const channelConfig = buildChannelConfig();
|
const channel = document.getElementById('wifiChannel')?.value || null;
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
@@ -510,8 +476,7 @@ const WiFiMode = (function() {
|
|||||||
interface: iface,
|
interface: iface,
|
||||||
scan_type: 'deep',
|
scan_type: 'deep',
|
||||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||||
channel: channelConfig.channel,
|
channel: channel ? parseInt(channel) : null,
|
||||||
channels: channelConfig.channels,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -521,8 +486,7 @@ const WiFiMode = (function() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
interface: iface,
|
interface: iface,
|
||||||
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
|
||||||
channel: channelConfig.channel,
|
channel: channel ? parseInt(channel) : null,
|
||||||
channels: channelConfig.channels,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-657
@@ -505,42 +505,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shared Waterfall Controls -->
|
|
||||||
<div class="section" id="waterfallControlsSection" style="display: none;">
|
|
||||||
<h3>Waterfall</h3>
|
|
||||||
<div class="form-group" style="margin-bottom: 6px;">
|
|
||||||
<label style="font-size: 10px;">Start (MHz)</label>
|
|
||||||
<input type="number" id="waterfallStartFreq" value="88" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 6px;">
|
|
||||||
<label style="font-size: 10px;">End (MHz)</label>
|
|
||||||
<input type="number" id="waterfallEndFreq" value="108" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 6px;">
|
|
||||||
<label style="font-size: 10px;">Zoom</label>
|
|
||||||
<div style="display: flex; gap: 6px; align-items: center;">
|
|
||||||
<button class="tune-btn" type="button" onclick="zoomWaterfall('out')" style="padding: 4px 8px;">-</button>
|
|
||||||
<button class="tune-btn" type="button" onclick="zoomWaterfall('in')" style="padding: 4px 8px;">+</button>
|
|
||||||
<span id="waterfallZoomSpan" style="font-size: 10px; color: var(--text-muted);">20.0 MHz</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 6px;">
|
|
||||||
<label style="font-size: 10px;">FFT Size</label>
|
|
||||||
<select id="waterfallFftSize" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
<option value="512">512</option>
|
|
||||||
<option value="1024" selected>1024</option>
|
|
||||||
<option value="2048">2048</option>
|
|
||||||
<option value="4096">4096</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 8px;">
|
|
||||||
<label style="font-size: 10px;">Gain</label>
|
|
||||||
<input type="number" id="waterfallGain" value="40" min="0" max="50" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
</div>
|
|
||||||
<button class="run-btn" id="startWaterfallBtn" onclick="startWaterfall()" style="width: 100%; padding: 8px;">Start Waterfall</button>
|
|
||||||
<button class="stop-btn" id="stopWaterfallBtn" onclick="stopWaterfall()" style="display: none; width: 100%; padding: 8px; margin-top: 4px;">Stop Waterfall</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% include 'partials/modes/pager.html' %}
|
{% include 'partials/modes/pager.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/sensor.html' %}
|
{% include 'partials/modes/sensor.html' %}
|
||||||
@@ -579,10 +543,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="output-panel">
|
<div class="output-panel">
|
||||||
<div class="output-header">
|
<div class="output-header">
|
||||||
<h3 id="outputTitle">Pager Decoder</h3>
|
<h3 id="outputTitle">Pager Decoder</h3>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
<div class="stats" id="pagerStats">
|
<div class="stats" id="pagerStats">
|
||||||
<div title="Total Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg></span> <span id="msgCount">0</span></div>
|
<div title="Total Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg></span> <span id="msgCount">0</span></div>
|
||||||
<div title="POCSAG Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> <span id="pocsagCount">0</span></div>
|
<div title="POCSAG Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> <span id="pocsagCount">0</span></div>
|
||||||
@@ -604,21 +568,11 @@
|
|||||||
<div class="stats" id="satelliteStats" style="display: none;">
|
<div class="stats" id="satelliteStats" style="display: none;">
|
||||||
<div title="Upcoming Passes"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span> <span id="passCount">0</span></div>
|
<div title="Upcoming Passes"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span> <span id="passCount">0</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- WATERFALL / SPECTROGRAM PANEL -->
|
<!-- WiFi Layout Container -->
|
||||||
<div id="waterfallPanel" class="radio-module-box" style="padding: 10px; display: none; margin-bottom: 12px;">
|
<div class="wifi-layout-container" id="wifiLayoutContainer" style="display: none;">
|
||||||
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
|
|
||||||
<span>WATERFALL / SPECTROGRAM</span>
|
|
||||||
<span id="waterfallFreqRange" style="font-size: 9px; color: var(--accent-cyan);"></span>
|
|
||||||
</div>
|
|
||||||
<canvas id="spectrumCanvas" width="800" height="120" style="width: 100%; height: 120px; border-radius: 4px; background: rgba(0,0,0,0.8);"></canvas>
|
|
||||||
<canvas id="waterfallCanvas" width="800" height="400" style="width: 100%; height: 400px; border-radius: 4px; margin-top: 4px; background: rgba(0,0,0,0.9);"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WiFi Layout Container -->
|
|
||||||
<div class="wifi-layout-container" id="wifiLayoutContainer" style="display: none;">
|
|
||||||
<!-- Status Bar -->
|
<!-- Status Bar -->
|
||||||
<div class="wifi-status-bar">
|
<div class="wifi-status-bar">
|
||||||
<div class="wifi-status-item">
|
<div class="wifi-status-item">
|
||||||
@@ -852,8 +806,7 @@
|
|||||||
<div class="bt-detail-services" id="btDetailServices" style="display: none;">
|
<div class="bt-detail-services" id="btDetailServices" style="display: none;">
|
||||||
<span class="bt-detail-services-list" id="btDetailServicesList"></span>
|
<span class="bt-detail-services-list" id="btDetailServicesList"></span>
|
||||||
</div>
|
</div>
|
||||||
<button class="bt-detail-btn" id="btDetailWatchBtn" onclick="BluetoothMode.toggleWatchlist()">Watchlist</button>
|
<button class="bt-detail-btn" onclick="BluetoothMode.copyAddress()">Copy</button>
|
||||||
<button class="bt-detail-btn" id="btDetailCopyBtn" onclick="BluetoothMode.copyAddress()">Copy</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1441,6 +1394,15 @@
|
|||||||
<div id="listeningPostTimelineContainer"></div>
|
<div id="listeningPostTimelineContainer"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- WATERFALL / SPECTROGRAM PANEL -->
|
||||||
|
<div id="waterfallPanel" class="radio-module-box" style="grid-column: span 4; padding: 10px; display: none;">
|
||||||
|
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
|
||||||
|
<span>WATERFALL / SPECTROGRAM</span>
|
||||||
|
<span id="waterfallFreqRange" style="font-size: 9px; color: var(--accent-cyan);"></span>
|
||||||
|
</div>
|
||||||
|
<canvas id="spectrumCanvas" width="800" height="120" style="width: 100%; height: 120px; border-radius: 4px; background: rgba(0,0,0,0.8);"></canvas>
|
||||||
|
<canvas id="waterfallCanvas" width="800" height="400" style="width: 100%; height: 400px; border-radius: 4px; margin-top: 4px; background: rgba(0,0,0,0.9);"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Satellite Dashboard (Embedded) -->
|
<!-- Satellite Dashboard (Embedded) -->
|
||||||
@@ -1603,17 +1565,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- WiFi Clients Panel -->
|
|
||||||
<div class="tscm-panel" id="tscmWifiClientPanel">
|
|
||||||
<div class="tscm-panel-header">
|
|
||||||
WiFi Clients
|
|
||||||
<span class="badge" id="tscmWifiClientCount">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="tscm-panel-content" id="tscmWifiClientList">
|
|
||||||
<div class="tscm-empty">Start a sweep to scan for WiFi clients</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bluetooth Panel -->
|
<!-- Bluetooth Panel -->
|
||||||
<div class="tscm-panel" id="tscmBtPanel">
|
<div class="tscm-panel" id="tscmBtPanel">
|
||||||
<div class="tscm-panel-header">
|
<div class="tscm-panel-header">
|
||||||
@@ -2039,22 +1990,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Signal Scope -->
|
|
||||||
<div id="sstvScopePanel" style="display: none; margin-bottom: 12px;">
|
|
||||||
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'JetBrains Mono', 'Fira Code', monospace;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
|
||||||
<span>Signal Scope</span>
|
|
||||||
<div style="display: flex; gap: 14px;">
|
|
||||||
<span>RMS: <span id="sstvScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
|
|
||||||
<span>PEAK: <span id="sstvScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
|
||||||
<span id="sstvScopeToneLabel" style="color: #444;">QUIET</span>
|
|
||||||
<span id="sstvScopeStatusLabel" style="color: #444;">IDLE</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<canvas id="sstvScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Row (Live + Gallery) -->
|
<!-- Main Row (Live + Gallery) -->
|
||||||
<div class="sstv-main-row">
|
<div class="sstv-main-row">
|
||||||
<!-- Live Decode Section -->
|
<!-- Live Decode Section -->
|
||||||
@@ -2140,22 +2075,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Signal Scope -->
|
|
||||||
<div id="sstvGeneralScopePanel" style="display: none; margin-bottom: 12px;">
|
|
||||||
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'JetBrains Mono', 'Fira Code', monospace;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
|
||||||
<span>Signal Scope</span>
|
|
||||||
<div style="display: flex; gap: 14px;">
|
|
||||||
<span>RMS: <span id="sstvGeneralScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
|
|
||||||
<span>PEAK: <span id="sstvGeneralScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
|
||||||
<span id="sstvGeneralScopeToneLabel" style="color: #444;">QUIET</span>
|
|
||||||
<span id="sstvGeneralScopeStatusLabel" style="color: #444;">IDLE</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<canvas id="sstvGeneralScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Row (Live + Gallery) -->
|
<!-- Main Row (Live + Gallery) -->
|
||||||
<div class="sstv-general-main-row">
|
<div class="sstv-general-main-row">
|
||||||
<!-- Live Decode Section -->
|
<!-- Live Decode Section -->
|
||||||
@@ -2232,39 +2151,8 @@
|
|||||||
<!-- Filter Bar Container (populated by JavaScript based on active mode) -->
|
<!-- Filter Bar Container (populated by JavaScript based on active mode) -->
|
||||||
<div id="filterBarContainer" style="display: none;"></div>
|
<div id="filterBarContainer" style="display: none;"></div>
|
||||||
|
|
||||||
<!-- Pager Signal Scope -->
|
|
||||||
<div id="pagerScopePanel" style="display: none; margin-bottom: 12px;">
|
|
||||||
<div style="background: #0a0a0a; border: 1px solid #1a1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'JetBrains Mono', 'Fira Code', monospace;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
|
||||||
<span>Signal Scope</span>
|
|
||||||
<div style="display: flex; gap: 14px;">
|
|
||||||
<span>RMS: <span id="scopeRmsLabel" style="color: #0ff; font-variant-numeric: tabular-nums;">0</span></span>
|
|
||||||
<span>PEAK: <span id="scopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
|
||||||
<span id="scopeStatusLabel" style="color: #444;">IDLE</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<canvas id="pagerScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mode-specific Timeline Containers -->
|
<!-- Mode-specific Timeline Containers -->
|
||||||
<div id="pagerTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
<div id="pagerTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
||||||
|
|
||||||
<!-- Sensor Signal Scope -->
|
|
||||||
<div id="sensorScopePanel" style="display: none; margin-bottom: 12px;">
|
|
||||||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'JetBrains Mono', 'Fira Code', monospace;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
|
||||||
<span>Signal Scope</span>
|
|
||||||
<div style="display: flex; gap: 14px;">
|
|
||||||
<span>RSSI: <span id="sensorScopeRssiLabel" style="color: #0f0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
|
|
||||||
<span>SNR: <span id="sensorScopeSnrLabel" style="color: #fa0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
|
|
||||||
<span id="sensorScopeStatusLabel" style="color: #444;">IDLE</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<canvas id="sensorScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="sensorTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
<div id="sensorTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
||||||
|
|
||||||
<div class="output-content signal-feed" id="output">
|
<div class="output-content signal-feed" id="output">
|
||||||
@@ -3122,17 +3010,6 @@
|
|||||||
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
||||||
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none';
|
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'sstv_general' || mode === 'dmr') ? 'block' : 'none';
|
||||||
|
|
||||||
// Show shared waterfall controls for supported modes
|
|
||||||
const waterfallControlsSection = document.getElementById('waterfallControlsSection');
|
|
||||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
|
||||||
const waterfallModes = ['listening'];
|
|
||||||
const waterfallSupported = waterfallModes.includes(mode);
|
|
||||||
if (waterfallControlsSection) waterfallControlsSection.style.display = waterfallSupported ? 'block' : 'none';
|
|
||||||
if (waterfallPanel) {
|
|
||||||
const running = (typeof isWaterfallRunning !== 'undefined' && isWaterfallRunning);
|
|
||||||
waterfallPanel.style.display = (waterfallSupported && running) ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle mode-specific tool status displays
|
// Toggle mode-specific tool status displays
|
||||||
const toolStatusPager = document.getElementById('toolStatusPager');
|
const toolStatusPager = document.getElementById('toolStatusPager');
|
||||||
const toolStatusSensor = document.getElementById('toolStatusSensor');
|
const toolStatusSensor = document.getElementById('toolStatusSensor');
|
||||||
@@ -3233,160 +3110,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sensor Signal Scope ---
|
|
||||||
let sensorScopeCtx = null;
|
|
||||||
let sensorScopeAnim = null;
|
|
||||||
let sensorScopeHistory = [];
|
|
||||||
const SENSOR_SCOPE_LEN = 200;
|
|
||||||
let sensorScopeRssi = 0;
|
|
||||||
let sensorScopeSnr = 0;
|
|
||||||
let sensorScopeTargetRssi = 0;
|
|
||||||
let sensorScopeTargetSnr = 0;
|
|
||||||
let sensorScopeMsgBurst = 0;
|
|
||||||
let sensorScopeLastPulse = 0;
|
|
||||||
|
|
||||||
function initSensorScope() {
|
|
||||||
const canvas = document.getElementById('sensorScopeCanvas');
|
|
||||||
if (!canvas) return;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
|
||||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
|
||||||
sensorScopeCtx = canvas.getContext('2d');
|
|
||||||
sensorScopeHistory = new Array(SENSOR_SCOPE_LEN).fill(0);
|
|
||||||
sensorScopeRssi = 0;
|
|
||||||
sensorScopeSnr = 0;
|
|
||||||
sensorScopeTargetRssi = 0;
|
|
||||||
sensorScopeTargetSnr = 0;
|
|
||||||
sensorScopeMsgBurst = 0;
|
|
||||||
sensorScopeLastPulse = 0;
|
|
||||||
drawSensorScope();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawSensorScope() {
|
|
||||||
const ctx = sensorScopeCtx;
|
|
||||||
if (!ctx) return;
|
|
||||||
const W = ctx.canvas.width;
|
|
||||||
const H = ctx.canvas.height;
|
|
||||||
const midY = H / 2;
|
|
||||||
|
|
||||||
// Phosphor persistence
|
|
||||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
|
|
||||||
// Smooth towards targets (decay when no new packets)
|
|
||||||
sensorScopeRssi += (sensorScopeTargetRssi - sensorScopeRssi) * 0.25;
|
|
||||||
sensorScopeSnr += (sensorScopeTargetSnr - sensorScopeSnr) * 0.15;
|
|
||||||
|
|
||||||
// Decay targets back to zero between packets
|
|
||||||
sensorScopeTargetRssi *= 0.97;
|
|
||||||
sensorScopeTargetSnr *= 0.97;
|
|
||||||
|
|
||||||
// RSSI is typically negative dBm (e.g. -0.1 to -30+)
|
|
||||||
// Normalize: map absolute RSSI to 0-1 range (0 dB = max, -40 dB = min)
|
|
||||||
const rssiNorm = Math.min(Math.max(Math.abs(sensorScopeRssi) / 40, 0), 1.0);
|
|
||||||
sensorScopeHistory.push(rssiNorm);
|
|
||||||
if (sensorScopeHistory.length > SENSOR_SCOPE_LEN) {
|
|
||||||
sensorScopeHistory.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grid lines
|
|
||||||
ctx.strokeStyle = 'rgba(40, 80, 40, 0.4)';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let g = 0.25; g < 1; g += 0.25) {
|
|
||||||
const gy = midY - g * midY;
|
|
||||||
const gy2 = midY + g * midY;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, gy); ctx.lineTo(W, gy);
|
|
||||||
ctx.moveTo(0, gy2); ctx.lineTo(W, gy2);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Center baseline
|
|
||||||
ctx.strokeStyle = 'rgba(60, 100, 60, 0.5)';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, midY);
|
|
||||||
ctx.lineTo(W, midY);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Waveform (mirrored, green theme for 433)
|
|
||||||
const stepX = W / SENSOR_SCOPE_LEN;
|
|
||||||
ctx.strokeStyle = '#0f0';
|
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
ctx.shadowColor = '#0f0';
|
|
||||||
ctx.shadowBlur = 4;
|
|
||||||
|
|
||||||
// Upper half
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < sensorScopeHistory.length; i++) {
|
|
||||||
const x = i * stepX;
|
|
||||||
const amp = sensorScopeHistory[i] * midY * 0.9;
|
|
||||||
const y = midY - amp;
|
|
||||||
if (i === 0) ctx.moveTo(x, y);
|
|
||||||
else ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Lower half (mirror)
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < sensorScopeHistory.length; i++) {
|
|
||||||
const x = i * stepX;
|
|
||||||
const amp = sensorScopeHistory[i] * midY * 0.9;
|
|
||||||
const y = midY + amp;
|
|
||||||
if (i === 0) ctx.moveTo(x, y);
|
|
||||||
else ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
|
|
||||||
// SNR indicator (amber dashed line)
|
|
||||||
const snrNorm = Math.min(Math.max(Math.abs(sensorScopeSnr) / 40, 0), 1.0);
|
|
||||||
if (snrNorm > 0.01) {
|
|
||||||
const snrY = midY - snrNorm * midY * 0.9;
|
|
||||||
ctx.strokeStyle = 'rgba(255, 170, 0, 0.6)';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.setLineDash([4, 4]);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, snrY);
|
|
||||||
ctx.lineTo(W, snrY);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sensor decode flash (green overlay)
|
|
||||||
if (sensorScopeMsgBurst > 0.01) {
|
|
||||||
ctx.fillStyle = `rgba(0, 255, 100, ${sensorScopeMsgBurst * 0.15})`;
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
sensorScopeMsgBurst *= 0.88;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update labels
|
|
||||||
const rssiLabel = document.getElementById('sensorScopeRssiLabel');
|
|
||||||
const snrLabel = document.getElementById('sensorScopeSnrLabel');
|
|
||||||
const statusLabel = document.getElementById('sensorScopeStatusLabel');
|
|
||||||
if (rssiLabel) rssiLabel.textContent = sensorScopeRssi < -0.5 ? sensorScopeRssi.toFixed(1) : '--';
|
|
||||||
if (snrLabel) snrLabel.textContent = sensorScopeSnr > 0.5 ? sensorScopeSnr.toFixed(1) : '--';
|
|
||||||
if (statusLabel) {
|
|
||||||
if (Math.abs(sensorScopeRssi) > 1) {
|
|
||||||
statusLabel.textContent = 'SIGNAL';
|
|
||||||
statusLabel.style.color = '#0f0';
|
|
||||||
} else {
|
|
||||||
statusLabel.textContent = 'MONITORING';
|
|
||||||
statusLabel.style.color = '#555';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sensorScopeAnim = requestAnimationFrame(drawSensorScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopSensorScope() {
|
|
||||||
if (sensorScopeAnim) {
|
|
||||||
cancelAnimationFrame(sensorScopeAnim);
|
|
||||||
sensorScopeAnim = null;
|
|
||||||
}
|
|
||||||
sensorScopeCtx = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start sensor decoding
|
// Start sensor decoding
|
||||||
function startSensorDecoding() {
|
function startSensorDecoding() {
|
||||||
const freq = document.getElementById('sensorFrequency').value;
|
const freq = document.getElementById('sensorFrequency').value;
|
||||||
@@ -3576,18 +3299,6 @@
|
|||||||
document.getElementById('statusText').textContent = running ? 'Listening...' : 'Idle';
|
document.getElementById('statusText').textContent = running ? 'Listening...' : 'Idle';
|
||||||
document.getElementById('startSensorBtn').style.display = running ? 'none' : 'block';
|
document.getElementById('startSensorBtn').style.display = running ? 'none' : 'block';
|
||||||
document.getElementById('stopSensorBtn').style.display = running ? 'block' : 'none';
|
document.getElementById('stopSensorBtn').style.display = running ? 'block' : 'none';
|
||||||
|
|
||||||
// Signal scope
|
|
||||||
const scopePanel = document.getElementById('sensorScopePanel');
|
|
||||||
if (scopePanel) {
|
|
||||||
if (running) {
|
|
||||||
scopePanel.style.display = 'block';
|
|
||||||
initSensorScope();
|
|
||||||
} else {
|
|
||||||
stopSensorScope();
|
|
||||||
scopePanel.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startSensorStream() {
|
function startSensorStream() {
|
||||||
@@ -3605,9 +3316,6 @@
|
|||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.type === 'sensor') {
|
if (data.type === 'sensor') {
|
||||||
addSensorReading(data);
|
addSensorReading(data);
|
||||||
} else if (data.type === 'scope') {
|
|
||||||
sensorScopeTargetRssi = data.rssi;
|
|
||||||
sensorScopeTargetSnr = data.snr;
|
|
||||||
} else if (data.type === 'status') {
|
} else if (data.type === 'status') {
|
||||||
if (data.text === 'stopped') {
|
if (data.text === 'stopped') {
|
||||||
setSensorRunning(false);
|
setSensorRunning(false);
|
||||||
@@ -3632,9 +3340,6 @@
|
|||||||
playAlert();
|
playAlert();
|
||||||
pulseSignal();
|
pulseSignal();
|
||||||
|
|
||||||
// Flash sensor scope green on decode
|
|
||||||
sensorScopeMsgBurst = 1.0;
|
|
||||||
|
|
||||||
sensorCount++;
|
sensorCount++;
|
||||||
document.getElementById('sensorCount').textContent = sensorCount;
|
document.getElementById('sensorCount').textContent = sensorCount;
|
||||||
|
|
||||||
@@ -4500,153 +4205,6 @@
|
|||||||
// Pager mode polling timer for agent mode
|
// Pager mode polling timer for agent mode
|
||||||
let pagerPollTimer = null;
|
let pagerPollTimer = null;
|
||||||
|
|
||||||
// --- Pager Signal Scope ---
|
|
||||||
let pagerScopeCtx = null;
|
|
||||||
let pagerScopeAnim = null;
|
|
||||||
let pagerScopeHistory = [];
|
|
||||||
const SCOPE_HISTORY_LEN = 200;
|
|
||||||
let pagerScopeRms = 0;
|
|
||||||
let pagerScopePeak = 0;
|
|
||||||
let pagerScopeTargetRms = 0;
|
|
||||||
let pagerScopeTargetPeak = 0;
|
|
||||||
let pagerScopeMsgBurst = 0;
|
|
||||||
|
|
||||||
function initPagerScope() {
|
|
||||||
const canvas = document.getElementById('pagerScopeCanvas');
|
|
||||||
if (!canvas) return;
|
|
||||||
// Set actual pixel resolution
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
|
||||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
|
||||||
pagerScopeCtx = canvas.getContext('2d');
|
|
||||||
pagerScopeHistory = new Array(SCOPE_HISTORY_LEN).fill(0);
|
|
||||||
pagerScopeRms = 0;
|
|
||||||
pagerScopePeak = 0;
|
|
||||||
pagerScopeTargetRms = 0;
|
|
||||||
pagerScopeTargetPeak = 0;
|
|
||||||
pagerScopeMsgBurst = 0;
|
|
||||||
drawPagerScope();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawPagerScope() {
|
|
||||||
const ctx = pagerScopeCtx;
|
|
||||||
if (!ctx) return;
|
|
||||||
const W = ctx.canvas.width;
|
|
||||||
const H = ctx.canvas.height;
|
|
||||||
const midY = H / 2;
|
|
||||||
|
|
||||||
// Phosphor persistence: semi-transparent clear
|
|
||||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
|
|
||||||
// Smooth towards target values
|
|
||||||
pagerScopeRms += (pagerScopeTargetRms - pagerScopeRms) * 0.25;
|
|
||||||
pagerScopePeak += (pagerScopeTargetPeak - pagerScopePeak) * 0.15;
|
|
||||||
|
|
||||||
// Push current RMS into history (normalized 0-1 against 32768)
|
|
||||||
pagerScopeHistory.push(Math.min(pagerScopeRms / 32768, 1.0));
|
|
||||||
if (pagerScopeHistory.length > SCOPE_HISTORY_LEN) {
|
|
||||||
pagerScopeHistory.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grid lines
|
|
||||||
ctx.strokeStyle = 'rgba(40, 40, 80, 0.4)';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let g = 0.25; g < 1; g += 0.25) {
|
|
||||||
const gy = midY - g * midY;
|
|
||||||
const gy2 = midY + g * midY;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, gy); ctx.lineTo(W, gy);
|
|
||||||
ctx.moveTo(0, gy2); ctx.lineTo(W, gy2);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Center baseline
|
|
||||||
ctx.strokeStyle = 'rgba(60, 60, 100, 0.5)';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, midY);
|
|
||||||
ctx.lineTo(W, midY);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Waveform (mirrored)
|
|
||||||
const stepX = W / SCOPE_HISTORY_LEN;
|
|
||||||
ctx.strokeStyle = '#0ff';
|
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
ctx.shadowColor = '#0ff';
|
|
||||||
ctx.shadowBlur = 4;
|
|
||||||
|
|
||||||
// Upper half
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < pagerScopeHistory.length; i++) {
|
|
||||||
const x = i * stepX;
|
|
||||||
const amp = pagerScopeHistory[i] * midY * 0.9;
|
|
||||||
const y = midY - amp;
|
|
||||||
if (i === 0) ctx.moveTo(x, y);
|
|
||||||
else ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Lower half (mirror)
|
|
||||||
ctx.beginPath();
|
|
||||||
for (let i = 0; i < pagerScopeHistory.length; i++) {
|
|
||||||
const x = i * stepX;
|
|
||||||
const amp = pagerScopeHistory[i] * midY * 0.9;
|
|
||||||
const y = midY + amp;
|
|
||||||
if (i === 0) ctx.moveTo(x, y);
|
|
||||||
else ctx.lineTo(x, y);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
|
|
||||||
// Peak indicator (dashed red line)
|
|
||||||
const peakNorm = Math.min(pagerScopePeak / 32768, 1.0);
|
|
||||||
if (peakNorm > 0.01) {
|
|
||||||
const peakY = midY - peakNorm * midY * 0.9;
|
|
||||||
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.setLineDash([4, 4]);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, peakY);
|
|
||||||
ctx.lineTo(W, peakY);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message decode flash (green overlay)
|
|
||||||
if (pagerScopeMsgBurst > 0.01) {
|
|
||||||
ctx.fillStyle = `rgba(0, 255, 100, ${pagerScopeMsgBurst * 0.15})`;
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
pagerScopeMsgBurst *= 0.88;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update labels
|
|
||||||
const rmsLabel = document.getElementById('scopeRmsLabel');
|
|
||||||
const peakLabel = document.getElementById('scopePeakLabel');
|
|
||||||
const statusLabel = document.getElementById('scopeStatusLabel');
|
|
||||||
if (rmsLabel) rmsLabel.textContent = Math.round(pagerScopeRms);
|
|
||||||
if (peakLabel) peakLabel.textContent = Math.round(pagerScopePeak);
|
|
||||||
if (statusLabel) {
|
|
||||||
if (pagerScopeRms > 500) {
|
|
||||||
statusLabel.textContent = 'SIGNAL';
|
|
||||||
statusLabel.style.color = '#0f0';
|
|
||||||
} else {
|
|
||||||
statusLabel.textContent = 'MONITORING';
|
|
||||||
statusLabel.style.color = '#555';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pagerScopeAnim = requestAnimationFrame(drawPagerScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPagerScope() {
|
|
||||||
if (pagerScopeAnim) {
|
|
||||||
cancelAnimationFrame(pagerScopeAnim);
|
|
||||||
pagerScopeAnim = null;
|
|
||||||
}
|
|
||||||
pagerScopeCtx = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startDecoding() {
|
function startDecoding() {
|
||||||
const freq = document.getElementById('frequency').value;
|
const freq = document.getElementById('frequency').value;
|
||||||
const gain = document.getElementById('gain').value;
|
const gain = document.getElementById('gain').value;
|
||||||
@@ -4775,7 +4333,7 @@
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
showInfo('All processes stopped' + (data.processes.length ? ` (${data.processes.length} killed)` : ' (none were running)'));
|
showInfo('Killed all processes: ' + (data.processes.length ? data.processes.join(', ') : 'none running'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4826,18 +4384,6 @@
|
|||||||
document.getElementById('statusText').textContent = running ? 'Decoding...' : 'Idle';
|
document.getElementById('statusText').textContent = running ? 'Decoding...' : 'Idle';
|
||||||
document.getElementById('startBtn').style.display = running ? 'none' : 'block';
|
document.getElementById('startBtn').style.display = running ? 'none' : 'block';
|
||||||
document.getElementById('stopBtn').style.display = running ? 'block' : 'none';
|
document.getElementById('stopBtn').style.display = running ? 'block' : 'none';
|
||||||
|
|
||||||
// Signal scope
|
|
||||||
const scopePanel = document.getElementById('pagerScopePanel');
|
|
||||||
if (scopePanel) {
|
|
||||||
if (running) {
|
|
||||||
scopePanel.style.display = 'block';
|
|
||||||
initPagerScope();
|
|
||||||
} else {
|
|
||||||
stopPagerScope();
|
|
||||||
scopePanel.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startStream(isAgentMode = false) {
|
function startStream(isAgentMode = false) {
|
||||||
@@ -4873,9 +4419,6 @@
|
|||||||
}
|
}
|
||||||
} else if (payload.type === 'info') {
|
} else if (payload.type === 'info') {
|
||||||
showInfo(`[${data.agent_name}] ${payload.text}`);
|
showInfo(`[${data.agent_name}] ${payload.text}`);
|
||||||
} else if (payload.type === 'scope') {
|
|
||||||
pagerScopeTargetRms = payload.rms;
|
|
||||||
pagerScopeTargetPeak = payload.peak;
|
|
||||||
}
|
}
|
||||||
} else if (data.type === 'keepalive') {
|
} else if (data.type === 'keepalive') {
|
||||||
// Ignore keepalive messages
|
// Ignore keepalive messages
|
||||||
@@ -4894,9 +4437,6 @@
|
|||||||
showInfo(data.text);
|
showInfo(data.text);
|
||||||
} else if (data.type === 'raw') {
|
} else if (data.type === 'raw') {
|
||||||
showInfo(data.text);
|
showInfo(data.text);
|
||||||
} else if (data.type === 'scope') {
|
|
||||||
pagerScopeTargetRms = data.rms;
|
|
||||||
pagerScopeTargetPeak = data.peak;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -5004,9 +4544,6 @@
|
|||||||
// Update signal meter
|
// Update signal meter
|
||||||
pulseSignal();
|
pulseSignal();
|
||||||
|
|
||||||
// Flash signal scope green on decode
|
|
||||||
pagerScopeMsgBurst = 1.0;
|
|
||||||
|
|
||||||
// Use SignalCards component to create the message card (auto-detects status)
|
// Use SignalCards component to create the message card (auto-detects status)
|
||||||
const msgEl = SignalCards.createPagerCard(msg);
|
const msgEl = SignalCards.createPagerCard(msg);
|
||||||
|
|
||||||
@@ -6510,44 +6047,11 @@
|
|||||||
: 'Monitor mode: <span style="color: var(--accent-red);">Inactive</span>';
|
: 'Monitor mode: <span style="color: var(--accent-red);">Inactive</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWifiChannelPresetList(preset) {
|
|
||||||
switch (preset) {
|
|
||||||
case '2.4-common':
|
|
||||||
return '1,6,11';
|
|
||||||
case '2.4-all':
|
|
||||||
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
|
|
||||||
case '5-low':
|
|
||||||
return '36,40,44,48';
|
|
||||||
case '5-mid':
|
|
||||||
return '52,56,60,64';
|
|
||||||
case '5-high':
|
|
||||||
return '149,153,157,161,165';
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWifiChannelConfig() {
|
|
||||||
const preset = document.getElementById('wifiChannelPreset')?.value || '';
|
|
||||||
const listInput = document.getElementById('wifiChannelList')?.value || '';
|
|
||||||
const singleInput = document.getElementById('wifiChannel')?.value || '';
|
|
||||||
|
|
||||||
const listValue = listInput.trim();
|
|
||||||
const presetValue = getWifiChannelPresetList(preset);
|
|
||||||
const channels = listValue || presetValue || '';
|
|
||||||
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
|
|
||||||
|
|
||||||
return {
|
|
||||||
channels: channels || null,
|
|
||||||
channel: Number.isFinite(channel) ? channel : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start WiFi scan - auto-enables monitor mode if needed
|
// Start WiFi scan - auto-enables monitor mode if needed
|
||||||
async function startWifiScan() {
|
async function startWifiScan() {
|
||||||
console.log('startWifiScan called');
|
console.log('startWifiScan called');
|
||||||
const band = document.getElementById('wifiBand').value;
|
const band = document.getElementById('wifiBand').value;
|
||||||
const channelConfig = buildWifiChannelConfig();
|
const channel = document.getElementById('wifiChannel').value;
|
||||||
|
|
||||||
// Auto-enable monitor mode if not already enabled
|
// Auto-enable monitor mode if not already enabled
|
||||||
if (!monitorInterface) {
|
if (!monitorInterface) {
|
||||||
@@ -6609,8 +6113,7 @@
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
interface: monitorInterface,
|
interface: monitorInterface,
|
||||||
band: band,
|
band: band,
|
||||||
channel: channelConfig.channel,
|
channel: channel || null
|
||||||
channels: channelConfig.channels,
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const scanData = await scanResp.json();
|
const scanData = await scanResp.json();
|
||||||
@@ -7307,7 +6810,7 @@
|
|||||||
|
|
||||||
if (data.handshake_found) {
|
if (data.handshake_found) {
|
||||||
// Handshake captured!
|
// Handshake captured!
|
||||||
statusSpan.textContent = '✓ VALID HANDSHAKE CAPTURED!';
|
statusSpan.textContent = '✓ HANDSHAKE CAPTURED!';
|
||||||
statusSpan.style.color = 'var(--accent-green)';
|
statusSpan.style.color = 'var(--accent-green)';
|
||||||
handshakeCount++;
|
handshakeCount++;
|
||||||
document.getElementById('handshakeCount').textContent = handshakeCount;
|
document.getElementById('handshakeCount').textContent = handshakeCount;
|
||||||
@@ -7340,11 +6843,7 @@
|
|||||||
activeCapture.capturedFile = data.file;
|
activeCapture.capturedFile = data.file;
|
||||||
} else if (data.file_exists) {
|
} else if (data.file_exists) {
|
||||||
const sizeKB = (data.file_size / 1024).toFixed(1);
|
const sizeKB = (data.file_size / 1024).toFixed(1);
|
||||||
let extra = '';
|
statusSpan.textContent = 'Capturing... (' + sizeKB + ' KB, ' + elapsedStr + ')';
|
||||||
if (data.handshake_checked && data.handshake_valid === false) {
|
|
||||||
extra = data.handshake_reason ? ' • ' + data.handshake_reason : ' • No valid handshake yet';
|
|
||||||
}
|
|
||||||
statusSpan.textContent = 'Capturing... (' + sizeKB + ' KB, ' + elapsedStr + ')' + extra;
|
|
||||||
statusSpan.style.color = 'var(--accent-orange)';
|
statusSpan.style.color = 'var(--accent-orange)';
|
||||||
} else if (data.status === 'stopped') {
|
} else if (data.status === 'stopped') {
|
||||||
statusSpan.textContent = 'Capture stopped';
|
statusSpan.textContent = 'Capture stopped';
|
||||||
@@ -10280,7 +9779,6 @@
|
|||||||
let tscmEventSource = null;
|
let tscmEventSource = null;
|
||||||
let tscmThreats = [];
|
let tscmThreats = [];
|
||||||
let tscmWifiDevices = [];
|
let tscmWifiDevices = [];
|
||||||
let tscmWifiClients = [];
|
|
||||||
let tscmBtDevices = [];
|
let tscmBtDevices = [];
|
||||||
let tscmBaselineComparison = null;
|
let tscmBaselineComparison = null;
|
||||||
let tscmIdentityClusters = [];
|
let tscmIdentityClusters = [];
|
||||||
@@ -10515,7 +10013,6 @@
|
|||||||
// Reset displays
|
// Reset displays
|
||||||
tscmThreats = [];
|
tscmThreats = [];
|
||||||
tscmWifiDevices = [];
|
tscmWifiDevices = [];
|
||||||
tscmWifiClients = [];
|
|
||||||
tscmBtDevices = [];
|
tscmBtDevices = [];
|
||||||
tscmRfSignals = [];
|
tscmRfSignals = [];
|
||||||
tscmRfStatusMessage = null;
|
tscmRfStatusMessage = null;
|
||||||
@@ -11207,14 +10704,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (data.wifi_clients) {
|
|
||||||
data.wifi_clients.forEach(client => {
|
|
||||||
const clientMac = client.mac || client.address;
|
|
||||||
if (!tscmWifiClients.find(d => (d.mac || d.address) === clientMac)) {
|
|
||||||
handleTscmEvent({ type: 'wifi_client', ...client });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process Bluetooth devices
|
// Process Bluetooth devices
|
||||||
if (data.bt_devices) {
|
if (data.bt_devices) {
|
||||||
@@ -11270,9 +10759,6 @@
|
|||||||
case 'wifi_device':
|
case 'wifi_device':
|
||||||
addTscmWifiDevice(data);
|
addTscmWifiDevice(data);
|
||||||
break;
|
break;
|
||||||
case 'wifi_client':
|
|
||||||
addTscmWifiClient(data);
|
|
||||||
break;
|
|
||||||
case 'bt_device':
|
case 'bt_device':
|
||||||
addTscmBtDevice(data);
|
addTscmBtDevice(data);
|
||||||
break;
|
break;
|
||||||
@@ -11382,29 +10868,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTscmWifiClient(client) {
|
|
||||||
const mac = client.mac || client.address || '';
|
|
||||||
if (!mac) return;
|
|
||||||
const exists = tscmWifiClients.some(d => (d.mac || d.address) === mac);
|
|
||||||
if (!exists) {
|
|
||||||
if (!client.mac) client.mac = mac;
|
|
||||||
client.is_client = true;
|
|
||||||
tscmWifiClients.push(client);
|
|
||||||
updateTscmDisplays();
|
|
||||||
updateTscmThreatCounts();
|
|
||||||
if (client.score >= 3) {
|
|
||||||
addHighInterestDevice(client, 'wifi');
|
|
||||||
}
|
|
||||||
if (isRecordingBaseline) {
|
|
||||||
fetch('/tscm/feed/wifi', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(client)
|
|
||||||
}).catch(e => console.error('Baseline feed error:', e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addTscmBtDevice(device) {
|
function addTscmBtDevice(device) {
|
||||||
const mac = device.mac || device.address || '';
|
const mac = device.mac || device.address || '';
|
||||||
// Check if already exists
|
// Check if already exists
|
||||||
@@ -11604,12 +11067,10 @@
|
|||||||
const showRf = protocol === 'all' || protocol === 'rf';
|
const showRf = protocol === 'all' || protocol === 'rf';
|
||||||
|
|
||||||
const wifiPanel = document.getElementById('tscmWifiPanel');
|
const wifiPanel = document.getElementById('tscmWifiPanel');
|
||||||
const wifiClientPanel = document.getElementById('tscmWifiClientPanel');
|
|
||||||
const btPanel = document.getElementById('tscmBtPanel');
|
const btPanel = document.getElementById('tscmBtPanel');
|
||||||
const rfPanel = document.getElementById('tscmRfPanel');
|
const rfPanel = document.getElementById('tscmRfPanel');
|
||||||
|
|
||||||
if (wifiPanel) wifiPanel.style.display = showWifi ? '' : 'none';
|
if (wifiPanel) wifiPanel.style.display = showWifi ? '' : 'none';
|
||||||
if (wifiClientPanel) wifiClientPanel.style.display = showWifi ? '' : 'none';
|
|
||||||
if (btPanel) btPanel.style.display = showBt ? '' : 'none';
|
if (btPanel) btPanel.style.display = showBt ? '' : 'none';
|
||||||
if (rfPanel) rfPanel.style.display = showRf ? '' : 'none';
|
if (rfPanel) rfPanel.style.display = showRf ? '' : 'none';
|
||||||
}
|
}
|
||||||
@@ -11632,15 +11093,13 @@
|
|||||||
|
|
||||||
function getFilteredDevices(options = {}) {
|
function getFilteredDevices(options = {}) {
|
||||||
const wifi = tscmWifiDevices.filter(d => matchesTscmFilters(d, 'wifi', options));
|
const wifi = tscmWifiDevices.filter(d => matchesTscmFilters(d, 'wifi', options));
|
||||||
const wifi_clients = tscmWifiClients.filter(d => matchesTscmFilters(d, 'wifi', options));
|
|
||||||
const bt = tscmBtDevices.filter(d => matchesTscmFilters(d, 'bluetooth', options));
|
const bt = tscmBtDevices.filter(d => matchesTscmFilters(d, 'bluetooth', options));
|
||||||
const rf = tscmRfSignals.filter(d => matchesTscmFilters(d, 'rf', options));
|
const rf = tscmRfSignals.filter(d => matchesTscmFilters(d, 'rf', options));
|
||||||
return {
|
return {
|
||||||
wifi,
|
wifi,
|
||||||
wifi_clients,
|
|
||||||
bt,
|
bt,
|
||||||
rf,
|
rf,
|
||||||
all: [...wifi, ...wifi_clients, ...bt, ...rf],
|
all: [...wifi, ...bt, ...rf],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11706,18 +11165,6 @@
|
|||||||
return indicators.map(i => `<span class="indicator-tag">${escapeHtml(i.desc || i.type)}</span>`).join(' ');
|
return indicators.map(i => `<span class="indicator-tag">${escapeHtml(i.desc || i.type)}</span>`).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTrackerLabel(device) {
|
|
||||||
if (!device) return null;
|
|
||||||
return (device.tracker && (device.tracker.name || device.tracker.type)) ||
|
|
||||||
device.tracker_type || device.tracker_name || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTrackerBadge(device) {
|
|
||||||
const label = getTrackerLabel(device);
|
|
||||||
if (!label) return '';
|
|
||||||
return `<span class="tracker-badge" title="Tracker">${escapeHtml(label)}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getScoreBadge(score) {
|
function getScoreBadge(score) {
|
||||||
if (score === undefined || score === null) return '';
|
if (score === undefined || score === null) return '';
|
||||||
let scoreClass = 'score-low';
|
let scoreClass = 'score-low';
|
||||||
@@ -11730,7 +11177,6 @@
|
|||||||
function getAllTscmDevices() {
|
function getAllTscmDevices() {
|
||||||
const devices = {};
|
const devices = {};
|
||||||
tscmWifiDevices.forEach(d => { devices[`wifi:${d.bssid}`] = { ...d, protocol: 'wifi' }; });
|
tscmWifiDevices.forEach(d => { devices[`wifi:${d.bssid}`] = { ...d, protocol: 'wifi' }; });
|
||||||
tscmWifiClients.forEach(d => { devices[`wifi:${d.mac}`] = { ...d, protocol: 'wifi' }; });
|
|
||||||
tscmBtDevices.forEach(d => { devices[`bluetooth:${d.mac}`] = { ...d, protocol: 'bluetooth' }; });
|
tscmBtDevices.forEach(d => { devices[`bluetooth:${d.mac}`] = { ...d, protocol: 'bluetooth' }; });
|
||||||
tscmRfSignals.forEach(d => { devices[`rf:${d.frequency}`] = { ...d, protocol: 'rf' }; });
|
tscmRfSignals.forEach(d => { devices[`rf:${d.frequency}`] = { ...d, protocol: 'rf' }; });
|
||||||
return devices;
|
return devices;
|
||||||
@@ -11774,32 +11220,18 @@
|
|||||||
|
|
||||||
// Add device-specific fields
|
// Add device-specific fields
|
||||||
if (protocol === 'wifi') {
|
if (protocol === 'wifi') {
|
||||||
if (device.is_client) {
|
html += `
|
||||||
html += `
|
<tr><td>BSSID</td><td>${device.bssid || 'Unknown'}</td></tr>
|
||||||
<tr><td>Client MAC</td><td>${device.mac || 'Unknown'}</td></tr>
|
<tr><td>SSID</td><td>${escapeHtml(device.ssid || '[Hidden]')}</td></tr>
|
||||||
<tr><td>Vendor</td><td>${escapeHtml(device.vendor || 'Unknown')}</td></tr>
|
<tr><td>Channel</td><td>${device.channel || 'Unknown'}</td></tr>
|
||||||
<tr><td>RSSI</td><td>${device.rssi || '--'} dBm</td></tr>
|
<tr><td>Signal</td><td>${device.signal || '--'} dBm</td></tr>
|
||||||
<tr><td>Associated BSSID</td><td>${device.associated_bssid || 'Unassociated'}</td></tr>
|
<tr><td>Security</td><td>${device.security || 'Unknown'}</td></tr>
|
||||||
<tr><td>Probed SSIDs</td><td>${device.probe_count || (device.probed_ssids ? device.probed_ssids.length : 0)}</td></tr>
|
`;
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
html += `
|
|
||||||
<tr><td>BSSID</td><td>${device.bssid || 'Unknown'}</td></tr>
|
|
||||||
<tr><td>SSID</td><td>${escapeHtml(device.ssid || '[Hidden]')}</td></tr>
|
|
||||||
<tr><td>Vendor</td><td>${escapeHtml(device.vendor || 'Unknown')}</td></tr>
|
|
||||||
<tr><td>Channel</td><td>${device.channel || 'Unknown'}</td></tr>
|
|
||||||
<tr><td>Signal</td><td>${device.signal || '--'} dBm</td></tr>
|
|
||||||
<tr><td>Security</td><td>${device.security || 'Unknown'}</td></tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
} else if (protocol === 'bluetooth') {
|
} else if (protocol === 'bluetooth') {
|
||||||
const trackerLabel = getTrackerLabel(device);
|
|
||||||
html += `
|
html += `
|
||||||
<tr><td>MAC Address</td><td>${device.mac || 'Unknown'}</td></tr>
|
<tr><td>MAC Address</td><td>${device.mac || 'Unknown'}</td></tr>
|
||||||
<tr><td>Name</td><td>${escapeHtml(device.name || 'Unknown')}</td></tr>
|
<tr><td>Name</td><td>${escapeHtml(device.name || 'Unknown')}</td></tr>
|
||||||
<tr><td>Type</td><td>${device.device_type || 'Unknown'}</td></tr>
|
<tr><td>Type</td><td>${device.device_type || 'Unknown'}</td></tr>
|
||||||
<tr><td>Manufacturer</td><td>${escapeHtml(device.manufacturer || 'Unknown')}</td></tr>
|
|
||||||
<tr><td>Tracker</td><td>${trackerLabel ? escapeHtml(trackerLabel) : 'No'}</td></tr>
|
|
||||||
<tr><td>RSSI</td><td>${device.rssi || '--'} dBm</td></tr>
|
<tr><td>RSSI</td><td>${device.rssi || '--'} dBm</td></tr>
|
||||||
<tr><td>Audio Capable</td><td>${device.is_audio_capable ? 'Yes' : 'No'}</td></tr>
|
<tr><td>Audio Capable</td><td>${device.is_audio_capable ? 'Yes' : 'No'}</td></tr>
|
||||||
`;
|
`;
|
||||||
@@ -12132,8 +11564,6 @@
|
|||||||
<tr><td>Name</td><td>${escapeHtml(profile.name || 'N/A')}</td></tr>
|
<tr><td>Name</td><td>${escapeHtml(profile.name || 'N/A')}</td></tr>
|
||||||
<tr><td>Manufacturer</td><td>${escapeHtml(profile.manufacturer || 'N/A')}</td></tr>
|
<tr><td>Manufacturer</td><td>${escapeHtml(profile.manufacturer || 'N/A')}</td></tr>
|
||||||
<tr><td>Device Type</td><td>${escapeHtml(profile.device_type || 'N/A')}</td></tr>
|
<tr><td>Device Type</td><td>${escapeHtml(profile.device_type || 'N/A')}</td></tr>
|
||||||
${(profile.tracker_name || profile.tracker_type) ? `<tr><td>Tracker</td><td>${escapeHtml(profile.tracker_name || profile.tracker_type)}</td></tr>` : ''}
|
|
||||||
${profile.tracker_confidence ? `<tr><td>Tracker Confidence</td><td>${escapeHtml(profile.tracker_confidence)}</td></tr>` : ''}
|
|
||||||
<tr><td>First Seen</td><td>${profile.first_seen ? new Date(profile.first_seen).toLocaleString() : 'N/A'}</td></tr>
|
<tr><td>First Seen</td><td>${profile.first_seen ? new Date(profile.first_seen).toLocaleString() : 'N/A'}</td></tr>
|
||||||
<tr><td>Last Seen</td><td>${profile.last_seen ? new Date(profile.last_seen).toLocaleString() : 'N/A'}</td></tr>
|
<tr><td>Last Seen</td><td>${profile.last_seen ? new Date(profile.last_seen).toLocaleString() : 'N/A'}</td></tr>
|
||||||
<tr><td>Detections</td><td>${profile.detection_count || 0}</td></tr>
|
<tr><td>Detections</td><td>${profile.detection_count || 0}</td></tr>
|
||||||
@@ -12467,14 +11897,6 @@
|
|||||||
const section = document.getElementById('tscmWifiAdvancedSection');
|
const section = document.getElementById('tscmWifiAdvancedSection');
|
||||||
if (!section) return;
|
if (!section) return;
|
||||||
|
|
||||||
if (device && device.is_client) {
|
|
||||||
section.innerHTML = `
|
|
||||||
<h4>WiFi Advanced Indicators</h4>
|
|
||||||
<div class="tscm-empty">Client devices do not have AP indicators.</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
bssid: device.bssid,
|
bssid: device.bssid,
|
||||||
@@ -12828,11 +12250,6 @@
|
|||||||
const id = item.bssid || item.mac || '';
|
const id = item.bssid || item.mac || '';
|
||||||
return `${escapeHtml(name)} ${id ? `<span class="device-detail-id">${escapeHtml(id)}</span>` : ''}`;
|
return `${escapeHtml(name)} ${id ? `<span class="device-detail-id">${escapeHtml(id)}</span>` : ''}`;
|
||||||
}
|
}
|
||||||
if (protocol === 'wifi_clients') {
|
|
||||||
const name = item.vendor || 'WiFi Client';
|
|
||||||
const id = item.mac || item.address || '';
|
|
||||||
return `${escapeHtml(name)} ${id ? `<span class="device-detail-id">${escapeHtml(id)}</span>` : ''}`;
|
|
||||||
}
|
|
||||||
if (protocol === 'bluetooth') {
|
if (protocol === 'bluetooth') {
|
||||||
const name = item.name || 'Unknown';
|
const name = item.name || 'Unknown';
|
||||||
const id = item.mac || item.address || '';
|
const id = item.mac || item.address || '';
|
||||||
@@ -12859,7 +12276,6 @@
|
|||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
{ key: 'wifi', label: 'WiFi' },
|
{ key: 'wifi', label: 'WiFi' },
|
||||||
{ key: 'wifi_clients', label: 'WiFi Clients' },
|
|
||||||
{ key: 'bluetooth', label: 'Bluetooth' },
|
{ key: 'bluetooth', label: 'Bluetooth' },
|
||||||
{ key: 'rf', label: 'RF' },
|
{ key: 'rf', label: 'RF' },
|
||||||
];
|
];
|
||||||
@@ -12925,7 +12341,7 @@
|
|||||||
<div class="tscm-device-meta">
|
<div class="tscm-device-meta">
|
||||||
<span>${d.bssid}</span>
|
<span>${d.bssid}</span>
|
||||||
<span>${d.signal || '--'} dBm</span>
|
<span>${d.signal || '--'} dBm</span>
|
||||||
<span>${escapeHtml(d.vendor || 'Unknown')} • ${escapeHtml(d.security || 'Open')}</span>
|
<span>${d.security || 'Open'}</span>
|
||||||
</div>
|
</div>
|
||||||
${d.indicators && d.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(d.indicators)}</div>` : ''}
|
${d.indicators && d.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(d.indicators)}</div>` : ''}
|
||||||
${d.recommended_action && d.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${d.recommended_action}</div>` : ''}
|
${d.recommended_action && d.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${d.recommended_action}</div>` : ''}
|
||||||
@@ -12934,35 +12350,6 @@
|
|||||||
}
|
}
|
||||||
document.getElementById('tscmWifiCount').textContent = filtered.wifi.length;
|
document.getElementById('tscmWifiCount').textContent = filtered.wifi.length;
|
||||||
|
|
||||||
// Update WiFi clients list
|
|
||||||
const wifiClientList = document.getElementById('tscmWifiClientList');
|
|
||||||
if (filtered.wifi_clients.length === 0) {
|
|
||||||
wifiClientList.innerHTML = `<div class="tscm-empty">${filtersActive ? 'No WiFi clients match filters' : 'No WiFi clients detected'}</div>`;
|
|
||||||
} else {
|
|
||||||
const sortedClients = [...filtered.wifi_clients].sort((a, b) => (b.score || 0) - (a.score || 0));
|
|
||||||
wifiClientList.innerHTML = sortedClients.map(c => `
|
|
||||||
<div class="tscm-device-item ${getClassificationClass(c.classification)}" onclick="showDeviceDetails('${c.mac}', 'wifi')">
|
|
||||||
<div class="tscm-device-header">
|
|
||||||
<div class="tscm-device-name">
|
|
||||||
<span class="classification-indicator">${getClassificationIcon(c.classification)}</span>
|
|
||||||
${escapeHtml(c.vendor || 'WiFi Client')}
|
|
||||||
<span class="client-badge" title="WiFi client">CLIENT</span>
|
|
||||||
${c.known_device ? '<span class="known-badge" title="Known device">KNOWN</span>' : ''}
|
|
||||||
</div>
|
|
||||||
${getScoreBadge(c.score)}
|
|
||||||
</div>
|
|
||||||
<div class="tscm-device-meta">
|
|
||||||
<span>${c.mac}</span>
|
|
||||||
<span>${c.rssi || '--'} dBm</span>
|
|
||||||
<span>${c.associated_bssid ? `Assoc: ${c.associated_bssid}` : `Probes: ${c.probe_count || 0}`}</span>
|
|
||||||
</div>
|
|
||||||
${c.indicators && c.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(c.indicators)}</div>` : ''}
|
|
||||||
${c.recommended_action && c.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${c.recommended_action}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
document.getElementById('tscmWifiClientCount').textContent = filtered.wifi_clients.length;
|
|
||||||
|
|
||||||
// Update BT list
|
// Update BT list
|
||||||
const btList = document.getElementById('tscmBtList');
|
const btList = document.getElementById('tscmBtList');
|
||||||
if (filtered.bt.length === 0) {
|
if (filtered.bt.length === 0) {
|
||||||
@@ -12977,7 +12364,6 @@
|
|||||||
<span class="classification-indicator">${getClassificationIcon(d.classification)}</span>
|
<span class="classification-indicator">${getClassificationIcon(d.classification)}</span>
|
||||||
${escapeHtml(d.name || 'Unknown')}
|
${escapeHtml(d.name || 'Unknown')}
|
||||||
${d.is_audio_capable ? '<span class="audio-badge" title="Audio-capable device">AUDIO</span>' : ''}
|
${d.is_audio_capable ? '<span class="audio-badge" title="Audio-capable device">AUDIO</span>' : ''}
|
||||||
${formatTrackerBadge(d)}
|
|
||||||
${d.known_device ? '<span class="known-badge" title="Known device">KNOWN</span>' : ''}
|
${d.known_device ? '<span class="known-badge" title="Known device">KNOWN</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
${getScoreBadge(d.score)}
|
${getScoreBadge(d.score)}
|
||||||
@@ -12985,7 +12371,7 @@
|
|||||||
<div class="tscm-device-meta">
|
<div class="tscm-device-meta">
|
||||||
<span>${d.mac}</span>
|
<span>${d.mac}</span>
|
||||||
<span>${d.rssi || '--'} dBm</span>
|
<span>${d.rssi || '--'} dBm</span>
|
||||||
<span>${escapeHtml([d.device_type, d.manufacturer].filter(Boolean).join(' • ') || 'Unknown')}</span>
|
<span>${d.device_type || 'Unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
${d.indicators && d.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(d.indicators)}</div>` : ''}
|
${d.indicators && d.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(d.indicators)}</div>` : ''}
|
||||||
${d.recommended_action && d.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${d.recommended_action}</div>` : ''}
|
${d.recommended_action && d.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${d.recommended_action}</div>` : ''}
|
||||||
@@ -13149,10 +12535,6 @@
|
|||||||
const identityClusters = data.identity_clusters || (tscmIdentitySummary ? tscmIdentitySummary.total : 0);
|
const identityClusters = data.identity_clusters || (tscmIdentitySummary ? tscmIdentitySummary.total : 0);
|
||||||
const baselineNew = data.baseline_new_devices || 0;
|
const baselineNew = data.baseline_new_devices || 0;
|
||||||
const baselineMissing = data.baseline_missing_devices || 0;
|
const baselineMissing = data.baseline_missing_devices || 0;
|
||||||
const wifiCount = data.wifi_count ?? tscmWifiDevices.length;
|
|
||||||
const wifiClientCount = data.wifi_client_count ?? tscmWifiClients.length;
|
|
||||||
const btCount = data.bt_count ?? tscmBtDevices.length;
|
|
||||||
const rfCount = data.rf_count ?? tscmRfSignals.length;
|
|
||||||
|
|
||||||
let assessment = 'BASELINE ENVIRONMENT';
|
let assessment = 'BASELINE ENVIRONMENT';
|
||||||
let assessmentClass = 'informational';
|
let assessmentClass = 'informational';
|
||||||
@@ -13192,9 +12574,6 @@
|
|||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="tscm-summary-meta" style="margin-top: 8px; font-size: 10px; color: var(--text-muted);">
|
|
||||||
Devices: ${wifiCount} WiFi AP • ${wifiClientCount} WiFi Clients • ${btCount} BT • ${rfCount} RF
|
|
||||||
</div>
|
|
||||||
<div class="tscm-assessment ${assessmentClass}">
|
<div class="tscm-assessment ${assessmentClass}">
|
||||||
<strong>Assessment:</strong> ${assessment}
|
<strong>Assessment:</strong> ${assessment}
|
||||||
</div>
|
</div>
|
||||||
@@ -13262,7 +12641,7 @@
|
|||||||
|
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
document.getElementById('tscmBaselineStatus').textContent =
|
document.getElementById('tscmBaselineStatus').textContent =
|
||||||
`Baseline saved: ${data.wifi_count} WiFi, ${data.wifi_client_count || 0} Clients, ${data.bt_count} BT, ${data.rf_count} RF`;
|
`Baseline saved: ${data.wifi_count} WiFi, ${data.bt_count} BT, ${data.rf_count} RF`;
|
||||||
document.getElementById('tscmBaselineStatus').style.color = '#00ff88';
|
document.getElementById('tscmBaselineStatus').style.color = '#00ff88';
|
||||||
loadTscmBaselines();
|
loadTscmBaselines();
|
||||||
} else {
|
} else {
|
||||||
@@ -15077,9 +14456,6 @@
|
|||||||
<script src="{{ url_for('static', filename='js/core/updater.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/updater.js') }}"></script>
|
||||||
<!-- Settings Manager -->
|
<!-- Settings Manager -->
|
||||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
||||||
<!-- Alerts + Recording -->
|
|
||||||
<script src="{{ url_for('static', filename='js/core/alerts.js') }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='js/core/recordings.js') }}"></script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -64,4 +64,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Waterfall Controls -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Waterfall</h3>
|
||||||
|
<div class="form-group" style="margin-bottom: 6px;">
|
||||||
|
<label style="font-size: 10px;">Start (MHz)</label>
|
||||||
|
<input type="number" id="waterfallStartFreq" value="88" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom: 6px;">
|
||||||
|
<label style="font-size: 10px;">End (MHz)</label>
|
||||||
|
<input type="number" id="waterfallEndFreq" value="108" step="0.1" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom: 6px;">
|
||||||
|
<label style="font-size: 10px;">Bin Size</label>
|
||||||
|
<select id="waterfallBinSize" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
||||||
|
<option value="5000">5 kHz</option>
|
||||||
|
<option value="10000" selected>10 kHz</option>
|
||||||
|
<option value="25000">25 kHz</option>
|
||||||
|
<option value="100000">100 kHz</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom: 8px;">
|
||||||
|
<label style="font-size: 10px;">Gain</label>
|
||||||
|
<input type="number" id="waterfallGain" value="40" min="0" max="50" style="width: 100%; padding: 5px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
||||||
|
</div>
|
||||||
|
<button class="run-btn" id="startWaterfallBtn" onclick="startWaterfall()" style="width: 100%; padding: 8px;">Start Waterfall</button>
|
||||||
|
<button class="stop-btn" id="stopWaterfallBtn" onclick="stopWaterfall()" style="display: none; width: 100%; padding: 8px; margin-top: 4px;">Stop Waterfall</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,22 +69,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Channel Preset</label>
|
<label>Channel (empty = hop)</label>
|
||||||
<select id="wifiChannelPreset">
|
|
||||||
<option value="">Auto hop (all)</option>
|
|
||||||
<option value="2.4-common">2.4 GHz Common (1,6,11)</option>
|
|
||||||
<option value="2.4-all">2.4 GHz All (1-13)</option>
|
|
||||||
<option value="5-low">5 GHz Low (36-48)</option>
|
|
||||||
<option value="5-mid">5 GHz Mid/DFS (52-64)</option>
|
|
||||||
<option value="5-high">5 GHz High (149-165)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Channel List (overrides preset)</label>
|
|
||||||
<input type="text" id="wifiChannelList" placeholder="e.g., 1,6,11 or 36,40,44,48">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Channel (single)</label>
|
|
||||||
<input type="text" id="wifiChannel" placeholder="e.g., 6 or 36">
|
<input type="text" id="wifiChannel" placeholder="e.g., 6 or 36">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,6 @@
|
|||||||
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
|
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
|
||||||
<button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button>
|
<button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button>
|
||||||
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button>
|
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button>
|
||||||
<button class="settings-tab" data-tab="alerts" onclick="switchSettingsTab('alerts')">Alerts</button>
|
|
||||||
<button class="settings-tab" data-tab="recording" onclick="switchSettingsTab('recording')">Recording</button>
|
|
||||||
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
|
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -282,83 +280,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alerts Section -->
|
|
||||||
<div id="settings-alerts" class="settings-section">
|
|
||||||
<div class="settings-group">
|
|
||||||
<div class="settings-group-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
|
|
||||||
<div id="alertsFeedList" class="settings-feed">
|
|
||||||
<div class="settings-feed-empty">No alerts yet</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-group">
|
|
||||||
<div class="settings-group-title">Quick Rules</div>
|
|
||||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
|
||||||
<button class="check-assets-btn" onclick="AlertCenter.enableTrackerAlerts()">Enable Tracker Alerts</button>
|
|
||||||
<button class="check-assets-btn" onclick="AlertCenter.disableTrackerAlerts()">Disable Tracker Alerts</button>
|
|
||||||
</div>
|
|
||||||
<div class="settings-info" style="margin-top: 10px;">
|
|
||||||
Use Bluetooth device details to add specific device watchlist alerts.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recording Section -->
|
|
||||||
<div id="settings-recording" class="settings-section">
|
|
||||||
<div class="settings-group">
|
|
||||||
<div class="settings-group-title">Start Recording</div>
|
|
||||||
<div class="settings-row" style="border-bottom: none; padding-top: 0;">
|
|
||||||
<div class="settings-label">
|
|
||||||
<span class="settings-label-text">Mode</span>
|
|
||||||
<span class="settings-label-desc">Record live events for a mode</span>
|
|
||||||
</div>
|
|
||||||
<select id="recordingModeSelect" class="settings-select" style="width: 200px;">
|
|
||||||
<option value="pager">Pager</option>
|
|
||||||
<option value="sensor">433 Sensors</option>
|
|
||||||
<option value="wifi">WiFi</option>
|
|
||||||
<option value="bluetooth">Bluetooth</option>
|
|
||||||
<option value="adsb">ADS-B</option>
|
|
||||||
<option value="ais">AIS</option>
|
|
||||||
<option value="dsc">DSC</option>
|
|
||||||
<option value="acars">ACARS</option>
|
|
||||||
<option value="aprs">APRS</option>
|
|
||||||
<option value="rtlamr">RTLAMR</option>
|
|
||||||
<option value="dmr">DMR</option>
|
|
||||||
<option value="tscm">TSCM</option>
|
|
||||||
<option value="sstv">SSTV</option>
|
|
||||||
<option value="sstv_general">SSTV General</option>
|
|
||||||
<option value="listening_scanner">Listening Post</option>
|
|
||||||
<option value="waterfall">Waterfall</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="settings-row" style="border-bottom: none;">
|
|
||||||
<div class="settings-label">
|
|
||||||
<span class="settings-label-text">Label</span>
|
|
||||||
<span class="settings-label-desc">Optional note for the session</span>
|
|
||||||
</div>
|
|
||||||
<input type="text" id="recordingLabelInput" class="settings-input" placeholder="Morning sweep" style="width: 200px;">
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
|
||||||
<button class="check-assets-btn" onclick="RecordingUI.start()">Start</button>
|
|
||||||
<button class="check-assets-btn" onclick="RecordingUI.stop()">Stop</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-group">
|
|
||||||
<div class="settings-group-title">Active Sessions</div>
|
|
||||||
<div id="recordingActiveList" class="settings-feed">
|
|
||||||
<div class="settings-feed-empty">No active recordings</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-group">
|
|
||||||
<div class="settings-group-title">Recent Recordings</div>
|
|
||||||
<div id="recordingList" class="settings-feed">
|
|
||||||
<div class="settings-feed-empty">No recordings yet</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- About Section -->
|
<!-- About Section -->
|
||||||
<div id="settings-about" class="settings-section">
|
<div id="settings-about" class="settings-section">
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
"""Tests for the waterfall FFT pipeline."""
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from utils.waterfall_fft import (
|
|
||||||
build_binary_frame,
|
|
||||||
compute_power_spectrum,
|
|
||||||
cu8_to_complex,
|
|
||||||
quantize_to_uint8,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCu8ToComplex:
|
|
||||||
"""Tests for cu8_to_complex conversion."""
|
|
||||||
|
|
||||||
def test_zero_maps_to_negative_one(self):
|
|
||||||
# I=0, Q=0 -> approximately -1 - 1j
|
|
||||||
result = cu8_to_complex(bytes([0, 0]))
|
|
||||||
assert result[0].real == pytest.approx(-1.0, abs=0.01)
|
|
||||||
assert result[0].imag == pytest.approx(-1.0, abs=0.01)
|
|
||||||
|
|
||||||
def test_255_maps_to_positive_one(self):
|
|
||||||
# I=255, Q=255 -> approximately +1 + 1j
|
|
||||||
result = cu8_to_complex(bytes([255, 255]))
|
|
||||||
assert result[0].real == pytest.approx(1.0, abs=0.01)
|
|
||||||
assert result[0].imag == pytest.approx(1.0, abs=0.01)
|
|
||||||
|
|
||||||
def test_128_maps_to_near_zero(self):
|
|
||||||
# I=128, Q=128 -> approximately 0 + 0j
|
|
||||||
result = cu8_to_complex(bytes([128, 128]))
|
|
||||||
assert abs(result[0].real) < 0.01
|
|
||||||
assert abs(result[0].imag) < 0.01
|
|
||||||
|
|
||||||
def test_output_length(self):
|
|
||||||
raw = bytes(range(256)) * 4 # 1024 bytes -> 512 complex samples
|
|
||||||
result = cu8_to_complex(raw)
|
|
||||||
assert len(result) == 512
|
|
||||||
|
|
||||||
def test_output_dtype(self):
|
|
||||||
result = cu8_to_complex(bytes([100, 200, 50, 150]))
|
|
||||||
assert result.dtype == np.complex64 or np.issubdtype(result.dtype, np.complexfloating)
|
|
||||||
|
|
||||||
|
|
||||||
class TestComputePowerSpectrum:
|
|
||||||
"""Tests for compute_power_spectrum."""
|
|
||||||
|
|
||||||
def test_output_length_matches_fft_size(self):
|
|
||||||
samples = np.zeros(4096, dtype=np.complex64)
|
|
||||||
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
|
|
||||||
assert len(result) == 1024
|
|
||||||
|
|
||||||
def test_output_dtype(self):
|
|
||||||
samples = np.zeros(4096, dtype=np.complex64)
|
|
||||||
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
|
|
||||||
assert result.dtype == np.float32
|
|
||||||
|
|
||||||
def test_pure_tone_peak_at_correct_bin(self):
|
|
||||||
fft_size = 1024
|
|
||||||
avg_count = 4
|
|
||||||
n = fft_size * avg_count
|
|
||||||
# Generate a pure tone at bin 256 (1/4 of sample rate)
|
|
||||||
t = np.arange(n, dtype=np.float32)
|
|
||||||
freq_bin = 256
|
|
||||||
tone = np.exp(2j * np.pi * freq_bin / fft_size * t).astype(np.complex64)
|
|
||||||
result = compute_power_spectrum(tone, fft_size=fft_size, avg_count=avg_count)
|
|
||||||
# After fftshift, bin 256 maps to index 256 + 512 = 768
|
|
||||||
peak_idx = np.argmax(result)
|
|
||||||
expected_idx = fft_size // 2 + freq_bin
|
|
||||||
assert peak_idx == expected_idx
|
|
||||||
|
|
||||||
def test_insufficient_samples_returns_default(self):
|
|
||||||
# Not enough samples for even one segment
|
|
||||||
samples = np.zeros(100, dtype=np.complex64)
|
|
||||||
result = compute_power_spectrum(samples, fft_size=1024, avg_count=4)
|
|
||||||
assert len(result) == 1024
|
|
||||||
assert np.all(result == -100.0)
|
|
||||||
|
|
||||||
def test_partial_avg_count(self):
|
|
||||||
# Only enough for 2 of 4 requested averages
|
|
||||||
fft_size = 1024
|
|
||||||
samples = np.random.randn(2048).astype(np.float32).view(np.complex64)
|
|
||||||
result = compute_power_spectrum(samples, fft_size=fft_size, avg_count=4)
|
|
||||||
assert len(result) == fft_size
|
|
||||||
# Should still return valid dB values (not -100 default)
|
|
||||||
assert np.any(result != -100.0)
|
|
||||||
|
|
||||||
|
|
||||||
class TestQuantizeToUint8:
|
|
||||||
"""Tests for quantize_to_uint8."""
|
|
||||||
|
|
||||||
def test_db_min_maps_to_zero(self):
|
|
||||||
power = np.array([-90.0], dtype=np.float32)
|
|
||||||
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
|
||||||
assert result[0] == 0
|
|
||||||
|
|
||||||
def test_db_max_maps_to_255(self):
|
|
||||||
power = np.array([-20.0], dtype=np.float32)
|
|
||||||
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
|
||||||
assert result[0] == 255
|
|
||||||
|
|
||||||
def test_below_min_clamped_to_zero(self):
|
|
||||||
power = np.array([-120.0], dtype=np.float32)
|
|
||||||
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
|
||||||
assert result[0] == 0
|
|
||||||
|
|
||||||
def test_above_max_clamped_to_255(self):
|
|
||||||
power = np.array([0.0], dtype=np.float32)
|
|
||||||
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
|
||||||
assert result[0] == 255
|
|
||||||
|
|
||||||
def test_midpoint(self):
|
|
||||||
# Midpoint between -90 and -20 is -55 -> ~127-128
|
|
||||||
power = np.array([-55.0], dtype=np.float32)
|
|
||||||
result = quantize_to_uint8(power, db_min=-90, db_max=-20)
|
|
||||||
assert 125 <= result[0] <= 130
|
|
||||||
|
|
||||||
def test_output_length(self):
|
|
||||||
power = np.random.randn(1024).astype(np.float32) * 30 - 60
|
|
||||||
result = quantize_to_uint8(power)
|
|
||||||
assert len(result) == 1024
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildBinaryFrame:
|
|
||||||
"""Tests for build_binary_frame."""
|
|
||||||
|
|
||||||
def test_header_values(self):
|
|
||||||
bins = bytes([128] * 1024)
|
|
||||||
frame = build_binary_frame(100.0, 102.0, bins)
|
|
||||||
msg_type = frame[0]
|
|
||||||
start_freq, end_freq = struct.unpack_from('<ff', frame, 1)
|
|
||||||
bin_count = struct.unpack_from('<H', frame, 9)[0]
|
|
||||||
assert msg_type == 0x01
|
|
||||||
assert start_freq == pytest.approx(100.0, abs=0.01)
|
|
||||||
assert end_freq == pytest.approx(102.0, abs=0.01)
|
|
||||||
assert bin_count == 1024
|
|
||||||
|
|
||||||
def test_total_length(self):
|
|
||||||
bin_count = 1024
|
|
||||||
bins = bytes([0] * bin_count)
|
|
||||||
frame = build_binary_frame(88.0, 108.0, bins)
|
|
||||||
assert len(frame) == 11 + bin_count
|
|
||||||
|
|
||||||
def test_bins_in_payload(self):
|
|
||||||
bins = bytes(range(256))
|
|
||||||
frame = build_binary_frame(0.0, 1.0, bins)
|
|
||||||
payload = frame[11:]
|
|
||||||
assert payload == bins
|
|
||||||
|
|
||||||
def test_round_trip(self):
|
|
||||||
start = 433.0
|
|
||||||
end = 435.0
|
|
||||||
bins = bytes([i % 256 for i in range(2048)])
|
|
||||||
frame = build_binary_frame(start, end, bins)
|
|
||||||
|
|
||||||
# Parse it back
|
|
||||||
msg_type = frame[0]
|
|
||||||
parsed_start, parsed_end = struct.unpack_from('<ff', frame, 1)
|
|
||||||
parsed_count = struct.unpack_from('<H', frame, 9)[0]
|
|
||||||
parsed_bins = frame[11:]
|
|
||||||
|
|
||||||
assert msg_type == 0x01
|
|
||||||
assert parsed_start == pytest.approx(start, abs=0.01)
|
|
||||||
assert parsed_end == pytest.approx(end, abs=0.01)
|
|
||||||
assert parsed_count == 2048
|
|
||||||
assert parsed_bins == bins
|
|
||||||
-443
@@ -1,443 +0,0 @@
|
|||||||
"""Alerting engine for cross-mode events."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import queue
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any, Generator
|
|
||||||
|
|
||||||
from config import ALERT_WEBHOOK_URL, ALERT_WEBHOOK_TIMEOUT, ALERT_WEBHOOK_SECRET
|
|
||||||
from utils.database import get_db
|
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.alerts')
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AlertRule:
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
mode: str | None
|
|
||||||
event_type: str | None
|
|
||||||
match: dict
|
|
||||||
severity: str
|
|
||||||
enabled: bool
|
|
||||||
notify: dict
|
|
||||||
created_at: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class AlertManager:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._queue: queue.Queue = queue.Queue(maxsize=1000)
|
|
||||||
self._rules_cache: list[AlertRule] = []
|
|
||||||
self._rules_loaded_at = 0.0
|
|
||||||
self._cache_lock = threading.Lock()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Rule management
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def invalidate_cache(self) -> None:
|
|
||||||
with self._cache_lock:
|
|
||||||
self._rules_loaded_at = 0.0
|
|
||||||
|
|
||||||
def _load_rules(self) -> None:
|
|
||||||
with get_db() as conn:
|
|
||||||
cursor = conn.execute('''
|
|
||||||
SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at
|
|
||||||
FROM alert_rules
|
|
||||||
WHERE enabled = 1
|
|
||||||
ORDER BY id ASC
|
|
||||||
''')
|
|
||||||
rules: list[AlertRule] = []
|
|
||||||
for row in cursor:
|
|
||||||
match = {}
|
|
||||||
notify = {}
|
|
||||||
try:
|
|
||||||
match = json.loads(row['match']) if row['match'] else {}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
match = {}
|
|
||||||
try:
|
|
||||||
notify = json.loads(row['notify']) if row['notify'] else {}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
notify = {}
|
|
||||||
rules.append(AlertRule(
|
|
||||||
id=row['id'],
|
|
||||||
name=row['name'],
|
|
||||||
mode=row['mode'],
|
|
||||||
event_type=row['event_type'],
|
|
||||||
match=match,
|
|
||||||
severity=row['severity'] or 'medium',
|
|
||||||
enabled=bool(row['enabled']),
|
|
||||||
notify=notify,
|
|
||||||
created_at=row['created_at'],
|
|
||||||
))
|
|
||||||
with self._cache_lock:
|
|
||||||
self._rules_cache = rules
|
|
||||||
self._rules_loaded_at = time.time()
|
|
||||||
|
|
||||||
def _get_rules(self) -> list[AlertRule]:
|
|
||||||
with self._cache_lock:
|
|
||||||
stale = (time.time() - self._rules_loaded_at) > 10
|
|
||||||
if stale:
|
|
||||||
self._load_rules()
|
|
||||||
with self._cache_lock:
|
|
||||||
return list(self._rules_cache)
|
|
||||||
|
|
||||||
def list_rules(self, include_disabled: bool = False) -> list[dict]:
|
|
||||||
with get_db() as conn:
|
|
||||||
if include_disabled:
|
|
||||||
cursor = conn.execute('''
|
|
||||||
SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at
|
|
||||||
FROM alert_rules
|
|
||||||
ORDER BY id DESC
|
|
||||||
''')
|
|
||||||
else:
|
|
||||||
cursor = conn.execute('''
|
|
||||||
SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at
|
|
||||||
FROM alert_rules
|
|
||||||
WHERE enabled = 1
|
|
||||||
ORDER BY id DESC
|
|
||||||
''')
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
'id': row['id'],
|
|
||||||
'name': row['name'],
|
|
||||||
'mode': row['mode'],
|
|
||||||
'event_type': row['event_type'],
|
|
||||||
'match': json.loads(row['match']) if row['match'] else {},
|
|
||||||
'severity': row['severity'],
|
|
||||||
'enabled': bool(row['enabled']),
|
|
||||||
'notify': json.loads(row['notify']) if row['notify'] else {},
|
|
||||||
'created_at': row['created_at'],
|
|
||||||
}
|
|
||||||
for row in cursor
|
|
||||||
]
|
|
||||||
|
|
||||||
def add_rule(self, rule: dict) -> int:
|
|
||||||
with get_db() as conn:
|
|
||||||
cursor = conn.execute('''
|
|
||||||
INSERT INTO alert_rules (name, mode, event_type, match, severity, enabled, notify)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
''', (
|
|
||||||
rule.get('name') or 'Alert Rule',
|
|
||||||
rule.get('mode'),
|
|
||||||
rule.get('event_type'),
|
|
||||||
json.dumps(rule.get('match') or {}),
|
|
||||||
rule.get('severity') or 'medium',
|
|
||||||
1 if rule.get('enabled', True) else 0,
|
|
||||||
json.dumps(rule.get('notify') or {}),
|
|
||||||
))
|
|
||||||
rule_id = cursor.lastrowid
|
|
||||||
self.invalidate_cache()
|
|
||||||
return int(rule_id)
|
|
||||||
|
|
||||||
def update_rule(self, rule_id: int, updates: dict) -> bool:
|
|
||||||
fields = []
|
|
||||||
params = []
|
|
||||||
for key in ('name', 'mode', 'event_type', 'severity'):
|
|
||||||
if key in updates:
|
|
||||||
fields.append(f"{key} = ?")
|
|
||||||
params.append(updates[key])
|
|
||||||
if 'enabled' in updates:
|
|
||||||
fields.append('enabled = ?')
|
|
||||||
params.append(1 if updates['enabled'] else 0)
|
|
||||||
if 'match' in updates:
|
|
||||||
fields.append('match = ?')
|
|
||||||
params.append(json.dumps(updates['match'] or {}))
|
|
||||||
if 'notify' in updates:
|
|
||||||
fields.append('notify = ?')
|
|
||||||
params.append(json.dumps(updates['notify'] or {}))
|
|
||||||
|
|
||||||
if not fields:
|
|
||||||
return False
|
|
||||||
|
|
||||||
params.append(rule_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cursor = conn.execute(
|
|
||||||
f"UPDATE alert_rules SET {', '.join(fields)} WHERE id = ?",
|
|
||||||
params
|
|
||||||
)
|
|
||||||
updated = cursor.rowcount > 0
|
|
||||||
|
|
||||||
if updated:
|
|
||||||
self.invalidate_cache()
|
|
||||||
return updated
|
|
||||||
|
|
||||||
def delete_rule(self, rule_id: int) -> bool:
|
|
||||||
with get_db() as conn:
|
|
||||||
cursor = conn.execute('DELETE FROM alert_rules WHERE id = ?', (rule_id,))
|
|
||||||
deleted = cursor.rowcount > 0
|
|
||||||
if deleted:
|
|
||||||
self.invalidate_cache()
|
|
||||||
return deleted
|
|
||||||
|
|
||||||
def list_events(self, limit: int = 100, mode: str | None = None, severity: str | None = None) -> list[dict]:
|
|
||||||
query = 'SELECT id, rule_id, mode, event_type, severity, title, message, payload, created_at FROM alert_events'
|
|
||||||
clauses = []
|
|
||||||
params: list[Any] = []
|
|
||||||
if mode:
|
|
||||||
clauses.append('mode = ?')
|
|
||||||
params.append(mode)
|
|
||||||
if severity:
|
|
||||||
clauses.append('severity = ?')
|
|
||||||
params.append(severity)
|
|
||||||
if clauses:
|
|
||||||
query += ' WHERE ' + ' AND '.join(clauses)
|
|
||||||
query += ' ORDER BY id DESC LIMIT ?'
|
|
||||||
params.append(limit)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cursor = conn.execute(query, params)
|
|
||||||
events = []
|
|
||||||
for row in cursor:
|
|
||||||
events.append({
|
|
||||||
'id': row['id'],
|
|
||||||
'rule_id': row['rule_id'],
|
|
||||||
'mode': row['mode'],
|
|
||||||
'event_type': row['event_type'],
|
|
||||||
'severity': row['severity'],
|
|
||||||
'title': row['title'],
|
|
||||||
'message': row['message'],
|
|
||||||
'payload': json.loads(row['payload']) if row['payload'] else {},
|
|
||||||
'created_at': row['created_at'],
|
|
||||||
})
|
|
||||||
return events
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Event processing
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def process_event(self, mode: str, event: dict, event_type: str | None = None) -> None:
|
|
||||||
if not isinstance(event, dict):
|
|
||||||
return
|
|
||||||
|
|
||||||
if event_type in ('keepalive', 'ping', 'status'):
|
|
||||||
return
|
|
||||||
|
|
||||||
rules = self._get_rules()
|
|
||||||
if not rules:
|
|
||||||
return
|
|
||||||
|
|
||||||
for rule in rules:
|
|
||||||
if rule.mode and rule.mode != mode:
|
|
||||||
continue
|
|
||||||
if rule.event_type and event_type and rule.event_type != event_type:
|
|
||||||
continue
|
|
||||||
if rule.event_type and not event_type:
|
|
||||||
continue
|
|
||||||
if not self._match_rule(rule.match, event):
|
|
||||||
continue
|
|
||||||
|
|
||||||
title = rule.name or 'Alert'
|
|
||||||
message = self._build_message(rule, event, event_type)
|
|
||||||
payload = {
|
|
||||||
'mode': mode,
|
|
||||||
'event_type': event_type,
|
|
||||||
'event': event,
|
|
||||||
'rule': {
|
|
||||||
'id': rule.id,
|
|
||||||
'name': rule.name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
event_id = self._store_event(rule.id, mode, event_type, rule.severity, title, message, payload)
|
|
||||||
alert_payload = {
|
|
||||||
'id': event_id,
|
|
||||||
'rule_id': rule.id,
|
|
||||||
'mode': mode,
|
|
||||||
'event_type': event_type,
|
|
||||||
'severity': rule.severity,
|
|
||||||
'title': title,
|
|
||||||
'message': message,
|
|
||||||
'payload': payload,
|
|
||||||
'created_at': datetime.now(timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
self._queue_event(alert_payload)
|
|
||||||
self._maybe_send_webhook(alert_payload, rule.notify)
|
|
||||||
|
|
||||||
def _build_message(self, rule: AlertRule, event: dict, event_type: str | None) -> str:
|
|
||||||
if isinstance(rule.notify, dict) and rule.notify.get('message'):
|
|
||||||
return str(rule.notify.get('message'))
|
|
||||||
summary_bits = []
|
|
||||||
if event_type:
|
|
||||||
summary_bits.append(event_type)
|
|
||||||
if 'name' in event:
|
|
||||||
summary_bits.append(str(event.get('name')))
|
|
||||||
if 'ssid' in event:
|
|
||||||
summary_bits.append(str(event.get('ssid')))
|
|
||||||
if 'bssid' in event:
|
|
||||||
summary_bits.append(str(event.get('bssid')))
|
|
||||||
if 'address' in event:
|
|
||||||
summary_bits.append(str(event.get('address')))
|
|
||||||
if 'mac' in event:
|
|
||||||
summary_bits.append(str(event.get('mac')))
|
|
||||||
summary = ' | '.join(summary_bits) if summary_bits else 'Alert triggered'
|
|
||||||
return summary
|
|
||||||
|
|
||||||
def _store_event(
|
|
||||||
self,
|
|
||||||
rule_id: int,
|
|
||||||
mode: str,
|
|
||||||
event_type: str | None,
|
|
||||||
severity: str,
|
|
||||||
title: str,
|
|
||||||
message: str,
|
|
||||||
payload: dict,
|
|
||||||
) -> int:
|
|
||||||
with get_db() as conn:
|
|
||||||
cursor = conn.execute('''
|
|
||||||
INSERT INTO alert_events (rule_id, mode, event_type, severity, title, message, payload)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
''', (
|
|
||||||
rule_id,
|
|
||||||
mode,
|
|
||||||
event_type,
|
|
||||||
severity,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
json.dumps(payload),
|
|
||||||
))
|
|
||||||
return int(cursor.lastrowid)
|
|
||||||
|
|
||||||
def _queue_event(self, alert_payload: dict) -> None:
|
|
||||||
try:
|
|
||||||
self._queue.put_nowait(alert_payload)
|
|
||||||
except queue.Full:
|
|
||||||
try:
|
|
||||||
self._queue.get_nowait()
|
|
||||||
self._queue.put_nowait(alert_payload)
|
|
||||||
except queue.Empty:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _maybe_send_webhook(self, payload: dict, notify: dict) -> None:
|
|
||||||
if not ALERT_WEBHOOK_URL:
|
|
||||||
return
|
|
||||||
if isinstance(notify, dict) and notify.get('webhook') is False:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
import urllib.request
|
|
||||||
req = urllib.request.Request(
|
|
||||||
ALERT_WEBHOOK_URL,
|
|
||||||
data=json.dumps(payload).encode('utf-8'),
|
|
||||||
headers={
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'User-Agent': 'Intercept-Alert',
|
|
||||||
'X-Alert-Token': ALERT_WEBHOOK_SECRET or '',
|
|
||||||
},
|
|
||||||
method='POST'
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=ALERT_WEBHOOK_TIMEOUT) as _:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Alert webhook failed: {e}")
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Matching
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _match_rule(self, rule_match: dict, event: dict) -> bool:
|
|
||||||
if not rule_match:
|
|
||||||
return True
|
|
||||||
|
|
||||||
for key, expected in rule_match.items():
|
|
||||||
actual = self._extract_value(event, key)
|
|
||||||
if not self._match_value(actual, expected):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _extract_value(self, event: dict, key: str) -> Any:
|
|
||||||
if '.' not in key:
|
|
||||||
return event.get(key)
|
|
||||||
current: Any = event
|
|
||||||
for part in key.split('.'):
|
|
||||||
if isinstance(current, dict):
|
|
||||||
current = current.get(part)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
return current
|
|
||||||
|
|
||||||
def _match_value(self, actual: Any, expected: Any) -> bool:
|
|
||||||
if isinstance(expected, dict) and 'op' in expected:
|
|
||||||
op = expected.get('op')
|
|
||||||
value = expected.get('value')
|
|
||||||
return self._apply_op(op, actual, value)
|
|
||||||
|
|
||||||
if isinstance(expected, list):
|
|
||||||
return actual in expected
|
|
||||||
|
|
||||||
if isinstance(expected, str):
|
|
||||||
if actual is None:
|
|
||||||
return False
|
|
||||||
return str(actual).lower() == expected.lower()
|
|
||||||
|
|
||||||
return actual == expected
|
|
||||||
|
|
||||||
def _apply_op(self, op: str, actual: Any, value: Any) -> bool:
|
|
||||||
if op == 'exists':
|
|
||||||
return actual is not None
|
|
||||||
if op == 'eq':
|
|
||||||
return actual == value
|
|
||||||
if op == 'neq':
|
|
||||||
return actual != value
|
|
||||||
if op == 'gt':
|
|
||||||
return _safe_number(actual) is not None and _safe_number(actual) > _safe_number(value)
|
|
||||||
if op == 'gte':
|
|
||||||
return _safe_number(actual) is not None and _safe_number(actual) >= _safe_number(value)
|
|
||||||
if op == 'lt':
|
|
||||||
return _safe_number(actual) is not None and _safe_number(actual) < _safe_number(value)
|
|
||||||
if op == 'lte':
|
|
||||||
return _safe_number(actual) is not None and _safe_number(actual) <= _safe_number(value)
|
|
||||||
if op == 'in':
|
|
||||||
return actual in (value or [])
|
|
||||||
if op == 'contains':
|
|
||||||
if actual is None:
|
|
||||||
return False
|
|
||||||
if isinstance(actual, list):
|
|
||||||
return any(str(value).lower() in str(item).lower() for item in actual)
|
|
||||||
return str(value).lower() in str(actual).lower()
|
|
||||||
if op == 'regex':
|
|
||||||
if actual is None or value is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return re.search(str(value), str(actual)) is not None
|
|
||||||
except re.error:
|
|
||||||
return False
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Streaming
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def stream_events(self, timeout: float = 1.0) -> Generator[dict, None, None]:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
event = self._queue.get(timeout=timeout)
|
|
||||||
yield event
|
|
||||||
except queue.Empty:
|
|
||||||
yield {'type': 'keepalive'}
|
|
||||||
|
|
||||||
|
|
||||||
_alert_manager: AlertManager | None = None
|
|
||||||
_alert_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def get_alert_manager() -> AlertManager:
|
|
||||||
global _alert_manager
|
|
||||||
with _alert_lock:
|
|
||||||
if _alert_manager is None:
|
|
||||||
_alert_manager = AlertManager()
|
|
||||||
return _alert_manager
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_number(value: Any) -> float | None:
|
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
@@ -151,7 +151,6 @@ class BTDeviceAggregate:
|
|||||||
# Baseline tracking
|
# Baseline tracking
|
||||||
in_baseline: bool = False
|
in_baseline: bool = False
|
||||||
baseline_id: Optional[int] = None
|
baseline_id: Optional[int] = None
|
||||||
seen_before: bool = False
|
|
||||||
|
|
||||||
# Tracker detection fields
|
# Tracker detection fields
|
||||||
is_tracker: bool = False
|
is_tracker: bool = False
|
||||||
@@ -278,7 +277,6 @@ class BTDeviceAggregate:
|
|||||||
# Baseline
|
# Baseline
|
||||||
'in_baseline': self.in_baseline,
|
'in_baseline': self.in_baseline,
|
||||||
'baseline_id': self.baseline_id,
|
'baseline_id': self.baseline_id,
|
||||||
'seen_before': self.seen_before,
|
|
||||||
|
|
||||||
# Tracker detection
|
# Tracker detection
|
||||||
'tracker': {
|
'tracker': {
|
||||||
@@ -329,7 +327,6 @@ class BTDeviceAggregate:
|
|||||||
'seen_count': self.seen_count,
|
'seen_count': self.seen_count,
|
||||||
'heuristic_flags': self.heuristic_flags,
|
'heuristic_flags': self.heuristic_flags,
|
||||||
'in_baseline': self.in_baseline,
|
'in_baseline': self.in_baseline,
|
||||||
'seen_before': self.seen_before,
|
|
||||||
# Tracker info for list view
|
# Tracker info for list view
|
||||||
'is_tracker': self.is_tracker,
|
'is_tracker': self.is_tracker,
|
||||||
'tracker_type': self.tracker_type,
|
'tracker_type': self.tracker_type,
|
||||||
|
|||||||
+2
-64
@@ -102,52 +102,6 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
# Alert rules
|
|
||||||
conn.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
mode TEXT,
|
|
||||||
event_type TEXT,
|
|
||||||
match TEXT,
|
|
||||||
severity TEXT DEFAULT 'medium',
|
|
||||||
enabled BOOLEAN DEFAULT 1,
|
|
||||||
notify TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
''')
|
|
||||||
|
|
||||||
# Alert events
|
|
||||||
conn.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_events (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
rule_id INTEGER,
|
|
||||||
mode TEXT,
|
|
||||||
event_type TEXT,
|
|
||||||
severity TEXT DEFAULT 'medium',
|
|
||||||
title TEXT,
|
|
||||||
message TEXT,
|
|
||||||
payload TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (rule_id) REFERENCES alert_rules(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
''')
|
|
||||||
|
|
||||||
# Session recordings
|
|
||||||
conn.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS recording_sessions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
mode TEXT NOT NULL,
|
|
||||||
label TEXT,
|
|
||||||
started_at TIMESTAMP NOT NULL,
|
|
||||||
stopped_at TIMESTAMP,
|
|
||||||
file_path TEXT NOT NULL,
|
|
||||||
event_count INTEGER DEFAULT 0,
|
|
||||||
size_bytes INTEGER DEFAULT 0,
|
|
||||||
metadata TEXT
|
|
||||||
)
|
|
||||||
''')
|
|
||||||
|
|
||||||
# Users table for authentication
|
# Users table for authentication
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
@@ -185,7 +139,6 @@ def init_db() -> None:
|
|||||||
description TEXT,
|
description TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
wifi_networks TEXT,
|
wifi_networks TEXT,
|
||||||
wifi_clients TEXT,
|
|
||||||
bt_devices TEXT,
|
bt_devices TEXT,
|
||||||
rf_frequencies TEXT,
|
rf_frequencies TEXT,
|
||||||
gps_coords TEXT,
|
gps_coords TEXT,
|
||||||
@@ -193,14 +146,6 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
# Ensure new columns exist for older databases
|
|
||||||
try:
|
|
||||||
columns = {row['name'] for row in conn.execute("PRAGMA table_info(tscm_baselines)")}
|
|
||||||
if 'wifi_clients' not in columns:
|
|
||||||
conn.execute('ALTER TABLE tscm_baselines ADD COLUMN wifi_clients TEXT')
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Schema update skipped for tscm_baselines: {e}")
|
|
||||||
|
|
||||||
# TSCM Sweeps - Individual sweep sessions
|
# TSCM Sweeps - Individual sweep sessions
|
||||||
conn.execute('''
|
conn.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS tscm_sweeps (
|
CREATE TABLE IF NOT EXISTS tscm_sweeps (
|
||||||
@@ -745,7 +690,6 @@ def create_tscm_baseline(
|
|||||||
location: str | None = None,
|
location: str | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
wifi_networks: list | None = None,
|
wifi_networks: list | None = None,
|
||||||
wifi_clients: list | None = None,
|
|
||||||
bt_devices: list | None = None,
|
bt_devices: list | None = None,
|
||||||
rf_frequencies: list | None = None,
|
rf_frequencies: list | None = None,
|
||||||
gps_coords: dict | None = None
|
gps_coords: dict | None = None
|
||||||
@@ -759,14 +703,13 @@ def create_tscm_baseline(
|
|||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.execute('''
|
cursor = conn.execute('''
|
||||||
INSERT INTO tscm_baselines
|
INSERT INTO tscm_baselines
|
||||||
(name, location, description, wifi_networks, wifi_clients, bt_devices, rf_frequencies, gps_coords)
|
(name, location, description, wifi_networks, bt_devices, rf_frequencies, gps_coords)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
''', (
|
''', (
|
||||||
name,
|
name,
|
||||||
location,
|
location,
|
||||||
description,
|
description,
|
||||||
json.dumps(wifi_networks) if wifi_networks else None,
|
json.dumps(wifi_networks) if wifi_networks else None,
|
||||||
json.dumps(wifi_clients) if wifi_clients else None,
|
|
||||||
json.dumps(bt_devices) if bt_devices else None,
|
json.dumps(bt_devices) if bt_devices else None,
|
||||||
json.dumps(rf_frequencies) if rf_frequencies else None,
|
json.dumps(rf_frequencies) if rf_frequencies else None,
|
||||||
json.dumps(gps_coords) if gps_coords else None
|
json.dumps(gps_coords) if gps_coords else None
|
||||||
@@ -792,7 +735,6 @@ def get_tscm_baseline(baseline_id: int) -> dict | None:
|
|||||||
'description': row['description'],
|
'description': row['description'],
|
||||||
'created_at': row['created_at'],
|
'created_at': row['created_at'],
|
||||||
'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [],
|
'wifi_networks': json.loads(row['wifi_networks']) if row['wifi_networks'] else [],
|
||||||
'wifi_clients': json.loads(row['wifi_clients']) if row['wifi_clients'] else [],
|
|
||||||
'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [],
|
'bt_devices': json.loads(row['bt_devices']) if row['bt_devices'] else [],
|
||||||
'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [],
|
'rf_frequencies': json.loads(row['rf_frequencies']) if row['rf_frequencies'] else [],
|
||||||
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None,
|
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None,
|
||||||
@@ -842,7 +784,6 @@ def set_active_tscm_baseline(baseline_id: int) -> bool:
|
|||||||
def update_tscm_baseline(
|
def update_tscm_baseline(
|
||||||
baseline_id: int,
|
baseline_id: int,
|
||||||
wifi_networks: list | None = None,
|
wifi_networks: list | None = None,
|
||||||
wifi_clients: list | None = None,
|
|
||||||
bt_devices: list | None = None,
|
bt_devices: list | None = None,
|
||||||
rf_frequencies: list | None = None
|
rf_frequencies: list | None = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -853,9 +794,6 @@ def update_tscm_baseline(
|
|||||||
if wifi_networks is not None:
|
if wifi_networks is not None:
|
||||||
updates.append('wifi_networks = ?')
|
updates.append('wifi_networks = ?')
|
||||||
params.append(json.dumps(wifi_networks))
|
params.append(json.dumps(wifi_networks))
|
||||||
if wifi_clients is not None:
|
|
||||||
updates.append('wifi_clients = ?')
|
|
||||||
params.append(json.dumps(wifi_clients))
|
|
||||||
if bt_devices is not None:
|
if bt_devices is not None:
|
||||||
updates.append('bt_devices = ?')
|
updates.append('bt_devices = ?')
|
||||||
params.append(json.dumps(bt_devices))
|
params.append(json.dumps(bt_devices))
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
"""Shared event pipeline for alerts and recordings."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from utils.alerts import get_alert_manager
|
|
||||||
from utils.recording import get_recording_manager
|
|
||||||
|
|
||||||
IGNORE_TYPES = {'keepalive', 'ping'}
|
|
||||||
|
|
||||||
|
|
||||||
def process_event(mode: str, event: dict | Any, event_type: str | None = None) -> None:
|
|
||||||
if event_type in IGNORE_TYPES:
|
|
||||||
return
|
|
||||||
if not isinstance(event, dict):
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
get_recording_manager().record_event(mode, event, event_type)
|
|
||||||
except Exception:
|
|
||||||
# Recording failures should never break streaming
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
get_alert_manager().process_event(mode, event, event_type)
|
|
||||||
except Exception:
|
|
||||||
# Alert failures should never break streaming
|
|
||||||
pass
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
"""Session recording utilities for SSE/event streams."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from utils.database import get_db
|
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.recording')
|
|
||||||
|
|
||||||
RECORDING_ROOT = Path(__file__).parent.parent / 'instance' / 'recordings'
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RecordingSession:
|
|
||||||
id: str
|
|
||||||
mode: str
|
|
||||||
label: str | None
|
|
||||||
file_path: Path
|
|
||||||
started_at: datetime
|
|
||||||
stopped_at: datetime | None = None
|
|
||||||
event_count: int = 0
|
|
||||||
size_bytes: int = 0
|
|
||||||
metadata: dict | None = None
|
|
||||||
|
|
||||||
_file_handle: Any | None = None
|
|
||||||
_lock: threading.Lock = threading.Lock()
|
|
||||||
|
|
||||||
def open(self) -> None:
|
|
||||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._file_handle = self.file_path.open('a', encoding='utf-8')
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
if self._file_handle:
|
|
||||||
self._file_handle.flush()
|
|
||||||
self._file_handle.close()
|
|
||||||
self._file_handle = None
|
|
||||||
|
|
||||||
def write_event(self, record: dict) -> None:
|
|
||||||
if not self._file_handle:
|
|
||||||
self.open()
|
|
||||||
line = json.dumps(record, ensure_ascii=True) + '\n'
|
|
||||||
with self._lock:
|
|
||||||
self._file_handle.write(line)
|
|
||||||
self._file_handle.flush()
|
|
||||||
self.event_count += 1
|
|
||||||
self.size_bytes += len(line.encode('utf-8'))
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingManager:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._active_by_mode: dict[str, RecordingSession] = {}
|
|
||||||
self._active_by_id: dict[str, RecordingSession] = {}
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def start_recording(self, mode: str, label: str | None = None, metadata: dict | None = None) -> RecordingSession:
|
|
||||||
with self._lock:
|
|
||||||
existing = self._active_by_mode.get(mode)
|
|
||||||
if existing:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
|
||||||
started_at = datetime.now(timezone.utc)
|
|
||||||
filename = f"{mode}_{started_at.strftime('%Y%m%d_%H%M%S')}_{session_id}.jsonl"
|
|
||||||
file_path = RECORDING_ROOT / mode / filename
|
|
||||||
|
|
||||||
session = RecordingSession(
|
|
||||||
id=session_id,
|
|
||||||
mode=mode,
|
|
||||||
label=label,
|
|
||||||
file_path=file_path,
|
|
||||||
started_at=started_at,
|
|
||||||
metadata=metadata or {},
|
|
||||||
)
|
|
||||||
session.open()
|
|
||||||
|
|
||||||
self._active_by_mode[mode] = session
|
|
||||||
self._active_by_id[session_id] = session
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
conn.execute('''
|
|
||||||
INSERT INTO recording_sessions
|
|
||||||
(id, mode, label, started_at, file_path, event_count, size_bytes, metadata)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
''', (
|
|
||||||
session.id,
|
|
||||||
session.mode,
|
|
||||||
session.label,
|
|
||||||
session.started_at.isoformat(),
|
|
||||||
str(session.file_path),
|
|
||||||
session.event_count,
|
|
||||||
session.size_bytes,
|
|
||||||
json.dumps(session.metadata or {}),
|
|
||||||
))
|
|
||||||
|
|
||||||
return session
|
|
||||||
|
|
||||||
def stop_recording(self, mode: str | None = None, session_id: str | None = None) -> RecordingSession | None:
|
|
||||||
with self._lock:
|
|
||||||
session = None
|
|
||||||
if session_id:
|
|
||||||
session = self._active_by_id.get(session_id)
|
|
||||||
elif mode:
|
|
||||||
session = self._active_by_mode.get(mode)
|
|
||||||
|
|
||||||
if not session:
|
|
||||||
return None
|
|
||||||
|
|
||||||
session.stopped_at = datetime.now(timezone.utc)
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
self._active_by_mode.pop(session.mode, None)
|
|
||||||
self._active_by_id.pop(session.id, None)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
conn.execute('''
|
|
||||||
UPDATE recording_sessions
|
|
||||||
SET stopped_at = ?, event_count = ?, size_bytes = ?
|
|
||||||
WHERE id = ?
|
|
||||||
''', (
|
|
||||||
session.stopped_at.isoformat(),
|
|
||||||
session.event_count,
|
|
||||||
session.size_bytes,
|
|
||||||
session.id,
|
|
||||||
))
|
|
||||||
|
|
||||||
return session
|
|
||||||
|
|
||||||
def record_event(self, mode: str, event: dict, event_type: str | None = None) -> None:
|
|
||||||
if event_type in ('keepalive', 'ping'):
|
|
||||||
return
|
|
||||||
session = self._active_by_mode.get(mode)
|
|
||||||
if not session:
|
|
||||||
return
|
|
||||||
record = {
|
|
||||||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
|
||||||
'mode': mode,
|
|
||||||
'event_type': event_type,
|
|
||||||
'event': event,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
session.write_event(record)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Recording write failed: {e}")
|
|
||||||
|
|
||||||
def list_recordings(self, limit: int = 50) -> list[dict]:
|
|
||||||
with get_db() as conn:
|
|
||||||
cursor = conn.execute('''
|
|
||||||
SELECT id, mode, label, started_at, stopped_at, file_path, event_count, size_bytes, metadata
|
|
||||||
FROM recording_sessions
|
|
||||||
ORDER BY started_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
''', (limit,))
|
|
||||||
rows = []
|
|
||||||
for row in cursor:
|
|
||||||
rows.append({
|
|
||||||
'id': row['id'],
|
|
||||||
'mode': row['mode'],
|
|
||||||
'label': row['label'],
|
|
||||||
'started_at': row['started_at'],
|
|
||||||
'stopped_at': row['stopped_at'],
|
|
||||||
'file_path': row['file_path'],
|
|
||||||
'event_count': row['event_count'],
|
|
||||||
'size_bytes': row['size_bytes'],
|
|
||||||
'metadata': json.loads(row['metadata']) if row['metadata'] else {},
|
|
||||||
})
|
|
||||||
return rows
|
|
||||||
|
|
||||||
def get_recording(self, session_id: str) -> dict | None:
|
|
||||||
with get_db() as conn:
|
|
||||||
cursor = conn.execute('''
|
|
||||||
SELECT id, mode, label, started_at, stopped_at, file_path, event_count, size_bytes, metadata
|
|
||||||
FROM recording_sessions
|
|
||||||
WHERE id = ?
|
|
||||||
''', (session_id,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
'id': row['id'],
|
|
||||||
'mode': row['mode'],
|
|
||||||
'label': row['label'],
|
|
||||||
'started_at': row['started_at'],
|
|
||||||
'stopped_at': row['stopped_at'],
|
|
||||||
'file_path': row['file_path'],
|
|
||||||
'event_count': row['event_count'],
|
|
||||||
'size_bytes': row['size_bytes'],
|
|
||||||
'metadata': json.loads(row['metadata']) if row['metadata'] else {},
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_active(self) -> list[dict]:
|
|
||||||
with self._lock:
|
|
||||||
sessions = []
|
|
||||||
for session in self._active_by_mode.values():
|
|
||||||
sessions.append({
|
|
||||||
'id': session.id,
|
|
||||||
'mode': session.mode,
|
|
||||||
'label': session.label,
|
|
||||||
'started_at': session.started_at.isoformat(),
|
|
||||||
'event_count': session.event_count,
|
|
||||||
'size_bytes': session.size_bytes,
|
|
||||||
})
|
|
||||||
return sessions
|
|
||||||
|
|
||||||
|
|
||||||
_recording_manager: RecordingManager | None = None
|
|
||||||
_recording_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def get_recording_manager() -> RecordingManager:
|
|
||||||
global _recording_manager
|
|
||||||
with _recording_lock:
|
|
||||||
if _recording_manager is None:
|
|
||||||
_recording_manager = RecordingManager()
|
|
||||||
return _recording_manager
|
|
||||||
@@ -185,43 +185,6 @@ class AirspyCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
def build_iq_capture_command(
|
|
||||||
self,
|
|
||||||
device: SDRDevice,
|
|
||||||
frequency_mhz: float,
|
|
||||||
sample_rate: int = 2048000,
|
|
||||||
gain: Optional[float] = None,
|
|
||||||
ppm: Optional[int] = None,
|
|
||||||
bias_t: bool = False,
|
|
||||||
output_format: str = 'cu8',
|
|
||||||
) -> list[str]:
|
|
||||||
"""
|
|
||||||
Build rx_sdr command for raw I/Q capture with Airspy.
|
|
||||||
|
|
||||||
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
|
|
||||||
"""
|
|
||||||
device_str = self._build_device_string(device)
|
|
||||||
freq_hz = int(frequency_mhz * 1e6)
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
'rx_sdr',
|
|
||||||
'-d', device_str,
|
|
||||||
'-f', str(freq_hz),
|
|
||||||
'-s', str(sample_rate),
|
|
||||||
'-F', 'CU8',
|
|
||||||
]
|
|
||||||
|
|
||||||
if gain is not None and gain > 0:
|
|
||||||
cmd.extend(['-g', self._format_gain(gain)])
|
|
||||||
|
|
||||||
if bias_t:
|
|
||||||
cmd.append('-T')
|
|
||||||
|
|
||||||
# Output to stdout
|
|
||||||
cmd.append('-')
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
def get_capabilities(self) -> SDRCapabilities:
|
def get_capabilities(self) -> SDRCapabilities:
|
||||||
"""Return Airspy capabilities."""
|
"""Return Airspy capabilities."""
|
||||||
return self.CAPABILITIES
|
return self.CAPABILITIES
|
||||||
|
|||||||
@@ -186,41 +186,6 @@ class CommandBuilder(ABC):
|
|||||||
"""Return hardware capabilities for this SDR type."""
|
"""Return hardware capabilities for this SDR type."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def build_iq_capture_command(
|
|
||||||
self,
|
|
||||||
device: SDRDevice,
|
|
||||||
frequency_mhz: float,
|
|
||||||
sample_rate: int = 2048000,
|
|
||||||
gain: Optional[float] = None,
|
|
||||||
ppm: Optional[int] = None,
|
|
||||||
bias_t: bool = False,
|
|
||||||
output_format: str = 'cu8',
|
|
||||||
) -> list[str]:
|
|
||||||
"""
|
|
||||||
Build raw I/Q capture command for streaming samples to stdout.
|
|
||||||
|
|
||||||
Used for real-time waterfall/spectrum display. Output is unsigned
|
|
||||||
8-bit I/Q pairs (cu8) written continuously to stdout.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
device: The SDR device to use
|
|
||||||
frequency_mhz: Center frequency in MHz
|
|
||||||
sample_rate: Sample rate in Hz (default 2048000)
|
|
||||||
gain: Gain in dB (None for auto)
|
|
||||||
ppm: PPM frequency correction
|
|
||||||
bias_t: Enable bias-T power (for active antennas)
|
|
||||||
output_format: Output sample format (default 'cu8')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Command as list of strings for subprocess
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
NotImplementedError: If the SDR type does not support I/Q capture.
|
|
||||||
"""
|
|
||||||
raise NotImplementedError(
|
|
||||||
f"{self.__class__.__name__} does not support raw I/Q capture"
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_sdr_type(cls) -> SDRType:
|
def get_sdr_type(cls) -> SDRType:
|
||||||
|
|||||||
@@ -185,44 +185,6 @@ class HackRFCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
def build_iq_capture_command(
|
|
||||||
self,
|
|
||||||
device: SDRDevice,
|
|
||||||
frequency_mhz: float,
|
|
||||||
sample_rate: int = 2048000,
|
|
||||||
gain: Optional[float] = None,
|
|
||||||
ppm: Optional[int] = None,
|
|
||||||
bias_t: bool = False,
|
|
||||||
output_format: str = 'cu8',
|
|
||||||
) -> list[str]:
|
|
||||||
"""
|
|
||||||
Build rx_sdr command for raw I/Q capture with HackRF.
|
|
||||||
|
|
||||||
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
|
|
||||||
"""
|
|
||||||
device_str = self._build_device_string(device)
|
|
||||||
freq_hz = int(frequency_mhz * 1e6)
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
'rx_sdr',
|
|
||||||
'-d', device_str,
|
|
||||||
'-f', str(freq_hz),
|
|
||||||
'-s', str(sample_rate),
|
|
||||||
'-F', 'CU8',
|
|
||||||
]
|
|
||||||
|
|
||||||
if gain is not None and gain > 0:
|
|
||||||
lna, vga = self._split_gain(gain)
|
|
||||||
cmd.extend(['-g', f'LNA={lna},VGA={vga}'])
|
|
||||||
|
|
||||||
if bias_t:
|
|
||||||
cmd.append('-T')
|
|
||||||
|
|
||||||
# Output to stdout
|
|
||||||
cmd.append('-')
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
def get_capabilities(self) -> SDRCapabilities:
|
def get_capabilities(self) -> SDRCapabilities:
|
||||||
"""Return HackRF capabilities."""
|
"""Return HackRF capabilities."""
|
||||||
return self.CAPABILITIES
|
return self.CAPABILITIES
|
||||||
|
|||||||
@@ -162,41 +162,6 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
def build_iq_capture_command(
|
|
||||||
self,
|
|
||||||
device: SDRDevice,
|
|
||||||
frequency_mhz: float,
|
|
||||||
sample_rate: int = 2048000,
|
|
||||||
gain: Optional[float] = None,
|
|
||||||
ppm: Optional[int] = None,
|
|
||||||
bias_t: bool = False,
|
|
||||||
output_format: str = 'cu8',
|
|
||||||
) -> list[str]:
|
|
||||||
"""
|
|
||||||
Build rx_sdr command for raw I/Q capture with LimeSDR.
|
|
||||||
|
|
||||||
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
|
|
||||||
Note: LimeSDR does not support bias-T, parameter is ignored.
|
|
||||||
"""
|
|
||||||
device_str = self._build_device_string(device)
|
|
||||||
freq_hz = int(frequency_mhz * 1e6)
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
'rx_sdr',
|
|
||||||
'-d', device_str,
|
|
||||||
'-f', str(freq_hz),
|
|
||||||
'-s', str(sample_rate),
|
|
||||||
'-F', 'CU8',
|
|
||||||
]
|
|
||||||
|
|
||||||
if gain is not None and gain > 0:
|
|
||||||
cmd.extend(['-g', f'LNAH={int(gain)}'])
|
|
||||||
|
|
||||||
# Output to stdout
|
|
||||||
cmd.append('-')
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
def get_capabilities(self) -> SDRCapabilities:
|
def get_capabilities(self) -> SDRCapabilities:
|
||||||
"""Return LimeSDR capabilities."""
|
"""Return LimeSDR capabilities."""
|
||||||
return self.CAPABILITIES
|
return self.CAPABILITIES
|
||||||
|
|||||||
@@ -231,45 +231,6 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
def build_iq_capture_command(
|
|
||||||
self,
|
|
||||||
device: SDRDevice,
|
|
||||||
frequency_mhz: float,
|
|
||||||
sample_rate: int = 2048000,
|
|
||||||
gain: Optional[float] = None,
|
|
||||||
ppm: Optional[int] = None,
|
|
||||||
bias_t: bool = False,
|
|
||||||
output_format: str = 'cu8',
|
|
||||||
) -> list[str]:
|
|
||||||
"""
|
|
||||||
Build rtl_sdr command for raw I/Q capture.
|
|
||||||
|
|
||||||
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
|
|
||||||
"""
|
|
||||||
rtl_sdr_path = get_tool_path('rtl_sdr') or 'rtl_sdr'
|
|
||||||
freq_hz = int(frequency_mhz * 1e6)
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
rtl_sdr_path,
|
|
||||||
'-d', self._get_device_arg(device),
|
|
||||||
'-f', str(freq_hz),
|
|
||||||
'-s', str(sample_rate),
|
|
||||||
]
|
|
||||||
|
|
||||||
if gain is not None and gain > 0:
|
|
||||||
cmd.extend(['-g', str(gain)])
|
|
||||||
|
|
||||||
if ppm is not None and ppm != 0:
|
|
||||||
cmd.extend(['-p', str(ppm)])
|
|
||||||
|
|
||||||
if bias_t:
|
|
||||||
cmd.append('-T')
|
|
||||||
|
|
||||||
# Output to stdout
|
|
||||||
cmd.append('-')
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
def get_capabilities(self) -> SDRCapabilities:
|
def get_capabilities(self) -> SDRCapabilities:
|
||||||
"""Return RTL-SDR capabilities."""
|
"""Return RTL-SDR capabilities."""
|
||||||
return self.CAPABILITIES
|
return self.CAPABILITIES
|
||||||
|
|||||||
@@ -163,43 +163,6 @@ class SDRPlayCommandBuilder(CommandBuilder):
|
|||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
def build_iq_capture_command(
|
|
||||||
self,
|
|
||||||
device: SDRDevice,
|
|
||||||
frequency_mhz: float,
|
|
||||||
sample_rate: int = 2048000,
|
|
||||||
gain: Optional[float] = None,
|
|
||||||
ppm: Optional[int] = None,
|
|
||||||
bias_t: bool = False,
|
|
||||||
output_format: str = 'cu8',
|
|
||||||
) -> list[str]:
|
|
||||||
"""
|
|
||||||
Build rx_sdr command for raw I/Q capture with SDRPlay.
|
|
||||||
|
|
||||||
Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display.
|
|
||||||
"""
|
|
||||||
device_str = self._build_device_string(device)
|
|
||||||
freq_hz = int(frequency_mhz * 1e6)
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
'rx_sdr',
|
|
||||||
'-d', device_str,
|
|
||||||
'-f', str(freq_hz),
|
|
||||||
'-s', str(sample_rate),
|
|
||||||
'-F', 'CU8',
|
|
||||||
]
|
|
||||||
|
|
||||||
if gain is not None and gain > 0:
|
|
||||||
cmd.extend(['-g', f'IFGR={int(gain)}'])
|
|
||||||
|
|
||||||
if bias_t:
|
|
||||||
cmd.append('-T')
|
|
||||||
|
|
||||||
# Output to stdout
|
|
||||||
cmd.append('-')
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
def get_capabilities(self) -> SDRCapabilities:
|
def get_capabilities(self) -> SDRCapabilities:
|
||||||
"""Return SDRPlay capabilities."""
|
"""Return SDRPlay capabilities."""
|
||||||
return self.CAPABILITIES
|
return self.CAPABILITIES
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ class SSTVDecoder:
|
|||||||
self._rtl_process = None
|
self._rtl_process = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._callback: Callable[[dict], None] | None = None
|
self._callback: Callable[[DecodeProgress], None] | None = None
|
||||||
self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images')
|
self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images')
|
||||||
self._url_prefix = url_prefix
|
self._url_prefix = url_prefix
|
||||||
self._images: list[SSTVImage] = []
|
self._images: list[SSTVImage] = []
|
||||||
@@ -253,7 +253,7 @@ class SSTVDecoder:
|
|||||||
"""Return name of available decoder. Always available with pure Python."""
|
"""Return name of available decoder. Always available with pure Python."""
|
||||||
return 'python-sstv'
|
return 'python-sstv'
|
||||||
|
|
||||||
def set_callback(self, callback: Callable[[dict], None]) -> None:
|
def set_callback(self, callback: Callable[[DecodeProgress], None]) -> None:
|
||||||
"""Set callback for decode progress updates."""
|
"""Set callback for decode progress updates."""
|
||||||
self._callback = callback
|
self._callback = callback
|
||||||
|
|
||||||
@@ -420,10 +420,6 @@ class SSTVDecoder:
|
|||||||
|
|
||||||
chunk_counter += 1
|
chunk_counter += 1
|
||||||
|
|
||||||
# Scope: compute RMS/peak from raw int16 samples every chunk
|
|
||||||
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
|
|
||||||
peak_val = int(np.max(np.abs(raw_samples)))
|
|
||||||
|
|
||||||
if image_decoder is not None:
|
if image_decoder is not None:
|
||||||
# Currently decoding an image
|
# Currently decoding an image
|
||||||
complete = image_decoder.feed(samples)
|
complete = image_decoder.feed(samples)
|
||||||
@@ -451,7 +447,6 @@ class SSTVDecoder:
|
|||||||
message=f'Decoding {current_mode_name}: {pct}%',
|
message=f'Decoding {current_mode_name}: {pct}%',
|
||||||
partial_image=partial_url,
|
partial_image=partial_url,
|
||||||
))
|
))
|
||||||
self._emit_scope(rms_val, peak_val, 'decoding')
|
|
||||||
|
|
||||||
if complete:
|
if complete:
|
||||||
# Save image
|
# Save image
|
||||||
@@ -484,7 +479,6 @@ class SSTVDecoder:
|
|||||||
vis_detector.reset()
|
vis_detector.reset()
|
||||||
|
|
||||||
# Emit signal level metrics every ~500ms (every 5th 100ms chunk)
|
# Emit signal level metrics every ~500ms (every 5th 100ms chunk)
|
||||||
scope_tone: str | None = None
|
|
||||||
if chunk_counter % 5 == 0 and image_decoder is None:
|
if chunk_counter % 5 == 0 and image_decoder is None:
|
||||||
rms = float(np.sqrt(np.mean(samples ** 2)))
|
rms = float(np.sqrt(np.mean(samples ** 2)))
|
||||||
signal_level = min(100, int(rms * 500))
|
signal_level = min(100, int(rms * 500))
|
||||||
@@ -507,8 +501,6 @@ class SSTVDecoder:
|
|||||||
else:
|
else:
|
||||||
sstv_tone = None
|
sstv_tone = None
|
||||||
|
|
||||||
scope_tone = sstv_tone
|
|
||||||
|
|
||||||
self._emit_progress(DecodeProgress(
|
self._emit_progress(DecodeProgress(
|
||||||
status='detecting',
|
status='detecting',
|
||||||
message='Listening...',
|
message='Listening...',
|
||||||
@@ -517,8 +509,6 @@ class SSTVDecoder:
|
|||||||
vis_state=vis_detector.state.value,
|
vis_state=vis_detector.state.value,
|
||||||
))
|
))
|
||||||
|
|
||||||
self._emit_scope(rms_val, peak_val, scope_tone)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in decode thread: {e}")
|
logger.error(f"Error in decode thread: {e}")
|
||||||
if not self._running:
|
if not self._running:
|
||||||
@@ -746,18 +736,10 @@ class SSTVDecoder:
|
|||||||
"""Emit progress update to callback."""
|
"""Emit progress update to callback."""
|
||||||
if self._callback:
|
if self._callback:
|
||||||
try:
|
try:
|
||||||
self._callback(progress.to_dict())
|
self._callback(progress)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in progress callback: {e}")
|
logger.error(f"Error in progress callback: {e}")
|
||||||
|
|
||||||
def _emit_scope(self, rms: int, peak: int, tone: str | None = None) -> None:
|
|
||||||
"""Emit scope signal levels to callback."""
|
|
||||||
if self._callback:
|
|
||||||
try:
|
|
||||||
self._callback({'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def decode_file(self, audio_path: str | Path) -> list[SSTVImage]:
|
def decode_file(self, audio_path: str | Path) -> list[SSTVImage]:
|
||||||
"""Decode SSTV image(s) from an audio file.
|
"""Decode SSTV image(s) from an audio file.
|
||||||
|
|
||||||
|
|||||||
@@ -526,7 +526,6 @@ class BaselineDiff:
|
|||||||
def calculate_baseline_diff(
|
def calculate_baseline_diff(
|
||||||
baseline: dict,
|
baseline: dict,
|
||||||
current_wifi: list[dict],
|
current_wifi: list[dict],
|
||||||
current_wifi_clients: list[dict],
|
|
||||||
current_bt: list[dict],
|
current_bt: list[dict],
|
||||||
current_rf: list[dict],
|
current_rf: list[dict],
|
||||||
sweep_id: int
|
sweep_id: int
|
||||||
@@ -537,7 +536,6 @@ def calculate_baseline_diff(
|
|||||||
Args:
|
Args:
|
||||||
baseline: Baseline dict from database
|
baseline: Baseline dict from database
|
||||||
current_wifi: Current WiFi devices
|
current_wifi: Current WiFi devices
|
||||||
current_wifi_clients: Current WiFi clients
|
|
||||||
current_bt: Current Bluetooth devices
|
current_bt: Current Bluetooth devices
|
||||||
current_rf: Current RF signals
|
current_rf: Current RF signals
|
||||||
sweep_id: Current sweep ID
|
sweep_id: Current sweep ID
|
||||||
@@ -571,11 +569,6 @@ def calculate_baseline_diff(
|
|||||||
for d in baseline.get('wifi_networks', [])
|
for d in baseline.get('wifi_networks', [])
|
||||||
if d.get('bssid') or d.get('mac')
|
if d.get('bssid') or d.get('mac')
|
||||||
}
|
}
|
||||||
baseline_wifi_clients = {
|
|
||||||
d.get('mac', d.get('address', '')).upper(): d
|
|
||||||
for d in baseline.get('wifi_clients', [])
|
|
||||||
if d.get('mac') or d.get('address')
|
|
||||||
}
|
|
||||||
baseline_bt = {
|
baseline_bt = {
|
||||||
d.get('mac', d.get('address', '')).upper(): d
|
d.get('mac', d.get('address', '')).upper(): d
|
||||||
for d in baseline.get('bt_devices', [])
|
for d in baseline.get('bt_devices', [])
|
||||||
@@ -590,9 +583,6 @@ def calculate_baseline_diff(
|
|||||||
# Compare WiFi
|
# Compare WiFi
|
||||||
_compare_wifi(diff, baseline_wifi, current_wifi)
|
_compare_wifi(diff, baseline_wifi, current_wifi)
|
||||||
|
|
||||||
# Compare WiFi clients
|
|
||||||
_compare_wifi_clients(diff, baseline_wifi_clients, current_wifi_clients)
|
|
||||||
|
|
||||||
# Compare Bluetooth
|
# Compare Bluetooth
|
||||||
_compare_bluetooth(diff, baseline_bt, current_bt)
|
_compare_bluetooth(diff, baseline_bt, current_bt)
|
||||||
|
|
||||||
@@ -641,47 +631,6 @@ def _compare_wifi(diff: BaselineDiff, baseline: dict, current: list[dict]) -> No
|
|||||||
'rssi': device.get('power', device.get('signal')),
|
'rssi': device.get('power', device.get('signal')),
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
def _compare_wifi_clients(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None:
|
|
||||||
"""Compare WiFi clients between baseline and current."""
|
|
||||||
current_macs = {
|
|
||||||
d.get('mac', d.get('address', '')).upper(): d
|
|
||||||
for d in current
|
|
||||||
if d.get('mac') or d.get('address')
|
|
||||||
}
|
|
||||||
|
|
||||||
# Find new clients
|
|
||||||
for mac, device in current_macs.items():
|
|
||||||
if mac not in baseline:
|
|
||||||
name = device.get('vendor', 'WiFi Client')
|
|
||||||
diff.new_devices.append(DeviceChange(
|
|
||||||
identifier=mac,
|
|
||||||
protocol='wifi_client',
|
|
||||||
change_type='new',
|
|
||||||
description=f'New WiFi client: {name}',
|
|
||||||
expected=False,
|
|
||||||
details={
|
|
||||||
'vendor': name,
|
|
||||||
'rssi': device.get('rssi'),
|
|
||||||
'associated_bssid': device.get('associated_bssid'),
|
|
||||||
}
|
|
||||||
))
|
|
||||||
|
|
||||||
# Find missing clients
|
|
||||||
for mac, device in baseline.items():
|
|
||||||
if mac not in current_macs:
|
|
||||||
name = device.get('vendor', 'WiFi Client')
|
|
||||||
diff.missing_devices.append(DeviceChange(
|
|
||||||
identifier=mac,
|
|
||||||
protocol='wifi_client',
|
|
||||||
change_type='missing',
|
|
||||||
description=f'Missing WiFi client: {name}',
|
|
||||||
expected=True,
|
|
||||||
details={
|
|
||||||
'vendor': name,
|
|
||||||
}
|
|
||||||
))
|
|
||||||
else:
|
else:
|
||||||
# Check for changes
|
# Check for changes
|
||||||
baseline_dev = baseline[mac]
|
baseline_dev = baseline[mac]
|
||||||
@@ -849,7 +798,6 @@ def _calculate_baseline_health(diff: BaselineDiff, baseline: dict) -> None:
|
|||||||
# Device churn penalty
|
# Device churn penalty
|
||||||
total_baseline = (
|
total_baseline = (
|
||||||
len(baseline.get('wifi_networks', [])) +
|
len(baseline.get('wifi_networks', [])) +
|
||||||
len(baseline.get('wifi_clients', [])) +
|
|
||||||
len(baseline.get('bt_devices', [])) +
|
len(baseline.get('bt_devices', [])) +
|
||||||
len(baseline.get('rf_frequencies', []))
|
len(baseline.get('rf_frequencies', []))
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-78
@@ -30,7 +30,6 @@ class BaselineRecorder:
|
|||||||
self.recording = False
|
self.recording = False
|
||||||
self.current_baseline_id: int | None = None
|
self.current_baseline_id: int | None = None
|
||||||
self.wifi_networks: dict[str, dict] = {} # BSSID -> network info
|
self.wifi_networks: dict[str, dict] = {} # BSSID -> network info
|
||||||
self.wifi_clients: dict[str, dict] = {} # MAC -> client info
|
|
||||||
self.bt_devices: dict[str, dict] = {} # MAC -> device info
|
self.bt_devices: dict[str, dict] = {} # MAC -> device info
|
||||||
self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info
|
self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info
|
||||||
|
|
||||||
@@ -53,7 +52,6 @@ class BaselineRecorder:
|
|||||||
"""
|
"""
|
||||||
self.recording = True
|
self.recording = True
|
||||||
self.wifi_networks = {}
|
self.wifi_networks = {}
|
||||||
self.wifi_clients = {}
|
|
||||||
self.bt_devices = {}
|
self.bt_devices = {}
|
||||||
self.rf_frequencies = {}
|
self.rf_frequencies = {}
|
||||||
|
|
||||||
@@ -81,7 +79,6 @@ class BaselineRecorder:
|
|||||||
|
|
||||||
# Convert to lists for storage
|
# Convert to lists for storage
|
||||||
wifi_list = list(self.wifi_networks.values())
|
wifi_list = list(self.wifi_networks.values())
|
||||||
wifi_client_list = list(self.wifi_clients.values())
|
|
||||||
bt_list = list(self.bt_devices.values())
|
bt_list = list(self.bt_devices.values())
|
||||||
rf_list = list(self.rf_frequencies.values())
|
rf_list = list(self.rf_frequencies.values())
|
||||||
|
|
||||||
@@ -89,7 +86,6 @@ class BaselineRecorder:
|
|||||||
update_tscm_baseline(
|
update_tscm_baseline(
|
||||||
self.current_baseline_id,
|
self.current_baseline_id,
|
||||||
wifi_networks=wifi_list,
|
wifi_networks=wifi_list,
|
||||||
wifi_clients=wifi_client_list,
|
|
||||||
bt_devices=bt_list,
|
bt_devices=bt_list,
|
||||||
rf_frequencies=rf_list
|
rf_frequencies=rf_list
|
||||||
)
|
)
|
||||||
@@ -97,7 +93,6 @@ class BaselineRecorder:
|
|||||||
summary = {
|
summary = {
|
||||||
'baseline_id': self.current_baseline_id,
|
'baseline_id': self.current_baseline_id,
|
||||||
'wifi_count': len(wifi_list),
|
'wifi_count': len(wifi_list),
|
||||||
'wifi_client_count': len(wifi_client_list),
|
|
||||||
'bt_count': len(bt_list),
|
'bt_count': len(bt_list),
|
||||||
'rf_count': len(rf_list),
|
'rf_count': len(rf_list),
|
||||||
}
|
}
|
||||||
@@ -165,33 +160,6 @@ class BaselineRecorder:
|
|||||||
'last_seen': datetime.now().isoformat(),
|
'last_seen': datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def add_wifi_client(self, client: dict) -> None:
|
|
||||||
"""Add a WiFi client to the current baseline."""
|
|
||||||
if not self.recording:
|
|
||||||
return
|
|
||||||
|
|
||||||
mac = client.get('mac', client.get('address', '')).upper()
|
|
||||||
if not mac:
|
|
||||||
return
|
|
||||||
|
|
||||||
if mac in self.wifi_clients:
|
|
||||||
self.wifi_clients[mac].update({
|
|
||||||
'last_seen': datetime.now().isoformat(),
|
|
||||||
'rssi': client.get('rssi', self.wifi_clients[mac].get('rssi')),
|
|
||||||
'associated_bssid': client.get('associated_bssid', self.wifi_clients[mac].get('associated_bssid')),
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
self.wifi_clients[mac] = {
|
|
||||||
'mac': mac,
|
|
||||||
'vendor': client.get('vendor', ''),
|
|
||||||
'rssi': client.get('rssi'),
|
|
||||||
'associated_bssid': client.get('associated_bssid'),
|
|
||||||
'probed_ssids': client.get('probed_ssids', []),
|
|
||||||
'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))),
|
|
||||||
'first_seen': datetime.now().isoformat(),
|
|
||||||
'last_seen': datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def add_rf_signal(self, signal: dict) -> None:
|
def add_rf_signal(self, signal: dict) -> None:
|
||||||
"""Add an RF signal to the current baseline."""
|
"""Add an RF signal to the current baseline."""
|
||||||
if not self.recording:
|
if not self.recording:
|
||||||
@@ -229,7 +197,6 @@ class BaselineRecorder:
|
|||||||
'recording': self.recording,
|
'recording': self.recording,
|
||||||
'baseline_id': self.current_baseline_id,
|
'baseline_id': self.current_baseline_id,
|
||||||
'wifi_count': len(self.wifi_networks),
|
'wifi_count': len(self.wifi_networks),
|
||||||
'wifi_client_count': len(self.wifi_clients),
|
|
||||||
'bt_count': len(self.bt_devices),
|
'bt_count': len(self.bt_devices),
|
||||||
'rf_count': len(self.rf_frequencies),
|
'rf_count': len(self.rf_frequencies),
|
||||||
}
|
}
|
||||||
@@ -258,11 +225,6 @@ class BaselineComparator:
|
|||||||
for d in baseline.get('bt_devices', [])
|
for d in baseline.get('bt_devices', [])
|
||||||
if d.get('mac') or d.get('address')
|
if d.get('mac') or d.get('address')
|
||||||
}
|
}
|
||||||
self.baseline_wifi_clients = {
|
|
||||||
d.get('mac', d.get('address', '')).upper(): d
|
|
||||||
for d in baseline.get('wifi_clients', [])
|
|
||||||
if d.get('mac') or d.get('address')
|
|
||||||
}
|
|
||||||
self.baseline_rf = {
|
self.baseline_rf = {
|
||||||
round(d.get('frequency', 0), 1): d
|
round(d.get('frequency', 0), 1): d
|
||||||
for d in baseline.get('rf_frequencies', [])
|
for d in baseline.get('rf_frequencies', [])
|
||||||
@@ -338,37 +300,6 @@ class BaselineComparator:
|
|||||||
'matching_count': len(matching_devices),
|
'matching_count': len(matching_devices),
|
||||||
}
|
}
|
||||||
|
|
||||||
def compare_wifi_clients(self, current_devices: list[dict]) -> dict:
|
|
||||||
"""Compare current WiFi clients against baseline."""
|
|
||||||
current_macs = {
|
|
||||||
d.get('mac', d.get('address', '')).upper(): d
|
|
||||||
for d in current_devices
|
|
||||||
if d.get('mac') or d.get('address')
|
|
||||||
}
|
|
||||||
|
|
||||||
new_devices = []
|
|
||||||
missing_devices = []
|
|
||||||
matching_devices = []
|
|
||||||
|
|
||||||
for mac, device in current_macs.items():
|
|
||||||
if mac not in self.baseline_wifi_clients:
|
|
||||||
new_devices.append(device)
|
|
||||||
else:
|
|
||||||
matching_devices.append(device)
|
|
||||||
|
|
||||||
for mac, device in self.baseline_wifi_clients.items():
|
|
||||||
if mac not in current_macs:
|
|
||||||
missing_devices.append(device)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'new': new_devices,
|
|
||||||
'missing': missing_devices,
|
|
||||||
'matching': matching_devices,
|
|
||||||
'new_count': len(new_devices),
|
|
||||||
'missing_count': len(missing_devices),
|
|
||||||
'matching_count': len(matching_devices),
|
|
||||||
}
|
|
||||||
|
|
||||||
def compare_rf(self, current_signals: list[dict]) -> dict:
|
def compare_rf(self, current_signals: list[dict]) -> dict:
|
||||||
"""Compare current RF signals against baseline."""
|
"""Compare current RF signals against baseline."""
|
||||||
current_freqs = {
|
current_freqs = {
|
||||||
@@ -403,7 +334,6 @@ class BaselineComparator:
|
|||||||
def compare_all(
|
def compare_all(
|
||||||
self,
|
self,
|
||||||
wifi_devices: list[dict] | None = None,
|
wifi_devices: list[dict] | None = None,
|
||||||
wifi_clients: list[dict] | None = None,
|
|
||||||
bt_devices: list[dict] | None = None,
|
bt_devices: list[dict] | None = None,
|
||||||
rf_signals: list[dict] | None = None
|
rf_signals: list[dict] | None = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -415,7 +345,6 @@ class BaselineComparator:
|
|||||||
"""
|
"""
|
||||||
results = {
|
results = {
|
||||||
'wifi': None,
|
'wifi': None,
|
||||||
'wifi_clients': None,
|
|
||||||
'bluetooth': None,
|
'bluetooth': None,
|
||||||
'rf': None,
|
'rf': None,
|
||||||
'total_new': 0,
|
'total_new': 0,
|
||||||
@@ -427,11 +356,6 @@ class BaselineComparator:
|
|||||||
results['total_new'] += results['wifi']['new_count']
|
results['total_new'] += results['wifi']['new_count']
|
||||||
results['total_missing'] += results['wifi']['missing_count']
|
results['total_missing'] += results['wifi']['missing_count']
|
||||||
|
|
||||||
if wifi_clients is not None:
|
|
||||||
results['wifi_clients'] = self.compare_wifi_clients(wifi_clients)
|
|
||||||
results['total_new'] += results['wifi_clients']['new_count']
|
|
||||||
results['total_missing'] += results['wifi_clients']['missing_count']
|
|
||||||
|
|
||||||
if bt_devices is not None:
|
if bt_devices is not None:
|
||||||
results['bluetooth'] = self.compare_bluetooth(bt_devices)
|
results['bluetooth'] = self.compare_bluetooth(bt_devices)
|
||||||
results['total_new'] += results['bluetooth']['new_count']
|
results['total_new'] += results['bluetooth']['new_count']
|
||||||
@@ -447,7 +371,6 @@ class BaselineComparator:
|
|||||||
|
|
||||||
def get_comparison_for_active_baseline(
|
def get_comparison_for_active_baseline(
|
||||||
wifi_devices: list[dict] | None = None,
|
wifi_devices: list[dict] | None = None,
|
||||||
wifi_clients: list[dict] | None = None,
|
|
||||||
bt_devices: list[dict] | None = None,
|
bt_devices: list[dict] | None = None,
|
||||||
rf_signals: list[dict] | None = None
|
rf_signals: list[dict] | None = None
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
@@ -462,4 +385,4 @@ def get_comparison_for_active_baseline(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
comparator = BaselineComparator(baseline)
|
comparator = BaselineComparator(baseline)
|
||||||
return comparator.compare_all(wifi_devices, wifi_clients, bt_devices, rf_signals)
|
return comparator.compare_all(wifi_devices, bt_devices, rf_signals)
|
||||||
|
|||||||
+71
-209
@@ -122,11 +122,6 @@ class DeviceProfile:
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
manufacturer: Optional[str] = None
|
manufacturer: Optional[str] = None
|
||||||
device_type: Optional[str] = None
|
device_type: Optional[str] = None
|
||||||
tracker_type: Optional[str] = None
|
|
||||||
tracker_name: Optional[str] = None
|
|
||||||
tracker_confidence: Optional[str] = None
|
|
||||||
tracker_confidence_score: Optional[float] = None
|
|
||||||
tracker_evidence: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
# Bluetooth-specific
|
# Bluetooth-specific
|
||||||
services: list[str] = field(default_factory=list)
|
services: list[str] = field(default_factory=list)
|
||||||
@@ -244,11 +239,6 @@ class DeviceProfile:
|
|||||||
'name': self.name,
|
'name': self.name,
|
||||||
'manufacturer': self.manufacturer,
|
'manufacturer': self.manufacturer,
|
||||||
'device_type': self.device_type,
|
'device_type': self.device_type,
|
||||||
'tracker_type': self.tracker_type,
|
|
||||||
'tracker_name': self.tracker_name,
|
|
||||||
'tracker_confidence': self.tracker_confidence,
|
|
||||||
'tracker_confidence_score': self.tracker_confidence_score,
|
|
||||||
'tracker_evidence': self.tracker_evidence,
|
|
||||||
'ssid': self.ssid,
|
'ssid': self.ssid,
|
||||||
'frequency': self.frequency,
|
'frequency': self.frequency,
|
||||||
'first_seen': self.first_seen.isoformat() if self.first_seen else None,
|
'first_seen': self.first_seen.isoformat() if self.first_seen else None,
|
||||||
@@ -285,25 +275,6 @@ AUDIO_SERVICE_UUIDS = [
|
|||||||
'00001203-0000-1000-8000-00805f9b34fb', # Generic Audio
|
'00001203-0000-1000-8000-00805f9b34fb', # Generic Audio
|
||||||
]
|
]
|
||||||
|
|
||||||
_BT_BASE_UUID_SUFFIX = '-0000-1000-8000-00805f9b34fb'
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_bt_uuid(value: str) -> str:
|
|
||||||
"""Normalize BLE UUIDs to 16-bit where possible."""
|
|
||||||
if not value:
|
|
||||||
return ''
|
|
||||||
uuid = str(value).lower().strip()
|
|
||||||
if uuid.startswith('0x'):
|
|
||||||
uuid = uuid[2:]
|
|
||||||
if uuid.endswith(_BT_BASE_UUID_SUFFIX) and len(uuid) >= 8:
|
|
||||||
return uuid[4:8]
|
|
||||||
if len(uuid) == 4:
|
|
||||||
return uuid
|
|
||||||
return uuid
|
|
||||||
|
|
||||||
|
|
||||||
AUDIO_SERVICE_UUIDS_16 = {_normalize_bt_uuid(u) for u in AUDIO_SERVICE_UUIDS}
|
|
||||||
|
|
||||||
# Generic chipset vendors (often used in covert devices)
|
# Generic chipset vendors (often used in covert devices)
|
||||||
GENERIC_CHIPSET_VENDORS = [
|
GENERIC_CHIPSET_VENDORS = [
|
||||||
'espressif',
|
'espressif',
|
||||||
@@ -445,23 +416,9 @@ class CorrelationEngine:
|
|||||||
profile.name = device.get('name') or profile.name
|
profile.name = device.get('name') or profile.name
|
||||||
profile.manufacturer = device.get('manufacturer') or profile.manufacturer
|
profile.manufacturer = device.get('manufacturer') or profile.manufacturer
|
||||||
profile.device_type = device.get('type') or profile.device_type
|
profile.device_type = device.get('type') or profile.device_type
|
||||||
services = device.get('services')
|
profile.services = device.get('services', []) or profile.services
|
||||||
if not services:
|
|
||||||
services = device.get('service_uuids')
|
|
||||||
profile.services = services or profile.services
|
|
||||||
profile.company_id = device.get('company_id') or profile.company_id
|
profile.company_id = device.get('company_id') or profile.company_id
|
||||||
profile.advertising_interval = device.get('advertising_interval') or profile.advertising_interval
|
profile.advertising_interval = device.get('advertising_interval') or profile.advertising_interval
|
||||||
tracker_data = device.get('tracker') or {}
|
|
||||||
if tracker_data:
|
|
||||||
profile.tracker_type = tracker_data.get('type') or profile.tracker_type
|
|
||||||
profile.tracker_name = tracker_data.get('name') or profile.tracker_name
|
|
||||||
profile.tracker_confidence = tracker_data.get('confidence') or profile.tracker_confidence
|
|
||||||
profile.tracker_confidence_score = tracker_data.get('confidence_score') or profile.tracker_confidence_score
|
|
||||||
evidence = tracker_data.get('evidence')
|
|
||||||
if isinstance(evidence, list):
|
|
||||||
profile.tracker_evidence = evidence
|
|
||||||
elif evidence:
|
|
||||||
profile.tracker_evidence = [str(evidence)]
|
|
||||||
|
|
||||||
# Add RSSI sample
|
# Add RSSI sample
|
||||||
rssi = device.get('rssi', device.get('signal'))
|
rssi = device.get('rssi', device.get('signal'))
|
||||||
@@ -477,19 +434,6 @@ class CorrelationEngine:
|
|||||||
# === Detection Logic ===
|
# === Detection Logic ===
|
||||||
|
|
||||||
# 1. Unknown manufacturer or generic chipset
|
# 1. Unknown manufacturer or generic chipset
|
||||||
if not profile.manufacturer and mac and not device.get('is_randomized_mac'):
|
|
||||||
try:
|
|
||||||
first_octet = int(mac.split(':')[0], 16)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
first_octet = None
|
|
||||||
if first_octet is None or not (first_octet & 0x02):
|
|
||||||
try:
|
|
||||||
from data.oui import get_manufacturer
|
|
||||||
vendor = get_manufacturer(mac)
|
|
||||||
if vendor and vendor != 'Unknown':
|
|
||||||
profile.manufacturer = vendor
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not profile.manufacturer:
|
if not profile.manufacturer:
|
||||||
profile.add_indicator(
|
profile.add_indicator(
|
||||||
IndicatorType.UNKNOWN_DEVICE,
|
IndicatorType.UNKNOWN_DEVICE,
|
||||||
@@ -513,8 +457,8 @@ class CorrelationEngine:
|
|||||||
|
|
||||||
# 3. Audio-capable services
|
# 3. Audio-capable services
|
||||||
if profile.services:
|
if profile.services:
|
||||||
normalized_services = {_normalize_bt_uuid(s) for s in profile.services if s}
|
audio_services = [s for s in profile.services
|
||||||
audio_services = [s for s in normalized_services if s in AUDIO_SERVICE_UUIDS_16]
|
if s.lower() in [u.lower() for u in AUDIO_SERVICE_UUIDS]]
|
||||||
if audio_services:
|
if audio_services:
|
||||||
profile.add_indicator(
|
profile.add_indicator(
|
||||||
IndicatorType.AUDIO_CAPABLE,
|
IndicatorType.AUDIO_CAPABLE,
|
||||||
@@ -577,38 +521,6 @@ class CorrelationEngine:
|
|||||||
# 9. Known tracker detection (AirTag, Tile, SmartTag, ESP32)
|
# 9. Known tracker detection (AirTag, Tile, SmartTag, ESP32)
|
||||||
mac_prefix = mac[:8] if len(mac) >= 8 else ''
|
mac_prefix = mac[:8] if len(mac) >= 8 else ''
|
||||||
tracker_detected = False
|
tracker_detected = False
|
||||||
tracker_data = device.get('tracker') or {}
|
|
||||||
|
|
||||||
if tracker_data.get('is_tracker'):
|
|
||||||
tracker_detected = True
|
|
||||||
tracker_label = tracker_data.get('name') or tracker_data.get('type')
|
|
||||||
if tracker_label:
|
|
||||||
label_lower = str(tracker_label).lower()
|
|
||||||
if 'airtag' in label_lower or 'find my' in label_lower:
|
|
||||||
profile.add_indicator(
|
|
||||||
IndicatorType.AIRTAG_DETECTED,
|
|
||||||
f'Tracker detected: {tracker_label}',
|
|
||||||
{'mac': mac, 'tracker_type': tracker_label}
|
|
||||||
)
|
|
||||||
profile.device_type = 'AirTag'
|
|
||||||
elif 'tile' in label_lower:
|
|
||||||
profile.add_indicator(
|
|
||||||
IndicatorType.TILE_DETECTED,
|
|
||||||
f'Tracker detected: {tracker_label}',
|
|
||||||
{'mac': mac, 'tracker_type': tracker_label}
|
|
||||||
)
|
|
||||||
profile.device_type = 'Tile Tracker'
|
|
||||||
elif 'smarttag' in label_lower or 'samsung' in label_lower:
|
|
||||||
profile.add_indicator(
|
|
||||||
IndicatorType.SMARTTAG_DETECTED,
|
|
||||||
f'Tracker detected: {tracker_label}',
|
|
||||||
{'mac': mac, 'tracker_type': tracker_label}
|
|
||||||
)
|
|
||||||
profile.device_type = 'Samsung SmartTag'
|
|
||||||
else:
|
|
||||||
profile.device_type = tracker_label
|
|
||||||
elif not profile.device_type:
|
|
||||||
profile.device_type = 'Tracker'
|
|
||||||
|
|
||||||
# Check for tracker flags from BLE scanner (manufacturer ID detection)
|
# Check for tracker flags from BLE scanner (manufacturer ID detection)
|
||||||
if device.get('is_airtag'):
|
if device.get('is_airtag'):
|
||||||
@@ -762,25 +674,15 @@ class CorrelationEngine:
|
|||||||
"""
|
"""
|
||||||
bssid = device.get('bssid', device.get('mac', '')).upper()
|
bssid = device.get('bssid', device.get('mac', '')).upper()
|
||||||
profile = self.get_or_create_profile(bssid, 'wifi')
|
profile = self.get_or_create_profile(bssid, 'wifi')
|
||||||
is_client = bool(device.get('is_client') or device.get('role') == 'client')
|
|
||||||
|
|
||||||
# Update profile data
|
# Update profile data
|
||||||
ssid = device.get('ssid', device.get('essid', ''))
|
ssid = device.get('ssid', device.get('essid', ''))
|
||||||
if is_client:
|
profile.ssid = ssid if ssid else profile.ssid
|
||||||
profile.name = device.get('name') or device.get('vendor') or profile.name or f'Client ({bssid[-8:]})'
|
profile.name = ssid or f'Hidden Network ({bssid[-8:]})'
|
||||||
profile.device_type = 'client'
|
profile.channel = device.get('channel') or profile.channel
|
||||||
profile.ssid = profile.ssid # Clients are not SSIDs
|
profile.encryption = device.get('encryption', device.get('privacy')) or profile.encryption
|
||||||
profile.channel = device.get('channel') or profile.channel
|
profile.beacon_interval = device.get('beacon_interval') or profile.beacon_interval
|
||||||
profile.encryption = profile.encryption
|
profile.is_hidden = not ssid or ssid in ['', 'Hidden', '[Hidden]']
|
||||||
profile.beacon_interval = profile.beacon_interval
|
|
||||||
profile.is_hidden = False
|
|
||||||
else:
|
|
||||||
profile.ssid = ssid if ssid else profile.ssid
|
|
||||||
profile.name = ssid or f'Hidden Network ({bssid[-8:]})'
|
|
||||||
profile.channel = device.get('channel') or profile.channel
|
|
||||||
profile.encryption = device.get('encryption', device.get('privacy')) or profile.encryption
|
|
||||||
profile.beacon_interval = device.get('beacon_interval') or profile.beacon_interval
|
|
||||||
profile.is_hidden = not ssid or ssid in ['', 'Hidden', '[Hidden]']
|
|
||||||
|
|
||||||
# Extract manufacturer from OUI
|
# Extract manufacturer from OUI
|
||||||
if bssid and len(bssid) >= 8:
|
if bssid and len(bssid) >= 8:
|
||||||
@@ -798,118 +700,78 @@ class CorrelationEngine:
|
|||||||
profile.indicators = []
|
profile.indicators = []
|
||||||
|
|
||||||
# === Detection Logic ===
|
# === Detection Logic ===
|
||||||
if is_client:
|
|
||||||
if not profile.manufacturer:
|
|
||||||
profile.add_indicator(
|
|
||||||
IndicatorType.UNKNOWN_DEVICE,
|
|
||||||
'Unknown client manufacturer',
|
|
||||||
{'mac': bssid}
|
|
||||||
)
|
|
||||||
|
|
||||||
if profile.detection_count >= 3:
|
# 1. Hidden or unnamed SSID
|
||||||
profile.add_indicator(
|
if profile.is_hidden:
|
||||||
IndicatorType.PERSISTENT,
|
profile.add_indicator(
|
||||||
f'Persistent client ({profile.detection_count} detections)',
|
IndicatorType.HIDDEN_IDENTITY,
|
||||||
{'count': profile.detection_count}
|
'Hidden or empty SSID',
|
||||||
)
|
{'ssid': ssid}
|
||||||
|
)
|
||||||
|
|
||||||
rssi_stability = profile.get_rssi_stability()
|
# 2. BSSID not in authorized list (would need baseline)
|
||||||
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
|
# For now, mark as unknown if no manufacturer
|
||||||
profile.add_indicator(
|
if not profile.manufacturer:
|
||||||
IndicatorType.STABLE_RSSI,
|
profile.add_indicator(
|
||||||
f'Stable client signal (stability: {rssi_stability:.0%})',
|
IndicatorType.UNKNOWN_DEVICE,
|
||||||
{'stability': rssi_stability}
|
'Unknown AP manufacturer',
|
||||||
)
|
{'bssid': bssid}
|
||||||
|
)
|
||||||
|
|
||||||
if self.is_during_meeting():
|
# 3. Consumer device OUI in restricted environment
|
||||||
profile.add_indicator(
|
consumer_ouis = ['tp-link', 'netgear', 'd-link', 'linksys', 'asus']
|
||||||
IndicatorType.MEETING_CORRELATED,
|
if profile.manufacturer and any(c in profile.manufacturer.lower() for c in consumer_ouis):
|
||||||
'Detected during sensitive period',
|
profile.add_indicator(
|
||||||
{'during_meeting': True}
|
IndicatorType.ROGUE_AP,
|
||||||
)
|
f'Consumer-grade AP detected: {profile.manufacturer}',
|
||||||
|
{'manufacturer': profile.manufacturer}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
# 4. Camera device patterns
|
||||||
first_octet = int(bssid.split(':')[0], 16)
|
camera_keywords = ['cam', 'camera', 'ipcam', 'dvr', 'nvr', 'wyze',
|
||||||
if first_octet & 0x02:
|
'ring', 'arlo', 'nest', 'blink', 'eufy', 'yi']
|
||||||
profile.add_indicator(
|
if ssid and any(k in ssid.lower() for k in camera_keywords):
|
||||||
IndicatorType.MAC_ROTATION,
|
profile.add_indicator(
|
||||||
'Random/locally administered MAC detected',
|
IndicatorType.AUDIO_CAPABLE, # Cameras often have mics
|
||||||
{'mac': bssid}
|
f'Potential camera device: {ssid}',
|
||||||
)
|
{'ssid': ssid}
|
||||||
except (ValueError, IndexError):
|
)
|
||||||
pass
|
|
||||||
else:
|
|
||||||
# 1. Hidden or unnamed SSID
|
|
||||||
if profile.is_hidden:
|
|
||||||
profile.add_indicator(
|
|
||||||
IndicatorType.HIDDEN_IDENTITY,
|
|
||||||
'Hidden or empty SSID',
|
|
||||||
{'ssid': ssid}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. BSSID not in authorized list (would need baseline)
|
# 5. Persistent presence
|
||||||
# For now, mark as unknown if no manufacturer
|
if profile.detection_count >= 3:
|
||||||
if not profile.manufacturer:
|
profile.add_indicator(
|
||||||
profile.add_indicator(
|
IndicatorType.PERSISTENT,
|
||||||
IndicatorType.UNKNOWN_DEVICE,
|
f'Persistent AP ({profile.detection_count} detections)',
|
||||||
'Unknown AP manufacturer',
|
{'count': profile.detection_count}
|
||||||
{'bssid': bssid}
|
)
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Consumer device OUI in restricted environment
|
# 6. Stable RSSI (fixed placement)
|
||||||
consumer_ouis = ['tp-link', 'netgear', 'd-link', 'linksys', 'asus']
|
rssi_stability = profile.get_rssi_stability()
|
||||||
if profile.manufacturer and any(c in profile.manufacturer.lower() for c in consumer_ouis):
|
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.STABLE_RSSI,
|
||||||
|
f'Stable signal (stability: {rssi_stability:.0%})',
|
||||||
|
{'stability': rssi_stability}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7. Meeting correlation
|
||||||
|
if self.is_during_meeting():
|
||||||
|
profile.add_indicator(
|
||||||
|
IndicatorType.MEETING_CORRELATED,
|
||||||
|
'Detected during sensitive period',
|
||||||
|
{'during_meeting': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 8. Strong hidden AP (very suspicious)
|
||||||
|
if profile.is_hidden and profile.rssi_samples:
|
||||||
|
latest_rssi = profile.rssi_samples[-1][1]
|
||||||
|
if latest_rssi > -50:
|
||||||
profile.add_indicator(
|
profile.add_indicator(
|
||||||
IndicatorType.ROGUE_AP,
|
IndicatorType.ROGUE_AP,
|
||||||
f'Consumer-grade AP detected: {profile.manufacturer}',
|
f'Strong hidden AP (RSSI: {latest_rssi} dBm)',
|
||||||
{'manufacturer': profile.manufacturer}
|
{'rssi': latest_rssi}
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Camera device patterns
|
|
||||||
camera_keywords = ['cam', 'camera', 'ipcam', 'dvr', 'nvr', 'wyze',
|
|
||||||
'ring', 'arlo', 'nest', 'blink', 'eufy', 'yi']
|
|
||||||
if ssid and any(k in ssid.lower() for k in camera_keywords):
|
|
||||||
profile.add_indicator(
|
|
||||||
IndicatorType.AUDIO_CAPABLE, # Cameras often have mics
|
|
||||||
f'Potential camera device: {ssid}',
|
|
||||||
{'ssid': ssid}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. Persistent presence
|
|
||||||
if profile.detection_count >= 3:
|
|
||||||
profile.add_indicator(
|
|
||||||
IndicatorType.PERSISTENT,
|
|
||||||
f'Persistent AP ({profile.detection_count} detections)',
|
|
||||||
{'count': profile.detection_count}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 6. Stable RSSI (fixed placement)
|
|
||||||
rssi_stability = profile.get_rssi_stability()
|
|
||||||
if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5:
|
|
||||||
profile.add_indicator(
|
|
||||||
IndicatorType.STABLE_RSSI,
|
|
||||||
f'Stable signal (stability: {rssi_stability:.0%})',
|
|
||||||
{'stability': rssi_stability}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 7. Meeting correlation
|
|
||||||
if self.is_during_meeting():
|
|
||||||
profile.add_indicator(
|
|
||||||
IndicatorType.MEETING_CORRELATED,
|
|
||||||
'Detected during sensitive period',
|
|
||||||
{'during_meeting': True}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 8. Strong hidden AP (very suspicious)
|
|
||||||
if profile.is_hidden and profile.rssi_samples:
|
|
||||||
latest_rssi = profile.rssi_samples[-1][1]
|
|
||||||
if latest_rssi > -50:
|
|
||||||
profile.add_indicator(
|
|
||||||
IndicatorType.ROGUE_AP,
|
|
||||||
f'Strong hidden AP (RSSI: {latest_rssi} dBm)',
|
|
||||||
{'rssi': latest_rssi}
|
|
||||||
)
|
|
||||||
|
|
||||||
self._apply_known_device_modifier(profile, bssid, 'wifi')
|
self._apply_known_device_modifier(profile, bssid, 'wifi')
|
||||||
|
|
||||||
return profile
|
return profile
|
||||||
|
|||||||
+1
-19
@@ -122,10 +122,6 @@ class ThreatDetector:
|
|||||||
if 'mac' in client:
|
if 'mac' in client:
|
||||||
self.baseline_wifi_macs.add(client['mac'].upper())
|
self.baseline_wifi_macs.add(client['mac'].upper())
|
||||||
|
|
||||||
for client in baseline.get('wifi_clients', []):
|
|
||||||
if 'mac' in client:
|
|
||||||
self.baseline_wifi_macs.add(client['mac'].upper())
|
|
||||||
|
|
||||||
# Bluetooth devices
|
# Bluetooth devices
|
||||||
for device in baseline.get('bt_devices', []):
|
for device in baseline.get('bt_devices', []):
|
||||||
if 'mac' in device:
|
if 'mac' in device:
|
||||||
@@ -483,7 +479,6 @@ class ThreatDetector:
|
|||||||
manufacturer = device.get('manufacturer', '')
|
manufacturer = device.get('manufacturer', '')
|
||||||
device_type = device.get('type', '')
|
device_type = device.get('type', '')
|
||||||
manufacturer_data = device.get('manufacturer_data')
|
manufacturer_data = device.get('manufacturer_data')
|
||||||
tracker_data = device.get('tracker', {}) or {}
|
|
||||||
|
|
||||||
threats = []
|
threats = []
|
||||||
|
|
||||||
@@ -495,20 +490,7 @@ class ThreatDetector:
|
|||||||
'reason': 'Device not present in baseline',
|
'reason': 'Device not present in baseline',
|
||||||
})
|
})
|
||||||
|
|
||||||
# Check for known trackers (v2 tracker data if available)
|
# Check for known trackers
|
||||||
if tracker_data.get('is_tracker'):
|
|
||||||
tracker_label = tracker_data.get('name') or tracker_data.get('type') or 'Tracker'
|
|
||||||
confidence = str(tracker_data.get('confidence') or '').lower()
|
|
||||||
severity = 'high' if confidence in ('high', 'medium') else 'medium'
|
|
||||||
threats.append({
|
|
||||||
'type': 'tracker',
|
|
||||||
'severity': severity,
|
|
||||||
'reason': f"Tracker detected: {tracker_label}",
|
|
||||||
'tracker_type': tracker_label,
|
|
||||||
'details': tracker_data.get('evidence', []),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Check for known trackers (legacy patterns)
|
|
||||||
tracker_info = is_known_tracker(name, manufacturer_data)
|
tracker_info = is_known_tracker(name, manufacturer_data)
|
||||||
if tracker_info:
|
if tracker_info:
|
||||||
threats.append({
|
threats.append({
|
||||||
|
|||||||
+1
-11
@@ -105,7 +105,6 @@ class TSCMReport:
|
|||||||
# Statistics
|
# Statistics
|
||||||
total_devices_scanned: int = 0
|
total_devices_scanned: int = 0
|
||||||
wifi_devices: int = 0
|
wifi_devices: int = 0
|
||||||
wifi_clients: int = 0
|
|
||||||
bluetooth_devices: int = 0
|
bluetooth_devices: int = 0
|
||||||
rf_signals: int = 0
|
rf_signals: int = 0
|
||||||
new_devices: int = 0
|
new_devices: int = 0
|
||||||
@@ -204,7 +203,6 @@ def generate_executive_summary(report: TSCMReport) -> str:
|
|||||||
lines.append("SCAN STATISTICS:")
|
lines.append("SCAN STATISTICS:")
|
||||||
lines.append(f" - Total devices scanned: {report.total_devices_scanned}")
|
lines.append(f" - Total devices scanned: {report.total_devices_scanned}")
|
||||||
lines.append(f" - WiFi access points: {report.wifi_devices}")
|
lines.append(f" - WiFi access points: {report.wifi_devices}")
|
||||||
lines.append(f" - WiFi clients: {report.wifi_clients}")
|
|
||||||
lines.append(f" - Bluetooth devices: {report.bluetooth_devices}")
|
lines.append(f" - Bluetooth devices: {report.bluetooth_devices}")
|
||||||
lines.append(f" - RF signals: {report.rf_signals}")
|
lines.append(f" - RF signals: {report.rf_signals}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -432,7 +430,6 @@ def generate_technical_annex_json(report: TSCMReport) -> dict:
|
|||||||
'statistics': {
|
'statistics': {
|
||||||
'total_devices': report.total_devices_scanned,
|
'total_devices': report.total_devices_scanned,
|
||||||
'wifi_devices': report.wifi_devices,
|
'wifi_devices': report.wifi_devices,
|
||||||
'wifi_clients': report.wifi_clients,
|
|
||||||
'bluetooth_devices': report.bluetooth_devices,
|
'bluetooth_devices': report.bluetooth_devices,
|
||||||
'rf_signals': report.rf_signals,
|
'rf_signals': report.rf_signals,
|
||||||
'new_devices': report.new_devices,
|
'new_devices': report.new_devices,
|
||||||
@@ -787,17 +784,15 @@ class TSCMReportBuilder:
|
|||||||
def add_statistics(
|
def add_statistics(
|
||||||
self,
|
self,
|
||||||
wifi: int = 0,
|
wifi: int = 0,
|
||||||
wifi_clients: int = 0,
|
|
||||||
bluetooth: int = 0,
|
bluetooth: int = 0,
|
||||||
rf: int = 0,
|
rf: int = 0,
|
||||||
new: int = 0,
|
new: int = 0,
|
||||||
missing: int = 0
|
missing: int = 0
|
||||||
) -> 'TSCMReportBuilder':
|
) -> 'TSCMReportBuilder':
|
||||||
self.report.wifi_devices = wifi
|
self.report.wifi_devices = wifi
|
||||||
self.report.wifi_clients = wifi_clients
|
|
||||||
self.report.bluetooth_devices = bluetooth
|
self.report.bluetooth_devices = bluetooth
|
||||||
self.report.rf_signals = rf
|
self.report.rf_signals = rf
|
||||||
self.report.total_devices_scanned = wifi + wifi_clients + bluetooth + rf
|
self.report.total_devices_scanned = wifi + bluetooth + rf
|
||||||
self.report.new_devices = new
|
self.report.new_devices = new
|
||||||
self.report.missing_devices = missing
|
self.report.missing_devices = missing
|
||||||
return self
|
return self
|
||||||
@@ -900,10 +895,6 @@ def generate_report(
|
|||||||
if wifi_count is None:
|
if wifi_count is None:
|
||||||
wifi_count = len(results.get('wifi_devices', results.get('wifi', [])))
|
wifi_count = len(results.get('wifi_devices', results.get('wifi', [])))
|
||||||
|
|
||||||
wifi_client_count = results.get('wifi_client_count')
|
|
||||||
if wifi_client_count is None:
|
|
||||||
wifi_client_count = len(results.get('wifi_clients', []))
|
|
||||||
|
|
||||||
bt_count = results.get('bt_count')
|
bt_count = results.get('bt_count')
|
||||||
if bt_count is None:
|
if bt_count is None:
|
||||||
bt_count = len(results.get('bt_devices', results.get('bluetooth', [])))
|
bt_count = len(results.get('bt_devices', results.get('bluetooth', [])))
|
||||||
@@ -914,7 +905,6 @@ def generate_report(
|
|||||||
|
|
||||||
builder.add_statistics(
|
builder.add_statistics(
|
||||||
wifi=wifi_count,
|
wifi=wifi_count,
|
||||||
wifi_clients=wifi_client_count,
|
|
||||||
bluetooth=bt_count,
|
bluetooth=bt_count,
|
||||||
rf=rf_count,
|
rf=rf_count,
|
||||||
new=baseline_diff.get('summary', {}).get('new_devices', 0) if baseline_diff else 0,
|
new=baseline_diff.get('summary', {}).get('new_devices', 0) if baseline_diff else 0,
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
"""FFT pipeline for real-time waterfall display.
|
|
||||||
|
|
||||||
Converts raw I/Q samples from SDR hardware into quantized power spectrum
|
|
||||||
frames suitable for binary WebSocket transmission.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
|
|
||||||
def cu8_to_complex(raw: bytes) -> np.ndarray:
|
|
||||||
"""Convert unsigned 8-bit I/Q bytes to complex64.
|
|
||||||
|
|
||||||
RTL-SDR (and rx_sdr with -F cu8) outputs interleaved unsigned 8-bit
|
|
||||||
I/Q pairs where 128 is the zero point.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
raw: Raw bytes, length must be even (I/Q pairs).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Complex64 array of length len(raw) // 2.
|
|
||||||
"""
|
|
||||||
iq = np.frombuffer(raw, dtype=np.uint8).astype(np.float32)
|
|
||||||
# Normalize: 0 -> -1.0, 128 -> ~0.0, 255 -> +1.0
|
|
||||||
iq = (iq - 127.5) / 127.5
|
|
||||||
return iq[0::2] + 1j * iq[1::2]
|
|
||||||
|
|
||||||
|
|
||||||
def compute_power_spectrum(
|
|
||||||
samples: np.ndarray,
|
|
||||||
fft_size: int = 1024,
|
|
||||||
avg_count: int = 4,
|
|
||||||
) -> np.ndarray:
|
|
||||||
"""Compute averaged power spectrum in dBm.
|
|
||||||
|
|
||||||
Applies a Hann window, computes FFT, converts to power (dB),
|
|
||||||
and averages over multiple segments.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
samples: Complex64 array, length >= fft_size * avg_count.
|
|
||||||
fft_size: Number of FFT bins.
|
|
||||||
avg_count: Number of segments to average.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Float32 array of length fft_size with power in dB (fftshift'd).
|
|
||||||
"""
|
|
||||||
window = np.hanning(fft_size).astype(np.float32)
|
|
||||||
accum = np.zeros(fft_size, dtype=np.float32)
|
|
||||||
actual_avg = 0
|
|
||||||
|
|
||||||
for i in range(avg_count):
|
|
||||||
offset = i * fft_size
|
|
||||||
if offset + fft_size > len(samples):
|
|
||||||
break
|
|
||||||
segment = samples[offset : offset + fft_size] * window
|
|
||||||
spectrum = np.fft.fft(segment)
|
|
||||||
power = np.real(spectrum * np.conj(spectrum))
|
|
||||||
# Avoid log10(0)
|
|
||||||
power = np.maximum(power, 1e-20)
|
|
||||||
accum += 10.0 * np.log10(power)
|
|
||||||
actual_avg += 1
|
|
||||||
|
|
||||||
if actual_avg == 0:
|
|
||||||
return np.full(fft_size, -100.0, dtype=np.float32)
|
|
||||||
|
|
||||||
accum /= actual_avg
|
|
||||||
return np.fft.fftshift(accum).astype(np.float32)
|
|
||||||
|
|
||||||
|
|
||||||
def quantize_to_uint8(
|
|
||||||
power_db: np.ndarray,
|
|
||||||
db_min: float = -90.0,
|
|
||||||
db_max: float = -20.0,
|
|
||||||
) -> bytes:
|
|
||||||
"""Clamp and scale dB values to 0-255.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
power_db: Float32 array of power values in dB.
|
|
||||||
db_min: Value mapped to 0.
|
|
||||||
db_max: Value mapped to 255.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Bytes of length len(power_db), each in [0, 255].
|
|
||||||
"""
|
|
||||||
db_range = db_max - db_min
|
|
||||||
if db_range <= 0:
|
|
||||||
db_range = 1.0
|
|
||||||
scaled = (power_db - db_min) / db_range * 255.0
|
|
||||||
clamped = np.clip(scaled, 0, 255).astype(np.uint8)
|
|
||||||
return clamped.tobytes()
|
|
||||||
|
|
||||||
|
|
||||||
def build_binary_frame(
|
|
||||||
start_freq: float,
|
|
||||||
end_freq: float,
|
|
||||||
quantized_bins: bytes,
|
|
||||||
) -> bytes:
|
|
||||||
"""Pack a binary waterfall frame for WebSocket transmission.
|
|
||||||
|
|
||||||
Wire format (little-endian):
|
|
||||||
[uint8 msg_type=0x01]
|
|
||||||
[float32 start_freq]
|
|
||||||
[float32 end_freq]
|
|
||||||
[uint16 bin_count]
|
|
||||||
[uint8[] bins]
|
|
||||||
|
|
||||||
Total size = 11 + bin_count bytes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start_freq: Start frequency in MHz.
|
|
||||||
end_freq: End frequency in MHz.
|
|
||||||
quantized_bins: Pre-quantized uint8 bin data.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Binary frame bytes.
|
|
||||||
"""
|
|
||||||
bin_count = len(quantized_bins)
|
|
||||||
header = struct.pack('<BffH', 0x01, start_freq, end_freq, bin_count)
|
|
||||||
return header + quantized_bins
|
|
||||||
+1
-14
@@ -421,20 +421,7 @@ def get_vendor_from_mac(mac: str) -> str | None:
|
|||||||
# Normalize MAC format
|
# Normalize MAC format
|
||||||
mac_upper = mac.upper().replace('-', ':')
|
mac_upper = mac.upper().replace('-', ':')
|
||||||
oui = mac_upper[:8]
|
oui = mac_upper[:8]
|
||||||
vendor = VENDOR_OUIS.get(oui)
|
return VENDOR_OUIS.get(oui)
|
||||||
if vendor:
|
|
||||||
return vendor
|
|
||||||
|
|
||||||
# Fallback to expanded OUI database if available
|
|
||||||
try:
|
|
||||||
from data.oui import get_manufacturer
|
|
||||||
manufacturer = get_manufacturer(mac_upper)
|
|
||||||
if manufacturer and manufacturer != 'Unknown':
|
|
||||||
return manufacturer
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -264,7 +264,6 @@ class WiFiAccessPoint:
|
|||||||
return {
|
return {
|
||||||
'bssid': self.bssid,
|
'bssid': self.bssid,
|
||||||
'essid': self.essid or '',
|
'essid': self.essid or '',
|
||||||
'vendor': self.vendor,
|
|
||||||
'power': str(self.rssi_current) if self.rssi_current else '-100',
|
'power': str(self.rssi_current) if self.rssi_current else '-100',
|
||||||
'channel': str(self.channel) if self.channel else '',
|
'channel': str(self.channel) if self.channel else '',
|
||||||
'privacy': self.security,
|
'privacy': self.security,
|
||||||
|
|||||||
+3
-12
@@ -667,7 +667,6 @@ class UnifiedWiFiScanner:
|
|||||||
interface: Optional[str] = None,
|
interface: Optional[str] = None,
|
||||||
band: str = 'all',
|
band: str = 'all',
|
||||||
channel: Optional[int] = None,
|
channel: Optional[int] = None,
|
||||||
channels: Optional[list[int]] = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Start continuous deep scan with airodump-ng.
|
Start continuous deep scan with airodump-ng.
|
||||||
@@ -703,7 +702,7 @@ class UnifiedWiFiScanner:
|
|||||||
self._deep_scan_stop_event.clear()
|
self._deep_scan_stop_event.clear()
|
||||||
self._deep_scan_thread = threading.Thread(
|
self._deep_scan_thread = threading.Thread(
|
||||||
target=self._run_deep_scan,
|
target=self._run_deep_scan,
|
||||||
args=(iface, band, channel, channels),
|
args=(iface, band, channel),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self._deep_scan_thread.start()
|
self._deep_scan_thread.start()
|
||||||
@@ -767,13 +766,7 @@ class UnifiedWiFiScanner:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _run_deep_scan(
|
def _run_deep_scan(self, interface: str, band: str, channel: Optional[int]):
|
||||||
self,
|
|
||||||
interface: str,
|
|
||||||
band: str,
|
|
||||||
channel: Optional[int],
|
|
||||||
channels: Optional[list[int]],
|
|
||||||
):
|
|
||||||
"""Background thread for running airodump-ng."""
|
"""Background thread for running airodump-ng."""
|
||||||
from .parsers.airodump import parse_airodump_csv
|
from .parsers.airodump import parse_airodump_csv
|
||||||
|
|
||||||
@@ -786,9 +779,7 @@ class UnifiedWiFiScanner:
|
|||||||
# Build command
|
# Build command
|
||||||
cmd = ['airodump-ng', '-w', output_prefix, '--output-format', 'csv']
|
cmd = ['airodump-ng', '-w', output_prefix, '--output-format', 'csv']
|
||||||
|
|
||||||
if channels:
|
if channel:
|
||||||
cmd.extend(['-c', ','.join(str(c) for c in channels)])
|
|
||||||
elif channel:
|
|
||||||
cmd.extend(['-c', str(channel)])
|
cmd.extend(['-c', str(channel)])
|
||||||
elif band == '2.4':
|
elif band == '2.4':
|
||||||
cmd.extend(['--band', 'bg'])
|
cmd.extend(['--band', 'bg'])
|
||||||
|
|||||||
Reference in New Issue
Block a user