Add ISMS Listening Station with GSM cell detection

- Add spectrum monitoring via rtl_power with configurable presets
- Add OpenCelliD tower integration with Leaflet map display
- Add grgsm_scanner integration for passive GSM cell detection (alpha)
- Add rules engine for anomaly detection and findings
- Add baseline recording and comparison system
- Add setup.sh support for gr-gsm installation on Debian/Ubuntu

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-01-16 11:12:09 +00:00
parent 4c1690dd28
commit 35d138175e
15 changed files with 5578 additions and 4 deletions

315
data/isms_presets.py Normal file
View File

@@ -0,0 +1,315 @@
"""
ISMS scan presets and band definitions.
Defines frequency ranges and parameters for common RF monitoring scenarios.
"""
from __future__ import annotations
# Scan presets for common monitoring scenarios
ISMS_SCAN_PRESETS: dict[str, dict] = {
'vhf_airband': {
'name': 'VHF Airband',
'description': 'Aviation communications 118-137 MHz',
'freq_start': 118.0,
'freq_end': 137.0,
'bin_size': 25000, # 25 kHz channel spacing
'integration': 1.0,
'category': 'aviation',
},
'uhf_airband': {
'name': 'UHF Airband',
'description': 'Military aviation 225-400 MHz',
'freq_start': 225.0,
'freq_end': 400.0,
'bin_size': 25000,
'integration': 1.0,
'category': 'aviation',
},
'uhf_pmr': {
'name': 'UHF PMR446',
'description': 'License-free radio 446 MHz',
'freq_start': 446.0,
'freq_end': 446.2,
'bin_size': 12500, # 12.5 kHz channel spacing
'integration': 0.5,
'category': 'pmr',
},
'ism_433': {
'name': 'ISM 433 MHz',
'description': 'European ISM band (sensors, remotes)',
'freq_start': 433.0,
'freq_end': 434.8,
'bin_size': 10000,
'integration': 0.5,
'category': 'ism',
},
'ism_868': {
'name': 'ISM 868 MHz',
'description': 'European ISM band (LoRa, smart meters)',
'freq_start': 868.0,
'freq_end': 870.0,
'bin_size': 10000,
'integration': 0.5,
'category': 'ism',
},
'ism_915': {
'name': 'ISM 915 MHz',
'description': 'US ISM band',
'freq_start': 902.0,
'freq_end': 928.0,
'bin_size': 50000,
'integration': 1.0,
'category': 'ism',
},
'wifi_2g': {
'name': 'WiFi 2.4 GHz Vicinity',
'description': 'WiFi band activity (requires wideband SDR)',
'freq_start': 2400.0,
'freq_end': 2500.0,
'bin_size': 500000,
'integration': 2.0,
'category': 'wifi',
'note': 'Requires SDR with 2.4 GHz capability (HackRF, LimeSDR)',
},
'cellular_700': {
'name': 'Cellular 700 MHz',
'description': 'LTE Bands 12/13/17/28 downlink',
'freq_start': 728.0,
'freq_end': 803.0,
'bin_size': 100000,
'integration': 1.0,
'category': 'cellular',
},
'cellular_850': {
'name': 'Cellular 850 MHz',
'description': 'GSM/LTE Band 5 downlink',
'freq_start': 869.0,
'freq_end': 894.0,
'bin_size': 100000,
'integration': 1.0,
'category': 'cellular',
},
'cellular_900': {
'name': 'Cellular 900 MHz',
'description': 'GSM/LTE Band 8 downlink (Europe)',
'freq_start': 925.0,
'freq_end': 960.0,
'bin_size': 100000,
'integration': 1.0,
'category': 'cellular',
},
'cellular_1800': {
'name': 'Cellular 1800 MHz',
'description': 'GSM/LTE Band 3 downlink',
'freq_start': 1805.0,
'freq_end': 1880.0,
'bin_size': 100000,
'integration': 1.0,
'category': 'cellular',
},
'full_sweep': {
'name': 'Full Spectrum',
'description': 'Complete 24 MHz - 1.7 GHz sweep',
'freq_start': 24.0,
'freq_end': 1700.0,
'bin_size': 100000,
'integration': 5.0,
'category': 'full',
'note': 'Takes 3-5 minutes to complete',
},
}
# Cellular band definitions (downlink frequencies for reference)
CELLULAR_BANDS: dict[str, dict] = {
'B1': {
'name': 'UMTS/LTE Band 1',
'dl_start': 2110,
'dl_end': 2170,
'ul_start': 1920,
'ul_end': 1980,
'duplex': 'FDD',
},
'B3': {
'name': 'LTE Band 3',
'dl_start': 1805,
'dl_end': 1880,
'ul_start': 1710,
'ul_end': 1785,
'duplex': 'FDD',
},
'B5': {
'name': 'GSM/LTE Band 5',
'dl_start': 869,
'dl_end': 894,
'ul_start': 824,
'ul_end': 849,
'duplex': 'FDD',
},
'B7': {
'name': 'LTE Band 7',
'dl_start': 2620,
'dl_end': 2690,
'ul_start': 2500,
'ul_end': 2570,
'duplex': 'FDD',
},
'B8': {
'name': 'GSM/LTE Band 8',
'dl_start': 925,
'dl_end': 960,
'ul_start': 880,
'ul_end': 915,
'duplex': 'FDD',
},
'B20': {
'name': 'LTE Band 20',
'dl_start': 791,
'dl_end': 821,
'ul_start': 832,
'ul_end': 862,
'duplex': 'FDD',
},
'B28': {
'name': 'LTE Band 28',
'dl_start': 758,
'dl_end': 803,
'ul_start': 703,
'ul_end': 748,
'duplex': 'FDD',
},
'B38': {
'name': 'LTE Band 38 (TDD)',
'dl_start': 2570,
'dl_end': 2620,
'duplex': 'TDD',
},
'B40': {
'name': 'LTE Band 40 (TDD)',
'dl_start': 2300,
'dl_end': 2400,
'duplex': 'TDD',
},
'n77': {
'name': 'NR Band n77 (5G)',
'dl_start': 3300,
'dl_end': 4200,
'duplex': 'TDD',
},
'n78': {
'name': 'NR Band n78 (5G)',
'dl_start': 3300,
'dl_end': 3800,
'duplex': 'TDD',
},
}
# UK Mobile Network Operators (for PLMN identification)
UK_OPERATORS: dict[str, str] = {
'234-10': 'O2 UK',
'234-15': 'Vodafone UK',
'234-20': 'Three UK',
'234-30': 'EE',
'234-33': 'EE',
'234-34': 'EE',
'234-50': 'JT (Jersey)',
'234-55': 'Sure (Guernsey)',
}
# Common ISM band allocations
ISM_BANDS: dict[str, dict] = {
'ism_6m': {
'name': '6.78 MHz ISM',
'start': 6.765,
'end': 6.795,
'region': 'Worldwide',
},
'ism_13m': {
'name': '13.56 MHz ISM (NFC/RFID)',
'start': 13.553,
'end': 13.567,
'region': 'Worldwide',
},
'ism_27m': {
'name': '27 MHz ISM (CB)',
'start': 26.957,
'end': 27.283,
'region': 'Worldwide',
},
'ism_40m': {
'name': '40.68 MHz ISM',
'start': 40.66,
'end': 40.70,
'region': 'Worldwide',
},
'ism_433': {
'name': '433 MHz ISM',
'start': 433.05,
'end': 434.79,
'region': 'ITU Region 1 (Europe)',
},
'ism_868': {
'name': '868 MHz ISM',
'start': 868.0,
'end': 870.0,
'region': 'Europe',
},
'ism_915': {
'name': '915 MHz ISM',
'start': 902.0,
'end': 928.0,
'region': 'Americas',
},
'ism_2400': {
'name': '2.4 GHz ISM (WiFi/BT)',
'start': 2400.0,
'end': 2500.0,
'region': 'Worldwide',
},
'ism_5800': {
'name': '5.8 GHz ISM',
'start': 5725.0,
'end': 5875.0,
'region': 'Worldwide',
},
}
def get_preset(preset_name: str) -> dict | None:
"""Get a scan preset by name."""
return ISMS_SCAN_PRESETS.get(preset_name)
def get_presets_by_category(category: str) -> list[dict]:
"""Get all presets in a category."""
return [
{**preset, 'id': name}
for name, preset in ISMS_SCAN_PRESETS.items()
if preset.get('category') == category
]
def get_all_presets() -> list[dict]:
"""Get all presets with their IDs."""
return [
{**preset, 'id': name}
for name, preset in ISMS_SCAN_PRESETS.items()
]
def identify_band(freq_mhz: float) -> str | None:
"""Identify which cellular band a frequency belongs to."""
for band_id, band_info in CELLULAR_BANDS.items():
dl_start = band_info.get('dl_start', 0)
dl_end = band_info.get('dl_end', 0)
if dl_start <= freq_mhz <= dl_end:
return band_id
return None
def identify_operator(plmn: str) -> str | None:
"""Identify UK operator from PLMN code."""
return UK_OPERATORS.get(plmn)

View File

@@ -15,6 +15,7 @@ def register_blueprints(app):
from .correlation import correlation_bp
from .listening_post import listening_post_bp
from .tscm import tscm_bp, init_tscm_state
from .isms import isms_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -29,6 +30,7 @@ def register_blueprints(app):
app.register_blueprint(correlation_bp)
app.register_blueprint(listening_post_bp)
app.register_blueprint(tscm_bp)
app.register_blueprint(isms_bp)
# Initialize TSCM state with queue and lock from app
import app as app_module

981
routes/isms.py Normal file
View File

@@ -0,0 +1,981 @@
"""ISMS Listening Station routes for spectrum monitoring and tower mapping."""
from __future__ import annotations
import json
import queue
import shutil
import subprocess
import threading
import time
from datetime import datetime
from typing import Generator
from flask import Blueprint, Response, jsonify, request
from utils.logging import get_logger
from utils.sse import format_sse
from utils.constants import SSE_QUEUE_TIMEOUT, SSE_KEEPALIVE_INTERVAL
from utils.process import safe_terminate, register_process
from utils.gps import get_current_position
from utils.database import (
create_isms_baseline,
get_isms_baseline,
get_all_isms_baselines,
get_active_isms_baseline,
set_active_isms_baseline,
delete_isms_baseline,
update_isms_baseline,
create_isms_scan,
update_isms_scan,
get_isms_scan,
get_recent_isms_scans,
add_isms_finding,
get_isms_findings,
get_isms_findings_summary,
acknowledge_isms_finding,
)
from utils.isms.spectrum import (
run_rtl_power_scan,
compute_band_metrics,
detect_bursts,
get_rtl_power_path,
SpectrumBin,
BandMetrics,
)
from utils.isms.towers import (
query_nearby_towers,
format_tower_info,
build_ofcom_coverage_url,
build_ofcom_emf_url,
get_opencellid_token,
)
from utils.isms.rules import RulesEngine, create_default_engine
from utils.isms.baseline import (
BaselineRecorder,
compare_spectrum_baseline,
compare_tower_baseline,
save_baseline_to_db,
)
from utils.isms.gsm import (
GsmCell,
run_grgsm_scan,
run_gsm_scan_blocking,
get_grgsm_scanner_path,
format_gsm_cell,
deduplicate_cells,
identify_gsm_anomalies,
)
from data.isms_presets import (
ISMS_SCAN_PRESETS,
get_preset,
get_all_presets,
identify_band,
)
logger = get_logger('intercept.isms')
isms_bp = Blueprint('isms', __name__, url_prefix='/isms')
# ============================================
# GLOBAL STATE
# ============================================
# Scanner state
isms_thread: threading.Thread | None = None
isms_running = False
isms_lock = threading.Lock()
isms_process: subprocess.Popen | None = None
isms_current_scan_id: int | None = None
# Scanner configuration
isms_config = {
'preset': 'ism_433',
'freq_start': 433.0,
'freq_end': 434.8,
'bin_size': 10000,
'integration': 0.5,
'device': 0,
'gain': 40,
'ppm': 0,
'threshold': 50, # Activity threshold (0-100)
'lat': None,
'lon': None,
'baseline_id': None,
}
# SSE queue for real-time events
isms_queue: queue.Queue = queue.Queue(maxsize=100)
# Rules engine
rules_engine: RulesEngine = create_default_engine()
# Baseline recorder
baseline_recorder: BaselineRecorder = BaselineRecorder()
# Recent band metrics for display
recent_metrics: dict[str, BandMetrics] = {}
metrics_lock = threading.Lock()
# Findings count for current scan
current_findings_count = 0
# GSM scanner state
gsm_thread: threading.Thread | None = None
gsm_running = False
gsm_lock = threading.Lock()
gsm_detected_cells: list[GsmCell] = []
gsm_baseline_cells: list[GsmCell] = []
# ============================================
# HELPER FUNCTIONS
# ============================================
def emit_event(event_type: str, data: dict) -> None:
"""Emit an event to SSE queue."""
try:
isms_queue.put_nowait({
'type': event_type,
**data
})
except queue.Full:
pass
def emit_finding(severity: str, text: str, **details) -> None:
"""Emit a finding event and store in database."""
global current_findings_count
emit_event('finding', {
'severity': severity,
'text': text,
'details': details,
'timestamp': datetime.utcnow().isoformat() + 'Z',
})
# Store in database if we have an active scan
if isms_current_scan_id:
add_isms_finding(
scan_id=isms_current_scan_id,
finding_type=details.get('finding_type', 'general'),
severity=severity,
description=text,
band=details.get('band'),
frequency=details.get('frequency'),
details=details,
)
current_findings_count += 1
def emit_meter(band: str, level: float, noise_floor: float) -> None:
"""Emit a band meter update."""
emit_event('meter', {
'band': band,
'level': min(100, max(0, level)),
'noise_floor': noise_floor,
})
def emit_peak(freq_mhz: float, power_db: float, band: str) -> None:
"""Emit a spectrum peak event."""
emit_event('spectrum_peak', {
'freq_mhz': round(freq_mhz, 3),
'power_db': round(power_db, 1),
'band': band,
})
def emit_status(state: str, **data) -> None:
"""Emit a status update."""
emit_event('status', {
'state': state,
**data
})
# ============================================
# SCANNER LOOP
# ============================================
def isms_scan_loop() -> None:
"""Main ISMS scanning loop."""
global isms_running, isms_process, current_findings_count
logger.info("ISMS scan thread started")
emit_status('starting')
# Get preset configuration
preset_name = isms_config.get('preset', 'ism_433')
preset = get_preset(preset_name)
if preset:
freq_start = preset['freq_start']
freq_end = preset['freq_end']
bin_size = preset.get('bin_size', 10000)
integration = preset.get('integration', 1.0)
band_name = preset['name']
else:
freq_start = isms_config['freq_start']
freq_end = isms_config['freq_end']
bin_size = isms_config['bin_size']
integration = isms_config['integration']
band_name = f'{freq_start}-{freq_end} MHz'
device = isms_config['device']
gain = isms_config['gain']
ppm = isms_config['ppm']
threshold = isms_config['threshold']
# Get active baseline for comparison
active_baseline = None
baseline_spectrum = None
if isms_config.get('baseline_id'):
active_baseline = get_isms_baseline(isms_config['baseline_id'])
if active_baseline:
baseline_spectrum = active_baseline.get('spectrum_profile', {}).get(band_name)
emit_status('scanning', band=band_name, preset=preset_name)
current_bins: list[SpectrumBin] = []
sweep_count = 0
try:
# Run continuous spectrum scanning
for spectrum_bin in run_rtl_power_scan(
freq_start_mhz=freq_start,
freq_end_mhz=freq_end,
bin_size_hz=bin_size,
integration_time=integration,
device_index=device,
gain=gain,
ppm=ppm,
single_shot=False,
):
if not isms_running:
break
current_bins.append(spectrum_bin)
# Process a sweep's worth of data
if spectrum_bin.freq_hz >= (freq_end * 1_000_000 - bin_size):
sweep_count += 1
# Compute band metrics
metrics = compute_band_metrics(current_bins, band_name)
# Store in recent metrics
with metrics_lock:
recent_metrics[band_name] = metrics
# Add to baseline recorder if recording
if baseline_recorder.is_recording:
baseline_recorder.add_spectrum_sample(band_name, metrics)
# Emit meter update
emit_meter(band_name, metrics.activity_score, metrics.noise_floor_db)
# Emit peak if significant
if metrics.peak_power_db > metrics.noise_floor_db + 6:
emit_peak(metrics.peak_frequency_mhz, metrics.peak_power_db, band_name)
# Detect bursts
bursts = detect_bursts(current_bins)
if bursts:
emit_event('bursts_detected', {
'band': band_name,
'count': len(bursts),
})
# Run rules engine
findings = rules_engine.evaluate_spectrum(
band_name=band_name,
noise_floor=metrics.noise_floor_db,
peak_freq=metrics.peak_frequency_mhz,
peak_power=metrics.peak_power_db,
activity_score=metrics.activity_score,
baseline_noise=baseline_spectrum.get('noise_floor_db') if baseline_spectrum else None,
baseline_activity=baseline_spectrum.get('activity_score') if baseline_spectrum else None,
baseline_peaks=baseline_spectrum.get('peak_frequencies') if baseline_spectrum else None,
burst_count=len(bursts),
)
for finding in findings:
emit_finding(
finding.severity,
finding.description,
finding_type=finding.finding_type,
band=finding.band,
frequency=finding.frequency,
)
# Emit progress
if sweep_count % 5 == 0:
emit_status('scanning', band=band_name, sweeps=sweep_count)
# Clear for next sweep
current_bins.clear()
except Exception as e:
logger.error(f"ISMS scan error: {e}")
emit_status('error', message=str(e))
finally:
isms_running = False
emit_status('stopped', sweeps=sweep_count)
# Update scan record
if isms_current_scan_id:
update_isms_scan(
isms_current_scan_id,
status='completed',
findings_count=current_findings_count,
completed=True,
)
logger.info(f"ISMS scan stopped after {sweep_count} sweeps")
# ============================================
# ROUTES: SCAN CONTROL
# ============================================
@isms_bp.route('/start_scan', methods=['POST'])
def start_scan():
"""Start ISMS spectrum scanning."""
global isms_thread, isms_running, isms_current_scan_id, current_findings_count
with isms_lock:
if isms_running:
return jsonify({
'status': 'error',
'message': 'Scan already running'
}), 409
# Get configuration from request
data = request.get_json() or {}
isms_config['preset'] = data.get('preset', isms_config['preset'])
isms_config['device'] = data.get('device', isms_config['device'])
isms_config['gain'] = data.get('gain', isms_config['gain'])
isms_config['threshold'] = data.get('threshold', isms_config['threshold'])
isms_config['baseline_id'] = data.get('baseline_id')
# Custom frequency range
if data.get('freq_start'):
isms_config['freq_start'] = float(data['freq_start'])
if data.get('freq_end'):
isms_config['freq_end'] = float(data['freq_end'])
# Location
if data.get('lat') and data.get('lon'):
isms_config['lat'] = float(data['lat'])
isms_config['lon'] = float(data['lon'])
# Check for rtl_power
if not get_rtl_power_path():
return jsonify({
'status': 'error',
'message': 'rtl_power not found. Install rtl-sdr tools.'
}), 500
# Clear queue
while not isms_queue.empty():
try:
isms_queue.get_nowait()
except queue.Empty:
break
# Create scan record
gps_coords = None
if isms_config.get('lat') and isms_config.get('lon'):
gps_coords = {'lat': isms_config['lat'], 'lon': isms_config['lon']}
isms_current_scan_id = create_isms_scan(
scan_preset=isms_config['preset'],
baseline_id=isms_config.get('baseline_id'),
gps_coords=gps_coords,
)
current_findings_count = 0
# Start scanning thread
isms_running = True
isms_thread = threading.Thread(target=isms_scan_loop, daemon=True)
isms_thread.start()
return jsonify({
'status': 'started',
'scan_id': isms_current_scan_id,
'config': {
'preset': isms_config['preset'],
'device': isms_config['device'],
}
})
@isms_bp.route('/stop_scan', methods=['POST'])
def stop_scan():
"""Stop ISMS spectrum scanning."""
global isms_running, isms_process
with isms_lock:
if not isms_running:
return jsonify({
'status': 'error',
'message': 'No scan running'
}), 400
isms_running = False
# Terminate any subprocess
if isms_process:
safe_terminate(isms_process)
isms_process = None
return jsonify({'status': 'stopped'})
@isms_bp.route('/status', methods=['GET'])
def get_status():
"""Get current scanner status."""
with isms_lock:
status = {
'running': isms_running,
'config': {
'preset': isms_config['preset'],
'device': isms_config['device'],
'baseline_id': isms_config.get('baseline_id'),
},
'current_scan_id': isms_current_scan_id,
'findings_count': current_findings_count,
}
# Add recent metrics
with metrics_lock:
status['metrics'] = {
band: {
'activity_score': m.activity_score,
'noise_floor': m.noise_floor_db,
'peak_freq': m.peak_frequency_mhz,
'peak_power': m.peak_power_db,
}
for band, m in recent_metrics.items()
}
return jsonify(status)
# ============================================
# ROUTES: SSE STREAM
# ============================================
@isms_bp.route('/stream', methods=['GET'])
def stream():
"""SSE stream for real-time ISMS events."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
while True:
try:
msg = isms_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response
# ============================================
# ROUTES: PRESETS
# ============================================
@isms_bp.route('/presets', methods=['GET'])
def list_presets():
"""List available scan presets."""
return jsonify({
'presets': get_all_presets()
})
# ============================================
# ROUTES: TOWERS
# ============================================
@isms_bp.route('/towers', methods=['GET'])
def get_towers():
"""Query nearby cell towers from OpenCelliD."""
lat = request.args.get('lat', type=float)
lon = request.args.get('lon', type=float)
radius = request.args.get('radius', default=5.0, type=float)
radio = request.args.get('radio') # GSM, UMTS, LTE, NR
if lat is None or lon is None:
# Try to get from GPS
pos = get_current_position()
if pos:
lat = pos.latitude
lon = pos.longitude
else:
return jsonify({
'status': 'error',
'message': 'Location required (lat/lon parameters or GPS)'
}), 400
# Check for token
token = get_opencellid_token()
if not token:
return jsonify({
'status': 'error',
'message': 'OpenCelliD token not configured. Set OPENCELLID_TOKEN environment variable.',
'config_required': True,
}), 400
# Query towers
towers = query_nearby_towers(
lat=lat,
lon=lon,
radius_km=radius,
radio=radio,
)
# Format for response
formatted_towers = [format_tower_info(t) for t in towers]
# Add to baseline recorder if recording
if baseline_recorder.is_recording:
for tower in towers:
baseline_recorder.add_tower_sample(tower)
return jsonify({
'status': 'ok',
'query': {
'lat': lat,
'lon': lon,
'radius_km': radius,
},
'count': len(formatted_towers),
'towers': formatted_towers,
'links': {
'ofcom_coverage': build_ofcom_coverage_url(),
'ofcom_emf': build_ofcom_emf_url(),
}
})
# ============================================
# ROUTES: BASELINES
# ============================================
@isms_bp.route('/baselines', methods=['GET'])
def list_baselines():
"""List all ISMS baselines."""
baselines = get_all_isms_baselines()
return jsonify({
'baselines': baselines
})
@isms_bp.route('/baselines', methods=['POST'])
def create_baseline():
"""Create a new baseline manually."""
data = request.get_json() or {}
name = data.get('name')
if not name:
return jsonify({
'status': 'error',
'message': 'Name required'
}), 400
baseline_id = create_isms_baseline(
name=name,
location_name=data.get('location_name'),
latitude=data.get('latitude'),
longitude=data.get('longitude'),
spectrum_profile=data.get('spectrum_profile'),
cellular_environment=data.get('cellular_environment'),
known_towers=data.get('known_towers'),
)
return jsonify({
'status': 'created',
'baseline_id': baseline_id
})
@isms_bp.route('/baseline/<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():
return jsonify({
'status': 'error',
'message': 'grgsm_scanner not found. Install gr-gsm package.',
'install_hint': 'See setup.sh or install gr-gsm from https://github.com/ptrkrysik/gr-gsm'
}), 500
# Get configuration from request
data = request.get_json() or {}
band = data.get('band', 'GSM900')
gain = data.get('gain', isms_config['gain'])
ppm = data.get('ppm', isms_config.get('ppm', 0))
timeout = data.get('timeout', 60)
# Validate band
valid_bands = ['GSM900', 'EGSM900', 'GSM1800', 'GSM850', 'GSM1900']
if band.upper() not in valid_bands:
return jsonify({
'status': 'error',
'message': f'Invalid band. Must be one of: {", ".join(valid_bands)}'
}), 400
gsm_running = True
gsm_thread = threading.Thread(
target=gsm_scan_loop,
args=(band.upper(), gain, ppm, timeout),
daemon=True
)
gsm_thread.start()
return jsonify({
'status': 'started',
'config': {
'band': band.upper(),
'gain': gain,
'timeout': timeout,
}
})
@isms_bp.route('/gsm/scan', methods=['DELETE'])
def stop_gsm_scan():
"""Stop GSM scanning."""
global gsm_running
with gsm_lock:
if not gsm_running:
return jsonify({
'status': 'error',
'message': 'No GSM scan running'
}), 400
gsm_running = False
return jsonify({'status': 'stopping'})
@isms_bp.route('/gsm/status', methods=['GET'])
def get_gsm_status():
"""Get GSM scanner status and detected cells."""
with gsm_lock:
return jsonify({
'running': gsm_running,
'cell_count': len(gsm_detected_cells),
'cells': [format_gsm_cell(c) for c in gsm_detected_cells],
'grgsm_available': get_grgsm_scanner_path() is not None,
})
@isms_bp.route('/gsm/cells', methods=['GET'])
def get_gsm_cells():
"""Get all detected GSM cells from last scan."""
return jsonify({
'cells': [format_gsm_cell(c) for c in gsm_detected_cells],
'count': len(gsm_detected_cells),
})
@isms_bp.route('/gsm/baseline', methods=['POST'])
def set_gsm_baseline():
"""Save current GSM cells as baseline for comparison."""
global gsm_baseline_cells
if not gsm_detected_cells:
return jsonify({
'status': 'error',
'message': 'No GSM cells detected. Run a scan first.'
}), 400
gsm_baseline_cells = gsm_detected_cells.copy()
# Also add to baseline recorder if recording
if baseline_recorder.is_recording:
for cell in gsm_baseline_cells:
baseline_recorder.add_gsm_cell(cell)
return jsonify({
'status': 'saved',
'cell_count': len(gsm_baseline_cells),
'cells': [format_gsm_cell(c) for c in gsm_baseline_cells],
})
@isms_bp.route('/gsm/baseline', methods=['GET'])
def get_gsm_baseline():
"""Get current GSM baseline."""
return jsonify({
'cells': [format_gsm_cell(c) for c in gsm_baseline_cells],
'count': len(gsm_baseline_cells),
})
@isms_bp.route('/gsm/baseline', methods=['DELETE'])
def clear_gsm_baseline():
"""Clear GSM baseline."""
global gsm_baseline_cells
gsm_baseline_cells = []
return jsonify({'status': 'cleared'})

View File

@@ -166,6 +166,14 @@ check_tools() {
echo
info "SoapySDR:"
check_required "SoapySDRUtil" "SoapySDR CLI utility" SoapySDRUtil
echo
info "GSM (optional):"
if have_any grgsm_scanner; then
ok "grgsm_scanner - GSM cell scanner"
else
warn "grgsm_scanner - GSM cell scanner (optional, for ISMS GSM detection)"
fi
echo
}
@@ -352,6 +360,8 @@ install_macos_packages() {
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
info "TSCM BLE scanning uses bleak library (installed via pip) for manufacturer data detection."
warn "macOS note: gr-gsm (for GSM cell scanning) is not easily available on macOS."
info "GSM cell detection in ISMS mode requires Linux with GNU Radio."
echo
}
@@ -448,6 +458,77 @@ install_acarsdec_from_source_debian() {
)
}
install_grgsm_from_source_debian() {
info "Installing gr-gsm for GSM cell scanning (optional)..."
# Check if GNU Radio is available
if ! cmd_exists gnuradio-config-info; then
info "GNU Radio not found. Installing GNU Radio first..."
$SUDO apt-get install -y gnuradio gnuradio-dev >/dev/null 2>&1 || {
warn "Failed to install GNU Radio. gr-gsm requires GNU Radio 3.8+."
warn "GSM scanning will not be available."
return 1
}
fi
# Check GNU Radio version (need 3.8+)
local gr_version
gr_version=$(gnuradio-config-info --version 2>/dev/null || echo "0.0.0")
info "GNU Radio version: $gr_version"
# Install dependencies for gr-gsm
info "Installing gr-gsm dependencies..."
$SUDO apt-get install -y \
cmake \
autoconf \
libtool \
pkg-config \
build-essential \
python3-docutils \
libcppunit-dev \
swig \
doxygen \
liblog4cpp5-dev \
python3-scipy \
gnuradio-dev \
gr-osmosdr \
libosmocore-dev \
libosmocoding-dev \
libosmoctrl-dev \
libosmogsm-dev \
libosmovty-dev \
libosmocodec-dev \
>/dev/null 2>&1 || {
warn "Some gr-gsm dependencies failed to install."
warn "Attempting to continue anyway..."
}
# Run in subshell to isolate EXIT trap
(
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
info "Cloning gr-gsm..."
git clone --depth 1 https://github.com/ptrkrysik/gr-gsm.git "$tmp_dir/gr-gsm" >/dev/null 2>&1 \
|| { warn "Failed to clone gr-gsm"; exit 1; }
cd "$tmp_dir/gr-gsm"
mkdir -p build && cd build
info "Compiling gr-gsm (this may take a few minutes)..."
if cmake .. >/dev/null 2>&1 && make -j$(nproc) >/dev/null 2>&1; then
$SUDO make install >/dev/null 2>&1
$SUDO ldconfig
ok "gr-gsm installed successfully."
info "grgsm_scanner should now be available for GSM cell detection."
else
warn "Failed to build gr-gsm from source."
warn "GSM cell scanning will not be available in ISMS mode."
warn "You can try installing manually from: https://github.com/ptrkrysik/gr-gsm"
fi
)
}
install_rtlsdr_blog_drivers_debian() {
# The RTL-SDR Blog drivers provide better support for:
# - RTL-SDR Blog V4 (R828D tuner)
@@ -547,7 +628,7 @@ install_debian_packages() {
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
TOTAL_STEPS=18
TOTAL_STEPS=19
CURRENT_STEP=0
progress "Updating APT package lists"
@@ -617,6 +698,13 @@ install_debian_packages() {
fi
cmd_exists acarsdec || install_acarsdec_from_source_debian
progress "Installing gr-gsm (optional, for GSM cell detection)"
if ! cmd_exists grgsm_scanner; then
install_grgsm_from_source_debian || true
else
ok "grgsm_scanner already installed"
fi
progress "Configuring udev rules"
setup_udev_rules_debian

View File

@@ -5052,3 +5052,190 @@ body::before {
.preset-freq-btn:active {
transform: scale(0.98);
}
/* =============================================================================
ISMS Listening Station Styles
============================================================================= */
.isms-dashboard {
padding: 16px;
}
.isms-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.isms-panel {
background: var(--bg-card);
border-radius: 8px;
padding: 12px;
border: 1px solid var(--border-color);
}
.isms-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
.isms-info-banner {
margin-bottom: 12px;
padding: 8px 12px;
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px;
font-size: 10px;
color: var(--text-muted);
}
/* ISMS Badges */
.isms-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 9px;
font-weight: bold;
text-transform: uppercase;
}
.isms-badge-high {
background: rgba(255, 51, 102, 0.2);
color: var(--accent-red);
border: 1px solid rgba(255, 51, 102, 0.4);
}
.isms-badge-warn {
background: rgba(255, 170, 0, 0.2);
color: var(--accent-orange);
border: 1px solid rgba(255, 170, 0, 0.4);
}
.isms-badge-info {
background: rgba(0, 212, 255, 0.2);
color: var(--accent-cyan);
border: 1px solid rgba(0, 212, 255, 0.4);
}
/* ISMS Band Meter */
.isms-band-meter {
text-align: center;
min-width: 80px;
}
.isms-band-meter .meter-bar {
height: 60px;
width: 20px;
background: rgba(0, 0, 0, 0.5);
border-radius: 4px;
margin: 0 auto;
position: relative;
overflow: hidden;
}
.isms-band-meter .meter-fill {
position: absolute;
bottom: 0;
width: 100%;
background: linear-gradient(to top, var(--accent-green), var(--accent-cyan), var(--accent-orange));
transition: height 0.3s;
}
.isms-band-meter .meter-value {
font-size: 11px;
margin-top: 4px;
font-family: 'JetBrains Mono', monospace;
}
.isms-band-meter .meter-noise {
font-size: 9px;
color: var(--text-muted);
}
/* ISMS Tower Map */
#ismsTowerMap {
height: 180px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
}
#ismsTowerMap .leaflet-control-attribution {
font-size: 8px;
background: rgba(0, 0, 0, 0.6);
color: var(--text-muted);
}
.isms-user-marker {
background: transparent;
border: none;
}
/* ISMS Finding Item */
.isms-finding-item {
padding: 8px;
border-bottom: 1px solid var(--border-color);
font-size: 11px;
}
.isms-finding-item:last-child {
border-bottom: none;
}
/* ISMS Collapsible Panel */
.isms-panel.collapsible .isms-panel-header.clickable {
cursor: pointer;
padding: 12px;
margin: 0;
}
.isms-panel.collapsible .isms-panel-header.clickable:hover {
background: rgba(0, 212, 255, 0.05);
}
/* ISMS Start Button */
#ismsStartBtn.running {
background: linear-gradient(135deg, #ff3366, #ff6b6b);
border-color: #ff3366;
animation: pulse-glow 2s ease-in-out infinite;
}
/* ISMS Recording Status */
#ismsBaselineRecordingStatus {
background: rgba(255, 100, 100, 0.1);
border-radius: 4px;
padding: 8px;
font-size: 10px;
color: var(--accent-red);
animation: recording-blink 1s ease-in-out infinite;
}
@keyframes recording-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ISMS Mode Content */
#ismsMode.mode-content {
display: none;
}
#ismsMode.mode-content.active {
display: block;
}
/* Responsive ISMS Grid */
@media (max-width: 1024px) {
.isms-grid {
grid-template-columns: repeat(2, 1fr);
}
.isms-panel[style*="grid-column: span 4"] {
grid-column: span 2 !important;
}
}

992
static/js/modes/isms.js Normal file
View File

@@ -0,0 +1,992 @@
/**
* ISMS Listening Station Mode
* Spectrum monitoring, cellular environment, tower mapping
*/
// ============== STATE ==============
let isIsmsScanRunning = false;
let ismsEventSource = null;
let ismsTowerMap = null;
let ismsTowerMarkers = [];
let ismsLocation = { lat: null, lon: null };
let ismsBandMetrics = {};
let ismsFindings = [];
let ismsPeaks = [];
let ismsBaselineRecording = false;
let ismsInitialized = false;
// Finding counts
let ismsFindingCounts = { high: 0, warn: 0, info: 0 };
// GSM scanner state
let isGsmScanRunning = false;
let ismsGsmCells = [];
// ============== INITIALIZATION ==============
function initIsmsMode() {
if (ismsInitialized) return;
// Initialize Leaflet map for towers
initIsmsTowerMap();
// Load baselines
ismsRefreshBaselines();
// Check for GPS
ismsCheckGps();
// Populate SDR devices
ismsPopulateSdrDevices();
// Set up event listeners
setupIsmsEventListeners();
ismsInitialized = true;
console.log('ISMS mode initialized');
}
function initIsmsTowerMap() {
const container = document.getElementById('ismsTowerMap');
if (!container || ismsTowerMap) return;
// Clear placeholder content
container.innerHTML = '';
ismsTowerMap = L.map('ismsTowerMap', {
center: [51.5074, -0.1278],
zoom: 12,
zoomControl: false,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OSM'
}).addTo(ismsTowerMap);
// Add zoom control to bottom right
L.control.zoom({ position: 'bottomright' }).addTo(ismsTowerMap);
}
function setupIsmsEventListeners() {
// Preset change
const presetSelect = document.getElementById('ismsScanPreset');
if (presetSelect) {
presetSelect.addEventListener('change', function() {
const customRange = document.getElementById('ismsCustomRange');
if (customRange) {
customRange.style.display = this.value === 'custom' ? 'block' : 'none';
}
});
}
// Gain slider
const gainSlider = document.getElementById('ismsGain');
if (gainSlider) {
gainSlider.addEventListener('input', function() {
document.getElementById('ismsGainValue').textContent = this.value;
});
}
// Threshold slider
const thresholdSlider = document.getElementById('ismsActivityThreshold');
if (thresholdSlider) {
thresholdSlider.addEventListener('input', function() {
document.getElementById('ismsThresholdValue').textContent = this.value + '%';
});
}
}
async function ismsPopulateSdrDevices() {
try {
const response = await fetch('/devices');
const devices = await response.json();
const select = document.getElementById('ismsSdrDevice');
if (!select) return;
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="0">No devices found</option>';
return;
}
devices.forEach((device, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = `${index}: ${device.name || 'RTL-SDR'}`;
select.appendChild(option);
});
} catch (e) {
console.error('Failed to load SDR devices:', e);
}
}
// ============== GPS ==============
async function ismsCheckGps() {
try {
const response = await fetch('/gps/status');
const data = await response.json();
if (data.connected && data.position) {
ismsLocation.lat = data.position.latitude;
ismsLocation.lon = data.position.longitude;
updateIsmsLocationDisplay();
}
} catch (e) {
console.debug('GPS not available');
}
}
function ismsUseGPS() {
fetch('/gps/status')
.then(r => r.json())
.then(data => {
if (data.connected && data.position) {
ismsLocation.lat = data.position.latitude;
ismsLocation.lon = data.position.longitude;
updateIsmsLocationDisplay();
showNotification('ISMS', 'GPS location acquired');
} else {
showNotification('ISMS', 'GPS not available. Connect GPS first.');
}
})
.catch(() => {
showNotification('ISMS', 'Failed to get GPS position');
});
}
function ismsSetManualLocation() {
const lat = prompt('Enter latitude:', ismsLocation.lat || '51.5074');
if (lat === null) return;
const lon = prompt('Enter longitude:', ismsLocation.lon || '-0.1278');
if (lon === null) return;
ismsLocation.lat = parseFloat(lat);
ismsLocation.lon = parseFloat(lon);
updateIsmsLocationDisplay();
}
function updateIsmsLocationDisplay() {
const coordsEl = document.getElementById('ismsCoords');
const quickLocEl = document.getElementById('ismsQuickLocation');
if (ismsLocation.lat && ismsLocation.lon) {
const text = `${ismsLocation.lat.toFixed(4)}, ${ismsLocation.lon.toFixed(4)}`;
if (coordsEl) coordsEl.textContent = `Lat: ${ismsLocation.lat.toFixed(4)}, Lon: ${ismsLocation.lon.toFixed(4)}`;
if (quickLocEl) quickLocEl.textContent = text;
// Center map on location
if (ismsTowerMap) {
ismsTowerMap.setView([ismsLocation.lat, ismsLocation.lon], 13);
}
}
}
// ============== SCAN CONTROLS ==============
function ismsToggleScan() {
if (isIsmsScanRunning) {
ismsStopScan();
} else {
ismsStartScan();
}
}
async function ismsStartScan() {
const preset = document.getElementById('ismsScanPreset').value;
const device = parseInt(document.getElementById('ismsSdrDevice').value || '0');
const gain = parseInt(document.getElementById('ismsGain').value || '40');
const threshold = parseInt(document.getElementById('ismsActivityThreshold').value || '50');
const baselineId = document.getElementById('ismsBaselineSelect').value || null;
const config = {
preset: preset,
device: device,
gain: gain,
threshold: threshold,
baseline_id: baselineId ? parseInt(baselineId) : null,
};
// Add custom range if selected
if (preset === 'custom') {
config.freq_start = parseFloat(document.getElementById('ismsStartFreq').value);
config.freq_end = parseFloat(document.getElementById('ismsEndFreq').value);
}
// Add location
if (ismsLocation.lat && ismsLocation.lon) {
config.lat = ismsLocation.lat;
config.lon = ismsLocation.lon;
}
try {
const response = await fetch('/isms/start_scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const data = await response.json();
if (data.status === 'started') {
isIsmsScanRunning = true;
updateIsmsUI('scanning');
connectIsmsStream();
// Reset findings
ismsFindingCounts = { high: 0, warn: 0, info: 0 };
ismsFindings = [];
ismsPeaks = [];
updateIsmsFindingsBadges();
} else {
showNotification('ISMS Error', data.message || 'Failed to start scan');
}
} catch (e) {
showNotification('ISMS Error', 'Failed to start scan: ' + e.message);
}
}
async function ismsStopScan() {
try {
await fetch('/isms/stop_scan', { method: 'POST' });
} catch (e) {
console.error('Error stopping scan:', e);
}
isIsmsScanRunning = false;
disconnectIsmsStream();
updateIsmsUI('stopped');
}
function updateIsmsUI(state) {
const startBtn = document.getElementById('ismsStartBtn');
const quickStatus = document.getElementById('ismsQuickStatus');
const scanStatus = document.getElementById('ismsScanStatus');
if (state === 'scanning') {
if (startBtn) {
startBtn.textContent = 'Stop Scan';
startBtn.classList.add('running');
}
if (quickStatus) quickStatus.textContent = 'SCANNING';
if (scanStatus) scanStatus.textContent = 'SCANNING';
// Update quick band display
const presetSelect = document.getElementById('ismsScanPreset');
const quickBand = document.getElementById('ismsQuickBand');
if (presetSelect && quickBand) {
quickBand.textContent = presetSelect.options[presetSelect.selectedIndex].text;
}
} else {
if (startBtn) {
startBtn.textContent = 'Start Scan';
startBtn.classList.remove('running');
}
if (quickStatus) quickStatus.textContent = 'IDLE';
if (scanStatus) scanStatus.textContent = 'IDLE';
}
}
// ============== SSE STREAM ==============
function connectIsmsStream() {
if (ismsEventSource) {
ismsEventSource.close();
}
ismsEventSource = new EventSource('/isms/stream');
ismsEventSource.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
handleIsmsEvent(data);
} catch (e) {
console.error('Failed to parse ISMS event:', e);
}
};
ismsEventSource.onerror = function() {
console.error('ISMS stream error');
};
}
function disconnectIsmsStream() {
if (ismsEventSource) {
ismsEventSource.close();
ismsEventSource = null;
}
}
function handleIsmsEvent(data) {
switch (data.type) {
case 'meter':
updateIsmsBandMeter(data.band, data.level, data.noise_floor);
break;
case 'spectrum_peak':
addIsmsPeak(data);
break;
case 'finding':
addIsmsFinding(data);
break;
case 'status':
updateIsmsStatus(data);
break;
case 'gsm_cell':
handleGsmCell(data.cell);
break;
case 'gsm_scan_complete':
handleGsmScanComplete(data);
break;
case 'gsm_scanning':
case 'gsm_stopped':
case 'gsm_error':
handleGsmStatus(data);
break;
case 'keepalive':
// Ignore
break;
default:
console.debug('Unknown ISMS event:', data.type);
}
}
// ============== BAND METERS ==============
function updateIsmsBandMeter(band, level, noiseFloor) {
ismsBandMetrics[band] = { level, noiseFloor };
const container = document.getElementById('ismsBandMeters');
if (!container) return;
// Find or create meter for this band
let meter = container.querySelector(`[data-band="${band}"]`);
if (!meter) {
// Clear placeholder if first meter
if (container.querySelector('div:not([data-band])')) {
container.innerHTML = '';
}
meter = document.createElement('div');
meter.setAttribute('data-band', band);
meter.className = 'isms-band-meter';
meter.style.cssText = 'text-align: center; min-width: 80px;';
meter.innerHTML = `
<div style="font-size: 9px; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">${band}</div>
<div class="meter-bar" style="height: 60px; width: 20px; background: rgba(0,0,0,0.5); border-radius: 4px; margin: 0 auto; position: relative; overflow: hidden;">
<div class="meter-fill" style="position: absolute; bottom: 0; width: 100%; background: linear-gradient(to top, var(--accent-green), var(--accent-cyan), var(--accent-orange)); transition: height 0.3s;"></div>
</div>
<div class="meter-value" style="font-size: 11px; margin-top: 4px; font-family: 'JetBrains Mono', monospace;">${level.toFixed(0)}%</div>
<div class="meter-noise" style="font-size: 9px; color: var(--text-muted);">${noiseFloor.toFixed(1)} dB</div>
`;
container.appendChild(meter);
}
// Update meter values
const fill = meter.querySelector('.meter-fill');
const value = meter.querySelector('.meter-value');
const noise = meter.querySelector('.meter-noise');
if (fill) fill.style.height = level + '%';
if (value) value.textContent = level.toFixed(0) + '%';
if (noise) noise.textContent = noiseFloor.toFixed(1) + ' dB';
}
// ============== PEAKS ==============
function addIsmsPeak(data) {
// Add to peaks array (keep last 20)
ismsPeaks.unshift({
freq: data.freq_mhz,
power: data.power_db,
band: data.band,
timestamp: new Date()
});
if (ismsPeaks.length > 20) {
ismsPeaks.pop();
}
updateIsmsPeaksList();
}
function updateIsmsPeaksList() {
const tbody = document.getElementById('ismsPeaksBody');
const countEl = document.getElementById('ismsPeakCount');
if (!tbody) return;
if (ismsPeaks.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center; padding: 20px; color: var(--text-muted);">No peaks detected</td></tr>';
if (countEl) countEl.textContent = '0';
return;
}
tbody.innerHTML = ismsPeaks.map(peak => `
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 4px 8px; font-family: 'JetBrains Mono', monospace;">${peak.freq.toFixed(3)} MHz</td>
<td style="padding: 4px 8px; text-align: right; color: ${peak.power > -50 ? 'var(--accent-green)' : 'var(--text-muted)'};">${peak.power.toFixed(1)} dB</td>
<td style="padding: 4px 8px; color: var(--text-muted);">${peak.band || '--'}</td>
</tr>
`).join('');
if (countEl) countEl.textContent = ismsPeaks.length;
}
// ============== FINDINGS ==============
function addIsmsFinding(data) {
const finding = {
severity: data.severity,
text: data.text,
details: data.details,
timestamp: data.timestamp || new Date().toISOString()
};
ismsFindings.unshift(finding);
// Update counts
if (data.severity === 'high') ismsFindingCounts.high++;
else if (data.severity === 'warn') ismsFindingCounts.warn++;
else ismsFindingCounts.info++;
updateIsmsFindingsBadges();
updateIsmsFindingsTimeline();
// Update quick findings count
const quickFindings = document.getElementById('ismsQuickFindings');
if (quickFindings) {
quickFindings.textContent = ismsFindings.length;
quickFindings.style.color = ismsFindingCounts.high > 0 ? 'var(--accent-red)' :
ismsFindingCounts.warn > 0 ? 'var(--accent-orange)' : 'var(--accent-green)';
}
}
function updateIsmsFindingsBadges() {
const highBadge = document.getElementById('ismsFindingsHigh');
const warnBadge = document.getElementById('ismsFindingsWarn');
const infoBadge = document.getElementById('ismsFindingsInfo');
if (highBadge) {
highBadge.textContent = ismsFindingCounts.high + ' HIGH';
highBadge.style.display = ismsFindingCounts.high > 0 ? 'inline-block' : 'none';
}
if (warnBadge) {
warnBadge.textContent = ismsFindingCounts.warn + ' WARN';
warnBadge.style.display = ismsFindingCounts.warn > 0 ? 'inline-block' : 'none';
}
if (infoBadge) {
infoBadge.textContent = ismsFindingCounts.info + ' INFO';
}
}
function updateIsmsFindingsTimeline() {
const timeline = document.getElementById('ismsFindingsTimeline');
if (!timeline) return;
if (ismsFindings.length === 0) {
timeline.innerHTML = `
<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>
`;
return;
}
timeline.innerHTML = ismsFindings.slice(0, 50).map(finding => {
const severityColor = finding.severity === 'high' ? 'var(--accent-red)' :
finding.severity === 'warn' ? 'var(--accent-orange)' : 'var(--accent-cyan)';
const time = new Date(finding.timestamp).toLocaleTimeString();
return `
<div class="isms-finding-item" style="padding: 8px; border-bottom: 1px solid var(--border-color); font-size: 11px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: ${severityColor}; font-weight: bold; text-transform: uppercase;">${finding.severity}</span>
<span style="color: var(--text-muted);">${time}</span>
</div>
<div style="color: var(--text-primary);">${finding.text}</div>
</div>
`;
}).join('');
}
// ============== STATUS ==============
function updateIsmsStatus(data) {
if (data.state === 'stopped' || data.state === 'error') {
isIsmsScanRunning = false;
updateIsmsUI('stopped');
if (data.state === 'error') {
showNotification('ISMS Error', data.message || 'Scan error');
}
}
}
// ============== TOWERS ==============
async function ismsRefreshTowers() {
if (!ismsLocation.lat || !ismsLocation.lon) {
showNotification('ISMS', 'Set location first to query towers');
return;
}
const towerCountEl = document.getElementById('ismsTowerCount');
if (towerCountEl) towerCountEl.textContent = 'Querying...';
try {
const response = await fetch(`/isms/towers?lat=${ismsLocation.lat}&lon=${ismsLocation.lon}&radius=5`);
const data = await response.json();
if (data.status === 'error') {
if (towerCountEl) towerCountEl.textContent = data.message;
if (data.config_required) {
showNotification('ISMS', 'OpenCelliD token required. Set OPENCELLID_TOKEN environment variable.');
}
return;
}
updateIsmsTowerMap(data.towers);
updateIsmsTowerList(data.towers);
if (towerCountEl) towerCountEl.textContent = `${data.count} towers found`;
} catch (e) {
console.error('Failed to query towers:', e);
if (towerCountEl) towerCountEl.textContent = 'Query failed';
}
}
function updateIsmsTowerMap(towers) {
if (!ismsTowerMap) return;
// Clear existing markers
ismsTowerMarkers.forEach(marker => marker.remove());
ismsTowerMarkers = [];
// Add tower markers
towers.forEach(tower => {
const marker = L.circleMarker([tower.lat, tower.lon], {
radius: 6,
fillColor: getTowerColor(tower.radio),
color: '#fff',
weight: 1,
opacity: 1,
fillOpacity: 0.8
});
marker.bindPopup(`
<div style="font-size: 11px;">
<strong>${tower.operator}</strong><br>
${tower.radio} - CID: ${tower.cellid}<br>
Distance: ${tower.distance_km} km<br>
<a href="${tower.cellmapper_url}" target="_blank" rel="noopener">CellMapper</a>
</div>
`);
marker.addTo(ismsTowerMap);
ismsTowerMarkers.push(marker);
});
// Add user location marker
if (ismsLocation.lat && ismsLocation.lon) {
const userMarker = L.marker([ismsLocation.lat, ismsLocation.lon], {
icon: L.divIcon({
className: 'isms-user-marker',
html: '<div style="background: var(--accent-cyan); width: 12px; height: 12px; border-radius: 50%; border: 2px solid #fff;"></div>',
iconSize: [16, 16],
iconAnchor: [8, 8]
})
});
userMarker.addTo(ismsTowerMap);
ismsTowerMarkers.push(userMarker);
}
// Fit map to markers if we have towers
if (towers.length > 0 && ismsTowerMarkers.length > 0) {
const group = L.featureGroup(ismsTowerMarkers);
ismsTowerMap.fitBounds(group.getBounds().pad(0.1));
}
}
function getTowerColor(radio) {
switch (radio) {
case 'LTE': return '#00d4ff';
case 'NR': return '#ff00ff';
case 'UMTS': return '#00ff88';
case 'GSM': return '#ffaa00';
default: return '#888';
}
}
function updateIsmsTowerList(towers) {
const list = document.getElementById('ismsTowerList');
if (!list) return;
if (towers.length === 0) {
list.innerHTML = '<div style="color: var(--text-muted); padding: 8px;">No towers found</div>';
return;
}
list.innerHTML = towers.slice(0, 10).map(tower => `
<div style="padding: 4px 0; border-bottom: 1px solid var(--border-color);">
<span style="color: ${getTowerColor(tower.radio)};">${tower.radio}</span>
<span style="color: var(--text-primary);">${tower.operator}</span>
<span style="color: var(--text-muted); float: right;">${tower.distance_km} km</span>
</div>
`).join('');
}
// ============== BASELINES ==============
async function ismsRefreshBaselines() {
try {
const response = await fetch('/isms/baselines');
const data = await response.json();
const select = document.getElementById('ismsBaselineSelect');
if (!select) return;
// Keep the "No Baseline" option
select.innerHTML = '<option value="">No Baseline (Compare Disabled)</option>';
data.baselines.forEach(baseline => {
const option = document.createElement('option');
option.value = baseline.id;
option.textContent = `${baseline.name}${baseline.is_active ? ' (Active)' : ''}`;
if (baseline.is_active) option.selected = true;
select.appendChild(option);
});
} catch (e) {
console.error('Failed to load baselines:', e);
}
}
function ismsToggleBaselineRecording() {
if (ismsBaselineRecording) {
ismsStopBaselineRecording();
} else {
ismsStartBaselineRecording();
}
}
async function ismsStartBaselineRecording() {
try {
const response = await fetch('/isms/baseline/record/start', { method: 'POST' });
const data = await response.json();
if (data.status === 'recording_started') {
ismsBaselineRecording = true;
const btn = document.getElementById('ismsRecordBaselineBtn');
const status = document.getElementById('ismsBaselineRecordingStatus');
if (btn) {
btn.textContent = 'Stop Recording';
btn.style.background = 'var(--accent-red)';
}
if (status) status.style.display = 'block';
showNotification('ISMS', 'Baseline recording started');
}
} catch (e) {
showNotification('ISMS Error', 'Failed to start recording');
}
}
async function ismsStopBaselineRecording() {
const name = prompt('Enter baseline name:', `Baseline ${new Date().toLocaleDateString()}`);
if (!name) return;
try {
const response = await fetch('/isms/baseline/record/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name,
latitude: ismsLocation.lat,
longitude: ismsLocation.lon
})
});
const data = await response.json();
if (data.status === 'saved') {
ismsBaselineRecording = false;
const btn = document.getElementById('ismsRecordBaselineBtn');
const status = document.getElementById('ismsBaselineRecordingStatus');
if (btn) {
btn.textContent = 'Record New';
btn.style.background = '';
}
if (status) status.style.display = 'none';
showNotification('ISMS', `Baseline saved: ${data.summary.bands} bands, ${data.summary.towers} towers`);
ismsRefreshBaselines();
}
} catch (e) {
showNotification('ISMS Error', 'Failed to save baseline');
}
}
// ============== BASELINE PANEL ==============
function ismsToggleBaselinePanel() {
const content = document.getElementById('ismsBaselineCompare');
const icon = document.getElementById('ismsBaselinePanelIcon');
if (content && icon) {
const isVisible = content.style.display !== 'none';
content.style.display = isVisible ? 'none' : 'block';
icon.textContent = isVisible ? '▶' : '▼';
}
}
// ============== UTILITY ==============
function showNotification(title, message) {
// Use existing notification system if available
if (typeof window.showNotification === 'function') {
window.showNotification(title, message);
} else {
console.log(`[${title}] ${message}`);
}
}
// ============== GSM SCANNING ==============
function ismsToggleGsmScan() {
if (isGsmScanRunning) {
ismsStopGsmScan();
} else {
ismsStartGsmScan();
}
}
async function ismsStartGsmScan() {
const band = document.getElementById('ismsGsmBand').value;
const gain = parseInt(document.getElementById('ismsGain').value || '40');
const config = {
band: band,
gain: gain,
timeout: 60
};
try {
const response = await fetch('/isms/gsm/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const data = await response.json();
if (data.status === 'started') {
isGsmScanRunning = true;
ismsGsmCells = [];
updateGsmScanUI('scanning');
// Connect to SSE stream if not already connected
if (!ismsEventSource) {
connectIsmsStream();
}
showNotification('ISMS', `GSM scan started on ${band}`);
} else {
showNotification('ISMS Error', data.message || 'Failed to start GSM scan');
}
} catch (e) {
showNotification('ISMS Error', 'Failed to start GSM scan: ' + e.message);
}
}
async function ismsStopGsmScan() {
try {
await fetch('/isms/gsm/scan', { method: 'DELETE' });
} catch (e) {
console.error('Error stopping GSM scan:', e);
}
isGsmScanRunning = false;
updateGsmScanUI('stopped');
}
function updateGsmScanUI(state) {
const btn = document.getElementById('ismsGsmScanBtn');
const statusText = document.getElementById('ismsGsmStatusText');
if (state === 'scanning') {
if (btn) {
btn.textContent = 'Stop Scan';
btn.style.background = 'var(--accent-red)';
}
if (statusText) {
statusText.textContent = 'Scanning...';
statusText.style.color = 'var(--accent-orange)';
}
} else {
if (btn) {
btn.textContent = 'Scan GSM Cells';
btn.style.background = '';
}
if (statusText) {
statusText.textContent = 'Ready';
statusText.style.color = 'var(--accent-cyan)';
}
}
}
function handleGsmCell(cell) {
// Check if we already have this ARFCN
const existing = ismsGsmCells.find(c => c.arfcn === cell.arfcn);
if (existing) {
// Update if stronger signal
if (cell.power_dbm > existing.power_dbm) {
Object.assign(existing, cell);
}
} else {
ismsGsmCells.push(cell);
}
// Update count display
const countEl = document.getElementById('ismsGsmCellCount');
if (countEl) {
countEl.textContent = ismsGsmCells.length;
}
// Update cells list
updateGsmCellsList();
}
function handleGsmScanComplete(data) {
isGsmScanRunning = false;
updateGsmScanUI('stopped');
// Update with final cell list
if (data.cells) {
ismsGsmCells = data.cells;
updateGsmCellsList();
}
const countEl = document.getElementById('ismsGsmCellCount');
if (countEl) {
countEl.textContent = data.cell_count || ismsGsmCells.length;
}
showNotification('ISMS', `GSM scan complete: ${data.cell_count} cells found`);
}
function handleGsmStatus(data) {
const statusText = document.getElementById('ismsGsmStatusText');
if (data.type === 'gsm_scanning') {
if (statusText) {
statusText.textContent = `Scanning ${data.band || 'GSM'}...`;
statusText.style.color = 'var(--accent-orange)';
}
} else if (data.type === 'gsm_stopped') {
isGsmScanRunning = false;
updateGsmScanUI('stopped');
if (statusText) {
statusText.textContent = `Found ${data.cell_count || 0} cells`;
statusText.style.color = 'var(--accent-green)';
}
} else if (data.type === 'gsm_error') {
isGsmScanRunning = false;
updateGsmScanUI('stopped');
if (statusText) {
statusText.textContent = 'Error';
statusText.style.color = 'var(--accent-red)';
}
showNotification('ISMS Error', data.message || 'GSM scan error');
}
}
function updateGsmCellsList() {
const container = document.getElementById('ismsGsmCells');
if (!container) return;
if (ismsGsmCells.length === 0) {
container.innerHTML = '<div style="color: var(--text-muted); padding: 4px;">No cells detected</div>';
return;
}
// Sort by signal strength
const sortedCells = [...ismsGsmCells].sort((a, b) => b.power_dbm - a.power_dbm);
container.innerHTML = sortedCells.map(cell => {
const signalColor = cell.power_dbm > -70 ? 'var(--accent-green)' :
cell.power_dbm > -85 ? 'var(--accent-orange)' : 'var(--text-muted)';
const operator = cell.plmn ? getOperatorName(cell.plmn) : '--';
return `
<div style="padding: 4px 0; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between;">
<span>ARFCN ${cell.arfcn}</span>
<span style="color: ${signalColor};">${cell.power_dbm.toFixed(0)} dBm</span>
</div>
<div style="color: var(--text-muted); font-size: 9px;">
${cell.freq_mhz.toFixed(1)} MHz | ${operator}
${cell.cell_id ? ` | CID: ${cell.cell_id}` : ''}
</div>
</div>
`;
}).join('');
}
function getOperatorName(plmn) {
// UK operators
const operators = {
'234-10': 'O2',
'234-15': 'Vodafone',
'234-20': 'Three',
'234-30': 'EE',
'234-31': 'EE',
'234-32': 'EE',
'234-33': 'EE',
};
return operators[plmn] || plmn;
}
async function ismsSetGsmBaseline() {
if (ismsGsmCells.length === 0) {
showNotification('ISMS', 'No GSM cells to save. Run a scan first.');
return;
}
try {
const response = await fetch('/isms/gsm/baseline', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.status === 'saved') {
showNotification('ISMS', `GSM baseline saved: ${data.cell_count} cells`);
} else {
showNotification('ISMS Error', data.message || 'Failed to save baseline');
}
} catch (e) {
showNotification('ISMS Error', 'Failed to save GSM baseline');
}
}
// Export for global access
window.initIsmsMode = initIsmsMode;
window.ismsToggleScan = ismsToggleScan;
window.ismsRefreshTowers = ismsRefreshTowers;
window.ismsUseGPS = ismsUseGPS;
window.ismsSetManualLocation = ismsSetManualLocation;
window.ismsRefreshBaselines = ismsRefreshBaselines;
window.ismsToggleBaselineRecording = ismsToggleBaselineRecording;
window.ismsToggleBaselinePanel = ismsToggleBaselinePanel;
window.ismsToggleGsmScan = ismsToggleGsmScan;
window.ismsSetGsmBaseline = ismsSetGsmBaseline;

View File

@@ -385,6 +385,7 @@
</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">
@@ -403,6 +404,7 @@
<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>
@@ -501,6 +503,8 @@
{% 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>
@@ -1112,6 +1116,113 @@
</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;">
@@ -1185,6 +1296,7 @@
<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
@@ -1532,7 +1644,7 @@
'pager': 'sdr', 'sensor': 'sdr',
'aprs': 'sdr', 'satellite': 'sdr', 'listening': 'sdr',
'wifi': 'wireless', 'bluetooth': 'wireless',
'tscm': 'security'
'tscm': 'security', 'isms': 'security'
};
// Remove has-active from all dropdowns
@@ -1590,6 +1702,7 @@
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';
@@ -1615,7 +1728,8 @@
'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST',
'aprs': 'APRS',
'tscm': 'TSCM'
'tscm': 'TSCM',
'isms': 'ISMS STATION'
};
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
@@ -1624,6 +1738,12 @@
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 = {
@@ -1634,7 +1754,8 @@
'bluetooth': 'Bluetooth Scanner',
'listening': 'Listening Post',
'aprs': 'APRS Tracker',
'tscm': 'TSCM Counter-Surveillance'
'tscm': 'TSCM Counter-Surveillance',
'isms': 'ISMS Listening Station'
};
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';

View File

@@ -0,0 +1,169 @@
<!-- 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;">&#8635;</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;">&#128190;</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>

View File

@@ -194,6 +194,76 @@ 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")
@@ -793,3 +863,354 @@ 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

79
utils/isms/__init__.py Normal file
View File

@@ -0,0 +1,79 @@
"""
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',
]

533
utils/isms/baseline.py Normal file
View File

@@ -0,0 +1,533 @@
"""
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'),
)

529
utils/isms/gsm.py Normal file
View File

@@ -0,0 +1,529 @@
"""
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

401
utils/isms/rules.py Normal file
View File

@@ -0,0 +1,401 @@
"""
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())

416
utils/isms/spectrum.py Normal file
View File

@@ -0,0 +1,416 @@
"""
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

340
utils/isms/towers.py Normal file
View File

@@ -0,0 +1,340 @@
"""
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(),
}