mirror of
https://github.com/smittix/intercept.git
synced 2026-05-26 17:54:46 -07:00
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:
@@ -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
79
utils/isms/__init__.py
Normal 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
533
utils/isms/baseline.py
Normal 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
529
utils/isms/gsm.py
Normal 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
401
utils/isms/rules.py
Normal 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
416
utils/isms/spectrum.py
Normal 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
340
utils/isms/towers.py
Normal 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(),
|
||||
}
|
||||
Reference in New Issue
Block a user