mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Revert ISMS Listening Station implementation
Remove all ISMS (Intelligent Spectrum Monitoring Station) code including:
- GSM cell scanning with gr-gsm
- Spectrum monitoring via rtl_power
- OpenCelliD tower integration
- Baseline recording and comparison
- Setup script changes for gr-gsm/libosmocore
Reverts to pre-ISMS state (commit 4c1690d).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,315 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -15,7 +15,6 @@ 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)
|
||||
@@ -30,7 +29,6 @@ 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
|
||||
|
||||
983
routes/isms.py
983
routes/isms.py
@@ -1,983 +0,0 @@
|
||||
"""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/<int:baseline_id>', 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/<int:baseline_id>', 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/<int:baseline_id>/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/<int:finding_id>/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/<int:scan_id>', 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():
|
||||
gsm_running = False
|
||||
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',
|
||||
'grgsm_available': False
|
||||
}), 503
|
||||
|
||||
# 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'})
|
||||
192
setup.sh
192
setup.sh
@@ -166,14 +166,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -360,8 +352,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -458,167 +448,6 @@ 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..."
|
||||
if ! $SUDO apt-get install -y gnuradio gnuradio-dev; then
|
||||
warn "Failed to install GNU Radio. gr-gsm requires GNU Radio 3.8+."
|
||||
warn "GSM scanning will not be available."
|
||||
return 1
|
||||
fi
|
||||
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 basic build dependencies first
|
||||
info "Installing gr-gsm build dependencies..."
|
||||
$SUDO apt-get install -y \
|
||||
cmake \
|
||||
autoconf \
|
||||
automake \
|
||||
libtool \
|
||||
pkg-config \
|
||||
build-essential \
|
||||
python3-docutils \
|
||||
libcppunit-dev \
|
||||
swig \
|
||||
doxygen \
|
||||
liblog4cpp5-dev \
|
||||
python3-scipy \
|
||||
python3-numpy \
|
||||
gnuradio-dev \
|
||||
gr-osmosdr \
|
||||
libtalloc-dev \
|
||||
libpcsclite-dev \
|
||||
libusb-1.0-0-dev \
|
||||
libgnutls28-dev \
|
||||
libmnl-dev \
|
||||
libsctp-dev \
|
||||
pybind11-dev \
|
||||
python3-pybind11 \
|
||||
|| warn "Some build dependencies failed to install."
|
||||
|
||||
# Check if libosmocore is available via apt
|
||||
info "Checking for libosmocore packages..."
|
||||
if ! $SUDO apt-get install -y libosmocore-dev 2>/dev/null; then
|
||||
info "libosmocore not available in repos, building from source..."
|
||||
|
||||
# Build libosmocore from source
|
||||
local osmo_tmp
|
||||
osmo_tmp="$(mktemp -d)"
|
||||
|
||||
info "Cloning libosmocore..."
|
||||
if git clone --depth 1 https://gitea.osmocom.org/osmocom/libosmocore.git "$osmo_tmp/libosmocore"; then
|
||||
cd "$osmo_tmp/libosmocore"
|
||||
|
||||
info "Building libosmocore (this may take a few minutes)..."
|
||||
autoreconf -fi
|
||||
./configure --prefix=/usr/local
|
||||
if make -j$(nproc) && $SUDO make install && $SUDO ldconfig; then
|
||||
ok "libosmocore built and installed successfully"
|
||||
else
|
||||
warn "Failed to build libosmocore from source"
|
||||
cd /
|
||||
rm -rf "$osmo_tmp"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cd /
|
||||
rm -rf "$osmo_tmp"
|
||||
else
|
||||
warn "Failed to clone libosmocore"
|
||||
rm -rf "$osmo_tmp"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
ok "libosmocore installed from packages"
|
||||
# Also try to install the other osmo packages
|
||||
$SUDO apt-get install -y \
|
||||
libosmocoding-dev \
|
||||
libosmoctrl-dev \
|
||||
libosmogsm-dev \
|
||||
libosmovty-dev \
|
||||
libosmocodec-dev \
|
||||
2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Build gr-gsm
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d)"
|
||||
|
||||
# Check GNU Radio version to select correct gr-gsm fork
|
||||
local gr_major gr_minor
|
||||
gr_major=$(gnuradio-config-info --version 2>/dev/null | cut -d. -f1 || echo "3")
|
||||
gr_minor=$(gnuradio-config-info --version 2>/dev/null | cut -d. -f2 || echo "8")
|
||||
|
||||
local grgsm_repo grgsm_branch
|
||||
if [[ "$gr_major" -ge 3 && "$gr_minor" -ge 10 ]]; then
|
||||
# GNU Radio 3.10+ needs velichkov fork with maint-3.10 branch
|
||||
info "GNU Radio 3.10+ detected, using velichkov fork (maint-3.10 branch)..."
|
||||
grgsm_repo="https://github.com/velichkov/gr-gsm.git"
|
||||
grgsm_branch="maint-3.10"
|
||||
|
||||
# Install pybind11 for GNU Radio 3.10+
|
||||
$SUDO apt-get install -y pybind11-dev python3-pybind11 || true
|
||||
else
|
||||
# Older GNU Radio versions use original repo with SWIG
|
||||
info "GNU Radio < 3.10 detected, using original gr-gsm..."
|
||||
grgsm_repo="https://github.com/ptrkrysik/gr-gsm.git"
|
||||
grgsm_branch="master"
|
||||
fi
|
||||
|
||||
info "Cloning gr-gsm from $grgsm_repo..."
|
||||
if ! git clone --depth 1 -b "$grgsm_branch" "$grgsm_repo" "$tmp_dir/gr-gsm"; then
|
||||
warn "Failed to clone gr-gsm repository"
|
||||
rm -rf "$tmp_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cd "$tmp_dir/gr-gsm"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Configuring gr-gsm..."
|
||||
# Include /usr/local in CMAKE_PREFIX_PATH for source-built libosmocore
|
||||
if ! cmake -DCMAKE_PREFIX_PATH="/usr/local;/usr" -DCMAKE_INSTALL_PREFIX=/usr/local ..; then
|
||||
warn "Failed to configure gr-gsm (cmake failed)"
|
||||
warn "Check that all dependencies are installed"
|
||||
cd /
|
||||
rm -rf "$tmp_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "Compiling gr-gsm (this may take a few minutes)..."
|
||||
if ! make -j$(nproc); then
|
||||
warn "Failed to compile gr-gsm"
|
||||
warn "Check the build output above for errors"
|
||||
cd /
|
||||
rm -rf "$tmp_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "Installing gr-gsm..."
|
||||
if $SUDO make install && $SUDO ldconfig; then
|
||||
ok "gr-gsm installed successfully."
|
||||
info "grgsm_scanner should now be available for GSM cell detection."
|
||||
else
|
||||
warn "Failed to install gr-gsm"
|
||||
cd /
|
||||
rm -rf "$tmp_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
cd /
|
||||
rm -rf "$tmp_dir"
|
||||
return 0
|
||||
}
|
||||
|
||||
install_rtlsdr_blog_drivers_debian() {
|
||||
# The RTL-SDR Blog drivers provide better support for:
|
||||
# - RTL-SDR Blog V4 (R828D tuner)
|
||||
@@ -718,24 +547,14 @@ install_debian_packages() {
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export NEEDRESTART_MODE=a
|
||||
|
||||
TOTAL_STEPS=19
|
||||
TOTAL_STEPS=18
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Updating APT package lists"
|
||||
$SUDO apt-get update -y >/dev/null
|
||||
|
||||
# Fix any broken RTL-SDR packages first (common issue with Blog drivers vs stock)
|
||||
info "Fixing RTL-SDR package conflicts..."
|
||||
# Force remove broken packages
|
||||
$SUDO dpkg --remove --force-remove-reinstreq librtlsdr0 librtlsdr2 librtlsdr-dev rtl-sdr 2>/dev/null || true
|
||||
$SUDO apt-get -f install -y 2>/dev/null || true
|
||||
$SUDO dpkg --configure -a 2>/dev/null || true
|
||||
$SUDO apt-get autoremove -y --purge 2>/dev/null || true
|
||||
|
||||
progress "Installing RTL-SDR"
|
||||
# Skip apt rtl-sdr package - we'll use RTL-SDR Blog drivers instead which are better
|
||||
# The stock packages often conflict with the Blog drivers
|
||||
info "Skipping stock rtl-sdr package (RTL-SDR Blog drivers will be used instead)"
|
||||
apt_install rtl-sdr
|
||||
|
||||
progress "Installing RTL-SDR Blog drivers (V4 support)"
|
||||
install_rtlsdr_blog_drivers_debian
|
||||
@@ -798,13 +617,6 @@ 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
|
||||
|
||||
|
||||
@@ -5052,190 +5052,3 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -112,11 +112,6 @@
|
||||
<span class="mode-name">APRS</span>
|
||||
<span class="mode-desc">Amateur radio</span>
|
||||
</button>
|
||||
<button class="mode-card" onclick="selectMode('isms')">
|
||||
<span class="mode-icon">📊</span>
|
||||
<span class="mode-name">ISMS</span>
|
||||
<span class="mode-desc">Spectrum monitoring</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -390,7 +385,6 @@
|
||||
</button>
|
||||
<div class="mode-nav-dropdown-menu">
|
||||
<button class="mode-nav-btn" onclick="switchMode('tscm')"><span class="nav-icon">🔍</span><span class="nav-label">TSCM</span></button>
|
||||
<button class="mode-nav-btn" onclick="switchMode('isms')"><span class="nav-icon">📊</span><span class="nav-label">ISMS Station</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-nav-actions">
|
||||
@@ -409,7 +403,6 @@
|
||||
<button class="mobile-nav-btn" data-mode="wifi" onclick="switchMode('wifi')">📶 WiFi</button>
|
||||
<button class="mobile-nav-btn" data-mode="bluetooth" onclick="switchMode('bluetooth')">🔵 BT</button>
|
||||
<button class="mobile-nav-btn" data-mode="tscm" onclick="switchMode('tscm')">🔍 TSCM</button>
|
||||
<button class="mobile-nav-btn" data-mode="isms" onclick="switchMode('isms')">📊 ISMS</button>
|
||||
<button class="mobile-nav-btn" data-mode="satellite" onclick="switchMode('satellite')">🛰️ Sat</button>
|
||||
<button class="mobile-nav-btn" data-mode="listening" onclick="switchMode('listening')">📻 Scanner</button>
|
||||
</nav>
|
||||
@@ -508,8 +501,6 @@
|
||||
|
||||
{% include 'partials/modes/tscm.html' %}
|
||||
|
||||
{% include 'partials/modes/isms.html' %}
|
||||
|
||||
<button class="preset-btn" onclick="killAll()" style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
|
||||
Kill All Processes
|
||||
</button>
|
||||
@@ -1121,113 +1112,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ISMS Listening Station Dashboard -->
|
||||
<div id="ismsVisuals" class="isms-dashboard" style="display: none; padding: 16px;">
|
||||
<!-- Legal/Info Banner -->
|
||||
<div class="isms-info-banner" style="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);">
|
||||
<strong>ISMS Listening Station:</strong> RF spectrum monitoring for situational awareness.
|
||||
Requires rtl_power. Tower data from OpenCelliD (API token required).
|
||||
</div>
|
||||
|
||||
<!-- Main Grid Layout -->
|
||||
<div class="isms-grid" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;">
|
||||
|
||||
<!-- Band Activity Meters -->
|
||||
<div class="isms-panel" style="grid-column: span 4; background: var(--bg-card); border-radius: 8px; padding: 12px; border: 1px solid var(--border-color);">
|
||||
<div class="isms-panel-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
||||
<span style="font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px;">Band Activity</span>
|
||||
<span id="ismsScanStatus" style="font-size: 10px; color: var(--accent-cyan);">IDLE</span>
|
||||
</div>
|
||||
<div id="ismsBandMeters" style="display: flex; gap: 12px; flex-wrap: wrap; justify-content: center;">
|
||||
<div style="color: var(--text-muted); font-size: 11px; text-align: center; padding: 20px;">
|
||||
Start a scan to see band activity
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Peaks -->
|
||||
<div class="isms-panel" style="grid-column: span 2; background: var(--bg-card); border-radius: 8px; padding: 12px; border: 1px solid var(--border-color);">
|
||||
<div class="isms-panel-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px;">Top Peaks</span>
|
||||
<span id="ismsPeakCount" style="font-size: 10px; color: var(--accent-cyan);">0</span>
|
||||
</div>
|
||||
<div id="ismsPeaksList" style="max-height: 200px; overflow-y: auto;">
|
||||
<table style="width: 100%; font-size: 10px; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="color: var(--text-muted); text-transform: uppercase;">
|
||||
<th style="text-align: left; padding: 4px 8px;">Freq</th>
|
||||
<th style="text-align: right; padding: 4px 8px;">Power</th>
|
||||
<th style="text-align: left; padding: 4px 8px;">Band</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ismsPeaksBody">
|
||||
<tr><td colspan="3" style="text-align: center; padding: 20px; color: var(--text-muted);">No peaks detected</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nearby Towers Map -->
|
||||
<div class="isms-panel" style="grid-column: span 2; background: var(--bg-card); border-radius: 8px; padding: 12px; border: 1px solid var(--border-color);">
|
||||
<div class="isms-panel-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px;">Nearby Towers</span>
|
||||
<button class="preset-btn" onclick="ismsRefreshTowers()" style="padding: 2px 8px; font-size: 9px;">Refresh</button>
|
||||
</div>
|
||||
<div id="ismsTowerMap" style="height: 180px; background: rgba(0,0,0,0.3); border-radius: 4px; margin-bottom: 8px;">
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-muted); font-size: 11px;">
|
||||
Set location and query towers
|
||||
</div>
|
||||
</div>
|
||||
<div id="ismsTowerList" style="max-height: 80px; overflow-y: auto; font-size: 10px;">
|
||||
<!-- Tower list items will appear here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Findings Timeline -->
|
||||
<div class="isms-panel" style="grid-column: span 4; background: var(--bg-card); border-radius: 8px; padding: 12px; border: 1px solid var(--border-color);">
|
||||
<div class="isms-panel-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px;">Findings</span>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<span id="ismsFindingsHigh" class="isms-badge isms-badge-high" style="display: none;">0 HIGH</span>
|
||||
<span id="ismsFindingsWarn" class="isms-badge isms-badge-warn" style="display: none;">0 WARN</span>
|
||||
<span id="ismsFindingsInfo" class="isms-badge isms-badge-info">0 INFO</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ismsFindingsTimeline" style="max-height: 150px; overflow-y: auto;">
|
||||
<div style="color: var(--text-muted); font-size: 11px; text-align: center; padding: 20px;">
|
||||
No findings yet. Start a scan and enable baseline comparison.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Baseline Comparison (collapsible) -->
|
||||
<div class="isms-panel collapsible" style="grid-column: span 4; background: var(--bg-card); border-radius: 8px; border: 1px solid var(--border-color);">
|
||||
<div class="isms-panel-header clickable" onclick="ismsToggleBaselinePanel()" style="padding: 12px; cursor: pointer; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px;">
|
||||
<span id="ismsBaselinePanelIcon">▶</span> Baseline Comparison
|
||||
</span>
|
||||
<span id="ismsBaselineStatus" style="font-size: 10px; color: var(--text-muted);">No baseline selected</span>
|
||||
</div>
|
||||
<div id="ismsBaselineCompare" style="display: none; padding: 0 12px 12px 12px;">
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 9px; color: var(--text-muted); text-transform: uppercase;">Noise Floor Delta</div>
|
||||
<div id="ismsNoiseFloorDelta" style="font-size: 18px; font-weight: bold; color: var(--accent-cyan);">-- dB</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 9px; color: var(--text-muted); text-transform: uppercase;">Activity Delta</div>
|
||||
<div id="ismsActivityDelta" style="font-size: 18px; font-weight: bold; color: var(--accent-cyan);">--%</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 9px; color: var(--text-muted); text-transform: uppercase;">New Peaks</div>
|
||||
<div id="ismsNewPeaks" style="font-size: 18px; font-weight: bold; color: var(--accent-cyan);">--</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||||
<div class="recon-panel collapsed" id="reconPanel">
|
||||
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
||||
@@ -1301,7 +1185,6 @@
|
||||
<script src="{{ url_for('static', filename='js/core/audio.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/radio-knob.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/isms.js') }}"></script>
|
||||
|
||||
<script>
|
||||
// Selected mode from welcome screen
|
||||
@@ -1649,7 +1532,7 @@
|
||||
'pager': 'sdr', 'sensor': 'sdr',
|
||||
'aprs': 'sdr', 'satellite': 'sdr', 'listening': 'sdr',
|
||||
'wifi': 'wireless', 'bluetooth': 'wireless',
|
||||
'tscm': 'security', 'isms': 'security'
|
||||
'tscm': 'security'
|
||||
};
|
||||
|
||||
// Remove has-active from all dropdowns
|
||||
@@ -1707,7 +1590,6 @@
|
||||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
||||
document.getElementById('aprsMode').classList.toggle('active', mode === 'aprs');
|
||||
document.getElementById('tscmMode').classList.toggle('active', mode === 'tscm');
|
||||
document.getElementById('ismsMode').classList.toggle('active', mode === 'isms');
|
||||
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
|
||||
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
|
||||
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
|
||||
@@ -1733,8 +1615,7 @@
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
'listening': 'LISTENING POST',
|
||||
'aprs': 'APRS',
|
||||
'tscm': 'TSCM',
|
||||
'isms': 'ISMS STATION'
|
||||
'tscm': 'TSCM'
|
||||
};
|
||||
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
|
||||
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
@@ -1743,12 +1624,6 @@
|
||||
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
|
||||
document.getElementById('aprsVisuals').style.display = mode === 'aprs' ? 'flex' : 'none';
|
||||
document.getElementById('tscmVisuals').style.display = mode === 'tscm' ? 'flex' : 'none';
|
||||
document.getElementById('ismsVisuals').style.display = mode === 'isms' ? 'block' : 'none';
|
||||
|
||||
// Initialize ISMS mode when switching to it
|
||||
if (mode === 'isms' && typeof initIsmsMode === 'function') {
|
||||
initIsmsMode();
|
||||
}
|
||||
|
||||
// Update output panel title based on mode
|
||||
const titles = {
|
||||
@@ -1759,8 +1634,7 @@
|
||||
'bluetooth': 'Bluetooth Scanner',
|
||||
'listening': 'Listening Post',
|
||||
'aprs': 'APRS Tracker',
|
||||
'tscm': 'TSCM Counter-Surveillance',
|
||||
'isms': 'ISMS Listening Station'
|
||||
'tscm': 'TSCM Counter-Surveillance'
|
||||
};
|
||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
<!-- ISMS LISTENING STATION MODE -->
|
||||
<div id="ismsMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Status</h3>
|
||||
|
||||
<!-- Quick Status -->
|
||||
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
|
||||
<span id="ismsQuickStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Band</span>
|
||||
<span id="ismsQuickBand" style="font-size: 11px; color: var(--text-primary);">--</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Findings</span>
|
||||
<span id="ismsQuickFindings" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Location</span>
|
||||
<span id="ismsQuickLocation" style="font-size: 10px; color: var(--text-muted);">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Scan Preset</h3>
|
||||
<select id="ismsScanPreset" class="full-width-select">
|
||||
<option value="vhf_airband">VHF Airband (118-137 MHz)</option>
|
||||
<option value="uhf_airband">UHF Airband (225-400 MHz)</option>
|
||||
<option value="uhf_pmr">UHF PMR446</option>
|
||||
<option value="ism_433" selected>ISM 433 MHz</option>
|
||||
<option value="ism_868">ISM 868 MHz</option>
|
||||
<option value="ism_915">ISM 915 MHz (US)</option>
|
||||
<option value="cellular_700">Cellular 700 MHz</option>
|
||||
<option value="cellular_850">Cellular 850 MHz</option>
|
||||
<option value="cellular_900">Cellular 900 MHz</option>
|
||||
<option value="full_sweep">Full Spectrum</option>
|
||||
<option value="custom">Custom Range</option>
|
||||
</select>
|
||||
|
||||
<!-- Custom range (shown when custom selected) -->
|
||||
<div id="ismsCustomRange" style="display: none; margin-top: 8px;">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<input type="number" id="ismsStartFreq" placeholder="Start MHz" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
||||
<input type="number" id="ismsEndFreq" placeholder="End MHz" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>SDR Settings</h3>
|
||||
<div class="form-group">
|
||||
<label>Device</label>
|
||||
<select id="ismsSdrDevice" class="full-width-select">
|
||||
<option value="0">Device 0</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gain</label>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="range" id="ismsGain" min="0" max="50" value="40" style="flex: 1;">
|
||||
<span id="ismsGainValue" style="min-width: 32px; text-align: right; font-size: 11px;">40</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Activity Threshold</label>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="range" id="ismsActivityThreshold" min="0" max="100" value="50" style="flex: 1;">
|
||||
<span id="ismsThresholdValue" style="min-width: 32px; text-align: right; font-size: 11px;">50%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Location</h3>
|
||||
<div class="form-group">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="preset-btn" onclick="ismsUseGPS()" style="flex: 1;">Use GPS</button>
|
||||
<button class="preset-btn" onclick="ismsSetManualLocation()" style="flex: 1;">Manual</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ismsCoords" style="font-size: 10px; color: var(--text-muted); margin-top: 4px; font-family: 'JetBrains Mono', monospace;">
|
||||
Lat: --, Lon: --
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Baseline</h3>
|
||||
<select id="ismsBaselineSelect" class="full-width-select">
|
||||
<option value="">No Baseline (Compare Disabled)</option>
|
||||
</select>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||
<button class="preset-btn" id="ismsRecordBaselineBtn" onclick="ismsToggleBaselineRecording()" style="flex: 1;">
|
||||
Record New
|
||||
</button>
|
||||
<button class="preset-btn" onclick="ismsRefreshBaselines()" style="padding: 6px 10px;">
|
||||
<span style="font-size: 12px;">↻</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="ismsBaselineRecordingStatus" style="display: none; margin-top: 8px; padding: 8px; background: rgba(255, 100, 100, 0.1); border-radius: 4px; font-size: 10px; color: var(--accent-red);">
|
||||
Recording baseline...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Cell Towers</h3>
|
||||
<div class="form-group">
|
||||
<button class="preset-btn" onclick="ismsRefreshTowers()" style="width: 100%;">
|
||||
Query Nearby Towers
|
||||
</button>
|
||||
</div>
|
||||
<div id="ismsTowerCount" style="font-size: 10px; color: var(--text-muted); margin-top: 4px;">
|
||||
No tower data
|
||||
</div>
|
||||
<div style="margin-top: 8px;">
|
||||
<a href="https://www.ofcom.org.uk/phones-and-broadband/coverage-and-quality/mobile-coverage-checker" target="_blank" rel="noopener" style="font-size: 10px; color: var(--accent-cyan);">
|
||||
Ofcom Coverage Checker
|
||||
</a>
|
||||
<br>
|
||||
<a href="https://www.ofcom.org.uk/phones-telecoms-and-internet/information-for-industry/radiocomms-and-spectrum/radio-spectrum/spectrum-for-mobile-services/electromagnetic-fields-emf" target="_blank" rel="noopener" style="font-size: 10px; color: var(--accent-cyan);">
|
||||
Ofcom EMF Info
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>GSM Scanner</h3>
|
||||
<div style="background: rgba(255, 170, 0, 0.1); border: 1px solid rgba(255, 170, 0, 0.3); border-radius: 4px; padding: 6px 8px; margin-bottom: 8px; font-size: 9px; color: var(--accent-orange);">
|
||||
Alpha - Under Development
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>GSM Band</label>
|
||||
<select id="ismsGsmBand" class="full-width-select">
|
||||
<option value="GSM900" selected>GSM 900 MHz (Europe)</option>
|
||||
<option value="EGSM900">E-GSM 900 MHz</option>
|
||||
<option value="GSM1800">GSM 1800 MHz (DCS)</option>
|
||||
<option value="GSM850">GSM 850 MHz (US)</option>
|
||||
<option value="GSM1900">GSM 1900 MHz (PCS)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||
<button class="preset-btn" id="ismsGsmScanBtn" onclick="ismsToggleGsmScan()" style="flex: 1;">
|
||||
Scan GSM Cells
|
||||
</button>
|
||||
<button class="preset-btn" onclick="ismsSetGsmBaseline()" style="padding: 6px 10px;" title="Save as baseline">
|
||||
<span style="font-size: 12px;">💾</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="ismsGsmStatus" style="margin-top: 8px; padding: 8px; background: rgba(0,0,0,0.3); border-radius: 4px; font-size: 10px;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: var(--text-muted);">Status:</span>
|
||||
<span id="ismsGsmStatusText" style="color: var(--accent-cyan);">Ready</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--text-muted);">Cells Found:</span>
|
||||
<span id="ismsGsmCellCount" style="color: var(--accent-green); font-weight: bold;">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ismsGsmCells" style="margin-top: 8px; max-height: 150px; overflow-y: auto; font-size: 10px; font-family: 'JetBrains Mono', monospace;">
|
||||
<!-- GSM cells will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="ismsStartBtn" onclick="ismsToggleScan()">
|
||||
Start Scan
|
||||
</button>
|
||||
</div>
|
||||
@@ -194,76 +194,6 @@ def init_db() -> None:
|
||||
ON tscm_sweeps(baseline_id)
|
||||
''')
|
||||
|
||||
# =====================================================================
|
||||
# ISMS (Intelligent Spectrum Monitoring Station) Tables
|
||||
# =====================================================================
|
||||
|
||||
# ISMS Baselines - Location-based spectrum profiles
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS isms_baselines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
location_name TEXT,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
spectrum_profile TEXT,
|
||||
cellular_environment TEXT,
|
||||
known_towers TEXT,
|
||||
is_active BOOLEAN DEFAULT 0
|
||||
)
|
||||
''')
|
||||
|
||||
# ISMS Scans - Individual scan sessions
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS isms_scans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
baseline_id INTEGER,
|
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
status TEXT DEFAULT 'running',
|
||||
scan_preset TEXT,
|
||||
gps_coords TEXT,
|
||||
results TEXT,
|
||||
findings_count INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (baseline_id) REFERENCES isms_baselines(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# ISMS Findings - Detected anomalies and observations
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS isms_findings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scan_id INTEGER,
|
||||
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
finding_type TEXT NOT NULL,
|
||||
severity TEXT DEFAULT 'info',
|
||||
band TEXT,
|
||||
frequency REAL,
|
||||
description TEXT,
|
||||
details TEXT,
|
||||
acknowledged BOOLEAN DEFAULT 0,
|
||||
FOREIGN KEY (scan_id) REFERENCES isms_scans(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# ISMS indexes for performance
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_isms_baselines_location
|
||||
ON isms_baselines(latitude, longitude)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_isms_findings_severity
|
||||
ON isms_findings(severity, detected_at)
|
||||
''')
|
||||
|
||||
conn.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_isms_scans_baseline
|
||||
ON isms_scans(baseline_id)
|
||||
''')
|
||||
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
|
||||
@@ -863,354 +793,3 @@ def get_tscm_threat_summary() -> dict:
|
||||
summary['total'] += row['count']
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ISMS Functions
|
||||
# =============================================================================
|
||||
|
||||
def create_isms_baseline(
|
||||
name: str,
|
||||
location_name: str | None = None,
|
||||
latitude: float | None = None,
|
||||
longitude: float | None = None,
|
||||
spectrum_profile: dict | None = None,
|
||||
cellular_environment: list | None = None,
|
||||
known_towers: list | None = None
|
||||
) -> int:
|
||||
"""
|
||||
Create a new ISMS baseline.
|
||||
|
||||
Returns:
|
||||
The ID of the created baseline
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO isms_baselines
|
||||
(name, location_name, latitude, longitude, spectrum_profile,
|
||||
cellular_environment, known_towers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
name,
|
||||
location_name,
|
||||
latitude,
|
||||
longitude,
|
||||
json.dumps(spectrum_profile) if spectrum_profile else None,
|
||||
json.dumps(cellular_environment) if cellular_environment else None,
|
||||
json.dumps(known_towers) if known_towers else None
|
||||
))
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def get_isms_baseline(baseline_id: int) -> dict | None:
|
||||
"""Get a specific ISMS baseline by ID."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'SELECT * FROM isms_baselines WHERE id = ?',
|
||||
(baseline_id,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
'location_name': row['location_name'],
|
||||
'latitude': row['latitude'],
|
||||
'longitude': row['longitude'],
|
||||
'created_at': row['created_at'],
|
||||
'updated_at': row['updated_at'],
|
||||
'spectrum_profile': json.loads(row['spectrum_profile']) if row['spectrum_profile'] else {},
|
||||
'cellular_environment': json.loads(row['cellular_environment']) if row['cellular_environment'] else [],
|
||||
'known_towers': json.loads(row['known_towers']) if row['known_towers'] else [],
|
||||
'is_active': bool(row['is_active'])
|
||||
}
|
||||
|
||||
|
||||
def get_all_isms_baselines() -> list[dict]:
|
||||
"""Get all ISMS baselines."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT id, name, location_name, latitude, longitude, created_at, is_active
|
||||
FROM isms_baselines
|
||||
ORDER BY created_at DESC
|
||||
''')
|
||||
return [dict(row) for row in cursor]
|
||||
|
||||
|
||||
def get_active_isms_baseline() -> dict | None:
|
||||
"""Get the currently active ISMS baseline."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'SELECT * FROM isms_baselines WHERE is_active = 1 LIMIT 1'
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return get_isms_baseline(row['id'])
|
||||
|
||||
|
||||
def set_active_isms_baseline(baseline_id: int | None) -> bool:
|
||||
"""Set a baseline as active (deactivates others). Pass None to deactivate all."""
|
||||
with get_db() as conn:
|
||||
# Deactivate all
|
||||
conn.execute('UPDATE isms_baselines SET is_active = 0')
|
||||
|
||||
if baseline_id is not None:
|
||||
# Activate selected
|
||||
cursor = conn.execute(
|
||||
'UPDATE isms_baselines SET is_active = 1 WHERE id = ?',
|
||||
(baseline_id,)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def update_isms_baseline(
|
||||
baseline_id: int,
|
||||
spectrum_profile: dict | None = None,
|
||||
cellular_environment: list | None = None,
|
||||
known_towers: list | None = None
|
||||
) -> bool:
|
||||
"""Update baseline spectrum/cellular data."""
|
||||
updates = ['updated_at = CURRENT_TIMESTAMP']
|
||||
params = []
|
||||
|
||||
if spectrum_profile is not None:
|
||||
updates.append('spectrum_profile = ?')
|
||||
params.append(json.dumps(spectrum_profile))
|
||||
if cellular_environment is not None:
|
||||
updates.append('cellular_environment = ?')
|
||||
params.append(json.dumps(cellular_environment))
|
||||
if known_towers is not None:
|
||||
updates.append('known_towers = ?')
|
||||
params.append(json.dumps(known_towers))
|
||||
|
||||
params.append(baseline_id)
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
f'UPDATE isms_baselines SET {", ".join(updates)} WHERE id = ?',
|
||||
params
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def delete_isms_baseline(baseline_id: int) -> bool:
|
||||
"""Delete an ISMS baseline."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'DELETE FROM isms_baselines WHERE id = ?',
|
||||
(baseline_id,)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def create_isms_scan(
|
||||
scan_preset: str,
|
||||
baseline_id: int | None = None,
|
||||
gps_coords: dict | None = None
|
||||
) -> int:
|
||||
"""
|
||||
Create a new ISMS scan session.
|
||||
|
||||
Returns:
|
||||
The ID of the created scan
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO isms_scans (baseline_id, scan_preset, gps_coords)
|
||||
VALUES (?, ?, ?)
|
||||
''', (
|
||||
baseline_id,
|
||||
scan_preset,
|
||||
json.dumps(gps_coords) if gps_coords else None
|
||||
))
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def update_isms_scan(
|
||||
scan_id: int,
|
||||
status: str | None = None,
|
||||
results: dict | None = None,
|
||||
findings_count: int | None = None,
|
||||
completed: bool = False
|
||||
) -> bool:
|
||||
"""Update an ISMS scan."""
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if status is not None:
|
||||
updates.append('status = ?')
|
||||
params.append(status)
|
||||
if results is not None:
|
||||
updates.append('results = ?')
|
||||
params.append(json.dumps(results))
|
||||
if findings_count is not None:
|
||||
updates.append('findings_count = ?')
|
||||
params.append(findings_count)
|
||||
if completed:
|
||||
updates.append('completed_at = CURRENT_TIMESTAMP')
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
params.append(scan_id)
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
f'UPDATE isms_scans SET {", ".join(updates)} WHERE id = ?',
|
||||
params
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_isms_scan(scan_id: int) -> dict | None:
|
||||
"""Get a specific ISMS scan by ID."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('SELECT * FROM isms_scans WHERE id = ?', (scan_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
'id': row['id'],
|
||||
'baseline_id': row['baseline_id'],
|
||||
'started_at': row['started_at'],
|
||||
'completed_at': row['completed_at'],
|
||||
'status': row['status'],
|
||||
'scan_preset': row['scan_preset'],
|
||||
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None,
|
||||
'results': json.loads(row['results']) if row['results'] else None,
|
||||
'findings_count': row['findings_count']
|
||||
}
|
||||
|
||||
|
||||
def get_recent_isms_scans(limit: int = 20) -> list[dict]:
|
||||
"""Get recent ISMS scans."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT id, baseline_id, started_at, completed_at, status,
|
||||
scan_preset, findings_count
|
||||
FROM isms_scans
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ?
|
||||
''', (limit,))
|
||||
return [dict(row) for row in cursor]
|
||||
|
||||
|
||||
def add_isms_finding(
|
||||
scan_id: int,
|
||||
finding_type: str,
|
||||
severity: str,
|
||||
description: str,
|
||||
band: str | None = None,
|
||||
frequency: float | None = None,
|
||||
details: dict | None = None
|
||||
) -> int:
|
||||
"""
|
||||
Add a finding to an ISMS scan.
|
||||
|
||||
Returns:
|
||||
The ID of the created finding
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO isms_findings
|
||||
(scan_id, finding_type, severity, band, frequency, description, details)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
scan_id, finding_type, severity, band, frequency, description,
|
||||
json.dumps(details) if details else None
|
||||
))
|
||||
|
||||
# Increment findings count on scan
|
||||
conn.execute(
|
||||
'UPDATE isms_scans SET findings_count = findings_count + 1 WHERE id = ?',
|
||||
(scan_id,)
|
||||
)
|
||||
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def get_isms_findings(
|
||||
scan_id: int | None = None,
|
||||
severity: str | None = None,
|
||||
acknowledged: bool | None = None,
|
||||
limit: int = 100
|
||||
) -> list[dict]:
|
||||
"""Get ISMS findings with optional filters."""
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if scan_id is not None:
|
||||
conditions.append('scan_id = ?')
|
||||
params.append(scan_id)
|
||||
if severity is not None:
|
||||
conditions.append('severity = ?')
|
||||
params.append(severity)
|
||||
if acknowledged is not None:
|
||||
conditions.append('acknowledged = ?')
|
||||
params.append(1 if acknowledged else 0)
|
||||
|
||||
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
|
||||
params.append(limit)
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(f'''
|
||||
SELECT * FROM isms_findings
|
||||
{where_clause}
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT ?
|
||||
''', params)
|
||||
|
||||
results = []
|
||||
for row in cursor:
|
||||
results.append({
|
||||
'id': row['id'],
|
||||
'scan_id': row['scan_id'],
|
||||
'detected_at': row['detected_at'],
|
||||
'finding_type': row['finding_type'],
|
||||
'severity': row['severity'],
|
||||
'band': row['band'],
|
||||
'frequency': row['frequency'],
|
||||
'description': row['description'],
|
||||
'details': json.loads(row['details']) if row['details'] else None,
|
||||
'acknowledged': bool(row['acknowledged'])
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def acknowledge_isms_finding(finding_id: int) -> bool:
|
||||
"""Acknowledge an ISMS finding."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'UPDATE isms_findings SET acknowledged = 1 WHERE id = ?',
|
||||
(finding_id,)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
def get_isms_findings_summary() -> dict:
|
||||
"""Get summary counts of findings by severity."""
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT severity, COUNT(*) as count
|
||||
FROM isms_findings
|
||||
WHERE acknowledged = 0
|
||||
GROUP BY severity
|
||||
''')
|
||||
|
||||
summary = {'high': 0, 'warn': 0, 'info': 0, 'total': 0}
|
||||
for row in cursor:
|
||||
summary[row['severity']] = row['count']
|
||||
summary['total'] += row['count']
|
||||
|
||||
return summary
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""
|
||||
ISMS (Intelligent Spectrum Monitoring Station) utilities.
|
||||
|
||||
Provides spectrum analysis, tower integration, anomaly detection,
|
||||
and baseline management for RF situational awareness.
|
||||
"""
|
||||
|
||||
from .spectrum import (
|
||||
SpectrumBin,
|
||||
BandMetrics,
|
||||
run_rtl_power_scan,
|
||||
compute_band_metrics,
|
||||
detect_bursts,
|
||||
get_rtl_power_path,
|
||||
)
|
||||
from .towers import (
|
||||
CellTower,
|
||||
query_nearby_towers,
|
||||
build_cellmapper_url,
|
||||
build_ofcom_coverage_url,
|
||||
build_ofcom_emf_url,
|
||||
get_opencellid_token,
|
||||
)
|
||||
from .rules import (
|
||||
Rule,
|
||||
Finding,
|
||||
RulesEngine,
|
||||
ISMS_RULES,
|
||||
)
|
||||
from .baseline import (
|
||||
BaselineRecorder,
|
||||
compare_spectrum_baseline,
|
||||
compare_tower_baseline,
|
||||
)
|
||||
from .gsm import (
|
||||
GsmCell,
|
||||
GsmScanResult,
|
||||
run_grgsm_scan,
|
||||
run_gsm_scan_blocking,
|
||||
get_grgsm_scanner_path,
|
||||
format_gsm_cell,
|
||||
deduplicate_cells,
|
||||
identify_gsm_anomalies,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Spectrum
|
||||
'SpectrumBin',
|
||||
'BandMetrics',
|
||||
'run_rtl_power_scan',
|
||||
'compute_band_metrics',
|
||||
'detect_bursts',
|
||||
'get_rtl_power_path',
|
||||
# Towers
|
||||
'CellTower',
|
||||
'query_nearby_towers',
|
||||
'build_cellmapper_url',
|
||||
'build_ofcom_coverage_url',
|
||||
'build_ofcom_emf_url',
|
||||
'get_opencellid_token',
|
||||
# Rules
|
||||
'Rule',
|
||||
'Finding',
|
||||
'RulesEngine',
|
||||
'ISMS_RULES',
|
||||
# Baseline
|
||||
'BaselineRecorder',
|
||||
'compare_spectrum_baseline',
|
||||
'compare_tower_baseline',
|
||||
# GSM
|
||||
'GsmCell',
|
||||
'GsmScanResult',
|
||||
'run_grgsm_scan',
|
||||
'run_gsm_scan_blocking',
|
||||
'get_grgsm_scanner_path',
|
||||
'format_gsm_cell',
|
||||
'deduplicate_cells',
|
||||
'identify_gsm_anomalies',
|
||||
]
|
||||
@@ -1,533 +0,0 @@
|
||||
"""
|
||||
ISMS baseline management.
|
||||
|
||||
Provides functions for recording, storing, and comparing
|
||||
spectrum and cellular baselines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from statistics import mean, stdev
|
||||
from typing import Any
|
||||
|
||||
from utils.database import (
|
||||
create_isms_baseline,
|
||||
get_isms_baseline,
|
||||
update_isms_baseline,
|
||||
get_active_isms_baseline,
|
||||
)
|
||||
|
||||
from .spectrum import BandMetrics, SpectrumBin, compute_band_metrics
|
||||
from .towers import CellTower
|
||||
|
||||
logger = logging.getLogger('intercept.isms.baseline')
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpectrumBaseline:
|
||||
"""Baseline spectrum profile for a band."""
|
||||
band_name: str
|
||||
freq_start_mhz: float
|
||||
freq_end_mhz: float
|
||||
noise_floor_db: float
|
||||
avg_power_db: float
|
||||
activity_score: float
|
||||
peak_frequencies: list[float] # MHz
|
||||
recorded_at: datetime = field(default_factory=datetime.now)
|
||||
sample_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for storage."""
|
||||
return {
|
||||
'band_name': self.band_name,
|
||||
'freq_start_mhz': self.freq_start_mhz,
|
||||
'freq_end_mhz': self.freq_end_mhz,
|
||||
'noise_floor_db': self.noise_floor_db,
|
||||
'avg_power_db': self.avg_power_db,
|
||||
'activity_score': self.activity_score,
|
||||
'peak_frequencies': self.peak_frequencies,
|
||||
'recorded_at': self.recorded_at.isoformat(),
|
||||
'sample_count': self.sample_count,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> 'SpectrumBaseline':
|
||||
"""Create from dictionary."""
|
||||
recorded_at = data.get('recorded_at')
|
||||
if isinstance(recorded_at, str):
|
||||
recorded_at = datetime.fromisoformat(recorded_at)
|
||||
elif recorded_at is None:
|
||||
recorded_at = datetime.now()
|
||||
|
||||
return cls(
|
||||
band_name=data.get('band_name', 'Unknown'),
|
||||
freq_start_mhz=data.get('freq_start_mhz', 0),
|
||||
freq_end_mhz=data.get('freq_end_mhz', 0),
|
||||
noise_floor_db=data.get('noise_floor_db', -100),
|
||||
avg_power_db=data.get('avg_power_db', -100),
|
||||
activity_score=data.get('activity_score', 0),
|
||||
peak_frequencies=data.get('peak_frequencies', []),
|
||||
recorded_at=recorded_at,
|
||||
sample_count=data.get('sample_count', 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CellularBaseline:
|
||||
"""Baseline cellular environment."""
|
||||
cells: list[dict] # List of cell info dicts
|
||||
operators: list[str] # List of PLMNs seen
|
||||
recorded_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for storage."""
|
||||
return {
|
||||
'cells': self.cells,
|
||||
'operators': self.operators,
|
||||
'recorded_at': self.recorded_at.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> 'CellularBaseline':
|
||||
"""Create from dictionary."""
|
||||
recorded_at = data.get('recorded_at')
|
||||
if isinstance(recorded_at, str):
|
||||
recorded_at = datetime.fromisoformat(recorded_at)
|
||||
elif recorded_at is None:
|
||||
recorded_at = datetime.now()
|
||||
|
||||
return cls(
|
||||
cells=data.get('cells', []),
|
||||
operators=data.get('operators', []),
|
||||
recorded_at=recorded_at,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TowerBaseline:
|
||||
"""Baseline tower environment."""
|
||||
towers: list[dict] # List of tower info dicts
|
||||
recorded_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for storage."""
|
||||
return {
|
||||
'towers': self.towers,
|
||||
'recorded_at': self.recorded_at.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> 'TowerBaseline':
|
||||
"""Create from dictionary."""
|
||||
recorded_at = data.get('recorded_at')
|
||||
if isinstance(recorded_at, str):
|
||||
recorded_at = datetime.fromisoformat(recorded_at)
|
||||
elif recorded_at is None:
|
||||
recorded_at = datetime.now()
|
||||
|
||||
return cls(
|
||||
towers=data.get('towers', []),
|
||||
recorded_at=recorded_at,
|
||||
)
|
||||
|
||||
|
||||
class BaselineRecorder:
|
||||
"""Records spectrum and cellular data for baseline creation."""
|
||||
|
||||
def __init__(self):
|
||||
self._spectrum_samples: dict[str, list[BandMetrics]] = {}
|
||||
self._cellular_samples: list[dict] = []
|
||||
self._tower_samples: list[dict] = []
|
||||
self._gsm_cells: list[dict] = []
|
||||
self._recording = False
|
||||
self._started_at: datetime | None = None
|
||||
|
||||
@property
|
||||
def is_recording(self) -> bool:
|
||||
"""Check if recording is active."""
|
||||
return self._recording
|
||||
|
||||
def start_recording(self) -> None:
|
||||
"""Start baseline recording."""
|
||||
self._spectrum_samples.clear()
|
||||
self._cellular_samples.clear()
|
||||
self._tower_samples.clear()
|
||||
self._gsm_cells.clear()
|
||||
self._recording = True
|
||||
self._started_at = datetime.now()
|
||||
logger.info("Started baseline recording")
|
||||
|
||||
def stop_recording(self) -> dict:
|
||||
"""
|
||||
Stop recording and return compiled baseline data.
|
||||
|
||||
Returns:
|
||||
Dictionary with spectrum_profile, cellular_environment, known_towers
|
||||
"""
|
||||
self._recording = False
|
||||
|
||||
# Compile spectrum baselines
|
||||
spectrum_profile = {}
|
||||
for band_name, samples in self._spectrum_samples.items():
|
||||
if samples:
|
||||
spectrum_profile[band_name] = self._compile_spectrum_baseline(
|
||||
band_name, samples
|
||||
).to_dict()
|
||||
|
||||
# Compile cellular baseline
|
||||
cellular_env = self._compile_cellular_baseline().to_dict()
|
||||
|
||||
# Compile tower baseline
|
||||
tower_data = self._compile_tower_baseline().to_dict()
|
||||
|
||||
logger.info(
|
||||
f"Stopped baseline recording: {len(spectrum_profile)} bands, "
|
||||
f"{len(cellular_env.get('cells', []))} cells, "
|
||||
f"{len(tower_data.get('towers', []))} towers, "
|
||||
f"{len(self._gsm_cells)} GSM cells"
|
||||
)
|
||||
|
||||
return {
|
||||
'spectrum_profile': spectrum_profile,
|
||||
'cellular_environment': cellular_env.get('cells', []),
|
||||
'known_towers': tower_data.get('towers', []),
|
||||
'gsm_cells': self._gsm_cells.copy(),
|
||||
}
|
||||
|
||||
def add_spectrum_sample(self, band_name: str, metrics: BandMetrics) -> None:
|
||||
"""Add a spectrum sample during recording."""
|
||||
if not self._recording:
|
||||
return
|
||||
|
||||
if band_name not in self._spectrum_samples:
|
||||
self._spectrum_samples[band_name] = []
|
||||
|
||||
self._spectrum_samples[band_name].append(metrics)
|
||||
|
||||
def add_cellular_sample(self, cell_info: dict) -> None:
|
||||
"""Add a cellular sample during recording."""
|
||||
if not self._recording:
|
||||
return
|
||||
|
||||
# Deduplicate by cell_id + plmn
|
||||
key = (cell_info.get('cell_id'), cell_info.get('plmn'))
|
||||
existing = next(
|
||||
(c for c in self._cellular_samples
|
||||
if (c.get('cell_id'), c.get('plmn')) == key),
|
||||
None
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update signal strength if stronger
|
||||
if cell_info.get('rsrp', -200) > existing.get('rsrp', -200):
|
||||
existing.update(cell_info)
|
||||
else:
|
||||
self._cellular_samples.append(cell_info)
|
||||
|
||||
def add_tower_sample(self, tower: CellTower | dict) -> None:
|
||||
"""Add a tower sample during recording."""
|
||||
if not self._recording:
|
||||
return
|
||||
|
||||
if isinstance(tower, CellTower):
|
||||
tower_dict = tower.to_dict()
|
||||
else:
|
||||
tower_dict = tower
|
||||
|
||||
# Deduplicate by cellid
|
||||
cell_id = tower_dict.get('cellid')
|
||||
if not any(t.get('cellid') == cell_id for t in self._tower_samples):
|
||||
self._tower_samples.append(tower_dict)
|
||||
|
||||
def add_gsm_cell(self, cell: Any) -> None:
|
||||
"""
|
||||
Add a GSM cell sample during recording.
|
||||
|
||||
Args:
|
||||
cell: GsmCell object or dict with GSM cell info
|
||||
"""
|
||||
if not self._recording:
|
||||
return
|
||||
|
||||
# Convert to dict if needed
|
||||
if hasattr(cell, 'arfcn'):
|
||||
# It's a GsmCell object
|
||||
cell_dict = {
|
||||
'arfcn': cell.arfcn,
|
||||
'freq_mhz': cell.freq_mhz,
|
||||
'power_dbm': cell.power_dbm,
|
||||
'mcc': cell.mcc,
|
||||
'mnc': cell.mnc,
|
||||
'lac': cell.lac,
|
||||
'cell_id': cell.cell_id,
|
||||
'bsic': cell.bsic,
|
||||
'plmn': cell.plmn,
|
||||
'cell_global_id': cell.cell_global_id,
|
||||
}
|
||||
else:
|
||||
cell_dict = cell
|
||||
|
||||
# Deduplicate by ARFCN (keep strongest signal)
|
||||
arfcn = cell_dict.get('arfcn')
|
||||
existing = next(
|
||||
(c for c in self._gsm_cells if c.get('arfcn') == arfcn),
|
||||
None
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update if stronger signal
|
||||
if cell_dict.get('power_dbm', -200) > existing.get('power_dbm', -200):
|
||||
existing.update(cell_dict)
|
||||
else:
|
||||
self._gsm_cells.append(cell_dict)
|
||||
|
||||
def _compile_spectrum_baseline(
|
||||
self,
|
||||
band_name: str,
|
||||
samples: list[BandMetrics]
|
||||
) -> SpectrumBaseline:
|
||||
"""Compile spectrum samples into a baseline."""
|
||||
if not samples:
|
||||
return SpectrumBaseline(
|
||||
band_name=band_name,
|
||||
freq_start_mhz=0,
|
||||
freq_end_mhz=0,
|
||||
noise_floor_db=-100,
|
||||
avg_power_db=-100,
|
||||
activity_score=0,
|
||||
peak_frequencies=[],
|
||||
)
|
||||
|
||||
# Average the noise floors
|
||||
noise_floors = [s.noise_floor_db for s in samples]
|
||||
avg_noise = mean(noise_floors)
|
||||
|
||||
# Average the power levels
|
||||
avg_powers = [s.avg_power_db for s in samples]
|
||||
avg_power = mean(avg_powers)
|
||||
|
||||
# Average activity scores
|
||||
activity_scores = [s.activity_score for s in samples]
|
||||
avg_activity = mean(activity_scores)
|
||||
|
||||
# Collect peak frequencies that appear consistently
|
||||
all_peaks = [s.peak_frequency_mhz for s in samples]
|
||||
# Group peaks within 0.1 MHz
|
||||
peak_groups: dict[float, int] = {}
|
||||
for peak in all_peaks:
|
||||
# Find existing group
|
||||
found = False
|
||||
for group_freq in list(peak_groups.keys()):
|
||||
if abs(peak - group_freq) < 0.1:
|
||||
peak_groups[group_freq] += 1
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
peak_groups[peak] = 1
|
||||
|
||||
# Keep peaks that appear in >50% of samples
|
||||
threshold = len(samples) * 0.5
|
||||
consistent_peaks = [
|
||||
freq for freq, count in peak_groups.items()
|
||||
if count >= threshold
|
||||
]
|
||||
|
||||
return SpectrumBaseline(
|
||||
band_name=band_name,
|
||||
freq_start_mhz=samples[0].freq_start_mhz,
|
||||
freq_end_mhz=samples[0].freq_end_mhz,
|
||||
noise_floor_db=avg_noise,
|
||||
avg_power_db=avg_power,
|
||||
activity_score=avg_activity,
|
||||
peak_frequencies=consistent_peaks,
|
||||
sample_count=len(samples),
|
||||
)
|
||||
|
||||
def _compile_cellular_baseline(self) -> CellularBaseline:
|
||||
"""Compile cellular samples into a baseline."""
|
||||
operators = list(set(
|
||||
c.get('plmn') for c in self._cellular_samples
|
||||
if c.get('plmn')
|
||||
))
|
||||
|
||||
return CellularBaseline(
|
||||
cells=self._cellular_samples.copy(),
|
||||
operators=operators,
|
||||
)
|
||||
|
||||
def _compile_tower_baseline(self) -> TowerBaseline:
|
||||
"""Compile tower samples into a baseline."""
|
||||
return TowerBaseline(
|
||||
towers=self._tower_samples.copy(),
|
||||
)
|
||||
|
||||
|
||||
def compare_spectrum_baseline(
|
||||
current: BandMetrics,
|
||||
baseline: SpectrumBaseline | dict,
|
||||
) -> dict:
|
||||
"""
|
||||
Compare current spectrum metrics to baseline.
|
||||
|
||||
Args:
|
||||
current: Current band metrics
|
||||
baseline: Baseline to compare against (SpectrumBaseline or dict)
|
||||
|
||||
Returns:
|
||||
Dictionary with comparison results
|
||||
"""
|
||||
if isinstance(baseline, dict):
|
||||
baseline = SpectrumBaseline.from_dict(baseline)
|
||||
|
||||
noise_delta = current.noise_floor_db - baseline.noise_floor_db
|
||||
activity_delta = current.activity_score - baseline.activity_score
|
||||
|
||||
# Check if current peak is new
|
||||
is_new_peak = all(
|
||||
abs(current.peak_frequency_mhz - bp) > 0.1
|
||||
for bp in baseline.peak_frequencies
|
||||
) if baseline.peak_frequencies else False
|
||||
|
||||
return {
|
||||
'band_name': current.band_name,
|
||||
'noise_floor_current': current.noise_floor_db,
|
||||
'noise_floor_baseline': baseline.noise_floor_db,
|
||||
'noise_delta': noise_delta,
|
||||
'activity_current': current.activity_score,
|
||||
'activity_baseline': baseline.activity_score,
|
||||
'activity_delta': activity_delta,
|
||||
'peak_current': current.peak_frequency_mhz,
|
||||
'is_new_peak': is_new_peak,
|
||||
'baseline_peaks': baseline.peak_frequencies,
|
||||
'anomaly_detected': (
|
||||
abs(noise_delta) > 6 or
|
||||
abs(activity_delta) > 30 or
|
||||
is_new_peak
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def compare_tower_baseline(
|
||||
current_towers: list[CellTower | dict],
|
||||
baseline: TowerBaseline | dict,
|
||||
) -> dict:
|
||||
"""
|
||||
Compare current towers to baseline.
|
||||
|
||||
Args:
|
||||
current_towers: List of current towers
|
||||
baseline: Baseline to compare against
|
||||
|
||||
Returns:
|
||||
Dictionary with comparison results
|
||||
"""
|
||||
if isinstance(baseline, dict):
|
||||
baseline = TowerBaseline.from_dict(baseline)
|
||||
|
||||
# Get cell IDs from baseline
|
||||
baseline_ids = set(t.get('cellid') for t in baseline.towers)
|
||||
|
||||
# Get current cell IDs
|
||||
current_ids = set()
|
||||
for tower in current_towers:
|
||||
if isinstance(tower, CellTower):
|
||||
current_ids.add(tower.cellid)
|
||||
else:
|
||||
current_ids.add(tower.get('cellid'))
|
||||
|
||||
new_towers = current_ids - baseline_ids
|
||||
missing_towers = baseline_ids - current_ids
|
||||
unchanged = current_ids & baseline_ids
|
||||
|
||||
return {
|
||||
'total_current': len(current_ids),
|
||||
'total_baseline': len(baseline_ids),
|
||||
'new_tower_ids': list(new_towers),
|
||||
'missing_tower_ids': list(missing_towers),
|
||||
'unchanged_count': len(unchanged),
|
||||
'new_count': len(new_towers),
|
||||
'missing_count': len(missing_towers),
|
||||
'anomaly_detected': len(new_towers) > 0,
|
||||
}
|
||||
|
||||
|
||||
def compare_cellular_baseline(
|
||||
current_cells: list[dict],
|
||||
baseline: CellularBaseline | dict,
|
||||
) -> dict:
|
||||
"""
|
||||
Compare current cellular environment to baseline.
|
||||
|
||||
Args:
|
||||
current_cells: List of current cell info dicts
|
||||
baseline: Baseline to compare against
|
||||
|
||||
Returns:
|
||||
Dictionary with comparison results
|
||||
"""
|
||||
if isinstance(baseline, dict):
|
||||
baseline = CellularBaseline.from_dict(baseline)
|
||||
|
||||
# Get cell identifiers from baseline
|
||||
baseline_cell_keys = set(
|
||||
(c.get('cell_id'), c.get('plmn'))
|
||||
for c in baseline.cells
|
||||
)
|
||||
|
||||
# Get current cell identifiers
|
||||
current_cell_keys = set(
|
||||
(c.get('cell_id'), c.get('plmn'))
|
||||
for c in current_cells
|
||||
)
|
||||
|
||||
new_cells = current_cell_keys - baseline_cell_keys
|
||||
missing_cells = baseline_cell_keys - current_cell_keys
|
||||
|
||||
# Check for new operators
|
||||
current_operators = set(c.get('plmn') for c in current_cells if c.get('plmn'))
|
||||
new_operators = current_operators - set(baseline.operators)
|
||||
|
||||
return {
|
||||
'total_current': len(current_cell_keys),
|
||||
'total_baseline': len(baseline_cell_keys),
|
||||
'new_cells': list(new_cells),
|
||||
'missing_cells': list(missing_cells),
|
||||
'new_cell_count': len(new_cells),
|
||||
'missing_cell_count': len(missing_cells),
|
||||
'new_operators': list(new_operators),
|
||||
'anomaly_detected': len(new_cells) > 0 or len(new_operators) > 0,
|
||||
}
|
||||
|
||||
|
||||
def save_baseline_to_db(
|
||||
name: str,
|
||||
location_name: str | None,
|
||||
latitude: float | None,
|
||||
longitude: float | None,
|
||||
baseline_data: dict,
|
||||
) -> int:
|
||||
"""
|
||||
Save baseline data to database.
|
||||
|
||||
Args:
|
||||
name: Baseline name
|
||||
location_name: Location description
|
||||
latitude: GPS latitude
|
||||
longitude: GPS longitude
|
||||
baseline_data: Dict from BaselineRecorder.stop_recording()
|
||||
|
||||
Returns:
|
||||
Database ID of created baseline
|
||||
"""
|
||||
return create_isms_baseline(
|
||||
name=name,
|
||||
location_name=location_name,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
spectrum_profile=baseline_data.get('spectrum_profile'),
|
||||
cellular_environment=baseline_data.get('cellular_environment'),
|
||||
known_towers=baseline_data.get('known_towers'),
|
||||
)
|
||||
@@ -1,529 +0,0 @@
|
||||
"""
|
||||
GSM cell detection using grgsm_scanner.
|
||||
|
||||
Provides passive GSM broadcast channel scanning to detect nearby
|
||||
base stations and extract cell identity information.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
logger = logging.getLogger('intercept.isms.gsm')
|
||||
|
||||
|
||||
@dataclass
|
||||
class GsmCell:
|
||||
"""Detected GSM cell from broadcast channel."""
|
||||
arfcn: int # Absolute Radio Frequency Channel Number
|
||||
freq_mhz: float
|
||||
power_dbm: float
|
||||
mcc: int | None = None # Mobile Country Code
|
||||
mnc: int | None = None # Mobile Network Code
|
||||
lac: int | None = None # Location Area Code
|
||||
cell_id: int | None = None
|
||||
bsic: str | None = None # Base Station Identity Code
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
@property
|
||||
def plmn(self) -> str | None:
|
||||
"""Public Land Mobile Network identifier (MCC-MNC)."""
|
||||
if self.mcc is not None and self.mnc is not None:
|
||||
return f'{self.mcc}-{self.mnc:02d}'
|
||||
return None
|
||||
|
||||
@property
|
||||
def cell_global_id(self) -> str | None:
|
||||
"""Cell Global Identity string."""
|
||||
if all(v is not None for v in [self.mcc, self.mnc, self.lac, self.cell_id]):
|
||||
return f'{self.mcc}-{self.mnc:02d}-{self.lac}-{self.cell_id}'
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GsmScanResult:
|
||||
"""Result of a GSM scan."""
|
||||
cells: list[GsmCell]
|
||||
scan_duration_s: float
|
||||
device_index: int
|
||||
freq_start_mhz: float
|
||||
freq_end_mhz: float
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# GSM frequency bands (ARFCN to frequency mapping)
|
||||
# GSM900: ARFCN 1-124 (Uplink: 890-915 MHz, Downlink: 935-960 MHz)
|
||||
# GSM1800: ARFCN 512-885 (Uplink: 1710-1785 MHz, Downlink: 1805-1880 MHz)
|
||||
|
||||
def arfcn_to_freq(arfcn: int, band: str = 'auto') -> float:
|
||||
"""
|
||||
Convert ARFCN to downlink frequency in MHz.
|
||||
|
||||
Args:
|
||||
arfcn: Absolute Radio Frequency Channel Number
|
||||
band: Band hint ('gsm900', 'gsm1800', 'auto')
|
||||
|
||||
Returns:
|
||||
Downlink frequency in MHz
|
||||
"""
|
||||
if band == 'auto':
|
||||
if 1 <= arfcn <= 124:
|
||||
band = 'gsm900'
|
||||
elif 512 <= arfcn <= 885:
|
||||
band = 'gsm1800'
|
||||
elif 975 <= arfcn <= 1023:
|
||||
band = 'egsm900' # Extended GSM900
|
||||
else:
|
||||
band = 'gsm900' # Default
|
||||
|
||||
if band in ('gsm900', 'p-gsm'):
|
||||
# P-GSM 900: ARFCN 1-124
|
||||
# Downlink = 935 + 0.2 * (ARFCN - 1)
|
||||
if 1 <= arfcn <= 124:
|
||||
return 935.0 + 0.2 * arfcn
|
||||
elif arfcn == 0:
|
||||
return 935.0
|
||||
|
||||
elif band == 'egsm900':
|
||||
# E-GSM 900: ARFCN 975-1023, 0-124
|
||||
if 975 <= arfcn <= 1023:
|
||||
return 935.0 + 0.2 * (arfcn - 1024)
|
||||
elif 0 <= arfcn <= 124:
|
||||
return 935.0 + 0.2 * arfcn
|
||||
|
||||
elif band in ('gsm1800', 'dcs1800'):
|
||||
# DCS 1800: ARFCN 512-885
|
||||
# Downlink = 1805.2 + 0.2 * (ARFCN - 512)
|
||||
if 512 <= arfcn <= 885:
|
||||
return 1805.2 + 0.2 * (arfcn - 512)
|
||||
|
||||
elif band in ('gsm1900', 'pcs1900'):
|
||||
# PCS 1900: ARFCN 512-810
|
||||
# Downlink = 1930.2 + 0.2 * (ARFCN - 512)
|
||||
if 512 <= arfcn <= 810:
|
||||
return 1930.2 + 0.2 * (arfcn - 512)
|
||||
|
||||
# Fallback for unknown
|
||||
return 935.0 + 0.2 * arfcn
|
||||
|
||||
|
||||
def get_grgsm_scanner_path() -> str | None:
|
||||
"""Get the path to grgsm_scanner executable."""
|
||||
return shutil.which('grgsm_scanner')
|
||||
|
||||
|
||||
def _drain_stderr(process: subprocess.Popen, stop_event: threading.Event) -> list[str]:
|
||||
"""Drain stderr and collect error messages."""
|
||||
errors = []
|
||||
try:
|
||||
while not stop_event.is_set() and process.poll() is None:
|
||||
if process.stderr:
|
||||
line = process.stderr.readline()
|
||||
if line:
|
||||
errors.append(line.strip())
|
||||
except Exception:
|
||||
pass
|
||||
return errors
|
||||
|
||||
|
||||
def parse_grgsm_output(line: str) -> GsmCell | None:
|
||||
"""
|
||||
Parse a line of grgsm_scanner output.
|
||||
|
||||
grgsm_scanner output format varies but typically includes:
|
||||
ARFCN: XXX, Freq: XXX.X MHz, Power: -XX.X dBm, CID: XXXX, LAC: XXXX, MCC: XXX, MNC: XX
|
||||
|
||||
Args:
|
||||
line: A line of grgsm_scanner output
|
||||
|
||||
Returns:
|
||||
GsmCell if parsed successfully, None otherwise
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return None
|
||||
|
||||
# Skip header/info lines
|
||||
if line.startswith(('#', 'ARFCN', '---', 'gr-gsm', 'Using', 'Scanning')):
|
||||
return None
|
||||
|
||||
# Pattern for typical grgsm_scanner output
|
||||
# Example: "ARFCN: 73, Freq: 949.6M, CID: 12345, LAC: 1234, MCC: 234, MNC: 10, Pwr: -65.2"
|
||||
arfcn_match = re.search(r'ARFCN[:\s]+(\d+)', line, re.IGNORECASE)
|
||||
freq_match = re.search(r'Freq[:\s]+([\d.]+)\s*M', line, re.IGNORECASE)
|
||||
power_match = re.search(r'(?:Pwr|Power)[:\s]+([-\d.]+)', line, re.IGNORECASE)
|
||||
cid_match = re.search(r'CID[:\s]+(\d+)', line, re.IGNORECASE)
|
||||
lac_match = re.search(r'LAC[:\s]+(\d+)', line, re.IGNORECASE)
|
||||
mcc_match = re.search(r'MCC[:\s]+(\d+)', line, re.IGNORECASE)
|
||||
mnc_match = re.search(r'MNC[:\s]+(\d+)', line, re.IGNORECASE)
|
||||
bsic_match = re.search(r'BSIC[:\s]+([0-9,]+)', line, re.IGNORECASE)
|
||||
|
||||
# Alternative format: tab/comma separated values
|
||||
# ARFCN Freq Pwr CID LAC MCC MNC BSIC
|
||||
if not arfcn_match:
|
||||
parts = re.split(r'[,\t]+', line)
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
# Try to parse as numeric fields
|
||||
arfcn = int(parts[0].strip())
|
||||
freq = float(parts[1].strip().replace('M', ''))
|
||||
power = float(parts[2].strip())
|
||||
cell = GsmCell(
|
||||
arfcn=arfcn,
|
||||
freq_mhz=freq,
|
||||
power_dbm=power,
|
||||
)
|
||||
if len(parts) > 3:
|
||||
cell.cell_id = int(parts[3].strip()) if parts[3].strip().isdigit() else None
|
||||
if len(parts) > 4:
|
||||
cell.lac = int(parts[4].strip()) if parts[4].strip().isdigit() else None
|
||||
if len(parts) > 5:
|
||||
cell.mcc = int(parts[5].strip()) if parts[5].strip().isdigit() else None
|
||||
if len(parts) > 6:
|
||||
cell.mnc = int(parts[6].strip()) if parts[6].strip().isdigit() else None
|
||||
return cell
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
if not arfcn_match:
|
||||
return None
|
||||
|
||||
arfcn = int(arfcn_match.group(1))
|
||||
|
||||
# Get frequency from output or calculate from ARFCN
|
||||
if freq_match:
|
||||
freq_mhz = float(freq_match.group(1))
|
||||
else:
|
||||
freq_mhz = arfcn_to_freq(arfcn)
|
||||
|
||||
# Get power (default to weak if not found)
|
||||
if power_match:
|
||||
power_dbm = float(power_match.group(1))
|
||||
else:
|
||||
power_dbm = -100.0
|
||||
|
||||
cell = GsmCell(
|
||||
arfcn=arfcn,
|
||||
freq_mhz=freq_mhz,
|
||||
power_dbm=power_dbm,
|
||||
)
|
||||
|
||||
if cid_match:
|
||||
cell.cell_id = int(cid_match.group(1))
|
||||
if lac_match:
|
||||
cell.lac = int(lac_match.group(1))
|
||||
if mcc_match:
|
||||
cell.mcc = int(mcc_match.group(1))
|
||||
if mnc_match:
|
||||
cell.mnc = int(mnc_match.group(1))
|
||||
if bsic_match:
|
||||
cell.bsic = bsic_match.group(1)
|
||||
|
||||
return cell
|
||||
|
||||
|
||||
def run_grgsm_scan(
|
||||
band: str = 'GSM900',
|
||||
device_index: int = 0,
|
||||
gain: int = 40,
|
||||
ppm: int = 0,
|
||||
speed: int = 4,
|
||||
timeout: float = 60.0,
|
||||
) -> Generator[GsmCell, None, None]:
|
||||
"""
|
||||
Run grgsm_scanner and yield detected GSM cells.
|
||||
|
||||
Args:
|
||||
band: GSM band to scan ('GSM900', 'GSM1800', 'GSM850', 'GSM1900')
|
||||
device_index: RTL-SDR device index
|
||||
gain: Gain in dB
|
||||
ppm: Frequency correction in PPM
|
||||
speed: Scan speed (1-5, higher is faster but less accurate)
|
||||
timeout: Maximum scan duration in seconds
|
||||
|
||||
Yields:
|
||||
GsmCell objects for each detected cell
|
||||
"""
|
||||
grgsm_scanner = get_grgsm_scanner_path()
|
||||
if not grgsm_scanner:
|
||||
logger.error("grgsm_scanner not found in PATH")
|
||||
return
|
||||
|
||||
# Map band names to grgsm_scanner arguments
|
||||
band_args = {
|
||||
'GSM900': ['--band', 'P-GSM'],
|
||||
'EGSM900': ['--band', 'E-GSM'],
|
||||
'GSM1800': ['--band', 'DCS1800'],
|
||||
'GSM850': ['--band', 'GSM850'],
|
||||
'GSM1900': ['--band', 'PCS1900'],
|
||||
}
|
||||
|
||||
band_arg = band_args.get(band.upper(), ['--band', 'P-GSM'])
|
||||
|
||||
cmd = [
|
||||
grgsm_scanner,
|
||||
*band_arg,
|
||||
'-g', str(gain),
|
||||
'-p', str(ppm),
|
||||
'-s', str(speed),
|
||||
'-v', # Verbose output for more cell details
|
||||
]
|
||||
|
||||
logger.info(f"Starting grgsm_scanner: {' '.join(cmd)}")
|
||||
|
||||
stop_event = threading.Event()
|
||||
stderr_thread = None
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
# Drain stderr in background
|
||||
stderr_thread = threading.Thread(
|
||||
target=_drain_stderr,
|
||||
args=(process, stop_event),
|
||||
daemon=True
|
||||
)
|
||||
stderr_thread.start()
|
||||
|
||||
# Set up timeout
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
# Parse output line by line
|
||||
for line in iter(process.stdout.readline, ''):
|
||||
if time.time() - start_time > timeout:
|
||||
logger.info(f"grgsm_scanner timeout after {timeout}s")
|
||||
break
|
||||
|
||||
cell = parse_grgsm_output(line)
|
||||
if cell:
|
||||
yield cell
|
||||
|
||||
# Terminate if still running
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"grgsm_scanner error: {e}")
|
||||
|
||||
finally:
|
||||
stop_event.set()
|
||||
if stderr_thread:
|
||||
stderr_thread.join(timeout=1.0)
|
||||
|
||||
|
||||
def run_gsm_scan_blocking(
|
||||
band: str = 'GSM900',
|
||||
device_index: int = 0,
|
||||
gain: int = 40,
|
||||
ppm: int = 0,
|
||||
speed: int = 4,
|
||||
timeout: float = 60.0,
|
||||
) -> GsmScanResult:
|
||||
"""
|
||||
Run a complete GSM scan and return all results.
|
||||
|
||||
Args:
|
||||
band: GSM band to scan
|
||||
device_index: RTL-SDR device index
|
||||
gain: Gain in dB
|
||||
ppm: Frequency correction in PPM
|
||||
speed: Scan speed (1-5)
|
||||
timeout: Maximum scan duration
|
||||
|
||||
Returns:
|
||||
GsmScanResult with all detected cells
|
||||
"""
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
cells: list[GsmCell] = []
|
||||
error: str | None = None
|
||||
|
||||
try:
|
||||
for cell in run_grgsm_scan(
|
||||
band=band,
|
||||
device_index=device_index,
|
||||
gain=gain,
|
||||
ppm=ppm,
|
||||
speed=speed,
|
||||
timeout=timeout,
|
||||
):
|
||||
cells.append(cell)
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
logger.error(f"GSM scan error: {e}")
|
||||
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Determine frequency range based on band
|
||||
freq_ranges = {
|
||||
'GSM900': (935.0, 960.0),
|
||||
'EGSM900': (925.0, 960.0),
|
||||
'GSM1800': (1805.0, 1880.0),
|
||||
'GSM850': (869.0, 894.0),
|
||||
'GSM1900': (1930.0, 1990.0),
|
||||
}
|
||||
freq_start, freq_end = freq_ranges.get(band.upper(), (935.0, 960.0))
|
||||
|
||||
return GsmScanResult(
|
||||
cells=cells,
|
||||
scan_duration_s=duration,
|
||||
device_index=device_index,
|
||||
freq_start_mhz=freq_start,
|
||||
freq_end_mhz=freq_end,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def format_gsm_cell(cell: GsmCell) -> dict:
|
||||
"""Format GSM cell for JSON output."""
|
||||
return {
|
||||
'arfcn': cell.arfcn,
|
||||
'freq_mhz': round(cell.freq_mhz, 1),
|
||||
'power_dbm': round(cell.power_dbm, 1),
|
||||
'mcc': cell.mcc,
|
||||
'mnc': cell.mnc,
|
||||
'lac': cell.lac,
|
||||
'cell_id': cell.cell_id,
|
||||
'bsic': cell.bsic,
|
||||
'plmn': cell.plmn,
|
||||
'cell_global_id': cell.cell_global_id,
|
||||
'timestamp': cell.timestamp.isoformat() + 'Z',
|
||||
}
|
||||
|
||||
|
||||
def deduplicate_cells(cells: list[GsmCell]) -> list[GsmCell]:
|
||||
"""
|
||||
Deduplicate cells by ARFCN, keeping strongest signal.
|
||||
|
||||
Args:
|
||||
cells: List of detected cells
|
||||
|
||||
Returns:
|
||||
Deduplicated list with strongest signal per ARFCN
|
||||
"""
|
||||
best_cells: dict[int, GsmCell] = {}
|
||||
|
||||
for cell in cells:
|
||||
if cell.arfcn not in best_cells:
|
||||
best_cells[cell.arfcn] = cell
|
||||
elif cell.power_dbm > best_cells[cell.arfcn].power_dbm:
|
||||
best_cells[cell.arfcn] = cell
|
||||
|
||||
return sorted(best_cells.values(), key=lambda c: c.power_dbm, reverse=True)
|
||||
|
||||
|
||||
def get_uk_operator_name(mcc: int | None, mnc: int | None) -> str | None:
|
||||
"""Get UK mobile operator name from MCC/MNC."""
|
||||
if mcc != 234: # UK MCC
|
||||
return None
|
||||
|
||||
uk_operators = {
|
||||
10: 'O2',
|
||||
15: 'Vodafone',
|
||||
20: 'Three',
|
||||
30: 'EE',
|
||||
31: 'EE',
|
||||
32: 'EE',
|
||||
33: 'EE',
|
||||
34: 'EE',
|
||||
50: 'JT',
|
||||
55: 'Sure',
|
||||
58: 'Manx Telecom',
|
||||
}
|
||||
|
||||
return uk_operators.get(mnc)
|
||||
|
||||
|
||||
def identify_gsm_anomalies(
|
||||
current_cells: list[GsmCell],
|
||||
baseline_cells: list[GsmCell] | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Identify potential anomalies in GSM environment.
|
||||
|
||||
Checks for:
|
||||
- New cells not in baseline
|
||||
- Cells with unusually strong signals
|
||||
- Cells with suspicious MCC/MNC combinations
|
||||
- Missing expected cells from baseline
|
||||
|
||||
Args:
|
||||
current_cells: Currently detected cells
|
||||
baseline_cells: Optional baseline for comparison
|
||||
|
||||
Returns:
|
||||
List of anomaly findings
|
||||
"""
|
||||
anomalies = []
|
||||
|
||||
current_arfcns = {c.arfcn for c in current_cells}
|
||||
|
||||
# Check for very strong signals (potential nearby transmitter)
|
||||
for cell in current_cells:
|
||||
if cell.power_dbm > -40:
|
||||
anomalies.append({
|
||||
'type': 'strong_signal',
|
||||
'severity': 'warn',
|
||||
'description': f'Unusually strong GSM signal on ARFCN {cell.arfcn} ({cell.power_dbm:.1f} dBm)',
|
||||
'cell': format_gsm_cell(cell),
|
||||
})
|
||||
|
||||
if baseline_cells:
|
||||
baseline_arfcns = {c.arfcn for c in baseline_cells}
|
||||
baseline_cids = {c.cell_global_id for c in baseline_cells if c.cell_global_id}
|
||||
|
||||
# New ARFCNs not in baseline
|
||||
new_arfcns = current_arfcns - baseline_arfcns
|
||||
for arfcn in new_arfcns:
|
||||
cell = next((c for c in current_cells if c.arfcn == arfcn), None)
|
||||
if cell:
|
||||
anomalies.append({
|
||||
'type': 'new_arfcn',
|
||||
'severity': 'info',
|
||||
'description': f'New ARFCN {arfcn} detected ({cell.freq_mhz:.1f} MHz, {cell.power_dbm:.1f} dBm)',
|
||||
'cell': format_gsm_cell(cell),
|
||||
})
|
||||
|
||||
# Missing ARFCNs from baseline
|
||||
missing_arfcns = baseline_arfcns - current_arfcns
|
||||
for arfcn in missing_arfcns:
|
||||
baseline_cell = next((c for c in baseline_cells if c.arfcn == arfcn), None)
|
||||
if baseline_cell:
|
||||
anomalies.append({
|
||||
'type': 'missing_arfcn',
|
||||
'severity': 'info',
|
||||
'description': f'Expected ARFCN {arfcn} not detected (was {baseline_cell.power_dbm:.1f} dBm)',
|
||||
'cell': format_gsm_cell(baseline_cell),
|
||||
})
|
||||
|
||||
# Check for new cell IDs on existing ARFCNs (potential fake base station)
|
||||
for cell in current_cells:
|
||||
if cell.cell_global_id and cell.cell_global_id not in baseline_cids:
|
||||
if cell.arfcn in baseline_arfcns:
|
||||
anomalies.append({
|
||||
'type': 'new_cell_id',
|
||||
'severity': 'warn',
|
||||
'description': f'New Cell ID on existing ARFCN {cell.arfcn}: {cell.cell_global_id}',
|
||||
'cell': format_gsm_cell(cell),
|
||||
})
|
||||
|
||||
return anomalies
|
||||
@@ -1,401 +0,0 @@
|
||||
"""
|
||||
Rules engine for ISMS anomaly detection.
|
||||
|
||||
Provides a configurable rules system for detecting spectrum anomalies,
|
||||
cellular environment changes, and suspicious RF patterns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger('intercept.isms.rules')
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
"""A detected anomaly or observation."""
|
||||
finding_type: str
|
||||
severity: str # 'info', 'warn', 'high'
|
||||
description: str
|
||||
band: str | None = None
|
||||
frequency: float | None = None
|
||||
details: dict = field(default_factory=dict)
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'finding_type': self.finding_type,
|
||||
'severity': self.severity,
|
||||
'description': self.description,
|
||||
'band': self.band,
|
||||
'frequency': self.frequency,
|
||||
'details': self.details,
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rule:
|
||||
"""An anomaly detection rule."""
|
||||
name: str
|
||||
description: str
|
||||
severity: str # 'info', 'warn', 'high'
|
||||
check: Callable[[dict], bool]
|
||||
message_template: str
|
||||
category: str = 'general'
|
||||
enabled: bool = True
|
||||
|
||||
def evaluate(self, context: dict) -> Finding | None:
|
||||
"""
|
||||
Evaluate rule against context.
|
||||
|
||||
Args:
|
||||
context: Dictionary with detection context data
|
||||
|
||||
Returns:
|
||||
Finding if rule triggered, None otherwise
|
||||
"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
if self.check(context):
|
||||
# Format message with context values
|
||||
try:
|
||||
message = self.message_template.format(**context)
|
||||
except KeyError:
|
||||
message = self.message_template
|
||||
|
||||
return Finding(
|
||||
finding_type=self.name,
|
||||
severity=self.severity,
|
||||
description=message,
|
||||
band=context.get('band'),
|
||||
frequency=context.get('frequency') or context.get('freq_mhz'),
|
||||
details=context,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Rule {self.name} evaluation error: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Built-in anomaly detection rules
|
||||
ISMS_RULES: list[Rule] = [
|
||||
Rule(
|
||||
name='burst_detected',
|
||||
description='Short RF burst above noise floor',
|
||||
severity='warn',
|
||||
category='spectrum',
|
||||
check=lambda ctx: ctx.get('burst_count', 0) > 0,
|
||||
message_template='Detected {burst_count} burst(s) in {band}',
|
||||
),
|
||||
Rule(
|
||||
name='periodic_burst',
|
||||
description='Repeated periodic bursts consistent with beacon',
|
||||
severity='warn',
|
||||
category='spectrum',
|
||||
check=lambda ctx: (
|
||||
ctx.get('burst_count', 0) >= 3 and
|
||||
ctx.get('burst_interval_stdev', float('inf')) < 2.0
|
||||
),
|
||||
message_template='Periodic bursts detected (~{burst_interval_avg:.1f}s interval) in {band}',
|
||||
),
|
||||
Rule(
|
||||
name='new_peak_frequency',
|
||||
description='New peak frequency not in baseline',
|
||||
severity='info',
|
||||
category='spectrum',
|
||||
check=lambda ctx: ctx.get('is_new_peak', False),
|
||||
message_template='New peak at {freq_mhz:.3f} MHz ({power_db:.1f} dB)',
|
||||
),
|
||||
Rule(
|
||||
name='strong_signal_indoors',
|
||||
description='Strong signal above indoor threshold',
|
||||
severity='warn',
|
||||
category='spectrum',
|
||||
check=lambda ctx: ctx.get('power_db', -100) > ctx.get('indoor_threshold', -40),
|
||||
message_template='Strong signal ({power_db:.1f} dB) at {freq_mhz:.3f} MHz',
|
||||
),
|
||||
Rule(
|
||||
name='noise_floor_increase',
|
||||
description='Significant noise floor increase from baseline',
|
||||
severity='warn',
|
||||
category='spectrum',
|
||||
check=lambda ctx: ctx.get('noise_delta', 0) > 6, # >6dB increase
|
||||
message_template='Noise floor increased by {noise_delta:.1f} dB in {band}',
|
||||
),
|
||||
Rule(
|
||||
name='noise_floor_decrease',
|
||||
description='Significant noise floor decrease from baseline',
|
||||
severity='info',
|
||||
category='spectrum',
|
||||
check=lambda ctx: ctx.get('noise_delta', 0) < -6, # >6dB decrease
|
||||
message_template='Noise floor decreased by {noise_delta:.1f} dB in {band}',
|
||||
),
|
||||
Rule(
|
||||
name='high_activity_band',
|
||||
description='Unusually high activity in band',
|
||||
severity='info',
|
||||
category='spectrum',
|
||||
check=lambda ctx: ctx.get('activity_score', 0) > 80,
|
||||
message_template='High activity ({activity_score:.0f}%) in {band}',
|
||||
),
|
||||
Rule(
|
||||
name='activity_increase',
|
||||
description='Activity score increased from baseline',
|
||||
severity='info',
|
||||
category='spectrum',
|
||||
check=lambda ctx: ctx.get('activity_delta', 0) > 30, # >30% increase
|
||||
message_template='Activity increased by {activity_delta:.0f}% in {band}',
|
||||
),
|
||||
Rule(
|
||||
name='new_cell_detected',
|
||||
description='New cell tower not in baseline',
|
||||
severity='info',
|
||||
category='cellular',
|
||||
check=lambda ctx: ctx.get('is_new_cell', False),
|
||||
message_template='New cell: {plmn} {radio} CID {cell_id}',
|
||||
),
|
||||
Rule(
|
||||
name='cell_disappeared',
|
||||
description='Previously seen cell no longer detected',
|
||||
severity='info',
|
||||
category='cellular',
|
||||
check=lambda ctx: ctx.get('is_missing_cell', False),
|
||||
message_template='Cell no longer seen: {plmn} CID {cell_id}',
|
||||
),
|
||||
Rule(
|
||||
name='new_operator',
|
||||
description='New network operator detected',
|
||||
severity='warn',
|
||||
category='cellular',
|
||||
check=lambda ctx: ctx.get('is_new_operator', False),
|
||||
message_template='New operator detected: {operator} ({plmn})',
|
||||
),
|
||||
Rule(
|
||||
name='signal_strength_change',
|
||||
description='Significant change in cell signal strength',
|
||||
severity='info',
|
||||
category='cellular',
|
||||
check=lambda ctx: abs(ctx.get('rsrp_delta', 0)) > 10, # >10dB change
|
||||
message_template='Signal change: {plmn} CID {cell_id} ({rsrp_delta:+.0f} dB)',
|
||||
),
|
||||
Rule(
|
||||
name='suspicious_ism_activity',
|
||||
description='Unusual activity in ISM band',
|
||||
severity='warn',
|
||||
category='spectrum',
|
||||
check=lambda ctx: (
|
||||
ctx.get('band', '').startswith('ISM') and
|
||||
ctx.get('activity_score', 0) > 60 and
|
||||
ctx.get('is_new_peak', False)
|
||||
),
|
||||
message_template='Suspicious ISM activity at {freq_mhz:.3f} MHz',
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class RulesEngine:
|
||||
"""Engine for evaluating ISMS detection rules."""
|
||||
|
||||
def __init__(self, rules: list[Rule] | None = None):
|
||||
"""
|
||||
Initialize rules engine.
|
||||
|
||||
Args:
|
||||
rules: List of rules to use (defaults to ISMS_RULES)
|
||||
"""
|
||||
self.rules = rules if rules is not None else ISMS_RULES.copy()
|
||||
self._custom_rules: list[Rule] = []
|
||||
|
||||
def add_rule(self, rule: Rule) -> None:
|
||||
"""Add a custom rule."""
|
||||
self._custom_rules.append(rule)
|
||||
|
||||
def remove_rule(self, rule_name: str) -> bool:
|
||||
"""Remove a rule by name."""
|
||||
for rules_list in [self.rules, self._custom_rules]:
|
||||
for i, rule in enumerate(rules_list):
|
||||
if rule.name == rule_name:
|
||||
rules_list.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def enable_rule(self, rule_name: str) -> bool:
|
||||
"""Enable a rule by name."""
|
||||
for rule in self.rules + self._custom_rules:
|
||||
if rule.name == rule_name:
|
||||
rule.enabled = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable_rule(self, rule_name: str) -> bool:
|
||||
"""Disable a rule by name."""
|
||||
for rule in self.rules + self._custom_rules:
|
||||
if rule.name == rule_name:
|
||||
rule.enabled = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_rules_by_category(self, category: str) -> list[Rule]:
|
||||
"""Get all rules in a category."""
|
||||
return [
|
||||
r for r in self.rules + self._custom_rules
|
||||
if r.category == category and r.enabled
|
||||
]
|
||||
|
||||
def evaluate(self, context: dict) -> list[Finding]:
|
||||
"""
|
||||
Evaluate all rules against context.
|
||||
|
||||
Args:
|
||||
context: Dictionary with detection context data
|
||||
|
||||
Returns:
|
||||
List of Finding objects for triggered rules
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for rule in self.rules + self._custom_rules:
|
||||
finding = rule.evaluate(context)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
logger.debug(f"Rule '{rule.name}' triggered: {finding.description}")
|
||||
|
||||
return findings
|
||||
|
||||
def evaluate_spectrum(
|
||||
self,
|
||||
band_name: str,
|
||||
noise_floor: float,
|
||||
peak_freq: float,
|
||||
peak_power: float,
|
||||
activity_score: float,
|
||||
baseline_noise: float | None = None,
|
||||
baseline_activity: float | None = None,
|
||||
baseline_peaks: list[float] | None = None,
|
||||
burst_count: int = 0,
|
||||
burst_interval_avg: float | None = None,
|
||||
burst_interval_stdev: float | None = None,
|
||||
indoor_threshold: float = -40,
|
||||
) -> list[Finding]:
|
||||
"""
|
||||
Evaluate spectrum-related rules.
|
||||
|
||||
Args:
|
||||
band_name: Name of the band
|
||||
noise_floor: Current noise floor in dB
|
||||
peak_freq: Peak frequency in MHz
|
||||
peak_power: Peak power in dB
|
||||
activity_score: Activity score 0-100
|
||||
baseline_noise: Baseline noise floor for comparison
|
||||
baseline_activity: Baseline activity score for comparison
|
||||
baseline_peaks: List of baseline peak frequencies
|
||||
burst_count: Number of bursts detected
|
||||
burst_interval_avg: Average interval between bursts
|
||||
burst_interval_stdev: Standard deviation of burst intervals
|
||||
indoor_threshold: Power threshold for indoor signal detection
|
||||
|
||||
Returns:
|
||||
List of Finding objects
|
||||
"""
|
||||
context = {
|
||||
'band': band_name,
|
||||
'freq_mhz': peak_freq,
|
||||
'power_db': peak_power,
|
||||
'noise_floor': noise_floor,
|
||||
'activity_score': activity_score,
|
||||
'burst_count': burst_count,
|
||||
'indoor_threshold': indoor_threshold,
|
||||
}
|
||||
|
||||
# Calculate deltas from baseline
|
||||
if baseline_noise is not None:
|
||||
context['noise_delta'] = noise_floor - baseline_noise
|
||||
|
||||
if baseline_activity is not None:
|
||||
context['activity_delta'] = activity_score - baseline_activity
|
||||
|
||||
# Check if peak is new
|
||||
if baseline_peaks is not None:
|
||||
# Consider peak "new" if not within 0.1 MHz of any baseline peak
|
||||
context['is_new_peak'] = all(
|
||||
abs(peak_freq - bp) > 0.1 for bp in baseline_peaks
|
||||
)
|
||||
else:
|
||||
context['is_new_peak'] = False
|
||||
|
||||
# Add burst timing info
|
||||
if burst_interval_avg is not None:
|
||||
context['burst_interval_avg'] = burst_interval_avg
|
||||
if burst_interval_stdev is not None:
|
||||
context['burst_interval_stdev'] = burst_interval_stdev
|
||||
|
||||
return self.evaluate(context)
|
||||
|
||||
def evaluate_cellular(
|
||||
self,
|
||||
plmn: str,
|
||||
cell_id: int,
|
||||
radio: str,
|
||||
rsrp: int | None = None,
|
||||
operator: str | None = None,
|
||||
baseline_cells: list[dict] | None = None,
|
||||
baseline_operators: list[str] | None = None,
|
||||
previous_rsrp: int | None = None,
|
||||
) -> list[Finding]:
|
||||
"""
|
||||
Evaluate cellular-related rules.
|
||||
|
||||
Args:
|
||||
plmn: PLMN code (MCC-MNC)
|
||||
cell_id: Cell ID
|
||||
radio: Radio type (GSM, UMTS, LTE, NR)
|
||||
rsrp: Signal strength in dBm
|
||||
operator: Operator name
|
||||
baseline_cells: List of baseline cell dicts for comparison
|
||||
baseline_operators: List of baseline operator PLMNs
|
||||
previous_rsrp: Previous RSRP reading for this cell
|
||||
|
||||
Returns:
|
||||
List of Finding objects
|
||||
"""
|
||||
context = {
|
||||
'plmn': plmn,
|
||||
'cell_id': cell_id,
|
||||
'radio': radio,
|
||||
'operator': operator or plmn,
|
||||
}
|
||||
|
||||
if rsrp is not None:
|
||||
context['rsrp'] = rsrp
|
||||
|
||||
# Check if cell is new
|
||||
if baseline_cells is not None:
|
||||
context['is_new_cell'] = not any(
|
||||
c.get('cell_id') == cell_id and c.get('plmn') == plmn
|
||||
for c in baseline_cells
|
||||
)
|
||||
else:
|
||||
context['is_new_cell'] = False
|
||||
|
||||
# Check if operator is new
|
||||
if baseline_operators is not None:
|
||||
context['is_new_operator'] = plmn not in baseline_operators
|
||||
|
||||
# Calculate RSRP delta
|
||||
if rsrp is not None and previous_rsrp is not None:
|
||||
context['rsrp_delta'] = rsrp - previous_rsrp
|
||||
|
||||
return self.evaluate(context)
|
||||
|
||||
|
||||
def create_default_engine() -> RulesEngine:
|
||||
"""Create a rules engine with default rules."""
|
||||
return RulesEngine(ISMS_RULES.copy())
|
||||
@@ -1,416 +0,0 @@
|
||||
"""
|
||||
Spectrum analysis using rtl_power.
|
||||
|
||||
Provides functions to scan RF spectrum, compute band metrics,
|
||||
and detect signal anomalies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from statistics import mean, stdev
|
||||
from typing import Generator
|
||||
|
||||
logger = logging.getLogger('intercept.isms.spectrum')
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpectrumBin:
|
||||
"""A single frequency bin from rtl_power output."""
|
||||
freq_hz: float
|
||||
power_db: float
|
||||
timestamp: datetime
|
||||
freq_start: float = 0.0
|
||||
freq_end: float = 0.0
|
||||
|
||||
@property
|
||||
def freq_mhz(self) -> float:
|
||||
"""Frequency in MHz."""
|
||||
return self.freq_hz / 1_000_000
|
||||
|
||||
|
||||
@dataclass
|
||||
class BandMetrics:
|
||||
"""Computed metrics for a frequency band."""
|
||||
band_name: str
|
||||
freq_start_mhz: float
|
||||
freq_end_mhz: float
|
||||
noise_floor_db: float
|
||||
peak_frequency_mhz: float
|
||||
peak_power_db: float
|
||||
activity_score: float # 0-100 based on variance/peaks above noise
|
||||
bin_count: int = 0
|
||||
avg_power_db: float = 0.0
|
||||
power_variance: float = 0.0
|
||||
peaks_above_threshold: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class BurstEvent:
|
||||
"""Detected burst/transient signal."""
|
||||
freq_mhz: float
|
||||
power_db: float
|
||||
timestamp: datetime
|
||||
duration_estimate: float = 0.0
|
||||
above_noise_db: float = 0.0
|
||||
|
||||
|
||||
def get_rtl_power_path() -> str | None:
|
||||
"""Get the path to rtl_power executable."""
|
||||
return shutil.which('rtl_power')
|
||||
|
||||
|
||||
def _drain_stderr(process: subprocess.Popen, stop_event: threading.Event) -> None:
|
||||
"""Drain stderr to prevent buffer deadlock."""
|
||||
try:
|
||||
while not stop_event.is_set() and process.poll() is None:
|
||||
if process.stderr:
|
||||
process.stderr.read(1024)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_rtl_power_scan(
|
||||
freq_start_mhz: float,
|
||||
freq_end_mhz: float,
|
||||
bin_size_hz: int = 10000,
|
||||
integration_time: float = 1.0,
|
||||
device_index: int = 0,
|
||||
gain: int = 40,
|
||||
ppm: int = 0,
|
||||
single_shot: bool = False,
|
||||
output_file: Path | None = None,
|
||||
) -> Generator[SpectrumBin, None, None]:
|
||||
"""
|
||||
Run rtl_power and yield spectrum bins.
|
||||
|
||||
Args:
|
||||
freq_start_mhz: Start frequency in MHz
|
||||
freq_end_mhz: End frequency in MHz
|
||||
bin_size_hz: Frequency bin size in Hz
|
||||
integration_time: Integration time per sweep in seconds
|
||||
device_index: RTL-SDR device index
|
||||
gain: Gain in dB (0 for auto)
|
||||
ppm: Frequency correction in PPM
|
||||
single_shot: If True, exit after one complete sweep
|
||||
output_file: Optional file to write CSV output
|
||||
|
||||
Yields:
|
||||
SpectrumBin objects for each frequency bin
|
||||
"""
|
||||
rtl_power = get_rtl_power_path()
|
||||
if not rtl_power:
|
||||
logger.error("rtl_power not found in PATH")
|
||||
return
|
||||
|
||||
# Build command
|
||||
freq_range = f'{freq_start_mhz}M:{freq_end_mhz}M:{bin_size_hz}'
|
||||
|
||||
cmd = [
|
||||
rtl_power,
|
||||
'-f', freq_range,
|
||||
'-i', str(integration_time),
|
||||
'-d', str(device_index),
|
||||
'-g', str(gain),
|
||||
'-p', str(ppm),
|
||||
]
|
||||
|
||||
if single_shot:
|
||||
cmd.extend(['-1']) # Single shot mode
|
||||
|
||||
# Use temp file if not provided
|
||||
if output_file is None:
|
||||
temp_fd, temp_path = tempfile.mkstemp(suffix='.csv', prefix='rtl_power_')
|
||||
output_file = Path(temp_path)
|
||||
cleanup_temp = True
|
||||
else:
|
||||
cleanup_temp = False
|
||||
|
||||
cmd.extend(['-c', '0']) # Continuous output to stdout
|
||||
|
||||
logger.info(f"Starting rtl_power: {' '.join(cmd)}")
|
||||
|
||||
stop_event = threading.Event()
|
||||
stderr_thread = None
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
# Drain stderr in background to prevent deadlock
|
||||
stderr_thread = threading.Thread(
|
||||
target=_drain_stderr,
|
||||
args=(process, stop_event),
|
||||
daemon=True
|
||||
)
|
||||
stderr_thread.start()
|
||||
|
||||
# Parse CSV output line by line
|
||||
# rtl_power format: date, time, freq_low, freq_high, step, samples, db_values...
|
||||
for line in iter(process.stdout.readline, ''):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
parts = line.split(',')
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
|
||||
# Parse timestamp
|
||||
date_str = parts[0].strip()
|
||||
time_str = parts[1].strip()
|
||||
try:
|
||||
timestamp = datetime.strptime(
|
||||
f'{date_str} {time_str}',
|
||||
'%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
except ValueError:
|
||||
timestamp = datetime.now()
|
||||
|
||||
# Parse frequency range
|
||||
freq_low = float(parts[2])
|
||||
freq_high = float(parts[3])
|
||||
freq_step = float(parts[4])
|
||||
# samples = int(parts[5])
|
||||
|
||||
# Parse power values
|
||||
db_values = [float(v) for v in parts[6:] if v.strip()]
|
||||
|
||||
# Yield each bin
|
||||
current_freq = freq_low
|
||||
for db_value in db_values:
|
||||
yield SpectrumBin(
|
||||
freq_hz=current_freq,
|
||||
power_db=db_value,
|
||||
timestamp=timestamp,
|
||||
freq_start=freq_low,
|
||||
freq_end=freq_high,
|
||||
)
|
||||
current_freq += freq_step
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.debug(f"Failed to parse rtl_power line: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"rtl_power error: {e}")
|
||||
|
||||
finally:
|
||||
stop_event.set()
|
||||
if stderr_thread:
|
||||
stderr_thread.join(timeout=1.0)
|
||||
if cleanup_temp and output_file.exists():
|
||||
output_file.unlink()
|
||||
|
||||
|
||||
def compute_band_metrics(
|
||||
bins: list[SpectrumBin],
|
||||
band_name: str = 'Unknown',
|
||||
noise_percentile: float = 10.0,
|
||||
activity_threshold_db: float = 6.0,
|
||||
) -> BandMetrics:
|
||||
"""
|
||||
Compute metrics from spectrum bins.
|
||||
|
||||
Args:
|
||||
bins: List of SpectrumBin objects
|
||||
band_name: Name for this band
|
||||
noise_percentile: Percentile to use for noise floor estimation
|
||||
activity_threshold_db: dB above noise to count as activity
|
||||
|
||||
Returns:
|
||||
BandMetrics with computed values
|
||||
"""
|
||||
if not bins:
|
||||
return BandMetrics(
|
||||
band_name=band_name,
|
||||
freq_start_mhz=0,
|
||||
freq_end_mhz=0,
|
||||
noise_floor_db=-100,
|
||||
peak_frequency_mhz=0,
|
||||
peak_power_db=-100,
|
||||
activity_score=0,
|
||||
)
|
||||
|
||||
powers = [b.power_db for b in bins]
|
||||
freqs = [b.freq_mhz for b in bins]
|
||||
|
||||
# Sort for percentile calculation
|
||||
sorted_powers = sorted(powers)
|
||||
noise_idx = int(len(sorted_powers) * noise_percentile / 100)
|
||||
noise_floor = sorted_powers[noise_idx] if noise_idx < len(sorted_powers) else sorted_powers[0]
|
||||
|
||||
# Find peak
|
||||
peak_idx = powers.index(max(powers))
|
||||
peak_power = powers[peak_idx]
|
||||
peak_freq = freqs[peak_idx]
|
||||
|
||||
# Calculate activity score
|
||||
# Based on: variance of power levels and count of peaks above threshold
|
||||
threshold = noise_floor + activity_threshold_db
|
||||
peaks_above = sum(1 for p in powers if p > threshold)
|
||||
|
||||
# Calculate variance
|
||||
try:
|
||||
power_var = stdev(powers) ** 2 if len(powers) > 1 else 0
|
||||
except Exception:
|
||||
power_var = 0
|
||||
|
||||
# Activity score: combination of peak ratio and variance
|
||||
peak_ratio = peaks_above / len(bins) if bins else 0
|
||||
# Normalize variance (typical range 0-100 dB^2)
|
||||
var_component = min(power_var / 100, 1.0)
|
||||
|
||||
# Weighted combination
|
||||
activity_score = min(100, (peak_ratio * 70 + var_component * 30))
|
||||
|
||||
return BandMetrics(
|
||||
band_name=band_name,
|
||||
freq_start_mhz=min(freqs),
|
||||
freq_end_mhz=max(freqs),
|
||||
noise_floor_db=noise_floor,
|
||||
peak_frequency_mhz=peak_freq,
|
||||
peak_power_db=peak_power,
|
||||
activity_score=activity_score,
|
||||
bin_count=len(bins),
|
||||
avg_power_db=mean(powers) if powers else -100,
|
||||
power_variance=power_var,
|
||||
peaks_above_threshold=peaks_above,
|
||||
)
|
||||
|
||||
|
||||
def detect_bursts(
|
||||
bins: list[SpectrumBin],
|
||||
threshold_db: float = 10.0,
|
||||
min_power_db: float = -80.0,
|
||||
noise_floor_db: float | None = None,
|
||||
) -> list[BurstEvent]:
|
||||
"""
|
||||
Detect short bursts above noise floor.
|
||||
|
||||
Args:
|
||||
bins: List of SpectrumBin objects (should be time-ordered for one frequency)
|
||||
threshold_db: dB above noise to consider a burst
|
||||
min_power_db: Minimum absolute power to consider
|
||||
noise_floor_db: Noise floor (computed if not provided)
|
||||
|
||||
Returns:
|
||||
List of detected BurstEvent objects
|
||||
"""
|
||||
if not bins:
|
||||
return []
|
||||
|
||||
# Estimate noise floor if not provided
|
||||
if noise_floor_db is None:
|
||||
sorted_powers = sorted(b.power_db for b in bins)
|
||||
noise_idx = int(len(sorted_powers) * 0.1) # 10th percentile
|
||||
noise_floor_db = sorted_powers[noise_idx]
|
||||
|
||||
threshold = noise_floor_db + threshold_db
|
||||
threshold = max(threshold, min_power_db)
|
||||
|
||||
bursts = []
|
||||
|
||||
for bin_data in bins:
|
||||
if bin_data.power_db > threshold:
|
||||
bursts.append(BurstEvent(
|
||||
freq_mhz=bin_data.freq_mhz,
|
||||
power_db=bin_data.power_db,
|
||||
timestamp=bin_data.timestamp,
|
||||
above_noise_db=bin_data.power_db - noise_floor_db,
|
||||
))
|
||||
|
||||
return bursts
|
||||
|
||||
|
||||
def parse_rtl_power_csv(csv_path: Path) -> list[SpectrumBin]:
|
||||
"""
|
||||
Parse an rtl_power CSV file.
|
||||
|
||||
Args:
|
||||
csv_path: Path to CSV file
|
||||
|
||||
Returns:
|
||||
List of SpectrumBin objects
|
||||
"""
|
||||
bins = []
|
||||
|
||||
with open(csv_path, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
parts = line.split(',')
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
|
||||
date_str = parts[0].strip()
|
||||
time_str = parts[1].strip()
|
||||
try:
|
||||
timestamp = datetime.strptime(
|
||||
f'{date_str} {time_str}',
|
||||
'%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
except ValueError:
|
||||
timestamp = datetime.now()
|
||||
|
||||
freq_low = float(parts[2])
|
||||
freq_step = float(parts[4])
|
||||
db_values = [float(v) for v in parts[6:] if v.strip()]
|
||||
|
||||
current_freq = freq_low
|
||||
for db_value in db_values:
|
||||
bins.append(SpectrumBin(
|
||||
freq_hz=current_freq,
|
||||
power_db=db_value,
|
||||
timestamp=timestamp,
|
||||
))
|
||||
current_freq += freq_step
|
||||
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
return bins
|
||||
|
||||
|
||||
def group_bins_by_band(
|
||||
bins: list[SpectrumBin],
|
||||
band_ranges: dict[str, tuple[float, float]],
|
||||
) -> dict[str, list[SpectrumBin]]:
|
||||
"""
|
||||
Group spectrum bins by predefined band ranges.
|
||||
|
||||
Args:
|
||||
bins: List of SpectrumBin objects
|
||||
band_ranges: Dict mapping band name to (start_mhz, end_mhz)
|
||||
|
||||
Returns:
|
||||
Dict mapping band name to list of bins in that band
|
||||
"""
|
||||
grouped: dict[str, list[SpectrumBin]] = {name: [] for name in band_ranges}
|
||||
|
||||
for bin_data in bins:
|
||||
freq_mhz = bin_data.freq_mhz
|
||||
for band_name, (start, end) in band_ranges.items():
|
||||
if start <= freq_mhz <= end:
|
||||
grouped[band_name].append(bin_data)
|
||||
break
|
||||
|
||||
return grouped
|
||||
@@ -1,340 +0,0 @@
|
||||
"""
|
||||
Cell tower integration via OpenCelliD API.
|
||||
|
||||
Provides functions to query nearby towers and generate link-outs
|
||||
to CellMapper and Ofcom resources.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from math import acos, cos, radians, sin
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger('intercept.isms.towers')
|
||||
|
||||
# OpenCelliD API endpoint
|
||||
OPENCELLID_API_URL = 'https://opencellid.org/cell/getInArea'
|
||||
|
||||
# Request timeout
|
||||
REQUEST_TIMEOUT = 10.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class CellTower:
|
||||
"""Cell tower information from OpenCelliD."""
|
||||
tower_id: int
|
||||
mcc: int
|
||||
mnc: int
|
||||
lac: int
|
||||
cellid: int
|
||||
lat: float
|
||||
lon: float
|
||||
range_m: int
|
||||
radio: str # GSM, UMTS, LTE, NR
|
||||
samples: int = 0
|
||||
changeable: bool = True
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
|
||||
@property
|
||||
def plmn(self) -> str:
|
||||
"""Get PLMN code (MCC-MNC)."""
|
||||
return f'{self.mcc}-{self.mnc}'
|
||||
|
||||
@property
|
||||
def distance_km(self) -> float | None:
|
||||
"""Distance from query point (set after query)."""
|
||||
return getattr(self, '_distance_km', None)
|
||||
|
||||
@distance_km.setter
|
||||
def distance_km(self, value: float) -> None:
|
||||
self._distance_km = value
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
'tower_id': self.tower_id,
|
||||
'mcc': self.mcc,
|
||||
'mnc': self.mnc,
|
||||
'lac': self.lac,
|
||||
'cellid': self.cellid,
|
||||
'lat': self.lat,
|
||||
'lon': self.lon,
|
||||
'range_m': self.range_m,
|
||||
'radio': self.radio,
|
||||
'plmn': self.plmn,
|
||||
'samples': self.samples,
|
||||
'distance_km': self.distance_km,
|
||||
'cellmapper_url': build_cellmapper_url(self.mcc, self.mnc, self.lac, self.cellid),
|
||||
}
|
||||
|
||||
|
||||
def get_opencellid_token() -> str | None:
|
||||
"""Get OpenCelliD API token from environment."""
|
||||
return os.environ.get('OPENCELLID_TOKEN')
|
||||
|
||||
|
||||
def _haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Calculate distance between two points in km using Haversine formula."""
|
||||
R = 6371 # Earth radius in km
|
||||
|
||||
lat1_rad = radians(lat1)
|
||||
lat2_rad = radians(lat2)
|
||||
lon1_rad = radians(lon1)
|
||||
lon2_rad = radians(lon2)
|
||||
|
||||
dlat = lat2_rad - lat1_rad
|
||||
dlon = lon2_rad - lon1_rad
|
||||
|
||||
# Haversine formula
|
||||
a = sin(dlat / 2) ** 2 + cos(lat1_rad) * cos(lat2_rad) * sin(dlon / 2) ** 2
|
||||
c = 2 * acos(min(1, (1 - a) ** 0.5 * (1 + a) ** 0.5 + a ** 0.5 * (1 - a) ** 0.5))
|
||||
|
||||
# Simplified: c = 2 * atan2(sqrt(a), sqrt(1-a))
|
||||
# Using identity: acos(1 - 2a) for small angles
|
||||
|
||||
return R * c
|
||||
|
||||
|
||||
def query_nearby_towers(
|
||||
lat: float,
|
||||
lon: float,
|
||||
radius_km: float = 5.0,
|
||||
token: str | None = None,
|
||||
radio: str | None = None,
|
||||
mcc: int | None = None,
|
||||
mnc: int | None = None,
|
||||
) -> list[CellTower]:
|
||||
"""
|
||||
Query OpenCelliD for towers within radius.
|
||||
|
||||
Args:
|
||||
lat: Latitude of center point
|
||||
lon: Longitude of center point
|
||||
radius_km: Search radius in kilometers
|
||||
token: OpenCelliD API token (uses env var if not provided)
|
||||
radio: Filter by radio type (GSM, UMTS, LTE, NR)
|
||||
mcc: Filter by MCC (country code)
|
||||
mnc: Filter by MNC (network code)
|
||||
|
||||
Returns:
|
||||
List of CellTower objects sorted by distance
|
||||
"""
|
||||
if token is None:
|
||||
token = get_opencellid_token()
|
||||
|
||||
if not token:
|
||||
logger.warning("OpenCelliD token not configured")
|
||||
return []
|
||||
|
||||
# Convert radius to bounding box
|
||||
# Approximate: 1 degree latitude ~ 111 km
|
||||
lat_delta = radius_km / 111.0
|
||||
# Longitude varies with latitude
|
||||
lon_delta = radius_km / (111.0 * cos(radians(lat)))
|
||||
|
||||
params = {
|
||||
'key': token,
|
||||
'BBOX': f'{lon - lon_delta},{lat - lat_delta},{lon + lon_delta},{lat + lat_delta}',
|
||||
'format': 'json',
|
||||
}
|
||||
|
||||
if radio:
|
||||
params['radio'] = radio
|
||||
if mcc is not None:
|
||||
params['mcc'] = mcc
|
||||
if mnc is not None:
|
||||
params['mnc'] = mnc
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
OPENCELLID_API_URL,
|
||||
params=params,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
if 'cells' not in data:
|
||||
logger.debug(f"No cells in OpenCelliD response: {data}")
|
||||
return []
|
||||
|
||||
towers = []
|
||||
for cell in data['cells']:
|
||||
try:
|
||||
tower = CellTower(
|
||||
tower_id=cell.get('cellid', 0),
|
||||
mcc=cell.get('mcc', 0),
|
||||
mnc=cell.get('mnc', 0),
|
||||
lac=cell.get('lac', 0),
|
||||
cellid=cell.get('cellid', 0),
|
||||
lat=cell.get('lat', 0),
|
||||
lon=cell.get('lon', 0),
|
||||
range_m=cell.get('range', 0),
|
||||
radio=cell.get('radio', 'UNKNOWN'),
|
||||
samples=cell.get('samples', 0),
|
||||
changeable=cell.get('changeable', True),
|
||||
created=cell.get('created', 0),
|
||||
updated=cell.get('updated', 0),
|
||||
)
|
||||
|
||||
# Calculate distance
|
||||
distance = _haversine_distance(lat, lon, tower.lat, tower.lon)
|
||||
tower.distance_km = round(distance, 2)
|
||||
|
||||
# Only include towers within actual radius (bounding box is larger)
|
||||
if distance <= radius_km:
|
||||
towers.append(tower)
|
||||
|
||||
except (KeyError, TypeError) as e:
|
||||
logger.debug(f"Failed to parse cell: {e}")
|
||||
continue
|
||||
|
||||
# Sort by distance
|
||||
towers.sort(key=lambda t: t.distance_km or 0)
|
||||
|
||||
logger.info(f"Found {len(towers)} towers within {radius_km}km of ({lat}, {lon})")
|
||||
return towers
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"OpenCelliD API error: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Error querying towers: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def build_cellmapper_url(mcc: int, mnc: int, lac: int, cid: int) -> str:
|
||||
"""
|
||||
Build link-out URL to CellMapper (no scraping).
|
||||
|
||||
Args:
|
||||
mcc: Mobile Country Code
|
||||
mnc: Mobile Network Code
|
||||
lac: Location Area Code
|
||||
cid: Cell ID
|
||||
|
||||
Returns:
|
||||
URL to CellMapper map view for this cell
|
||||
"""
|
||||
return f'https://www.cellmapper.net/map?MCC={mcc}&MNC={mnc}&LAC={lac}&CID={cid}'
|
||||
|
||||
|
||||
def build_cellmapper_tower_url(mcc: int, mnc: int, lat: float, lon: float) -> str:
|
||||
"""
|
||||
Build link-out URL to CellMapper map centered on location.
|
||||
|
||||
Args:
|
||||
mcc: Mobile Country Code
|
||||
mnc: Mobile Network Code
|
||||
lat: Latitude
|
||||
lon: Longitude
|
||||
|
||||
Returns:
|
||||
URL to CellMapper map view centered on location
|
||||
"""
|
||||
return f'https://www.cellmapper.net/map?MCC={mcc}&MNC={mnc}&latitude={lat}&longitude={lon}&zoom=15'
|
||||
|
||||
|
||||
def build_ofcom_coverage_url(lat: float | None = None, lon: float | None = None) -> str:
|
||||
"""
|
||||
Build link to Ofcom mobile coverage checker.
|
||||
|
||||
Args:
|
||||
lat: Optional latitude for location
|
||||
lon: Optional longitude for location
|
||||
|
||||
Returns:
|
||||
URL to Ofcom coverage checker
|
||||
"""
|
||||
base_url = 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-quality/mobile-coverage-checker'
|
||||
|
||||
# Note: Ofcom coverage checker uses postcode entry, not lat/lon parameters
|
||||
# So we just return the base URL
|
||||
return base_url
|
||||
|
||||
|
||||
def build_ofcom_emf_url() -> str:
|
||||
"""
|
||||
Build link to Ofcom EMF/base station audits info.
|
||||
|
||||
Returns:
|
||||
URL to Ofcom EMF information page
|
||||
"""
|
||||
return 'https://www.ofcom.org.uk/phones-telecoms-and-internet/information-for-industry/radiocomms-and-spectrum/radio-spectrum/spectrum-for-mobile-services/electromagnetic-fields-emf'
|
||||
|
||||
|
||||
def build_ofcom_sitefinder_url() -> str:
|
||||
"""
|
||||
Build link to Ofcom Sitefinder (base station database).
|
||||
|
||||
Note: Sitefinder was retired in 2017. This returns the info page.
|
||||
|
||||
Returns:
|
||||
URL to Ofcom mobile sites information
|
||||
"""
|
||||
return 'https://www.ofcom.org.uk/phones-telecoms-and-internet/advice-for-consumers/mobile-services'
|
||||
|
||||
|
||||
def get_uk_operator_name(mcc: int, mnc: int) -> str | None:
|
||||
"""
|
||||
Get UK operator name from MCC/MNC.
|
||||
|
||||
Args:
|
||||
mcc: Mobile Country Code
|
||||
mnc: Mobile Network Code
|
||||
|
||||
Returns:
|
||||
Operator name or None if not found
|
||||
"""
|
||||
# UK MCC is 234
|
||||
if mcc != 234:
|
||||
return None
|
||||
|
||||
operators = {
|
||||
10: 'O2 UK',
|
||||
15: 'Vodafone UK',
|
||||
20: 'Three UK',
|
||||
30: 'EE',
|
||||
33: 'EE',
|
||||
34: 'EE',
|
||||
50: 'JT (Jersey)',
|
||||
55: 'Sure (Guernsey)',
|
||||
}
|
||||
|
||||
return operators.get(mnc)
|
||||
|
||||
|
||||
def format_tower_info(tower: CellTower) -> dict:
|
||||
"""
|
||||
Format tower information for display.
|
||||
|
||||
Args:
|
||||
tower: CellTower object
|
||||
|
||||
Returns:
|
||||
Formatted dictionary with display-friendly values
|
||||
"""
|
||||
operator = get_uk_operator_name(tower.mcc, tower.mnc)
|
||||
|
||||
return {
|
||||
'id': tower.tower_id,
|
||||
'plmn': tower.plmn,
|
||||
'operator': operator or f'MCC {tower.mcc} / MNC {tower.mnc}',
|
||||
'radio': tower.radio,
|
||||
'lac': tower.lac,
|
||||
'cellid': tower.cellid,
|
||||
'lat': round(tower.lat, 6),
|
||||
'lon': round(tower.lon, 6),
|
||||
'range_km': round(tower.range_m / 1000, 2) if tower.range_m else None,
|
||||
'distance_km': tower.distance_km,
|
||||
'samples': tower.samples,
|
||||
'cellmapper_url': build_cellmapper_url(tower.mcc, tower.mnc, tower.lac, tower.cellid),
|
||||
'ofcom_coverage_url': build_ofcom_coverage_url(),
|
||||
'ofcom_emf_url': build_ofcom_emf_url(),
|
||||
}
|
||||
Reference in New Issue
Block a user