diff --git a/data/isms_presets.py b/data/isms_presets.py new file mode 100644 index 0000000..12eb388 --- /dev/null +++ b/data/isms_presets.py @@ -0,0 +1,315 @@ +""" +ISMS scan presets and band definitions. + +Defines frequency ranges and parameters for common RF monitoring scenarios. +""" + +from __future__ import annotations + +# Scan presets for common monitoring scenarios +ISMS_SCAN_PRESETS: dict[str, dict] = { + 'vhf_airband': { + 'name': 'VHF Airband', + 'description': 'Aviation communications 118-137 MHz', + 'freq_start': 118.0, + 'freq_end': 137.0, + 'bin_size': 25000, # 25 kHz channel spacing + 'integration': 1.0, + 'category': 'aviation', + }, + 'uhf_airband': { + 'name': 'UHF Airband', + 'description': 'Military aviation 225-400 MHz', + 'freq_start': 225.0, + 'freq_end': 400.0, + 'bin_size': 25000, + 'integration': 1.0, + 'category': 'aviation', + }, + 'uhf_pmr': { + 'name': 'UHF PMR446', + 'description': 'License-free radio 446 MHz', + 'freq_start': 446.0, + 'freq_end': 446.2, + 'bin_size': 12500, # 12.5 kHz channel spacing + 'integration': 0.5, + 'category': 'pmr', + }, + 'ism_433': { + 'name': 'ISM 433 MHz', + 'description': 'European ISM band (sensors, remotes)', + 'freq_start': 433.0, + 'freq_end': 434.8, + 'bin_size': 10000, + 'integration': 0.5, + 'category': 'ism', + }, + 'ism_868': { + 'name': 'ISM 868 MHz', + 'description': 'European ISM band (LoRa, smart meters)', + 'freq_start': 868.0, + 'freq_end': 870.0, + 'bin_size': 10000, + 'integration': 0.5, + 'category': 'ism', + }, + 'ism_915': { + 'name': 'ISM 915 MHz', + 'description': 'US ISM band', + 'freq_start': 902.0, + 'freq_end': 928.0, + 'bin_size': 50000, + 'integration': 1.0, + 'category': 'ism', + }, + 'wifi_2g': { + 'name': 'WiFi 2.4 GHz Vicinity', + 'description': 'WiFi band activity (requires wideband SDR)', + 'freq_start': 2400.0, + 'freq_end': 2500.0, + 'bin_size': 500000, + 'integration': 2.0, + 'category': 'wifi', + 'note': 'Requires SDR with 2.4 GHz capability (HackRF, LimeSDR)', + }, + 'cellular_700': { + 'name': 'Cellular 700 MHz', + 'description': 'LTE Bands 12/13/17/28 downlink', + 'freq_start': 728.0, + 'freq_end': 803.0, + 'bin_size': 100000, + 'integration': 1.0, + 'category': 'cellular', + }, + 'cellular_850': { + 'name': 'Cellular 850 MHz', + 'description': 'GSM/LTE Band 5 downlink', + 'freq_start': 869.0, + 'freq_end': 894.0, + 'bin_size': 100000, + 'integration': 1.0, + 'category': 'cellular', + }, + 'cellular_900': { + 'name': 'Cellular 900 MHz', + 'description': 'GSM/LTE Band 8 downlink (Europe)', + 'freq_start': 925.0, + 'freq_end': 960.0, + 'bin_size': 100000, + 'integration': 1.0, + 'category': 'cellular', + }, + 'cellular_1800': { + 'name': 'Cellular 1800 MHz', + 'description': 'GSM/LTE Band 3 downlink', + 'freq_start': 1805.0, + 'freq_end': 1880.0, + 'bin_size': 100000, + 'integration': 1.0, + 'category': 'cellular', + }, + 'full_sweep': { + 'name': 'Full Spectrum', + 'description': 'Complete 24 MHz - 1.7 GHz sweep', + 'freq_start': 24.0, + 'freq_end': 1700.0, + 'bin_size': 100000, + 'integration': 5.0, + 'category': 'full', + 'note': 'Takes 3-5 minutes to complete', + }, +} + + +# Cellular band definitions (downlink frequencies for reference) +CELLULAR_BANDS: dict[str, dict] = { + 'B1': { + 'name': 'UMTS/LTE Band 1', + 'dl_start': 2110, + 'dl_end': 2170, + 'ul_start': 1920, + 'ul_end': 1980, + 'duplex': 'FDD', + }, + 'B3': { + 'name': 'LTE Band 3', + 'dl_start': 1805, + 'dl_end': 1880, + 'ul_start': 1710, + 'ul_end': 1785, + 'duplex': 'FDD', + }, + 'B5': { + 'name': 'GSM/LTE Band 5', + 'dl_start': 869, + 'dl_end': 894, + 'ul_start': 824, + 'ul_end': 849, + 'duplex': 'FDD', + }, + 'B7': { + 'name': 'LTE Band 7', + 'dl_start': 2620, + 'dl_end': 2690, + 'ul_start': 2500, + 'ul_end': 2570, + 'duplex': 'FDD', + }, + 'B8': { + 'name': 'GSM/LTE Band 8', + 'dl_start': 925, + 'dl_end': 960, + 'ul_start': 880, + 'ul_end': 915, + 'duplex': 'FDD', + }, + 'B20': { + 'name': 'LTE Band 20', + 'dl_start': 791, + 'dl_end': 821, + 'ul_start': 832, + 'ul_end': 862, + 'duplex': 'FDD', + }, + 'B28': { + 'name': 'LTE Band 28', + 'dl_start': 758, + 'dl_end': 803, + 'ul_start': 703, + 'ul_end': 748, + 'duplex': 'FDD', + }, + 'B38': { + 'name': 'LTE Band 38 (TDD)', + 'dl_start': 2570, + 'dl_end': 2620, + 'duplex': 'TDD', + }, + 'B40': { + 'name': 'LTE Band 40 (TDD)', + 'dl_start': 2300, + 'dl_end': 2400, + 'duplex': 'TDD', + }, + 'n77': { + 'name': 'NR Band n77 (5G)', + 'dl_start': 3300, + 'dl_end': 4200, + 'duplex': 'TDD', + }, + 'n78': { + 'name': 'NR Band n78 (5G)', + 'dl_start': 3300, + 'dl_end': 3800, + 'duplex': 'TDD', + }, +} + + +# UK Mobile Network Operators (for PLMN identification) +UK_OPERATORS: dict[str, str] = { + '234-10': 'O2 UK', + '234-15': 'Vodafone UK', + '234-20': 'Three UK', + '234-30': 'EE', + '234-33': 'EE', + '234-34': 'EE', + '234-50': 'JT (Jersey)', + '234-55': 'Sure (Guernsey)', +} + + +# Common ISM band allocations +ISM_BANDS: dict[str, dict] = { + 'ism_6m': { + 'name': '6.78 MHz ISM', + 'start': 6.765, + 'end': 6.795, + 'region': 'Worldwide', + }, + 'ism_13m': { + 'name': '13.56 MHz ISM (NFC/RFID)', + 'start': 13.553, + 'end': 13.567, + 'region': 'Worldwide', + }, + 'ism_27m': { + 'name': '27 MHz ISM (CB)', + 'start': 26.957, + 'end': 27.283, + 'region': 'Worldwide', + }, + 'ism_40m': { + 'name': '40.68 MHz ISM', + 'start': 40.66, + 'end': 40.70, + 'region': 'Worldwide', + }, + 'ism_433': { + 'name': '433 MHz ISM', + 'start': 433.05, + 'end': 434.79, + 'region': 'ITU Region 1 (Europe)', + }, + 'ism_868': { + 'name': '868 MHz ISM', + 'start': 868.0, + 'end': 870.0, + 'region': 'Europe', + }, + 'ism_915': { + 'name': '915 MHz ISM', + 'start': 902.0, + 'end': 928.0, + 'region': 'Americas', + }, + 'ism_2400': { + 'name': '2.4 GHz ISM (WiFi/BT)', + 'start': 2400.0, + 'end': 2500.0, + 'region': 'Worldwide', + }, + 'ism_5800': { + 'name': '5.8 GHz ISM', + 'start': 5725.0, + 'end': 5875.0, + 'region': 'Worldwide', + }, +} + + +def get_preset(preset_name: str) -> dict | None: + """Get a scan preset by name.""" + return ISMS_SCAN_PRESETS.get(preset_name) + + +def get_presets_by_category(category: str) -> list[dict]: + """Get all presets in a category.""" + return [ + {**preset, 'id': name} + for name, preset in ISMS_SCAN_PRESETS.items() + if preset.get('category') == category + ] + + +def get_all_presets() -> list[dict]: + """Get all presets with their IDs.""" + return [ + {**preset, 'id': name} + for name, preset in ISMS_SCAN_PRESETS.items() + ] + + +def identify_band(freq_mhz: float) -> str | None: + """Identify which cellular band a frequency belongs to.""" + for band_id, band_info in CELLULAR_BANDS.items(): + dl_start = band_info.get('dl_start', 0) + dl_end = band_info.get('dl_end', 0) + if dl_start <= freq_mhz <= dl_end: + return band_id + return None + + +def identify_operator(plmn: str) -> str | None: + """Identify UK operator from PLMN code.""" + return UK_OPERATORS.get(plmn) diff --git a/routes/__init__.py b/routes/__init__.py index b7960d1..3a017ed 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -15,6 +15,7 @@ def register_blueprints(app): from .correlation import correlation_bp from .listening_post import listening_post_bp from .tscm import tscm_bp, init_tscm_state + from .isms import isms_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -29,6 +30,7 @@ def register_blueprints(app): app.register_blueprint(correlation_bp) app.register_blueprint(listening_post_bp) app.register_blueprint(tscm_bp) + app.register_blueprint(isms_bp) # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/isms.py b/routes/isms.py new file mode 100644 index 0000000..ca34e2a --- /dev/null +++ b/routes/isms.py @@ -0,0 +1,981 @@ +"""ISMS Listening Station routes for spectrum monitoring and tower mapping.""" + +from __future__ import annotations + +import json +import queue +import shutil +import subprocess +import threading +import time +from datetime import datetime +from typing import Generator + +from flask import Blueprint, Response, jsonify, request + +from utils.logging import get_logger +from utils.sse import format_sse +from utils.constants import SSE_QUEUE_TIMEOUT, SSE_KEEPALIVE_INTERVAL +from utils.process import safe_terminate, register_process +from utils.gps import get_current_position +from utils.database import ( + create_isms_baseline, + get_isms_baseline, + get_all_isms_baselines, + get_active_isms_baseline, + set_active_isms_baseline, + delete_isms_baseline, + update_isms_baseline, + create_isms_scan, + update_isms_scan, + get_isms_scan, + get_recent_isms_scans, + add_isms_finding, + get_isms_findings, + get_isms_findings_summary, + acknowledge_isms_finding, +) +from utils.isms.spectrum import ( + run_rtl_power_scan, + compute_band_metrics, + detect_bursts, + get_rtl_power_path, + SpectrumBin, + BandMetrics, +) +from utils.isms.towers import ( + query_nearby_towers, + format_tower_info, + build_ofcom_coverage_url, + build_ofcom_emf_url, + get_opencellid_token, +) +from utils.isms.rules import RulesEngine, create_default_engine +from utils.isms.baseline import ( + BaselineRecorder, + compare_spectrum_baseline, + compare_tower_baseline, + save_baseline_to_db, +) +from utils.isms.gsm import ( + GsmCell, + run_grgsm_scan, + run_gsm_scan_blocking, + get_grgsm_scanner_path, + format_gsm_cell, + deduplicate_cells, + identify_gsm_anomalies, +) +from data.isms_presets import ( + ISMS_SCAN_PRESETS, + get_preset, + get_all_presets, + identify_band, +) + +logger = get_logger('intercept.isms') + +isms_bp = Blueprint('isms', __name__, url_prefix='/isms') + +# ============================================ +# GLOBAL STATE +# ============================================ + +# Scanner state +isms_thread: threading.Thread | None = None +isms_running = False +isms_lock = threading.Lock() +isms_process: subprocess.Popen | None = None +isms_current_scan_id: int | None = None + +# Scanner configuration +isms_config = { + 'preset': 'ism_433', + 'freq_start': 433.0, + 'freq_end': 434.8, + 'bin_size': 10000, + 'integration': 0.5, + 'device': 0, + 'gain': 40, + 'ppm': 0, + 'threshold': 50, # Activity threshold (0-100) + 'lat': None, + 'lon': None, + 'baseline_id': None, +} + +# SSE queue for real-time events +isms_queue: queue.Queue = queue.Queue(maxsize=100) + +# Rules engine +rules_engine: RulesEngine = create_default_engine() + +# Baseline recorder +baseline_recorder: BaselineRecorder = BaselineRecorder() + +# Recent band metrics for display +recent_metrics: dict[str, BandMetrics] = {} +metrics_lock = threading.Lock() + +# Findings count for current scan +current_findings_count = 0 + +# GSM scanner state +gsm_thread: threading.Thread | None = None +gsm_running = False +gsm_lock = threading.Lock() +gsm_detected_cells: list[GsmCell] = [] +gsm_baseline_cells: list[GsmCell] = [] + + +# ============================================ +# HELPER FUNCTIONS +# ============================================ + +def emit_event(event_type: str, data: dict) -> None: + """Emit an event to SSE queue.""" + try: + isms_queue.put_nowait({ + 'type': event_type, + **data + }) + except queue.Full: + pass + + +def emit_finding(severity: str, text: str, **details) -> None: + """Emit a finding event and store in database.""" + global current_findings_count + + emit_event('finding', { + 'severity': severity, + 'text': text, + 'details': details, + 'timestamp': datetime.utcnow().isoformat() + 'Z', + }) + + # Store in database if we have an active scan + if isms_current_scan_id: + add_isms_finding( + scan_id=isms_current_scan_id, + finding_type=details.get('finding_type', 'general'), + severity=severity, + description=text, + band=details.get('band'), + frequency=details.get('frequency'), + details=details, + ) + current_findings_count += 1 + + +def emit_meter(band: str, level: float, noise_floor: float) -> None: + """Emit a band meter update.""" + emit_event('meter', { + 'band': band, + 'level': min(100, max(0, level)), + 'noise_floor': noise_floor, + }) + + +def emit_peak(freq_mhz: float, power_db: float, band: str) -> None: + """Emit a spectrum peak event.""" + emit_event('spectrum_peak', { + 'freq_mhz': round(freq_mhz, 3), + 'power_db': round(power_db, 1), + 'band': band, + }) + + +def emit_status(state: str, **data) -> None: + """Emit a status update.""" + emit_event('status', { + 'state': state, + **data + }) + + +# ============================================ +# SCANNER LOOP +# ============================================ + +def isms_scan_loop() -> None: + """Main ISMS scanning loop.""" + global isms_running, isms_process, current_findings_count + + logger.info("ISMS scan thread started") + emit_status('starting') + + # Get preset configuration + preset_name = isms_config.get('preset', 'ism_433') + preset = get_preset(preset_name) + + if preset: + freq_start = preset['freq_start'] + freq_end = preset['freq_end'] + bin_size = preset.get('bin_size', 10000) + integration = preset.get('integration', 1.0) + band_name = preset['name'] + else: + freq_start = isms_config['freq_start'] + freq_end = isms_config['freq_end'] + bin_size = isms_config['bin_size'] + integration = isms_config['integration'] + band_name = f'{freq_start}-{freq_end} MHz' + + device = isms_config['device'] + gain = isms_config['gain'] + ppm = isms_config['ppm'] + threshold = isms_config['threshold'] + + # Get active baseline for comparison + active_baseline = None + baseline_spectrum = None + if isms_config.get('baseline_id'): + active_baseline = get_isms_baseline(isms_config['baseline_id']) + if active_baseline: + baseline_spectrum = active_baseline.get('spectrum_profile', {}).get(band_name) + + emit_status('scanning', band=band_name, preset=preset_name) + + current_bins: list[SpectrumBin] = [] + sweep_count = 0 + + try: + # Run continuous spectrum scanning + for spectrum_bin in run_rtl_power_scan( + freq_start_mhz=freq_start, + freq_end_mhz=freq_end, + bin_size_hz=bin_size, + integration_time=integration, + device_index=device, + gain=gain, + ppm=ppm, + single_shot=False, + ): + if not isms_running: + break + + current_bins.append(spectrum_bin) + + # Process a sweep's worth of data + if spectrum_bin.freq_hz >= (freq_end * 1_000_000 - bin_size): + sweep_count += 1 + + # Compute band metrics + metrics = compute_band_metrics(current_bins, band_name) + + # Store in recent metrics + with metrics_lock: + recent_metrics[band_name] = metrics + + # Add to baseline recorder if recording + if baseline_recorder.is_recording: + baseline_recorder.add_spectrum_sample(band_name, metrics) + + # Emit meter update + emit_meter(band_name, metrics.activity_score, metrics.noise_floor_db) + + # Emit peak if significant + if metrics.peak_power_db > metrics.noise_floor_db + 6: + emit_peak(metrics.peak_frequency_mhz, metrics.peak_power_db, band_name) + + # Detect bursts + bursts = detect_bursts(current_bins) + if bursts: + emit_event('bursts_detected', { + 'band': band_name, + 'count': len(bursts), + }) + + # Run rules engine + findings = rules_engine.evaluate_spectrum( + band_name=band_name, + noise_floor=metrics.noise_floor_db, + peak_freq=metrics.peak_frequency_mhz, + peak_power=metrics.peak_power_db, + activity_score=metrics.activity_score, + baseline_noise=baseline_spectrum.get('noise_floor_db') if baseline_spectrum else None, + baseline_activity=baseline_spectrum.get('activity_score') if baseline_spectrum else None, + baseline_peaks=baseline_spectrum.get('peak_frequencies') if baseline_spectrum else None, + burst_count=len(bursts), + ) + + for finding in findings: + emit_finding( + finding.severity, + finding.description, + finding_type=finding.finding_type, + band=finding.band, + frequency=finding.frequency, + ) + + # Emit progress + if sweep_count % 5 == 0: + emit_status('scanning', band=band_name, sweeps=sweep_count) + + # Clear for next sweep + current_bins.clear() + + except Exception as e: + logger.error(f"ISMS scan error: {e}") + emit_status('error', message=str(e)) + + finally: + isms_running = False + emit_status('stopped', sweeps=sweep_count) + + # Update scan record + if isms_current_scan_id: + update_isms_scan( + isms_current_scan_id, + status='completed', + findings_count=current_findings_count, + completed=True, + ) + + logger.info(f"ISMS scan stopped after {sweep_count} sweeps") + + +# ============================================ +# ROUTES: SCAN CONTROL +# ============================================ + +@isms_bp.route('/start_scan', methods=['POST']) +def start_scan(): + """Start ISMS spectrum scanning.""" + global isms_thread, isms_running, isms_current_scan_id, current_findings_count + + with isms_lock: + if isms_running: + return jsonify({ + 'status': 'error', + 'message': 'Scan already running' + }), 409 + + # Get configuration from request + data = request.get_json() or {} + + isms_config['preset'] = data.get('preset', isms_config['preset']) + isms_config['device'] = data.get('device', isms_config['device']) + isms_config['gain'] = data.get('gain', isms_config['gain']) + isms_config['threshold'] = data.get('threshold', isms_config['threshold']) + isms_config['baseline_id'] = data.get('baseline_id') + + # Custom frequency range + if data.get('freq_start'): + isms_config['freq_start'] = float(data['freq_start']) + if data.get('freq_end'): + isms_config['freq_end'] = float(data['freq_end']) + + # Location + if data.get('lat') and data.get('lon'): + isms_config['lat'] = float(data['lat']) + isms_config['lon'] = float(data['lon']) + + # Check for rtl_power + if not get_rtl_power_path(): + return jsonify({ + 'status': 'error', + 'message': 'rtl_power not found. Install rtl-sdr tools.' + }), 500 + + # Clear queue + while not isms_queue.empty(): + try: + isms_queue.get_nowait() + except queue.Empty: + break + + # Create scan record + gps_coords = None + if isms_config.get('lat') and isms_config.get('lon'): + gps_coords = {'lat': isms_config['lat'], 'lon': isms_config['lon']} + + isms_current_scan_id = create_isms_scan( + scan_preset=isms_config['preset'], + baseline_id=isms_config.get('baseline_id'), + gps_coords=gps_coords, + ) + current_findings_count = 0 + + # Start scanning thread + isms_running = True + isms_thread = threading.Thread(target=isms_scan_loop, daemon=True) + isms_thread.start() + + return jsonify({ + 'status': 'started', + 'scan_id': isms_current_scan_id, + 'config': { + 'preset': isms_config['preset'], + 'device': isms_config['device'], + } + }) + + +@isms_bp.route('/stop_scan', methods=['POST']) +def stop_scan(): + """Stop ISMS spectrum scanning.""" + global isms_running, isms_process + + with isms_lock: + if not isms_running: + return jsonify({ + 'status': 'error', + 'message': 'No scan running' + }), 400 + + isms_running = False + + # Terminate any subprocess + if isms_process: + safe_terminate(isms_process) + isms_process = None + + return jsonify({'status': 'stopped'}) + + +@isms_bp.route('/status', methods=['GET']) +def get_status(): + """Get current scanner status.""" + with isms_lock: + status = { + 'running': isms_running, + 'config': { + 'preset': isms_config['preset'], + 'device': isms_config['device'], + 'baseline_id': isms_config.get('baseline_id'), + }, + 'current_scan_id': isms_current_scan_id, + 'findings_count': current_findings_count, + } + + # Add recent metrics + with metrics_lock: + status['metrics'] = { + band: { + 'activity_score': m.activity_score, + 'noise_floor': m.noise_floor_db, + 'peak_freq': m.peak_frequency_mhz, + 'peak_power': m.peak_power_db, + } + for band, m in recent_metrics.items() + } + + return jsonify(status) + + +# ============================================ +# ROUTES: SSE STREAM +# ============================================ + +@isms_bp.route('/stream', methods=['GET']) +def stream(): + """SSE stream for real-time ISMS events.""" + def generate() -> Generator[str, None, None]: + last_keepalive = time.time() + + while True: + try: + msg = isms_queue.get(timeout=SSE_QUEUE_TIMEOUT) + last_keepalive = time.time() + yield format_sse(msg) + except queue.Empty: + now = time.time() + if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: + yield format_sse({'type': 'keepalive'}) + last_keepalive = now + + response = Response(generate(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response + + +# ============================================ +# ROUTES: PRESETS +# ============================================ + +@isms_bp.route('/presets', methods=['GET']) +def list_presets(): + """List available scan presets.""" + return jsonify({ + 'presets': get_all_presets() + }) + + +# ============================================ +# ROUTES: TOWERS +# ============================================ + +@isms_bp.route('/towers', methods=['GET']) +def get_towers(): + """Query nearby cell towers from OpenCelliD.""" + lat = request.args.get('lat', type=float) + lon = request.args.get('lon', type=float) + radius = request.args.get('radius', default=5.0, type=float) + radio = request.args.get('radio') # GSM, UMTS, LTE, NR + + if lat is None or lon is None: + # Try to get from GPS + pos = get_current_position() + if pos: + lat = pos.latitude + lon = pos.longitude + else: + return jsonify({ + 'status': 'error', + 'message': 'Location required (lat/lon parameters or GPS)' + }), 400 + + # Check for token + token = get_opencellid_token() + if not token: + return jsonify({ + 'status': 'error', + 'message': 'OpenCelliD token not configured. Set OPENCELLID_TOKEN environment variable.', + 'config_required': True, + }), 400 + + # Query towers + towers = query_nearby_towers( + lat=lat, + lon=lon, + radius_km=radius, + radio=radio, + ) + + # Format for response + formatted_towers = [format_tower_info(t) for t in towers] + + # Add to baseline recorder if recording + if baseline_recorder.is_recording: + for tower in towers: + baseline_recorder.add_tower_sample(tower) + + return jsonify({ + 'status': 'ok', + 'query': { + 'lat': lat, + 'lon': lon, + 'radius_km': radius, + }, + 'count': len(formatted_towers), + 'towers': formatted_towers, + 'links': { + 'ofcom_coverage': build_ofcom_coverage_url(), + 'ofcom_emf': build_ofcom_emf_url(), + } + }) + + +# ============================================ +# ROUTES: BASELINES +# ============================================ + +@isms_bp.route('/baselines', methods=['GET']) +def list_baselines(): + """List all ISMS baselines.""" + baselines = get_all_isms_baselines() + return jsonify({ + 'baselines': baselines + }) + + +@isms_bp.route('/baselines', methods=['POST']) +def create_baseline(): + """Create a new baseline manually.""" + data = request.get_json() or {} + + name = data.get('name') + if not name: + return jsonify({ + 'status': 'error', + 'message': 'Name required' + }), 400 + + baseline_id = create_isms_baseline( + name=name, + location_name=data.get('location_name'), + latitude=data.get('latitude'), + longitude=data.get('longitude'), + spectrum_profile=data.get('spectrum_profile'), + cellular_environment=data.get('cellular_environment'), + known_towers=data.get('known_towers'), + ) + + return jsonify({ + 'status': 'created', + 'baseline_id': baseline_id + }) + + +@isms_bp.route('/baseline/', methods=['GET']) +def get_baseline(baseline_id: int): + """Get a specific baseline.""" + baseline = get_isms_baseline(baseline_id) + if not baseline: + return jsonify({ + 'status': 'error', + 'message': 'Baseline not found' + }), 404 + + return jsonify(baseline) + + +@isms_bp.route('/baseline/', methods=['DELETE']) +def remove_baseline(baseline_id: int): + """Delete a baseline.""" + if delete_isms_baseline(baseline_id): + return jsonify({'status': 'deleted'}) + return jsonify({ + 'status': 'error', + 'message': 'Baseline not found' + }), 404 + + +@isms_bp.route('/baseline//activate', methods=['POST']) +def activate_baseline(baseline_id: int): + """Set a baseline as active.""" + if set_active_isms_baseline(baseline_id): + return jsonify({'status': 'activated'}) + return jsonify({ + 'status': 'error', + 'message': 'Baseline not found' + }), 404 + + +@isms_bp.route('/baseline/active', methods=['GET']) +def get_active_baseline(): + """Get the currently active baseline.""" + baseline = get_active_isms_baseline() + if baseline: + return jsonify(baseline) + return jsonify({'status': 'none'}) + + +@isms_bp.route('/baseline/record/start', methods=['POST']) +def start_baseline_recording(): + """Start recording a new baseline.""" + if baseline_recorder.is_recording: + return jsonify({ + 'status': 'error', + 'message': 'Already recording' + }), 409 + + baseline_recorder.start_recording() + return jsonify({'status': 'recording_started'}) + + +@isms_bp.route('/baseline/record/stop', methods=['POST']) +def stop_baseline_recording(): + """Stop recording and save baseline.""" + if not baseline_recorder.is_recording: + return jsonify({ + 'status': 'error', + 'message': 'Not recording' + }), 400 + + data = request.get_json() or {} + name = data.get('name', f'Baseline {datetime.now().strftime("%Y-%m-%d %H:%M")}') + location_name = data.get('location_name') + + # Get current location + lat = data.get('latitude') or isms_config.get('lat') + lon = data.get('longitude') or isms_config.get('lon') + + if not lat or not lon: + pos = get_current_position() + if pos: + lat = pos.latitude + lon = pos.longitude + + # Stop recording and compile data + baseline_data = baseline_recorder.stop_recording() + + # Save to database + baseline_id = save_baseline_to_db( + name=name, + location_name=location_name, + latitude=lat, + longitude=lon, + baseline_data=baseline_data, + ) + + return jsonify({ + 'status': 'saved', + 'baseline_id': baseline_id, + 'summary': { + 'bands': len(baseline_data.get('spectrum_profile', {})), + 'cells': len(baseline_data.get('cellular_environment', [])), + 'towers': len(baseline_data.get('known_towers', [])), + } + }) + + +# ============================================ +# ROUTES: FINDINGS +# ============================================ + +@isms_bp.route('/findings', methods=['GET']) +def list_findings(): + """Get ISMS findings.""" + scan_id = request.args.get('scan_id', type=int) + severity = request.args.get('severity') + limit = request.args.get('limit', default=100, type=int) + + findings = get_isms_findings( + scan_id=scan_id, + severity=severity, + limit=limit, + ) + + return jsonify({ + 'findings': findings, + 'count': len(findings), + }) + + +@isms_bp.route('/findings/summary', methods=['GET']) +def findings_summary(): + """Get findings count summary.""" + summary = get_isms_findings_summary() + return jsonify(summary) + + +@isms_bp.route('/finding//acknowledge', methods=['POST']) +def acknowledge_finding(finding_id: int): + """Acknowledge a finding.""" + if acknowledge_isms_finding(finding_id): + return jsonify({'status': 'acknowledged'}) + return jsonify({ + 'status': 'error', + 'message': 'Finding not found' + }), 404 + + +# ============================================ +# ROUTES: SCANS +# ============================================ + +@isms_bp.route('/scans', methods=['GET']) +def list_scans(): + """List recent scans.""" + limit = request.args.get('limit', default=20, type=int) + scans = get_recent_isms_scans(limit=limit) + return jsonify({ + 'scans': scans + }) + + +@isms_bp.route('/scan/', methods=['GET']) +def get_scan_details(scan_id: int): + """Get details of a specific scan.""" + scan = get_isms_scan(scan_id) + if not scan: + return jsonify({ + 'status': 'error', + 'message': 'Scan not found' + }), 404 + + # Include findings + findings = get_isms_findings(scan_id=scan_id) + scan['findings'] = findings + + return jsonify(scan) + + +# ============================================ +# ROUTES: GSM SCANNING +# ============================================ + +def gsm_scan_loop(band: str, gain: int, ppm: int, timeout: float) -> None: + """GSM scanning background thread.""" + global gsm_running, gsm_detected_cells + + logger.info(f"GSM scan thread started for band {band}") + emit_status('gsm_scanning', band=band) + + cells: list[GsmCell] = [] + + try: + for cell in run_grgsm_scan( + band=band, + gain=gain, + ppm=ppm, + speed=4, + timeout=timeout, + ): + if not gsm_running: + break + + cells.append(cell) + + # Emit cell detection event + emit_event('gsm_cell', { + 'cell': format_gsm_cell(cell), + }) + + # Deduplicate and store results + gsm_detected_cells = deduplicate_cells(cells) + + # Run anomaly detection if baseline available + if gsm_baseline_cells: + anomalies = identify_gsm_anomalies(gsm_detected_cells, gsm_baseline_cells) + for anomaly in anomalies: + emit_finding( + anomaly['severity'], + anomaly['description'], + finding_type=anomaly['type'], + band='GSM', + frequency=anomaly.get('cell', {}).get('freq_mhz'), + ) + + # Emit summary + emit_event('gsm_scan_complete', { + 'cell_count': len(gsm_detected_cells), + 'cells': [format_gsm_cell(c) for c in gsm_detected_cells[:10]], # Top 10 by signal + }) + + except Exception as e: + logger.error(f"GSM scan error: {e}") + emit_status('gsm_error', message=str(e)) + + finally: + gsm_running = False + emit_status('gsm_stopped', cell_count=len(gsm_detected_cells)) + logger.info(f"GSM scan stopped, found {len(gsm_detected_cells)} cells") + + +@isms_bp.route('/gsm/scan', methods=['POST']) +def start_gsm_scan(): + """Start GSM cell scanning with grgsm_scanner.""" + global gsm_thread, gsm_running + + with gsm_lock: + if gsm_running: + return jsonify({ + 'status': 'error', + 'message': 'GSM scan already running' + }), 409 + + # Check for grgsm_scanner + if not get_grgsm_scanner_path(): + return jsonify({ + 'status': 'error', + 'message': 'grgsm_scanner not found. Install gr-gsm package.', + 'install_hint': 'See setup.sh or install gr-gsm from https://github.com/ptrkrysik/gr-gsm' + }), 500 + + # Get configuration from request + data = request.get_json() or {} + band = data.get('band', 'GSM900') + gain = data.get('gain', isms_config['gain']) + ppm = data.get('ppm', isms_config.get('ppm', 0)) + timeout = data.get('timeout', 60) + + # Validate band + valid_bands = ['GSM900', 'EGSM900', 'GSM1800', 'GSM850', 'GSM1900'] + if band.upper() not in valid_bands: + return jsonify({ + 'status': 'error', + 'message': f'Invalid band. Must be one of: {", ".join(valid_bands)}' + }), 400 + + gsm_running = True + gsm_thread = threading.Thread( + target=gsm_scan_loop, + args=(band.upper(), gain, ppm, timeout), + daemon=True + ) + gsm_thread.start() + + return jsonify({ + 'status': 'started', + 'config': { + 'band': band.upper(), + 'gain': gain, + 'timeout': timeout, + } + }) + + +@isms_bp.route('/gsm/scan', methods=['DELETE']) +def stop_gsm_scan(): + """Stop GSM scanning.""" + global gsm_running + + with gsm_lock: + if not gsm_running: + return jsonify({ + 'status': 'error', + 'message': 'No GSM scan running' + }), 400 + + gsm_running = False + + return jsonify({'status': 'stopping'}) + + +@isms_bp.route('/gsm/status', methods=['GET']) +def get_gsm_status(): + """Get GSM scanner status and detected cells.""" + with gsm_lock: + return jsonify({ + 'running': gsm_running, + 'cell_count': len(gsm_detected_cells), + 'cells': [format_gsm_cell(c) for c in gsm_detected_cells], + 'grgsm_available': get_grgsm_scanner_path() is not None, + }) + + +@isms_bp.route('/gsm/cells', methods=['GET']) +def get_gsm_cells(): + """Get all detected GSM cells from last scan.""" + return jsonify({ + 'cells': [format_gsm_cell(c) for c in gsm_detected_cells], + 'count': len(gsm_detected_cells), + }) + + +@isms_bp.route('/gsm/baseline', methods=['POST']) +def set_gsm_baseline(): + """Save current GSM cells as baseline for comparison.""" + global gsm_baseline_cells + + if not gsm_detected_cells: + return jsonify({ + 'status': 'error', + 'message': 'No GSM cells detected. Run a scan first.' + }), 400 + + gsm_baseline_cells = gsm_detected_cells.copy() + + # Also add to baseline recorder if recording + if baseline_recorder.is_recording: + for cell in gsm_baseline_cells: + baseline_recorder.add_gsm_cell(cell) + + return jsonify({ + 'status': 'saved', + 'cell_count': len(gsm_baseline_cells), + 'cells': [format_gsm_cell(c) for c in gsm_baseline_cells], + }) + + +@isms_bp.route('/gsm/baseline', methods=['GET']) +def get_gsm_baseline(): + """Get current GSM baseline.""" + return jsonify({ + 'cells': [format_gsm_cell(c) for c in gsm_baseline_cells], + 'count': len(gsm_baseline_cells), + }) + + +@isms_bp.route('/gsm/baseline', methods=['DELETE']) +def clear_gsm_baseline(): + """Clear GSM baseline.""" + global gsm_baseline_cells + gsm_baseline_cells = [] + return jsonify({'status': 'cleared'}) diff --git a/setup.sh b/setup.sh index 79b5627..d218dbd 100755 --- a/setup.sh +++ b/setup.sh @@ -166,6 +166,14 @@ check_tools() { echo info "SoapySDR:" check_required "SoapySDRUtil" "SoapySDR CLI utility" SoapySDRUtil + + echo + info "GSM (optional):" + if have_any grgsm_scanner; then + ok "grgsm_scanner - GSM cell scanner" + else + warn "grgsm_scanner - GSM cell scanner (optional, for ISMS GSM detection)" + fi echo } @@ -352,6 +360,8 @@ install_macos_packages() { warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS." info "TSCM BLE scanning uses bleak library (installed via pip) for manufacturer data detection." + warn "macOS note: gr-gsm (for GSM cell scanning) is not easily available on macOS." + info "GSM cell detection in ISMS mode requires Linux with GNU Radio." echo } @@ -448,6 +458,77 @@ install_acarsdec_from_source_debian() { ) } +install_grgsm_from_source_debian() { + info "Installing gr-gsm for GSM cell scanning (optional)..." + + # Check if GNU Radio is available + if ! cmd_exists gnuradio-config-info; then + info "GNU Radio not found. Installing GNU Radio first..." + $SUDO apt-get install -y gnuradio gnuradio-dev >/dev/null 2>&1 || { + warn "Failed to install GNU Radio. gr-gsm requires GNU Radio 3.8+." + warn "GSM scanning will not be available." + return 1 + } + fi + + # Check GNU Radio version (need 3.8+) + local gr_version + gr_version=$(gnuradio-config-info --version 2>/dev/null || echo "0.0.0") + info "GNU Radio version: $gr_version" + + # Install dependencies for gr-gsm + info "Installing gr-gsm dependencies..." + $SUDO apt-get install -y \ + cmake \ + autoconf \ + libtool \ + pkg-config \ + build-essential \ + python3-docutils \ + libcppunit-dev \ + swig \ + doxygen \ + liblog4cpp5-dev \ + python3-scipy \ + gnuradio-dev \ + gr-osmosdr \ + libosmocore-dev \ + libosmocoding-dev \ + libosmoctrl-dev \ + libosmogsm-dev \ + libosmovty-dev \ + libosmocodec-dev \ + >/dev/null 2>&1 || { + warn "Some gr-gsm dependencies failed to install." + warn "Attempting to continue anyway..." + } + + # Run in subshell to isolate EXIT trap + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + info "Cloning gr-gsm..." + git clone --depth 1 https://github.com/ptrkrysik/gr-gsm.git "$tmp_dir/gr-gsm" >/dev/null 2>&1 \ + || { warn "Failed to clone gr-gsm"; exit 1; } + + cd "$tmp_dir/gr-gsm" + mkdir -p build && cd build + + info "Compiling gr-gsm (this may take a few minutes)..." + if cmake .. >/dev/null 2>&1 && make -j$(nproc) >/dev/null 2>&1; then + $SUDO make install >/dev/null 2>&1 + $SUDO ldconfig + ok "gr-gsm installed successfully." + info "grgsm_scanner should now be available for GSM cell detection." + else + warn "Failed to build gr-gsm from source." + warn "GSM cell scanning will not be available in ISMS mode." + warn "You can try installing manually from: https://github.com/ptrkrysik/gr-gsm" + fi + ) +} + install_rtlsdr_blog_drivers_debian() { # The RTL-SDR Blog drivers provide better support for: # - RTL-SDR Blog V4 (R828D tuner) @@ -547,7 +628,7 @@ install_debian_packages() { export DEBIAN_FRONTEND=noninteractive export NEEDRESTART_MODE=a - TOTAL_STEPS=18 + TOTAL_STEPS=19 CURRENT_STEP=0 progress "Updating APT package lists" @@ -617,6 +698,13 @@ install_debian_packages() { fi cmd_exists acarsdec || install_acarsdec_from_source_debian + progress "Installing gr-gsm (optional, for GSM cell detection)" + if ! cmd_exists grgsm_scanner; then + install_grgsm_from_source_debian || true + else + ok "grgsm_scanner already installed" + fi + progress "Configuring udev rules" setup_udev_rules_debian diff --git a/static/css/index.css b/static/css/index.css index 69196c9..e039a4a 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -5052,3 +5052,190 @@ body::before { .preset-freq-btn:active { transform: scale(0.98); } + +/* ============================================================================= + ISMS Listening Station Styles + ============================================================================= */ + +.isms-dashboard { + padding: 16px; +} + +.isms-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +.isms-panel { + background: var(--bg-card); + border-radius: 8px; + padding: 12px; + border: 1px solid var(--border-color); +} + +.isms-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; +} + +.isms-info-banner { + margin-bottom: 12px; + padding: 8px 12px; + background: rgba(0, 212, 255, 0.1); + border: 1px solid rgba(0, 212, 255, 0.3); + border-radius: 4px; + font-size: 10px; + color: var(--text-muted); +} + +/* ISMS Badges */ +.isms-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 9px; + font-weight: bold; + text-transform: uppercase; +} + +.isms-badge-high { + background: rgba(255, 51, 102, 0.2); + color: var(--accent-red); + border: 1px solid rgba(255, 51, 102, 0.4); +} + +.isms-badge-warn { + background: rgba(255, 170, 0, 0.2); + color: var(--accent-orange); + border: 1px solid rgba(255, 170, 0, 0.4); +} + +.isms-badge-info { + background: rgba(0, 212, 255, 0.2); + color: var(--accent-cyan); + border: 1px solid rgba(0, 212, 255, 0.4); +} + +/* ISMS Band Meter */ +.isms-band-meter { + text-align: center; + min-width: 80px; +} + +.isms-band-meter .meter-bar { + height: 60px; + width: 20px; + background: rgba(0, 0, 0, 0.5); + border-radius: 4px; + margin: 0 auto; + position: relative; + overflow: hidden; +} + +.isms-band-meter .meter-fill { + position: absolute; + bottom: 0; + width: 100%; + background: linear-gradient(to top, var(--accent-green), var(--accent-cyan), var(--accent-orange)); + transition: height 0.3s; +} + +.isms-band-meter .meter-value { + font-size: 11px; + margin-top: 4px; + font-family: 'JetBrains Mono', monospace; +} + +.isms-band-meter .meter-noise { + font-size: 9px; + color: var(--text-muted); +} + +/* ISMS Tower Map */ +#ismsTowerMap { + height: 180px; + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; +} + +#ismsTowerMap .leaflet-control-attribution { + font-size: 8px; + background: rgba(0, 0, 0, 0.6); + color: var(--text-muted); +} + +.isms-user-marker { + background: transparent; + border: none; +} + +/* ISMS Finding Item */ +.isms-finding-item { + padding: 8px; + border-bottom: 1px solid var(--border-color); + font-size: 11px; +} + +.isms-finding-item:last-child { + border-bottom: none; +} + +/* ISMS Collapsible Panel */ +.isms-panel.collapsible .isms-panel-header.clickable { + cursor: pointer; + padding: 12px; + margin: 0; +} + +.isms-panel.collapsible .isms-panel-header.clickable:hover { + background: rgba(0, 212, 255, 0.05); +} + +/* ISMS Start Button */ +#ismsStartBtn.running { + background: linear-gradient(135deg, #ff3366, #ff6b6b); + border-color: #ff3366; + animation: pulse-glow 2s ease-in-out infinite; +} + +/* ISMS Recording Status */ +#ismsBaselineRecordingStatus { + background: rgba(255, 100, 100, 0.1); + border-radius: 4px; + padding: 8px; + font-size: 10px; + color: var(--accent-red); + animation: recording-blink 1s ease-in-out infinite; +} + +@keyframes recording-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* ISMS Mode Content */ +#ismsMode.mode-content { + display: none; +} + +#ismsMode.mode-content.active { + display: block; +} + +/* Responsive ISMS Grid */ +@media (max-width: 1024px) { + .isms-grid { + grid-template-columns: repeat(2, 1fr); + } + + .isms-panel[style*="grid-column: span 4"] { + grid-column: span 2 !important; + } +} diff --git a/static/js/modes/isms.js b/static/js/modes/isms.js new file mode 100644 index 0000000..cb044ec --- /dev/null +++ b/static/js/modes/isms.js @@ -0,0 +1,992 @@ +/** + * ISMS Listening Station Mode + * Spectrum monitoring, cellular environment, tower mapping + */ + +// ============== STATE ============== +let isIsmsScanRunning = false; +let ismsEventSource = null; +let ismsTowerMap = null; +let ismsTowerMarkers = []; +let ismsLocation = { lat: null, lon: null }; +let ismsBandMetrics = {}; +let ismsFindings = []; +let ismsPeaks = []; +let ismsBaselineRecording = false; +let ismsInitialized = false; + +// Finding counts +let ismsFindingCounts = { high: 0, warn: 0, info: 0 }; + +// GSM scanner state +let isGsmScanRunning = false; +let ismsGsmCells = []; + +// ============== INITIALIZATION ============== + +function initIsmsMode() { + if (ismsInitialized) return; + + // Initialize Leaflet map for towers + initIsmsTowerMap(); + + // Load baselines + ismsRefreshBaselines(); + + // Check for GPS + ismsCheckGps(); + + // Populate SDR devices + ismsPopulateSdrDevices(); + + // Set up event listeners + setupIsmsEventListeners(); + + ismsInitialized = true; + console.log('ISMS mode initialized'); +} + +function initIsmsTowerMap() { + const container = document.getElementById('ismsTowerMap'); + if (!container || ismsTowerMap) return; + + // Clear placeholder content + container.innerHTML = ''; + + ismsTowerMap = L.map('ismsTowerMap', { + center: [51.5074, -0.1278], + zoom: 12, + zoomControl: false, + }); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OSM' + }).addTo(ismsTowerMap); + + // Add zoom control to bottom right + L.control.zoom({ position: 'bottomright' }).addTo(ismsTowerMap); +} + +function setupIsmsEventListeners() { + // Preset change + const presetSelect = document.getElementById('ismsScanPreset'); + if (presetSelect) { + presetSelect.addEventListener('change', function() { + const customRange = document.getElementById('ismsCustomRange'); + if (customRange) { + customRange.style.display = this.value === 'custom' ? 'block' : 'none'; + } + }); + } + + // Gain slider + const gainSlider = document.getElementById('ismsGain'); + if (gainSlider) { + gainSlider.addEventListener('input', function() { + document.getElementById('ismsGainValue').textContent = this.value; + }); + } + + // Threshold slider + const thresholdSlider = document.getElementById('ismsActivityThreshold'); + if (thresholdSlider) { + thresholdSlider.addEventListener('input', function() { + document.getElementById('ismsThresholdValue').textContent = this.value + '%'; + }); + } +} + +async function ismsPopulateSdrDevices() { + try { + const response = await fetch('/devices'); + const devices = await response.json(); + + const select = document.getElementById('ismsSdrDevice'); + if (!select) return; + + select.innerHTML = ''; + + if (devices.length === 0) { + select.innerHTML = ''; + return; + } + + devices.forEach((device, index) => { + const option = document.createElement('option'); + option.value = index; + option.textContent = `${index}: ${device.name || 'RTL-SDR'}`; + select.appendChild(option); + }); + } catch (e) { + console.error('Failed to load SDR devices:', e); + } +} + +// ============== GPS ============== + +async function ismsCheckGps() { + try { + const response = await fetch('/gps/status'); + const data = await response.json(); + + if (data.connected && data.position) { + ismsLocation.lat = data.position.latitude; + ismsLocation.lon = data.position.longitude; + updateIsmsLocationDisplay(); + } + } catch (e) { + console.debug('GPS not available'); + } +} + +function ismsUseGPS() { + fetch('/gps/status') + .then(r => r.json()) + .then(data => { + if (data.connected && data.position) { + ismsLocation.lat = data.position.latitude; + ismsLocation.lon = data.position.longitude; + updateIsmsLocationDisplay(); + showNotification('ISMS', 'GPS location acquired'); + } else { + showNotification('ISMS', 'GPS not available. Connect GPS first.'); + } + }) + .catch(() => { + showNotification('ISMS', 'Failed to get GPS position'); + }); +} + +function ismsSetManualLocation() { + const lat = prompt('Enter latitude:', ismsLocation.lat || '51.5074'); + if (lat === null) return; + + const lon = prompt('Enter longitude:', ismsLocation.lon || '-0.1278'); + if (lon === null) return; + + ismsLocation.lat = parseFloat(lat); + ismsLocation.lon = parseFloat(lon); + updateIsmsLocationDisplay(); +} + +function updateIsmsLocationDisplay() { + const coordsEl = document.getElementById('ismsCoords'); + const quickLocEl = document.getElementById('ismsQuickLocation'); + + if (ismsLocation.lat && ismsLocation.lon) { + const text = `${ismsLocation.lat.toFixed(4)}, ${ismsLocation.lon.toFixed(4)}`; + if (coordsEl) coordsEl.textContent = `Lat: ${ismsLocation.lat.toFixed(4)}, Lon: ${ismsLocation.lon.toFixed(4)}`; + if (quickLocEl) quickLocEl.textContent = text; + + // Center map on location + if (ismsTowerMap) { + ismsTowerMap.setView([ismsLocation.lat, ismsLocation.lon], 13); + } + } +} + +// ============== SCAN CONTROLS ============== + +function ismsToggleScan() { + if (isIsmsScanRunning) { + ismsStopScan(); + } else { + ismsStartScan(); + } +} + +async function ismsStartScan() { + const preset = document.getElementById('ismsScanPreset').value; + const device = parseInt(document.getElementById('ismsSdrDevice').value || '0'); + const gain = parseInt(document.getElementById('ismsGain').value || '40'); + const threshold = parseInt(document.getElementById('ismsActivityThreshold').value || '50'); + const baselineId = document.getElementById('ismsBaselineSelect').value || null; + + const config = { + preset: preset, + device: device, + gain: gain, + threshold: threshold, + baseline_id: baselineId ? parseInt(baselineId) : null, + }; + + // Add custom range if selected + if (preset === 'custom') { + config.freq_start = parseFloat(document.getElementById('ismsStartFreq').value); + config.freq_end = parseFloat(document.getElementById('ismsEndFreq').value); + } + + // Add location + if (ismsLocation.lat && ismsLocation.lon) { + config.lat = ismsLocation.lat; + config.lon = ismsLocation.lon; + } + + try { + const response = await fetch('/isms/start_scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + + const data = await response.json(); + + if (data.status === 'started') { + isIsmsScanRunning = true; + updateIsmsUI('scanning'); + connectIsmsStream(); + + // Reset findings + ismsFindingCounts = { high: 0, warn: 0, info: 0 }; + ismsFindings = []; + ismsPeaks = []; + updateIsmsFindingsBadges(); + } else { + showNotification('ISMS Error', data.message || 'Failed to start scan'); + } + } catch (e) { + showNotification('ISMS Error', 'Failed to start scan: ' + e.message); + } +} + +async function ismsStopScan() { + try { + await fetch('/isms/stop_scan', { method: 'POST' }); + } catch (e) { + console.error('Error stopping scan:', e); + } + + isIsmsScanRunning = false; + disconnectIsmsStream(); + updateIsmsUI('stopped'); +} + +function updateIsmsUI(state) { + const startBtn = document.getElementById('ismsStartBtn'); + const quickStatus = document.getElementById('ismsQuickStatus'); + const scanStatus = document.getElementById('ismsScanStatus'); + + if (state === 'scanning') { + if (startBtn) { + startBtn.textContent = 'Stop Scan'; + startBtn.classList.add('running'); + } + if (quickStatus) quickStatus.textContent = 'SCANNING'; + if (scanStatus) scanStatus.textContent = 'SCANNING'; + + // Update quick band display + const presetSelect = document.getElementById('ismsScanPreset'); + const quickBand = document.getElementById('ismsQuickBand'); + if (presetSelect && quickBand) { + quickBand.textContent = presetSelect.options[presetSelect.selectedIndex].text; + } + } else { + if (startBtn) { + startBtn.textContent = 'Start Scan'; + startBtn.classList.remove('running'); + } + if (quickStatus) quickStatus.textContent = 'IDLE'; + if (scanStatus) scanStatus.textContent = 'IDLE'; + } +} + +// ============== SSE STREAM ============== + +function connectIsmsStream() { + if (ismsEventSource) { + ismsEventSource.close(); + } + + ismsEventSource = new EventSource('/isms/stream'); + + ismsEventSource.onmessage = function(event) { + try { + const data = JSON.parse(event.data); + handleIsmsEvent(data); + } catch (e) { + console.error('Failed to parse ISMS event:', e); + } + }; + + ismsEventSource.onerror = function() { + console.error('ISMS stream error'); + }; +} + +function disconnectIsmsStream() { + if (ismsEventSource) { + ismsEventSource.close(); + ismsEventSource = null; + } +} + +function handleIsmsEvent(data) { + switch (data.type) { + case 'meter': + updateIsmsBandMeter(data.band, data.level, data.noise_floor); + break; + case 'spectrum_peak': + addIsmsPeak(data); + break; + case 'finding': + addIsmsFinding(data); + break; + case 'status': + updateIsmsStatus(data); + break; + case 'gsm_cell': + handleGsmCell(data.cell); + break; + case 'gsm_scan_complete': + handleGsmScanComplete(data); + break; + case 'gsm_scanning': + case 'gsm_stopped': + case 'gsm_error': + handleGsmStatus(data); + break; + case 'keepalive': + // Ignore + break; + default: + console.debug('Unknown ISMS event:', data.type); + } +} + +// ============== BAND METERS ============== + +function updateIsmsBandMeter(band, level, noiseFloor) { + ismsBandMetrics[band] = { level, noiseFloor }; + + const container = document.getElementById('ismsBandMeters'); + if (!container) return; + + // Find or create meter for this band + let meter = container.querySelector(`[data-band="${band}"]`); + + if (!meter) { + // Clear placeholder if first meter + if (container.querySelector('div:not([data-band])')) { + container.innerHTML = ''; + } + + meter = document.createElement('div'); + meter.setAttribute('data-band', band); + meter.className = 'isms-band-meter'; + meter.style.cssText = 'text-align: center; min-width: 80px;'; + meter.innerHTML = ` +
${band}
+
+
+
+
${level.toFixed(0)}%
+
${noiseFloor.toFixed(1)} dB
+ `; + container.appendChild(meter); + } + + // Update meter values + const fill = meter.querySelector('.meter-fill'); + const value = meter.querySelector('.meter-value'); + const noise = meter.querySelector('.meter-noise'); + + if (fill) fill.style.height = level + '%'; + if (value) value.textContent = level.toFixed(0) + '%'; + if (noise) noise.textContent = noiseFloor.toFixed(1) + ' dB'; +} + +// ============== PEAKS ============== + +function addIsmsPeak(data) { + // Add to peaks array (keep last 20) + ismsPeaks.unshift({ + freq: data.freq_mhz, + power: data.power_db, + band: data.band, + timestamp: new Date() + }); + + if (ismsPeaks.length > 20) { + ismsPeaks.pop(); + } + + updateIsmsPeaksList(); +} + +function updateIsmsPeaksList() { + const tbody = document.getElementById('ismsPeaksBody'); + const countEl = document.getElementById('ismsPeakCount'); + + if (!tbody) return; + + if (ismsPeaks.length === 0) { + tbody.innerHTML = 'No peaks detected'; + if (countEl) countEl.textContent = '0'; + return; + } + + tbody.innerHTML = ismsPeaks.map(peak => ` + + ${peak.freq.toFixed(3)} MHz + ${peak.power.toFixed(1)} dB + ${peak.band || '--'} + + `).join(''); + + if (countEl) countEl.textContent = ismsPeaks.length; +} + +// ============== FINDINGS ============== + +function addIsmsFinding(data) { + const finding = { + severity: data.severity, + text: data.text, + details: data.details, + timestamp: data.timestamp || new Date().toISOString() + }; + + ismsFindings.unshift(finding); + + // Update counts + if (data.severity === 'high') ismsFindingCounts.high++; + else if (data.severity === 'warn') ismsFindingCounts.warn++; + else ismsFindingCounts.info++; + + updateIsmsFindingsBadges(); + updateIsmsFindingsTimeline(); + + // Update quick findings count + const quickFindings = document.getElementById('ismsQuickFindings'); + if (quickFindings) { + quickFindings.textContent = ismsFindings.length; + quickFindings.style.color = ismsFindingCounts.high > 0 ? 'var(--accent-red)' : + ismsFindingCounts.warn > 0 ? 'var(--accent-orange)' : 'var(--accent-green)'; + } +} + +function updateIsmsFindingsBadges() { + const highBadge = document.getElementById('ismsFindingsHigh'); + const warnBadge = document.getElementById('ismsFindingsWarn'); + const infoBadge = document.getElementById('ismsFindingsInfo'); + + if (highBadge) { + highBadge.textContent = ismsFindingCounts.high + ' HIGH'; + highBadge.style.display = ismsFindingCounts.high > 0 ? 'inline-block' : 'none'; + } + if (warnBadge) { + warnBadge.textContent = ismsFindingCounts.warn + ' WARN'; + warnBadge.style.display = ismsFindingCounts.warn > 0 ? 'inline-block' : 'none'; + } + if (infoBadge) { + infoBadge.textContent = ismsFindingCounts.info + ' INFO'; + } +} + +function updateIsmsFindingsTimeline() { + const timeline = document.getElementById('ismsFindingsTimeline'); + if (!timeline) return; + + if (ismsFindings.length === 0) { + timeline.innerHTML = ` +
+ No findings yet. Start a scan and enable baseline comparison. +
+ `; + return; + } + + timeline.innerHTML = ismsFindings.slice(0, 50).map(finding => { + const severityColor = finding.severity === 'high' ? 'var(--accent-red)' : + finding.severity === 'warn' ? 'var(--accent-orange)' : 'var(--accent-cyan)'; + const time = new Date(finding.timestamp).toLocaleTimeString(); + + return ` +
+
+ ${finding.severity} + ${time} +
+
${finding.text}
+
+ `; + }).join(''); +} + +// ============== STATUS ============== + +function updateIsmsStatus(data) { + if (data.state === 'stopped' || data.state === 'error') { + isIsmsScanRunning = false; + updateIsmsUI('stopped'); + + if (data.state === 'error') { + showNotification('ISMS Error', data.message || 'Scan error'); + } + } +} + +// ============== TOWERS ============== + +async function ismsRefreshTowers() { + if (!ismsLocation.lat || !ismsLocation.lon) { + showNotification('ISMS', 'Set location first to query towers'); + return; + } + + const towerCountEl = document.getElementById('ismsTowerCount'); + if (towerCountEl) towerCountEl.textContent = 'Querying...'; + + try { + const response = await fetch(`/isms/towers?lat=${ismsLocation.lat}&lon=${ismsLocation.lon}&radius=5`); + const data = await response.json(); + + if (data.status === 'error') { + if (towerCountEl) towerCountEl.textContent = data.message; + if (data.config_required) { + showNotification('ISMS', 'OpenCelliD token required. Set OPENCELLID_TOKEN environment variable.'); + } + return; + } + + updateIsmsTowerMap(data.towers); + updateIsmsTowerList(data.towers); + + if (towerCountEl) towerCountEl.textContent = `${data.count} towers found`; + } catch (e) { + console.error('Failed to query towers:', e); + if (towerCountEl) towerCountEl.textContent = 'Query failed'; + } +} + +function updateIsmsTowerMap(towers) { + if (!ismsTowerMap) return; + + // Clear existing markers + ismsTowerMarkers.forEach(marker => marker.remove()); + ismsTowerMarkers = []; + + // Add tower markers + towers.forEach(tower => { + const marker = L.circleMarker([tower.lat, tower.lon], { + radius: 6, + fillColor: getTowerColor(tower.radio), + color: '#fff', + weight: 1, + opacity: 1, + fillOpacity: 0.8 + }); + + marker.bindPopup(` +
+ ${tower.operator}
+ ${tower.radio} - CID: ${tower.cellid}
+ Distance: ${tower.distance_km} km
+ CellMapper +
+ `); + + marker.addTo(ismsTowerMap); + ismsTowerMarkers.push(marker); + }); + + // Add user location marker + if (ismsLocation.lat && ismsLocation.lon) { + const userMarker = L.marker([ismsLocation.lat, ismsLocation.lon], { + icon: L.divIcon({ + className: 'isms-user-marker', + html: '
', + iconSize: [16, 16], + iconAnchor: [8, 8] + }) + }); + userMarker.addTo(ismsTowerMap); + ismsTowerMarkers.push(userMarker); + } + + // Fit map to markers if we have towers + if (towers.length > 0 && ismsTowerMarkers.length > 0) { + const group = L.featureGroup(ismsTowerMarkers); + ismsTowerMap.fitBounds(group.getBounds().pad(0.1)); + } +} + +function getTowerColor(radio) { + switch (radio) { + case 'LTE': return '#00d4ff'; + case 'NR': return '#ff00ff'; + case 'UMTS': return '#00ff88'; + case 'GSM': return '#ffaa00'; + default: return '#888'; + } +} + +function updateIsmsTowerList(towers) { + const list = document.getElementById('ismsTowerList'); + if (!list) return; + + if (towers.length === 0) { + list.innerHTML = '
No towers found
'; + return; + } + + list.innerHTML = towers.slice(0, 10).map(tower => ` +
+ ${tower.radio} + ${tower.operator} + ${tower.distance_km} km +
+ `).join(''); +} + +// ============== BASELINES ============== + +async function ismsRefreshBaselines() { + try { + const response = await fetch('/isms/baselines'); + const data = await response.json(); + + const select = document.getElementById('ismsBaselineSelect'); + if (!select) return; + + // Keep the "No Baseline" option + select.innerHTML = ''; + + data.baselines.forEach(baseline => { + const option = document.createElement('option'); + option.value = baseline.id; + option.textContent = `${baseline.name}${baseline.is_active ? ' (Active)' : ''}`; + if (baseline.is_active) option.selected = true; + select.appendChild(option); + }); + } catch (e) { + console.error('Failed to load baselines:', e); + } +} + +function ismsToggleBaselineRecording() { + if (ismsBaselineRecording) { + ismsStopBaselineRecording(); + } else { + ismsStartBaselineRecording(); + } +} + +async function ismsStartBaselineRecording() { + try { + const response = await fetch('/isms/baseline/record/start', { method: 'POST' }); + const data = await response.json(); + + if (data.status === 'recording_started') { + ismsBaselineRecording = true; + + const btn = document.getElementById('ismsRecordBaselineBtn'); + const status = document.getElementById('ismsBaselineRecordingStatus'); + + if (btn) { + btn.textContent = 'Stop Recording'; + btn.style.background = 'var(--accent-red)'; + } + if (status) status.style.display = 'block'; + + showNotification('ISMS', 'Baseline recording started'); + } + } catch (e) { + showNotification('ISMS Error', 'Failed to start recording'); + } +} + +async function ismsStopBaselineRecording() { + const name = prompt('Enter baseline name:', `Baseline ${new Date().toLocaleDateString()}`); + if (!name) return; + + try { + const response = await fetch('/isms/baseline/record/stop', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name, + latitude: ismsLocation.lat, + longitude: ismsLocation.lon + }) + }); + + const data = await response.json(); + + if (data.status === 'saved') { + ismsBaselineRecording = false; + + const btn = document.getElementById('ismsRecordBaselineBtn'); + const status = document.getElementById('ismsBaselineRecordingStatus'); + + if (btn) { + btn.textContent = 'Record New'; + btn.style.background = ''; + } + if (status) status.style.display = 'none'; + + showNotification('ISMS', `Baseline saved: ${data.summary.bands} bands, ${data.summary.towers} towers`); + ismsRefreshBaselines(); + } + } catch (e) { + showNotification('ISMS Error', 'Failed to save baseline'); + } +} + +// ============== BASELINE PANEL ============== + +function ismsToggleBaselinePanel() { + const content = document.getElementById('ismsBaselineCompare'); + const icon = document.getElementById('ismsBaselinePanelIcon'); + + if (content && icon) { + const isVisible = content.style.display !== 'none'; + content.style.display = isVisible ? 'none' : 'block'; + icon.textContent = isVisible ? '▶' : '▼'; + } +} + +// ============== UTILITY ============== + +function showNotification(title, message) { + // Use existing notification system if available + if (typeof window.showNotification === 'function') { + window.showNotification(title, message); + } else { + console.log(`[${title}] ${message}`); + } +} + +// ============== GSM SCANNING ============== + +function ismsToggleGsmScan() { + if (isGsmScanRunning) { + ismsStopGsmScan(); + } else { + ismsStartGsmScan(); + } +} + +async function ismsStartGsmScan() { + const band = document.getElementById('ismsGsmBand').value; + const gain = parseInt(document.getElementById('ismsGain').value || '40'); + + const config = { + band: band, + gain: gain, + timeout: 60 + }; + + try { + const response = await fetch('/isms/gsm/scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + + const data = await response.json(); + + if (data.status === 'started') { + isGsmScanRunning = true; + ismsGsmCells = []; + updateGsmScanUI('scanning'); + + // Connect to SSE stream if not already connected + if (!ismsEventSource) { + connectIsmsStream(); + } + + showNotification('ISMS', `GSM scan started on ${band}`); + } else { + showNotification('ISMS Error', data.message || 'Failed to start GSM scan'); + } + } catch (e) { + showNotification('ISMS Error', 'Failed to start GSM scan: ' + e.message); + } +} + +async function ismsStopGsmScan() { + try { + await fetch('/isms/gsm/scan', { method: 'DELETE' }); + } catch (e) { + console.error('Error stopping GSM scan:', e); + } + + isGsmScanRunning = false; + updateGsmScanUI('stopped'); +} + +function updateGsmScanUI(state) { + const btn = document.getElementById('ismsGsmScanBtn'); + const statusText = document.getElementById('ismsGsmStatusText'); + + if (state === 'scanning') { + if (btn) { + btn.textContent = 'Stop Scan'; + btn.style.background = 'var(--accent-red)'; + } + if (statusText) { + statusText.textContent = 'Scanning...'; + statusText.style.color = 'var(--accent-orange)'; + } + } else { + if (btn) { + btn.textContent = 'Scan GSM Cells'; + btn.style.background = ''; + } + if (statusText) { + statusText.textContent = 'Ready'; + statusText.style.color = 'var(--accent-cyan)'; + } + } +} + +function handleGsmCell(cell) { + // Check if we already have this ARFCN + const existing = ismsGsmCells.find(c => c.arfcn === cell.arfcn); + + if (existing) { + // Update if stronger signal + if (cell.power_dbm > existing.power_dbm) { + Object.assign(existing, cell); + } + } else { + ismsGsmCells.push(cell); + } + + // Update count display + const countEl = document.getElementById('ismsGsmCellCount'); + if (countEl) { + countEl.textContent = ismsGsmCells.length; + } + + // Update cells list + updateGsmCellsList(); +} + +function handleGsmScanComplete(data) { + isGsmScanRunning = false; + updateGsmScanUI('stopped'); + + // Update with final cell list + if (data.cells) { + ismsGsmCells = data.cells; + updateGsmCellsList(); + } + + const countEl = document.getElementById('ismsGsmCellCount'); + if (countEl) { + countEl.textContent = data.cell_count || ismsGsmCells.length; + } + + showNotification('ISMS', `GSM scan complete: ${data.cell_count} cells found`); +} + +function handleGsmStatus(data) { + const statusText = document.getElementById('ismsGsmStatusText'); + + if (data.type === 'gsm_scanning') { + if (statusText) { + statusText.textContent = `Scanning ${data.band || 'GSM'}...`; + statusText.style.color = 'var(--accent-orange)'; + } + } else if (data.type === 'gsm_stopped') { + isGsmScanRunning = false; + updateGsmScanUI('stopped'); + if (statusText) { + statusText.textContent = `Found ${data.cell_count || 0} cells`; + statusText.style.color = 'var(--accent-green)'; + } + } else if (data.type === 'gsm_error') { + isGsmScanRunning = false; + updateGsmScanUI('stopped'); + if (statusText) { + statusText.textContent = 'Error'; + statusText.style.color = 'var(--accent-red)'; + } + showNotification('ISMS Error', data.message || 'GSM scan error'); + } +} + +function updateGsmCellsList() { + const container = document.getElementById('ismsGsmCells'); + if (!container) return; + + if (ismsGsmCells.length === 0) { + container.innerHTML = '
No cells detected
'; + return; + } + + // Sort by signal strength + const sortedCells = [...ismsGsmCells].sort((a, b) => b.power_dbm - a.power_dbm); + + container.innerHTML = sortedCells.map(cell => { + const signalColor = cell.power_dbm > -70 ? 'var(--accent-green)' : + cell.power_dbm > -85 ? 'var(--accent-orange)' : 'var(--text-muted)'; + + const operator = cell.plmn ? getOperatorName(cell.plmn) : '--'; + + return ` +
+
+ ARFCN ${cell.arfcn} + ${cell.power_dbm.toFixed(0)} dBm +
+
+ ${cell.freq_mhz.toFixed(1)} MHz | ${operator} + ${cell.cell_id ? ` | CID: ${cell.cell_id}` : ''} +
+
+ `; + }).join(''); +} + +function getOperatorName(plmn) { + // UK operators + const operators = { + '234-10': 'O2', + '234-15': 'Vodafone', + '234-20': 'Three', + '234-30': 'EE', + '234-31': 'EE', + '234-32': 'EE', + '234-33': 'EE', + }; + return operators[plmn] || plmn; +} + +async function ismsSetGsmBaseline() { + if (ismsGsmCells.length === 0) { + showNotification('ISMS', 'No GSM cells to save. Run a scan first.'); + return; + } + + try { + const response = await fetch('/isms/gsm/baseline', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + const data = await response.json(); + + if (data.status === 'saved') { + showNotification('ISMS', `GSM baseline saved: ${data.cell_count} cells`); + } else { + showNotification('ISMS Error', data.message || 'Failed to save baseline'); + } + } catch (e) { + showNotification('ISMS Error', 'Failed to save GSM baseline'); + } +} + +// Export for global access +window.initIsmsMode = initIsmsMode; +window.ismsToggleScan = ismsToggleScan; +window.ismsRefreshTowers = ismsRefreshTowers; +window.ismsUseGPS = ismsUseGPS; +window.ismsSetManualLocation = ismsSetManualLocation; +window.ismsRefreshBaselines = ismsRefreshBaselines; +window.ismsToggleBaselineRecording = ismsToggleBaselineRecording; +window.ismsToggleBaselinePanel = ismsToggleBaselinePanel; +window.ismsToggleGsmScan = ismsToggleGsmScan; +window.ismsSetGsmBaseline = ismsSetGsmBaseline; diff --git a/templates/index.html b/templates/index.html index cd0a888..a6914a1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -385,6 +385,7 @@
+
@@ -403,6 +404,7 @@ + @@ -501,6 +503,8 @@ {% include 'partials/modes/tscm.html' %} + {% include 'partials/modes/isms.html' %} + @@ -1112,6 +1116,113 @@
+ + +