diff --git a/CLAUDE.md b/CLAUDE.md index d489373..dd9bd6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,3 +187,56 @@ Each signal type has its own Flask blueprint: ## Testing Notes Tests use pytest with extensive mocking of external tools. Key fixtures in `tests/conftest.py`. Mock subprocess calls when testing decoder integration. + +### Think Before Coding + +Don't assume. Don't hide confusion. Surface tradeoffs. + +Before implementing: + +State your assumptions explicitly. If uncertain, ask. +If multiple interpretations exist, present them - don't pick silently. +If a simpler approach exists, say so. Push back when warranted. +If something is unclear, stop. Name what's confusing. Ask. +2. Simplicity First + +Minimum code that solves the problem. Nothing speculative. + +No features beyond what was asked. +No abstractions for single-use code. +No "flexibility" or "configurability" that wasn't requested. +No error handling for impossible scenarios. +If you write 200 lines and it could be 50, rewrite it. +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +### Surgical Changes + +Touch only what you must. Clean up only your own mess. + +When editing existing code: + +Don't "improve" adjacent code, comments, or formatting. +Don't refactor things that aren't broken. +Match existing style, even if you'd do it differently. +If you notice unrelated dead code, mention it - don't delete it. +When your changes create orphans: + +Remove imports/variables/functions that YOUR changes made unused. +Don't remove pre-existing dead code unless asked. +The test: Every changed line should trace directly to the user's request. + +### Goal-Driven Execution + +Define success criteria. Loop until verified. + +Transform tasks into verifiable goals: + +"Add validation" → "Write tests for invalid inputs, then make them pass" +"Fix the bug" → "Write a test that reproduces it, then make it pass" +"Refactor X" → "Ensure tests pass before and after" +For multi-step tasks, state a brief plan: + +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] + diff --git a/bin/import_artemis.py b/bin/import_artemis.py index 832727a..2665d26 100755 --- a/bin/import_artemis.py +++ b/bin/import_artemis.py @@ -38,9 +38,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent SIGNALS_JSON = REPO_ROOT / "data" / "signals.json" -RELEASE_INFO_URL = ( - "https://raw.githubusercontent.com/AresValley/Artemis/master/config/release-info.json" -) +RELEASE_INFO_URL = "https://raw.githubusercontent.com/AresValley/Artemis/master/config/release-info.json" _LOCATION_MAP: dict[str, str] = { "worldwide": "GLOBAL", @@ -65,6 +63,7 @@ _LOCATION_MAP: dict[str, str] = { # Download helpers # --------------------------------------------------------------------------- + def _fetch_json(url: str) -> dict: with urllib.request.urlopen(url, timeout=15) as r: return json.loads(r.read()) @@ -97,18 +96,14 @@ def _download_db(dest_dir: Path) -> Path: extract_dir.mkdir() with tarfile.open(tar_path) as tf: # Safety: strip any absolute paths or '..' traversal - members = [ - m for m in tf.getmembers() - if not os.path.isabs(m.name) and ".." not in m.name - ] + members = [m for m in tf.getmembers() if not os.path.isabs(m.name) and ".." not in m.name] tf.extractall(extract_dir, members=members) # Find data.sqlite anywhere in the extracted tree matches = list(extract_dir.rglob("data.sqlite")) if not matches: raise FileNotFoundError( - f"No data.sqlite found after extracting {tar_path}. " - "The Artemis-DB tar structure may have changed." + f"No data.sqlite found after extracting {tar_path}. The Artemis-DB tar structure may have changed." ) return matches[0] @@ -118,6 +113,7 @@ def _download_db(dest_dir: Path) -> Path: # Discovery (for users who have Artemis installed) # --------------------------------------------------------------------------- + def _find_installed_db() -> Path | None: home = Path.home() candidates = [ @@ -140,6 +136,7 @@ def _find_installed_db() -> Path | None: # Conversion helpers # --------------------------------------------------------------------------- + def _slugify(name: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") return slug[:60] @@ -206,6 +203,7 @@ def _bandwidth_range(bw_values: list[int]) -> dict[str, int] | None: # Database loading # --------------------------------------------------------------------------- + def load_artemis(db_path: Path, limit: int = 0, existing_ids: set[str] | None = None) -> list[dict]: """Read signals from an Artemis data.sqlite and return them in Intercept's schema.""" with closing(sqlite3.connect(str(db_path))) as conn: @@ -281,17 +279,19 @@ def load_artemis(db_path: Path, limit: int = 0, existing_ids: set[str] | None = slug = _unique_slug(_slugify(name.strip()), used_ids) used_ids.add(slug) - results.append({ - "id": slug, - "name": name.strip(), - "description": (description or "").strip(), - "categories": cats[sig_id], - "frequency_ranges": freq_ranges, - "bandwidth_range": _bandwidth_range(bws[sig_id]), - "modulations": mods[sig_id], - "regions": locs[sig_id] or ["GLOBAL"], - "sigidwiki_url": sigidwiki_url, - }) + results.append( + { + "id": slug, + "name": name.strip(), + "description": (description or "").strip(), + "categories": cats[sig_id], + "frequency_ranges": freq_ranges, + "bandwidth_range": _bandwidth_range(bws[sig_id]), + "modulations": mods[sig_id], + "regions": locs[sig_id] or ["GLOBAL"], + "sigidwiki_url": sigidwiki_url, + } + ) if skipped_no_freq: print(f" Skipped {skipped_no_freq} signals with no frequency data") @@ -302,6 +302,7 @@ def load_artemis(db_path: Path, limit: int = 0, existing_ids: set[str] | None = # Merge # --------------------------------------------------------------------------- + def merge(existing: list[dict], imported: list[dict]) -> tuple[list[dict], int, int]: existing_names = {s["name"].lower() for s in existing} merged = list(existing) @@ -320,6 +321,7 @@ def merge(existing: list[dict], imported: list[dict]) -> tuple[list[dict], int, # Main # --------------------------------------------------------------------------- + def main() -> None: parser = argparse.ArgumentParser( description="Import Artemis signal database into data/signals.json", @@ -327,23 +329,30 @@ def main() -> None: epilog=__doc__, ) parser.add_argument( - "db_path", nargs="?", + "db_path", + nargs="?", help="Path to data.sqlite (omit to auto-detect installed Artemis DB)", ) parser.add_argument( - "--download", action="store_true", + "--download", + action="store_true", help="Download the latest Artemis-DB tar (~290 MB) and import automatically", ) parser.add_argument( - "--dry-run", action="store_true", + "--dry-run", + action="store_true", help="Show import stats without writing data/signals.json", ) parser.add_argument( - "--no-merge", action="store_true", + "--no-merge", + action="store_true", help="Replace data/signals.json entirely instead of merging", ) parser.add_argument( - "--limit", type=int, default=0, metavar="N", + "--limit", + type=int, + default=0, + metavar="N", help="Only process the first N signals (for testing)", ) args = parser.parse_args() diff --git a/data/patterns.py b/data/patterns.py index b427c44..c632cc7 100644 --- a/data/patterns.py +++ b/data/patterns.py @@ -1,39 +1,83 @@ # Detection patterns for various device types # Known beacon prefixes for tracker detection -AIRTAG_PREFIXES = ['4C:00'] # Apple continuity -TILE_PREFIXES = ['C4:E7', 'DC:54', 'E4:B0', 'F8:8A'] -SAMSUNG_TRACKER = ['58:4D', 'A0:75'] +AIRTAG_PREFIXES = ["4C:00"] # Apple continuity +TILE_PREFIXES = ["C4:E7", "DC:54", "E4:B0", "F8:8A"] +SAMSUNG_TRACKER = ["58:4D", "A0:75"] # Drone detection patterns (SSID patterns) DRONE_SSID_PATTERNS = [ # DJI - 'DJI-', 'DJI_', 'Mavic', 'Phantom', 'Spark-', 'Mini-', 'Air-', 'Inspire', - 'Matrice', 'Avata', 'FPV-', 'Osmo', 'RoboMaster', 'Tello', + "DJI-", + "DJI_", + "Mavic", + "Phantom", + "Spark-", + "Mini-", + "Air-", + "Inspire", + "Matrice", + "Avata", + "FPV-", + "Osmo", + "RoboMaster", + "Tello", # Parrot - 'Parrot', 'Bebop', 'Anafi', 'Disco-', 'Mambo', 'Swing', + "Parrot", + "Bebop", + "Anafi", + "Disco-", + "Mambo", + "Swing", # Autel - 'Autel', 'EVO-', 'Dragonfish', 'Lite+', 'Nano', + "Autel", + "EVO-", + "Dragonfish", + "Lite+", + "Nano", # Skydio - 'Skydio', + "Skydio", # Other brands - 'Holy Stone', 'Potensic', 'SYMA', 'Hubsan', 'Eachine', 'FIMI', - 'Xiaomi_FIMI', 'Yuneec', 'Typhoon', 'PowerVision', 'PowerEgg', + "Holy Stone", + "Potensic", + "SYMA", + "Hubsan", + "Eachine", + "FIMI", + "Xiaomi_FIMI", + "Yuneec", + "Typhoon", + "PowerVision", + "PowerEgg", # Generic drone patterns - 'Drone', 'UAV-', 'Quadcopter', 'FPV_', 'RC-Drone' + "Drone", + "UAV-", + "Quadcopter", + "FPV_", + "RC-Drone", ] # Drone OUI prefixes (MAC address prefixes for drone manufacturers) DRONE_OUI_PREFIXES = { # DJI - '60:60:1F': 'DJI', '48:1C:B9': 'DJI', '34:D2:62': 'DJI', 'E0:DB:55': 'DJI', - 'C8:6C:87': 'DJI', 'A0:14:3D': 'DJI', '70:D7:11': 'DJI', '98:3A:56': 'DJI', + "60:60:1F": "DJI", + "48:1C:B9": "DJI", + "34:D2:62": "DJI", + "E0:DB:55": "DJI", + "C8:6C:87": "DJI", + "A0:14:3D": "DJI", + "70:D7:11": "DJI", + "98:3A:56": "DJI", # Parrot - '90:03:B7': 'Parrot', 'A0:14:3D': 'Parrot', '00:12:1C': 'Parrot', '00:26:7E': 'Parrot', + "90:03:B7": "Parrot", + "A0:14:3D": "Parrot", + "00:12:1C": "Parrot", + "00:26:7E": "Parrot", # Autel - '8C:F5:A3': 'Autel', 'D8:E0:E1': 'Autel', + "8C:F5:A3": "Autel", + "D8:E0:E1": "Autel", # Yuneec - '60:60:1F': 'Yuneec', + "60:60:1F": "Yuneec", # Skydio - 'F8:0F:6F': 'Skydio', + "F8:0F:6F": "Skydio", } diff --git a/data/tscm_frequencies.py b/data/tscm_frequencies.py index 75be955..fc43036 100644 --- a/data/tscm_frequencies.py +++ b/data/tscm_frequencies.py @@ -12,56 +12,51 @@ from __future__ import annotations # ============================================================================= SURVEILLANCE_FREQUENCIES = { - 'wireless_mics': [ - {'start': 49.0, 'end': 50.0, 'name': '49 MHz Wireless Mics', 'risk': 'medium'}, - {'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Mics', 'risk': 'medium'}, - {'start': 170.0, 'end': 216.0, 'name': 'VHF High Band Wireless', 'risk': 'medium'}, - {'start': 470.0, 'end': 698.0, 'name': 'UHF TV Band Wireless', 'risk': 'medium'}, - {'start': 902.0, 'end': 928.0, 'name': '900 MHz ISM Wireless', 'risk': 'high'}, - {'start': 1880.0, 'end': 1920.0, 'name': 'DECT Wireless', 'risk': 'high'}, + "wireless_mics": [ + {"start": 49.0, "end": 50.0, "name": "49 MHz Wireless Mics", "risk": "medium"}, + {"start": 72.0, "end": 76.0, "name": "VHF Low Band Mics", "risk": "medium"}, + {"start": 170.0, "end": 216.0, "name": "VHF High Band Wireless", "risk": "medium"}, + {"start": 470.0, "end": 698.0, "name": "UHF TV Band Wireless", "risk": "medium"}, + {"start": 902.0, "end": 928.0, "name": "900 MHz ISM Wireless", "risk": "high"}, + {"start": 1880.0, "end": 1920.0, "name": "DECT Wireless", "risk": "high"}, ], - - 'wireless_cameras': [ - {'start': 900.0, 'end': 930.0, 'name': '900 MHz Video TX', 'risk': 'high'}, - {'start': 1200.0, 'end': 1300.0, 'name': '1.2 GHz Video', 'risk': 'high'}, - {'start': 2400.0, 'end': 2483.5, 'name': '2.4 GHz WiFi Cameras', 'risk': 'high'}, - {'start': 5150.0, 'end': 5850.0, 'name': '5.8 GHz Video', 'risk': 'high'}, + "wireless_cameras": [ + {"start": 900.0, "end": 930.0, "name": "900 MHz Video TX", "risk": "high"}, + {"start": 1200.0, "end": 1300.0, "name": "1.2 GHz Video", "risk": "high"}, + {"start": 2400.0, "end": 2483.5, "name": "2.4 GHz WiFi Cameras", "risk": "high"}, + {"start": 5150.0, "end": 5850.0, "name": "5.8 GHz Video", "risk": "high"}, ], - - 'gps_trackers': [ - {'start': 824.0, 'end': 849.0, 'name': 'Cellular 850 Uplink', 'risk': 'high'}, - {'start': 869.0, 'end': 894.0, 'name': 'Cellular 850 Downlink', 'risk': 'high'}, - {'start': 1710.0, 'end': 1755.0, 'name': 'AWS Uplink', 'risk': 'high'}, - {'start': 1850.0, 'end': 1910.0, 'name': 'PCS Uplink', 'risk': 'high'}, - {'start': 1930.0, 'end': 1990.0, 'name': 'PCS Downlink', 'risk': 'high'}, + "gps_trackers": [ + {"start": 824.0, "end": 849.0, "name": "Cellular 850 Uplink", "risk": "high"}, + {"start": 869.0, "end": 894.0, "name": "Cellular 850 Downlink", "risk": "high"}, + {"start": 1710.0, "end": 1755.0, "name": "AWS Uplink", "risk": "high"}, + {"start": 1850.0, "end": 1910.0, "name": "PCS Uplink", "risk": "high"}, + {"start": 1930.0, "end": 1990.0, "name": "PCS Downlink", "risk": "high"}, ], - - 'body_worn': [ - {'start': 49.0, 'end': 50.0, 'name': '49 MHz Body Wires', 'risk': 'critical'}, - {'start': 72.0, 'end': 76.0, 'name': 'VHF Low Band Wires', 'risk': 'critical'}, - {'start': 150.0, 'end': 174.0, 'name': 'VHF High Band', 'risk': 'critical'}, - {'start': 380.0, 'end': 400.0, 'name': 'TETRA Band', 'risk': 'high'}, - {'start': 406.0, 'end': 420.0, 'name': 'Federal/Government', 'risk': 'critical'}, - {'start': 450.0, 'end': 470.0, 'name': 'UHF Business Band', 'risk': 'high'}, + "body_worn": [ + {"start": 49.0, "end": 50.0, "name": "49 MHz Body Wires", "risk": "critical"}, + {"start": 72.0, "end": 76.0, "name": "VHF Low Band Wires", "risk": "critical"}, + {"start": 150.0, "end": 174.0, "name": "VHF High Band", "risk": "critical"}, + {"start": 380.0, "end": 400.0, "name": "TETRA Band", "risk": "high"}, + {"start": 406.0, "end": 420.0, "name": "Federal/Government", "risk": "critical"}, + {"start": 450.0, "end": 470.0, "name": "UHF Business Band", "risk": "high"}, ], - - 'common_bugs': [ - {'start': 88.0, 'end': 108.0, 'name': 'FM Broadcast Band Bugs', 'risk': 'low'}, - {'start': 140.0, 'end': 150.0, 'name': 'Low VHF Bugs', 'risk': 'high'}, - {'start': 418.0, 'end': 419.0, 'name': '418 MHz ISM', 'risk': 'medium'}, - {'start': 433.0, 'end': 434.8, 'name': '433 MHz ISM Band', 'risk': 'medium'}, - {'start': 868.0, 'end': 870.0, 'name': '868 MHz ISM (Europe)', 'risk': 'medium'}, - {'start': 315.0, 'end': 316.0, 'name': '315 MHz ISM (US)', 'risk': 'medium'}, + "common_bugs": [ + {"start": 88.0, "end": 108.0, "name": "FM Broadcast Band Bugs", "risk": "low"}, + {"start": 140.0, "end": 150.0, "name": "Low VHF Bugs", "risk": "high"}, + {"start": 418.0, "end": 419.0, "name": "418 MHz ISM", "risk": "medium"}, + {"start": 433.0, "end": 434.8, "name": "433 MHz ISM Band", "risk": "medium"}, + {"start": 868.0, "end": 870.0, "name": "868 MHz ISM (Europe)", "risk": "medium"}, + {"start": 315.0, "end": 316.0, "name": "315 MHz ISM (US)", "risk": "medium"}, ], - - 'ism_bands': [ - {'start': 26.96, 'end': 27.41, 'name': 'CB Radio / ISM 27 MHz', 'risk': 'low'}, - {'start': 40.66, 'end': 40.70, 'name': 'ISM 40 MHz', 'risk': 'low'}, - {'start': 315.0, 'end': 316.0, 'name': 'ISM 315 MHz (US)', 'risk': 'medium'}, - {'start': 433.05, 'end': 434.79, 'name': 'ISM 433 MHz (EU)', 'risk': 'medium'}, - {'start': 868.0, 'end': 868.6, 'name': 'ISM 868 MHz (EU)', 'risk': 'medium'}, - {'start': 902.0, 'end': 928.0, 'name': 'ISM 915 MHz (US)', 'risk': 'medium'}, - {'start': 2400.0, 'end': 2483.5, 'name': 'ISM 2.4 GHz', 'risk': 'medium'}, + "ism_bands": [ + {"start": 26.96, "end": 27.41, "name": "CB Radio / ISM 27 MHz", "risk": "low"}, + {"start": 40.66, "end": 40.70, "name": "ISM 40 MHz", "risk": "low"}, + {"start": 315.0, "end": 316.0, "name": "ISM 315 MHz (US)", "risk": "medium"}, + {"start": 433.05, "end": 434.79, "name": "ISM 433 MHz (EU)", "risk": "medium"}, + {"start": 868.0, "end": 868.6, "name": "ISM 868 MHz (EU)", "risk": "medium"}, + {"start": 902.0, "end": 928.0, "name": "ISM 915 MHz (US)", "risk": "medium"}, + {"start": 2400.0, "end": 2483.5, "name": "ISM 2.4 GHz", "risk": "medium"}, ], } @@ -71,108 +66,101 @@ SURVEILLANCE_FREQUENCIES = { # ============================================================================= SWEEP_PRESETS = { - 'quick': { - 'name': 'Quick Scan', - 'description': 'Fast 2-minute check of most common bug frequencies', - 'duration_seconds': 120, - 'ranges': [ - {'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'}, - {'start': 433.0, 'end': 435.0, 'step': 0.025, 'name': '433 MHz ISM'}, - {'start': 868.0, 'end': 870.0, 'step': 0.025, 'name': '868 MHz ISM'}, + "quick": { + "name": "Quick Scan", + "description": "Fast 2-minute check of most common bug frequencies", + "duration_seconds": 120, + "ranges": [ + {"start": 88.0, "end": 108.0, "step": 0.1, "name": "FM Band"}, + {"start": 433.0, "end": 435.0, "step": 0.025, "name": "433 MHz ISM"}, + {"start": 868.0, "end": 870.0, "step": 0.025, "name": "868 MHz ISM"}, ], - 'wifi': True, - 'bluetooth': True, - 'rf': True, + "wifi": True, + "bluetooth": True, + "rf": True, }, - - 'standard': { - 'name': 'Standard Sweep', - 'description': 'Comprehensive 5-minute sweep of common surveillance bands', - 'duration_seconds': 300, - 'ranges': [ - {'start': 25.0, 'end': 50.0, 'step': 0.1, 'name': 'HF/Low VHF'}, - {'start': 88.0, 'end': 108.0, 'step': 0.1, 'name': 'FM Band'}, - {'start': 140.0, 'end': 175.0, 'step': 0.025, 'name': 'VHF'}, - {'start': 380.0, 'end': 450.0, 'step': 0.025, 'name': 'UHF Low'}, - {'start': 868.0, 'end': 930.0, 'step': 0.05, 'name': 'ISM 868/915'}, + "standard": { + "name": "Standard Sweep", + "description": "Comprehensive 5-minute sweep of common surveillance bands", + "duration_seconds": 300, + "ranges": [ + {"start": 25.0, "end": 50.0, "step": 0.1, "name": "HF/Low VHF"}, + {"start": 88.0, "end": 108.0, "step": 0.1, "name": "FM Band"}, + {"start": 140.0, "end": 175.0, "step": 0.025, "name": "VHF"}, + {"start": 380.0, "end": 450.0, "step": 0.025, "name": "UHF Low"}, + {"start": 868.0, "end": 930.0, "step": 0.05, "name": "ISM 868/915"}, ], - 'wifi': True, - 'bluetooth': True, - 'rf': True, + "wifi": True, + "bluetooth": True, + "rf": True, }, - - 'full': { - 'name': 'Full Spectrum', - 'description': 'Complete 15-minute spectrum sweep (24 MHz - 1.7 GHz)', - 'duration_seconds': 900, - 'ranges': [ - {'start': 24.0, 'end': 1700.0, 'step': 0.1, 'name': 'Full Spectrum'}, + "full": { + "name": "Full Spectrum", + "description": "Complete 15-minute spectrum sweep (24 MHz - 1.7 GHz)", + "duration_seconds": 900, + "ranges": [ + {"start": 24.0, "end": 1700.0, "step": 0.1, "name": "Full Spectrum"}, ], - 'wifi': True, - 'bluetooth': True, - 'rf': True, + "wifi": True, + "bluetooth": True, + "rf": True, }, - - 'wireless_cameras': { - 'name': 'Wireless Cameras', - 'description': 'Focus on video transmission frequencies', - 'duration_seconds': 180, - 'ranges': [ - {'start': 900.0, 'end': 930.0, 'step': 0.1, 'name': '900 MHz Video'}, - {'start': 1200.0, 'end': 1300.0, 'step': 0.5, 'name': '1.2 GHz Video'}, + "wireless_cameras": { + "name": "Wireless Cameras", + "description": "Focus on video transmission frequencies", + "duration_seconds": 180, + "ranges": [ + {"start": 900.0, "end": 930.0, "step": 0.1, "name": "900 MHz Video"}, + {"start": 1200.0, "end": 1300.0, "step": 0.5, "name": "1.2 GHz Video"}, ], - 'wifi': True, # WiFi cameras - 'bluetooth': False, - 'rf': True, + "wifi": True, # WiFi cameras + "bluetooth": False, + "rf": True, }, - - 'body_worn': { - 'name': 'Body-Worn Devices', - 'description': 'Detect body wires and covert transmitters', - 'duration_seconds': 240, - 'ranges': [ - {'start': 49.0, 'end': 50.0, 'step': 0.01, 'name': '49 MHz'}, - {'start': 72.0, 'end': 76.0, 'step': 0.01, 'name': 'VHF Low'}, - {'start': 150.0, 'end': 174.0, 'step': 0.0125, 'name': 'VHF High'}, - {'start': 406.0, 'end': 420.0, 'step': 0.0125, 'name': 'Federal'}, - {'start': 450.0, 'end': 470.0, 'step': 0.0125, 'name': 'UHF'}, + "body_worn": { + "name": "Body-Worn Devices", + "description": "Detect body wires and covert transmitters", + "duration_seconds": 240, + "ranges": [ + {"start": 49.0, "end": 50.0, "step": 0.01, "name": "49 MHz"}, + {"start": 72.0, "end": 76.0, "step": 0.01, "name": "VHF Low"}, + {"start": 150.0, "end": 174.0, "step": 0.0125, "name": "VHF High"}, + {"start": 406.0, "end": 420.0, "step": 0.0125, "name": "Federal"}, + {"start": 450.0, "end": 470.0, "step": 0.0125, "name": "UHF"}, ], - 'wifi': False, - 'bluetooth': True, # BLE bugs - 'rf': True, + "wifi": False, + "bluetooth": True, # BLE bugs + "rf": True, }, - - 'gps_trackers': { - 'name': 'GPS Trackers', - 'description': 'Detect cellular-based GPS tracking devices', - 'duration_seconds': 180, - 'ranges': [ - {'start': 824.0, 'end': 894.0, 'step': 0.1, 'name': 'Cellular 850'}, - {'start': 1850.0, 'end': 1990.0, 'step': 0.1, 'name': 'PCS Band'}, + "gps_trackers": { + "name": "GPS Trackers", + "description": "Detect cellular-based GPS tracking devices", + "duration_seconds": 180, + "ranges": [ + {"start": 824.0, "end": 894.0, "step": 0.1, "name": "Cellular 850"}, + {"start": 1850.0, "end": 1990.0, "step": 0.1, "name": "PCS Band"}, ], - 'wifi': False, - 'bluetooth': True, # BLE trackers - 'rf': True, + "wifi": False, + "bluetooth": True, # BLE trackers + "rf": True, }, - - 'bluetooth_only': { - 'name': 'Bluetooth/BLE Trackers', - 'description': 'Focus on BLE tracking devices (AirTag, Tile, etc.)', - 'duration_seconds': 60, - 'ranges': [], - 'wifi': False, - 'bluetooth': True, - 'rf': False, + "bluetooth_only": { + "name": "Bluetooth/BLE Trackers", + "description": "Focus on BLE tracking devices (AirTag, Tile, etc.)", + "duration_seconds": 60, + "ranges": [], + "wifi": False, + "bluetooth": True, + "rf": False, }, - - 'wifi_only': { - 'name': 'WiFi Devices', - 'description': 'Scan for hidden WiFi cameras and access points', - 'duration_seconds': 60, - 'ranges': [], - 'wifi': True, - 'bluetooth': False, - 'rf': False, + "wifi_only": { + "name": "WiFi Devices", + "description": "Scan for hidden WiFi cameras and access points", + "duration_seconds": 60, + "ranges": [], + "wifi": True, + "bluetooth": False, + "rf": False, }, } @@ -182,41 +170,41 @@ SWEEP_PRESETS = { # ============================================================================= BLE_TRACKER_SIGNATURES = { - 'apple_airtag': { - 'name': 'Apple AirTag', - 'company_id': 0x004C, - 'patterns': ['findmy', 'airtag'], - 'risk': 'high', - 'description': 'Apple Find My network tracker', + "apple_airtag": { + "name": "Apple AirTag", + "company_id": 0x004C, + "patterns": ["findmy", "airtag"], + "risk": "high", + "description": "Apple Find My network tracker", }, - 'tile': { - 'name': 'Tile Tracker', - 'company_id': 0x00ED, - 'patterns': ['tile'], - 'oui_prefixes': ['C4:E7', 'DC:54', 'E6:43'], - 'risk': 'high', - 'description': 'Tile Bluetooth tracker', + "tile": { + "name": "Tile Tracker", + "company_id": 0x00ED, + "patterns": ["tile"], + "oui_prefixes": ["C4:E7", "DC:54", "E6:43"], + "risk": "high", + "description": "Tile Bluetooth tracker", }, - 'samsung_smarttag': { - 'name': 'Samsung SmartTag', - 'company_id': 0x0075, - 'patterns': ['smarttag', 'smartthings'], - 'risk': 'high', - 'description': 'Samsung SmartThings tracker', + "samsung_smarttag": { + "name": "Samsung SmartTag", + "company_id": 0x0075, + "patterns": ["smarttag", "smartthings"], + "risk": "high", + "description": "Samsung SmartThings tracker", }, - 'chipolo': { - 'name': 'Chipolo', - 'company_id': 0x0A09, - 'patterns': ['chipolo'], - 'risk': 'high', - 'description': 'Chipolo Bluetooth tracker', + "chipolo": { + "name": "Chipolo", + "company_id": 0x0A09, + "patterns": ["chipolo"], + "risk": "high", + "description": "Chipolo Bluetooth tracker", }, - 'generic_beacon': { - 'name': 'Unknown BLE Beacon', - 'company_id': None, - 'patterns': [], - 'risk': 'medium', - 'description': 'Unidentified BLE beacon device', + "generic_beacon": { + "name": "Unknown BLE Beacon", + "company_id": None, + "patterns": [], + "risk": "medium", + "description": "Unidentified BLE beacon device", }, } @@ -226,68 +214,68 @@ BLE_TRACKER_SIGNATURES = { # ============================================================================= THREAT_TYPES = { - 'new_device': { - 'name': 'New Device', - 'description': 'Device not present in baseline', - 'default_severity': 'medium', + "new_device": { + "name": "New Device", + "description": "Device not present in baseline", + "default_severity": "medium", }, - 'tracker': { - 'name': 'Tracking Device', - 'description': 'Known BLE tracker detected', - 'default_severity': 'high', + "tracker": { + "name": "Tracking Device", + "description": "Known BLE tracker detected", + "default_severity": "high", }, - 'unknown_signal': { - 'name': 'Unknown Signal', - 'description': 'Unidentified RF transmission', - 'default_severity': 'medium', + "unknown_signal": { + "name": "Unknown Signal", + "description": "Unidentified RF transmission", + "default_severity": "medium", }, - 'burst_transmission': { - 'name': 'Burst Transmission', - 'description': 'Intermittent/store-and-forward signal detected', - 'default_severity': 'high', + "burst_transmission": { + "name": "Burst Transmission", + "description": "Intermittent/store-and-forward signal detected", + "default_severity": "high", }, - 'hidden_camera': { - 'name': 'Potential Hidden Camera', - 'description': 'WiFi camera or video transmitter detected', - 'default_severity': 'critical', + "hidden_camera": { + "name": "Potential Hidden Camera", + "description": "WiFi camera or video transmitter detected", + "default_severity": "critical", }, - 'gsm_bug': { - 'name': 'GSM/Cellular Bug', - 'description': 'Cellular transmission in non-phone device context', - 'default_severity': 'critical', + "gsm_bug": { + "name": "GSM/Cellular Bug", + "description": "Cellular transmission in non-phone device context", + "default_severity": "critical", }, - 'rogue_ap': { - 'name': 'Rogue Access Point', - 'description': 'Unauthorized WiFi access point', - 'default_severity': 'high', + "rogue_ap": { + "name": "Rogue Access Point", + "description": "Unauthorized WiFi access point", + "default_severity": "high", }, - 'anomaly': { - 'name': 'Signal Anomaly', - 'description': 'Unusual signal pattern or behavior', - 'default_severity': 'low', + "anomaly": { + "name": "Signal Anomaly", + "description": "Unusual signal pattern or behavior", + "default_severity": "low", }, } SEVERITY_LEVELS = { - 'critical': { - 'level': 4, - 'color': '#ff0000', - 'description': 'Immediate action required - active surveillance likely', + "critical": { + "level": 4, + "color": "#ff0000", + "description": "Immediate action required - active surveillance likely", }, - 'high': { - 'level': 3, - 'color': '#ff6600', - 'description': 'Strong indicator of surveillance device', + "high": { + "level": 3, + "color": "#ff6600", + "description": "Strong indicator of surveillance device", }, - 'medium': { - 'level': 2, - 'color': '#ffcc00', - 'description': 'Potential threat - requires investigation', + "medium": { + "level": 2, + "color": "#ffcc00", + "description": "Potential threat - requires investigation", }, - 'low': { - 'level': 1, - 'color': '#00cc00', - 'description': 'Minor anomaly - low probability of threat', + "low": { + "level": 1, + "color": "#00cc00", + "description": "Minor anomaly - low probability of threat", }, } @@ -297,34 +285,47 @@ SEVERITY_LEVELS = { # ============================================================================= WIFI_CAMERA_PATTERNS = { - 'ssid_patterns': [ - 'cam', 'camera', 'ipcam', 'webcam', 'dvr', 'nvr', - 'hikvision', 'dahua', 'reolink', 'wyze', 'ring', - 'arlo', 'nest', 'blink', 'eufy', 'yi', + "ssid_patterns": [ + "cam", + "camera", + "ipcam", + "webcam", + "dvr", + "nvr", + "hikvision", + "dahua", + "reolink", + "wyze", + "ring", + "arlo", + "nest", + "blink", + "eufy", + "yi", ], - 'oui_manufacturers': [ - 'Hikvision', - 'Dahua', - 'Axis Communications', - 'Hanwha Techwin', - 'Vivotek', - 'Ubiquiti', - 'Wyze Labs', - 'Amazon Technologies', # Ring - 'Google', # Nest + "oui_manufacturers": [ + "Hikvision", + "Dahua", + "Axis Communications", + "Hanwha Techwin", + "Vivotek", + "Ubiquiti", + "Wyze Labs", + "Amazon Technologies", # Ring + "Google", # Nest ], - 'mac_prefixes': { - 'C0:25:E9': 'TP-Link Camera', - 'A4:DA:22': 'TP-Link Camera', - '78:8C:B5': 'TP-Link Camera', - 'D4:6E:0E': 'TP-Link Camera', - '2C:AA:8E': 'Wyze Camera', - 'AC:CF:85': 'Hikvision', - '54:C4:15': 'Hikvision', - 'C0:56:E3': 'Hikvision', - '3C:EF:8C': 'Dahua', - 'A0:BD:1D': 'Dahua', - 'E4:24:6C': 'Dahua', + "mac_prefixes": { + "C0:25:E9": "TP-Link Camera", + "A4:DA:22": "TP-Link Camera", + "78:8C:B5": "TP-Link Camera", + "D4:6E:0E": "TP-Link Camera", + "2C:AA:8E": "Wyze Camera", + "AC:CF:85": "Hikvision", + "54:C4:15": "Hikvision", + "C0:56:E3": "Hikvision", + "3C:EF:8C": "Dahua", + "A0:BD:1D": "Dahua", + "E4:24:6C": "Dahua", }, } @@ -333,6 +334,7 @@ WIFI_CAMERA_PATTERNS = { # Utility Functions # ============================================================================= + def get_frequency_risk(frequency_mhz: float) -> tuple[str, str]: """ Determine the risk level for a given frequency. @@ -342,10 +344,10 @@ def get_frequency_risk(frequency_mhz: float) -> tuple[str, str]: """ for _category, ranges in SURVEILLANCE_FREQUENCIES.items(): for freq_range in ranges: - if freq_range['start'] <= frequency_mhz <= freq_range['end']: - return freq_range['risk'], freq_range['name'] + if freq_range["start"] <= frequency_mhz <= freq_range["end"]: + return freq_range["risk"], freq_range["name"] - return 'low', 'Unknown Band' + return "low", "Unknown Band" def get_sweep_preset(preset_name: str) -> dict | None: @@ -357,9 +359,9 @@ def get_all_sweep_presets() -> dict: """Get all available sweep presets.""" return { name: { - 'name': preset['name'], - 'description': preset['description'], - 'duration_seconds': preset['duration_seconds'], + "name": preset["name"], + "description": preset["description"], + "duration_seconds": preset["duration_seconds"], } for name, preset in SWEEP_PRESETS.items() } @@ -379,7 +381,7 @@ def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | N if device_name: name_lower = device_name.lower() for _tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items(): - for pattern in tracker_info.get('patterns', []): + for pattern in tracker_info.get("patterns", []): if pattern in name_lower: return tracker_info @@ -393,9 +395,9 @@ def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | N return None if len(mfr_bytes) >= 2: - company_id = int.from_bytes(mfr_bytes[:2], 'little') + company_id = int.from_bytes(mfr_bytes[:2], "little") for _tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items(): - if tracker_info.get('company_id') == company_id: + if tracker_info.get("company_id") == company_id: return tracker_info return None @@ -405,18 +407,18 @@ def is_potential_camera(ssid: str | None = None, mac: str | None = None, vendor: """Check if a WiFi device might be a hidden camera.""" if ssid: ssid_lower = ssid.lower() - for pattern in WIFI_CAMERA_PATTERNS['ssid_patterns']: + for pattern in WIFI_CAMERA_PATTERNS["ssid_patterns"]: if pattern in ssid_lower: return True if mac: mac_prefix = mac[:8].upper() - if mac_prefix in WIFI_CAMERA_PATTERNS['mac_prefixes']: + if mac_prefix in WIFI_CAMERA_PATTERNS["mac_prefixes"]: return True if vendor: vendor_lower = vendor.lower() - for manufacturer in WIFI_CAMERA_PATTERNS['oui_manufacturers']: + for manufacturer in WIFI_CAMERA_PATTERNS["oui_manufacturers"]: if manufacturer.lower() in vendor_lower: return True @@ -435,15 +437,15 @@ def get_threat_severity(threat_type: str, context: dict | None = None) -> str: Severity level string """ threat_info = THREAT_TYPES.get(threat_type, {}) - base_severity = threat_info.get('default_severity', 'medium') + base_severity = threat_info.get("default_severity", "medium") if context: # Upgrade severity based on signal strength (closer = more concerning) - signal = context.get('signal_strength') + signal = context.get("signal_strength") if signal and signal > -50: # Very strong signal - if base_severity == 'medium': - return 'high' - elif base_severity == 'high': - return 'critical' + if base_severity == "medium": + return "high" + elif base_severity == "high": + return "critical" return base_severity diff --git a/gunicorn.conf.py b/gunicorn.conf.py index b171f60..00d6f43 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.py @@ -4,8 +4,8 @@ import contextlib import warnings warnings.filterwarnings( - 'ignore', - message='Patching more than once', + "ignore", + message="Patching more than once", category=DeprecationWarning, ) @@ -24,6 +24,7 @@ def post_fork(server, worker): """ try: from gevent import monkey + monkey.patch_all() except Exception: pass @@ -32,6 +33,7 @@ def post_fork(server, worker): # when subprocesses fork after a double monkey-patch. try: from gevent.threading import _ForkHooks + _orig = _ForkHooks.after_fork_in_child def _safe_after_fork(self): @@ -55,6 +57,7 @@ def post_worker_init(worker): import ssl from gevent import get_hub + hub = get_hub() suppress = (SystemExit, ssl.SSLZeroReturnError, ssl.SSLError) for exc in suppress: diff --git a/intercept.py b/intercept.py index 40593b0..b506674 100755 --- a/intercept.py +++ b/intercept.py @@ -18,8 +18,9 @@ import sys # Check Python version early, before imports that use 3.9+ syntax # Handle --version early before other imports -if '--version' in sys.argv or '-V' in sys.argv: +if "--version" in sys.argv or "-V" in sys.argv: from config import VERSION + print(f"INTERCEPT v{VERSION}") sys.exit(0) @@ -33,5 +34,5 @@ if not site.ENABLE_USER_SITE: from app import main -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/routes/acars.py b/routes/acars.py index 03d9039..907d8d4 100644 --- a/routes/acars.py +++ b/routes/acars.py @@ -34,15 +34,15 @@ from utils.sdr import SDRFactory, SDRType from utils.sse import sse_stream_fanout from utils.validation import validate_device_index, validate_gain, validate_ppm -acars_bp = Blueprint('acars', __name__, url_prefix='/acars') +acars_bp = Blueprint("acars", __name__, url_prefix="/acars") # Default VHF ACARS frequencies (MHz) - North America primary DEFAULT_ACARS_FREQUENCIES = [ - '131.550', # Primary worldwide / North America - '130.025', # North America secondary - '129.125', # North America tertiary - '131.725', # North America (major US carriers) - '131.825', # North America (major US carriers) + "131.550", # Primary worldwide / North America + "130.025", # North America secondary + "129.125", # North America tertiary + "131.725", # North America (major US carriers) + "131.825", # North America (major US carriers) ] # Message counter for statistics @@ -56,7 +56,7 @@ acars_active_sdr_type: str | None = None def find_acarsdec(): """Find acarsdec binary.""" - return shutil.which('acarsdec') + return shutil.which("acarsdec") def get_acarsdec_json_flag(acarsdec_path: str) -> str: @@ -69,37 +69,32 @@ def get_acarsdec_json_flag(acarsdec_path: str) -> str: """ try: # Get help/version by running acarsdec with no args (shows usage) - result = subprocess.run( - [acarsdec_path], - capture_output=True, - text=True, - timeout=5 - ) + result = subprocess.run([acarsdec_path], capture_output=True, text=True, timeout=5) output = result.stdout + result.stderr import re # Check for f00b4r0 fork signature: uses --output instead of -j/-o # f00b4r0's help shows "--output" for output configuration - if '--output' in output or 'json:file:' in output.lower(): + if "--output" in output or "json:file:" in output.lower(): logger.debug("Detected f00b4r0 acarsdec fork (--output syntax)") - return '--output' + return "--output" # Parse version from output like "Acarsdec v4.3.1" or "Acarsdec/acarsserv 3.7" - version_match = re.search(r'acarsdec[^\d]*v?(\d+)\.(\d+)', output, re.IGNORECASE) + version_match = re.search(r"acarsdec[^\d]*v?(\d+)\.(\d+)", output, re.IGNORECASE) if version_match: major = int(version_match.group(1)) # Version 4.0+ uses -j for JSON stdout if major >= 4: - return '-j' + return "-j" # Version 3.x uses -o for output mode else: - return '-o' + return "-o" except Exception as e: logger.debug(f"Could not detect acarsdec version: {e}") # Default to -j (TLeconte modern standard) - return '-j' + return "-j" def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -> None: @@ -107,15 +102,15 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) - global acars_message_count, acars_last_message_time try: - app_module.acars_queue.put({'type': 'status', 'status': 'started'}) + app_module.acars_queue.put({"type": "status", "status": "started"}) # Use appropriate sentinel based on mode (text mode for pty on macOS) - sentinel = '' if is_text_mode else b'' + sentinel = "" if is_text_mode else b"" for line in iter(process.stdout.readline, sentinel): if is_text_mode: line = line.strip() else: - line = line.decode('utf-8', errors='replace').strip() + line = line.decode("utf-8", errors="replace").strip() if not line: continue @@ -124,15 +119,15 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) - data = json.loads(line) # Add our metadata - data['type'] = 'acars' - data['timestamp'] = datetime.utcnow().isoformat() + 'Z' + data["type"] = "acars" + data["timestamp"] = datetime.utcnow().isoformat() + "Z" # Enrich with translated label and parsed fields try: translation = translate_message(data) - data['label_description'] = translation['label_description'] - data['message_type'] = translation['message_type'] - data['parsed'] = translation['parsed'] + data["label_description"] = translation["label_description"] + data["message_type"] = translation["message_type"] + data["parsed"] = translation["parsed"] except Exception: pass @@ -149,8 +144,8 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) - # Log if enabled if app_module.logging_enabled: try: - with open(app_module.log_file_path, 'a') as f: - ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + with open(app_module.log_file_path, "a") as f: + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") f.write(f"{ts} | ACARS | {json.dumps(data)}\n") except Exception: pass @@ -162,7 +157,7 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) - except Exception as e: logger.error(f"ACARS stream error: {e}") - app_module.acars_queue.put({'type': 'error', 'message': str(e)}) + app_module.acars_queue.put({"type": "error", "message": str(e)}) finally: global acars_active_device, acars_active_sdr_type # Ensure process is terminated @@ -173,82 +168,81 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) - with contextlib.suppress(Exception): process.kill() unregister_process(process) - app_module.acars_queue.put({'type': 'status', 'status': 'stopped'}) + app_module.acars_queue.put({"type": "status", "status": "stopped"}) with app_module.acars_lock: app_module.acars_process = None # Release SDR device if acars_active_device is not None: - app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or "rtlsdr") acars_active_device = None acars_active_sdr_type = None -@acars_bp.route('/tools') +@acars_bp.route("/tools") def check_acars_tools() -> Response: """Check for ACARS decoding tools.""" has_acarsdec = find_acarsdec() is not None - return jsonify({ - 'acarsdec': has_acarsdec, - 'ready': has_acarsdec - }) + return jsonify({"acarsdec": has_acarsdec, "ready": has_acarsdec}) -@acars_bp.route('/status') +@acars_bp.route("/status") def acars_status() -> Response: """Get ACARS decoder status.""" running = False if app_module.acars_process: running = app_module.acars_process.poll() is None - return jsonify({ - 'running': running, - 'message_count': acars_message_count, - 'last_message_time': acars_last_message_time, - 'queue_size': app_module.acars_queue.qsize() - }) + return jsonify( + { + "running": running, + "message_count": acars_message_count, + "last_message_time": acars_last_message_time, + "queue_size": app_module.acars_queue.qsize(), + } + ) -@acars_bp.route('/start', methods=['POST']) +@acars_bp.route("/start", methods=["POST"]) def start_acars() -> Response: """Start ACARS decoder.""" global acars_message_count, acars_last_message_time, acars_active_device, acars_active_sdr_type with app_module.acars_lock: if app_module.acars_process and app_module.acars_process.poll() is None: - return api_error('ACARS decoder already running', 409) + return api_error("ACARS decoder already running", 409) # Check for acarsdec acarsdec_path = find_acarsdec() if not acarsdec_path: - return api_error('acarsdec not found. Install with: sudo apt install acarsdec', 400) + return api_error("acarsdec not found. Install with: sudo apt install acarsdec", 400) data = request.json or {} # Validate inputs try: - device = validate_device_index(data.get('device', '0')) - gain = validate_gain(data.get('gain', '40')) - ppm = validate_ppm(data.get('ppm', '0')) + device = validate_device_index(data.get("device", "0")) + gain = validate_gain(data.get("gain", "40")) + ppm = validate_ppm(data.get("ppm", "0")) except ValueError as e: return api_error(str(e), 400) # Resolve SDR type for device selection - sdr_type_str = data.get('sdr_type', 'rtlsdr') + sdr_type_str = data.get("sdr_type", "rtlsdr") # Check if device is available device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str) + error = app_module.claim_sdr_device(device_int, "acars", sdr_type_str) if error: - return api_error(error, 409, error_type='DEVICE_BUSY') + return api_error(error, 409, error_type="DEVICE_BUSY") acars_active_device = device_int acars_active_sdr_type = sdr_type_str # Get frequencies - use provided or defaults - frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES) + frequencies = data.get("frequencies", DEFAULT_ACARS_FREQUENCIES) if isinstance(frequencies, str): - frequencies = [f.strip() for f in frequencies.split(',')] + frequencies = [f.strip() for f in frequencies.split(",")] # Clear queue while not app_module.acars_queue.empty(): @@ -277,21 +271,21 @@ def start_acars() -> Response: # Note: gain/ppm must come BEFORE -r/-d json_flag = get_acarsdec_json_flag(acarsdec_path) cmd = [acarsdec_path] - if json_flag == '--output': + if json_flag == "--output": # f00b4r0 fork: --output json:file (no path = stdout) - cmd.extend(['--output', 'json:file']) - elif json_flag == '-j': - cmd.append('-j') # JSON output (TLeconte v4+) + cmd.extend(["--output", "json:file"]) + elif json_flag == "-j": + cmd.append("-j") # JSON output (TLeconte v4+) else: - cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x) + cmd.extend(["-o", "4"]) # JSON output (TLeconte v3.x) # Add gain if not auto (must be before -r/-d) - if gain and str(gain) != '0': - cmd.extend(['-g', str(gain)]) + if gain and str(gain) != "0": + cmd.extend(["-g", str(gain)]) # Add PPM correction if specified (must be before -r/-d) - if ppm and str(ppm) != '0': - cmd.extend(['-p', str(ppm)]) + if ppm and str(ppm) != "0": + cmd.extend(["-p", str(ppm)]) # Add device and frequencies if is_soapy: @@ -300,19 +294,19 @@ def start_acars() -> Response: # Build SoapySDR driver string (e.g., "driver=sdrplay,serial=...") builder = SDRFactory.get_builder(sdr_type) device_str = builder._build_device_string(sdr_device) - if json_flag == '--output': - cmd.extend(['-m', '256']) - cmd.extend(['--soapysdr', device_str]) + if json_flag == "--output": + cmd.extend(["-m", "256"]) + cmd.extend(["--soapysdr", device_str]) else: - cmd.extend(['-d', device_str]) - elif json_flag == '--output': + cmd.extend(["-d", device_str]) + elif json_flag == "--output": # f00b4r0 fork RTL-SDR: --rtlsdr # Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span) - cmd.extend(['-m', '256']) - cmd.extend(['--rtlsdr', str(device)]) + cmd.extend(["-m", "256"]) + cmd.extend(["--rtlsdr", str(device)]) else: # TLeconte fork RTL-SDR: -r - cmd.extend(['-r', str(device)]) + cmd.extend(["-r", str(device)]) cmd.extend(frequencies) logger.info(f"Starting ACARS decoder: {' '.join(cmd)}") @@ -321,25 +315,15 @@ def start_acars() -> Response: is_text_mode = False # On macOS, use pty to avoid stdout buffering issues - if platform.system() == 'Darwin': + if platform.system() == "Darwin": master_fd, slave_fd = pty.openpty() - process = subprocess.Popen( - cmd, - stdout=slave_fd, - stderr=subprocess.PIPE, - start_new_session=True - ) + process = subprocess.Popen(cmd, stdout=slave_fd, stderr=subprocess.PIPE, start_new_session=True) os.close(slave_fd) # Wrap master_fd as a text file for line-buffered reading process.stdout = open(master_fd, buffering=1) is_text_mode = True else: - process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - start_new_session=True - ) + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) # Wait briefly to check if process started time.sleep(PROCESS_START_WAIT) @@ -347,17 +331,17 @@ def start_acars() -> Response: if process.poll() is not None: # Process died - release device if acars_active_device is not None: - app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or "rtlsdr") acars_active_device = None acars_active_sdr_type = None - stderr = '' + stderr = "" if process.stderr: - stderr = process.stderr.read().decode('utf-8', errors='replace') + stderr = process.stderr.read().decode("utf-8", errors="replace") if stderr: logger.error(f"acarsdec stderr:\n{stderr}") - error_msg = 'acarsdec failed to start' + error_msg = "acarsdec failed to start" if stderr: - error_msg += f': {stderr[:500]}' + error_msg += f": {stderr[:500]}" logger.error(error_msg) return api_error(error_msg, 500) @@ -365,38 +349,29 @@ def start_acars() -> Response: register_process(process) # Start output streaming thread - thread = threading.Thread( - target=stream_acars_output, - args=(process, is_text_mode), - daemon=True - ) + thread = threading.Thread(target=stream_acars_output, args=(process, is_text_mode), daemon=True) thread.start() - return jsonify({ - 'status': 'started', - 'frequencies': frequencies, - 'device': device, - 'gain': gain - }) + return jsonify({"status": "started", "frequencies": frequencies, "device": device, "gain": gain}) except Exception as e: # Release device on failure if acars_active_device is not None: - app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or "rtlsdr") acars_active_device = None acars_active_sdr_type = None logger.error(f"Failed to start ACARS decoder: {e}") return api_error(str(e), 500) -@acars_bp.route('/stop', methods=['POST']) +@acars_bp.route("/stop", methods=["POST"]) def stop_acars() -> Response: """Stop ACARS decoder.""" global acars_active_device, acars_active_sdr_type with app_module.acars_lock: if not app_module.acars_process: - return api_error('ACARS decoder not running', 400) + return api_error("ACARS decoder not running", 400) try: app_module.acars_process.terminate() @@ -410,61 +385,64 @@ def stop_acars() -> Response: # Release device from registry if acars_active_device is not None: - app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or "rtlsdr") acars_active_device = None acars_active_sdr_type = None - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@acars_bp.route('/stream') +@acars_bp.route("/stream") def stream_acars() -> Response: """SSE stream for ACARS messages.""" + def _on_msg(msg: dict[str, Any]) -> None: - process_event('acars', msg, msg.get('type')) + process_event("acars", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=app_module.acars_queue, - channel_key='acars', + channel_key="acars", timeout=SSE_QUEUE_TIMEOUT, keepalive_interval=SSE_KEEPALIVE_INTERVAL, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" return response -@acars_bp.route('/messages') +@acars_bp.route("/messages") def get_acars_messages() -> Response: """Get recent ACARS messages from correlator (for history reload).""" - limit = request.args.get('limit', 50, type=int) + limit = request.args.get("limit", 50, type=int) limit = max(1, min(limit, 200)) - msgs = get_flight_correlator().get_recent_messages('acars', limit) + msgs = get_flight_correlator().get_recent_messages("acars", limit) return jsonify(msgs) -@acars_bp.route('/clear', methods=['POST']) +@acars_bp.route("/clear", methods=["POST"]) def clear_acars_messages() -> Response: """Clear stored ACARS messages and reset counter.""" global acars_message_count, acars_last_message_time get_flight_correlator().clear_acars() acars_message_count = 0 acars_last_message_time = None - return jsonify({'status': 'cleared'}) + return jsonify({"status": "cleared"}) -@acars_bp.route('/frequencies') +@acars_bp.route("/frequencies") def get_frequencies() -> Response: """Get default ACARS frequencies.""" - return jsonify({ - 'default': DEFAULT_ACARS_FREQUENCIES, - 'regions': { - 'north_america': ['131.550', '130.025', '129.125', '131.725', '131.825'], - 'europe': ['131.525', '131.725', '131.550'], - 'asia_pacific': ['131.550', '131.450'], + return jsonify( + { + "default": DEFAULT_ACARS_FREQUENCIES, + "regions": { + "north_america": ["131.550", "130.025", "129.125", "131.725", "131.825"], + "europe": ["131.525", "131.725", "131.550"], + "asia_pacific": ["131.550", "131.450"], + }, } - }) + ) diff --git a/routes/ais.py b/routes/ais.py index 4d6eed2..c7572cc 100644 --- a/routes/ais.py +++ b/routes/ais.py @@ -34,9 +34,9 @@ from utils.sdr import SDRFactory, SDRType from utils.sse import sse_stream_fanout from utils.validation import validate_device_index, validate_gain -logger = get_logger('intercept.ais') +logger = get_logger("intercept.ais") -ais_bp = Blueprint('ais', __name__, url_prefix='/ais') +ais_bp = Blueprint("ais", __name__, url_prefix="/ais") # Track AIS state ais_running = False @@ -49,17 +49,17 @@ _ais_error_logged = True # Common installation paths for AIS-catcher AIS_CATCHER_PATHS = [ - '/usr/local/bin/AIS-catcher', - '/usr/bin/AIS-catcher', - '/opt/homebrew/bin/AIS-catcher', - '/opt/homebrew/bin/aiscatcher', + "/usr/local/bin/AIS-catcher", + "/usr/bin/AIS-catcher", + "/opt/homebrew/bin/AIS-catcher", + "/opt/homebrew/bin/aiscatcher", ] def find_ais_catcher(): """Find AIS-catcher binary, checking PATH and common locations.""" # First try PATH - for name in ['AIS-catcher', 'aiscatcher']: + for name in ["AIS-catcher", "aiscatcher"]: path = shutil.which(name) if path: return path @@ -84,7 +84,7 @@ def parse_ais_stream(port: int): try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(AIS_SOCKET_TIMEOUT) - sock.connect(('localhost', port)) + sock.connect(("localhost", port)) ais_connected = True _ais_error_logged = True logger.info("Connected to AIS-catcher TCP server") @@ -95,14 +95,14 @@ def parse_ais_stream(port: int): while ais_running: try: - data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore') + data = sock.recv(SOCKET_BUFFER_SIZE).decode("utf-8", errors="ignore") if not data: logger.warning("AIS connection closed (no data)") break buffer += data - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) line = line.strip() if not line: continue @@ -111,7 +111,7 @@ def parse_ais_stream(port: int): msg = json.loads(line) vessel = process_ais_message(msg) if vessel: - mmsi = vessel.get('mmsi') + mmsi = vessel.get("mmsi") if mmsi: app_module.ais_vessels.set(mmsi, vessel) pending_updates.add(mmsi) @@ -128,21 +128,25 @@ def parse_ais_stream(port: int): if mmsi in app_module.ais_vessels: _vessel_snap = app_module.ais_vessels[mmsi] with contextlib.suppress(queue.Full): - app_module.ais_queue.put_nowait({ - 'type': 'vessel', - **_vessel_snap - }) + app_module.ais_queue.put_nowait({"type": "vessel", **_vessel_snap}) # Geofence check - _v_lat = _vessel_snap.get('lat') - _v_lon = _vessel_snap.get('lon') + _v_lat = _vessel_snap.get("lat") + _v_lon = _vessel_snap.get("lon") if _v_lat and _v_lon: try: from utils.geofence import get_geofence_manager + for _gf_evt in get_geofence_manager().check_position( - mmsi, 'vessel', _v_lat, _v_lon, - {'name': _vessel_snap.get('name'), 'ship_type': _vessel_snap.get('ship_type_text')} + mmsi, + "vessel", + _v_lat, + _v_lon, + { + "name": _vessel_snap.get("name"), + "ship_type": _vessel_snap.get("ship_type_text"), + }, ): - process_event('ais', _gf_evt, 'geofence') + process_event("ais", _gf_evt, "geofence") except Exception: pass pending_updates.clear() @@ -172,139 +176,138 @@ def process_ais_message(msg: dict) -> dict | None: # AIS-catcher outputs different message types # We're interested in position reports and static data - mmsi = msg.get('mmsi') + mmsi = msg.get("mmsi") if not mmsi: return None mmsi = str(mmsi) # Get existing vessel data or create new - vessel = app_module.ais_vessels.get(mmsi) or {'mmsi': mmsi} + vessel = app_module.ais_vessels.get(mmsi) or {"mmsi": mmsi} # Extract common fields # AIS-catcher JSON_FULL uses 'longitude'/'latitude', but some versions use 'lon'/'lat' - lat_val = msg.get('latitude') or msg.get('lat') - lon_val = msg.get('longitude') or msg.get('lon') + lat_val = msg.get("latitude") or msg.get("lat") + lon_val = msg.get("longitude") or msg.get("lon") if lat_val is not None and lon_val is not None: try: lat = float(lat_val) lon = float(lon_val) # Validate coordinates (AIS uses 181 for unavailable) if -90 <= lat <= 90 and -180 <= lon <= 180: - vessel['lat'] = lat - vessel['lon'] = lon + vessel["lat"] = lat + vessel["lon"] = lon except (ValueError, TypeError): pass # Speed over ground (knots) - if 'speed' in msg: + if "speed" in msg: try: - speed = float(msg['speed']) + speed = float(msg["speed"]) if speed < 102.3: # 102.3 = not available - vessel['speed'] = round(speed, 1) + vessel["speed"] = round(speed, 1) except (ValueError, TypeError): pass # Course over ground (degrees) - if 'course' in msg: + if "course" in msg: try: - course = float(msg['course']) + course = float(msg["course"]) if course < 360: # 360 = not available - vessel['course'] = round(course, 1) + vessel["course"] = round(course, 1) except (ValueError, TypeError): pass # True heading (degrees) - if 'heading' in msg: + if "heading" in msg: try: - heading = int(msg['heading']) + heading = int(msg["heading"]) if heading < 511: # 511 = not available - vessel['heading'] = heading + vessel["heading"] = heading except (ValueError, TypeError): pass # Navigation status - if 'status' in msg: - vessel['nav_status'] = msg['status'] - if 'status_text' in msg: - vessel['nav_status_text'] = msg['status_text'] + if "status" in msg: + vessel["nav_status"] = msg["status"] + if "status_text" in msg: + vessel["nav_status_text"] = msg["status_text"] # Vessel name (from Type 5 or Type 24 messages) - if 'shipname' in msg: - name = msg['shipname'].strip().strip('@') + if "shipname" in msg: + name = msg["shipname"].strip().strip("@") if name: - vessel['name'] = name + vessel["name"] = name # Callsign - if 'callsign' in msg: - callsign = msg['callsign'].strip().strip('@') + if "callsign" in msg: + callsign = msg["callsign"].strip().strip("@") if callsign: - vessel['callsign'] = callsign + vessel["callsign"] = callsign # Ship type - if 'shiptype' in msg: - vessel['ship_type'] = msg['shiptype'] - if 'shiptype_text' in msg: - vessel['ship_type_text'] = msg['shiptype_text'] + if "shiptype" in msg: + vessel["ship_type"] = msg["shiptype"] + if "shiptype_text" in msg: + vessel["ship_type_text"] = msg["shiptype_text"] # Destination - if 'destination' in msg: - dest = msg['destination'].strip().strip('@') + if "destination" in msg: + dest = msg["destination"].strip().strip("@") if dest: - vessel['destination'] = dest + vessel["destination"] = dest # ETA - if 'eta' in msg: - vessel['eta'] = msg['eta'] + if "eta" in msg: + vessel["eta"] = msg["eta"] # Dimensions - if 'to_bow' in msg and 'to_stern' in msg: + if "to_bow" in msg and "to_stern" in msg: try: - length = int(msg['to_bow']) + int(msg['to_stern']) + length = int(msg["to_bow"]) + int(msg["to_stern"]) if length > 0: - vessel['length'] = length + vessel["length"] = length except (ValueError, TypeError): pass - if 'to_port' in msg and 'to_starboard' in msg: + if "to_port" in msg and "to_starboard" in msg: try: - width = int(msg['to_port']) + int(msg['to_starboard']) + width = int(msg["to_port"]) + int(msg["to_starboard"]) if width > 0: - vessel['width'] = width + vessel["width"] = width except (ValueError, TypeError): pass # Draught - if 'draught' in msg: + if "draught" in msg: try: - draught = float(msg['draught']) + draught = float(msg["draught"]) if draught > 0: - vessel['draught'] = draught + vessel["draught"] = draught except (ValueError, TypeError): pass # Rate of turn - if 'turn' in msg: + if "turn" in msg: try: - turn = float(msg['turn']) + turn = float(msg["turn"]) if -127 <= turn <= 127: # Valid range - vessel['rate_of_turn'] = turn + vessel["rate_of_turn"] = turn except (ValueError, TypeError): pass # Message type for debugging - if 'type' in msg: - vessel['last_msg_type'] = msg['type'] + if "type" in msg: + vessel["last_msg_type"] = msg["type"] # Timestamp - vessel['last_seen'] = time.time() + vessel["last_seen"] = time.time() # Check for DSC DISTRESS matching this MMSI try: for _dsc_key, _dsc_msg in app_module.dsc_messages.items(): - if (str(_dsc_msg.get('source_mmsi', '')) == mmsi - and _dsc_msg.get('category', '').upper() == 'DISTRESS'): - vessel['dsc_distress'] = True + if str(_dsc_msg.get("source_mmsi", "")) == mmsi and _dsc_msg.get("category", "").upper() == "DISTRESS": + vessel["dsc_distress"] = True break except Exception: pass @@ -312,7 +315,7 @@ def process_ais_message(msg: dict) -> dict | None: return vessel -@ais_bp.route('/tools') +@ais_bp.route("/tools") def check_ais_tools(): """Check for AIS decoding tools and hardware.""" has_ais_catcher = find_ais_catcher() is not None @@ -321,60 +324,64 @@ def check_ais_tools(): devices = SDRFactory.detect_devices() has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices) - return jsonify({ - 'ais_catcher': has_ais_catcher, - 'ais_catcher_path': find_ais_catcher(), - 'has_rtlsdr': has_rtlsdr, - 'device_count': len(devices) - }) + return jsonify( + { + "ais_catcher": has_ais_catcher, + "ais_catcher_path": find_ais_catcher(), + "has_rtlsdr": has_rtlsdr, + "device_count": len(devices), + } + ) -@ais_bp.route('/status') +@ais_bp.route("/status") def ais_status(): """Get AIS tracking status for debugging.""" process_running = False if app_module.ais_process: process_running = app_module.ais_process.poll() is None - return jsonify({ - 'tracking_active': ais_running, - 'active_device': ais_active_device, - 'connected': ais_connected, - 'messages_received': ais_messages_received, - 'last_message_time': ais_last_message_time, - 'vessel_count': len(app_module.ais_vessels), - 'vessels': dict(app_module.ais_vessels), - 'queue_size': app_module.ais_queue.qsize(), - 'ais_catcher_path': find_ais_catcher(), - 'process_running': process_running - }) + return jsonify( + { + "tracking_active": ais_running, + "active_device": ais_active_device, + "connected": ais_connected, + "messages_received": ais_messages_received, + "last_message_time": ais_last_message_time, + "vessel_count": len(app_module.ais_vessels), + "vessels": dict(app_module.ais_vessels), + "queue_size": app_module.ais_queue.qsize(), + "ais_catcher_path": find_ais_catcher(), + "process_running": process_running, + } + ) -@ais_bp.route('/start', methods=['POST']) +@ais_bp.route("/start", methods=["POST"]) def start_ais(): """Start AIS tracking.""" global ais_running, ais_active_device, ais_active_sdr_type with app_module.ais_lock: if ais_running: - return api_error('AIS tracking already active', 409) + return api_error("AIS tracking already active", 409) data = request.json or {} # Validate inputs try: - gain = int(validate_gain(data.get('gain', '40'))) - device = validate_device_index(data.get('device', '0')) + gain = int(validate_gain(data.get("gain", "40"))) + device = validate_device_index(data.get("device", "0")) except ValueError as e: return api_error(str(e), 400) # Find AIS-catcher ais_catcher_path = find_ais_catcher() if not ais_catcher_path: - return api_error('AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases', 400) + return api_error("AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases", 400) # Get SDR type from request - sdr_type_str = data.get('sdr_type', 'rtlsdr') + sdr_type_str = data.get("sdr_type", "rtlsdr") try: sdr_type = SDRType(sdr_type_str) except ValueError: @@ -397,27 +404,27 @@ def start_ais(): # Check if device is available device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str) + error = app_module.claim_sdr_device(device_int, "ais", sdr_type_str) if error: - return api_error(error, 409, error_type='DEVICE_BUSY') + return api_error(error, 409, error_type="DEVICE_BUSY") # Build command using SDR abstraction sdr_device = SDRFactory.create_default_device(sdr_type, index=device) builder = SDRFactory.get_builder(sdr_type) - bias_t = data.get('bias_t', False) + bias_t = data.get("bias_t", False) tcp_port = AIS_TCP_PORT # Optional UDP NMEA forwarding (e.g. for OpenCPN on port 10110) - udp_host = data.get('udp_host') or None + udp_host = data.get("udp_host") or None udp_port = None if udp_host: try: - udp_port = int(data.get('udp_port', 10110)) + udp_port = int(data.get("udp_port", 10110)) if not 1 <= udp_port <= 65535: raise ValueError except (TypeError, ValueError): - return api_error('Invalid udp_port (1-65535)', 400) + return api_error("Invalid udp_port (1-65535)", 400) cmd = builder.build_ais_command( device=sdr_device, @@ -434,10 +441,7 @@ def start_ais(): try: logger.info(f"Starting AIS-catcher with device {device}: {' '.join(cmd)}") app_module.ais_process = subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - start_new_session=True + cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, start_new_session=True ) # Wait for process to start @@ -446,15 +450,15 @@ def start_ais(): if app_module.ais_process.poll() is not None: # Release device on failure app_module.release_sdr_device(device_int, sdr_type_str) - stderr_output = '' + stderr_output = "" if app_module.ais_process.stderr: with contextlib.suppress(Exception): - stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip() + stderr_output = app_module.ais_process.stderr.read().decode("utf-8", errors="ignore").strip() if stderr_output: logger.error(f"AIS-catcher stderr:\n{stderr_output}") - error_msg = 'AIS-catcher failed to start. Check SDR device connection.' + error_msg = "AIS-catcher failed to start. Check SDR device connection." if stderr_output: - error_msg += f' Error: {stderr_output[:500]}' + error_msg += f" Error: {stderr_output[:500]}" return api_error(error_msg, 500) ais_running = True @@ -465,12 +469,7 @@ def start_ais(): thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True) thread.start() - return jsonify({ - 'status': 'started', - 'message': 'AIS tracking started', - 'device': device, - 'port': tcp_port - }) + return jsonify({"status": "started", "message": "AIS tracking started", "device": device, "port": tcp_port}) except Exception as e: # Release device on failure app_module.release_sdr_device(device_int, sdr_type_str) @@ -478,7 +477,7 @@ def start_ais(): return api_error(str(e), 500) -@ais_bp.route('/stop', methods=['POST']) +@ais_bp.route("/stop", methods=["POST"]) def stop_ais(): """Stop AIS tracking.""" global ais_running, ais_active_device, ais_active_sdr_type @@ -500,55 +499,56 @@ def stop_ais(): # Release device from registry if ais_active_device is not None: - app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or "rtlsdr") ais_running = False ais_active_device = None ais_active_sdr_type = None app_module.ais_vessels.clear() - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@ais_bp.route('/stream') +@ais_bp.route("/stream") def stream_ais(): """SSE stream for AIS vessels.""" + def _on_msg(msg: dict[str, Any]) -> None: - process_event('ais', msg, msg.get('type')) + process_event("ais", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=app_module.ais_queue, - channel_key='ais', + channel_key="ais", timeout=SSE_QUEUE_TIMEOUT, keepalive_interval=SSE_KEEPALIVE_INTERVAL, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" return response -@ais_bp.route('/vessel//dsc') +@ais_bp.route("/vessel//dsc") def get_vessel_dsc(mmsi: str): """Get DSC messages associated with a vessel MMSI.""" if not mmsi or not mmsi.isdigit(): - return api_error('Invalid MMSI', 400) + return api_error("Invalid MMSI", 400) matches = [] try: for _key, msg in app_module.dsc_messages.items(): - if str(msg.get('source_mmsi', '')) == mmsi: + if str(msg.get("source_mmsi", "")) == mmsi: matches.append(dict(msg)) except Exception: pass - return api_success(data={'mmsi': mmsi, 'dsc_messages': matches}) + return api_success(data={"mmsi": mmsi, "dsc_messages": matches}) -@ais_bp.route('/vessels') +@ais_bp.route("/vessels") def ais_vessels(): """Export current AIS vessel data as JSON. @@ -563,22 +563,24 @@ def ais_vessels(): """ vessels = dict(app_module.ais_vessels) - mmsi_filter = request.args.get('mmsi') + mmsi_filter = request.args.get("mmsi") if mmsi_filter: vessels = {k: v for k, v in vessels.items() if str(k) == str(mmsi_filter)} - return jsonify({ - 'count': len(vessels), - 'vessels': list(vessels.values()), - }) + return jsonify( + { + "count": len(vessels), + "vessels": list(vessels.values()), + } + ) -@ais_bp.route('/dashboard') +@ais_bp.route("/dashboard") def ais_dashboard(): """Popout AIS dashboard.""" - embedded = request.args.get('embedded', 'false') == 'true' + embedded = request.args.get("embedded", "false") == "true" return render_template( - 'ais_dashboard.html', + "ais_dashboard.html", shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED, default_latitude=DEFAULT_LATITUDE, default_longitude=DEFAULT_LONGITUDE, diff --git a/routes/aprs.py b/routes/aprs.py index 0d1d8d0..3164ed9 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -41,7 +41,7 @@ from utils.validation import ( validate_rtl_tcp_port, ) -aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs') +aprs_bp = Blueprint("aprs", __name__, url_prefix="/aprs") # Track which SDR device is being used aprs_active_device: int | None = None @@ -49,17 +49,17 @@ aprs_active_sdr_type: str | None = None # APRS frequencies by region (MHz) APRS_FREQUENCIES = { - 'north_america': '144.390', - 'europe': '144.800', - 'uk': '144.800', - 'australia': '145.175', - 'new_zealand': '144.575', - 'argentina': '144.930', - 'brazil': '145.570', - 'japan': '144.640', - 'china': '144.640', - 'iss': '145.825', - 'sonate2': '145.825', + "north_america": "144.390", + "europe": "144.800", + "uk": "144.800", + "australia": "145.175", + "new_zealand": "144.575", + "argentina": "144.930", + "brazil": "145.570", + "japan": "144.640", + "china": "144.640", + "iss": "145.825", + "sonate2": "145.825", } # Statistics @@ -78,31 +78,31 @@ METER_MIN_CHANGE = 2 # Only send if level changes by at least this much def find_direwolf() -> str | None: """Find direwolf binary.""" - return shutil.which('direwolf') + return shutil.which("direwolf") def find_multimon_ng() -> str | None: """Find multimon-ng binary.""" - return shutil.which('multimon-ng') + return shutil.which("multimon-ng") def find_rtl_fm() -> str | None: """Find rtl_fm binary.""" - return shutil.which('rtl_fm') + return shutil.which("rtl_fm") def find_rx_fm() -> str | None: """Find SoapySDR rx_fm binary.""" - return shutil.which('rx_fm') + return shutil.which("rx_fm") def find_rtl_power() -> str | None: """Find rtl_power binary for spectrum scanning.""" - return shutil.which('rtl_power') + return shutil.which("rtl_power") # Path to direwolf config file -DIREWOLF_CONFIG_PATH = os.path.join(tempfile.gettempdir(), 'intercept_direwolf.conf') +DIREWOLF_CONFIG_PATH = os.path.join(tempfile.gettempdir(), "intercept_direwolf.conf") def create_direwolf_config() -> str: @@ -118,7 +118,7 @@ FIX_BITS 1 AGWPORT 0 KISSPORT 0 """ - with open(DIREWOLF_CONFIG_PATH, 'w') as f: + with open(DIREWOLF_CONFIG_PATH, "w") as f: f.write(config) return DIREWOLF_CONFIG_PATH @@ -131,15 +131,15 @@ def normalize_aprs_output_line(line: str) -> str: - direwolf tags: ``[0.4] ...``, ``[0L] ...``, etc. """ if not line: - return '' + return "" normalized = line.strip() - if normalized.startswith('AFSK1200:'): + if normalized.startswith("AFSK1200:"): normalized = normalized[9:].strip() # Strip one or more leading bracket tags emitted by decoders. # Examples: [0.4], [0L], [NONE] - normalized = re.sub(r'^(?:\[[^\]]+\]\s*)+', '', normalized) + normalized = re.sub(r"^(?:\[[^\]]+\]\s*)+", "", normalized) return normalized @@ -167,7 +167,7 @@ def parse_aprs_packet(raw_packet: str) -> dict | None: # Example: N0CALL-9>APRS,TCPIP*:@092345z4903.50N/07201.75W_090/000g005t077 # Source callsigns can include tactical suffixes like "/1" on some stations. - match = re.match(r'^([A-Z0-9/\-]+)>([^:]+):(.+)$', raw_packet, re.IGNORECASE) + match = re.match(r"^([A-Z0-9/\-]+)>([^:]+):(.+)$", raw_packet, re.IGNORECASE) if not match: return None @@ -176,50 +176,50 @@ def parse_aprs_packet(raw_packet: str) -> dict | None: data = match.group(3) packet = { - 'type': 'aprs', - 'callsign': callsign, - 'path': path, - 'raw': raw_packet, - 'timestamp': datetime.utcnow().isoformat() + 'Z', + "type": "aprs", + "callsign": callsign, + "path": path, + "raw": raw_packet, + "timestamp": datetime.utcnow().isoformat() + "Z", } # Extract destination from path (first element before any comma) - dest_parts = path.split(',') - dest = dest_parts[0] if dest_parts else '' + dest_parts = path.split(",") + dest = dest_parts[0] if dest_parts else "" # Check for Mic-E format first (data starts with ` or ' and dest is 6+ chars) - if len(data) >= 9 and data[0] in '`\'' and len(dest) >= 6: + if len(data) >= 9 and data[0] in "`'" and len(dest) >= 6: mic_e_data = parse_mic_e(dest, data) if mic_e_data: - packet['packet_type'] = 'position' - packet['position_format'] = 'mic_e' + packet["packet_type"] = "position" + packet["position_format"] = "mic_e" packet.update(mic_e_data) return packet # Determine packet type and parse accordingly - if data.startswith('!') or data.startswith('='): + if data.startswith("!") or data.startswith("="): # Position without timestamp (! = no messaging, = = with messaging) - packet['packet_type'] = 'position' - packet['messaging_capable'] = data.startswith('=') + packet["packet_type"] = "position" + packet["messaging_capable"] = data.startswith("=") pos_data = data[1:] # Check for compressed format (starts with /\[A-Za-z]) - if len(pos_data) >= 13 and pos_data[0] in '/\\': + if len(pos_data) >= 13 and pos_data[0] in "/\\": pos = parse_compressed_position(pos_data) if pos: - packet['position_format'] = 'compressed' + packet["position_format"] = "compressed" packet.update(pos) else: pos = parse_position(pos_data) if pos: - packet['position_format'] = 'uncompressed' + packet["position_format"] = "uncompressed" packet.update(pos) # Check for weather data in position packet (after position) - if '_' in pos_data or 'g' in pos_data or 't' in pos_data[15:] if len(pos_data) > 15 else False: + if "_" in pos_data or "g" in pos_data or "t" in pos_data[15:] if len(pos_data) > 15 else False: weather = parse_weather(pos_data) if weather: - packet['weather'] = weather + packet["weather"] = weather # Check for PHG data phg = parse_phg(pos_data) @@ -236,10 +236,10 @@ def parse_aprs_packet(raw_packet: str) -> dict | None: if df: packet.update(df) - elif data.startswith('/') or data.startswith('@'): + elif data.startswith("/") or data.startswith("@"): # Position with timestamp (/ = no messaging, @ = with messaging) - packet['packet_type'] = 'position' - packet['messaging_capable'] = data.startswith('@') + packet["packet_type"] = "position" + packet["messaging_capable"] = data.startswith("@") # Parse timestamp (first 7 chars after type indicator) if len(data) > 8: @@ -250,21 +250,21 @@ def parse_aprs_packet(raw_packet: str) -> dict | None: pos_data = data[8:] # Check for compressed format - if len(pos_data) >= 13 and pos_data[0] in '/\\': + if len(pos_data) >= 13 and pos_data[0] in "/\\": pos = parse_compressed_position(pos_data) if pos: - packet['position_format'] = 'compressed' + packet["position_format"] = "compressed" packet.update(pos) else: pos = parse_position(pos_data) if pos: - packet['position_format'] = 'uncompressed' + packet["position_format"] = "uncompressed" packet.update(pos) # Check for weather data in position packet weather = parse_weather(pos_data) if weather: - packet['weather'] = weather + packet["weather"] = weather # Check for PHG data phg = parse_phg(pos_data) @@ -276,154 +276,154 @@ def parse_aprs_packet(raw_packet: str) -> dict | None: if rng: packet.update(rng) - elif data.startswith('>'): + elif data.startswith(">"): # Status message - packet['packet_type'] = 'status' + packet["packet_type"] = "status" status_data = data[1:] - packet['status'] = status_data + packet["status"] = status_data # Check for Maidenhead grid locator in status (common pattern) - grid_match = re.match(r'^([A-R]{2}[0-9]{2}[A-X]{0,2})\s*', status_data, re.IGNORECASE) + grid_match = re.match(r"^([A-R]{2}[0-9]{2}[A-X]{0,2})\s*", status_data, re.IGNORECASE) if grid_match: - packet['grid'] = grid_match.group(1).upper() + packet["grid"] = grid_match.group(1).upper() - elif data.startswith(':'): + elif data.startswith(":"): # Message format - check for various subtypes - packet['packet_type'] = 'message' + packet["packet_type"] = "message" # Standard message: :ADDRESSEE:MESSAGE - msg_match = re.match(r'^:([A-Z0-9 -]{9}):(.*)$', data, re.IGNORECASE) + msg_match = re.match(r"^:([A-Z0-9 -]{9}):(.*)$", data, re.IGNORECASE) if msg_match: addressee = msg_match.group(1).strip() message = msg_match.group(2) - packet['addressee'] = addressee + packet["addressee"] = addressee # Check for telemetry definition messages - telem_def_match = re.match(r'^(PARM|UNIT|EQNS|BITS)\.(.*)$', message) + telem_def_match = re.match(r"^(PARM|UNIT|EQNS|BITS)\.(.*)$", message) if telem_def_match: - packet['packet_type'] = 'telemetry_definition' + packet["packet_type"] = "telemetry_definition" telem_def = parse_telemetry_definition( addressee, telem_def_match.group(1), telem_def_match.group(2) ) if telem_def: packet.update(telem_def) else: - packet['message'] = message + packet["message"] = message # Check for ACK/REJ - ack_match = re.match(r'^ack(\w+)$', message, re.IGNORECASE) + ack_match = re.match(r"^ack(\w+)$", message, re.IGNORECASE) if ack_match: - packet['message_type'] = 'ack' - packet['ack_id'] = ack_match.group(1) + packet["message_type"] = "ack" + packet["ack_id"] = ack_match.group(1) - rej_match = re.match(r'^rej(\w+)$', message, re.IGNORECASE) + rej_match = re.match(r"^rej(\w+)$", message, re.IGNORECASE) if rej_match: - packet['message_type'] = 'rej' - packet['rej_id'] = rej_match.group(1) + packet["message_type"] = "rej" + packet["rej_id"] = rej_match.group(1) # Check for message ID (for acknowledgment) - msgid_match = re.search(r'\{(\w{1,5})$', message) + msgid_match = re.search(r"\{(\w{1,5})$", message) if msgid_match: - packet['message_id'] = msgid_match.group(1) - packet['message'] = message[:message.rfind('{')] + packet["message_id"] = msgid_match.group(1) + packet["message"] = message[: message.rfind("{")] # Bulletin format: :BLNn :message - elif data[1:4] == 'BLN': - packet['packet_type'] = 'bulletin' - bln_match = re.match(r'^:BLN([0-9A-Z])[ ]*:(.*)$', data, re.IGNORECASE) + elif data[1:4] == "BLN": + packet["packet_type"] = "bulletin" + bln_match = re.match(r"^:BLN([0-9A-Z])[ ]*:(.*)$", data, re.IGNORECASE) if bln_match: - packet['bulletin_id'] = bln_match.group(1) - packet['bulletin'] = bln_match.group(2) + packet["bulletin_id"] = bln_match.group(1) + packet["bulletin"] = bln_match.group(2) # NWS weather alert: :NWS-xxxxx:message - elif data[1:5] == 'NWS-': - packet['packet_type'] = 'nws_alert' - nws_match = re.match(r'^:NWS-([A-Z]+)[ ]*:(.*)$', data, re.IGNORECASE) + elif data[1:5] == "NWS-": + packet["packet_type"] = "nws_alert" + nws_match = re.match(r"^:NWS-([A-Z]+)[ ]*:(.*)$", data, re.IGNORECASE) if nws_match: - packet['nws_id'] = nws_match.group(1) - packet['alert'] = nws_match.group(2) + packet["nws_id"] = nws_match.group(1) + packet["alert"] = nws_match.group(2) - elif data.startswith('_'): + elif data.startswith("_"): # Weather report (Positionless) - packet['packet_type'] = 'weather' - packet['weather'] = parse_weather(data) + packet["packet_type"] = "weather" + packet["weather"] = parse_weather(data) - elif data.startswith(';'): + elif data.startswith(";"): # Object format: ;OBJECTNAME*DDHHMMzPOSITION or ;OBJECTNAME_DDHHMMzPOSITION - packet['packet_type'] = 'object' + packet["packet_type"] = "object" obj_data = parse_object(data) if obj_data: packet.update(obj_data) # Check for weather data in object - remaining = data[18:] if len(data) > 18 else '' + remaining = data[18:] if len(data) > 18 else "" weather = parse_weather(remaining) if weather: - packet['weather'] = weather + packet["weather"] = weather - elif data.startswith(')'): + elif data.startswith(")"): # Item format: )ITEMNAME!POSITION or )ITEMNAME_POSITION - packet['packet_type'] = 'item' + packet["packet_type"] = "item" item_data = parse_item(data) if item_data: packet.update(item_data) - elif data.startswith('T'): + elif data.startswith("T"): # Telemetry - packet['packet_type'] = 'telemetry' + packet["packet_type"] = "telemetry" telem = parse_telemetry(data) if telem: packet.update(telem) - elif data.startswith('}'): + elif data.startswith("}"): # Third-party traffic - packet['packet_type'] = 'third_party' + packet["packet_type"] = "third_party" third = parse_third_party(data) if third: packet.update(third) - elif data.startswith('$'): + elif data.startswith("$"): # Raw GPS NMEA data - packet['packet_type'] = 'nmea' + packet["packet_type"] = "nmea" nmea = parse_nmea(data) if nmea: packet.update(nmea) - elif data.startswith('{'): + elif data.startswith("{"): # User-defined format - packet['packet_type'] = 'user_defined' + packet["packet_type"] = "user_defined" user = parse_user_defined(data) if user: packet.update(user) - elif data.startswith('<'): + elif data.startswith("<"): # Station capabilities - packet['packet_type'] = 'capabilities' + packet["packet_type"] = "capabilities" caps = parse_capabilities(data) if caps: packet.update(caps) - elif data.startswith('?'): + elif data.startswith("?"): # Query - packet['packet_type'] = 'query' + packet["packet_type"] = "query" query = parse_capabilities(data) if query: packet.update(query) else: - packet['packet_type'] = 'other' - packet['data'] = data + packet["packet_type"] = "other" + packet["data"] = data # Extract comment if present (after standard data) # Many APRS packets have freeform comments at the end - if 'data' not in packet and packet['packet_type'] in ('position', 'object', 'item'): + if "data" not in packet and packet["packet_type"] in ("position", "object", "item"): # Look for common comment patterns - comment_match = re.search(r'/([^/]+)$', data) - if comment_match and not re.match(r'^A=[-\d]+', comment_match.group(1)): + comment_match = re.search(r"/([^/]+)$", data) + if comment_match and not re.match(r"^A=[-\d]+", comment_match.group(1)): potential_comment = comment_match.group(1) # Exclude things that look like data fields - if len(potential_comment) > 3 and not re.match(r'^\d{3}/', potential_comment): - packet['comment'] = potential_comment + if len(potential_comment) > 3 and not re.match(r"^\d{3}/", potential_comment): + packet["comment"] = potential_comment return packet @@ -438,10 +438,7 @@ def parse_position(data: str) -> dict | None: # Format: DDMM.mmN/DDDMM.mmW (or similar with symbols) # Example: 4903.50N/07201.75W - pos_match = re.match( - r'^(\d{2})(\d{2}\.\d+)([NS])(.)(\d{3})(\d{2}\.\d+)([EW])(.)?', - data - ) + pos_match = re.match(r"^(\d{2})(\d{2}\.\d+)([NS])(.)(\d{3})(\d{2}\.\d+)([EW])(.)?", data) if pos_match: lat_deg = int(pos_match.group(1)) @@ -451,44 +448,41 @@ def parse_position(data: str) -> dict | None: lon_deg = int(pos_match.group(5)) lon_min = float(pos_match.group(6)) lon_dir = pos_match.group(7) - symbol_code = pos_match.group(8) or '' + symbol_code = pos_match.group(8) or "" lat = lat_deg + lat_min / 60.0 - if lat_dir == 'S': + if lat_dir == "S": lat = -lat lon = lon_deg + lon_min / 60.0 - if lon_dir == 'W': + if lon_dir == "W": lon = -lon result = { - 'lat': round(lat, 6), - 'lon': round(lon, 6), - 'symbol': symbol_table + symbol_code, + "lat": round(lat, 6), + "lon": round(lon, 6), + "symbol": symbol_table + symbol_code, } # Parse additional data after position (course/speed, altitude, etc.) - remaining = data[18:] if len(data) > 18 else '' + remaining = data[18:] if len(data) > 18 else "" # Course/Speed: CCC/SSS - cs_match = re.search(r'(\d{3})/(\d{3})', remaining) + cs_match = re.search(r"(\d{3})/(\d{3})", remaining) if cs_match: - result['course'] = int(cs_match.group(1)) - result['speed'] = int(cs_match.group(2)) # knots + result["course"] = int(cs_match.group(1)) + result["speed"] = int(cs_match.group(2)) # knots # Altitude: /A=NNNNNN - alt_match = re.search(r'/A=(-?\d+)', remaining) + alt_match = re.search(r"/A=(-?\d+)", remaining) if alt_match: - result['altitude'] = int(alt_match.group(1)) # feet + result["altitude"] = int(alt_match.group(1)) # feet return result # Legacy/no-decimal variant occasionally seen in degraded decodes: # DDMMN/DDDMMW (symbol chars still present between/after coords). - nodot_match = re.match( - r'^(\d{2})(\d{2})([NS])(.)(\d{3})(\d{2})([EW])(.)?', - data - ) + nodot_match = re.match(r"^(\d{2})(\d{2})([NS])(.)(\d{3})(\d{2})([EW])(.)?", data) if nodot_match: lat_deg = int(nodot_match.group(1)) lat_min = float(nodot_match.group(2)) @@ -497,32 +491,32 @@ def parse_position(data: str) -> dict | None: lon_deg = int(nodot_match.group(5)) lon_min = float(nodot_match.group(6)) lon_dir = nodot_match.group(7) - symbol_code = nodot_match.group(8) or '' + symbol_code = nodot_match.group(8) or "" lat = lat_deg + lat_min / 60.0 - if lat_dir == 'S': + if lat_dir == "S": lat = -lat lon = lon_deg + lon_min / 60.0 - if lon_dir == 'W': + if lon_dir == "W": lon = -lon result = { - 'lat': round(lat, 6), - 'lon': round(lon, 6), - 'symbol': symbol_table + symbol_code, + "lat": round(lat, 6), + "lon": round(lon, 6), + "symbol": symbol_table + symbol_code, } - remaining = data[13:] if len(data) > 13 else '' + remaining = data[13:] if len(data) > 13 else "" - cs_match = re.search(r'(\d{3})/(\d{3})', remaining) + cs_match = re.search(r"(\d{3})/(\d{3})", remaining) if cs_match: - result['course'] = int(cs_match.group(1)) - result['speed'] = int(cs_match.group(2)) + result["course"] = int(cs_match.group(1)) + result["speed"] = int(cs_match.group(2)) - alt_match = re.search(r'/A=(-?\d+)', remaining) + alt_match = re.search(r"/A=(-?\d+)", remaining) if alt_match: - result['altitude'] = int(alt_match.group(1)) + result["altitude"] = int(alt_match.group(1)) return result @@ -531,27 +525,22 @@ def parse_position(data: str) -> dict | None: if len(data) >= 18: lat_field = data[0:7] lat_dir = data[7] - symbol_table = data[8] if len(data) > 8 else '' - lon_field = data[9:17] if len(data) >= 17 else '' - lon_dir = data[17] if len(data) > 17 else '' - symbol_code = data[18] if len(data) > 18 else '' + symbol_table = data[8] if len(data) > 8 else "" + lon_field = data[9:17] if len(data) >= 17 else "" + lon_dir = data[17] if len(data) > 17 else "" + symbol_code = data[18] if len(data) > 18 else "" - if ( - len(lat_field) == 7 - and len(lon_field) == 8 - and lat_dir in ('N', 'S') - and lon_dir in ('E', 'W') - ): + if len(lat_field) == 7 and len(lon_field) == 8 and lat_dir in ("N", "S") and lon_dir in ("E", "W"): lat_deg_txt = lat_field[:2] - lat_min_txt = lat_field[2:].replace(' ', '0') + lat_min_txt = lat_field[2:].replace(" ", "0") lon_deg_txt = lon_field[:3] - lon_min_txt = lon_field[3:].replace(' ', '0') + lon_min_txt = lon_field[3:].replace(" ", "0") if ( lat_deg_txt.isdigit() and lon_deg_txt.isdigit() - and re.match(r'^\d{2}\.\d+$', lat_min_txt) - and re.match(r'^\d{2}\.\d+$', lon_min_txt) + and re.match(r"^\d{2}\.\d+$", lat_min_txt) + and re.match(r"^\d{2}\.\d+$", lon_min_txt) ): lat_deg = int(lat_deg_txt) lon_deg = int(lon_deg_txt) @@ -559,30 +548,30 @@ def parse_position(data: str) -> dict | None: lon_min = float(lon_min_txt) lat = lat_deg + lat_min / 60.0 - if lat_dir == 'S': + if lat_dir == "S": lat = -lat lon = lon_deg + lon_min / 60.0 - if lon_dir == 'W': + if lon_dir == "W": lon = -lon result = { - 'lat': round(lat, 6), - 'lon': round(lon, 6), - 'symbol': symbol_table + symbol_code, + "lat": round(lat, 6), + "lon": round(lon, 6), + "symbol": symbol_table + symbol_code, } # Keep same extension parsing behavior as primary branch. - remaining = data[19:] if len(data) > 19 else '' + remaining = data[19:] if len(data) > 19 else "" - cs_match = re.search(r'(\d{3})/(\d{3})', remaining) + cs_match = re.search(r"(\d{3})/(\d{3})", remaining) if cs_match: - result['course'] = int(cs_match.group(1)) - result['speed'] = int(cs_match.group(2)) + result["course"] = int(cs_match.group(1)) + result["speed"] = int(cs_match.group(2)) - alt_match = re.search(r'/A=(-?\d+)', remaining) + alt_match = re.search(r"/A=(-?\d+)", remaining) if alt_match: - result['altitude'] = int(alt_match.group(1)) + result["altitude"] = int(alt_match.group(1)) return result @@ -606,14 +595,14 @@ def parse_object(data: str) -> dict | None: character rather than assuming exact position. """ try: - if not data.startswith(';') or len(data) < 18: + if not data.startswith(";") or len(data) < 18: return None # Find the status character (* or _) which marks end of object name # It should be around position 10, but allow some flexibility status_pos = -1 for i in range(10, min(13, len(data))): - if data[i] in '*_': + if data[i] in "*_": status_pos = i break @@ -625,8 +614,8 @@ def parse_object(data: str) -> dict | None: obj_name = data[1:status_pos].strip() # Get status character - status_char = data[status_pos] if status_pos < len(data) else '*' - is_live = status_char == '*' + status_char = data[status_pos] if status_pos < len(data) else "*" + is_live = status_char == "*" # Timestamp is 7 chars after status, position follows pos_start = status_pos + 8 # status + 7 char timestamp @@ -636,8 +625,8 @@ def parse_object(data: str) -> dict | None: pos = None result = { - 'object_name': obj_name, - 'object_live': is_live, + "object_name": obj_name, + "object_live": is_live, } if pos: @@ -660,14 +649,14 @@ def parse_item(data: str) -> dict | None: - Position follows immediately in standard APRS format """ try: - if not data.startswith(')') or len(data) < 5: + if not data.startswith(")") or len(data) < 5: return None # Find the status delimiter (! or _) which terminates the name # Item name is 3-9 chars, so check positions 4-10 (1-based: chars 4-10 after ')') status_pos = -1 for i in range(4, min(11, len(data))): - if data[i] in '!_': + if data[i] in "!_": status_pos = i break @@ -677,17 +666,17 @@ def parse_item(data: str) -> dict | None: # Extract item name and status item_name = data[1:status_pos].strip() status_char = data[status_pos] - is_live = status_char == '!' + is_live = status_char == "!" # Parse position after status character if len(data) > status_pos + 1: - pos = parse_position(data[status_pos + 1:]) + pos = parse_position(data[status_pos + 1 :]) else: pos = None result = { - 'item_name': item_name, - 'item_live': is_live, + "item_name": item_name, + "item_live": is_live, } if pos: @@ -709,125 +698,147 @@ def parse_weather(data: str) -> dict: weather = {} # Wind direction: cCCC (degrees) or _CCC at start of positionless - match = re.search(r'c(\d{3})', data) + match = re.search(r"c(\d{3})", data) if match: - weather['wind_direction'] = int(match.group(1)) - elif data.startswith('_') and len(data) > 4: + weather["wind_direction"] = int(match.group(1)) + elif data.startswith("_") and len(data) > 4: # Positionless format starts with _MMDDhhmm then wind dir - wind_match = re.match(r'_\d{8}(\d{3})', data) + wind_match = re.match(r"_\d{8}(\d{3})", data) if wind_match: - weather['wind_direction'] = int(wind_match.group(1)) + weather["wind_direction"] = int(wind_match.group(1)) # Wind speed: sSSS (mph) - match = re.search(r's(\d{3})', data) + match = re.search(r"s(\d{3})", data) if match: - weather['wind_speed'] = int(match.group(1)) + weather["wind_speed"] = int(match.group(1)) # Wind gust: gGGG (mph) - match = re.search(r'g(\d{3})', data) + match = re.search(r"g(\d{3})", data) if match: - weather['wind_gust'] = int(match.group(1)) + weather["wind_gust"] = int(match.group(1)) # Temperature: tTTT (Fahrenheit, can be negative) - match = re.search(r't(-?\d{2,3})', data) + match = re.search(r"t(-?\d{2,3})", data) if match: - weather['temperature'] = int(match.group(1)) + weather["temperature"] = int(match.group(1)) # Rain last hour: rRRR (hundredths of inch) - match = re.search(r'r(\d{3})', data) + match = re.search(r"r(\d{3})", data) if match: - weather['rain_1h'] = int(match.group(1)) / 100.0 + weather["rain_1h"] = int(match.group(1)) / 100.0 # Rain last 24h: pPPP (hundredths of inch) - match = re.search(r'p(\d{3})', data) + match = re.search(r"p(\d{3})", data) if match: - weather['rain_24h'] = int(match.group(1)) / 100.0 + weather["rain_24h"] = int(match.group(1)) / 100.0 # Rain since midnight: PPPP (hundredths of inch) - match = re.search(r'P(\d{3})', data) + match = re.search(r"P(\d{3})", data) if match: - weather['rain_midnight'] = int(match.group(1)) / 100.0 + weather["rain_midnight"] = int(match.group(1)) / 100.0 # Humidity: hHH (%, 00 = 100%) - match = re.search(r'h(\d{2})', data) + match = re.search(r"h(\d{2})", data) if match: h = int(match.group(1)) - weather['humidity'] = 100 if h == 0 else h + weather["humidity"] = 100 if h == 0 else h # Barometric pressure: bBBBBB (tenths of millibars) - match = re.search(r'b(\d{5})', data) + match = re.search(r"b(\d{5})", data) if match: - weather['pressure'] = int(match.group(1)) / 10.0 + weather["pressure"] = int(match.group(1)) / 10.0 # Luminosity: LLLL (watts per square meter) # L = 0-999 W/m², l = 1000-1999 W/m² (subtract 1000) - match = re.search(r'L(\d{3})', data) + match = re.search(r"L(\d{3})", data) if match: - weather['luminosity'] = int(match.group(1)) + weather["luminosity"] = int(match.group(1)) else: - match = re.search(r'l(\d{3})', data) + match = re.search(r"l(\d{3})", data) if match: - weather['luminosity'] = int(match.group(1)) + 1000 + weather["luminosity"] = int(match.group(1)) + 1000 # Snow (last 24h): #SSS (inches) - match = re.search(r'#(\d{3})', data) + match = re.search(r"#(\d{3})", data) if match: - weather['snow_24h'] = int(match.group(1)) + weather["snow_24h"] = int(match.group(1)) # Raw rain counter: !RRR (for Peet Bros stations) - match = re.search(r'!(\d{3})', data) + match = re.search(r"!(\d{3})", data) if match: - weather['rain_raw'] = int(match.group(1)) + weather["rain_raw"] = int(match.group(1)) # Radiation: X### (nanosieverts/hour) - some weather stations - match = re.search(r'X(\d{3})', data) + match = re.search(r"X(\d{3})", data) if match: - weather['radiation'] = int(match.group(1)) + weather["radiation"] = int(match.group(1)) # Flooding/water level: F### (feet above/below flood stage) - match = re.search(r'F(-?\d{3})', data) + match = re.search(r"F(-?\d{3})", data) if match: - weather['flood_level'] = int(match.group(1)) + weather["flood_level"] = int(match.group(1)) # Voltage: V### (volts, for battery monitoring) - match = re.search(r'V(\d{3})', data) + match = re.search(r"V(\d{3})", data) if match: - weather['voltage'] = int(match.group(1)) / 10.0 + weather["voltage"] = int(match.group(1)) / 10.0 # Software type often at end (e.g., "Davis" or "Arduino") # Extract as weather station type - wx_type_match = re.search(r'([A-Za-z]{4,})$', data) + wx_type_match = re.search(r"([A-Za-z]{4,})$", data) if wx_type_match: - weather['wx_station_type'] = wx_type_match.group(1) + weather["wx_station_type"] = wx_type_match.group(1) return weather # Mic-E encoding tables MIC_E_DEST_TABLE = { - '0': (0, 'S', 0), '1': (1, 'S', 0), '2': (2, 'S', 0), '3': (3, 'S', 0), - '4': (4, 'S', 0), '5': (5, 'S', 0), '6': (6, 'S', 0), '7': (7, 'S', 0), - '8': (8, 'S', 0), '9': (9, 'S', 0), - 'A': (0, 'S', 1), 'B': (1, 'S', 1), 'C': (2, 'S', 1), 'D': (3, 'S', 1), - 'E': (4, 'S', 1), 'F': (5, 'S', 1), 'G': (6, 'S', 1), 'H': (7, 'S', 1), - 'I': (8, 'S', 1), 'J': (9, 'S', 1), - 'K': (0, 'S', 1), 'L': (0, 'S', 0), - 'P': (0, 'N', 1), 'Q': (1, 'N', 1), 'R': (2, 'N', 1), 'S': (3, 'N', 1), - 'T': (4, 'N', 1), 'U': (5, 'N', 1), 'V': (6, 'N', 1), 'W': (7, 'N', 1), - 'X': (8, 'N', 1), 'Y': (9, 'N', 1), - 'Z': (0, 'N', 1), + "0": (0, "S", 0), + "1": (1, "S", 0), + "2": (2, "S", 0), + "3": (3, "S", 0), + "4": (4, "S", 0), + "5": (5, "S", 0), + "6": (6, "S", 0), + "7": (7, "S", 0), + "8": (8, "S", 0), + "9": (9, "S", 0), + "A": (0, "S", 1), + "B": (1, "S", 1), + "C": (2, "S", 1), + "D": (3, "S", 1), + "E": (4, "S", 1), + "F": (5, "S", 1), + "G": (6, "S", 1), + "H": (7, "S", 1), + "I": (8, "S", 1), + "J": (9, "S", 1), + "K": (0, "S", 1), + "L": (0, "S", 0), + "P": (0, "N", 1), + "Q": (1, "N", 1), + "R": (2, "N", 1), + "S": (3, "N", 1), + "T": (4, "N", 1), + "U": (5, "N", 1), + "V": (6, "N", 1), + "W": (7, "N", 1), + "X": (8, "N", 1), + "Y": (9, "N", 1), + "Z": (0, "N", 1), } # Mic-E message types encoded in destination MIC_E_MESSAGE_TYPES = { - (1, 1, 1): ('off_duty', 'Off Duty'), - (1, 1, 0): ('en_route', 'En Route'), - (1, 0, 1): ('in_service', 'In Service'), - (1, 0, 0): ('returning', 'Returning'), - (0, 1, 1): ('committed', 'Committed'), - (0, 1, 0): ('special', 'Special'), - (0, 0, 1): ('priority', 'Priority'), - (0, 0, 0): ('emergency', 'Emergency'), + (1, 1, 1): ("off_duty", "Off Duty"), + (1, 1, 0): ("en_route", "En Route"), + (1, 0, 1): ("in_service", "In Service"), + (1, 0, 0): ("returning", "Returning"), + (0, 1, 1): ("committed", "Committed"), + (0, 1, 0): ("special", "Special"), + (0, 0, 1): ("priority", "Priority"), + (0, 0, 0): ("emergency", "Emergency"), } @@ -855,11 +866,11 @@ def parse_mic_e(dest: str, data: str) -> dict | None: return None # First char indicates Mic-E type: ` = current, ' = old - mic_e_type = 'current' if data[0] == '`' else 'old' + mic_e_type = "current" if data[0] == "`" else "old" # Parse latitude from destination (first 6 chars) lat_digits = [] - lat_dir = 'N' + lat_dir = "N" lon_offset = 0 msg_bits = [] @@ -881,16 +892,16 @@ def parse_mic_e(dest: str, data: str) -> dict | None: lat_dir = ns # Char 5 determines longitude offset (100 degrees) if i == 4: - lon_offset = 100 if ns == 'N' else 0 + lon_offset = 100 if ns == "N" else 0 # Char 6 determines longitude W/E if i == 5: - lon_dir = 'W' if ns == 'N' else 'E' + lon_dir = "W" if ns == "N" else "E" # Calculate latitude lat_deg = lat_digits[0] * 10 + lat_digits[1] lat_min = lat_digits[2] * 10 + lat_digits[3] + (lat_digits[4] * 10 + lat_digits[5]) / 100.0 lat = lat_deg + lat_min / 60.0 - if lat_dir == 'S': + if lat_dir == "S": lat = -lat # Parse longitude from data (bytes 1-3 after type char) @@ -914,7 +925,7 @@ def parse_mic_e(dest: str, data: str) -> dict | None: lon_hun = ord(d[2]) - 28 lon = lon_deg + (lon_min + lon_hun / 100.0) / 60.0 - if lon_dir == 'W': + if lon_dir == "W": lon = -lon # Parse speed and course (bytes 4-6) @@ -935,37 +946,40 @@ def parse_mic_e(dest: str, data: str) -> dict | None: symbol_table = d[7] result = { - 'lat': round(lat, 6), - 'lon': round(lon, 6), - 'symbol': symbol_table + symbol_code, - 'speed': speed, # knots - 'course': course, - 'mic_e_type': mic_e_type, + "lat": round(lat, 6), + "lon": round(lon, 6), + "symbol": symbol_table + symbol_code, + "speed": speed, # knots + "course": course, + "mic_e_type": mic_e_type, } # Decode message type from first 3 destination chars msg_tuple = tuple(msg_bits) if msg_tuple in MIC_E_MESSAGE_TYPES: - result['mic_e_status'] = MIC_E_MESSAGE_TYPES[msg_tuple][0] - result['mic_e_status_text'] = MIC_E_MESSAGE_TYPES[msg_tuple][1] + result["mic_e_status"] = MIC_E_MESSAGE_TYPES[msg_tuple][0] + result["mic_e_status_text"] = MIC_E_MESSAGE_TYPES[msg_tuple][1] # Parse optional fields after symbol (byte 9 onwards) if len(d) > 8: extra = d[8:] # Altitude: `XXX} where XXX is base-91 encoded - alt_match = re.search(r'([\x21-\x7b]{3})\}', extra) + alt_match = re.search(r"([\x21-\x7b]{3})\}", extra) if alt_match: alt_chars = alt_match.group(1) - alt = ((ord(alt_chars[0]) - 33) * 91 * 91 + - (ord(alt_chars[1]) - 33) * 91 + - (ord(alt_chars[2]) - 33) - 10000) - result['altitude'] = alt # meters + alt = ( + (ord(alt_chars[0]) - 33) * 91 * 91 + + (ord(alt_chars[1]) - 33) * 91 + + (ord(alt_chars[2]) - 33) + - 10000 + ) + result["altitude"] = alt # meters # Status text (after altitude or at end) - status_text = re.sub(r'[\x21-\x7b]{3}\}', '', extra).strip() + status_text = re.sub(r"[\x21-\x7b]{3}\}", "", extra).strip() if status_text: - result['status'] = status_text + result["status"] = status_text return result @@ -1010,10 +1024,10 @@ def parse_compressed_position(data: str) -> dict | None: symbol_code = data[9] result = { - 'lat': round(lat, 6), - 'lon': round(lon, 6), - 'symbol': symbol_table + symbol_code, - 'compressed': True, + "lat": round(lat, 6), + "lon": round(lon, 6), + "symbol": symbol_table + symbol_code, + "compressed": True, } # Course/speed or altitude (chars 10-11) and type byte (char 12) @@ -1033,23 +1047,23 @@ def parse_compressed_position(data: str) -> dict | None: if comp_type == 0: # c/s are course/speed if c != 0 or s != 0: - result['course'] = c * 4 - result['speed'] = round(1.08 ** s - 1, 1) # knots + result["course"] = c * 4 + result["speed"] = round(1.08**s - 1, 1) # knots elif comp_type == 1: # c/s are altitude if c != 0 or s != 0: alt = 1.002 ** (c * 91 + s) - result['altitude'] = round(alt) # feet + result["altitude"] = round(alt) # feet elif comp_type == 2: # Radio range if s != 0: - result['range'] = round(2 * 1.08 ** s, 1) # miles + result["range"] = round(2 * 1.08**s, 1) # miles # GPS fix quality from type byte if t & 0x20: - result['gps_fix'] = 'current' + result["gps_fix"] = "current" else: - result['gps_fix'] = 'old' + result["gps_fix"] = "old" return result @@ -1067,39 +1081,33 @@ def parse_telemetry(data: str) -> dict | None: - bbbbbbbb = 8 digital bits """ try: - if not data.startswith('T'): + if not data.startswith("T"): return None - result = {'packet_type': 'telemetry'} + result = {"packet_type": "telemetry"} # Match telemetry format - match = re.match( - r'^T#(\d{3}|MIC),(\d{1,3}),(\d{1,3}),(\d{1,3}),(\d{1,3}),(\d{1,3}),([01]{8})', - data - ) + match = re.match(r"^T#(\d{3}|MIC),(\d{1,3}),(\d{1,3}),(\d{1,3}),(\d{1,3}),(\d{1,3}),([01]{8})", data) if match: - result['sequence'] = match.group(1) - result['analog'] = [ + result["sequence"] = match.group(1) + result["analog"] = [ int(match.group(2)), int(match.group(3)), int(match.group(4)), int(match.group(5)), int(match.group(6)), ] - result['digital'] = match.group(7) - result['digital_bits'] = [int(b) for b in match.group(7)] + result["digital"] = match.group(7) + result["digital_bits"] = [int(b) for b in match.group(7)] return result # Try simpler format without digital bits - match = re.match( - r'^T#(\d{3}|MIC),(\d{1,3}),(\d{1,3}),(\d{1,3}),(\d{1,3}),(\d{1,3})', - data - ) + match = re.match(r"^T#(\d{3}|MIC),(\d{1,3}),(\d{1,3}),(\d{1,3}),(\d{1,3}),(\d{1,3})", data) if match: - result['sequence'] = match.group(1) - result['analog'] = [ + result["sequence"] = match.group(1) + result["analog"] = [ int(match.group(2)), int(match.group(3)), int(match.group(4)), @@ -1109,11 +1117,11 @@ def parse_telemetry(data: str) -> dict | None: return result # Even simpler - just sequence and some analog - match = re.match(r'^T#(\d{3}|MIC),(.+)$', data) + match = re.match(r"^T#(\d{3}|MIC),(.+)$", data) if match: - result['sequence'] = match.group(1) - values = match.group(2).split(',') - result['analog'] = [int(v) for v in values if v.isdigit()] + result["sequence"] = match.group(1) + values = match.group(2).split(",") + result["analog"] = [int(v) for v in values if v.isdigit()] return result return None @@ -1131,42 +1139,44 @@ def parse_telemetry_definition(callsign: str, msg_type: str, content: str) -> di """ try: result = { - 'telemetry_definition': True, - 'definition_type': msg_type, - 'for_station': callsign.strip(), + "telemetry_definition": True, + "definition_type": msg_type, + "for_station": callsign.strip(), } - values = [v.strip() for v in content.split(',')] + values = [v.strip() for v in content.split(",")] - if msg_type == 'PARM': + if msg_type == "PARM": # Parameter names - result['param_names'] = values[:5] # Analog names - result['bit_names'] = values[5:13] # Digital bit names + result["param_names"] = values[:5] # Analog names + result["bit_names"] = values[5:13] # Digital bit names - elif msg_type == 'UNIT': + elif msg_type == "UNIT": # Units for parameters - result['param_units'] = values[:5] - result['bit_labels'] = values[5:13] + result["param_units"] = values[:5] + result["bit_labels"] = values[5:13] - elif msg_type == 'EQNS': + elif msg_type == "EQNS": # Equations: a*x^2 + b*x + c for each analog channel # Format: a1,b1,c1,a2,b2,c2,a3,b3,c3,a4,b4,c4,a5,b5,c5 - result['equations'] = [] + result["equations"] = [] for i in range(0, min(15, len(values)), 3): if i + 2 < len(values): - result['equations'].append({ - 'a': float(values[i]) if values[i] else 0, - 'b': float(values[i + 1]) if values[i + 1] else 1, - 'c': float(values[i + 2]) if values[i + 2] else 0, - }) + result["equations"].append( + { + "a": float(values[i]) if values[i] else 0, + "b": float(values[i + 1]) if values[i + 1] else 1, + "c": float(values[i + 2]) if values[i + 2] else 0, + } + ) - elif msg_type == 'BITS': + elif msg_type == "BITS": # Bit sense and project name # Format: bbbbbbbb,Project Name if values: - result['bit_sense'] = values[0][:8] + result["bit_sense"] = values[0][:8] if len(values) > 1: - result['project_name'] = ','.join(values[1:]) + result["project_name"] = ",".join(values[1:]) return result @@ -1185,7 +1195,7 @@ def parse_phg(data: str) -> dict | None: - d = directivity code (0-9) """ try: - match = re.search(r'PHG(\d)(\d)(\d)(\d)', data) + match = re.search(r"PHG(\d)(\d)(\d)(\d)", data) if not match: return None @@ -1195,22 +1205,22 @@ def parse_phg(data: str) -> dict | None: power_watts = p * p # Height in feet: 10 * 2^h - height_feet = 10 * (2 ** h) + height_feet = 10 * (2**h) # Gain in dB gain_db = g # Directivity (0=omni, 1-8 = 45° sectors starting from N) - directions = ['omni', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW', 'N'] - directivity = directions[d] if d < len(directions) else 'omni' + directions = ["omni", "NE", "E", "SE", "S", "SW", "W", "NW", "N"] + directivity = directions[d] if d < len(directions) else "omni" return { - 'phg': True, - 'power_watts': power_watts, - 'height_feet': height_feet, - 'gain_db': gain_db, - 'directivity': directivity, - 'directivity_code': d, + "phg": True, + "power_watts": power_watts, + "height_feet": height_feet, + "gain_db": gain_db, + "directivity": directivity, + "directivity_code": d, } except Exception as e: @@ -1224,9 +1234,9 @@ def parse_rng(data: str) -> dict | None: Format: RNGrrrr where rrrr is range in miles. """ try: - match = re.search(r'RNG(\d{4})', data) + match = re.search(r"RNG(\d{4})", data) if match: - return {'range_miles': int(match.group(1))} + return {"range_miles": int(match.group(1))} return None except Exception: return None @@ -1243,17 +1253,17 @@ def parse_df_report(data: str) -> dict | None: result = {} # DF bearing format: /BRG (3 digits) - brg_match = re.search(r'/(\d{3})/', data) + brg_match = re.search(r"/(\d{3})/", data) if brg_match: - result['df_bearing'] = int(brg_match.group(1)) + result["df_bearing"] = int(brg_match.group(1)) # NRQ format - nrq_match = re.search(r'/(\d)(\d)(\d)$', data) + nrq_match = re.search(r"/(\d)(\d)(\d)$", data) if nrq_match: n, r, q = [int(x) for x in nrq_match.groups()] - result['df_hits'] = n # Number of signal hits - result['df_range'] = r # Range: 0=useless, 8=exact - result['df_quality'] = q # Quality: 0=useless, 8=excellent + result["df_hits"] = n # Number of signal hits + result["df_range"] = r # Range: 0=useless, 8=exact + result["df_quality"] = q # Quality: 0=useless, 8=excellent return result if result else None @@ -1273,30 +1283,30 @@ def parse_timestamp(data: str) -> dict | None: result = {} # Zulu time: DDHHMMz - match = re.match(r'^(\d{2})(\d{2})(\d{2})z', data) + match = re.match(r"^(\d{2})(\d{2})(\d{2})z", data) if match: - result['time_day'] = int(match.group(1)) - result['time_hour'] = int(match.group(2)) - result['time_minute'] = int(match.group(3)) - result['time_format'] = 'zulu' + result["time_day"] = int(match.group(1)) + result["time_hour"] = int(match.group(2)) + result["time_minute"] = int(match.group(3)) + result["time_format"] = "zulu" return result # Local time: HHMMSSh - match = re.match(r'^(\d{2})(\d{2})(\d{2})h', data) + match = re.match(r"^(\d{2})(\d{2})(\d{2})h", data) if match: - result['time_hour'] = int(match.group(1)) - result['time_minute'] = int(match.group(2)) - result['time_second'] = int(match.group(3)) - result['time_format'] = 'local' + result["time_hour"] = int(match.group(1)) + result["time_minute"] = int(match.group(2)) + result["time_second"] = int(match.group(3)) + result["time_format"] = "local" return result # Local with day: DDHHMMl (less common) - match = re.match(r'^(\d{2})(\d{2})(\d{2})/', data) + match = re.match(r"^(\d{2})(\d{2})(\d{2})/", data) if match: - result['time_day'] = int(match.group(1)) - result['time_hour'] = int(match.group(2)) - result['time_minute'] = int(match.group(3)) - result['time_format'] = 'local_day' + result["time_day"] = int(match.group(1)) + result["time_hour"] = int(match.group(2)) + result["time_minute"] = int(match.group(3)) + result["time_format"] = "local_day" return result return None @@ -1311,7 +1321,7 @@ def parse_third_party(data: str) -> dict | None: Format: }CALL>PATH:DATA (the } indicates third-party) """ try: - if not data.startswith('}'): + if not data.startswith("}"): return None # The rest is a standard APRS packet @@ -1321,11 +1331,11 @@ def parse_third_party(data: str) -> dict | None: inner = parse_aprs_packet(inner_packet) if inner: return { - 'third_party': True, - 'inner_packet': inner, + "third_party": True, + "inner_packet": inner, } - return {'third_party': True, 'inner_raw': inner_packet} + return {"third_party": True, "inner_raw": inner_packet} except Exception: return None @@ -1340,13 +1350,13 @@ def parse_user_defined(data: str) -> dict | None: - XXXX = user-defined data """ try: - if not data.startswith('{') or len(data) < 3: + if not data.startswith("{") or len(data) < 3: return None return { - 'user_defined': True, - 'user_id': data[1:3], - 'user_data': data[3:], + "user_defined": True, + "user_id": data[1:3], + "user_data": data[3:], } except Exception: @@ -1360,20 +1370,20 @@ def parse_capabilities(data: str) -> dict | None: or query format: ?APRS? or ?WX? etc. """ try: - if data.startswith('<'): + if data.startswith("<"): # Capabilities response - caps = data[1:].split(',') + caps = data[1:].split(",") return { - 'capabilities': [c.strip() for c in caps if c.strip()], + "capabilities": [c.strip() for c in caps if c.strip()], } - elif data.startswith('?'): + elif data.startswith("?"): # Query - query_match = re.match(r'\?([A-Z]+)\?', data) + query_match = re.match(r"\?([A-Z]+)\?", data) if query_match: return { - 'query': True, - 'query_type': query_match.group(1), + "query": True, + "query_type": query_match.group(1), } return None @@ -1388,21 +1398,21 @@ def parse_nmea(data: str) -> dict | None: APRS can include raw NMEA data starting with $. """ try: - if not data.startswith('$'): + if not data.startswith("$"): return None result = { - 'nmea': True, - 'nmea_sentence': data, + "nmea": True, + "nmea_sentence": data, } # Try to identify sentence type - if data.startswith('$GPGGA') or data.startswith('$GNGGA'): - result['nmea_type'] = 'GGA' - elif data.startswith('$GPRMC') or data.startswith('$GNRMC'): - result['nmea_type'] = 'RMC' - elif data.startswith('$GPGLL') or data.startswith('$GNGLL'): - result['nmea_type'] = 'GLL' + if data.startswith("$GPGGA") or data.startswith("$GNGGA"): + result["nmea_type"] = "GGA" + elif data.startswith("$GPRMC") or data.startswith("$GNRMC"): + result["nmea_type"] = "RMC" + elif data.startswith("$GPGLL") or data.startswith("$GNGLL"): + result["nmea_type"] = "GLL" return result @@ -1421,7 +1431,7 @@ def parse_audio_level(line: str) -> int | None: We normalize it to 0-100 scale (direwolf typically outputs 0-100+). """ # Match "Audio level = NN" pattern - match = re.search(r'Audio level\s*=\s*(\d+)', line, re.IGNORECASE) + match = re.search(r"Audio level\s*=\s*(\d+)", line, re.IGNORECASE) if match: raw_level = int(match.group(1)) # Normalize: direwolf levels are typically 0-100, but can go higher @@ -1474,7 +1484,7 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr _last_meter_level = -1 try: - app_module.aprs_queue.put({'type': 'status', 'status': 'started'}) + app_module.aprs_queue.put({"type": "status", "status": "started"}) # Read from PTY using select() for non-blocking reads. # PTY forces the decoder to line-buffer, so output arrives immediately @@ -1491,12 +1501,12 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr data = os.read(master_fd, 1024) if not data: break - buffer += data.decode('utf-8', errors='replace') + buffer += data.decode("utf-8", errors="replace") except OSError: break - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) line = line.strip() if not line: continue @@ -1506,9 +1516,9 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr if audio_level is not None: if should_send_meter_update(audio_level): meter_msg = { - 'type': 'meter', - 'level': audio_level, - 'ts': datetime.utcnow().isoformat() + 'Z' + "type": "meter", + "level": audio_level, + "ts": datetime.utcnow().isoformat() + "Z", } app_module.aprs_queue.put(meter_msg) continue # Audio level lines are not packets @@ -1517,7 +1527,7 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr line = normalize_aprs_output_line(line) # Skip non-packet lines (APRS format: CALL>PATH:DATA) - if '>' not in line or ':' not in line: + if ">" not in line or ":" not in line: continue packet = parse_aprs_packet(line) @@ -1526,7 +1536,7 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr aprs_last_packet_time = time.time() # Track unique stations - callsign = packet.get('callsign') + callsign = packet.get("callsign") if callsign and callsign not in aprs_stations: aprs_station_count += 1 @@ -1534,15 +1544,15 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr # packets do not contain position fields. if callsign: existing = aprs_stations.get(callsign, {}) - packet_lat = packet.get('lat') - packet_lon = packet.get('lon') + packet_lat = packet.get("lat") + packet_lon = packet.get("lon") aprs_stations[callsign] = { - 'callsign': callsign, - 'lat': packet_lat if packet_lat is not None else existing.get('lat'), - 'lon': packet_lon if packet_lon is not None else existing.get('lon'), - 'symbol': packet.get('symbol') or existing.get('symbol'), - 'last_seen': packet.get('timestamp'), - 'packet_type': packet.get('packet_type'), + "callsign": callsign, + "lat": packet_lat if packet_lat is not None else existing.get("lat"), + "lon": packet_lon if packet_lon is not None else existing.get("lon"), + "symbol": packet.get("symbol") or existing.get("symbol"), + "last_seen": packet.get("timestamp"), + "packet_type": packet.get("packet_type"), } # Geofence check _aprs_lat = packet_lat @@ -1550,18 +1560,18 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr if _aprs_lat is not None and _aprs_lon is not None: try: from utils.geofence import get_geofence_manager + for _gf_evt in get_geofence_manager().check_position( - callsign, 'aprs_station', _aprs_lat, _aprs_lon, - {'callsign': callsign} + callsign, "aprs_station", _aprs_lat, _aprs_lon, {"callsign": callsign} ): - process_event('aprs', _gf_evt, 'geofence') + process_event("aprs", _gf_evt, "geofence") except Exception: pass # Evict oldest stations when limit is exceeded if len(aprs_stations) > APRS_MAX_STATIONS: oldest = min( aprs_stations, - key=lambda k: aprs_stations[k].get('last_seen', ''), + key=lambda k: aprs_stations[k].get("last_seen", ""), ) del aprs_stations[oldest] @@ -1570,19 +1580,19 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr # Log if enabled if app_module.logging_enabled: try: - with open(app_module.log_file_path, 'a') as f: - ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + with open(app_module.log_file_path, "a") as f: + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") f.write(f"{ts} | APRS | {json.dumps(packet)}\n") except Exception: pass except Exception as e: logger.error(f"APRS stream error: {e}") - app_module.aprs_queue.put({'type': 'error', 'message': str(e)}) + app_module.aprs_queue.put({"type": "error", "message": str(e)}) finally: with contextlib.suppress(OSError): os.close(master_fd) - app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'}) + app_module.aprs_queue.put({"type": "status", "status": "stopped"}) # Cleanup processes for proc in [rtl_process, decoder_process]: try: @@ -1593,12 +1603,12 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr proc.kill() # Release SDR device — only if it's still ours (not reclaimed by a new start) if my_device is not None and aprs_active_device == my_device: - app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(my_device, aprs_active_sdr_type or "rtlsdr") aprs_active_device = None aprs_active_sdr_type = None -@aprs_bp.route('/tools') +@aprs_bp.route("/tools") def check_aprs_tools() -> Response: """Check for APRS decoding tools.""" has_rtl_fm = find_rtl_fm() is not None @@ -1607,59 +1617,62 @@ def check_aprs_tools() -> Response: has_multimon = find_multimon_ng() is not None has_fm_demod = has_rtl_fm or has_rx_fm - return jsonify({ - 'rtl_fm': has_rtl_fm, - 'rx_fm': has_rx_fm, - 'direwolf': has_direwolf, - 'multimon_ng': has_multimon, - 'ready': has_fm_demod and (has_direwolf or has_multimon), - 'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None) - }) + return jsonify( + { + "rtl_fm": has_rtl_fm, + "rx_fm": has_rx_fm, + "direwolf": has_direwolf, + "multimon_ng": has_multimon, + "ready": has_fm_demod and (has_direwolf or has_multimon), + "decoder": "direwolf" if has_direwolf else ("multimon-ng" if has_multimon else None), + } + ) -@aprs_bp.route('/status') +@aprs_bp.route("/status") def aprs_status() -> Response: """Get APRS decoder status.""" running = False if app_module.aprs_process: running = app_module.aprs_process.poll() is None - return jsonify({ - 'running': running, - 'packet_count': aprs_packet_count, - 'station_count': aprs_station_count, - 'last_packet_time': aprs_last_packet_time, - 'queue_size': app_module.aprs_queue.qsize() - }) + return jsonify( + { + "running": running, + "packet_count": aprs_packet_count, + "station_count": aprs_station_count, + "last_packet_time": aprs_last_packet_time, + "queue_size": app_module.aprs_queue.qsize(), + } + ) -@aprs_bp.route('/stations') +@aprs_bp.route("/stations") def get_stations() -> Response: """Get all tracked APRS stations.""" - return jsonify({ - 'stations': list(aprs_stations.values()), - 'count': len(aprs_stations) - }) + return jsonify({"stations": list(aprs_stations.values()), "count": len(aprs_stations)}) -@aprs_bp.route('/data') +@aprs_bp.route("/data") def aprs_data() -> Response: """Get APRS data snapshot for remote controller polling compatibility.""" running = False if app_module.aprs_process: running = app_module.aprs_process.poll() is None - return api_success(data={ - 'running': running, - 'stations': list(aprs_stations.values()), - 'count': len(aprs_stations), - 'packet_count': aprs_packet_count, - 'station_count': aprs_station_count, - 'last_packet_time': aprs_last_packet_time, - }) + return api_success( + data={ + "running": running, + "stations": list(aprs_stations.values()), + "count": len(aprs_stations), + "packet_count": aprs_packet_count, + "station_count": aprs_station_count, + "last_packet_time": aprs_last_packet_time, + } + ) -@aprs_bp.route('/start', methods=['POST']) +@aprs_bp.route("/start", methods=["POST"]) def start_aprs() -> Response: """Start APRS decoder.""" global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations @@ -1667,30 +1680,30 @@ def start_aprs() -> Response: with app_module.aprs_lock: if app_module.aprs_process and app_module.aprs_process.poll() is None: - return api_error('APRS decoder already running', 409) + return api_error("APRS decoder already running", 409) # Check for decoder (prefer direwolf, fallback to multimon-ng) direwolf_path = find_direwolf() multimon_path = find_multimon_ng() if not direwolf_path and not multimon_path: - return api_error('No APRS decoder found. Install direwolf or multimon-ng', 400) + return api_error("No APRS decoder found. Install direwolf or multimon-ng", 400) data = request.json or {} # Validate inputs try: - device = validate_device_index(data.get('device', '0')) - gain = validate_gain(data.get('gain', '40')) - ppm = validate_ppm(data.get('ppm', '0')) + device = validate_device_index(data.get("device", "0")) + gain = validate_gain(data.get("gain", "40")) + ppm = validate_ppm(data.get("ppm", "0")) except ValueError as e: return api_error(str(e), 400) # Check for rtl_tcp (remote SDR) connection - rtl_tcp_host = data.get('rtl_tcp_host') - rtl_tcp_port = data.get('rtl_tcp_port', 1234) + rtl_tcp_host = data.get("rtl_tcp_host") + rtl_tcp_port = data.get("rtl_tcp_port", 1234) - sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower() + sdr_type_str = str(data.get("sdr_type", "rtlsdr")).lower() try: sdr_type = SDRType(sdr_type_str) except ValueError: @@ -1698,26 +1711,26 @@ def start_aprs() -> Response: if sdr_type == SDRType.RTL_SDR: if find_rtl_fm() is None: - return api_error('rtl_fm not found. Install with: sudo apt install rtl-sdr', 400) + return api_error("rtl_fm not found. Install with: sudo apt install rtl-sdr", 400) else: if find_rx_fm() is None: - return api_error(f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.', 400) + return api_error(f"rx_fm not found. Install SoapySDR tools for {sdr_type.value}.", 400) # Reserve SDR device to prevent conflicts (skip for remote rtl_tcp) if not rtl_tcp_host: - error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str) + error = app_module.claim_sdr_device(device, "aprs", sdr_type_str) if error: - return api_error(error, 409, error_type='DEVICE_BUSY') + return api_error(error, 409, error_type="DEVICE_BUSY") aprs_active_device = device aprs_active_sdr_type = sdr_type_str # Get frequency for region - region = data.get('region', 'north_america') - frequency = APRS_FREQUENCIES.get(region, '144.390') + region = data.get("region", "north_america") + frequency = APRS_FREQUENCIES.get(region, "144.390") # Allow custom frequency override - if data.get('frequency'): - frequency = data.get('frequency') + if data.get("frequency"): + frequency = data.get("frequency") # Clear queue and reset stats while not app_module.aprs_queue.empty(): @@ -1748,22 +1761,22 @@ def start_aprs() -> Response: device=sdr_device, frequency_mhz=float(frequency), sample_rate=22050, - gain=float(gain) if gain and str(gain) != '0' else None, - ppm=int(ppm) if ppm and str(ppm) != '0' else None, - modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm', + gain=float(gain) if gain and str(gain) != "0" else None, + ppm=int(ppm) if ppm and str(ppm) != "0" else None, + modulation="nfm" if sdr_type == SDRType.RTL_SDR else "fm", squelch=None, - bias_t=bool(data.get('bias_t', False)), + bias_t=bool(data.get("bias_t", False)), ) - if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-': + if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == "-": # APRS benefits from DC blocking + fast AGC on rtl_fm. - rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-'] + rtl_cmd = rtl_cmd[:-1] + ["-E", "dc", "-A", "fast", "-"] except Exception as e: if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or "rtlsdr") aprs_active_device = None aprs_active_sdr_type = None - return api_error(f'Failed to build SDR command: {e}', 500) + return api_error(f"Failed to build SDR command: {e}", 500) # Build decoder command if direwolf_path: @@ -1778,20 +1791,12 @@ def start_aprs() -> Response: # -t 0 = disable text colors (for cleaner parsing) # NOTE: We do NOT use -q h here so we get audio level lines for the signal meter # - = read audio from stdin (must be last argument) - decoder_cmd = [ - direwolf_path, - '-c', config_path, - '-n', '1', - '-r', '22050', - '-b', '16', - '-t', '0', - '-' - ] - decoder_name = 'direwolf' + decoder_cmd = [direwolf_path, "-c", config_path, "-n", "1", "-r", "22050", "-b", "16", "-t", "0", "-"] + decoder_name = "direwolf" else: # Fallback to multimon-ng - decoder_cmd = [multimon_path, '-t', 'raw', '-a', 'AFSK1200', '-'] - decoder_name = 'multimon-ng' + decoder_cmd = [multimon_path, "-t", "raw", "-a", "AFSK1200", "-"] + decoder_name = "multimon-ng" logger.info(f"Starting APRS decoder: {' '.join(rtl_cmd)} | {' '.join(decoder_cmd)}") @@ -1799,17 +1804,12 @@ def start_aprs() -> Response: # Start rtl_fm with stdout piped to decoder. # stderr is captured via PIPE so errors are reported to the user. # NOTE: RTL-SDR Blog V4 may show offset-tuned frequency in logs - this is normal. - rtl_process = subprocess.Popen( - rtl_cmd, - stdout=PIPE, - stderr=PIPE, - start_new_session=True - ) + rtl_process = subprocess.Popen(rtl_cmd, stdout=PIPE, stderr=PIPE, start_new_session=True) # Start a thread to monitor rtl_fm stderr for errors def monitor_rtl_stderr(): for line in rtl_process.stderr: - err_text = line.decode('utf-8', errors='replace').strip() + err_text = line.decode("utf-8", errors="replace").strip() if err_text: logger.debug(f"[RTL_FM] {err_text}") @@ -1829,7 +1829,7 @@ def start_aprs() -> Response: stdout=slave_fd, stderr=slave_fd, close_fds=True, - start_new_session=True + start_new_session=True, ) # Close slave fd in parent — decoder owns it now. @@ -1844,49 +1844,49 @@ def start_aprs() -> Response: if rtl_process.poll() is not None: # rtl_fm exited early - capture stderr for diagnostics - stderr_output = '' + stderr_output = "" try: remaining = rtl_process.stderr.read() if remaining: - stderr_output = remaining.decode('utf-8', errors='replace').strip() + stderr_output = remaining.decode("utf-8", errors="replace").strip() except Exception: pass if stderr_output: logger.error(f"rtl_fm stderr:\n{stderr_output}") - error_msg = f'rtl_fm failed to start (exit code {rtl_process.returncode})' + error_msg = f"rtl_fm failed to start (exit code {rtl_process.returncode})" if stderr_output: - error_msg += f': {stderr_output[:500]}' + error_msg += f": {stderr_output[:500]}" logger.error(error_msg) with contextlib.suppress(OSError): os.close(master_fd) with contextlib.suppress(Exception): decoder_process.kill() if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or "rtlsdr") aprs_active_device = None aprs_active_sdr_type = None return api_error(error_msg, 500) if decoder_process.poll() is not None: # Decoder exited early - capture any output from PTY - error_output = '' + error_output = "" try: ready, _, _ = select.select([master_fd], [], [], 0.5) if ready: raw = os.read(master_fd, 500) - error_output = raw.decode('utf-8', errors='replace') + error_output = raw.decode("utf-8", errors="replace") except Exception: pass - error_msg = f'{decoder_name} failed to start' + error_msg = f"{decoder_name} failed to start" if error_output: - error_msg += f': {error_output}' + error_msg += f": {error_output}" logger.error(error_msg) with contextlib.suppress(OSError): os.close(master_fd) with contextlib.suppress(Exception): rtl_process.kill() if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or "rtlsdr") aprs_active_device = None aprs_active_sdr_type = None return api_error(error_msg, 500) @@ -1898,31 +1898,31 @@ def start_aprs() -> Response: # Start background thread to read decoder output and push to queue thread = threading.Thread( - target=stream_aprs_output, - args=(master_fd, rtl_process, decoder_process), - daemon=True + target=stream_aprs_output, args=(master_fd, rtl_process, decoder_process), daemon=True ) thread.start() - return jsonify({ - 'status': 'started', - 'frequency': frequency, - 'region': region, - 'device': device, - 'sdr_type': sdr_type.value, - 'decoder': decoder_name - }) + return jsonify( + { + "status": "started", + "frequency": frequency, + "region": region, + "device": device, + "sdr_type": sdr_type.value, + "decoder": decoder_name, + } + ) except Exception as e: logger.error(f"Failed to start APRS decoder: {e}") if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or "rtlsdr") aprs_active_device = None aprs_active_sdr_type = None return api_error(str(e), 500) -@aprs_bp.route('/stop', methods=['POST']) +@aprs_bp.route("/stop", methods=["POST"]) def stop_aprs() -> Response: """Stop APRS decoder. @@ -1936,26 +1936,26 @@ def stop_aprs() -> Response: with app_module.aprs_lock: processes_to_stop = [] - if hasattr(app_module, 'aprs_rtl_process') and app_module.aprs_rtl_process: + if hasattr(app_module, "aprs_rtl_process") and app_module.aprs_rtl_process: processes_to_stop.append(app_module.aprs_rtl_process) if app_module.aprs_process: processes_to_stop.append(app_module.aprs_process) if not processes_to_stop: - return api_error('APRS decoder not running', 400) + return api_error("APRS decoder not running", 400) # Release SDR device immediately so status panel reflects the # change without waiting for process termination. if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or "rtlsdr") aprs_active_device = None aprs_active_sdr_type = None # Capture refs to clear before releasing the lock - master_fd = getattr(app_module, 'aprs_master_fd', None) + master_fd = getattr(app_module, "aprs_master_fd", None) app_module.aprs_process = None - if hasattr(app_module, 'aprs_rtl_process'): + if hasattr(app_module, "aprs_rtl_process"): app_module.aprs_rtl_process = None app_module.aprs_master_fd = None @@ -1978,37 +1978,38 @@ def stop_aprs() -> Response: threading.Thread(target=_cleanup, daemon=True).start() - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@aprs_bp.route('/stream') +@aprs_bp.route("/stream") def stream_aprs() -> Response: """SSE stream for APRS packets.""" + def _on_msg(msg: dict[str, Any]) -> None: - process_event('aprs', msg, msg.get('type')) + process_event("aprs", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=app_module.aprs_queue, - channel_key='aprs', + channel_key="aprs", timeout=SSE_QUEUE_TIMEOUT, keepalive_interval=SSE_KEEPALIVE_INTERVAL, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" return response -@aprs_bp.route('/frequencies') +@aprs_bp.route("/frequencies") def get_frequencies() -> Response: """Get APRS frequencies by region.""" return jsonify(APRS_FREQUENCIES) -@aprs_bp.route('/spectrum', methods=['GET', 'POST']) +@aprs_bp.route("/spectrum", methods=["GET", "POST"]) def scan_aprs_spectrum() -> Response: """Scan spectrum around APRS frequency for signal visibility debugging. @@ -2027,7 +2028,7 @@ def scan_aprs_spectrum() -> Response: """ rtl_power_path = find_rtl_power() if not rtl_power_path: - return api_error('rtl_power not found. Install with: sudo apt install rtl-sdr', 400) + return api_error("rtl_power not found. Install with: sudo apt install rtl-sdr", 400) # Get parameters from JSON body or query args if request.is_json: @@ -2035,11 +2036,11 @@ def scan_aprs_spectrum() -> Response: else: data = {} - device = data.get('device', request.args.get('device', '0')) - gain = data.get('gain', request.args.get('gain', '0')) - region = data.get('region', request.args.get('region', 'europe')) - frequency = data.get('frequency', request.args.get('frequency')) - duration = data.get('duration', request.args.get('duration', '10')) + device = data.get("device", request.args.get("device", "0")) + gain = data.get("gain", request.args.get("gain", "0")) + region = data.get("region", request.args.get("region", "europe")) + frequency = data.get("frequency", request.args.get("frequency")) + duration = data.get("duration", request.args.get("duration", "10")) # Validate inputs try: @@ -2053,7 +2054,7 @@ def scan_aprs_spectrum() -> Response: if frequency: center_freq_mhz = float(frequency) else: - center_freq_mhz = float(APRS_FREQUENCIES.get(region, '144.800')) + center_freq_mhz = float(APRS_FREQUENCIES.get(region, "144.800")) # Scan 20 kHz around center frequency (±10 kHz) start_freq_mhz = center_freq_mhz - 0.010 @@ -2061,22 +2062,26 @@ def scan_aprs_spectrum() -> Response: bin_size_hz = 200 # 200 Hz bins for good resolution # Create temp file for rtl_power output - tmp_file = os.path.join(tempfile.gettempdir(), f'intercept_rtl_power_{os.getpid()}.csv') + tmp_file = os.path.join(tempfile.gettempdir(), f"intercept_rtl_power_{os.getpid()}.csv") try: # Build rtl_power command # Format: rtl_power -f start:end:bin_size -d device -g gain -i interval -e duration output_file rtl_power_cmd = [ rtl_power_path, - '-f', f'{start_freq_mhz}M:{end_freq_mhz}M:{bin_size_hz}', - '-d', str(device), - '-i', '1', # 1 second integration - '-e', f'{duration}s', + "-f", + f"{start_freq_mhz}M:{end_freq_mhz}M:{bin_size_hz}", + "-d", + str(device), + "-i", + "1", # 1 second integration + "-e", + f"{duration}s", ] # Gain: 0 means auto - if gain and str(gain) != '0': - rtl_power_cmd.extend(['-g', str(gain)]) + if gain and str(gain) != "0": + rtl_power_cmd.extend(["-g", str(gain)]) rtl_power_cmd.append(tmp_file) @@ -2087,17 +2092,17 @@ def scan_aprs_spectrum() -> Response: rtl_power_cmd, capture_output=True, text=True, - timeout=duration + 15 # Allow extra time for startup/shutdown + timeout=duration + 15, # Allow extra time for startup/shutdown ) if result.returncode != 0: - error_msg = result.stderr[:200] if result.stderr else f'Exit code {result.returncode}' - return api_error(f'rtl_power failed: {error_msg}', 500) + error_msg = result.stderr[:200] if result.stderr else f"Exit code {result.returncode}" + return api_error(f"rtl_power failed: {error_msg}", 500) # Parse rtl_power CSV output # Format: date, time, start_hz, end_hz, step_hz, samples, db1, db2, db3, ... if not os.path.exists(tmp_file): - return api_error('rtl_power did not produce output file', 500) + return api_error("rtl_power did not produce output file", 500) bins = [] with open(tmp_file) as f: @@ -2112,29 +2117,29 @@ def scan_aprs_spectrum() -> Response: for i, db_str in enumerate(row[6:]): db_val = float(db_str.strip()) freq_hz = row_start_hz + (i * row_step_hz) - bins.append({'freq_hz': freq_hz, 'db': db_val}) + bins.append({"freq_hz": freq_hz, "db": db_val}) except (ValueError, IndexError): continue if not bins: - return api_error('No spectrum data collected. Check SDR connection and antenna.', 500) + return api_error("No spectrum data collected. Check SDR connection and antenna.", 500) # Calculate statistics - db_values = [b['db'] for b in bins] + db_values = [b["db"] for b in bins] avg_db = sum(db_values) / len(db_values) - max_bin = max(bins, key=lambda x: x['db']) + max_bin = max(bins, key=lambda x: x["db"]) min_db = min(db_values) # Find peak near center frequency (within 5 kHz) center_hz = center_freq_mhz * 1e6 - near_center_bins = [b for b in bins if abs(b['freq_hz'] - center_hz) < 5000] + near_center_bins = [b for b in bins if abs(b["freq_hz"] - center_hz) < 5000] if near_center_bins: - peak_near_center = max(near_center_bins, key=lambda x: x['db']) + peak_near_center = max(near_center_bins, key=lambda x: x["db"]) else: peak_near_center = max_bin # Signal analysis - peak_above_noise = peak_near_center['db'] - avg_db + peak_above_noise = peak_near_center["db"] - avg_db signal_detected = peak_above_noise > 3 # 3 dB above noise floor # Generate advice @@ -2147,33 +2152,35 @@ def scan_aprs_spectrum() -> Response: else: advice = "Good signal detected. Decoding should work well." - return api_success(data={ - 'scan_params': { - 'center_freq_mhz': center_freq_mhz, - 'start_freq_mhz': start_freq_mhz, - 'end_freq_mhz': end_freq_mhz, - 'bin_size_hz': bin_size_hz, - 'duration_seconds': duration, - 'device': device, - 'gain': gain, - 'region': region, - }, - 'results': { - 'total_bins': len(bins), - 'noise_floor_db': round(avg_db, 1), - 'min_db': round(min_db, 1), - 'peak_freq_mhz': round(max_bin['freq_hz'] / 1e6, 6), - 'peak_db': round(max_bin['db'], 1), - 'peak_near_aprs_freq_mhz': round(peak_near_center['freq_hz'] / 1e6, 6), - 'peak_near_aprs_db': round(peak_near_center['db'], 1), - 'signal_above_noise_db': round(peak_above_noise, 1), - 'signal_detected': signal_detected, - }, - 'advice': advice, - }) + return api_success( + data={ + "scan_params": { + "center_freq_mhz": center_freq_mhz, + "start_freq_mhz": start_freq_mhz, + "end_freq_mhz": end_freq_mhz, + "bin_size_hz": bin_size_hz, + "duration_seconds": duration, + "device": device, + "gain": gain, + "region": region, + }, + "results": { + "total_bins": len(bins), + "noise_floor_db": round(avg_db, 1), + "min_db": round(min_db, 1), + "peak_freq_mhz": round(max_bin["freq_hz"] / 1e6, 6), + "peak_db": round(max_bin["db"], 1), + "peak_near_aprs_freq_mhz": round(peak_near_center["freq_hz"] / 1e6, 6), + "peak_near_aprs_db": round(peak_near_center["db"], 1), + "signal_above_noise_db": round(peak_above_noise, 1), + "signal_detected": signal_detected, + }, + "advice": advice, + } + ) except subprocess.TimeoutExpired: - return api_error(f'Spectrum scan timed out after {duration + 15} seconds', 500) + return api_error(f"Spectrum scan timed out after {duration + 15} seconds", 500) except Exception as e: logger.error(f"Spectrum scan error: {e}") return api_error(str(e), 500) @@ -2184,4 +2191,3 @@ def scan_aprs_spectrum() -> Response: os.remove(tmp_file) except Exception: pass - diff --git a/routes/audio_websocket.py b/routes/audio_websocket.py index 9e65214..c7962e7 100644 --- a/routes/audio_websocket.py +++ b/routes/audio_websocket.py @@ -12,6 +12,7 @@ from flask import Flask # Try to import flask-sock try: from flask_sock import Sock + WEBSOCKET_AVAILABLE = True except ImportError: WEBSOCKET_AVAILABLE = False @@ -21,36 +22,30 @@ import contextlib from utils.logging import get_logger -logger = get_logger('intercept.audio_ws') +logger = get_logger("intercept.audio_ws") # Global state audio_process = None rtl_process = None process_lock = threading.Lock() -current_config = { - 'frequency': 118.0, - 'modulation': 'am', - 'squelch': 0, - 'gain': 40, - 'device': 0 -} +current_config = {"frequency": 118.0, "modulation": "am", "squelch": 0, "gain": 40, "device": 0} def find_rtl_fm(): - return shutil.which('rtl_fm') + return shutil.which("rtl_fm") -def find_ffmpeg(): - return shutil.which('ffmpeg') - - -def _rtl_fm_demod_mode(modulation): - """Map UI modulation names to rtl_fm demod tokens.""" - mod = str(modulation or '').lower().strip() - return 'wbfm' if mod == 'wfm' else mod - - -def kill_audio_processes(): +def find_ffmpeg(): + return shutil.which("ffmpeg") + + +def _rtl_fm_demod_mode(modulation): + """Map UI modulation names to rtl_fm demod tokens.""" + mod = str(modulation or "").lower().strip() + return "wbfm" if mod == "wfm" else mod + + +def kill_audio_processes(): """Kill any running audio processes.""" global audio_process, rtl_process @@ -90,17 +85,17 @@ def start_audio_stream(config): current_config.update(config) - freq = config.get('frequency', 118.0) - mod = config.get('modulation', 'am') - squelch = config.get('squelch', 0) - gain = config.get('gain', 40) - device = config.get('device', 0) + freq = config.get("frequency", 118.0) + mod = config.get("modulation", "am") + squelch = config.get("squelch", 0) + gain = config.get("gain", 40) + device = config.get("device", 0) # Sample rates based on modulation - if mod == 'wfm': + if mod == "wfm": sample_rate = 170000 resample_rate = 32000 - elif mod in ['usb', 'lsb']: + elif mod in ["usb", "lsb"]: sample_rate = 12000 resample_rate = 12000 else: @@ -109,47 +104,55 @@ def start_audio_stream(config): freq_hz = int(freq * 1e6) - rtl_cmd = [ - rtl_fm, - '-M', _rtl_fm_demod_mode(mod), - '-f', str(freq_hz), - '-s', str(sample_rate), - '-r', str(resample_rate), - '-g', str(gain), - '-d', str(device), - '-l', str(squelch), + rtl_cmd = [ + rtl_fm, + "-M", + _rtl_fm_demod_mode(mod), + "-f", + str(freq_hz), + "-s", + str(sample_rate), + "-r", + str(resample_rate), + "-g", + str(gain), + "-d", + str(device), + "-l", + str(squelch), ] # Encode to MP3 for browser compatibility ffmpeg_cmd = [ ffmpeg, - '-hide_banner', - '-loglevel', 'error', - '-f', 's16le', - '-ar', str(resample_rate), - '-ac', '1', - '-i', 'pipe:0', - '-acodec', 'libmp3lame', - '-b:a', '128k', - '-f', 'mp3', - '-flush_packets', '1', - 'pipe:1' + "-hide_banner", + "-loglevel", + "error", + "-f", + "s16le", + "-ar", + str(resample_rate), + "-ac", + "1", + "-i", + "pipe:0", + "-acodec", + "libmp3lame", + "-b:a", + "128k", + "-f", + "mp3", + "-flush_packets", + "1", + "pipe:1", ] try: logger.info(f"Starting rtl_fm: {freq} MHz, {mod}") - rtl_process = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL - ) + rtl_process = subprocess.Popen(rtl_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) audio_process = subprocess.Popen( - ffmpeg_cmd, - stdin=rtl_process.stdout, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - bufsize=0 + ffmpeg_cmd, stdin=rtl_process.stdout, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0 ) rtl_process.stdout.close() @@ -177,7 +180,7 @@ def init_audio_websocket(app: Flask): sock = Sock(app) - @sock.route('/ws/audio') + @sock.route("/ws/audio") def audio_stream(ws): """WebSocket endpoint for audio streaming.""" logger.info("WebSocket audio client connected") @@ -192,39 +195,39 @@ def init_audio_websocket(app: Flask): msg = ws.receive(timeout=0.01) if msg: data = json.loads(msg) - cmd = data.get('cmd') + cmd = data.get("cmd") - if cmd == 'start': - config = data.get('config', {}) + if cmd == "start": + config = data.get("config", {}) logger.info(f"Starting audio: {config}") with process_lock: proc = start_audio_stream(config) if proc: streaming = True - ws.send(json.dumps({'status': 'started'})) + ws.send(json.dumps({"status": "started"})) else: - ws.send(json.dumps({'status': 'error', 'message': 'Failed to start'})) + ws.send(json.dumps({"status": "error", "message": "Failed to start"})) - elif cmd == 'stop': + elif cmd == "stop": logger.info("Stopping audio") streaming = False with process_lock: kill_audio_processes() proc = None - ws.send(json.dumps({'status': 'stopped'})) + ws.send(json.dumps({"status": "stopped"})) - elif cmd == 'tune': + elif cmd == "tune": # Change frequency/modulation - restart stream - config = data.get('config', {}) + config = data.get("config", {}) logger.info(f"Retuning: {config}") with process_lock: proc = start_audio_stream(config) if proc: streaming = True - ws.send(json.dumps({'status': 'tuned'})) + ws.send(json.dumps({"status": "tuned"})) else: streaming = False - ws.send(json.dumps({'status': 'error', 'message': 'Failed to tune'})) + ws.send(json.dumps({"status": "error", "message": "Failed to tune"})) except TimeoutError: pass @@ -248,7 +251,7 @@ def init_audio_websocket(app: Flask): elif streaming: # Process died streaming = False - ws.send(json.dumps({'status': 'error', 'message': 'Audio process died'})) + ws.send(json.dumps({"status": "error", "message": "Audio process died"})) else: time.sleep(0.01) diff --git a/routes/bluetooth.py b/routes/bluetooth.py index 4564a8c..8052d2d 100644 --- a/routes/bluetooth.py +++ b/routes/bluetooth.py @@ -29,7 +29,7 @@ from utils.responses import api_error, api_success from utils.sse import sse_stream_fanout from utils.validation import validate_bluetooth_interface -bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt') +bluetooth_bp = Blueprint("bluetooth", __name__, url_prefix="/bt") # --- v1 deprecation --- # These endpoints are deprecated in favor of /api/bluetooth/*. @@ -41,8 +41,8 @@ _v1_deprecation_logged = set() @bluetooth_bp.after_request def _add_deprecation_header(response): """Add X-Deprecated header to all v1 Bluetooth responses.""" - response.headers['X-Deprecated'] = 'Use /api/bluetooth/* endpoints instead' - endpoint = request.endpoint or '' + response.headers["X-Deprecated"] = "Use /api/bluetooth/* endpoints instead" + endpoint = request.endpoint or "" if endpoint not in _v1_deprecation_logged: _v1_deprecation_logged.add(endpoint) logger.warning(f"Deprecated v1 Bluetooth endpoint called: {request.path} — migrate to /api/bluetooth/*") @@ -51,93 +51,213 @@ def _add_deprecation_header(response): def classify_bt_device(name, device_class, services, manufacturer=None): """Classify Bluetooth device type based on available info.""" - name_lower = (name or '').lower() - mfr_lower = (manufacturer or '').lower() + name_lower = (name or "").lower() + mfr_lower = (manufacturer or "").lower() # Audio devices - check name patterns first audio_patterns = [ - 'airpod', 'earbud', 'headphone', 'headset', 'speaker', 'audio', 'beats', 'bose', - 'jbl', 'sony wh', 'sony wf', 'sennheiser', 'jabra', 'soundcore', 'anker', 'buds', - 'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen', - 'powerbeats', 'soundlink', 'soundsport', 'quietcomfort', 'qc35', 'qc45', 'nc700', - 'wh-1000', 'wf-1000', 'linkbuds', 'freebuds', 'galaxy buds', 'pixel buds', - 'echo dot', 'homepod', 'sonos', 'ue boom', 'flip', 'charge', 'xtreme', 'pulse' + "airpod", + "earbud", + "headphone", + "headset", + "speaker", + "audio", + "beats", + "bose", + "jbl", + "sony wh", + "sony wf", + "sennheiser", + "jabra", + "soundcore", + "anker", + "buds", + "earphone", + "pod", + "soundbar", + "skullcandy", + "marshall", + "b&o", + "bang", + "olufsen", + "powerbeats", + "soundlink", + "soundsport", + "quietcomfort", + "qc35", + "qc45", + "nc700", + "wh-1000", + "wf-1000", + "linkbuds", + "freebuds", + "galaxy buds", + "pixel buds", + "echo dot", + "homepod", + "sonos", + "ue boom", + "flip", + "charge", + "xtreme", + "pulse", ] if any(x in name_lower for x in audio_patterns): - return 'audio' + return "audio" # Wearables wearable_patterns = [ - 'watch', 'band', 'fitbit', 'garmin', 'mi band', 'miband', 'amazfit', - 'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire', 'fenix', - 'forerunner', 'venu', 'vivoactive', 'instinct', 'apple watch', 'gt 2', 'gt2' + "watch", + "band", + "fitbit", + "garmin", + "mi band", + "miband", + "amazfit", + "galaxy watch", + "gear", + "versa", + "sense", + "charge", + "inspire", + "fenix", + "forerunner", + "venu", + "vivoactive", + "instinct", + "apple watch", + "gt 2", + "gt2", ] if any(x in name_lower for x in wearable_patterns): - return 'wearable' + return "wearable" # Phones - check name patterns phone_patterns = [ - 'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi', - 'redmi', 'poco', 'realme', 'oppo', 'vivo', 'motorola', 'nokia', 'lg-', 'sm-', - 'moto g', 'moto e', 'note', 'ultra', 'pro max', 's21', 's22', 's23', 's24' + "iphone", + "galaxy", + "pixel", + "phone", + "android", + "oneplus", + "huawei", + "xiaomi", + "redmi", + "poco", + "realme", + "oppo", + "vivo", + "motorola", + "nokia", + "lg-", + "sm-", + "moto g", + "moto e", + "note", + "ultra", + "pro max", + "s21", + "s22", + "s23", + "s24", ] if any(x in name_lower for x in phone_patterns): - return 'phone' + return "phone" # Trackers - tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my', 'findmy'] + tracker_patterns = ["airtag", "tile", "smarttag", "chipolo", "find my", "findmy"] if any(x in name_lower for x in tracker_patterns): - return 'tracker' + return "tracker" # Input devices - input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote', 'trackpad', - 'magic keyboard', 'magic mouse', 'magic trackpad', 'mx master', 'mx keys', - 'logitech k', 'logitech m', 'razer', 'dualshock', 'dualsense', 'xbox'] + input_patterns = [ + "keyboard", + "mouse", + "controller", + "gamepad", + "remote", + "trackpad", + "magic keyboard", + "magic mouse", + "magic trackpad", + "mx master", + "mx keys", + "logitech k", + "logitech m", + "razer", + "dualshock", + "dualsense", + "xbox", + ] if any(x in name_lower for x in input_patterns): - return 'input' + return "input" # Computers/laptops - computer_patterns = ['macbook', 'imac', 'mac pro', 'mac mini', 'dell', 'hp ', 'lenovo', - 'thinkpad', 'surface', 'chromebook', 'laptop', 'desktop', 'pc'] + computer_patterns = [ + "macbook", + "imac", + "mac pro", + "mac mini", + "dell", + "hp ", + "lenovo", + "thinkpad", + "surface", + "chromebook", + "laptop", + "desktop", + "pc", + ] if any(x in name_lower for x in computer_patterns): - return 'computer' + return "computer" # Check manufacturer for device type inference - audio_manufacturers = ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats', - 'bang & olufsen', 'audio-technica', 'skullcandy', 'anker', 'plantronics'] + audio_manufacturers = [ + "bose", + "jbl", + "sony", + "sennheiser", + "jabra", + "beats", + "bang & olufsen", + "audio-technica", + "skullcandy", + "anker", + "plantronics", + ] if mfr_lower in audio_manufacturers: - return 'audio' + return "audio" - wearable_manufacturers = ['fitbit', 'garmin'] + wearable_manufacturers = ["fitbit", "garmin"] if mfr_lower in wearable_manufacturers: - return 'wearable' + return "wearable" - if mfr_lower == 'tile': - return 'tracker' + if mfr_lower == "tile": + return "tracker" - phone_manufacturers = ['samsung', 'xiaomi', 'huawei', 'oneplus', 'google', 'oppo', 'vivo', 'realme'] + phone_manufacturers = ["samsung", "xiaomi", "huawei", "oneplus", "google", "oppo", "vivo", "realme"] if mfr_lower in phone_manufacturers: - return 'phone' + return "phone" - computer_manufacturers = ['dell', 'hp', 'lenovo', 'microsoft', 'intel'] + computer_manufacturers = ["dell", "hp", "lenovo", "microsoft", "intel"] if mfr_lower in computer_manufacturers: - return 'computer' + return "computer" # Check device class if available if device_class: major_class = (device_class >> 8) & 0x1F if major_class == 1: - return 'computer' + return "computer" elif major_class == 2: - return 'phone' + return "phone" elif major_class == 4: - return 'audio' + return "audio" elif major_class == 5: - return 'input' + return "input" elif major_class == 7: - return 'wearable' + return "wearable" - return 'other' + return "other" def detect_tracker(mac, name, manufacturer_data=None): @@ -145,20 +265,20 @@ def detect_tracker(mac, name, manufacturer_data=None): mac_prefix = mac[:5].upper() if any(mac_prefix.startswith(p) for p in AIRTAG_PREFIXES): - if manufacturer_data and b'\\x4c\\x00' in manufacturer_data: - return {'type': 'airtag', 'name': 'Apple AirTag', 'risk': 'high'} + if manufacturer_data and b"\\x4c\\x00" in manufacturer_data: + return {"type": "airtag", "name": "Apple AirTag", "risk": "high"} if any(mac_prefix.startswith(p) for p in TILE_PREFIXES): - return {'type': 'tile', 'name': 'Tile Tracker', 'risk': 'medium'} + return {"type": "tile", "name": "Tile Tracker", "risk": "medium"} if any(mac_prefix.startswith(p) for p in SAMSUNG_TRACKER): - return {'type': 'smarttag', 'name': 'Samsung SmartTag', 'risk': 'medium'} + return {"type": "smarttag", "name": "Samsung SmartTag", "risk": "medium"} - name_lower = (name or '').lower() - if 'airtag' in name_lower: - return {'type': 'airtag', 'name': 'Apple AirTag', 'risk': 'high'} - if 'tile' in name_lower: - return {'type': 'tile', 'name': 'Tile Tracker', 'risk': 'medium'} + name_lower = (name or "").lower() + if "airtag" in name_lower: + return {"type": "airtag", "name": "Apple AirTag", "risk": "high"} + if "tile" in name_lower: + return {"type": "tile", "name": "Tile Tracker", "risk": "medium"} return None @@ -167,22 +287,18 @@ def detect_bt_interfaces(): """Detect available Bluetooth interfaces.""" interfaces = [] - if platform.system() == 'Linux': + if platform.system() == "Linux": try: - result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT) - blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE) + result = subprocess.run(["hciconfig"], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT) + blocks = re.split(r"(?=^hci\d+:)", result.stdout, flags=re.MULTILINE) for block in blocks: if block.strip(): - first_line = block.split('\n')[0] - match = re.match(r'(hci\d+):', first_line) + first_line = block.split("\n")[0] + match = re.match(r"(hci\d+):", first_line) if match: iface_name = match.group(1) - is_up = 'UP RUNNING' in block or '\tUP ' in block - interfaces.append({ - 'name': iface_name, - 'type': 'hci', - 'status': 'up' if is_up else 'down' - }) + is_up = "UP RUNNING" in block or "\tUP " in block + interfaces.append({"name": iface_name, "type": "hci", "status": "up" if is_up else "down"}) except FileNotFoundError: logger.debug("hciconfig not found") except subprocess.TimeoutExpired: @@ -190,12 +306,8 @@ def detect_bt_interfaces(): except subprocess.SubprocessError as e: logger.warning(f"Error running hciconfig: {e}") - elif platform.system() == 'Darwin': - interfaces.append({ - 'name': 'default', - 'type': 'macos', - 'status': 'available' - }) + elif platform.system() == "Darwin": + interfaces.append({"name": "default", "type": "macos", "status": "available"}) return interfaces @@ -203,50 +315,52 @@ def detect_bt_interfaces(): def stream_bt_scan(process, scan_mode): """Stream Bluetooth scan output to queue.""" try: - app_module.bt_queue.put({'type': 'status', 'text': 'started'}) + app_module.bt_queue.put({"type": "status", "text": "started"}) - if scan_mode == 'hcitool': - for line in iter(process.stdout.readline, b''): - line = line.decode('utf-8', errors='replace').strip() - if not line or 'LE Scan' in line: + if scan_mode == "hcitool": + for line in iter(process.stdout.readline, b""): + line = line.decode("utf-8", errors="replace").strip() + if not line or "LE Scan" in line: continue parts = line.split() - if len(parts) >= 1 and ':' in parts[0]: + if len(parts) >= 1 and ":" in parts[0]: mac = parts[0] - name = ' '.join(parts[1:]) if len(parts) > 1 else '' + name = " ".join(parts[1:]) if len(parts) > 1 else "" manufacturer = get_manufacturer(mac) device = { - 'mac': mac, - 'name': name or '[Unknown]', - 'manufacturer': manufacturer, - 'type': classify_bt_device(name, None, None, manufacturer), - 'rssi': None, - 'last_seen': time.time() + "mac": mac, + "name": name or "[Unknown]", + "manufacturer": manufacturer, + "type": classify_bt_device(name, None, None, manufacturer), + "rssi": None, + "last_seen": time.time(), } tracker = detect_tracker(mac, name) if tracker: - device['tracker'] = tracker + device["tracker"] = tracker is_new = mac not in app_module.bt_devices app_module.bt_devices[mac] = device - app_module.bt_queue.put({ - **device, - 'type': 'device', - 'device_type': device.get('type', 'other'), - 'action': 'new' if is_new else 'update', - }) + app_module.bt_queue.put( + { + **device, + "type": "device", + "device_type": device.get("type", "other"), + "action": "new" if is_new else "update", + } + ) - elif scan_mode == 'bluetoothctl': - master_fd = getattr(process, '_master_fd', None) + elif scan_mode == "bluetoothctl": + master_fd = getattr(process, "_master_fd", None) if not master_fd: - app_module.bt_queue.put({'type': 'error', 'text': 'bluetoothctl pty not available'}) + app_module.bt_queue.put({"type": "error", "text": "bluetoothctl pty not available"}) return - buffer = '' + buffer = "" while process.poll() is None: readable, _, _ = select.select([master_fd], [], [], 1.0) if readable: @@ -254,68 +368,78 @@ def stream_bt_scan(process, scan_mode): data = os.read(master_fd, 4096) if not data: break - buffer += data.decode('utf-8', errors='replace') + buffer += data.decode("utf-8", errors="replace") - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) line = line.strip() - line = re.sub(r'\x1b\[[0-9;]*m', '', line) - line = re.sub(r'\r', '', line) + line = re.sub(r"\x1b\[[0-9;]*m", "", line) + line = re.sub(r"\r", "", line) - if 'Device' in line: + if "Device" in line: # Check for RSSI update: [CHG] Device XX:XX:XX RSSI: -65 - rssi_match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}).*RSSI:\s*(-?\d+)', line) + rssi_match = re.search( + r"([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}).*RSSI:\s*(-?\d+)", + line, + ) if rssi_match: mac = rssi_match.group(1).upper() rssi = int(rssi_match.group(2)) if mac in app_module.bt_devices: - app_module.bt_devices[mac]['rssi'] = rssi - app_module.bt_devices[mac]['last_seen'] = time.time() + app_module.bt_devices[mac]["rssi"] = rssi + app_module.bt_devices[mac]["last_seen"] = time.time() # Send RSSI update - app_module.bt_queue.put({ - **app_module.bt_devices[mac], - 'type': 'device', - 'device_type': app_module.bt_devices[mac].get('type', 'other'), - 'action': 'update', - }) + app_module.bt_queue.put( + { + **app_module.bt_devices[mac], + "type": "device", + "device_type": app_module.bt_devices[mac].get("type", "other"), + "action": "update", + } + ) continue # Check for new device: [NEW] Device XX:XX:XX Name - match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)', line) + match = re.search( + r"([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)", + line, + ) if match: mac = match.group(1).upper() name = match.group(2).strip() # Extract RSSI from name if present - rssi_in_name = re.search(r'RSSI:\s*(-?\d+)', name) + rssi_in_name = re.search(r"RSSI:\s*(-?\d+)", name) initial_rssi = int(rssi_in_name.group(1)) if rssi_in_name else None # Remove "RSSI: -XX" from name - name = re.sub(r'\s*RSSI:\s*-?\d+\s*', '', name).strip() + name = re.sub(r"\s*RSSI:\s*-?\d+\s*", "", name).strip() manufacturer = get_manufacturer(mac) device = { - 'mac': mac, - 'name': name or '[Unknown]', - 'manufacturer': manufacturer, - 'type': classify_bt_device(name, None, None, manufacturer), - 'rssi': initial_rssi, - 'last_seen': time.time() + "mac": mac, + "name": name or "[Unknown]", + "manufacturer": manufacturer, + "type": classify_bt_device(name, None, None, manufacturer), + "rssi": initial_rssi, + "last_seen": time.time(), } tracker = detect_tracker(mac, name) if tracker: - device['tracker'] = tracker + device["tracker"] = tracker is_new = mac not in app_module.bt_devices app_module.bt_devices[mac] = device - app_module.bt_queue.put({ - **device, - 'type': 'device', - 'device_type': device.get('type', 'other'), - 'action': 'new' if is_new else 'update', - }) + app_module.bt_queue.put( + { + **device, + "type": "device", + "device_type": device.get("type", "other"), + "action": "new" if is_new else "update", + } + ) except OSError: break @@ -323,60 +447,56 @@ def stream_bt_scan(process, scan_mode): os.close(master_fd) except Exception as e: - app_module.bt_queue.put({'type': 'error', 'text': str(e)}) + app_module.bt_queue.put({"type": "error", "text": str(e)}) finally: process.wait() - app_module.bt_queue.put({'type': 'status', 'text': 'stopped'}) + app_module.bt_queue.put({"type": "status", "text": "stopped"}) with app_module.bt_lock: app_module.bt_process = None -@bluetooth_bp.route('/reload-oui', methods=['POST']) +@bluetooth_bp.route("/reload-oui", methods=["POST"]) def reload_oui_database_route(): """Reload OUI database from external file.""" new_db = load_oui_database() if new_db: OUI_DATABASE.clear() OUI_DATABASE.update(new_db) - return api_success(data={'entries': len(OUI_DATABASE)}) - return api_error('Could not load oui_database.json') + return api_success(data={"entries": len(OUI_DATABASE)}) + return api_error("Could not load oui_database.json") -@bluetooth_bp.route('/interfaces') +@bluetooth_bp.route("/interfaces") def get_bt_interfaces(): """Get available Bluetooth interfaces and tools.""" interfaces = detect_bt_interfaces() tools = { - 'hcitool': check_tool('hcitool'), - 'bluetoothctl': check_tool('bluetoothctl'), - 'hciconfig': check_tool('hciconfig'), - 'l2ping': check_tool('l2ping'), - 'sdptool': check_tool('sdptool') + "hcitool": check_tool("hcitool"), + "bluetoothctl": check_tool("bluetoothctl"), + "hciconfig": check_tool("hciconfig"), + "l2ping": check_tool("l2ping"), + "sdptool": check_tool("sdptool"), } - return jsonify({ - 'interfaces': interfaces, - 'tools': tools, - 'current_interface': app_module.bt_interface - }) + return jsonify({"interfaces": interfaces, "tools": tools, "current_interface": app_module.bt_interface}) -@bluetooth_bp.route('/scan/start', methods=['POST']) +@bluetooth_bp.route("/scan/start", methods=["POST"]) def start_bt_scan(): """Start Bluetooth scanning.""" with app_module.bt_lock: if app_module.bt_process: if app_module.bt_process.poll() is None: - return api_error('Scan already running') + return api_error("Scan already running") else: app_module.bt_process = None data = request.json - scan_mode = data.get('mode', 'hcitool') - scan_ble = data.get('scan_ble', True) + scan_mode = data.get("mode", "hcitool") + scan_ble = data.get("scan_ble", True) # Validate Bluetooth interface name try: - interface = validate_bluetooth_interface(data.get('interface', 'hci0')) + interface = validate_bluetooth_interface(data.get("interface", "hci0")) except ValueError as e: return api_error(str(e), 400) @@ -390,59 +510,51 @@ def start_bt_scan(): break try: - if scan_mode == 'hcitool': + if scan_mode == "hcitool": if scan_ble: - cmd = ['hcitool', '-i', interface, 'lescan', '--duplicates'] + cmd = ["hcitool", "-i", interface, "lescan", "--duplicates"] else: - cmd = ['hcitool', '-i', interface, 'scan'] + cmd = ["hcitool", "-i", interface, "scan"] - app_module.bt_process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + app_module.bt_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - elif scan_mode == 'bluetoothctl': + elif scan_mode == "bluetoothctl": master_fd, slave_fd = pty.openpty() app_module.bt_process = subprocess.Popen( - ['bluetoothctl'], - stdin=slave_fd, - stdout=slave_fd, - stderr=slave_fd, - close_fds=True + ["bluetoothctl"], stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, close_fds=True ) os.close(slave_fd) app_module.bt_process._master_fd = master_fd time.sleep(0.5) - os.write(master_fd, b'power on\n') + os.write(master_fd, b"power on\n") time.sleep(0.3) - os.write(master_fd, b'scan on\n') + os.write(master_fd, b"scan on\n") else: - return api_error(f'Unknown scan mode: {scan_mode}') + return api_error(f"Unknown scan mode: {scan_mode}") time.sleep(0.5) if app_module.bt_process.poll() is not None: - stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip() + stderr_output = app_module.bt_process.stderr.read().decode("utf-8", errors="replace").strip() app_module.bt_process = None - return api_error(stderr_output or 'Process failed to start') + return api_error(stderr_output or "Process failed to start") thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode)) thread.daemon = True thread.start() - app_module.bt_queue.put({'type': 'info', 'text': f'Started {scan_mode} scan on {interface}'}) - return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface}) + app_module.bt_queue.put({"type": "info", "text": f"Started {scan_mode} scan on {interface}"}) + return jsonify({"status": "started", "mode": scan_mode, "interface": interface}) except FileNotFoundError as e: - return api_error(f'Tool not found: {e.filename}') + return api_error(f"Tool not found: {e.filename}") except Exception as e: return api_error(str(e)) -@bluetooth_bp.route('/scan/stop', methods=['POST']) +@bluetooth_bp.route("/scan/stop", methods=["POST"]) def stop_bt_scan(): """Stop Bluetooth scanning.""" with app_module.bt_lock: @@ -453,18 +565,18 @@ def stop_bt_scan(): except subprocess.TimeoutExpired: app_module.bt_process.kill() app_module.bt_process = None - return jsonify({'status': 'stopped'}) - return jsonify({'status': 'not_running'}) + return jsonify({"status": "stopped"}) + return jsonify({"status": "not_running"}) -@bluetooth_bp.route('/reset', methods=['POST']) +@bluetooth_bp.route("/reset", methods=["POST"]) def reset_bt_adapter(): """Reset Bluetooth adapter.""" data = request.json # Validate Bluetooth interface name try: - interface = validate_bluetooth_interface(data.get('interface', 'hci0')) + interface = validate_bluetooth_interface(data.get("interface", "hci0")) except ValueError as e: return api_error(str(e), 400) @@ -479,101 +591,100 @@ def reset_bt_adapter(): app_module.bt_process = None try: - subprocess.run(['pkill', '-f', 'hcitool'], capture_output=True, timeout=2) - subprocess.run(['pkill', '-f', 'bluetoothctl'], capture_output=True, timeout=2) + subprocess.run(["pkill", "-f", "hcitool"], capture_output=True, timeout=2) + subprocess.run(["pkill", "-f", "bluetoothctl"], capture_output=True, timeout=2) time.sleep(0.5) - subprocess.run(['rfkill', 'unblock', 'bluetooth'], capture_output=True, timeout=5) - subprocess.run(['hciconfig', interface, 'down'], capture_output=True, timeout=5) + subprocess.run(["rfkill", "unblock", "bluetooth"], capture_output=True, timeout=5) + subprocess.run(["hciconfig", interface, "down"], capture_output=True, timeout=5) time.sleep(1) - subprocess.run(['hciconfig', interface, 'up'], capture_output=True, timeout=5) + subprocess.run(["hciconfig", interface, "up"], capture_output=True, timeout=5) time.sleep(0.5) - result = subprocess.run(['hciconfig', interface], capture_output=True, text=True, timeout=5) - is_up = 'UP RUNNING' in result.stdout + result = subprocess.run(["hciconfig", interface], capture_output=True, text=True, timeout=5) + is_up = "UP RUNNING" in result.stdout - return jsonify({ - 'status': 'success' if is_up else 'warning', - 'message': f'Adapter {interface} reset' if is_up else 'Reset attempted but adapter may still be down', - 'is_up': is_up - }) + return jsonify( + { + "status": "success" if is_up else "warning", + "message": f"Adapter {interface} reset" if is_up else "Reset attempted but adapter may still be down", + "is_up": is_up, + } + ) except Exception as e: return api_error(str(e)) -@bluetooth_bp.route('/enum', methods=['POST']) +@bluetooth_bp.route("/enum", methods=["POST"]) def enum_bt_services(): """Enumerate services on a Bluetooth device.""" data = request.json - target_mac = data.get('mac') + target_mac = data.get("mac") if not target_mac: - return api_error('Target MAC required') + return api_error("Target MAC required") try: - result = subprocess.run( - ['sdptool', 'browse', target_mac], - capture_output=True, text=True, timeout=30 - ) + result = subprocess.run(["sdptool", "browse", target_mac], capture_output=True, text=True, timeout=30) services = [] current_service = {} - for line in result.stdout.split('\n'): + for line in result.stdout.split("\n"): line = line.strip() - if line.startswith('Service Name:'): + if line.startswith("Service Name:"): if current_service: services.append(current_service) - current_service = {'name': line.split(':', 1)[1].strip()} - elif line.startswith('Service Description:'): - current_service['description'] = line.split(':', 1)[1].strip() + current_service = {"name": line.split(":", 1)[1].strip()} + elif line.startswith("Service Description:"): + current_service["description"] = line.split(":", 1)[1].strip() if current_service: services.append(current_service) app_module.bt_services[target_mac] = services - return api_success(data={ - 'mac': target_mac, - 'services': services - }) + return api_success(data={"mac": target_mac, "services": services}) except subprocess.TimeoutExpired: - return api_error('Connection timed out') + return api_error("Connection timed out") except FileNotFoundError: - return api_error('sdptool not found') + return api_error("sdptool not found") except Exception as e: return api_error(str(e)) -@bluetooth_bp.route('/devices') +@bluetooth_bp.route("/devices") def get_bt_devices(): """Get current list of discovered Bluetooth devices.""" - return jsonify({ - 'devices': list(app_module.bt_devices.values()), - 'beacons': list(app_module.bt_beacons.values()), - 'interface': app_module.bt_interface - }) + return jsonify( + { + "devices": list(app_module.bt_devices.values()), + "beacons": list(app_module.bt_beacons.values()), + "interface": app_module.bt_interface, + } + ) -@bluetooth_bp.route('/stream') +@bluetooth_bp.route("/stream") def stream_bt(): """SSE stream for Bluetooth events.""" + def _on_msg(msg: dict[str, Any]) -> None: - process_event('bluetooth', msg, msg.get('type')) + process_event("bluetooth", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=app_module.bt_queue, - channel_key='bluetooth', + channel_key="bluetooth", timeout=1.0, keepalive_interval=30.0, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response diff --git a/routes/bt_locate.py b/routes/bt_locate.py index acc95b7..6169d8a 100644 --- a/routes/bt_locate.py +++ b/routes/bt_locate.py @@ -24,12 +24,12 @@ from utils.bt_locate import ( from utils.responses import api_error from utils.sse import format_sse -logger = logging.getLogger('intercept.bt_locate') +logger = logging.getLogger("intercept.bt_locate") -bt_locate_bp = Blueprint('bt_locate', __name__, url_prefix='/bt_locate') +bt_locate_bp = Blueprint("bt_locate", __name__, url_prefix="/bt_locate") -@bt_locate_bp.route('/start', methods=['POST']) +@bt_locate_bp.route("/start", methods=["POST"]) def start_session(): """ Start a locate session. @@ -54,53 +54,55 @@ def start_session(): # Build target target = LocateTarget( - mac_address=data.get('mac_address'), - name_pattern=data.get('name_pattern'), - irk_hex=data.get('irk_hex'), - device_id=data.get('device_id'), - device_key=data.get('device_key'), - fingerprint_id=data.get('fingerprint_id'), - known_name=data.get('known_name'), - known_manufacturer=data.get('known_manufacturer'), - last_known_rssi=data.get('last_known_rssi'), + mac_address=data.get("mac_address"), + name_pattern=data.get("name_pattern"), + irk_hex=data.get("irk_hex"), + device_id=data.get("device_id"), + device_key=data.get("device_key"), + fingerprint_id=data.get("fingerprint_id"), + known_name=data.get("known_name"), + known_manufacturer=data.get("known_manufacturer"), + last_known_rssi=data.get("last_known_rssi"), ) # At least one identifier required - if not any([ - target.mac_address, - target.name_pattern, - target.irk_hex, - target.device_id, - target.device_key, - target.fingerprint_id, - ]): + if not any( + [ + target.mac_address, + target.name_pattern, + target.irk_hex, + target.device_id, + target.device_key, + target.fingerprint_id, + ] + ): return api_error( - 'At least one target identifier required ' - '(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)', - 400 + "At least one target identifier required " + "(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)", + 400, ) # Parse environment - env_str = data.get('environment', 'OUTDOOR').upper() + env_str = data.get("environment", "OUTDOOR").upper() try: environment = Environment[env_str] except KeyError: - return api_error(f'Invalid environment: {env_str}', 400) + return api_error(f"Invalid environment: {env_str}", 400) - custom_exponent = data.get('custom_exponent') + custom_exponent = data.get("custom_exponent") if custom_exponent is not None: try: custom_exponent = float(custom_exponent) except (ValueError, TypeError): - return api_error('custom_exponent must be a number', 400) + return api_error("custom_exponent must be a number", 400) # Fallback coordinates when GPS is unavailable (from user settings) fallback_lat = None fallback_lon = None - if data.get('fallback_lat') is not None and data.get('fallback_lon') is not None: + if data.get("fallback_lat") is not None and data.get("fallback_lon") is not None: try: - fallback_lat = float(data['fallback_lat']) - fallback_lon = float(data['fallback_lon']) + fallback_lat = float(data["fallback_lat"]) + fallback_lon = float(data["fallback_lon"]) except (ValueError, TypeError): pass @@ -110,61 +112,65 @@ def start_session(): ) try: - session = start_locate_session( - target, environment, custom_exponent, fallback_lat, fallback_lon - ) + session = start_locate_session(target, environment, custom_exponent, fallback_lat, fallback_lon) except RuntimeError as exc: logger.warning(f"Unable to start BT Locate session: {exc}") - return api_error('Bluetooth scanner could not be started. Check adapter permissions/capabilities.', 503) + return api_error("Bluetooth scanner could not be started. Check adapter permissions/capabilities.", 503) except Exception as exc: logger.exception(f"Unexpected error starting BT Locate session: {exc}") - return api_error('Failed to start locate session', 500) + return api_error("Failed to start locate session", 500) - return jsonify({ - 'status': 'started', - 'session': session.get_status(), - }) + return jsonify( + { + "status": "started", + "session": session.get_status(), + } + ) -@bt_locate_bp.route('/stop', methods=['POST']) +@bt_locate_bp.route("/stop", methods=["POST"]) def stop_session(): """Stop the active locate session.""" session = get_locate_session() if not session: - return jsonify({'status': 'no_session'}) + return jsonify({"status": "no_session"}) stop_locate_session() - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@bt_locate_bp.route('/status', methods=['GET']) +@bt_locate_bp.route("/status", methods=["GET"]) def get_status(): """Get locate session status.""" session = get_locate_session() if not session: - return jsonify({ - 'active': False, - 'target': None, - }) + return jsonify( + { + "active": False, + "target": None, + } + ) - include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes') + include_debug = str(request.args.get("debug", "")).lower() in ("1", "true", "yes") return jsonify(session.get_status(include_debug=include_debug)) -@bt_locate_bp.route('/trail', methods=['GET']) +@bt_locate_bp.route("/trail", methods=["GET"]) def get_trail(): """Get detection trail data.""" session = get_locate_session() if not session: - return jsonify({'trail': [], 'gps_trail': []}) + return jsonify({"trail": [], "gps_trail": []}) - return jsonify({ - 'trail': session.get_trail(), - 'gps_trail': session.get_gps_trail(), - }) + return jsonify( + { + "trail": session.get_trail(), + "gps_trail": session.get_gps_trail(), + } + ) -@bt_locate_bp.route('/stream', methods=['GET']) +@bt_locate_bp.route("/stream", methods=["GET"]) def stream_detections(): """SSE stream of detection events.""" @@ -173,27 +179,27 @@ def stream_detections(): # Re-fetch session each iteration in case it changes s = get_locate_session() if not s: - yield format_sse({'type': 'session_ended'}, event='session_ended') + yield format_sse({"type": "session_ended"}, event="session_ended") return try: event = s.event_queue.get(timeout=2.0) - yield format_sse(event, event='detection') + yield format_sse(event, event="detection") except Exception: - yield format_sse({}, event='ping') + yield format_sse({}, event="ping") return Response( event_generator(), - mimetype='text/event-stream', + mimetype="text/event-stream", headers={ - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no', - } + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, ) -@bt_locate_bp.route('/resolve_rpa', methods=['POST']) +@bt_locate_bp.route("/resolve_rpa", methods=["POST"]) def test_resolve_rpa(): """ Test if an IRK resolves to a given address. @@ -206,43 +212,45 @@ def test_resolve_rpa(): JSON with resolution result. """ data = request.get_json() or {} - irk_hex = data.get('irk_hex', '') - address = data.get('address', '') + irk_hex = data.get("irk_hex", "") + address = data.get("address", "") if not irk_hex or not address: - return api_error('irk_hex and address are required', 400) + return api_error("irk_hex and address are required", 400) try: irk = bytes.fromhex(irk_hex) except ValueError: - return api_error('Invalid IRK hex string', 400) + return api_error("Invalid IRK hex string", 400) if len(irk) != 16: - return api_error('IRK must be exactly 16 bytes (32 hex characters)', 400) + return api_error("IRK must be exactly 16 bytes (32 hex characters)", 400) result = resolve_rpa(irk, address) - return jsonify({ - 'resolved': result, - 'irk_hex': irk_hex, - 'address': address, - }) + return jsonify( + { + "resolved": result, + "irk_hex": irk_hex, + "address": address, + } + ) -@bt_locate_bp.route('/environment', methods=['POST']) +@bt_locate_bp.route("/environment", methods=["POST"]) def set_environment(): """Update the environment on the active session.""" session = get_locate_session() if not session: - return api_error('no active session', 400) + return api_error("no active session", 400) data = request.get_json() or {} - env_str = data.get('environment', '').upper() + env_str = data.get("environment", "").upper() try: environment = Environment[env_str] except KeyError: - return api_error(f'Invalid environment: {env_str}', 400) + return api_error(f"Invalid environment: {env_str}", 400) - custom_exponent = data.get('custom_exponent') + custom_exponent = data.get("custom_exponent") if custom_exponent is not None: try: custom_exponent = float(custom_exponent) @@ -250,59 +258,63 @@ def set_environment(): custom_exponent = None session.set_environment(environment, custom_exponent) - return jsonify({ - 'status': 'updated', - 'environment': environment.name, - 'path_loss_exponent': session.estimator.n, - }) + return jsonify( + { + "status": "updated", + "environment": environment.name, + "path_loss_exponent": session.estimator.n, + } + ) -@bt_locate_bp.route('/debug', methods=['GET']) +@bt_locate_bp.route("/debug", methods=["GET"]) def debug_matching(): """Debug endpoint showing scanner devices and match results.""" session = get_locate_session() if not session: - return api_error('no session') + return api_error("no session") scanner = session._scanner if not scanner: - return api_error('no scanner') + return api_error("no scanner") devices = scanner.get_devices(max_age_seconds=30) - return jsonify({ - 'target': session.target.to_dict(), - 'device_count': len(devices), - 'devices': [ - { - 'device_id': d.device_id, - 'address': d.address, - 'name': d.name, - 'rssi': d.rssi_current, - 'matches': session.target.matches(d), - } - for d in devices - ], - }) + return jsonify( + { + "target": session.target.to_dict(), + "device_count": len(devices), + "devices": [ + { + "device_id": d.device_id, + "address": d.address, + "name": d.name, + "rssi": d.rssi_current, + "matches": session.target.matches(d), + } + for d in devices + ], + } + ) -@bt_locate_bp.route('/paired_irks', methods=['GET']) +@bt_locate_bp.route("/paired_irks", methods=["GET"]) def paired_irks(): """Return paired Bluetooth devices that have IRKs.""" try: devices = get_paired_irks() except Exception as e: logger.exception("Failed to read paired IRKs") - return jsonify({'devices': [], 'error': str(e)}) + return jsonify({"devices": [], "error": str(e)}) - return jsonify({'devices': devices}) + return jsonify({"devices": devices}) -@bt_locate_bp.route('/clear_trail', methods=['POST']) +@bt_locate_bp.route("/clear_trail", methods=["POST"]) def clear_trail(): """Clear the detection trail.""" session = get_locate_session() if not session: - return jsonify({'status': 'no_session'}) + return jsonify({"status": "no_session"}) session.clear_trail() - return jsonify({'status': 'cleared'}) + return jsonify({"status": "cleared"}) diff --git a/routes/correlation.py b/routes/correlation.py index c4fd678..5bb2132 100644 --- a/routes/correlation.py +++ b/routes/correlation.py @@ -9,12 +9,12 @@ from utils.correlation import get_correlations from utils.logging import get_logger from utils.responses import api_error, api_success -logger = get_logger('intercept.correlation') +logger = get_logger("intercept.correlation") -correlation_bp = Blueprint('correlation', __name__, url_prefix='/correlation') +correlation_bp = Blueprint("correlation", __name__, url_prefix="/correlation") -@correlation_bp.route('', methods=['GET']) +@correlation_bp.route("", methods=["GET"]) def get_device_correlations() -> Response: """ Get device correlations between WiFi and Bluetooth. @@ -23,8 +23,8 @@ def get_device_correlations() -> Response: min_confidence: Minimum confidence threshold (default 0.5) include_historical: Include database correlations (default true) """ - min_confidence = request.args.get('min_confidence', 0.5, type=float) - include_historical = request.args.get('include_historical', 'true').lower() == 'true' + min_confidence = request.args.get("min_confidence", 0.5, type=float) + include_historical = request.args.get("include_historical", "true").lower() == "true" try: # Get current device data @@ -37,20 +37,18 @@ def get_device_correlations() -> Response: wifi_devices=wifi_devices, bt_devices=bt_devices, min_confidence=min_confidence, - include_historical=include_historical + include_historical=include_historical, ) - return api_success(data={ - 'correlations': correlations, - 'wifi_count': len(wifi_devices), - 'bt_count': len(bt_devices) - }) + return api_success( + data={"correlations": correlations, "wifi_count": len(wifi_devices), "bt_count": len(bt_devices)} + ) except Exception as e: logger.error(f"Error calculating correlations: {e}") return api_error(str(e), 500) -@correlation_bp.route('/analyze', methods=['POST']) +@correlation_bp.route("/analyze", methods=["POST"]) def analyze_correlation() -> Response: """ Analyze specific device pair for correlation. @@ -60,11 +58,11 @@ def analyze_correlation() -> Response: bt_mac: Bluetooth device MAC address """ data = request.json or {} - wifi_mac = data.get('wifi_mac') - bt_mac = data.get('bt_mac') + wifi_mac = data.get("wifi_mac") + bt_mac = data.get("bt_mac") if not wifi_mac or not bt_mac: - return api_error('wifi_mac and bt_mac are required', 400) + return api_error("wifi_mac and bt_mac are required", 400) try: # Get device data @@ -75,23 +73,23 @@ def analyze_correlation() -> Response: bt_device = app_module.bt_devices.get(bt_mac) if not wifi_device: - return api_error(f'WiFi device {wifi_mac} not found', 404) + return api_error(f"WiFi device {wifi_mac} not found", 404) if not bt_device: - return api_error(f'Bluetooth device {bt_mac} not found', 404) + return api_error(f"Bluetooth device {bt_mac} not found", 404) # Calculate correlation for this specific pair correlations = get_correlations( wifi_devices={wifi_mac: wifi_device}, bt_devices={bt_mac: bt_device}, min_confidence=0.0, # Show even low confidence for analysis - include_historical=True + include_historical=True, ) if correlations: - return api_success(data={'correlation': correlations[0]}) + return api_success(data={"correlation": correlations[0]}) else: - return api_success(data={'correlation': None}, message='No correlation detected between these devices') + return api_success(data={"correlation": None}, message="No correlation detected between these devices") except Exception as e: logger.error(f"Error analyzing correlation: {e}") return api_error(str(e), 500) diff --git a/routes/dsc.py b/routes/dsc.py index cd458a6..119111a 100644 --- a/routes/dsc.py +++ b/routes/dsc.py @@ -47,9 +47,9 @@ from utils.validation import ( validate_rtl_tcp_port, ) -logger = logging.getLogger('intercept.dsc') +logger = logging.getLogger("intercept.dsc") -dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc') +dsc_bp = Blueprint("dsc", __name__, url_prefix="/dsc") # Module state (track if running independent of process state) dsc_running = False @@ -62,12 +62,12 @@ dsc_active_sdr_type: str | None = None def _get_dsc_decoder_path() -> str | None: """Get path to DSC decoder.""" # Check for our custom decoder - project_bin = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'bin', 'dsc-decoder') + project_bin = os.path.join(os.path.dirname(os.path.dirname(__file__)), "bin", "dsc-decoder") if os.path.isfile(project_bin) and os.access(project_bin, os.X_OK): return project_bin # Check system PATH - system_decoder = shutil.which('dsc-decoder') + system_decoder = shutil.which("dsc-decoder") if system_decoder: return system_decoder @@ -76,7 +76,7 @@ def _get_dsc_decoder_path() -> str | None: def _check_dsc_tools() -> dict: """Check availability of DSC decoding tools.""" - rtl_fm_path = get_tool_path('rtl_fm') + rtl_fm_path = get_tool_path("rtl_fm") decoder_path = _get_dsc_decoder_path() # Check for scipy/numpy (needed for decoder) @@ -84,24 +84,16 @@ def _check_dsc_tools() -> dict: try: import numpy import scipy + scipy_available = True except ImportError: pass return { - 'rtl_fm': { - 'available': rtl_fm_path is not None, - 'path': rtl_fm_path - }, - 'dsc_decoder': { - 'available': decoder_path is not None, - 'path': decoder_path - }, - 'scipy': { - 'available': scipy_available, - 'note': 'Required for DSC signal processing' - }, - 'ready': rtl_fm_path is not None and decoder_path is not None and scipy_available + "rtl_fm": {"available": rtl_fm_path is not None, "path": rtl_fm_path}, + "dsc_decoder": {"available": decoder_path is not None, "path": decoder_path}, + "scipy": {"available": scipy_available, "note": "Required for DSC signal processing"}, + "ready": rtl_fm_path is not None and decoder_path is not None and scipy_available, } @@ -116,7 +108,7 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non global dsc_running try: - app_module.dsc_queue.put({'type': 'status', 'status': 'started'}) + app_module.dsc_queue.put({"type": "status", "status": "started"}) buffer = "" while dsc_running: @@ -130,10 +122,10 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non data = os.read(master_fd, 1024) if not data: break - buffer += data.decode('utf-8', errors='replace') + buffer += data.decode("utf-8", errors="replace") - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) line = line.strip() if not line: continue @@ -143,7 +135,7 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non if parsed: # Generate unique message ID msg_id = f"{parsed['source_mmsi']}_{int(time.time() * 1000)}" - parsed['id'] = msg_id + parsed["id"] = msg_id # Store in transient DataStore app_module.dsc_messages.set(msg_id, parsed) @@ -155,14 +147,11 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non logger.warning("DSC queue full, dropping message") # Store critical alerts permanently - if parsed.get('is_critical'): + if parsed.get("is_critical"): _store_critical_alert(parsed) else: # Raw output for debugging - app_module.dsc_queue.put({ - 'type': 'raw', - 'text': line - }) + app_module.dsc_queue.put({"type": "raw", "text": line}) except OSError: break @@ -172,10 +161,7 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non except Exception as e: logger.error(f"DSC decoder error: {e}") - app_module.dsc_queue.put({ - 'type': 'error', - 'error': str(e) - }) + app_module.dsc_queue.put({"type": "error", "error": str(e)}) finally: global dsc_active_device, dsc_active_sdr_type with contextlib.suppress(OSError): @@ -193,13 +179,13 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non with contextlib.suppress(Exception): proc.kill() unregister_process(proc) - app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'}) + app_module.dsc_queue.put({"type": "status", "status": "stopped"}) with app_module.dsc_lock: app_module.dsc_process = None app_module.dsc_rtl_process = None # Release SDR device if dsc_active_device is not None: - app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or "rtlsdr") dsc_active_device = None dsc_active_sdr_type = None @@ -208,15 +194,15 @@ def _store_critical_alert(msg: dict) -> None: """Store critical DSC alert (DISTRESS/URGENCY) to database.""" try: store_dsc_alert( - source_mmsi=msg.get('source_mmsi', ''), - format_code=str(msg.get('format_code', '')), - category=msg.get('category', 'UNKNOWN'), - source_name=msg.get('source_name'), - dest_mmsi=msg.get('dest_mmsi'), - nature_of_distress=msg.get('nature_of_distress'), - latitude=msg.get('latitude'), - longitude=msg.get('longitude'), - raw_message=msg.get('raw_message') + source_mmsi=msg.get("source_mmsi", ""), + format_code=str(msg.get("format_code", "")), + category=msg.get("category", "UNKNOWN"), + source_name=msg.get("source_name"), + dest_mmsi=msg.get("dest_mmsi"), + nature_of_distress=msg.get("nature_of_distress"), + latitude=msg.get("latitude"), + longitude=msg.get("longitude"), + raw_message=msg.get("raw_message"), ) logger.info(f"Stored {msg.get('category')} alert from {msg.get('source_mmsi')}") except Exception as e: @@ -231,115 +217,101 @@ def monitor_rtl_stderr(process: subprocess.Popen) -> None: for line in process.stderr: if not dsc_running: break - err_text = line.decode('utf-8', errors='replace').strip() + err_text = line.decode("utf-8", errors="replace").strip() if err_text: logger.debug(f"[RTL_FM] {err_text}") # Check for device busy error - if 'usb_claim_interface' in err_text.lower(): - app_module.dsc_queue.put({ - 'type': 'error', - 'error': 'SDR device busy', - 'error_type': 'DEVICE_BUSY', - 'suggestion': 'Use a different SDR device or stop other SDR processes' - }) + if "usb_claim_interface" in err_text.lower(): + app_module.dsc_queue.put( + { + "type": "error", + "error": "SDR device busy", + "error_type": "DEVICE_BUSY", + "suggestion": "Use a different SDR device or stop other SDR processes", + } + ) # Check for other common errors - if 'no supported devices' in err_text.lower(): - app_module.dsc_queue.put({ - 'type': 'error', - 'error': 'No SDR device found', - 'error_type': 'NO_DEVICE' - }) + if "no supported devices" in err_text.lower(): + app_module.dsc_queue.put( + {"type": "error", "error": "No SDR device found", "error_type": "NO_DEVICE"} + ) except Exception: pass -@dsc_bp.route('/status') +@dsc_bp.route("/status") def get_status() -> Response: """Get DSC decoder status.""" global dsc_running with app_module.dsc_lock: - running = ( - dsc_running and - app_module.dsc_process is not None and - app_module.dsc_process.poll() is None - ) + running = dsc_running and app_module.dsc_process is not None and app_module.dsc_process.poll() is None # Get message counts message_count = len(app_module.dsc_messages) alert_summary = get_dsc_alert_summary() - return jsonify({ - 'running': running, - 'frequency': DSC_VHF_FREQUENCY_MHZ, - 'message_count': message_count, - 'alerts': alert_summary - }) + return jsonify( + { + "running": running, + "frequency": DSC_VHF_FREQUENCY_MHZ, + "message_count": message_count, + "alerts": alert_summary, + } + ) -@dsc_bp.route('/tools') +@dsc_bp.route("/tools") def check_tools() -> Response: """Check DSC decoder tool availability.""" tools = _check_dsc_tools() return jsonify(tools) -@dsc_bp.route('/start', methods=['POST']) +@dsc_bp.route("/start", methods=["POST"]) def start_decoding() -> Response: """Start DSC decoder.""" global dsc_running with app_module.dsc_lock: if app_module.dsc_process and app_module.dsc_process.poll() is None: - return jsonify({ - 'status': 'error', - 'message': 'DSC decoder already running' - }), 409 + return jsonify({"status": "error", "message": "DSC decoder already running"}), 409 # Check tools tools = _check_dsc_tools() - if not tools['ready']: + if not tools["ready"]: missing = [] - if not tools['rtl_fm']['available']: - missing.append('rtl_fm') - if not tools['dsc_decoder']['available']: - missing.append('dsc-decoder') - if not tools['scipy']['available']: - missing.append('scipy/numpy') + if not tools["rtl_fm"]["available"]: + missing.append("rtl_fm") + if not tools["dsc_decoder"]["available"]: + missing.append("dsc-decoder") + if not tools["scipy"]["available"]: + missing.append("scipy/numpy") - return jsonify({ - 'status': 'error', - 'message': f'Missing required tools: {", ".join(missing)}' - }), 400 + return jsonify({"status": "error", "message": f"Missing required tools: {', '.join(missing)}"}), 400 data = request.json or {} # Validate device try: - device = validate_device_index(data.get('device', '0')) + device = validate_device_index(data.get("device", "0")) except ValueError as e: - return jsonify({ - 'status': 'error', - 'message': str(e) - }), 400 + return jsonify({"status": "error", "message": str(e)}), 400 # Validate gain try: - gain = validate_gain(data.get('gain', '40')) + gain = validate_gain(data.get("gain", "40")) except ValueError as e: - return jsonify({ - 'status': 'error', - 'message': str(e) - }), 400 + return jsonify({"status": "error", "message": str(e)}), 400 # Get SDR type from request - sdr_type_str = data.get('sdr_type', 'rtlsdr') + sdr_type_str = data.get("sdr_type", "rtlsdr") # Check for rtl_tcp (remote SDR) connection - rtl_tcp_host = data.get('rtl_tcp_host') - rtl_tcp_port = data.get('rtl_tcp_port', 1234) + rtl_tcp_host = data.get("rtl_tcp_host") + rtl_tcp_port = data.get("rtl_tcp_port", 1234) try: sdr_type = SDRType(sdr_type_str) @@ -350,13 +322,9 @@ def start_decoding() -> Response: global dsc_active_device, dsc_active_sdr_type if not rtl_tcp_host: device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str) + error = app_module.claim_sdr_device(device_int, "dsc", sdr_type_str) if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error - }), 409 + return jsonify({"status": "error", "error_type": "DEVICE_BUSY", "message": error}), 409 dsc_active_device = device_int dsc_active_sdr_type = sdr_type_str @@ -369,7 +337,7 @@ def start_decoding() -> Response: break # Build rtl_fm command via SDR abstraction layer - decoder_path = tools['dsc_decoder']['path'] + decoder_path = tools["dsc_decoder"]["path"] if rtl_tcp_host: try: @@ -383,39 +351,33 @@ def start_decoding() -> Response: sdr_device = SDRFactory.create_default_device(sdr_type, index=int(device)) builder = SDRFactory.get_builder(sdr_device.sdr_type) - rtl_cmd = list(builder.build_fm_demod_command( - device=sdr_device, - frequency_mhz=DSC_VHF_FREQUENCY_MHZ, - sample_rate=DSC_SAMPLE_RATE, - gain=float(gain) if gain and str(gain) != '0' else None, - modulation='fm', - squelch=0, - )) + rtl_cmd = list( + builder.build_fm_demod_command( + device=sdr_device, + frequency_mhz=DSC_VHF_FREQUENCY_MHZ, + sample_rate=DSC_SAMPLE_RATE, + gain=float(gain) if gain and str(gain) != "0" else None, + modulation="fm", + squelch=0, + ) + ) # Ensure trailing '-' for stdin piping and add DC blocking filter - if rtl_cmd and rtl_cmd[-1] == '-': - rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-'] + if rtl_cmd and rtl_cmd[-1] == "-": + rtl_cmd = rtl_cmd[:-1] + ["-E", "dc", "-"] # Decoder command decoder_cmd = [decoder_path] - full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(decoder_cmd) + full_cmd = " ".join(rtl_cmd) + " | " + " ".join(decoder_cmd) logger.info(f"Starting DSC decoder: {full_cmd}") try: # Start rtl_fm subprocess - rtl_process = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + rtl_process = subprocess.Popen(rtl_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) register_process(rtl_process) # Start stderr monitor thread - stderr_thread = threading.Thread( - target=monitor_rtl_stderr, - args=(rtl_process,), - daemon=True - ) + stderr_thread = threading.Thread(target=monitor_rtl_stderr, args=(rtl_process,), daemon=True) stderr_thread.start() # Create PTY for decoder output @@ -423,11 +385,7 @@ def start_decoding() -> Response: # Start decoder subprocess decoder_process = subprocess.Popen( - decoder_cmd, - stdin=rtl_process.stdout, - stdout=slave_fd, - stderr=slave_fd, - close_fds=True + decoder_cmd, stdin=rtl_process.stdout, stdout=slave_fd, stderr=slave_fd, close_fds=True ) register_process(decoder_process) @@ -440,20 +398,18 @@ def start_decoding() -> Response: dsc_running = True # Start output streaming thread - output_thread = threading.Thread( - target=stream_dsc_decoder, - args=(master_fd, decoder_process), - daemon=True - ) + output_thread = threading.Thread(target=stream_dsc_decoder, args=(master_fd, decoder_process), daemon=True) output_thread.start() - return jsonify({ - 'status': 'started', - 'frequency': DSC_VHF_FREQUENCY_MHZ, - 'device': device, - 'gain': gain, - 'command': full_cmd - }) + return jsonify( + { + "status": "started", + "frequency": DSC_VHF_FREQUENCY_MHZ, + "device": device, + "gain": gain, + "command": full_cmd, + } + ) except FileNotFoundError as e: # Kill orphaned rtl_fm process @@ -465,13 +421,10 @@ def start_decoding() -> Response: rtl_process.kill() # Release device on failure if dsc_active_device is not None: - app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or "rtlsdr") dsc_active_device = None dsc_active_sdr_type = None - return jsonify({ - 'status': 'error', - 'message': f'Tool not found: {e.filename}' - }), 400 + return jsonify({"status": "error", "message": f"Tool not found: {e.filename}"}), 400 except Exception as e: # Kill orphaned rtl_fm process if it was started try: @@ -482,24 +435,21 @@ def start_decoding() -> Response: rtl_process.kill() # Release device on failure if dsc_active_device is not None: - app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or "rtlsdr") dsc_active_device = None dsc_active_sdr_type = None logger.error(f"Failed to start DSC decoder: {e}") - return jsonify({ - 'status': 'error', - 'message': str(e) - }), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@dsc_bp.route('/stop', methods=['POST']) +@dsc_bp.route("/stop", methods=["POST"]) def stop_decoding() -> Response: """Stop DSC decoder.""" global dsc_running, dsc_active_device, dsc_active_sdr_type with app_module.dsc_lock: if not app_module.dsc_process: - return jsonify({'status': 'not_running'}) + return jsonify({"status": "not_running"}) dsc_running = False @@ -530,116 +480,94 @@ def stop_decoding() -> Response: # Release device from registry if dsc_active_device is not None: - app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or "rtlsdr") dsc_active_device = None dsc_active_sdr_type = None - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@dsc_bp.route('/stream') +@dsc_bp.route("/stream") def stream() -> Response: """SSE stream for real-time DSC messages.""" + def _on_msg(msg: dict[str, Any]) -> None: - process_event('dsc', msg, msg.get('type')) + process_event("dsc", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=app_module.dsc_queue, - channel_key='dsc', + channel_key="dsc", timeout=1.0, keepalive_interval=30.0, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response -@dsc_bp.route('/messages') +@dsc_bp.route("/messages") def get_messages() -> Response: """Get current DSC messages from transient store.""" messages = list(app_module.dsc_messages.values()) # Sort by timestamp (newest first) - messages.sort(key=lambda m: m.get('timestamp', ''), reverse=True) + messages.sort(key=lambda m: m.get("timestamp", ""), reverse=True) - return jsonify({ - 'count': len(messages), - 'messages': messages - }) + return jsonify({"count": len(messages), "messages": messages}) -@dsc_bp.route('/alerts') +@dsc_bp.route("/alerts") def get_alerts_endpoint() -> Response: """Get stored DSC alerts (paginated).""" # Parse query params - category = request.args.get('category') - acknowledged = request.args.get('acknowledged') - limit = min(int(request.args.get('limit', 50)), 200) - offset = int(request.args.get('offset', 0)) + category = request.args.get("category") + acknowledged = request.args.get("acknowledged") + limit = min(int(request.args.get("limit", 50)), 200) + offset = int(request.args.get("offset", 0)) # Convert acknowledged param ack_filter = None if acknowledged is not None: - ack_filter = acknowledged.lower() in ('true', '1', 'yes') + ack_filter = acknowledged.lower() in ("true", "1", "yes") - alerts = get_dsc_alerts( - category=category, - acknowledged=ack_filter, - limit=limit, - offset=offset - ) + alerts = get_dsc_alerts(category=category, acknowledged=ack_filter, limit=limit, offset=offset) summary = get_dsc_alert_summary() - return jsonify({ - 'alerts': alerts, - 'count': len(alerts), - 'summary': summary, - 'pagination': { - 'limit': limit, - 'offset': offset - } - }) + return jsonify( + {"alerts": alerts, "count": len(alerts), "summary": summary, "pagination": {"limit": limit, "offset": offset}} + ) -@dsc_bp.route('/alerts/') +@dsc_bp.route("/alerts/") def get_alert(alert_id: int) -> Response: """Get a specific DSC alert by ID.""" alert = get_dsc_alert(alert_id) if not alert: - return jsonify({ - 'status': 'error', - 'message': 'Alert not found' - }), 404 + return jsonify({"status": "error", "message": "Alert not found"}), 404 return jsonify(alert) -@dsc_bp.route('/alerts//acknowledge', methods=['POST']) +@dsc_bp.route("/alerts//acknowledge", methods=["POST"]) def acknowledge_alert(alert_id: int) -> Response: """Acknowledge a DSC alert.""" data = request.json or {} - notes = data.get('notes') + notes = data.get("notes") success = acknowledge_dsc_alert(alert_id, notes) if not success: - return jsonify({ - 'status': 'error', - 'message': 'Alert not found' - }), 404 + return jsonify({"status": "error", "message": "Alert not found"}), 404 - return jsonify({ - 'status': 'acknowledged', - 'alert_id': alert_id - }) + return jsonify({"status": "acknowledged", "alert_id": alert_id}) -@dsc_bp.route('/alerts/summary') +@dsc_bp.route("/alerts/summary") def get_alerts_summary() -> Response: """Get summary of unacknowledged DSC alerts.""" summary = get_dsc_alert_summary() diff --git a/routes/gps.py b/routes/gps.py index 7e777f0..eadbaf9 100644 --- a/routes/gps.py +++ b/routes/gps.py @@ -21,9 +21,9 @@ from utils.gps import ( from utils.logging import get_logger from utils.sse import sse_stream_fanout -logger = get_logger('intercept.gps') +logger = get_logger("intercept.gps") -gps_bp = Blueprint('gps', __name__, url_prefix='/gps') +gps_bp = Blueprint("gps", __name__, url_prefix="/gps") # Queue for SSE position updates _gps_queue: queue.Queue = queue.Queue(maxsize=100) @@ -32,12 +32,12 @@ _gps_queue: queue.Queue = queue.Queue(maxsize=100) def _position_callback(position: GPSPosition) -> None: """Callback to queue position updates for SSE stream.""" try: - _gps_queue.put_nowait({'type': 'position', **position.to_dict()}) + _gps_queue.put_nowait({"type": "position", **position.to_dict()}) except queue.Full: # Discard oldest if queue is full try: _gps_queue.get_nowait() - _gps_queue.put_nowait({'type': 'position', **position.to_dict()}) + _gps_queue.put_nowait({"type": "position", **position.to_dict()}) except queue.Empty: pass @@ -45,16 +45,16 @@ def _position_callback(position: GPSPosition) -> None: def _sky_callback(sky: GPSSkyData) -> None: """Callback to queue sky data updates for SSE stream.""" try: - _gps_queue.put_nowait({'type': 'sky', **sky.to_dict()}) + _gps_queue.put_nowait({"type": "sky", **sky.to_dict()}) except queue.Full: try: _gps_queue.get_nowait() - _gps_queue.put_nowait({'type': 'sky', **sky.to_dict()}) + _gps_queue.put_nowait({"type": "sky", **sky.to_dict()}) except queue.Empty: pass -@gps_bp.route('/auto-connect', methods=['POST']) +@gps_bp.route("/auto-connect", methods=["POST"]) def auto_connect_gps(): """ Automatically connect to gpsd if available. @@ -71,35 +71,36 @@ def auto_connect_gps(): reader.add_sky_callback(_sky_callback) position = reader.position sky = reader.sky - return jsonify({ - 'status': 'connected', - 'source': 'gpsd', - 'has_fix': position is not None, - 'position': position.to_dict() if position else None, - 'sky': sky.to_dict() if sky else None, - }) + return jsonify( + { + "status": "connected", + "source": "gpsd", + "has_fix": position is not None, + "position": position.to_dict() if position else None, + "sky": sky.to_dict() if sky else None, + } + ) - host = 'localhost' + host = "localhost" port = 2947 # If gpsd isn't running, try to detect a device and start it if not is_gpsd_running(host, port): devices = detect_gps_devices() if not devices: - return jsonify({ - 'status': 'unavailable', - 'message': 'No GPS device detected' - }) + return jsonify({"status": "unavailable", "message": "No GPS device detected"}) # Try to start gpsd with the first detected device - device_path = devices[0]['path'] + device_path = devices[0]["path"] success, msg = start_gpsd_daemon(device_path, host, port) if not success: - return jsonify({ - 'status': 'unavailable', - 'message': msg, - 'devices': devices, - }) + return jsonify( + { + "status": "unavailable", + "message": msg, + "devices": devices, + } + ) logger.info(f"Auto-started gpsd on {device_path}") # Clear the queue @@ -110,36 +111,35 @@ def auto_connect_gps(): break # Start the gpsd client - success = start_gpsd(host, port, - callback=_position_callback, - sky_callback=_sky_callback) + success = start_gpsd(host, port, callback=_position_callback, sky_callback=_sky_callback) if success: - return jsonify({ - 'status': 'connected', - 'source': 'gpsd', - 'has_fix': False, - 'position': None, - 'sky': None, - }) + return jsonify( + { + "status": "connected", + "source": "gpsd", + "has_fix": False, + "position": None, + "sky": None, + } + ) else: - return jsonify({ - 'status': 'unavailable', - 'message': 'Failed to connect to gpsd' - }) + return jsonify({"status": "unavailable", "message": "Failed to connect to gpsd"}) -@gps_bp.route('/devices') +@gps_bp.route("/devices") def list_gps_devices(): """List detected GPS serial devices.""" devices = detect_gps_devices() - return jsonify({ - 'devices': devices, - 'gpsd_running': is_gpsd_running(), - }) + return jsonify( + { + "devices": devices, + "gpsd_running": is_gpsd_running(), + } + ) -@gps_bp.route('/stop', methods=['POST']) +@gps_bp.route("/stop", methods=["POST"]) def stop_gps_reader(): """Stop GPS client and gpsd daemon if we started it.""" reader = get_gps_reader() @@ -150,99 +150,86 @@ def stop_gps_reader(): stop_gps() stop_gpsd_daemon() - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@gps_bp.route('/status') +@gps_bp.route("/status") def get_gps_status(): """Get current GPS client status.""" reader = get_gps_reader() if not reader: - return jsonify({ - 'running': False, - 'device': None, - 'position': None, - 'sky': None, - 'error': None, - 'message': 'GPS client not started' - }) + return jsonify( + { + "running": False, + "device": None, + "position": None, + "sky": None, + "error": None, + "message": "GPS client not started", + } + ) position = reader.position sky = reader.sky - return jsonify({ - 'running': reader.is_running, - 'device': reader.device_path, - 'position': position.to_dict() if position else None, - 'sky': sky.to_dict() if sky else None, - 'last_update': reader.last_update.isoformat() if reader.last_update else None, - 'error': reader.error, - 'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None - }) + return jsonify( + { + "running": reader.is_running, + "device": reader.device_path, + "position": position.to_dict() if position else None, + "sky": sky.to_dict() if sky else None, + "last_update": reader.last_update.isoformat() if reader.last_update else None, + "error": reader.error, + "message": "Waiting for GPS fix - ensure GPS has clear view of sky" + if reader.is_running and not position + else None, + } + ) -@gps_bp.route('/position') +@gps_bp.route("/position") def get_position(): """Get current GPS position.""" position = get_current_position() if position: - return jsonify({ - 'status': 'ok', - 'position': position.to_dict() - }) + return jsonify({"status": "ok", "position": position.to_dict()}) else: reader = get_gps_reader() if not reader or not reader.is_running: - return jsonify({ - 'status': 'error', - 'message': 'GPS client not running' - }), 400 + return jsonify({"status": "error", "message": "GPS client not running"}), 400 else: - return jsonify({ - 'status': 'waiting', - 'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' - }) + return jsonify({"status": "waiting", "message": "Waiting for GPS fix - ensure GPS has clear view of sky"}) -@gps_bp.route('/satellites') +@gps_bp.route("/satellites") def get_satellites(): """Get current satellite sky view data.""" reader = get_gps_reader() if not reader or not reader.is_running: - return jsonify({ - 'status': 'waiting', - 'running': False, - 'message': 'GPS client not running' - }) + return jsonify({"status": "waiting", "running": False, "message": "GPS client not running"}) sky = reader.sky if sky: - return jsonify({ - 'status': 'ok', - 'sky': sky.to_dict() - }) + return jsonify({"status": "ok", "sky": sky.to_dict()}) else: - return jsonify({ - 'status': 'waiting', - 'message': 'Waiting for satellite data' - }) + return jsonify({"status": "waiting", "message": "Waiting for satellite data"}) -@gps_bp.route('/stream') +@gps_bp.route("/stream") def stream_gps(): """SSE stream of GPS position and sky updates.""" response = Response( sse_stream_fanout( source_queue=_gps_queue, - channel_key='gps', + channel_key="gps", timeout=1.0, keepalive_interval=30.0, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response diff --git a/routes/ground_station.py b/routes/ground_station.py index d9aa213..dfb0f08 100644 --- a/routes/ground_station.py +++ b/routes/ground_station.py @@ -18,9 +18,9 @@ from flask import Blueprint, Response, jsonify, request, send_file from utils.logging import get_logger from utils.sse import sse_stream_fanout -logger = get_logger('intercept.ground_station.routes') +logger = get_logger("intercept.ground_station.routes") -ground_station_bp = Blueprint('ground_station', __name__, url_prefix='/ground_station') +ground_station_bp = Blueprint("ground_station", __name__, url_prefix="/ground_station") # --------------------------------------------------------------------------- @@ -30,12 +30,14 @@ ground_station_bp = Blueprint('ground_station', __name__, url_prefix='/ground_st def _get_scheduler(): from utils.ground_station.scheduler import get_ground_station_scheduler + return get_ground_station_scheduler() def _get_queue(): import app as _app - return getattr(_app, 'ground_station_queue', None) or queue.Queue() + + return getattr(_app, "ground_station_queue", None) or queue.Queue() # --------------------------------------------------------------------------- @@ -43,28 +45,30 @@ def _get_queue(): # --------------------------------------------------------------------------- -@ground_station_bp.route('/profiles', methods=['GET']) +@ground_station_bp.route("/profiles", methods=["GET"]) def list_profiles(): from utils.ground_station.observation_profile import list_profiles as _list + return jsonify([p.to_dict() for p in _list()]) -@ground_station_bp.route('/profiles/', methods=['GET']) +@ground_station_bp.route("/profiles/", methods=["GET"]) def get_profile(norad_id: int): from utils.ground_station.observation_profile import get_profile as _get + p = _get(norad_id) if not p: - return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404 + return jsonify({"error": f"No profile for NORAD {norad_id}"}), 404 return jsonify(p.to_dict()) -@ground_station_bp.route('/profiles', methods=['POST']) +@ground_station_bp.route("/profiles", methods=["POST"]) def create_profile(): data = request.get_json(force=True) or {} try: _validate_profile(data) except ValueError as e: - return jsonify({'error': str(e)}), 400 + return jsonify({"error": str(e)}), 400 from utils.ground_station.observation_profile import ( ObservationProfile, @@ -73,30 +77,31 @@ def create_profile(): save_profile, tasks_to_legacy_decoder, ) - tasks = normalize_tasks(data.get('tasks')) + + tasks = normalize_tasks(data.get("tasks")) if not tasks: tasks = legacy_decoder_to_tasks( - str(data.get('decoder_type', 'fm')), - bool(data.get('record_iq', False)), + str(data.get("decoder_type", "fm")), + bool(data.get("record_iq", False)), ) profile = ObservationProfile( - norad_id=int(data['norad_id']), - name=str(data['name']), - frequency_mhz=float(data['frequency_mhz']), + norad_id=int(data["norad_id"]), + name=str(data["name"]), + frequency_mhz=float(data["frequency_mhz"]), decoder_type=tasks_to_legacy_decoder(tasks), - gain=float(data.get('gain', 40.0)), - bandwidth_hz=int(data.get('bandwidth_hz', 200_000)), - min_elevation=float(data.get('min_elevation', 10.0)), - enabled=bool(data.get('enabled', True)), - record_iq=bool(data.get('record_iq', False)) or ('record_iq' in tasks), - iq_sample_rate=int(data.get('iq_sample_rate', 2_400_000)), + gain=float(data.get("gain", 40.0)), + bandwidth_hz=int(data.get("bandwidth_hz", 200_000)), + min_elevation=float(data.get("min_elevation", 10.0)), + enabled=bool(data.get("enabled", True)), + record_iq=bool(data.get("record_iq", False)) or ("record_iq" in tasks), + iq_sample_rate=int(data.get("iq_sample_rate", 2_400_000)), tasks=tasks, ) saved = save_profile(profile) return jsonify(saved.to_dict()), 201 -@ground_station_bp.route('/profiles/', methods=['PUT']) +@ground_station_bp.route("/profiles/", methods=["PUT"]) def update_profile(norad_id: int): data = request.get_json(force=True) or {} from utils.ground_station.observation_profile import ( @@ -108,44 +113,50 @@ def update_profile(norad_id: int): save_profile, tasks_to_legacy_decoder, ) + existing = _get(norad_id) if not existing: - return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404 + return jsonify({"error": f"No profile for NORAD {norad_id}"}), 404 # Apply updates for field, cast in [ - ('name', str), ('frequency_mhz', float), ('decoder_type', str), - ('gain', float), ('bandwidth_hz', int), ('min_elevation', float), + ("name", str), + ("frequency_mhz", float), + ("decoder_type", str), + ("gain", float), + ("bandwidth_hz", int), + ("min_elevation", float), ]: if field in data: setattr(existing, field, cast(data[field])) - for field in ('enabled', 'record_iq'): + for field in ("enabled", "record_iq"): if field in data: setattr(existing, field, bool(data[field])) - if 'iq_sample_rate' in data: - existing.iq_sample_rate = int(data['iq_sample_rate']) - if 'tasks' in data: - existing.tasks = normalize_tasks(data['tasks']) - elif 'decoder_type' in data: + if "iq_sample_rate" in data: + existing.iq_sample_rate = int(data["iq_sample_rate"]) + if "tasks" in data: + existing.tasks = normalize_tasks(data["tasks"]) + elif "decoder_type" in data: existing.tasks = legacy_decoder_to_tasks( - str(data.get('decoder_type', existing.decoder_type)), - bool(data.get('record_iq', existing.record_iq)), + str(data.get("decoder_type", existing.decoder_type)), + bool(data.get("record_iq", existing.record_iq)), ) existing.decoder_type = tasks_to_legacy_decoder(existing.tasks) - existing.record_iq = bool(existing.record_iq) or ('record_iq' in existing.tasks) + existing.record_iq = bool(existing.record_iq) or ("record_iq" in existing.tasks) saved = save_profile(existing) return jsonify(saved.to_dict()) -@ground_station_bp.route('/profiles/', methods=['DELETE']) +@ground_station_bp.route("/profiles/", methods=["DELETE"]) def delete_profile(norad_id: int): from utils.ground_station.observation_profile import delete_profile as _del + ok = _del(norad_id) if not ok: - return jsonify({'error': f'No profile for NORAD {norad_id}'}), 404 - return jsonify({'status': 'deleted', 'norad_id': norad_id}) + return jsonify({"error": f"No profile for NORAD {norad_id}"}), 404 + return jsonify({"status": "deleted", "norad_id": norad_id}) # --------------------------------------------------------------------------- @@ -153,45 +164,45 @@ def delete_profile(norad_id: int): # --------------------------------------------------------------------------- -@ground_station_bp.route('/scheduler/status', methods=['GET']) +@ground_station_bp.route("/scheduler/status", methods=["GET"]) def scheduler_status(): return jsonify(_get_scheduler().get_status()) -@ground_station_bp.route('/scheduler/enable', methods=['POST']) +@ground_station_bp.route("/scheduler/enable", methods=["POST"]) def scheduler_enable(): data = request.get_json(force=True) or {} try: - lat = float(data.get('lat', 0.0)) - lon = float(data.get('lon', 0.0)) - device = int(data.get('device', 0)) - sdr_type = str(data.get('sdr_type', 'rtlsdr')) + lat = float(data.get("lat", 0.0)) + lon = float(data.get("lon", 0.0)) + device = int(data.get("device", 0)) + sdr_type = str(data.get("sdr_type", "rtlsdr")) except (TypeError, ValueError) as e: - return jsonify({'error': str(e)}), 400 + return jsonify({"error": str(e)}), 400 status = _get_scheduler().enable(lat=lat, lon=lon, device=device, sdr_type=sdr_type) return jsonify(status) -@ground_station_bp.route('/scheduler/disable', methods=['POST']) +@ground_station_bp.route("/scheduler/disable", methods=["POST"]) def scheduler_disable(): return jsonify(_get_scheduler().disable()) -@ground_station_bp.route('/scheduler/observations', methods=['GET']) +@ground_station_bp.route("/scheduler/observations", methods=["GET"]) def get_observations(): return jsonify(_get_scheduler().get_scheduled_observations()) -@ground_station_bp.route('/scheduler/trigger/', methods=['POST']) +@ground_station_bp.route("/scheduler/trigger/", methods=["POST"]) def trigger_manual(norad_id: int): ok, msg = _get_scheduler().trigger_manual(norad_id) if not ok: - return jsonify({'error': msg}), 400 - return jsonify({'status': 'started', 'message': msg}) + return jsonify({"error": msg}), 400 + return jsonify({"status": "started", "message": msg}) -@ground_station_bp.route('/scheduler/stop', methods=['POST']) +@ground_station_bp.route("/scheduler/stop", methods=["POST"]) def stop_active(): return jsonify(_get_scheduler().stop_active()) @@ -201,15 +212,16 @@ def stop_active(): # --------------------------------------------------------------------------- -@ground_station_bp.route('/observations', methods=['GET']) +@ground_station_bp.route("/observations", methods=["GET"]) def observation_history(): - limit = min(int(request.args.get('limit', 50)), 200) + limit = min(int(request.args.get("limit", 50)), 200) try: from utils.database import get_db + with get_db() as conn: rows = conn.execute( - '''SELECT * FROM ground_station_observations - ORDER BY created_at DESC LIMIT ?''', + """SELECT * FROM ground_station_observations + ORDER BY created_at DESC LIMIT ?""", (limit,), ).fetchall() return jsonify([dict(r) for r in rows]) @@ -223,15 +235,15 @@ def observation_history(): # --------------------------------------------------------------------------- -@ground_station_bp.route('/stream') +@ground_station_bp.route("/stream") def sse_stream(): gs_queue = _get_queue() return Response( - sse_stream_fanout(gs_queue, 'ground_station'), - mimetype='text/event-stream', + sse_stream_fanout(gs_queue, "ground_station"), + mimetype="text/event-stream", headers={ - 'Cache-Control': 'no-cache', - 'X-Accel-Buffering': 'no', + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", }, ) @@ -241,169 +253,178 @@ def sse_stream(): # --------------------------------------------------------------------------- -@ground_station_bp.route('/recordings', methods=['GET']) +@ground_station_bp.route("/recordings", methods=["GET"]) def list_recordings(): try: from utils.database import get_db + with get_db() as conn: - rows = conn.execute( - 'SELECT * FROM sigmf_recordings ORDER BY created_at DESC LIMIT 100' - ).fetchall() + rows = conn.execute("SELECT * FROM sigmf_recordings ORDER BY created_at DESC LIMIT 100").fetchall() return jsonify([dict(r) for r in rows]) except Exception as e: logger.error(f"Failed to fetch recordings: {e}") return jsonify([]) -@ground_station_bp.route('/recordings/', methods=['GET']) +@ground_station_bp.route("/recordings/", methods=["GET"]) def get_recording(rec_id: int): try: from utils.database import get_db + with get_db() as conn: - row = conn.execute( - 'SELECT * FROM sigmf_recordings WHERE id=?', (rec_id,) - ).fetchone() + row = conn.execute("SELECT * FROM sigmf_recordings WHERE id=?", (rec_id,)).fetchone() if not row: - return jsonify({'error': 'Not found'}), 404 + return jsonify({"error": "Not found"}), 404 return jsonify(dict(row)) except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@ground_station_bp.route('/recordings/', methods=['DELETE']) +@ground_station_bp.route("/recordings/", methods=["DELETE"]) def delete_recording(rec_id: int): try: from utils.database import get_db + with get_db() as conn: row = conn.execute( - 'SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?', + "SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?", (rec_id,), ).fetchone() if not row: - return jsonify({'error': 'Not found'}), 404 + return jsonify({"error": "Not found"}), 404 # Remove files - for path_col in ('sigmf_data_path', 'sigmf_meta_path'): + for path_col in ("sigmf_data_path", "sigmf_meta_path"): p = Path(row[path_col]) if p.exists(): p.unlink(missing_ok=True) - conn.execute('DELETE FROM sigmf_recordings WHERE id=?', (rec_id,)) - return jsonify({'status': 'deleted', 'id': rec_id}) + conn.execute("DELETE FROM sigmf_recordings WHERE id=?", (rec_id,)) + return jsonify({"status": "deleted", "id": rec_id}) except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@ground_station_bp.route('/recordings//download/') +@ground_station_bp.route("/recordings//download/") def download_recording(rec_id: int, file_type: str): - if file_type not in ('data', 'meta'): - return jsonify({'error': 'file_type must be data or meta'}), 400 + if file_type not in ("data", "meta"): + return jsonify({"error": "file_type must be data or meta"}), 400 try: from utils.database import get_db + with get_db() as conn: row = conn.execute( - 'SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?', + "SELECT sigmf_data_path, sigmf_meta_path FROM sigmf_recordings WHERE id=?", (rec_id,), ).fetchone() if not row: - return jsonify({'error': 'Not found'}), 404 + return jsonify({"error": "Not found"}), 404 - col = 'sigmf_data_path' if file_type == 'data' else 'sigmf_meta_path' + col = "sigmf_data_path" if file_type == "data" else "sigmf_meta_path" p = Path(row[col]) if not p.exists(): - return jsonify({'error': 'File not found on disk'}), 404 + return jsonify({"error": "File not found on disk"}), 404 - mimetype = 'application/octet-stream' if file_type == 'data' else 'application/json' + mimetype = "application/octet-stream" if file_type == "data" else "application/json" return send_file(p, mimetype=mimetype, as_attachment=True, download_name=p.name) except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@ground_station_bp.route('/outputs', methods=['GET']) +@ground_station_bp.route("/outputs", methods=["GET"]) def list_outputs(): try: - query = ''' + query = """ SELECT * FROM ground_station_outputs WHERE (? IS NULL OR norad_id = ?) AND (? IS NULL OR observation_id = ?) AND (? IS NULL OR output_type = ?) ORDER BY created_at DESC LIMIT 200 - ''' - norad_id = request.args.get('norad_id', type=int) - observation_id = request.args.get('observation_id', type=int) - output_type = request.args.get('type') + """ + norad_id = request.args.get("norad_id", type=int) + observation_id = request.args.get("observation_id", type=int) + output_type = request.args.get("type") from utils.database import get_db + with get_db() as conn: rows = conn.execute( query, ( - norad_id, norad_id, - observation_id, observation_id, - output_type, output_type, + norad_id, + norad_id, + observation_id, + observation_id, + output_type, + output_type, ), ).fetchall() results = [] for row in rows: item = dict(row) - metadata_raw = item.get('metadata_json') + metadata_raw = item.get("metadata_json") if metadata_raw: try: - item['metadata'] = json.loads(metadata_raw) + item["metadata"] = json.loads(metadata_raw) except json.JSONDecodeError: - item['metadata'] = {} + item["metadata"] = {} else: - item['metadata'] = {} - item.pop('metadata_json', None) + item["metadata"] = {} + item.pop("metadata_json", None) results.append(item) return jsonify(results) except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@ground_station_bp.route('/outputs//download', methods=['GET']) +@ground_station_bp.route("/outputs//download", methods=["GET"]) def download_output(output_id: int): try: from utils.database import get_db + with get_db() as conn: row = conn.execute( - 'SELECT file_path FROM ground_station_outputs WHERE id=?', + "SELECT file_path FROM ground_station_outputs WHERE id=?", (output_id,), ).fetchone() if not row: - return jsonify({'error': 'Not found'}), 404 - p = Path(row['file_path']) + return jsonify({"error": "Not found"}), 404 + p = Path(row["file_path"]) if not p.exists(): - return jsonify({'error': 'File not found on disk'}), 404 + return jsonify({"error": "File not found on disk"}), 404 return send_file(p, as_attachment=True, download_name=p.name) except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@ground_station_bp.route('/decode-jobs', methods=['GET']) +@ground_station_bp.route("/decode-jobs", methods=["GET"]) def list_decode_jobs(): try: - query = ''' + query = """ SELECT * FROM ground_station_decode_jobs WHERE (? IS NULL OR norad_id = ?) AND (? IS NULL OR observation_id = ?) AND (? IS NULL OR backend = ?) ORDER BY created_at DESC LIMIT ? - ''' - norad_id = request.args.get('norad_id', type=int) - observation_id = request.args.get('observation_id', type=int) - backend = request.args.get('backend') - limit = min(request.args.get('limit', 20, type=int) or 20, 200) + """ + norad_id = request.args.get("norad_id", type=int) + observation_id = request.args.get("observation_id", type=int) + backend = request.args.get("backend") + limit = min(request.args.get("limit", 20, type=int) or 20, 200) from utils.database import get_db + with get_db() as conn: rows = conn.execute( query, ( - norad_id, norad_id, - observation_id, observation_id, - backend, backend, + norad_id, + norad_id, + observation_id, + observation_id, + backend, + backend, limit, ), ).fetchall() @@ -411,19 +432,19 @@ def list_decode_jobs(): results = [] for row in rows: item = dict(row) - details_raw = item.get('details_json') + details_raw = item.get("details_json") if details_raw: try: - item['details'] = json.loads(details_raw) + item["details"] = json.loads(details_raw) except json.JSONDecodeError: - item['details'] = {} + item["details"] = {} else: - item['details'] = {} - item.pop('details_json', None) + item["details"] = {} + item.pop("details_json", None) results.append(item) return jsonify(results) except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 # --------------------------------------------------------------------------- @@ -441,16 +462,17 @@ def init_ground_station_websocket(app) -> None: sock = Sock(app) - @sock.route('/ws/satellite_waterfall') + @sock.route("/ws/satellite_waterfall") def satellite_waterfall_ws(ws): """Stream binary waterfall frames from the active ground station IQ bus.""" scheduler = _get_scheduler() wf_queue = scheduler.waterfall_queue from utils.sse import subscribe_fanout_queue + sub_queue, unsubscribe = subscribe_fanout_queue( source_queue=wf_queue, - channel_key='gs_waterfall', + channel_key="gs_waterfall", subscriber_queue_size=120, ) @@ -474,53 +496,58 @@ def init_ground_station_websocket(app) -> None: # --------------------------------------------------------------------------- -@ground_station_bp.route('/rotator/status', methods=['GET']) +@ground_station_bp.route("/rotator/status", methods=["GET"]) def rotator_status(): from utils.rotator import get_rotator + return jsonify(get_rotator().get_status()) -@ground_station_bp.route('/rotator/config', methods=['POST']) +@ground_station_bp.route("/rotator/config", methods=["POST"]) def rotator_config(): data = request.get_json(force=True) or {} - host = str(data.get('host', '127.0.0.1')) - port = int(data.get('port', 4533)) + host = str(data.get("host", "127.0.0.1")) + port = int(data.get("port", 4533)) from utils.rotator import get_rotator + ok = get_rotator().connect(host, port) if not ok: - return jsonify({'error': f'Could not connect to rotctld at {host}:{port}'}), 503 + return jsonify({"error": f"Could not connect to rotctld at {host}:{port}"}), 503 return jsonify(get_rotator().get_status()) -@ground_station_bp.route('/rotator/point', methods=['POST']) +@ground_station_bp.route("/rotator/point", methods=["POST"]) def rotator_point(): data = request.get_json(force=True) or {} try: - az = float(data['az']) - el = float(data['el']) + az = float(data["az"]) + el = float(data["el"]) except (KeyError, TypeError, ValueError) as e: - return jsonify({'error': f'az and el required: {e}'}), 400 + return jsonify({"error": f"az and el required: {e}"}), 400 from utils.rotator import get_rotator + ok = get_rotator().point_to(az, el) if not ok: - return jsonify({'error': 'Rotator command failed'}), 503 - return jsonify({'status': 'ok', 'az': az, 'el': el}) + return jsonify({"error": "Rotator command failed"}), 503 + return jsonify({"status": "ok", "az": az, "el": el}) -@ground_station_bp.route('/rotator/park', methods=['POST']) +@ground_station_bp.route("/rotator/park", methods=["POST"]) def rotator_park(): from utils.rotator import get_rotator + ok = get_rotator().park() if not ok: - return jsonify({'error': 'Rotator park failed'}), 503 - return jsonify({'status': 'parked'}) + return jsonify({"error": "Rotator park failed"}), 503 + return jsonify({"status": "parked"}) -@ground_station_bp.route('/rotator/disconnect', methods=['POST']) +@ground_station_bp.route("/rotator/disconnect", methods=["POST"]) def rotator_disconnect(): from utils.rotator import get_rotator + get_rotator().disconnect() - return jsonify({'status': 'disconnected'}) + return jsonify({"status": "disconnected"}) # --------------------------------------------------------------------------- @@ -529,39 +556,34 @@ def rotator_disconnect(): def _validate_profile(data: dict) -> None: - if 'norad_id' not in data: + if "norad_id" not in data: raise ValueError("norad_id is required") - if 'name' not in data: + if "name" not in data: raise ValueError("name is required") - if 'frequency_mhz' not in data: + if "frequency_mhz" not in data: raise ValueError("frequency_mhz is required") try: - norad_id = int(data['norad_id']) + norad_id = int(data["norad_id"]) if norad_id <= 0: raise ValueError("norad_id must be positive") except (TypeError, ValueError): raise ValueError("norad_id must be a positive integer") try: - freq = float(data['frequency_mhz']) + freq = float(data["frequency_mhz"]) if not (0.1 <= freq <= 3000.0): raise ValueError("frequency_mhz must be between 0.1 and 3000") except (TypeError, ValueError): raise ValueError("frequency_mhz must be a number between 0.1 and 3000") from utils.ground_station.observation_profile import VALID_TASK_TYPES - valid_decoders = {'fm', 'afsk', 'gmsk', 'bpsk', 'iq_only'} - if 'tasks' in data: - if not isinstance(data['tasks'], list): + valid_decoders = {"fm", "afsk", "gmsk", "bpsk", "iq_only"} + if "tasks" in data: + if not isinstance(data["tasks"], list): raise ValueError("tasks must be a list") - invalid = [ - str(task) for task in data['tasks'] - if str(task).strip().lower() not in VALID_TASK_TYPES - ] + invalid = [str(task) for task in data["tasks"] if str(task).strip().lower() not in VALID_TASK_TYPES] if invalid: - raise ValueError( - f"tasks contains unsupported values: {', '.join(invalid)}" - ) + raise ValueError(f"tasks contains unsupported values: {', '.join(invalid)}") else: - dt = str(data.get('decoder_type', 'fm')) + dt = str(data.get("decoder_type", "fm")) if dt not in valid_decoders: raise ValueError(f"decoder_type must be one of: {', '.join(sorted(valid_decoders))}") diff --git a/routes/listening_post/__init__.py b/routes/listening_post/__init__.py index b6e7e5a..d4f426e 100644 --- a/routes/listening_post/__init__.py +++ b/routes/listening_post/__init__.py @@ -32,9 +32,9 @@ from utils.logging import get_logger from utils.sdr import SDRFactory, SDRType from utils.sse import sse_stream_fanout -logger = get_logger('intercept.receiver') +logger = get_logger("intercept.receiver") -receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver') +receiver_bp = Blueprint("receiver", __name__, url_prefix="/receiver") # Deferred import to avoid circular import at module load time. # app.py -> register_blueprints -> from .listening_post import receiver_bp @@ -54,8 +54,8 @@ audio_lock = threading.Lock() audio_start_lock = threading.Lock() audio_running = False audio_frequency = 0.0 -audio_modulation = 'fm' -audio_source = 'process' +audio_modulation = "fm" +audio_source = "process" audio_start_token = 0 # Scanner state @@ -65,24 +65,24 @@ scanner_lock = threading.Lock() scanner_paused = False scanner_current_freq = 0.0 scanner_active_device: int | None = None -scanner_active_sdr_type: str = 'rtlsdr' +scanner_active_sdr_type: str = "rtlsdr" receiver_active_device: int | None = None -receiver_active_sdr_type: str = 'rtlsdr' +receiver_active_sdr_type: str = "rtlsdr" scanner_power_process: subprocess.Popen | None = None scanner_config = { - 'start_freq': 88.0, - 'end_freq': 108.0, - 'step': 0.1, - 'modulation': 'wfm', - 'squelch': 0, - 'dwell_time': 10.0, # Seconds to stay on active frequency - 'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning) - 'device': 0, - 'gain': 40, - 'bias_t': False, # Bias-T power for external LNA - 'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay - 'scan_method': 'power', # power (rtl_power) or classic (rtl_fm hop) - 'snr_threshold': 8, + "start_freq": 88.0, + "end_freq": 108.0, + "step": 0.1, + "modulation": "wfm", + "squelch": 0, + "dwell_time": 10.0, # Seconds to stay on active frequency + "scan_delay": 0.1, # Seconds between frequency hops (keep low for fast scanning) + "device": 0, + "gain": 40, + "bias_t": False, # Bias-T power for external LNA + "sdr_type": "rtlsdr", # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay + "scan_method": "power", # power (rtl_power) or classic (rtl_fm hop) + "snr_threshold": 8, } # Activity log @@ -103,15 +103,15 @@ waterfall_running = False waterfall_lock = threading.Lock() waterfall_queue: queue.Queue = queue.Queue(maxsize=200) waterfall_active_device: int | None = None -waterfall_active_sdr_type: str = 'rtlsdr' +waterfall_active_sdr_type: str = "rtlsdr" waterfall_config = { - 'start_freq': 88.0, - 'end_freq': 108.0, - 'bin_size': 10000, - 'gain': 40, - 'device': 0, - 'max_bins': 1024, - 'interval': 0.4, + "start_freq": 88.0, + "end_freq": 108.0, + "bin_size": 10000, + "gain": 40, + "device": 0, + "max_bins": 1024, + "interval": 0.4, } @@ -119,41 +119,41 @@ waterfall_config = { # HELPER FUNCTIONS (shared across sub-modules) # ============================================ -VALID_MODULATIONS = ['fm', 'wfm', 'am', 'usb', 'lsb'] +VALID_MODULATIONS = ["fm", "wfm", "am", "usb", "lsb"] def find_rtl_fm() -> str | None: """Find rtl_fm binary.""" - return shutil.which('rtl_fm') + return shutil.which("rtl_fm") def find_rtl_power() -> str | None: """Find rtl_power binary.""" - return shutil.which('rtl_power') + return shutil.which("rtl_power") def find_rx_fm() -> str | None: """Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR).""" - return shutil.which('rx_fm') + return shutil.which("rx_fm") def find_ffmpeg() -> str | None: """Find ffmpeg for audio encoding.""" - return shutil.which('ffmpeg') + return shutil.which("ffmpeg") def normalize_modulation(value: str) -> str: """Normalize and validate modulation string.""" - mod = str(value or '').lower().strip() + mod = str(value or "").lower().strip() if mod not in VALID_MODULATIONS: - raise ValueError(f'Invalid modulation. Use: {", ".join(VALID_MODULATIONS)}') + raise ValueError(f"Invalid modulation. Use: {', '.join(VALID_MODULATIONS)}") return mod def _rtl_fm_demod_mode(modulation: str) -> str: """Map UI modulation names to rtl_fm demod tokens.""" - mod = str(modulation or '').lower().strip() - return 'wbfm' if mod == 'wfm' else mod + mod = str(modulation or "").lower().strip() + return "wbfm" if mod == "wfm" else mod def _wav_header(sample_rate: int = 48000, bits_per_sample: int = 16, channels: int = 1) -> bytes: @@ -162,24 +162,24 @@ def _wav_header(sample_rate: int = 48000, bits_per_sample: int = 16, channels: i byte_rate = sample_rate * channels * bytes_per_sample block_align = channels * bytes_per_sample return ( - b'RIFF' - + struct.pack(' None: if waterfall_active_device is not None: app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type) waterfall_active_device = None - waterfall_active_sdr_type = 'rtlsdr' + waterfall_active_sdr_type = "rtlsdr" # ============================================ diff --git a/routes/listening_post/audio.py b/routes/listening_post/audio.py index 39133b0..b1567a4 100644 --- a/routes/listening_post/audio.py +++ b/routes/listening_post/audio.py @@ -28,54 +28,48 @@ from . import ( # MANUAL AUDIO ENDPOINTS (for direct listening) # ============================================ -@receiver_bp.route('/audio/start', methods=['POST']) + +@receiver_bp.route("/audio/start", methods=["POST"]) def start_audio() -> Response: """Start audio at specific frequency (manual mode).""" data = request.json or {} try: - frequency = float(data.get('frequency', 0)) - modulation = normalize_modulation(data.get('modulation', 'wfm')) - squelch = int(data['squelch']) if data.get('squelch') is not None else 0 - gain = int(data['gain']) if data.get('gain') is not None else 40 - device = int(data['device']) if data.get('device') is not None else 0 - sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower() - request_token_raw = data.get('request_token') + frequency = float(data.get("frequency", 0)) + modulation = normalize_modulation(data.get("modulation", "wfm")) + squelch = int(data["squelch"]) if data.get("squelch") is not None else 0 + gain = int(data["gain"]) if data.get("gain") is not None else 40 + device = int(data["device"]) if data.get("device") is not None else 0 + sdr_type = str(data.get("sdr_type", "rtlsdr")).lower() + request_token_raw = data.get("request_token") request_token = int(request_token_raw) if request_token_raw is not None else None - bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False)) + bias_t_raw = data.get("bias_t", scanner_config.get("bias_t", False)) if isinstance(bias_t_raw, str): - bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'} + bias_t = bias_t_raw.strip().lower() in {"1", "true", "yes", "on"} else: bias_t = bool(bias_t_raw) except (ValueError, TypeError) as e: - return jsonify({ - 'status': 'error', - 'message': f'Invalid parameter: {e}' - }), 400 + return jsonify({"status": "error", "message": f"Invalid parameter: {e}"}), 400 if frequency <= 0: - return jsonify({ - 'status': 'error', - 'message': 'frequency is required' - }), 400 + return jsonify({"status": "error", "message": "frequency is required"}), 400 - valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay'] + valid_sdr_types = ["rtlsdr", "hackrf", "airspy", "limesdr", "sdrplay"] if sdr_type not in valid_sdr_types: - return jsonify({ - 'status': 'error', - 'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}' - }), 400 + return jsonify({"status": "error", "message": f"Invalid sdr_type. Use: {', '.join(valid_sdr_types)}"}), 400 with _state.audio_start_lock: if request_token is not None: if request_token < _state.audio_start_token: - return jsonify({ - 'status': 'stale', - 'message': 'Superseded audio start request', - 'source': _state.audio_source, - 'superseded': True, - 'current_token': _state.audio_start_token, - }), 409 + return jsonify( + { + "status": "stale", + "message": "Superseded audio start request", + "source": _state.audio_source, + "superseded": True, + "current_token": _state.audio_start_token, + } + ), 409 _state.audio_start_token = request_token else: _state.audio_start_token += 1 @@ -90,18 +84,18 @@ def start_audio() -> Response: if _state.scanner_active_device is not None: app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type) _state.scanner_active_device = None - _state.scanner_active_sdr_type = 'rtlsdr' + _state.scanner_active_sdr_type = "rtlsdr" scanner_thread_ref = _state.scanner_thread scanner_proc_ref = _state.scanner_power_process _state.scanner_power_process = None need_scanner_teardown = True # Update config for audio - scanner_config['squelch'] = squelch - scanner_config['gain'] = gain - scanner_config['device'] = device - scanner_config['sdr_type'] = sdr_type - scanner_config['bias_t'] = bias_t + scanner_config["squelch"] = squelch + scanner_config["gain"] = gain + scanner_config["device"] = device + scanner_config["sdr_type"] = sdr_type + scanner_config["bias_t"] = bias_t # Scanner teardown outside lock (blocking: thread join, process wait, pkill, sleep) if need_scanner_teardown: @@ -116,12 +110,11 @@ def start_audio() -> Response: with contextlib.suppress(Exception): scanner_proc_ref.kill() with contextlib.suppress(Exception): - subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5) + subprocess.run(["pkill", "-9", "rtl_power"], capture_output=True, timeout=0.5) time.sleep(0.5) # Re-acquire lock for waterfall check and device claim with _state.audio_start_lock: - # Preferred path: when waterfall WebSocket is active on the same SDR, # derive monitor audio from that IQ stream instead of spawning rtl_fm. try: @@ -131,7 +124,7 @@ def start_audio() -> Response: ) shared = get_shared_capture_status() - if shared.get('running') and shared.get('device') == device: + if shared.get("running") and shared.get("device") == device: _stop_audio_stream() ok, msg = start_shared_monitor_from_capture( device=device, @@ -143,19 +136,21 @@ def start_audio() -> Response: _state.audio_running = True _state.audio_frequency = frequency _state.audio_modulation = modulation - _state.audio_source = 'waterfall' + _state.audio_source = "waterfall" # Shared monitor uses the waterfall's existing SDR claim. if _state.receiver_active_device is not None: app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type) _state.receiver_active_device = None - _state.receiver_active_sdr_type = 'rtlsdr' - return jsonify({ - 'status': 'started', - 'frequency': frequency, - 'modulation': modulation, - 'source': 'waterfall', - 'request_token': request_token, - }) + _state.receiver_active_sdr_type = "rtlsdr" + return jsonify( + { + "status": "started", + "frequency": frequency, + "modulation": modulation, + "source": "waterfall", + "request_token": request_token, + } + ) logger.warning(f"Shared waterfall monitor unavailable: {msg}") except Exception as e: logger.debug(f"Shared waterfall monitor probe failed: {e}") @@ -173,27 +168,22 @@ def start_audio() -> Response: if _state.receiver_active_device is not None: app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type) _state.receiver_active_device = None - _state.receiver_active_sdr_type = 'rtlsdr' + _state.receiver_active_sdr_type = "rtlsdr" error = None max_claim_attempts = 6 for attempt in range(max_claim_attempts): - error = app_module.claim_sdr_device(device, 'receiver', sdr_type) + error = app_module.claim_sdr_device(device, "receiver", sdr_type) if not error: break if attempt < max_claim_attempts - 1: logger.debug( - f"Device claim attempt {attempt + 1}/{max_claim_attempts} " - f"failed, retrying in 0.5s: {error}" + f"Device claim attempt {attempt + 1}/{max_claim_attempts} failed, retrying in 0.5s: {error}" ) time.sleep(0.5) if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error - }), 409 + return jsonify({"status": "error", "error_type": "DEVICE_BUSY", "message": error}), 409 _state.receiver_active_device = device _state.receiver_active_sdr_type = sdr_type @@ -208,23 +198,25 @@ def start_audio() -> Response: ) if _state.audio_running: - _state.audio_source = 'process' - return jsonify({ - 'status': 'started', - 'frequency': _state.audio_frequency, - 'modulation': _state.audio_modulation, - 'source': 'process', - 'request_token': request_token, - }) + _state.audio_source = "process" + return jsonify( + { + "status": "started", + "frequency": _state.audio_frequency, + "modulation": _state.audio_modulation, + "source": "process", + "request_token": request_token, + } + ) # Avoid leaving a stale device claim after startup failure. if _state.receiver_active_device is not None: app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type) _state.receiver_active_device = None - _state.receiver_active_sdr_type = 'rtlsdr' + _state.receiver_active_sdr_type = "rtlsdr" - start_error = '' - for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'): + start_error = "" + for log_path in ("/tmp/rtl_fm_stderr.log", "/tmp/ffmpeg_stderr.log"): try: with open(log_path) as handle: content = handle.read().strip() @@ -234,63 +226,62 @@ def start_audio() -> Response: except Exception: continue - message = 'Failed to start audio. Check SDR device.' + message = "Failed to start audio. Check SDR device." if start_error: - message = f'Failed to start audio: {start_error}' - return jsonify({ - 'status': 'error', - 'message': message - }), 500 + message = f"Failed to start audio: {start_error}" + return jsonify({"status": "error", "message": message}), 500 -@receiver_bp.route('/audio/stop', methods=['POST']) +@receiver_bp.route("/audio/stop", methods=["POST"]) def stop_audio() -> Response: """Stop audio.""" _stop_audio_stream() if _state.receiver_active_device is not None: app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type) _state.receiver_active_device = None - _state.receiver_active_sdr_type = 'rtlsdr' - return jsonify({'status': 'stopped'}) + _state.receiver_active_sdr_type = "rtlsdr" + return jsonify({"status": "stopped"}) -@receiver_bp.route('/audio/status') +@receiver_bp.route("/audio/status") def audio_status() -> Response: """Get audio status.""" running = _state.audio_running - if _state.audio_source == 'waterfall': + if _state.audio_source == "waterfall": try: from routes.waterfall_websocket import get_shared_capture_status shared = get_shared_capture_status() - running = bool(shared.get('running') and shared.get('monitor_enabled')) + running = bool(shared.get("running") and shared.get("monitor_enabled")) except Exception: running = False - return jsonify({ - 'running': running, - 'frequency': _state.audio_frequency, - 'modulation': _state.audio_modulation, - 'source': _state.audio_source, - }) + return jsonify( + { + "running": running, + "frequency": _state.audio_frequency, + "modulation": _state.audio_modulation, + "source": _state.audio_source, + } + ) -@receiver_bp.route('/audio/debug') +@receiver_bp.route("/audio/debug") def audio_debug() -> Response: """Get audio debug status and recent stderr logs.""" - rtl_log_path = '/tmp/rtl_fm_stderr.log' - ffmpeg_log_path = '/tmp/ffmpeg_stderr.log' - sample_path = '/tmp/audio_probe.bin' + rtl_log_path = "/tmp/rtl_fm_stderr.log" + ffmpeg_log_path = "/tmp/ffmpeg_stderr.log" + sample_path = "/tmp/audio_probe.bin" def _read_log(path: str) -> str: try: with open(path) as handle: return handle.read().strip() except Exception: - return '' + return "" shared = {} - if _state.audio_source == 'waterfall': + if _state.audio_source == "waterfall": try: from routes.waterfall_websocket import get_shared_capture_status @@ -298,65 +289,67 @@ def audio_debug() -> Response: except Exception: shared = {} - return jsonify({ - 'running': _state.audio_running, - 'frequency': _state.audio_frequency, - 'modulation': _state.audio_modulation, - 'source': _state.audio_source, - 'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'), - 'device': scanner_config.get('device', 0), - 'gain': scanner_config.get('gain', 0), - 'squelch': scanner_config.get('squelch', 0), - 'audio_process_alive': bool(_state.audio_process and _state.audio_process.poll() is None), - 'shared_capture': shared, - 'rtl_fm_stderr': _read_log(rtl_log_path), - 'ffmpeg_stderr': _read_log(ffmpeg_log_path), - 'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0, - }) + return jsonify( + { + "running": _state.audio_running, + "frequency": _state.audio_frequency, + "modulation": _state.audio_modulation, + "source": _state.audio_source, + "sdr_type": scanner_config.get("sdr_type", "rtlsdr"), + "device": scanner_config.get("device", 0), + "gain": scanner_config.get("gain", 0), + "squelch": scanner_config.get("squelch", 0), + "audio_process_alive": bool(_state.audio_process and _state.audio_process.poll() is None), + "shared_capture": shared, + "rtl_fm_stderr": _read_log(rtl_log_path), + "ffmpeg_stderr": _read_log(ffmpeg_log_path), + "audio_probe_bytes": os.path.getsize(sample_path) if os.path.exists(sample_path) else 0, + } + ) -@receiver_bp.route('/audio/probe') +@receiver_bp.route("/audio/probe") def audio_probe() -> Response: """Grab a small chunk of audio bytes from the pipeline for debugging.""" - if _state.audio_source == 'waterfall': + if _state.audio_source == "waterfall": try: from routes.waterfall_websocket import read_shared_monitor_audio_chunk data = read_shared_monitor_audio_chunk(timeout=2.0) if not data: - return jsonify({'status': 'error', 'message': 'no shared audio data available'}), 504 - sample_path = '/tmp/audio_probe.bin' - with open(sample_path, 'wb') as handle: + return jsonify({"status": "error", "message": "no shared audio data available"}), 504 + sample_path = "/tmp/audio_probe.bin" + with open(sample_path, "wb") as handle: handle.write(data) - return jsonify({'status': 'ok', 'bytes': len(data), 'source': 'waterfall'}) + return jsonify({"status": "ok", "bytes": len(data), "source": "waterfall"}) except Exception as e: - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 if not _state.audio_process or not _state.audio_process.stdout: - return jsonify({'status': 'error', 'message': 'audio process not running'}), 400 + return jsonify({"status": "error", "message": "audio process not running"}), 400 - sample_path = '/tmp/audio_probe.bin' + sample_path = "/tmp/audio_probe.bin" size = 0 try: ready, _, _ = select.select([_state.audio_process.stdout], [], [], 2.0) if not ready: - return jsonify({'status': 'error', 'message': 'no data available'}), 504 + return jsonify({"status": "error", "message": "no data available"}), 504 data = _state.audio_process.stdout.read(4096) if not data: - return jsonify({'status': 'error', 'message': 'no data read'}), 504 - with open(sample_path, 'wb') as handle: + return jsonify({"status": "error", "message": "no data read"}), 504 + with open(sample_path, "wb") as handle: handle.write(data) size = len(data) except Exception as e: - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 - return jsonify({'status': 'ok', 'bytes': size}) + return jsonify({"status": "ok", "bytes": size}) -@receiver_bp.route('/audio/stream') +@receiver_bp.route("/audio/stream") def stream_audio() -> Response: """Stream WAV audio.""" - request_token_raw = request.args.get('request_token') + request_token_raw = request.args.get("request_token") request_token = None if request_token_raw is not None: try: @@ -365,16 +358,16 @@ def stream_audio() -> Response: request_token = None if request_token is not None and request_token < _state.audio_start_token: - return Response(b'', mimetype='audio/wav', status=204) + return Response(b"", mimetype="audio/wav", status=204) - if _state.audio_source == 'waterfall': + if _state.audio_source == "waterfall": for _ in range(40): if _state.audio_running: break time.sleep(0.05) if not _state.audio_running: - return Response(b'', mimetype='audio/wav', status=204) + return Response(b"", mimetype="audio/wav", status=204) def generate_shared(): try: @@ -389,7 +382,7 @@ def stream_audio() -> Response: yield _wav_header(sample_rate=48000) inactive_since: float | None = None - while _state.audio_running and _state.audio_source == 'waterfall': + while _state.audio_running and _state.audio_source == "waterfall": if request_token is not None and request_token < _state.audio_start_token: break chunk = read_shared_monitor_audio_chunk(timeout=1.0) @@ -398,7 +391,7 @@ def stream_audio() -> Response: yield chunk continue shared = get_shared_capture_status() - if shared.get('running') and shared.get('monitor_enabled'): + if shared.get("running") and shared.get("monitor_enabled"): inactive_since = None continue if inactive_since is None: @@ -406,20 +399,20 @@ def stream_audio() -> Response: continue if (time.monotonic() - inactive_since) < 4.0: continue - if not shared.get('running') or not shared.get('monitor_enabled'): + if not shared.get("running") or not shared.get("monitor_enabled"): _state.audio_running = False - _state.audio_source = 'process' + _state.audio_source = "process" break return Response( generate_shared(), - mimetype='audio/wav', + mimetype="audio/wav", headers={ - 'Content-Type': 'audio/wav', - 'Cache-Control': 'no-cache, no-store', - 'X-Accel-Buffering': 'no', - 'Transfer-Encoding': 'chunked', - } + "Content-Type": "audio/wav", + "Cache-Control": "no-cache, no-store", + "X-Accel-Buffering": "no", + "Transfer-Encoding": "chunked", + }, ) # Wait for audio process to be ready (up to 2 seconds). @@ -429,7 +422,7 @@ def stream_audio() -> Response: time.sleep(0.05) if not _state.audio_running or not _state.audio_process: - return Response(b'', mimetype='audio/wav', status=204) + return Response(b"", mimetype="audio/wav", status=204) def generate(): # Capture local reference to avoid race condition with stop @@ -486,11 +479,11 @@ def stream_audio() -> Response: return Response( generate(), - mimetype='audio/wav', + mimetype="audio/wav", headers={ - 'Content-Type': 'audio/wav', - 'Cache-Control': 'no-cache, no-store', - 'X-Accel-Buffering': 'no', - 'Transfer-Encoding': 'chunked', - } + "Content-Type": "audio/wav", + "Cache-Control": "no-cache, no-store", + "X-Accel-Buffering": "no", + "Transfer-Encoding": "chunked", + }, ) diff --git a/routes/listening_post/scanner.py b/routes/listening_post/scanner.py index 01fa090..e10ac00 100644 --- a/routes/listening_post/scanner.py +++ b/routes/listening_post/scanner.py @@ -42,21 +42,25 @@ from . import ( # SCANNER IMPLEMENTATION # ============================================ + def scanner_loop(): """Main scanner loop - scans frequencies looking for signals.""" logger.info("Scanner thread started") - add_activity_log('scanner_start', scanner_config['start_freq'], - f"Scanning {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz") + add_activity_log( + "scanner_start", + scanner_config["start_freq"], + f"Scanning {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz", + ) rtl_fm_path = find_rtl_fm() if not rtl_fm_path: logger.error("rtl_fm not found") - add_activity_log('error', 0, 'rtl_fm not found') + add_activity_log("error", 0, "rtl_fm not found") _state.scanner_running = False return - current_freq = scanner_config['start_freq'] + current_freq = scanner_config["start_freq"] last_signal_time = 0 signal_detected = False @@ -68,32 +72,34 @@ def scanner_loop(): continue # Read config values on each iteration (allows live updates) - step_mhz = scanner_config['step'] / 1000.0 - squelch = scanner_config['squelch'] - mod = scanner_config['modulation'] - gain = scanner_config['gain'] - device = scanner_config['device'] + step_mhz = scanner_config["step"] / 1000.0 + squelch = scanner_config["squelch"] + mod = scanner_config["modulation"] + gain = scanner_config["gain"] + device = scanner_config["device"] _state.scanner_current_freq = current_freq # Notify clients of frequency change with contextlib.suppress(queue.Full): - scanner_queue.put_nowait({ - 'type': 'freq_change', - 'frequency': current_freq, - 'scanning': not signal_detected, - 'range_start': scanner_config['start_freq'], - 'range_end': scanner_config['end_freq'] - }) + scanner_queue.put_nowait( + { + "type": "freq_change", + "frequency": current_freq, + "scanning": not signal_detected, + "range_start": scanner_config["start_freq"], + "range_end": scanner_config["end_freq"], + } + ) # Start rtl_fm at this frequency freq_hz = int(current_freq * 1e6) # Sample rates - if mod == 'wfm': + if mod == "wfm": sample_rate = 170000 resample_rate = 32000 - elif mod in ['usb', 'lsb']: + elif mod in ["usb", "lsb"]: sample_rate = 12000 resample_rate = 12000 else: @@ -103,27 +109,29 @@ def scanner_loop(): # Don't use squelch in rtl_fm - we want to analyze raw audio rtl_cmd = [ rtl_fm_path, - '-M', _rtl_fm_demod_mode(mod), - '-f', str(freq_hz), - '-s', str(sample_rate), - '-r', str(resample_rate), - '-g', str(gain), - '-d', str(device), + "-M", + _rtl_fm_demod_mode(mod), + "-f", + str(freq_hz), + "-s", + str(sample_rate), + "-r", + str(resample_rate), + "-g", + str(gain), + "-d", + str(device), ] # Add bias-t flag if enabled (for external LNA power) - if scanner_config.get('bias_t', False): - rtl_cmd.append('-T') + if scanner_config.get("bias_t", False): + rtl_cmd.append("-T") try: # Start rtl_fm - rtl_proc = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL - ) + rtl_proc = subprocess.Popen(rtl_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) # Read audio data for analysis - audio_data = b'' + audio_data = b"" # Read audio samples for a short period sample_duration = 0.25 # 250ms - balance between speed and detection @@ -147,14 +155,14 @@ def scanner_loop(): rms = 0 threshold = 500 if len(audio_data) > 100: - samples = struct.unpack(f'{len(audio_data)//2}h', audio_data) + samples = struct.unpack(f"{len(audio_data) // 2}h", audio_data) # Calculate RMS level (root mean square) - rms = (sum(s*s for s in samples) / len(samples)) ** 0.5 + rms = (sum(s * s for s in samples) / len(samples)) ** 0.5 # Threshold based on squelch setting # Lower squelch = more sensitive (lower threshold) # squelch 0 = very sensitive, squelch 100 = only strong signals - if mod == 'wfm': + if mod == "wfm": # WFM: threshold 500-10000 based on squelch threshold = 500 + (squelch * 95) min_threshold = 1500 @@ -168,41 +176,50 @@ def scanner_loop(): # Send level info to clients with contextlib.suppress(queue.Full): - scanner_queue.put_nowait({ - 'type': 'scan_update', - 'frequency': current_freq, - 'level': int(rms), - 'threshold': int(effective_threshold) if 'effective_threshold' in dir() else 0, - 'detected': audio_detected, - 'range_start': scanner_config['start_freq'], - 'range_end': scanner_config['end_freq'] - }) + scanner_queue.put_nowait( + { + "type": "scan_update", + "frequency": current_freq, + "level": int(rms), + "threshold": int(effective_threshold) if "effective_threshold" in dir() else 0, + "detected": audio_detected, + "range_start": scanner_config["start_freq"], + "range_end": scanner_config["end_freq"], + } + ) if audio_detected and _state.scanner_running: if not signal_detected: # New signal found! signal_detected = True last_signal_time = time.time() - add_activity_log('signal_found', current_freq, - f'Signal detected on {current_freq:.3f} MHz ({mod.upper()})') + add_activity_log( + "signal_found", current_freq, f"Signal detected on {current_freq:.3f} MHz ({mod.upper()})" + ) logger.info(f"Signal found at {current_freq} MHz") # Start audio streaming for user _start_audio_stream(current_freq, mod) try: - snr_db = round(10 * math.log10(rms / effective_threshold), 1) if rms > 0 and effective_threshold > 0 else 0.0 - scanner_queue.put_nowait({ - 'type': 'signal_found', - 'frequency': current_freq, - 'modulation': mod, - 'audio_streaming': True, - 'level': int(rms), - 'threshold': int(effective_threshold), - 'snr': snr_db, - 'range_start': scanner_config['start_freq'], - 'range_end': scanner_config['end_freq'] - }) + snr_db = ( + round(10 * math.log10(rms / effective_threshold), 1) + if rms > 0 and effective_threshold > 0 + else 0.0 + ) + scanner_queue.put_nowait( + { + "type": "signal_found", + "frequency": current_freq, + "modulation": mod, + "audio_streaming": True, + "level": int(rms), + "threshold": int(effective_threshold), + "snr": snr_db, + "range_start": scanner_config["start_freq"], + "range_end": scanner_config["end_freq"], + } + ) except queue.Full: pass @@ -212,19 +229,16 @@ def scanner_loop(): signal_detected = False _stop_audio_stream() with contextlib.suppress(queue.Full): - scanner_queue.put_nowait({ - 'type': 'signal_skipped', - 'frequency': current_freq - }) + scanner_queue.put_nowait({"type": "signal_skipped", "frequency": current_freq}) # Move to next frequency (step is in kHz, convert to MHz) current_freq += step_mhz - if current_freq > scanner_config['end_freq']: - current_freq = scanner_config['start_freq'] + if current_freq > scanner_config["end_freq"]: + current_freq = scanner_config["start_freq"] continue # Stay on this frequency (dwell) but check periodically dwell_start = time.time() - while (time.time() - dwell_start) < scanner_config['dwell_time'] and _state.scanner_running: + while (time.time() - dwell_start) < scanner_config["dwell_time"] and _state.scanner_running: if _state.scanner_skip_signal: break time.sleep(0.2) @@ -236,44 +250,42 @@ def scanner_loop(): signal_detected = False _stop_audio_stream() with contextlib.suppress(queue.Full): - scanner_queue.put_nowait({ - 'type': 'signal_lost', - 'frequency': current_freq, - 'range_start': scanner_config['start_freq'], - 'range_end': scanner_config['end_freq'] - }) + scanner_queue.put_nowait( + { + "type": "signal_lost", + "frequency": current_freq, + "range_start": scanner_config["start_freq"], + "range_end": scanner_config["end_freq"], + } + ) current_freq += step_mhz - if current_freq > scanner_config['end_freq']: - current_freq = scanner_config['start_freq'] - add_activity_log('scan_cycle', current_freq, 'Scan cycle complete') - time.sleep(scanner_config['scan_delay']) + if current_freq > scanner_config["end_freq"]: + current_freq = scanner_config["start_freq"] + add_activity_log("scan_cycle", current_freq, "Scan cycle complete") + time.sleep(scanner_config["scan_delay"]) else: # No signal at this frequency if signal_detected: # Signal lost - duration = time.time() - last_signal_time + scanner_config['dwell_time'] - add_activity_log('signal_lost', current_freq, - f'Signal lost after {duration:.1f}s') + duration = time.time() - last_signal_time + scanner_config["dwell_time"] + add_activity_log("signal_lost", current_freq, f"Signal lost after {duration:.1f}s") signal_detected = False # Stop audio _stop_audio_stream() with contextlib.suppress(queue.Full): - scanner_queue.put_nowait({ - 'type': 'signal_lost', - 'frequency': current_freq - }) + scanner_queue.put_nowait({"type": "signal_lost", "frequency": current_freq}) # Move to next frequency (step is in kHz, convert to MHz) current_freq += step_mhz - if current_freq > scanner_config['end_freq']: - current_freq = scanner_config['start_freq'] - add_activity_log('scan_cycle', current_freq, 'Scan cycle complete') + if current_freq > scanner_config["end_freq"]: + current_freq = scanner_config["start_freq"] + add_activity_log("scan_cycle", current_freq, "Scan cycle complete") - time.sleep(scanner_config['scan_delay']) + time.sleep(scanner_config["scan_delay"]) except Exception as e: logger.error(f"Scanner error at {current_freq} MHz: {e}") @@ -284,20 +296,23 @@ def scanner_loop(): finally: _state.scanner_running = False _stop_audio_stream() - add_activity_log('scanner_stop', _state.scanner_current_freq, 'Scanner stopped') + add_activity_log("scanner_stop", _state.scanner_current_freq, "Scanner stopped") logger.info("Scanner thread stopped") def scanner_loop_power(): """Power sweep scanner using rtl_power to detect peaks.""" logger.info("Power sweep scanner thread started") - add_activity_log('scanner_start', scanner_config['start_freq'], - f"Power sweep {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz") + add_activity_log( + "scanner_start", + scanner_config["start_freq"], + f"Power sweep {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz", + ) rtl_power_path = find_rtl_power() if not rtl_power_path: logger.error("rtl_power not found") - add_activity_log('error', 0, 'rtl_power not found') + add_activity_log("error", 0, "rtl_power not found") _state.scanner_running = False return @@ -307,28 +322,32 @@ def scanner_loop_power(): time.sleep(0.1) continue - start_mhz = scanner_config['start_freq'] - end_mhz = scanner_config['end_freq'] - step_khz = scanner_config['step'] - gain = scanner_config['gain'] - device = scanner_config['device'] - scanner_config['squelch'] - mod = scanner_config['modulation'] + start_mhz = scanner_config["start_freq"] + end_mhz = scanner_config["end_freq"] + step_khz = scanner_config["step"] + gain = scanner_config["gain"] + device = scanner_config["device"] + scanner_config["squelch"] + mod = scanner_config["modulation"] # Configure sweep bin_hz = max(1000, int(step_khz * 1000)) start_hz = int(start_mhz * 1e6) end_hz = int(end_mhz * 1e6) # Integration time per sweep (seconds) - integration = max(0.3, min(1.0, scanner_config.get('scan_delay', 0.5))) + integration = max(0.3, min(1.0, scanner_config.get("scan_delay", 0.5))) cmd = [ rtl_power_path, - '-f', f'{start_hz}:{end_hz}:{bin_hz}', - '-i', f'{integration}', - '-1', - '-g', str(gain), - '-d', str(device), + "-f", + f"{start_hz}:{end_hz}:{bin_hz}", + "-i", + f"{integration}", + "-1", + "-g", + str(gain), + "-d", + str(device), ] try: @@ -337,7 +356,7 @@ def scanner_loop_power(): stdout, _ = proc.communicate(timeout=15) except subprocess.TimeoutExpired: proc.kill() - stdout = b'' + stdout = b"" finally: _state.scanner_power_process = None @@ -345,27 +364,29 @@ def scanner_loop_power(): break if not stdout: - add_activity_log('error', start_mhz, 'Power sweep produced no data') + add_activity_log("error", start_mhz, "Power sweep produced no data") with contextlib.suppress(queue.Full): - scanner_queue.put_nowait({ - 'type': 'scan_update', - 'frequency': end_mhz, - 'level': 0, - 'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100), - 'detected': False, - 'range_start': scanner_config['start_freq'], - 'range_end': scanner_config['end_freq'] - }) + scanner_queue.put_nowait( + { + "type": "scan_update", + "frequency": end_mhz, + "level": 0, + "threshold": int(float(scanner_config.get("snr_threshold", 12)) * 100), + "detected": False, + "range_start": scanner_config["start_freq"], + "range_end": scanner_config["end_freq"], + } + ) time.sleep(0.2) continue - lines = stdout.decode(errors='ignore').splitlines() + lines = stdout.decode(errors="ignore").splitlines() segments = [] for line in lines: - if not line or line.startswith('#'): + if not line or line.startswith("#"): continue - parts = [p.strip() for p in line.split(',')] + parts = [p.strip() for p in line.split(",")] # Find start_hz token start_idx = None for i, tok in enumerate(parts): @@ -384,7 +405,7 @@ def scanner_loop_power(): sweep_end = float(parts[start_idx + 1]) sweep_bin = float(parts[start_idx + 2]) raw_values = [] - for v in parts[start_idx + 3:]: + for v in parts[start_idx + 3 :]: try: raw_values.append(float(v)) except ValueError: @@ -402,17 +423,19 @@ def scanner_loop_power(): segments.append((sweep_start, sweep_end, sweep_bin, bin_values)) if not segments: - add_activity_log('error', start_mhz, 'Power sweep bins missing') + add_activity_log("error", start_mhz, "Power sweep bins missing") with contextlib.suppress(queue.Full): - scanner_queue.put_nowait({ - 'type': 'scan_update', - 'frequency': end_mhz, - 'level': 0, - 'threshold': int(float(scanner_config.get('snr_threshold', 12)) * 100), - 'detected': False, - 'range_start': scanner_config['start_freq'], - 'range_end': scanner_config['end_freq'] - }) + scanner_queue.put_nowait( + { + "type": "scan_update", + "frequency": end_mhz, + "level": 0, + "threshold": int(float(scanner_config.get("snr_threshold", 12)) * 100), + "detected": False, + "range_start": scanner_config["start_freq"], + "range_end": scanner_config["end_freq"], + } + ) time.sleep(0.2) continue @@ -431,7 +454,7 @@ def scanner_loop_power(): noise_floor = sorted_vals[mid] # SNR threshold (dB) - snr_threshold = float(scanner_config.get('snr_threshold', 12)) + snr_threshold = float(scanner_config.get("snr_threshold", 12)) # Emit progress updates (throttled) emit_stride = max(1, len(bin_values) // 60) @@ -445,16 +468,18 @@ def scanner_loop_power(): threshold = int(snr_threshold * 100) progress = min(1.0, (segment_offset + idx) / max(1, total_bins - 1)) with contextlib.suppress(queue.Full): - scanner_queue.put_nowait({ - 'type': 'scan_update', - 'frequency': _state.scanner_current_freq, - 'level': level, - 'threshold': threshold, - 'detected': snr >= snr_threshold, - 'progress': progress, - 'range_start': scanner_config['start_freq'], - 'range_end': scanner_config['end_freq'] - }) + scanner_queue.put_nowait( + { + "type": "scan_update", + "frequency": _state.scanner_current_freq, + "level": level, + "threshold": threshold, + "detected": snr >= snr_threshold, + "progress": progress, + "range_start": scanner_config["start_freq"], + "range_end": scanner_config["end_freq"], + } + ) segment_offset += len(bin_values) # Detect peaks (clusters above threshold) @@ -488,29 +513,30 @@ def scanner_loop_power(): snr = val - noise_floor level = int(max(0, snr) * 100) threshold = int(snr_threshold * 100) - add_activity_log('signal_found', freq_mhz, - f'Peak detected at {freq_mhz:.3f} MHz ({mod.upper()})') + add_activity_log("signal_found", freq_mhz, f"Peak detected at {freq_mhz:.3f} MHz ({mod.upper()})") with contextlib.suppress(queue.Full): - scanner_queue.put_nowait({ - 'type': 'signal_found', - 'frequency': freq_mhz, - 'modulation': mod, - 'audio_streaming': False, - 'level': level, - 'threshold': threshold, - 'snr': round(snr, 1), - 'range_start': scanner_config['start_freq'], - 'range_end': scanner_config['end_freq'] - }) + scanner_queue.put_nowait( + { + "type": "signal_found", + "frequency": freq_mhz, + "modulation": mod, + "audio_streaming": False, + "level": level, + "threshold": threshold, + "snr": round(snr, 1), + "range_start": scanner_config["start_freq"], + "range_end": scanner_config["end_freq"], + } + ) - add_activity_log('scan_cycle', start_mhz, 'Power sweep complete') - time.sleep(max(0.1, scanner_config.get('scan_delay', 0.5))) + add_activity_log("scan_cycle", start_mhz, "Power sweep complete") + time.sleep(max(0.1, scanner_config.get("scan_delay", 0.5))) except Exception as e: logger.error(f"Power sweep scanner error: {e}") finally: _state.scanner_running = False - add_activity_log('scanner_stop', _state.scanner_current_freq, 'Scanner stopped') + add_activity_log("scanner_stop", _state.scanner_current_freq, "Scanner stopped") logger.info("Power sweep scanner thread stopped") @@ -518,15 +544,13 @@ def scanner_loop_power(): # SCANNER API ENDPOINTS # ============================================ -@receiver_bp.route('/scanner/start', methods=['POST']) + +@receiver_bp.route("/scanner/start", methods=["POST"]) def start_scanner() -> Response: """Start the frequency scanner.""" with scanner_lock: if _state.scanner_running: - return jsonify({ - 'status': 'error', - 'message': 'Scanner already running' - }), 409 + return jsonify({"status": "error", "message": "Scanner already running"}), 409 # Clear stale queue entries so UI updates immediately try: @@ -539,106 +563,82 @@ def start_scanner() -> Response: # Update scanner config try: - scanner_config['start_freq'] = float(data.get('start_freq', 88.0)) - scanner_config['end_freq'] = float(data.get('end_freq', 108.0)) - scanner_config['step'] = float(data.get('step', 0.1)) - scanner_config['modulation'] = normalize_modulation(data.get('modulation', 'wfm')) - scanner_config['squelch'] = int(data.get('squelch', 0)) - scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0)) - scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5)) - scanner_config['device'] = int(data.get('device', 0)) - scanner_config['gain'] = int(data.get('gain', 40)) - scanner_config['bias_t'] = bool(data.get('bias_t', False)) - scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower() - scanner_config['scan_method'] = str(data.get('scan_method', '')).lower().strip() - if data.get('snr_threshold') is not None: - scanner_config['snr_threshold'] = float(data.get('snr_threshold')) + scanner_config["start_freq"] = float(data.get("start_freq", 88.0)) + scanner_config["end_freq"] = float(data.get("end_freq", 108.0)) + scanner_config["step"] = float(data.get("step", 0.1)) + scanner_config["modulation"] = normalize_modulation(data.get("modulation", "wfm")) + scanner_config["squelch"] = int(data.get("squelch", 0)) + scanner_config["dwell_time"] = float(data.get("dwell_time", 3.0)) + scanner_config["scan_delay"] = float(data.get("scan_delay", 0.5)) + scanner_config["device"] = int(data.get("device", 0)) + scanner_config["gain"] = int(data.get("gain", 40)) + scanner_config["bias_t"] = bool(data.get("bias_t", False)) + scanner_config["sdr_type"] = str(data.get("sdr_type", "rtlsdr")).lower() + scanner_config["scan_method"] = str(data.get("scan_method", "")).lower().strip() + if data.get("snr_threshold") is not None: + scanner_config["snr_threshold"] = float(data.get("snr_threshold")) except (ValueError, TypeError) as e: - return jsonify({ - 'status': 'error', - 'message': f'Invalid parameter: {e}' - }), 400 + return jsonify({"status": "error", "message": f"Invalid parameter: {e}"}), 400 # Validate - if scanner_config['start_freq'] >= scanner_config['end_freq']: - return jsonify({ - 'status': 'error', - 'message': 'start_freq must be less than end_freq' - }), 400 + if scanner_config["start_freq"] >= scanner_config["end_freq"]: + return jsonify({"status": "error", "message": "start_freq must be less than end_freq"}), 400 # Decide scan method - if not scanner_config['scan_method']: - scanner_config['scan_method'] = 'power' if find_rtl_power() else 'classic' + if not scanner_config["scan_method"]: + scanner_config["scan_method"] = "power" if find_rtl_power() else "classic" - sdr_type = scanner_config['sdr_type'] + sdr_type = scanner_config["sdr_type"] # Power scan only supports RTL-SDR for now - if scanner_config['scan_method'] == 'power' and (sdr_type != 'rtlsdr' or not find_rtl_power()): - scanner_config['scan_method'] = 'classic' + if scanner_config["scan_method"] == "power" and (sdr_type != "rtlsdr" or not find_rtl_power()): + scanner_config["scan_method"] = "classic" # Check tools based on chosen method - if scanner_config['scan_method'] == 'power': + if scanner_config["scan_method"] == "power": if not find_rtl_power(): - return jsonify({ - 'status': 'error', - 'message': 'rtl_power not found. Install rtl-sdr tools.' - }), 503 + return jsonify({"status": "error", "message": "rtl_power not found. Install rtl-sdr tools."}), 503 # Release listening device if active if _state.receiver_active_device is not None: app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type) _state.receiver_active_device = None - _state.receiver_active_sdr_type = 'rtlsdr' + _state.receiver_active_sdr_type = "rtlsdr" # Claim device for scanner - error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type']) + error = app_module.claim_sdr_device(scanner_config["device"], "scanner", scanner_config["sdr_type"]) if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error - }), 409 - _state.scanner_active_device = scanner_config['device'] - _state.scanner_active_sdr_type = scanner_config['sdr_type'] + return jsonify({"status": "error", "error_type": "DEVICE_BUSY", "message": error}), 409 + _state.scanner_active_device = scanner_config["device"] + _state.scanner_active_sdr_type = scanner_config["sdr_type"] _state.scanner_running = True _state.scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True) _state.scanner_thread.start() else: - if sdr_type == 'rtlsdr': + if sdr_type == "rtlsdr": if not find_rtl_fm(): - return jsonify({ - 'status': 'error', - 'message': 'rtl_fm not found. Install rtl-sdr tools.' - }), 503 + return jsonify({"status": "error", "message": "rtl_fm not found. Install rtl-sdr tools."}), 503 else: if not find_rx_fm(): - return jsonify({ - 'status': 'error', - 'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.' - }), 503 + return jsonify( + {"status": "error", "message": f"rx_fm not found. Install SoapySDR utilities for {sdr_type}."} + ), 503 if _state.receiver_active_device is not None: app_module.release_sdr_device(_state.receiver_active_device, _state.receiver_active_sdr_type) _state.receiver_active_device = None - _state.receiver_active_sdr_type = 'rtlsdr' - error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type']) + _state.receiver_active_sdr_type = "rtlsdr" + error = app_module.claim_sdr_device(scanner_config["device"], "scanner", scanner_config["sdr_type"]) if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error - }), 409 - _state.scanner_active_device = scanner_config['device'] - _state.scanner_active_sdr_type = scanner_config['sdr_type'] + return jsonify({"status": "error", "error_type": "DEVICE_BUSY", "message": error}), 409 + _state.scanner_active_device = scanner_config["device"] + _state.scanner_active_sdr_type = scanner_config["sdr_type"] _state.scanner_running = True _state.scanner_thread = threading.Thread(target=scanner_loop, daemon=True) _state.scanner_thread.start() - return jsonify({ - 'status': 'started', - 'config': scanner_config - }) + return jsonify({"status": "started", "config": scanner_config}) -@receiver_bp.route('/scanner/stop', methods=['POST']) +@receiver_bp.route("/scanner/stop", methods=["POST"]) def stop_scanner() -> Response: """Stop the frequency scanner.""" _state.scanner_running = False @@ -654,151 +654,138 @@ def stop_scanner() -> Response: if _state.scanner_active_device is not None: app_module.release_sdr_device(_state.scanner_active_device, _state.scanner_active_sdr_type) _state.scanner_active_device = None - _state.scanner_active_sdr_type = 'rtlsdr' + _state.scanner_active_sdr_type = "rtlsdr" - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@receiver_bp.route('/scanner/pause', methods=['POST']) +@receiver_bp.route("/scanner/pause", methods=["POST"]) def pause_scanner() -> Response: """Pause/resume the scanner.""" _state.scanner_paused = not _state.scanner_paused if _state.scanner_paused: - add_activity_log('scanner_pause', _state.scanner_current_freq, 'Scanner paused') + add_activity_log("scanner_pause", _state.scanner_current_freq, "Scanner paused") else: - add_activity_log('scanner_resume', _state.scanner_current_freq, 'Scanner resumed') + add_activity_log("scanner_resume", _state.scanner_current_freq, "Scanner resumed") - return jsonify({ - 'status': 'paused' if _state.scanner_paused else 'resumed', - 'paused': _state.scanner_paused - }) + return jsonify({"status": "paused" if _state.scanner_paused else "resumed", "paused": _state.scanner_paused}) -@receiver_bp.route('/scanner/skip', methods=['POST']) +@receiver_bp.route("/scanner/skip", methods=["POST"]) def skip_signal() -> Response: """Skip current signal and continue scanning.""" if not _state.scanner_running: - return jsonify({ - 'status': 'error', - 'message': 'Scanner not running' - }), 400 + return jsonify({"status": "error", "message": "Scanner not running"}), 400 _state.scanner_skip_signal = True - add_activity_log('signal_skip', _state.scanner_current_freq, f'Skipped signal at {_state.scanner_current_freq:.3f} MHz') + add_activity_log( + "signal_skip", _state.scanner_current_freq, f"Skipped signal at {_state.scanner_current_freq:.3f} MHz" + ) - return jsonify({ - 'status': 'skipped', - 'frequency': _state.scanner_current_freq - }) + return jsonify({"status": "skipped", "frequency": _state.scanner_current_freq}) -@receiver_bp.route('/scanner/config', methods=['POST']) +@receiver_bp.route("/scanner/config", methods=["POST"]) def update_scanner_config() -> Response: """Update scanner config while running (step, squelch, gain, dwell).""" data = request.json or {} updated = [] - if 'step' in data: - scanner_config['step'] = float(data['step']) + if "step" in data: + scanner_config["step"] = float(data["step"]) updated.append(f"step={data['step']}kHz") - if 'squelch' in data: - scanner_config['squelch'] = int(data['squelch']) + if "squelch" in data: + scanner_config["squelch"] = int(data["squelch"]) updated.append(f"squelch={data['squelch']}") - if 'gain' in data: - scanner_config['gain'] = int(data['gain']) + if "gain" in data: + scanner_config["gain"] = int(data["gain"]) updated.append(f"gain={data['gain']}") - if 'dwell_time' in data: - scanner_config['dwell_time'] = int(data['dwell_time']) + if "dwell_time" in data: + scanner_config["dwell_time"] = int(data["dwell_time"]) updated.append(f"dwell={data['dwell_time']}s") - if 'modulation' in data: + if "modulation" in data: try: - scanner_config['modulation'] = normalize_modulation(data['modulation']) + scanner_config["modulation"] = normalize_modulation(data["modulation"]) updated.append(f"mod={data['modulation']}") except (ValueError, TypeError) as e: - return jsonify({ - 'status': 'error', - 'message': str(e) - }), 400 + return jsonify({"status": "error", "message": str(e)}), 400 if updated: logger.info(f"Scanner config updated: {', '.join(updated)}") - return jsonify({ - 'status': 'updated', - 'config': scanner_config - }) + return jsonify({"status": "updated", "config": scanner_config}) -@receiver_bp.route('/scanner/status') +@receiver_bp.route("/scanner/status") def scanner_status() -> Response: """Get scanner status.""" - return jsonify({ - 'running': _state.scanner_running, - 'paused': _state.scanner_paused, - 'current_freq': _state.scanner_current_freq, - 'config': scanner_config, - 'audio_streaming': _state.audio_running, - 'audio_frequency': _state.audio_frequency - }) + return jsonify( + { + "running": _state.scanner_running, + "paused": _state.scanner_paused, + "current_freq": _state.scanner_current_freq, + "config": scanner_config, + "audio_streaming": _state.audio_running, + "audio_frequency": _state.audio_frequency, + } + ) -@receiver_bp.route('/scanner/stream') +@receiver_bp.route("/scanner/stream") def stream_scanner_events() -> Response: """SSE stream for scanner events.""" + def _on_msg(msg: dict[str, Any]) -> None: - process_event('receiver_scanner', msg, msg.get('type')) + process_event("receiver_scanner", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=scanner_queue, - channel_key='receiver_scanner', + channel_key="receiver_scanner", timeout=SSE_QUEUE_TIMEOUT, keepalive_interval=SSE_KEEPALIVE_INTERVAL, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" return response -@receiver_bp.route('/scanner/log') +@receiver_bp.route("/scanner/log") def get_activity_log() -> Response: """Get activity log.""" - limit = request.args.get('limit', 100, type=int) + limit = request.args.get("limit", 100, type=int) with activity_log_lock: - return jsonify({ - 'log': activity_log[:limit], - 'total': len(activity_log) - }) + return jsonify({"log": activity_log[:limit], "total": len(activity_log)}) -@receiver_bp.route('/scanner/log/clear', methods=['POST']) +@receiver_bp.route("/scanner/log/clear", methods=["POST"]) def clear_activity_log() -> Response: """Clear activity log.""" with activity_log_lock: activity_log.clear() - return jsonify({'status': 'cleared'}) + return jsonify({"status": "cleared"}) -@receiver_bp.route('/presets') +@receiver_bp.route("/presets") def get_presets() -> Response: """Get scanner presets.""" presets = [ - {'name': 'FM Broadcast', 'start': 88.0, 'end': 108.0, 'step': 0.2, 'mod': 'wfm'}, - {'name': 'Air Band', 'start': 118.0, 'end': 137.0, 'step': 0.025, 'mod': 'am'}, - {'name': 'Marine VHF', 'start': 156.0, 'end': 163.0, 'step': 0.025, 'mod': 'fm'}, - {'name': 'Amateur 2m', 'start': 144.0, 'end': 148.0, 'step': 0.0125, 'mod': 'fm'}, - {'name': 'Amateur 70cm', 'start': 430.0, 'end': 440.0, 'step': 0.025, 'mod': 'fm'}, - {'name': 'PMR446', 'start': 446.0, 'end': 446.2, 'step': 0.0125, 'mod': 'fm'}, - {'name': 'FRS/GMRS', 'start': 462.5, 'end': 467.7, 'step': 0.025, 'mod': 'fm'}, - {'name': 'Weather Radio', 'start': 162.4, 'end': 162.55, 'step': 0.025, 'mod': 'fm'}, + {"name": "FM Broadcast", "start": 88.0, "end": 108.0, "step": 0.2, "mod": "wfm"}, + {"name": "Air Band", "start": 118.0, "end": 137.0, "step": 0.025, "mod": "am"}, + {"name": "Marine VHF", "start": 156.0, "end": 163.0, "step": 0.025, "mod": "fm"}, + {"name": "Amateur 2m", "start": 144.0, "end": 148.0, "step": 0.0125, "mod": "fm"}, + {"name": "Amateur 70cm", "start": 430.0, "end": 440.0, "step": 0.025, "mod": "fm"}, + {"name": "PMR446", "start": 446.0, "end": 446.2, "step": 0.0125, "mod": "fm"}, + {"name": "FRS/GMRS", "start": 462.5, "end": 467.7, "step": 0.025, "mod": "fm"}, + {"name": "Weather Radio", "start": 162.4, "end": 162.55, "step": 0.025, "mod": "fm"}, ] - return jsonify({'presets': presets}) + return jsonify({"presets": presets}) diff --git a/routes/listening_post/tools.py b/routes/listening_post/tools.py index 0e9dc42..05d5f72 100644 --- a/routes/listening_post/tools.py +++ b/routes/listening_post/tools.py @@ -17,7 +17,8 @@ from . import ( # TOOL CHECK ENDPOINT # ============================================ -@receiver_bp.route('/tools') + +@receiver_bp.route("/tools") def check_tools() -> Response: """Check for required tools.""" rtl_fm = find_rtl_fm() @@ -28,63 +29,67 @@ def check_tools() -> Response: # Determine which SDR types are supported supported_sdr_types = [] if rtl_fm: - supported_sdr_types.append('rtlsdr') + supported_sdr_types.append("rtlsdr") if rx_fm: # rx_fm from SoapySDR supports these types - supported_sdr_types.extend(['hackrf', 'airspy', 'limesdr', 'sdrplay']) + supported_sdr_types.extend(["hackrf", "airspy", "limesdr", "sdrplay"]) - return jsonify({ - 'rtl_fm': rtl_fm is not None, - 'rtl_power': rtl_power is not None, - 'rx_fm': rx_fm is not None, - 'ffmpeg': ffmpeg is not None, - 'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None, - 'supported_sdr_types': supported_sdr_types - }) + return jsonify( + { + "rtl_fm": rtl_fm is not None, + "rtl_power": rtl_power is not None, + "rx_fm": rx_fm is not None, + "ffmpeg": ffmpeg is not None, + "available": (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None, + "supported_sdr_types": supported_sdr_types, + } + ) # ============================================ # SIGNAL IDENTIFICATION ENDPOINT # ============================================ -@receiver_bp.route('/signal/guess', methods=['POST']) + +@receiver_bp.route("/signal/guess", methods=["POST"]) def guess_signal() -> Response: """Identify a signal based on frequency, modulation, and other parameters.""" data = request.json or {} - freq_mhz = data.get('frequency_mhz') + freq_mhz = data.get("frequency_mhz") if freq_mhz is None: - return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400 + return jsonify({"status": "error", "message": "frequency_mhz is required"}), 400 try: freq_mhz = float(freq_mhz) except (ValueError, TypeError): - return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400 + return jsonify({"status": "error", "message": "Invalid frequency_mhz"}), 400 if freq_mhz <= 0: - return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400 + return jsonify({"status": "error", "message": "frequency_mhz must be positive"}), 400 frequency_hz = int(freq_mhz * 1e6) - modulation = data.get('modulation') - bandwidth_hz = data.get('bandwidth_hz') + modulation = data.get("modulation") + bandwidth_hz = data.get("bandwidth_hz") if bandwidth_hz is not None: try: bandwidth_hz = int(bandwidth_hz) except (ValueError, TypeError): bandwidth_hz = None - region = data.get('region', 'UK/EU') + region = data.get("region", "UK/EU") try: from utils.signal_guess import guess_signal_type_dict + result = guess_signal_type_dict( frequency_hz=frequency_hz, modulation=modulation, bandwidth_hz=bandwidth_hz, region=region, ) - return jsonify({'status': 'ok', **result}) + return jsonify({"status": "ok", **result}) except Exception as e: logger.error(f"Signal guess error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 diff --git a/routes/listening_post/waterfall.py b/routes/listening_post/waterfall.py index 20daa55..1db545d 100644 --- a/routes/listening_post/waterfall.py +++ b/routes/listening_post/waterfall.py @@ -34,12 +34,13 @@ from . import ( # WATERFALL HELPER FUNCTIONS # ============================================ + def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | None, list[float]]: """Parse a single rtl_power CSV line into bins.""" - if not line or line.startswith('#'): + if not line or line.startswith("#"): return None, None, None, [] - parts = [p.strip() for p in line.split(',')] + parts = [p.strip() for p in line.split(",")] if len(parts) < 6: return None, None, None, [] @@ -62,7 +63,7 @@ def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | seg_start = float(parts[start_idx]) seg_end = float(parts[start_idx + 1]) raw_values = [] - for v in parts[start_idx + 3:]: + for v in parts[start_idx + 3 :]: try: raw_values.append(float(v)) except ValueError: @@ -77,11 +78,13 @@ def _parse_rtl_power_line(line: str) -> tuple[str | None, float | None, float | def _queue_waterfall_error(message: str) -> None: """Push an error message onto the waterfall SSE queue.""" with contextlib.suppress(queue.Full): - _state.waterfall_queue.put_nowait({ - 'type': 'waterfall_error', - 'message': message, - 'timestamp': datetime.now().isoformat(), - }) + _state.waterfall_queue.put_nowait( + { + "type": "waterfall_error", + "message": message, + "timestamp": datetime.now().isoformat(), + } + ) def _downsample_bins(values: list[float], target: int) -> list[float]: @@ -107,9 +110,10 @@ def _downsample_bins(values: list[float], target: int) -> list[float]: # WATERFALL LOOP IMPLEMENTATIONS # ============================================ + def _waterfall_loop(): """Continuous waterfall sweep loop emitting FFT data.""" - sdr_type_str = _state.waterfall_config.get('sdr_type', 'rtlsdr') + sdr_type_str = _state.waterfall_config.get("sdr_type", "rtlsdr") try: sdr_type = SDRType(sdr_type_str) except ValueError: @@ -123,11 +127,11 @@ def _waterfall_loop(): def _waterfall_loop_iq(sdr_type: SDRType): """Waterfall loop using rx_sdr IQ capture + FFT for HackRF/SoapySDR devices.""" - start_freq = _state.waterfall_config['start_freq'] - end_freq = _state.waterfall_config['end_freq'] - gain = _state.waterfall_config['gain'] - device = _state.waterfall_config['device'] - interval = float(_state.waterfall_config.get('interval', 0.4)) + start_freq = _state.waterfall_config["start_freq"] + end_freq = _state.waterfall_config["end_freq"] + gain = _state.waterfall_config["gain"] + device = _state.waterfall_config["device"] + interval = float(_state.waterfall_config.get("interval", 0.4)) # Use center frequency and sample rate to cover the requested span center_mhz = (start_freq + end_freq) / 2.0 @@ -147,7 +151,7 @@ def _waterfall_loop_iq(sdr_type: SDRType): gain=float(gain), ) - fft_size = min(int(_state.waterfall_config.get('max_bins') or 1024), 4096) + fft_size = min(int(_state.waterfall_config.get("max_bins") or 1024), 4096) try: _state.waterfall_process = subprocess.Popen( @@ -159,19 +163,19 @@ def _waterfall_loop_iq(sdr_type: SDRType): # Detect immediate startup failures time.sleep(0.35) if _state.waterfall_process.poll() is not None: - stderr_text = '' + stderr_text = "" try: if _state.waterfall_process.stderr: - stderr_text = _state.waterfall_process.stderr.read().decode('utf-8', errors='ignore').strip() + stderr_text = _state.waterfall_process.stderr.read().decode("utf-8", errors="ignore").strip() except Exception: - stderr_text = '' - msg = stderr_text or f'IQ capture exited early (code {_state.waterfall_process.returncode})' + stderr_text = "" + msg = stderr_text or f"IQ capture exited early (code {_state.waterfall_process.returncode})" logger.error(f"Waterfall startup failed: {msg}") _queue_waterfall_error(msg) return if not _state.waterfall_process.stdout: - _queue_waterfall_error('IQ capture stdout unavailable') + _queue_waterfall_error("IQ capture stdout unavailable") return # Read IQ samples and compute FFT @@ -190,7 +194,7 @@ def _waterfall_loop_iq(sdr_type: SDRType): received_any = True # Convert CU8 to complex float: center at 127.5 - iq = struct.unpack(f'{fft_size * 2}B', raw) + iq = struct.unpack(f"{fft_size * 2}B", raw) # Compute power spectrum via FFT real_parts = [(iq[i * 2] - 127.5) / 127.5 for i in range(fft_size)] imag_parts = [(iq[i * 2 + 1] - 127.5) / 127.5 for i in range(fft_size)] @@ -199,6 +203,7 @@ def _waterfall_loop_iq(sdr_type: SDRType): try: # Try numpy if available for efficient FFT import numpy as np + samples = np.array(real_parts, dtype=np.float32) + 1j * np.array(imag_parts, dtype=np.float32) # Apply Hann window window = np.hanning(fft_size) @@ -211,19 +216,19 @@ def _waterfall_loop_iq(sdr_type: SDRType): # Just report raw magnitudes per sample as approximate power for i in range(fft_size): mag = math.sqrt(real_parts[i] ** 2 + imag_parts[i] ** 2) - power = 10.0 * math.log10(mag ** 2 + 1e-10) + power = 10.0 * math.log10(mag**2 + 1e-10) bins.append(power) - max_bins = int(_state.waterfall_config.get('max_bins') or 0) + max_bins = int(_state.waterfall_config.get("max_bins") or 0) if max_bins > 0 and len(bins) > max_bins: bins = _downsample_bins(bins, max_bins) msg = { - 'type': 'waterfall_sweep', - 'start_freq': start_freq, - 'end_freq': end_freq, - 'bins': bins, - 'timestamp': datetime.now().isoformat(), + "type": "waterfall_sweep", + "start_freq": start_freq, + "end_freq": end_freq, + "bins": bins, + "timestamp": datetime.now().isoformat(), } try: _state.waterfall_queue.put_nowait(msg) @@ -237,7 +242,7 @@ def _waterfall_loop_iq(sdr_type: SDRType): time.sleep(interval) if _state.waterfall_running and not received_any: - _queue_waterfall_error(f'No IQ data received from {sdr_type.value}') + _queue_waterfall_error(f"No IQ data received from {sdr_type.value}") except Exception as e: logger.error(f"Waterfall IQ loop error: {e}") @@ -260,23 +265,27 @@ def _waterfall_loop_rtl_power(): rtl_power_path = find_rtl_power() if not rtl_power_path: logger.error("rtl_power not found for waterfall") - _queue_waterfall_error('rtl_power not found') + _queue_waterfall_error("rtl_power not found") _state.waterfall_running = False return - start_hz = int(_state.waterfall_config['start_freq'] * 1e6) - end_hz = int(_state.waterfall_config['end_freq'] * 1e6) - bin_hz = int(_state.waterfall_config['bin_size']) - gain = _state.waterfall_config['gain'] - device = _state.waterfall_config['device'] - interval = float(_state.waterfall_config.get('interval', 0.4)) + start_hz = int(_state.waterfall_config["start_freq"] * 1e6) + end_hz = int(_state.waterfall_config["end_freq"] * 1e6) + bin_hz = int(_state.waterfall_config["bin_size"]) + gain = _state.waterfall_config["gain"] + device = _state.waterfall_config["device"] + interval = float(_state.waterfall_config.get("interval", 0.4)) cmd = [ rtl_power_path, - '-f', f'{start_hz}:{end_hz}:{bin_hz}', - '-i', str(interval), - '-g', str(gain), - '-d', str(device), + "-f", + f"{start_hz}:{end_hz}:{bin_hz}", + "-i", + str(interval), + "-g", + str(gain), + "-d", + str(device), ] try: @@ -291,13 +300,13 @@ def _waterfall_loop_rtl_power(): # Detect immediate startup failures (e.g. device busy / no device). time.sleep(0.35) if _state.waterfall_process.poll() is not None: - stderr_text = '' + stderr_text = "" try: if _state.waterfall_process.stderr: stderr_text = _state.waterfall_process.stderr.read().strip() except Exception: - stderr_text = '' - msg = stderr_text or f'rtl_power exited early (code {_state.waterfall_process.returncode})' + stderr_text = "" + msg = stderr_text or f"rtl_power exited early (code {_state.waterfall_process.returncode})" logger.error(f"Waterfall startup failed: {msg}") _queue_waterfall_error(msg) return @@ -309,7 +318,7 @@ def _waterfall_loop_rtl_power(): received_any = False if not _state.waterfall_process.stdout: - _queue_waterfall_error('rtl_power stdout unavailable') + _queue_waterfall_error("rtl_power stdout unavailable") return for line in _state.waterfall_process.stdout: @@ -325,16 +334,16 @@ def _waterfall_loop_rtl_power(): current_ts = ts if ts != current_ts and all_bins: - max_bins = int(_state.waterfall_config.get('max_bins') or 0) + max_bins = int(_state.waterfall_config.get("max_bins") or 0) bins_to_send = all_bins if max_bins > 0 and len(bins_to_send) > max_bins: bins_to_send = _downsample_bins(bins_to_send, max_bins) msg = { - 'type': 'waterfall_sweep', - 'start_freq': sweep_start_hz / 1e6, - 'end_freq': sweep_end_hz / 1e6, - 'bins': bins_to_send, - 'timestamp': datetime.now().isoformat(), + "type": "waterfall_sweep", + "start_freq": sweep_start_hz / 1e6, + "end_freq": sweep_end_hz / 1e6, + "bins": bins_to_send, + "timestamp": datetime.now().isoformat(), } try: _state.waterfall_queue.put_nowait(msg) @@ -357,22 +366,22 @@ def _waterfall_loop_rtl_power(): # Flush any remaining bins if all_bins and _state.waterfall_running: - max_bins = int(_state.waterfall_config.get('max_bins') or 0) + max_bins = int(_state.waterfall_config.get("max_bins") or 0) bins_to_send = all_bins if max_bins > 0 and len(bins_to_send) > max_bins: bins_to_send = _downsample_bins(bins_to_send, max_bins) msg = { - 'type': 'waterfall_sweep', - 'start_freq': sweep_start_hz / 1e6, - 'end_freq': sweep_end_hz / 1e6, - 'bins': bins_to_send, - 'timestamp': datetime.now().isoformat(), + "type": "waterfall_sweep", + "start_freq": sweep_start_hz / 1e6, + "end_freq": sweep_end_hz / 1e6, + "bins": bins_to_send, + "timestamp": datetime.now().isoformat(), } with contextlib.suppress(queue.Full): _state.waterfall_queue.put_nowait(msg) if _state.waterfall_running and not received_any: - _queue_waterfall_error('No waterfall FFT data received from rtl_power') + _queue_waterfall_error("No waterfall FFT data received from rtl_power") except Exception as e: logger.error(f"Waterfall loop error: {e}") @@ -394,22 +403,25 @@ def _waterfall_loop_rtl_power(): # WATERFALL API ENDPOINTS # ============================================ -@receiver_bp.route('/waterfall/start', methods=['POST']) + +@receiver_bp.route("/waterfall/start", methods=["POST"]) def start_waterfall() -> Response: """Start the waterfall/spectrogram display.""" with _state.waterfall_lock: if _state.waterfall_running: - return jsonify({ - 'status': 'started', - 'already_running': True, - 'message': 'Waterfall already running', - 'config': _state.waterfall_config, - }) + return jsonify( + { + "status": "started", + "already_running": True, + "message": "Waterfall already running", + "config": _state.waterfall_config, + } + ) data = request.json or {} # Determine SDR type - sdr_type_str = data.get('sdr_type', 'rtlsdr') + sdr_type_str = data.get("sdr_type", "rtlsdr") try: sdr_type = SDRType(sdr_type_str) except ValueError: @@ -418,30 +430,30 @@ def start_waterfall() -> Response: # RTL-SDR uses rtl_power; other types use rx_sdr via IQ capture if sdr_type == SDRType.RTL_SDR and not find_rtl_power(): - return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503 + return jsonify({"status": "error", "message": "rtl_power not found"}), 503 try: - _state.waterfall_config['start_freq'] = float(data.get('start_freq', 88.0)) - _state.waterfall_config['end_freq'] = float(data.get('end_freq', 108.0)) - _state.waterfall_config['bin_size'] = int(data.get('bin_size', 10000)) - _state.waterfall_config['gain'] = int(data.get('gain', 40)) - _state.waterfall_config['device'] = int(data.get('device', 0)) - _state.waterfall_config['sdr_type'] = sdr_type_str - if data.get('interval') is not None: - interval = float(data.get('interval', _state.waterfall_config['interval'])) + _state.waterfall_config["start_freq"] = float(data.get("start_freq", 88.0)) + _state.waterfall_config["end_freq"] = float(data.get("end_freq", 108.0)) + _state.waterfall_config["bin_size"] = int(data.get("bin_size", 10000)) + _state.waterfall_config["gain"] = int(data.get("gain", 40)) + _state.waterfall_config["device"] = int(data.get("device", 0)) + _state.waterfall_config["sdr_type"] = sdr_type_str + if data.get("interval") is not None: + interval = float(data.get("interval", _state.waterfall_config["interval"])) if interval < 0.1 or interval > 5: - return jsonify({'status': 'error', 'message': 'interval must be between 0.1 and 5 seconds'}), 400 - _state.waterfall_config['interval'] = interval - if data.get('max_bins') is not None: - max_bins = int(data.get('max_bins', _state.waterfall_config['max_bins'])) + return jsonify({"status": "error", "message": "interval must be between 0.1 and 5 seconds"}), 400 + _state.waterfall_config["interval"] = interval + if data.get("max_bins") is not None: + max_bins = int(data.get("max_bins", _state.waterfall_config["max_bins"])) if max_bins < 64 or max_bins > 4096: - return jsonify({'status': 'error', 'message': 'max_bins must be between 64 and 4096'}), 400 - _state.waterfall_config['max_bins'] = max_bins + return jsonify({"status": "error", "message": "max_bins must be between 64 and 4096"}), 400 + _state.waterfall_config["max_bins"] = max_bins except (ValueError, TypeError) as e: - return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400 + return jsonify({"status": "error", "message": f"Invalid parameter: {e}"}), 400 - if _state.waterfall_config['start_freq'] >= _state.waterfall_config['end_freq']: - return jsonify({'status': 'error', 'message': 'start_freq must be less than end_freq'}), 400 + if _state.waterfall_config["start_freq"] >= _state.waterfall_config["end_freq"]: + return jsonify({"status": "error", "message": "start_freq must be less than end_freq"}), 400 # Clear stale queue try: @@ -451,43 +463,44 @@ def start_waterfall() -> Response: pass # Claim SDR device - error = app_module.claim_sdr_device(_state.waterfall_config['device'], 'waterfall', sdr_type_str) + error = app_module.claim_sdr_device(_state.waterfall_config["device"], "waterfall", sdr_type_str) if error: - return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 + return jsonify({"status": "error", "error_type": "DEVICE_BUSY", "message": error}), 409 - _state.waterfall_active_device = _state.waterfall_config['device'] + _state.waterfall_active_device = _state.waterfall_config["device"] _state.waterfall_active_sdr_type = sdr_type_str _state.waterfall_running = True _state.waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True) _state.waterfall_thread.start() - return jsonify({'status': 'started', 'config': _state.waterfall_config}) + return jsonify({"status": "started", "config": _state.waterfall_config}) -@receiver_bp.route('/waterfall/stop', methods=['POST']) +@receiver_bp.route("/waterfall/stop", methods=["POST"]) def stop_waterfall() -> Response: """Stop the waterfall display.""" _stop_waterfall_internal() - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@receiver_bp.route('/waterfall/stream') +@receiver_bp.route("/waterfall/stream") def stream_waterfall() -> Response: """SSE stream for waterfall data.""" + def _on_msg(msg: dict[str, Any]) -> None: - process_event('waterfall', msg, msg.get('type')) + process_event("waterfall", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=_state.waterfall_queue, - channel_key='receiver_waterfall', + channel_key="receiver_waterfall", timeout=SSE_QUEUE_TIMEOUT, keepalive_interval=SSE_KEEPALIVE_INTERVAL, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" return response diff --git a/routes/meshtastic.py b/routes/meshtastic.py index 5c4f9f1..633cc45 100644 --- a/routes/meshtastic.py +++ b/routes/meshtastic.py @@ -25,9 +25,9 @@ from utils.meshtastic import ( from utils.responses import api_error from utils.sse import sse_stream_fanout -logger = get_logger('intercept.meshtastic') +logger = get_logger("intercept.meshtastic") -meshtastic_bp = Blueprint('meshtastic', __name__, url_prefix='/meshtastic') +meshtastic_bp = Blueprint("meshtastic", __name__, url_prefix="/meshtastic") # Queue for SSE message streaming _mesh_queue: queue.Queue = queue.Queue(maxsize=500) @@ -57,7 +57,7 @@ def _message_callback(msg: MeshtasticMessage) -> None: pass -@meshtastic_bp.route('/ports') +@meshtastic_bp.route("/ports") def list_ports(): """ List available serial ports that may have Meshtastic devices. @@ -66,30 +66,19 @@ def list_ports(): JSON with list of available serial ports. """ if not is_meshtastic_available(): - return jsonify({ - 'status': 'error', - 'ports': [], - 'message': 'Meshtastic SDK not installed' - }) + return jsonify({"status": "error", "ports": [], "message": "Meshtastic SDK not installed"}) try: from meshtastic.util import findPorts + ports = findPorts() - return jsonify({ - 'status': 'ok', - 'ports': ports, - 'count': len(ports) - }) + return jsonify({"status": "ok", "ports": ports, "count": len(ports)}) except Exception as e: logger.error(f"Error listing ports: {e}") - return jsonify({ - 'status': 'error', - 'ports': [], - 'message': str(e) - }) + return jsonify({"status": "error", "ports": [], "message": str(e)}) -@meshtastic_bp.route('/status') +@meshtastic_bp.route("/status") def get_status(): """ Get Meshtastic connection status. @@ -98,36 +87,42 @@ def get_status(): JSON with connection status, device info, connection type, and node information. """ if not is_meshtastic_available(): - return jsonify({ - 'available': False, - 'running': False, - 'error': 'Meshtastic SDK not installed. Install with: pip install meshtastic' - }) + return jsonify( + { + "available": False, + "running": False, + "error": "Meshtastic SDK not installed. Install with: pip install meshtastic", + } + ) client = get_meshtastic_client() if not client: - return jsonify({ - 'available': True, - 'running': False, - 'device': None, - 'connection_type': None, - 'node_info': None, - }) + return jsonify( + { + "available": True, + "running": False, + "device": None, + "connection_type": None, + "node_info": None, + } + ) node_info = client.get_node_info() if client.is_running else None - return jsonify({ - 'available': True, - 'running': client.is_running, - 'device': client.device_path, - 'connection_type': client.connection_type, - 'error': client.error, - 'node_info': node_info.to_dict() if node_info else None, - }) + return jsonify( + { + "available": True, + "running": client.is_running, + "device": client.device_path, + "connection_type": client.connection_type, + "error": client.error, + "node_info": node_info.to_dict() if node_info else None, + } + ) -@meshtastic_bp.route('/start', methods=['POST']) +@meshtastic_bp.route("/start", methods=["POST"]) def start_mesh(): """ Start Meshtastic listener. @@ -151,18 +146,15 @@ def start_mesh(): JSON with connection status. """ if not is_meshtastic_available(): - return jsonify({ - 'status': 'error', - 'message': 'Meshtastic SDK not installed. Install with: pip install meshtastic' - }), 400 + return jsonify( + {"status": "error", "message": "Meshtastic SDK not installed. Install with: pip install meshtastic"} + ), 400 client = get_meshtastic_client() if client and client.is_running: - return jsonify({ - 'status': 'already_running', - 'device': client.device_path, - 'connection_type': client.connection_type - }) + return jsonify( + {"status": "already_running", "device": client.device_path, "connection_type": client.connection_type} + ) # Clear queue and history while not _mesh_queue.empty(): @@ -174,30 +166,23 @@ def start_mesh(): # Parse connection parameters data = request.get_json(silent=True) or {} - connection_type = data.get('connection_type', 'serial').lower().strip() - device = data.get('device') - hostname = data.get('hostname') + connection_type = data.get("connection_type", "serial").lower().strip() + device = data.get("device") + hostname = data.get("hostname") # Validate connection type - if connection_type not in ('serial', 'tcp'): - return jsonify({ - 'status': 'error', - 'message': f"Invalid connection_type: {connection_type}. Must be 'serial' or 'tcp'" - }), 400 + if connection_type not in ("serial", "tcp"): + return jsonify( + {"status": "error", "message": f"Invalid connection_type: {connection_type}. Must be 'serial' or 'tcp'"} + ), 400 # Validate TCP parameters - if connection_type == 'tcp': + if connection_type == "tcp": if not hostname: - return jsonify({ - 'status': 'error', - 'message': 'hostname is required for TCP connections' - }), 400 + return jsonify({"status": "error", "message": "hostname is required for TCP connections"}), 400 hostname = str(hostname).strip() if not hostname: - return jsonify({ - 'status': 'error', - 'message': 'hostname cannot be empty' - }), 400 + return jsonify({"status": "error", "message": "hostname cannot be empty"}), 400 # Validate serial device path if provided if device: @@ -207,30 +192,28 @@ def start_mesh(): # Start client success = start_meshtastic( - device=device, - callback=_message_callback, - connection_type=connection_type, - hostname=hostname + device=device, callback=_message_callback, connection_type=connection_type, hostname=hostname ) if success: client = get_meshtastic_client() node_info = client.get_node_info() if client else None - return jsonify({ - 'status': 'started', - 'device': client.device_path if client else None, - 'connection_type': client.connection_type if client else None, - 'node_info': node_info.to_dict() if node_info else None, - }) + return jsonify( + { + "status": "started", + "device": client.device_path if client else None, + "connection_type": client.connection_type if client else None, + "node_info": node_info.to_dict() if node_info else None, + } + ) else: client = get_meshtastic_client() - return jsonify({ - 'status': 'error', - 'message': client.error if client else 'Failed to connect to Meshtastic device' - }), 500 + return jsonify( + {"status": "error", "message": client.error if client else "Failed to connect to Meshtastic device"} + ), 500 -@meshtastic_bp.route('/stop', methods=['POST']) +@meshtastic_bp.route("/stop", methods=["POST"]) def stop_mesh(): """ Stop Meshtastic listener. @@ -241,10 +224,10 @@ def stop_mesh(): JSON confirmation. """ stop_meshtastic() - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@meshtastic_bp.route('/channels') +@meshtastic_bp.route("/channels") def get_channels(): """ Get configured channels on the connected device. @@ -256,20 +239,13 @@ def get_channels(): client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device' - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device"}), 400 channels = client.get_channels() - return jsonify({ - 'status': 'ok', - 'channels': [ch.to_dict() for ch in channels], - 'count': len(channels) - }) + return jsonify({"status": "ok", "channels": [ch.to_dict() for ch in channels], "count": len(channels)}) -@meshtastic_bp.route('/channels/', methods=['POST']) +@meshtastic_bp.route("/channels/", methods=["POST"]) def configure_channel(index: int): """ Configure a channel with name and/or encryption key. @@ -304,26 +280,17 @@ def configure_channel(index: int): client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device' - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device"}), 400 if not 0 <= index <= 7: - return jsonify({ - 'status': 'error', - 'message': 'Channel index must be 0-7' - }), 400 + return jsonify({"status": "error", "message": "Channel index must be 0-7"}), 400 data = request.get_json(silent=True) or {} - name = data.get('name') - psk = data.get('psk') + name = data.get("name") + psk = data.get("psk") if not name and not psk: - return jsonify({ - 'status': 'error', - 'message': 'Must provide name and/or psk' - }), 400 + return jsonify({"status": "error", "message": "Must provide name and/or psk"}), 400 # Sanitize name if provided if name: @@ -339,19 +306,12 @@ def configure_channel(index: int): # Return updated channel info channels = client.get_channels() updated = next((ch for ch in channels if ch.index == index), None) - return jsonify({ - 'status': 'ok', - 'message': message, - 'channel': updated.to_dict() if updated else None - }) + return jsonify({"status": "ok", "message": message, "channel": updated.to_dict() if updated else None}) else: - return jsonify({ - 'status': 'error', - 'message': message - }), 500 + return jsonify({"status": "error", "message": message}), 500 -@meshtastic_bp.route('/send', methods=['POST']) +@meshtastic_bp.route("/send", methods=["POST"]) def send_message(): """ Send a text message to the mesh network. @@ -367,57 +327,39 @@ def send_message(): JSON with send status. """ if not is_meshtastic_available(): - return jsonify({ - 'status': 'error', - 'message': 'Meshtastic SDK not installed' - }), 400 + return jsonify({"status": "error", "message": "Meshtastic SDK not installed"}), 400 client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device' - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device"}), 400 data = request.get_json(silent=True) or {} - text = data.get('text', '').strip() + text = data.get("text", "").strip() if not text: - return jsonify({ - 'status': 'error', - 'message': 'Message text is required' - }), 400 + return jsonify({"status": "error", "message": "Message text is required"}), 400 if len(text) > 237: - return jsonify({ - 'status': 'error', - 'message': 'Message too long (max 237 characters)' - }), 400 + return jsonify({"status": "error", "message": "Message too long (max 237 characters)"}), 400 - channel = data.get('channel', 0) + channel = data.get("channel", 0) if not isinstance(channel, int) or not 0 <= channel <= 7: - return jsonify({ - 'status': 'error', - 'message': 'Channel must be 0-7' - }), 400 + return jsonify({"status": "error", "message": "Channel must be 0-7"}), 400 - destination = data.get('to') + destination = data.get("to") logger.info(f"Sending message: text='{text[:50]}...', channel={channel}, to={destination}") success, error = client.send_text(text, channel=channel, destination=destination) logger.info(f"Send result: success={success}, error={error}") if success: - return jsonify({'status': 'sent'}) + return jsonify({"status": "sent"}) else: - return jsonify({ - 'status': 'error', - 'message': error or 'Failed to send message' - }), 500 + return jsonify({"status": "error", "message": error or "Failed to send message"}), 500 -@meshtastic_bp.route('/messages') +@meshtastic_bp.route("/messages") def get_messages(): """ Get recent message history. @@ -432,27 +374,23 @@ def get_messages(): Returns: JSON with message list. """ - limit = request.args.get('limit', type=int) - channel = request.args.get('channel', type=int) + limit = request.args.get("limit", type=int) + channel = request.args.get("channel", type=int) messages = _recent_messages.copy() # Filter by channel if specified if channel is not None: - messages = [m for m in messages if m.get('channel') == channel] + messages = [m for m in messages if m.get("channel") == channel] # Apply limit if limit and limit > 0: messages = messages[-limit:] - return jsonify({ - 'status': 'ok', - 'messages': messages, - 'count': len(messages) - }) + return jsonify({"status": "ok", "messages": messages, "count": len(messages)}) -@meshtastic_bp.route('/stream') +@meshtastic_bp.route("/stream") def stream_messages(): """ SSE stream of Meshtastic messages. @@ -471,19 +409,19 @@ def stream_messages(): response = Response( sse_stream_fanout( source_queue=_mesh_queue, - channel_key='meshtastic', + channel_key="meshtastic", timeout=1.0, keepalive_interval=30.0, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response -@meshtastic_bp.route('/node') +@meshtastic_bp.route("/node") def get_node(): """ Get local node information. @@ -497,26 +435,17 @@ def get_node(): client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device' - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device"}), 400 node_info = client.get_node_info() if node_info: - return jsonify({ - 'status': 'ok', - 'node': node_info.to_dict() - }) + return jsonify({"status": "ok", "node": node_info.to_dict()}) else: - return jsonify({ - 'status': 'error', - 'message': 'Failed to get node information' - }), 500 + return jsonify({"status": "error", "message": "Failed to get node information"}), 500 -@meshtastic_bp.route('/nodes') +@meshtastic_bp.route("/nodes") def get_nodes(): """ Get all tracked mesh nodes with their positions. @@ -533,29 +462,27 @@ def get_nodes(): client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device', - 'nodes': [] - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device", "nodes": []}), 400 nodes = client.get_nodes() nodes_list = [n.to_dict() for n in nodes] # Filter to only nodes with positions if requested - with_position = request.args.get('with_position', '').lower() == 'true' + with_position = request.args.get("with_position", "").lower() == "true" if with_position: - nodes_list = [n for n in nodes_list if n.get('has_position')] + nodes_list = [n for n in nodes_list if n.get("has_position")] - return jsonify({ - 'status': 'ok', - 'nodes': nodes_list, - 'count': len(nodes_list), - 'with_position_count': sum(1 for n in nodes_list if n.get('has_position')) - }) + return jsonify( + { + "status": "ok", + "nodes": nodes_list, + "count": len(nodes_list), + "with_position_count": sum(1 for n in nodes_list if n.get("has_position")), + } + ) -@meshtastic_bp.route('/traceroute', methods=['POST']) +@meshtastic_bp.route("/traceroute", methods=["POST"]) def send_traceroute(): """ Send a traceroute request to a mesh node. @@ -570,48 +497,32 @@ def send_traceroute(): JSON with traceroute request status. """ if not is_meshtastic_available(): - return jsonify({ - 'status': 'error', - 'message': 'Meshtastic SDK not installed' - }), 400 + return jsonify({"status": "error", "message": "Meshtastic SDK not installed"}), 400 client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device' - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device"}), 400 data = request.get_json(silent=True) or {} - destination = data.get('destination') + destination = data.get("destination") if not destination: - return jsonify({ - 'status': 'error', - 'message': 'Destination node ID is required' - }), 400 + return jsonify({"status": "error", "message": "Destination node ID is required"}), 400 - hop_limit = data.get('hop_limit', 7) + hop_limit = data.get("hop_limit", 7) if not isinstance(hop_limit, int) or not 1 <= hop_limit <= 7: hop_limit = 7 success, error = client.send_traceroute(destination, hop_limit=hop_limit) if success: - return jsonify({ - 'status': 'sent', - 'destination': destination, - 'hop_limit': hop_limit - }) + return jsonify({"status": "sent", "destination": destination, "hop_limit": hop_limit}) else: - return jsonify({ - 'status': 'error', - 'message': error or 'Failed to send traceroute' - }), 500 + return jsonify({"status": "error", "message": error or "Failed to send traceroute"}), 500 -@meshtastic_bp.route('/traceroute/results') +@meshtastic_bp.route("/traceroute/results") def get_traceroute_results(): """ Get recent traceroute results. @@ -625,23 +536,15 @@ def get_traceroute_results(): client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device', - 'results': [] - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device", "results": []}), 400 - limit = request.args.get('limit', 10, type=int) + limit = request.args.get("limit", 10, type=int) results = client.get_traceroute_results(limit=limit) - return jsonify({ - 'status': 'ok', - 'results': [r.to_dict() for r in results], - 'count': len(results) - }) + return jsonify({"status": "ok", "results": [r.to_dict() for r in results], "count": len(results)}) -@meshtastic_bp.route('/position/request', methods=['POST']) +@meshtastic_bp.route("/position/request", methods=["POST"]) def request_position(): """ Request position from a specific node. @@ -655,43 +558,28 @@ def request_position(): JSON with request status. """ if not is_meshtastic_available(): - return jsonify({ - 'status': 'error', - 'message': 'Meshtastic SDK not installed' - }), 400 + return jsonify({"status": "error", "message": "Meshtastic SDK not installed"}), 400 client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device' - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device"}), 400 data = request.get_json(silent=True) or {} - node_id = data.get('node_id') + node_id = data.get("node_id") if not node_id: - return jsonify({ - 'status': 'error', - 'message': 'Node ID is required' - }), 400 + return jsonify({"status": "error", "message": "Node ID is required"}), 400 success, error = client.request_position(node_id) if success: - return jsonify({ - 'status': 'sent', - 'node_id': node_id - }) + return jsonify({"status": "sent", "node_id": node_id}) else: - return jsonify({ - 'status': 'error', - 'message': error or 'Failed to request position' - }), 500 + return jsonify({"status": "error", "message": error or "Failed to request position"}), 500 -@meshtastic_bp.route('/firmware/check') +@meshtastic_bp.route("/firmware/check") def check_firmware(): """ Check current firmware version and compare to latest release. @@ -702,17 +590,14 @@ def check_firmware(): client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device' - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device"}), 400 result = client.check_firmware() - result['status'] = 'ok' + result["status"] = "ok" return jsonify(result) -@meshtastic_bp.route('/channels//qr') +@meshtastic_bp.route("/channels//qr") def get_channel_qr(index: int): """ Generate QR code for a channel configuration. @@ -726,29 +611,22 @@ def get_channel_qr(index: int): client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device' - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device"}), 400 if not 0 <= index <= 7: - return jsonify({ - 'status': 'error', - 'message': 'Channel index must be 0-7' - }), 400 + return jsonify({"status": "error", "message": "Channel index must be 0-7"}), 400 png_data = client.generate_channel_qr(index) if png_data: - return Response(png_data, mimetype='image/png') + return Response(png_data, mimetype="image/png") else: - return jsonify({ - 'status': 'error', - 'message': 'Failed to generate QR code. Make sure qrcode library is installed.' - }), 500 + return jsonify( + {"status": "error", "message": "Failed to generate QR code. Make sure qrcode library is installed."} + ), 500 -@meshtastic_bp.route('/telemetry/history') +@meshtastic_bp.route("/telemetry/history") def get_telemetry_history(): """ Get telemetry history for a node. @@ -763,47 +641,37 @@ def get_telemetry_history(): client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device', - 'data': [] - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device", "data": []}), 400 - node_id = request.args.get('node_id') - hours = request.args.get('hours', 24, type=int) + node_id = request.args.get("node_id") + hours = request.args.get("hours", 24, type=int) if not node_id: - return jsonify({ - 'status': 'error', - 'message': 'node_id is required', - 'data': [] - }), 400 + return jsonify({"status": "error", "message": "node_id is required", "data": []}), 400 # Parse node ID to number try: - if node_id.startswith('!'): + if node_id.startswith("!"): node_num = int(node_id[1:], 16) else: node_num = int(node_id) except ValueError: - return jsonify({ - 'status': 'error', - 'message': f'Invalid node_id: {node_id}', - 'data': [] - }), 400 + return jsonify({"status": "error", "message": f"Invalid node_id: {node_id}", "data": []}), 400 history = client.get_telemetry_history(node_num, hours=hours) - return jsonify({ - 'status': 'ok', - 'node_id': node_id, - 'hours': hours, - 'data': [p.to_dict() for p in history], - 'count': len(history) - }) + return jsonify( + { + "status": "ok", + "node_id": node_id, + "hours": hours, + "data": [p.to_dict() for p in history], + "count": len(history), + } + ) -@meshtastic_bp.route('/neighbors') +@meshtastic_bp.route("/neighbors") def get_neighbors(): """ Get neighbor information for mesh topology visualization. @@ -817,27 +685,19 @@ def get_neighbors(): client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device', - 'neighbors': {} - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device", "neighbors": {}}), 400 - node_id = request.args.get('node_id') + node_id = request.args.get("node_id") node_num = None if node_id: try: - if node_id.startswith('!'): + if node_id.startswith("!"): node_num = int(node_id[1:], 16) else: node_num = int(node_id) except ValueError: - return jsonify({ - 'status': 'error', - 'message': f'Invalid node_id: {node_id}', - 'neighbors': {} - }), 400 + return jsonify({"status": "error", "message": f"Invalid node_id: {node_id}", "neighbors": {}}), 400 neighbors = client.get_neighbors(node_num) @@ -847,14 +707,10 @@ def get_neighbors(): node_key = f"!{num:08x}" result[node_key] = [n.to_dict() for n in neighbor_list] - return jsonify({ - 'status': 'ok', - 'neighbors': result, - 'node_count': len(result) - }) + return jsonify({"status": "ok", "neighbors": result, "node_count": len(result)}) -@meshtastic_bp.route('/pending') +@meshtastic_bp.route("/pending") def get_pending_messages(): """ Get messages waiting for ACK. @@ -865,22 +721,14 @@ def get_pending_messages(): client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device', - 'messages': [] - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device", "messages": []}), 400 pending = client.get_pending_messages() - return jsonify({ - 'status': 'ok', - 'messages': [m.to_dict() for m in pending.values()], - 'count': len(pending) - }) + return jsonify({"status": "ok", "messages": [m.to_dict() for m in pending.values()], "count": len(pending)}) -@meshtastic_bp.route('/range-test/start', methods=['POST']) +@meshtastic_bp.route("/range-test/start", methods=["POST"]) def start_range_test(): """ Start a range test. @@ -895,22 +743,16 @@ def start_range_test(): JSON with start status. """ if not is_meshtastic_available(): - return jsonify({ - 'status': 'error', - 'message': 'Meshtastic SDK not installed' - }), 400 + return jsonify({"status": "error", "message": "Meshtastic SDK not installed"}), 400 client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device' - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device"}), 400 data = request.get_json(silent=True) or {} - count = data.get('count', 10) - interval = data.get('interval', 5) + count = data.get("count", 10) + interval = data.get("interval", 5) # Validate if not isinstance(count, int) or count < 1 or count > 100: @@ -921,19 +763,12 @@ def start_range_test(): success, error = client.start_range_test(count=count, interval=interval) if success: - return jsonify({ - 'status': 'started', - 'count': count, - 'interval': interval - }) + return jsonify({"status": "started", "count": count, "interval": interval}) else: - return jsonify({ - 'status': 'error', - 'message': error or 'Failed to start range test' - }), 500 + return jsonify({"status": "error", "message": error or "Failed to start range test"}), 500 -@meshtastic_bp.route('/range-test/stop', methods=['POST']) +@meshtastic_bp.route("/range-test/stop", methods=["POST"]) def stop_range_test(): """ Stop an ongoing range test. @@ -946,10 +781,10 @@ def stop_range_test(): if client: client.stop_range_test() - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@meshtastic_bp.route('/range-test/status') +@meshtastic_bp.route("/range-test/status") def get_range_test_status(): """ Get range test status and results. @@ -960,21 +795,15 @@ def get_range_test_status(): client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device', - 'running': False, - 'results': [] - }), 400 + return jsonify( + {"status": "error", "message": "Not connected to Meshtastic device", "running": False, "results": []} + ), 400 status = client.get_range_test_status() - return jsonify({ - 'status': 'ok', - **status - }) + return jsonify({"status": "ok", **status}) -@meshtastic_bp.route('/store-forward/status') +@meshtastic_bp.route("/store-forward/status") def get_store_forward_status(): """ Check if Store & Forward router is available. @@ -985,20 +814,13 @@ def get_store_forward_status(): client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device', - 'available': False - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device", "available": False}), 400 sf_status = client.check_store_forward_available() - return jsonify({ - 'status': 'ok', - **sf_status - }) + return jsonify({"status": "ok", **sf_status}) -@meshtastic_bp.route('/store-forward/request', methods=['POST']) +@meshtastic_bp.route("/store-forward/request", methods=["POST"]) def request_store_forward(): """ Request missed messages from Store & Forward router. @@ -1012,21 +834,15 @@ def request_store_forward(): JSON with request status. """ if not is_meshtastic_available(): - return jsonify({ - 'status': 'error', - 'message': 'Meshtastic SDK not installed' - }), 400 + return jsonify({"status": "error", "message": "Meshtastic SDK not installed"}), 400 client = get_meshtastic_client() if not client or not client.is_running: - return jsonify({ - 'status': 'error', - 'message': 'Not connected to Meshtastic device' - }), 400 + return jsonify({"status": "error", "message": "Not connected to Meshtastic device"}), 400 data = request.get_json(silent=True) or {} - window_minutes = data.get('window_minutes', 60) + window_minutes = data.get("window_minutes", 60) if not isinstance(window_minutes, int) or window_minutes < 1 or window_minutes > 1440: window_minutes = 60 @@ -1034,28 +850,24 @@ def request_store_forward(): success, error = client.request_store_forward(window_minutes=window_minutes) if success: - return jsonify({ - 'status': 'sent', - 'window_minutes': window_minutes - }) + return jsonify({"status": "sent", "window_minutes": window_minutes}) else: - return jsonify({ - 'status': 'error', - 'message': error or 'Failed to request S&F history' - }), 500 + return jsonify({"status": "error", "message": error or "Failed to request S&F history"}), 500 -@meshtastic_bp.route('/topology') +@meshtastic_bp.route("/topology") def mesh_topology(): """Return mesh network topology graph.""" if not is_meshtastic_available(): - return api_error('Meshtastic SDK not installed', 400) + return api_error("Meshtastic SDK not installed", 400) client = get_meshtastic_client() if not client or not client.is_running: - return api_error('Not connected', 400) + return api_error("Not connected", 400) - return jsonify({ - 'status': 'success', - 'topology': client.get_topology(), - }) + return jsonify( + { + "status": "success", + "topology": client.get_topology(), + } + ) diff --git a/routes/meteor_websocket.py b/routes/meteor_websocket.py index 3a0c560..72c6fa0 100644 --- a/routes/meteor_websocket.py +++ b/routes/meteor_websocket.py @@ -24,6 +24,7 @@ from utils.responses import api_error try: from flask_sock import Sock + WEBSOCKET_AVAILABLE = True except ImportError: WEBSOCKET_AVAILABLE = False @@ -43,15 +44,15 @@ from utils.waterfall_fft import ( quantize_to_uint8, ) -logger = get_logger('intercept.meteor') +logger = get_logger("intercept.meteor") # Module-level shared state _state_lock = threading.Lock() _state: dict[str, Any] = { - 'running': False, - 'device': None, - 'frequency_mhz': 0.0, - 'sample_rate': 0, + "running": False, + "device": None, + "frequency_mhz": 0.0, + "sample_rate": 0, } _detector: MeteorDetector | None = None _sse_queue: queue.Queue = queue.Queue(maxsize=500) @@ -80,12 +81,12 @@ def _push_sse(data: dict[str, Any]) -> None: def _resolve_sdr_type(sdr_type_str: str) -> SDRType: mapping = { - 'rtlsdr': SDRType.RTL_SDR, - 'rtl_sdr': SDRType.RTL_SDR, - 'hackrf': SDRType.HACKRF, - 'limesdr': SDRType.LIME_SDR, - 'airspy': SDRType.AIRSPY, - 'sdrplay': SDRType.SDRPLAY, + "rtlsdr": SDRType.RTL_SDR, + "rtl_sdr": SDRType.RTL_SDR, + "hackrf": SDRType.HACKRF, + "limesdr": SDRType.LIME_SDR, + "airspy": SDRType.AIRSPY, + "sdrplay": SDRType.SDRPLAY, } return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR) @@ -96,8 +97,8 @@ def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice: return SDRDevice( sdr_type=sdr_type, index=device_index, - name=f'{sdr_type.value}-{device_index}', - serial='N/A', + name=f"{sdr_type.value}-{device_index}", + serial="N/A", driver=sdr_type.value, capabilities=caps, ) @@ -113,96 +114,99 @@ def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> # ── Blueprint for REST/SSE endpoints ── -meteor_bp = Blueprint('meteor', __name__, url_prefix='/meteor') +meteor_bp = Blueprint("meteor", __name__, url_prefix="/meteor") -@meteor_bp.route('/status') +@meteor_bp.route("/status") def meteor_status(): """Return current meteor monitoring status.""" with _state_lock: - running = _state['running'] - freq = _state['frequency_mhz'] - device = _state['device'] - sr = _state['sample_rate'] + running = _state["running"] + freq = _state["frequency_mhz"] + device = _state["device"] + sr = _state["sample_rate"] detector = _detector stats = None if detector: stats = detector._build_stats(time.time()) - return jsonify({ - 'running': running, - 'frequency_mhz': freq, - 'device': device, - 'sample_rate': sr, - 'stats': stats, - }) + return jsonify( + { + "running": running, + "frequency_mhz": freq, + "device": device, + "sample_rate": sr, + "stats": stats, + } + ) -@meteor_bp.route('/stream') +@meteor_bp.route("/stream") def meteor_stream(): """SSE endpoint for meteor detection events and stats.""" response = Response( sse_stream_fanout( source_queue=_sse_queue, - channel_key='meteor', + channel_key="meteor", timeout=1.0, keepalive_interval=30.0, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response -@meteor_bp.route('/events') +@meteor_bp.route("/events") def meteor_events(): """Return detected events as JSON.""" detector = _detector if not detector: - return jsonify({'events': []}) - limit = request.args.get('limit', 500, type=int) - return jsonify({'events': detector.get_events(limit=limit)}) + return jsonify({"events": []}) + limit = request.args.get("limit", 500, type=int) + return jsonify({"events": detector.get_events(limit=limit)}) -@meteor_bp.route('/events/export') +@meteor_bp.route("/events/export") def meteor_events_export(): """Export events as CSV or JSON.""" detector = _detector if not detector: - return api_error('No active session', 400) + return api_error("No active session", 400) - fmt = request.args.get('format', 'json').lower() - if fmt == 'csv': + fmt = request.args.get("format", "json").lower() + if fmt == "csv": csv_data = detector.export_events_csv() return Response( csv_data, - mimetype='text/csv', - headers={'Content-Disposition': 'attachment; filename=meteor_events.csv'}, + mimetype="text/csv", + headers={"Content-Disposition": "attachment; filename=meteor_events.csv"}, ) else: json_data = detector.export_events_json() return Response( json_data, - mimetype='application/json', - headers={'Content-Disposition': 'attachment; filename=meteor_events.json'}, + mimetype="application/json", + headers={"Content-Disposition": "attachment; filename=meteor_events.json"}, ) -@meteor_bp.route('/events/clear', methods=['POST']) +@meteor_bp.route("/events/clear", methods=["POST"]) def meteor_events_clear(): """Clear all detected events.""" detector = _detector if not detector: - return jsonify({'cleared': 0}) + return jsonify({"cleared": 0}) count = detector.clear_events() - return jsonify({'cleared': count}) + return jsonify({"cleared": count}) # ── WebSocket handler ── + def init_meteor_websocket(app: Flask): """Initialize WebSocket meteor scatter streaming.""" global _detector @@ -213,7 +217,7 @@ def init_meteor_websocket(app: Flask): sock = Sock(app) - @sock.route('/ws/meteor') + @sock.route("/ws/meteor") def meteor_stream_ws(ws): """WebSocket endpoint for meteor scatter waterfall + detection.""" global _detector @@ -225,7 +229,7 @@ def init_meteor_websocket(app: Flask): reader_thread = None stop_event = threading.Event() claimed_device = None - claimed_sdr_type = 'rtlsdr' + claimed_sdr_type = "rtlsdr" send_queue: queue.Queue = queue.Queue(maxsize=120) try: @@ -264,9 +268,9 @@ def init_meteor_websocket(app: Flask): except (json.JSONDecodeError, TypeError): continue - cmd = data.get('cmd') + cmd = data.get("cmd") - if cmd == 'start': + if cmd == "start": # Stop any existing capture was_restarting = iq_process is not None stop_event.set() @@ -280,7 +284,7 @@ def init_meteor_websocket(app: Flask): app_module.release_sdr_device(claimed_device, claimed_sdr_type) claimed_device = None with _state_lock: - _state['running'] = False + _state["running"] = False stop_event.clear() while not send_queue.empty(): try: @@ -292,34 +296,38 @@ def init_meteor_websocket(app: Flask): # Parse config try: - frequency_mhz = float(data.get('frequency_mhz', 143.05)) + frequency_mhz = float(data.get("frequency_mhz", 143.05)) validate_frequency(frequency_mhz) - gain_raw = data.get('gain') - if gain_raw is None or str(gain_raw).lower() == 'auto': + gain_raw = data.get("gain") + if gain_raw is None or str(gain_raw).lower() == "auto": gain = None else: gain = validate_gain(float(gain_raw)) - device_index = validate_device_index(int(data.get('device', 0))) - sdr_type_str = data.get('sdr_type', 'rtlsdr') - sample_rate_req = int(data.get('sample_rate', 250000)) - fft_size = int(data.get('fft_size', 1024)) - fps = int(data.get('fps', 20)) - avg_count = int(data.get('avg_count', 4)) - ppm = data.get('ppm') + device_index = validate_device_index(int(data.get("device", 0))) + sdr_type_str = data.get("sdr_type", "rtlsdr") + sample_rate_req = int(data.get("sample_rate", 250000)) + fft_size = int(data.get("fft_size", 1024)) + fps = int(data.get("fps", 20)) + avg_count = int(data.get("avg_count", 4)) + ppm = data.get("ppm") if ppm is not None: ppm = int(ppm) - bias_t = bool(data.get('bias_t', False)) + bias_t = bool(data.get("bias_t", False)) # Detection settings - snr_threshold = float(data.get('snr_threshold', 6.0)) - min_duration = float(data.get('min_duration_ms', 50.0)) - cooldown = float(data.get('cooldown_ms', 200.0)) - freq_drift = float(data.get('freq_drift_tolerance_hz', 500.0)) + snr_threshold = float(data.get("snr_threshold", 6.0)) + min_duration = float(data.get("min_duration_ms", 50.0)) + cooldown = float(data.get("cooldown_ms", 200.0)) + freq_drift = float(data.get("freq_drift_tolerance_hz", 500.0)) except (TypeError, ValueError) as exc: - ws.send(json.dumps({ - 'status': 'error', - 'message': f'Invalid configuration: {exc}', - })) + ws.send( + json.dumps( + { + "status": "error", + "message": f"Invalid configuration: {exc}", + } + ) + ) continue # Clamp values @@ -342,17 +350,21 @@ def init_meteor_websocket(app: Flask): max_claim_attempts = 4 if was_restarting else 1 claim_err = None for _attempt in range(max_claim_attempts): - claim_err = app_module.claim_sdr_device(device_index, 'meteor', sdr_type_str) + claim_err = app_module.claim_sdr_device(device_index, "meteor", sdr_type_str) if not claim_err: break if _attempt < max_claim_attempts - 1: time.sleep(0.4) if claim_err: - ws.send(json.dumps({ - 'status': 'error', - 'message': claim_err, - 'error_type': 'DEVICE_BUSY', - })) + ws.send( + json.dumps( + { + "status": "error", + "message": claim_err, + "error_type": "DEVICE_BUSY", + } + ) + ) continue claimed_device = device_index claimed_sdr_type = sdr_type_str @@ -371,17 +383,21 @@ def init_meteor_websocket(app: Flask): except NotImplementedError as e: app_module.release_sdr_device(device_index, sdr_type_str) claimed_device = None - ws.send(json.dumps({'status': 'error', 'message': str(e)})) + ws.send(json.dumps({"status": "error", "message": str(e)})) continue # Check binary exists if not shutil.which(iq_cmd[0]): app_module.release_sdr_device(device_index, sdr_type_str) claimed_device = None - ws.send(json.dumps({ - 'status': 'error', - 'message': f'Required tool "{iq_cmd[0]}" not found.', - })) + ws.send( + json.dumps( + { + "status": "error", + "message": f'Required tool "{iq_cmd[0]}" not found.', + } + ) + ) continue # Spawn I/Q capture @@ -402,10 +418,10 @@ def init_meteor_websocket(app: Flask): time.sleep(0.3) if iq_process.poll() is not None: - stderr_out = '' + stderr_out = "" if iq_process.stderr: with suppress(Exception): - stderr_out = iq_process.stderr.read().decode('utf-8', errors='replace').strip() + stderr_out = iq_process.stderr.read().decode("utf-8", errors="replace").strip() unregister_process(iq_process) iq_process = None if attempt < max_attempts - 1: @@ -422,10 +438,14 @@ def init_meteor_websocket(app: Flask): iq_process = None app_module.release_sdr_device(device_index, sdr_type_str) claimed_device = None - ws.send(json.dumps({ - 'status': 'error', - 'message': f'Failed to start I/Q capture: {e}', - })) + ws.send( + json.dumps( + { + "status": "error", + "message": f"Failed to start I/Q capture: {e}", + } + ) + ) continue # Initialize detector @@ -437,27 +457,39 @@ def init_meteor_websocket(app: Flask): ) with _state_lock: - _state['running'] = True - _state['device'] = device_index - _state['frequency_mhz'] = frequency_mhz - _state['sample_rate'] = sample_rate + _state["running"] = True + _state["device"] = device_index + _state["frequency_mhz"] = frequency_mhz + _state["sample_rate"] = sample_rate # Send confirmation - ws.send(json.dumps({ - 'status': 'started', - 'frequency_mhz': frequency_mhz, - 'start_freq': start_freq, - 'end_freq': end_freq, - 'fft_size': fft_size, - 'sample_rate': sample_rate, - 'span_mhz': span_mhz, - })) + ws.send( + json.dumps( + { + "status": "started", + "frequency_mhz": frequency_mhz, + "start_freq": start_freq, + "end_freq": end_freq, + "fft_size": fft_size, + "sample_rate": sample_rate, + "span_mhz": span_mhz, + } + ) + ) # Start FFT reader + detection thread def fft_reader( - proc, _send_q, stop_evt, detector, - _fft_size, _avg_count, _fps, _sample_rate, - _start_freq, _end_freq, _freq_mhz, + proc, + _send_q, + stop_evt, + detector, + _fft_size, + _avg_count, + _fps, + _sample_rate, + _start_freq, + _end_freq, + _freq_mhz, ): required_fft_samples = _fft_size * _avg_count timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps))) @@ -475,7 +507,7 @@ def init_meteor_websocket(app: Flask): frame_start = time.monotonic() # Read raw I/Q - raw = b'' + raw = b"" remaining = bytes_per_frame while remaining > 0 and not stop_evt.is_set(): chunk = proc.stdout.read(min(remaining, 65536)) @@ -489,7 +521,9 @@ def init_meteor_websocket(app: Flask): # FFT pipeline samples = cu8_to_complex(raw) - fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples + fft_samples = ( + samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples + ) power_db = compute_power_spectrum( fft_samples, fft_size=_fft_size, @@ -505,20 +539,27 @@ def init_meteor_websocket(app: Flask): # Run detection on raw dB spectrum now = time.time() stats, event = detector.process_frame( - power_db, start_freq_hz, end_freq_hz, now, + power_db, + start_freq_hz, + end_freq_hz, + now, ) # Push event immediately via SSE if event: - _push_sse({ - 'type': 'event', - 'event': event.to_dict(), - }) + _push_sse( + { + "type": "event", + "event": event.to_dict(), + } + ) # Also send as JSON via WS for immediate UI update - event_msg = json.dumps({ - 'type': 'detection', - 'event': event.to_dict(), - }) + event_msg = json.dumps( + { + "type": "detection", + "event": event.to_dict(), + } + ) with suppress(queue.Full): _send_q.put_nowait(event_msg) @@ -539,26 +580,34 @@ def init_meteor_websocket(app: Flask): reader_thread = threading.Thread( target=fft_reader, args=( - iq_process, send_queue, stop_event, _detector, - fft_size, avg_count, fps, sample_rate, - start_freq, end_freq, frequency_mhz, + iq_process, + send_queue, + stop_event, + _detector, + fft_size, + avg_count, + fps, + sample_rate, + start_freq, + end_freq, + frequency_mhz, ), daemon=True, ) reader_thread.start() - elif cmd == 'update_threshold': + elif cmd == "update_threshold": detector = _detector if detector: detector.update_settings( - snr_threshold_db=data.get('snr_threshold'), - min_duration_ms=data.get('min_duration_ms'), - cooldown_ms=data.get('cooldown_ms'), - freq_drift_tolerance_hz=data.get('freq_drift_tolerance_hz'), + snr_threshold_db=data.get("snr_threshold"), + min_duration_ms=data.get("min_duration_ms"), + cooldown_ms=data.get("cooldown_ms"), + freq_drift_tolerance_hz=data.get("freq_drift_tolerance_hz"), ) - ws.send(json.dumps({'status': 'threshold_updated'})) + ws.send(json.dumps({"status": "threshold_updated"})) - elif cmd == 'stop': + elif cmd == "stop": stop_event.set() if reader_thread and reader_thread.is_alive(): reader_thread.join(timeout=2) @@ -571,10 +620,10 @@ def init_meteor_websocket(app: Flask): app_module.release_sdr_device(claimed_device, claimed_sdr_type) claimed_device = None with _state_lock: - _state['running'] = False - _state['device'] = None + _state["running"] = False + _state["device"] = None stop_event.clear() - ws.send(json.dumps({'status': 'stopped'})) + ws.send(json.dumps({"status": "stopped"})) except Exception as e: logger.info(f"WebSocket meteor closed: {e}") @@ -588,8 +637,8 @@ def init_meteor_websocket(app: Flask): if claimed_device is not None: app_module.release_sdr_device(claimed_device, claimed_sdr_type) with _state_lock: - _state['running'] = False - _state['device'] = None + _state["running"] = False + _state["device"] = None with suppress(Exception): ws.close() with suppress(Exception): diff --git a/routes/morse.py b/routes/morse.py index c5b3a4f..39bd488 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -33,7 +33,7 @@ from utils.validation import ( validate_rtl_tcp_port, ) -morse_bp = Blueprint('morse', __name__) +morse_bp = Blueprint("morse", __name__) class _FilteredQueue: @@ -43,30 +43,31 @@ class _FilteredQueue: self._inner = inner def put_nowait(self, item: Any) -> None: - if isinstance(item, dict) and item.get('type') == 'status' and item.get('status') == 'stopped': + if isinstance(item, dict) and item.get("type") == "status" and item.get("status") == "stopped": return self._inner.put_nowait(item) def put(self, item: Any, **kwargs: Any) -> None: - if isinstance(item, dict) and item.get('type') == 'status' and item.get('status') == 'stopped': + if isinstance(item, dict) and item.get("type") == "status" and item.get("status") == "stopped": return self._inner.put(item, **kwargs) + # Track which device is being used morse_active_device: int | None = None morse_active_sdr_type: str | None = None # Runtime lifecycle state. -MORSE_IDLE = 'idle' -MORSE_STARTING = 'starting' -MORSE_RUNNING = 'running' -MORSE_STOPPING = 'stopping' -MORSE_ERROR = 'error' +MORSE_IDLE = "idle" +MORSE_STARTING = "starting" +MORSE_RUNNING = "running" +MORSE_STOPPING = "stopping" +MORSE_ERROR = "error" morse_state = MORSE_IDLE -morse_state_message = 'Idle' +morse_state_message = "Idle" morse_state_since = time.monotonic() -morse_last_error = '' +morse_last_error = "" morse_runtime_config: dict[str, Any] = {} morse_session_id = 0 @@ -75,7 +76,8 @@ morse_stderr_worker: threading.Thread | None = None morse_stop_event: threading.Event | None = None morse_control_queue: queue.Queue | None = None -def _set_state(state: str, message: str = '', *, enqueue: bool = True, extra: dict[str, Any] | None = None) -> None: + +def _set_state(state: str, message: str = "", *, enqueue: bool = True, extra: dict[str, Any] | None = None) -> None: """Update lifecycle state and optionally emit a status queue event.""" global morse_state, morse_state_message, morse_state_since morse_state = state @@ -86,12 +88,12 @@ def _set_state(state: str, message: str = '', *, enqueue: bool = True, extra: di return payload: dict[str, Any] = { - 'type': 'status', - 'status': state, - 'state': state, - 'message': morse_state_message, - 'session_id': morse_session_id, - 'timestamp': time.strftime('%H:%M:%S'), + "type": "status", + "status": state, + "state": state, + "message": morse_state_message, + "session_id": morse_session_id, + "timestamp": time.strftime("%H:%M:%S"), } if extra: payload.update(extra) @@ -132,9 +134,9 @@ def _bool_value(value: Any, default: bool = False) -> bool: if value is None: return default text = str(value).strip().lower() - if text in {'1', 'true', 'yes', 'on'}: + if text in {"1", "true", "yes", "on"}: return True - if text in {'0', 'false', 'no', 'off'}: + if text in {"0", "false", "no", "off"}: return False return default @@ -151,10 +153,10 @@ def _validate_tone_freq(value: Any) -> float: try: freq = float(value) if not 300 <= freq <= 1200: - raise ValueError('Tone frequency must be between 300 and 1200 Hz') + raise ValueError("Tone frequency must be between 300 and 1200 Hz") return freq except (ValueError, TypeError) as e: - raise ValueError(f'Invalid tone frequency: {value}') from e + raise ValueError(f"Invalid tone frequency: {value}") from e def _validate_wpm(value: Any) -> int: @@ -162,33 +164,33 @@ def _validate_wpm(value: Any) -> int: try: wpm = int(value) if not 5 <= wpm <= 50: - raise ValueError('WPM must be between 5 and 50') + raise ValueError("WPM must be between 5 and 50") return wpm except (ValueError, TypeError) as e: - raise ValueError(f'Invalid WPM: {value}') from e + raise ValueError(f"Invalid WPM: {value}") from e def _validate_bandwidth(value: Any) -> int: try: bw = int(value) if bw not in (50, 100, 200, 400): - raise ValueError('Bandwidth must be one of 50, 100, 200, 400 Hz') + raise ValueError("Bandwidth must be one of 50, 100, 200, 400 Hz") return bw except (TypeError, ValueError) as e: - raise ValueError(f'Invalid bandwidth: {value}') from e + raise ValueError(f"Invalid bandwidth: {value}") from e def _validate_threshold_mode(value: Any) -> str: - mode = str(value or 'auto').strip().lower() - if mode not in {'auto', 'manual'}: - raise ValueError('threshold_mode must be auto or manual') + mode = str(value or "auto").strip().lower() + if mode not in {"auto", "manual"}: + raise ValueError("threshold_mode must be auto or manual") return mode def _validate_wpm_mode(value: Any) -> str: - mode = str(value or 'auto').strip().lower() - if mode not in {'auto', 'manual'}: - raise ValueError('wpm_mode must be auto or manual') + mode = str(value or "auto").strip().lower() + if mode not in {"auto", "manual"}: + raise ValueError("wpm_mode must be auto or manual") return mode @@ -196,36 +198,36 @@ def _validate_threshold_multiplier(value: Any) -> float: try: multiplier = float(value) if not 1.1 <= multiplier <= 8.0: - raise ValueError('threshold_multiplier must be between 1.1 and 8.0') + raise ValueError("threshold_multiplier must be between 1.1 and 8.0") return multiplier except (TypeError, ValueError) as e: - raise ValueError(f'Invalid threshold multiplier: {value}') from e + raise ValueError(f"Invalid threshold multiplier: {value}") from e def _validate_non_negative_float(value: Any, field_name: str) -> float: try: parsed = float(value) if parsed < 0: - raise ValueError(f'{field_name} must be non-negative') + raise ValueError(f"{field_name} must be non-negative") return parsed except (TypeError, ValueError) as e: - raise ValueError(f'Invalid {field_name}: {value}') from e + raise ValueError(f"Invalid {field_name}: {value}") from e def _validate_signal_gate(value: Any) -> float: try: gate = float(value) if not 0.0 <= gate <= 1.0: - raise ValueError('signal_gate must be between 0.0 and 1.0') + raise ValueError("signal_gate must be between 0.0 and 1.0") return gate except (TypeError, ValueError) as e: - raise ValueError(f'Invalid signal gate: {value}') from e + raise ValueError(f"Invalid signal gate: {value}") from e def _validate_detect_mode(value: Any) -> str: """Validate detection mode ('goertzel' or 'envelope').""" - mode = str(value or 'goertzel').lower().strip() - if mode not in ('goertzel', 'envelope'): + mode = str(value or "goertzel").lower().strip() + if mode not in ("goertzel", "envelope"): raise ValueError("detect_mode must be 'goertzel' or 'envelope'") return mode @@ -233,15 +235,15 @@ def _validate_detect_mode(value: Any) -> str: def _snapshot_live_resources() -> list[str]: alive: list[str] = [] if morse_decoder_worker and morse_decoder_worker.is_alive(): - alive.append('decoder_thread') + alive.append("decoder_thread") if morse_stderr_worker and morse_stderr_worker.is_alive(): - alive.append('stderr_thread') + alive.append("stderr_thread") if app_module.morse_process and app_module.morse_process.poll() is None: - alive.append('rtl_process') + alive.append("rtl_process") return alive -@morse_bp.route('/morse/start', methods=['POST']) +@morse_bp.route("/morse/start", methods=["POST"]) def start_morse() -> Response: global morse_active_device, morse_active_sdr_type, morse_decoder_worker, morse_stderr_worker global morse_stop_event, morse_control_queue, morse_runtime_config @@ -251,78 +253,82 @@ def start_morse() -> Response: # Validate detect_mode first — it determines frequency limits. try: - detect_mode = _validate_detect_mode(data.get('detect_mode', 'goertzel')) + detect_mode = _validate_detect_mode(data.get("detect_mode", "goertzel")) except ValueError as e: return api_error(str(e), 400) - freq_max = 1766.0 if detect_mode == 'envelope' else 30.0 + freq_max = 1766.0 if detect_mode == "envelope" else 30.0 try: - freq = validate_frequency(data.get('frequency', '14.060'), min_mhz=0.5, max_mhz=freq_max) - gain = validate_gain(data.get('gain', '0')) - ppm = validate_ppm(data.get('ppm', '0')) - device = validate_device_index(data.get('device', '0')) + freq = validate_frequency(data.get("frequency", "14.060"), min_mhz=0.5, max_mhz=freq_max) + gain = validate_gain(data.get("gain", "0")) + ppm = validate_ppm(data.get("ppm", "0")) + device = validate_device_index(data.get("device", "0")) except ValueError as e: return api_error(str(e), 400) try: - tone_freq = _validate_tone_freq(data.get('tone_freq', '700')) - wpm = _validate_wpm(data.get('wpm', '15')) - bandwidth_hz = _validate_bandwidth(data.get('bandwidth_hz', '200')) - threshold_mode = _validate_threshold_mode(data.get('threshold_mode', 'auto')) - wpm_mode = _validate_wpm_mode(data.get('wpm_mode', 'auto')) - threshold_multiplier = _validate_threshold_multiplier(data.get('threshold_multiplier', '2.8')) - manual_threshold = _validate_non_negative_float(data.get('manual_threshold', '0'), 'manual threshold') - threshold_offset = _validate_non_negative_float(data.get('threshold_offset', '0'), 'threshold offset') - min_signal_gate = _validate_signal_gate(data.get('signal_gate', '0')) - auto_tone_track = _bool_value(data.get('auto_tone_track', True), True) - tone_lock = _bool_value(data.get('tone_lock', False), False) - wpm_lock = _bool_value(data.get('wpm_lock', False), False) + tone_freq = _validate_tone_freq(data.get("tone_freq", "700")) + wpm = _validate_wpm(data.get("wpm", "15")) + bandwidth_hz = _validate_bandwidth(data.get("bandwidth_hz", "200")) + threshold_mode = _validate_threshold_mode(data.get("threshold_mode", "auto")) + wpm_mode = _validate_wpm_mode(data.get("wpm_mode", "auto")) + threshold_multiplier = _validate_threshold_multiplier(data.get("threshold_multiplier", "2.8")) + manual_threshold = _validate_non_negative_float(data.get("manual_threshold", "0"), "manual threshold") + threshold_offset = _validate_non_negative_float(data.get("threshold_offset", "0"), "threshold offset") + min_signal_gate = _validate_signal_gate(data.get("signal_gate", "0")) + auto_tone_track = _bool_value(data.get("auto_tone_track", True), True) + tone_lock = _bool_value(data.get("tone_lock", False), False) + wpm_lock = _bool_value(data.get("wpm_lock", False), False) except ValueError as e: return api_error(str(e), 400) - sdr_type_str = data.get('sdr_type', 'rtlsdr') + sdr_type_str = data.get("sdr_type", "rtlsdr") # Check for rtl_tcp (remote SDR) connection - rtl_tcp_host = data.get('rtl_tcp_host') - rtl_tcp_port = data.get('rtl_tcp_port', 1234) + rtl_tcp_host = data.get("rtl_tcp_host") + rtl_tcp_port = data.get("rtl_tcp_port", 1234) with app_module.morse_lock: if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}: - return jsonify({ - 'status': 'error', - 'message': f'Morse decoder is {morse_state}', - 'state': morse_state, - }), 409 + return jsonify( + { + "status": "error", + "message": f"Morse decoder is {morse_state}", + "state": morse_state, + } + ), 409 # Reserve SDR device (skip for remote rtl_tcp) if not rtl_tcp_host: device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str) + error = app_module.claim_sdr_device(device_int, "morse", sdr_type_str) if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error, - }), 409 + return jsonify( + { + "status": "error", + "error_type": "DEVICE_BUSY", + "message": error, + } + ), 409 morse_active_device = device_int morse_active_sdr_type = sdr_type_str - morse_last_error = '' + morse_last_error = "" morse_session_id += 1 _drain_queue(app_module.morse_queue) - _set_state(MORSE_STARTING, 'Starting decoder...') + _set_state(MORSE_STARTING, "Starting decoder...") # Envelope mode (OOK/AM): use AM demod, higher sample rate for better # envelope resolution. Goertzel mode (HF CW): use USB demod. - if detect_mode == 'envelope': + if detect_mode == "envelope": sample_rate = 48000 - modulation = 'am' + modulation = "am" else: sample_rate = 22050 - modulation = 'usb' + modulation = "usb" - bias_t = _bool_value(data.get('bias_t', False), False) + bias_t = _bool_value(data.get("bias_t", False), False) try: sdr_type = SDRType(sdr_type_str) @@ -352,8 +358,8 @@ def start_morse() -> Response: same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type] for d in same_type_devices: device_catalog[d.index] = { - 'name': str(d.name or f'SDR {d.index}'), - 'serial': str(d.serial or 'Unknown'), + "name": str(d.name or f"SDR {d.index}"), + "serial": str(d.serial or "Unknown"), } for d in sorted(same_type_devices, key=lambda dev: dev.index): if d.index not in candidate_device_indices: @@ -361,65 +367,67 @@ def start_morse() -> Response: def _device_label(device_index: int) -> str: meta = device_catalog.get(device_index, {}) - serial = str(meta.get('serial') or 'Unknown') - name = str(meta.get('name') or f'SDR {device_index}') - return f'device {device_index} ({name}, SN: {serial})' + serial = str(meta.get("serial") or "Unknown") + name = str(meta.get("name") or f"SDR {device_index}") + return f"device {device_index} ({name}, SN: {serial})" def _build_rtl_cmd(device_index: int, direct_sampling_mode: int | None) -> list[str]: # Envelope mode tunes directly to center freq (no tone offset). - if detect_mode == 'envelope': + if detect_mode == "envelope": tuned_frequency_mhz = max(0.5, float(freq)) else: tuned_frequency_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)) sdr_device = network_sdr_device or SDRFactory.create_default_device(sdr_type, index=device_index) fm_kwargs: dict[str, Any] = { - 'device': sdr_device, - 'frequency_mhz': tuned_frequency_mhz, - 'sample_rate': sample_rate, - 'gain': float(gain) if gain and gain != '0' else None, - 'ppm': int(ppm) if ppm and ppm != '0' else None, - 'modulation': modulation, - 'bias_t': bias_t, + "device": sdr_device, + "frequency_mhz": tuned_frequency_mhz, + "sample_rate": sample_rate, + "gain": float(gain) if gain and gain != "0" else None, + "ppm": int(ppm) if ppm and ppm != "0" else None, + "modulation": modulation, + "bias_t": bias_t, } if direct_sampling_mode in (1, 2): - fm_kwargs['direct_sampling'] = int(direct_sampling_mode) + fm_kwargs["direct_sampling"] = int(direct_sampling_mode) cmd = list(builder.build_fm_demod_command(**fm_kwargs)) - if cmd and cmd[-1] != '-': - cmd.append('-') + if cmd and cmd[-1] != "-": + cmd.append("-") return cmd can_try_direct_sampling = bool( sdr_type == SDRType.RTL_SDR - and detect_mode != 'envelope' # direct sampling is HF-only + and detect_mode != "envelope" # direct sampling is HF-only and float(freq) < 24.0 ) direct_sampling_attempts: list[int | None] = [2, 1, None] if can_try_direct_sampling else [None] runtime_config: dict[str, Any] = { - 'sample_rate': sample_rate, - 'detect_mode': detect_mode, - 'modulation': modulation, - 'rf_frequency_mhz': float(freq), - 'tuned_frequency_mhz': max(0.5, float(freq)) if detect_mode == 'envelope' else max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)), - 'tone_freq': tone_freq, - 'wpm': wpm, - 'bandwidth_hz': bandwidth_hz, - 'auto_tone_track': auto_tone_track, - 'tone_lock': tone_lock, - 'threshold_mode': threshold_mode, - 'manual_threshold': manual_threshold, - 'threshold_multiplier': threshold_multiplier, - 'threshold_offset': threshold_offset, - 'wpm_mode': wpm_mode, - 'wpm_lock': wpm_lock, - 'min_signal_gate': min_signal_gate, - 'source': 'rtl_fm', - 'requested_device': requested_device_index, - 'active_device': active_device_index, - 'device_serial': str(device_catalog.get(active_device_index, {}).get('serial') or 'Unknown'), - 'candidate_devices': list(candidate_device_indices), + "sample_rate": sample_rate, + "detect_mode": detect_mode, + "modulation": modulation, + "rf_frequency_mhz": float(freq), + "tuned_frequency_mhz": max(0.5, float(freq)) + if detect_mode == "envelope" + else max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)), + "tone_freq": tone_freq, + "wpm": wpm, + "bandwidth_hz": bandwidth_hz, + "auto_tone_track": auto_tone_track, + "tone_lock": tone_lock, + "threshold_mode": threshold_mode, + "manual_threshold": manual_threshold, + "threshold_multiplier": threshold_multiplier, + "threshold_offset": threshold_offset, + "wpm_mode": wpm_mode, + "wpm_lock": wpm_lock, + "min_signal_gate": min_signal_gate, + "source": "rtl_fm", + "requested_device": requested_device_index, + "active_device": active_device_index, + "device_serial": str(device_catalog.get(active_device_index, {}).get("serial") or "Unknown"), + "candidate_devices": list(candidate_device_indices), } active_rtl_process: subprocess.Popen[bytes] | None = None @@ -444,11 +452,11 @@ def start_morse() -> Response: stop_evt.set() if control_q is not None: with contextlib.suppress(queue.Full): - control_q.put_nowait({'cmd': 'shutdown'}) + control_q.put_nowait({"cmd": "shutdown"}) if rtl_proc is not None: - _close_pipe(getattr(rtl_proc, 'stdout', None)) - _close_pipe(getattr(rtl_proc, 'stderr', None)) + _close_pipe(getattr(rtl_proc, "stdout", None)) + _close_pipe(getattr(rtl_proc, "stderr", None)) if rtl_proc is not None: safe_terminate(rtl_proc, timeout=0.4) @@ -457,7 +465,7 @@ def start_morse() -> Response: _join_thread(decoder_worker, timeout_s=0.35) _join_thread(stderr_worker, timeout_s=0.35) - full_cmd = '' + full_cmd = "" attempt_errors: list[str] = [] try: @@ -465,34 +473,36 @@ def start_morse() -> Response: for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1): if candidate_device_index != active_device_index: prev_device = active_device_index - claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse', sdr_type_str) + claim_error = app_module.claim_sdr_device(candidate_device_index, "morse", sdr_type_str) if claim_error: - msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}' + msg = f"{_device_label(candidate_device_index)} unavailable: {claim_error}" attempt_errors.append(msg) - logger.warning('Morse startup device fallback skipped: %s', msg) - _queue_morse_event({'type': 'info', 'text': f'[morse] {msg}'}) + logger.warning("Morse startup device fallback skipped: %s", msg) + _queue_morse_event({"type": "info", "text": f"[morse] {msg}"}) continue if prev_device is not None: - app_module.release_sdr_device(prev_device, morse_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(prev_device, morse_active_sdr_type or "rtlsdr") active_device_index = candidate_device_index with app_module.morse_lock: morse_active_device = active_device_index - _queue_morse_event({ - 'type': 'info', - 'text': ( - f'[morse] switching to {_device_label(active_device_index)} ' - f'({device_pos}/{len(candidate_device_indices)})' - ), - }) + _queue_morse_event( + { + "type": "info", + "text": ( + f"[morse] switching to {_device_label(active_device_index)} " + f"({device_pos}/{len(candidate_device_indices)})" + ), + } + ) - runtime_config['active_device'] = active_device_index - runtime_config['device_serial'] = str( - device_catalog.get(active_device_index, {}).get('serial') or 'Unknown' + runtime_config["active_device"] = active_device_index + runtime_config["device_serial"] = str( + device_catalog.get(active_device_index, {}).get("serial") or "Unknown" ) - runtime_config.pop('startup_waiting', None) - runtime_config.pop('startup_warning', None) + runtime_config.pop("startup_waiting", None) + runtime_config.pop("startup_warning", None) for attempt_index, direct_sampling_mode in enumerate(direct_sampling_attempts, start=1): rtl_process = None @@ -502,21 +512,21 @@ def start_morse() -> Response: stderr_thread = None rtl_cmd = _build_rtl_cmd(active_device_index, direct_sampling_mode) - direct_mode_label = direct_sampling_mode if direct_sampling_mode is not None else 'none' - full_cmd = ' '.join(rtl_cmd) + direct_mode_label = direct_sampling_mode if direct_sampling_mode is not None else "none" + full_cmd = " ".join(rtl_cmd) logger.info( - 'Morse decoder attempt device=%s (%s/%s) rf=%.6f tuned=%.6f direct_mode=%s (%s/%s): %s', + "Morse decoder attempt device=%s (%s/%s) rf=%.6f tuned=%.6f direct_mode=%s (%s/%s): %s", active_device_index, device_pos, len(candidate_device_indices), float(freq), - float(runtime_config.get('tuned_frequency_mhz', freq)), + float(runtime_config.get("tuned_frequency_mhz", freq)), direct_mode_label, attempt_index, len(direct_sampling_attempts), full_cmd, ) - _queue_morse_event({'type': 'info', 'text': f'[cmd] {full_cmd}'}) + _queue_morse_event({"type": "info", "text": f"[cmd] {full_cmd}"}) rtl_process = subprocess.Popen( rtl_cmd, @@ -547,87 +557,85 @@ def start_morse() -> Response: break time.sleep(0.02) continue - err_text = line.decode('utf-8', errors='replace').strip() + err_text = line.decode("utf-8", errors="replace").strip() if not err_text: continue if len(capture_lines) >= 40: del capture_lines[:10] capture_lines.append(err_text) - _queue_morse_event({'type': 'info', 'text': f'[rtl_fm] {err_text}'}) + _queue_morse_event({"type": "info", "text": f"[rtl_fm] {err_text}"}) except (ValueError, OSError): return except Exception: return - stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr') + stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name="morse-stderr") stderr_thread.start() if rtl_process.stdout is None: - raise RuntimeError('rtl_fm stdout unavailable') + raise RuntimeError("rtl_fm stdout unavailable") decoder_thread = threading.Thread( target=morse_decoder_thread, kwargs={ - 'rtl_stdout': rtl_process.stdout, - 'output_queue': _FilteredQueue(app_module.morse_queue), - 'stop_event': stop_event, - 'sample_rate': sample_rate, - 'tone_freq': tone_freq, - 'wpm': wpm, - 'decoder_config': runtime_config, - 'control_queue': control_queue, - 'pcm_ready_event': pcm_ready_event, + "rtl_stdout": rtl_process.stdout, + "output_queue": _FilteredQueue(app_module.morse_queue), + "stop_event": stop_event, + "sample_rate": sample_rate, + "tone_freq": tone_freq, + "wpm": wpm, + "decoder_config": runtime_config, + "control_queue": control_queue, + "pcm_ready_event": pcm_ready_event, }, daemon=True, - name='morse-decoder', + name="morse-decoder", ) decoder_thread.start() startup_deadline = time.monotonic() + 4.0 startup_ok = False - startup_error = '' + startup_error = "" while time.monotonic() < startup_deadline: if pcm_ready_event.is_set(): startup_ok = True break if rtl_process.poll() is not None: - startup_error = f'rtl_fm exited during startup (code {rtl_process.returncode})' + startup_error = f"rtl_fm exited during startup (code {rtl_process.returncode})" break time.sleep(0.05) if not startup_ok: if not startup_error: - startup_error = 'No PCM samples received within startup timeout' + startup_error = "No PCM samples received within startup timeout" if stderr_lines: - startup_error = f'{startup_error}; stderr: {stderr_lines[-1]}' + startup_error = f"{startup_error}; stderr: {stderr_lines[-1]}" is_last_device = device_pos == len(candidate_device_indices) is_last_attempt = attempt_index == len(direct_sampling_attempts) - if ( - is_last_device - and is_last_attempt - and rtl_process.poll() is None - ): + if is_last_device and is_last_attempt and rtl_process.poll() is None: startup_ok = True - runtime_config['startup_waiting'] = True - runtime_config['startup_warning'] = startup_error + runtime_config["startup_waiting"] = True + runtime_config["startup_warning"] = startup_error logger.warning( - 'Morse startup continuing without PCM on %s: %s', + "Morse startup continuing without PCM on %s: %s", _device_label(active_device_index), startup_error, ) - _queue_morse_event({ - 'type': 'info', - 'text': '[morse] waiting for PCM stream...', - }) + _queue_morse_event( + { + "type": "info", + "text": "[morse] waiting for PCM stream...", + } + ) if startup_ok: - runtime_config['direct_sampling_mode'] = direct_sampling_mode - runtime_config['direct_sampling'] = ( + runtime_config["direct_sampling_mode"] = direct_sampling_mode + runtime_config["direct_sampling"] = ( int(direct_sampling_mode) if direct_sampling_mode is not None else 0 ) - runtime_config['command'] = full_cmd - runtime_config['active_device'] = active_device_index + runtime_config["command"] = full_cmd + runtime_config["active_device"] = active_device_index active_rtl_process = rtl_process active_stop_event = stop_event @@ -638,12 +646,12 @@ def start_morse() -> Response: break attempt_errors.append( - f'{_device_label(active_device_index)} ' - f'attempt {attempt_index}/{len(direct_sampling_attempts)} ' - f'(source=rtl_fm direct_mode={direct_mode_label}): {startup_error}' + f"{_device_label(active_device_index)} " + f"attempt {attempt_index}/{len(direct_sampling_attempts)} " + f"(source=rtl_fm direct_mode={direct_mode_label}): {startup_error}" ) - logger.warning('Morse startup attempt failed: %s', attempt_errors[-1]) - _queue_morse_event({'type': 'info', 'text': f'[morse] startup attempt failed: {startup_error}'}) + logger.warning("Morse startup attempt failed: %s", attempt_errors[-1]) + _queue_morse_event({"type": "info", "text": f"[morse] startup attempt failed: {startup_error}"}) _cleanup_attempt( rtl_process, @@ -663,17 +671,18 @@ def start_morse() -> Response: if device_pos < len(candidate_device_indices): next_device = candidate_device_indices[device_pos] - _queue_morse_event({ - 'type': 'status', - 'state': MORSE_STARTING, - 'status': MORSE_STARTING, - 'message': ( - f'No PCM on {_device_label(active_device_index)}. ' - f'Trying {_device_label(next_device)}...' - ), - 'session_id': morse_session_id, - 'timestamp': time.strftime('%H:%M:%S'), - }) + _queue_morse_event( + { + "type": "status", + "state": MORSE_STARTING, + "status": MORSE_STARTING, + "message": ( + f"No PCM on {_device_label(active_device_index)}. Trying {_device_label(next_device)}..." + ), + "session_id": morse_session_id, + "timestamp": time.strftime("%H:%M:%S"), + } + ) if ( active_rtl_process is None @@ -682,21 +691,18 @@ def start_morse() -> Response: or active_decoder_thread is None or active_stderr_thread is None ): - msg = ( - f'SDR capture started but no PCM stream was received from ' - f'{_device_label(active_device_index)}.' - ) + msg = f"SDR capture started but no PCM stream was received from {_device_label(active_device_index)}." if attempt_errors: - msg += ' ' + ' | '.join(attempt_errors) - logger.error('Morse startup failed: %s', msg) + msg += " " + " | ".join(attempt_errors) + logger.error("Morse startup failed: %s", msg) with app_module.morse_lock: if morse_active_device is not None: - app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or "rtlsdr") morse_active_device = None morse_active_sdr_type = None morse_last_error = msg _set_state(MORSE_ERROR, msg) - _set_state(MORSE_IDLE, 'Idle') + _set_state(MORSE_IDLE, "Idle") return api_error(msg, 500) with app_module.morse_lock: @@ -711,19 +717,21 @@ def start_morse() -> Response: morse_decoder_worker = active_decoder_thread morse_stderr_worker = active_stderr_thread morse_runtime_config = dict(runtime_config) - _set_state(MORSE_RUNNING, 'Listening') + _set_state(MORSE_RUNNING, "Listening") - return jsonify({ - 'status': 'started', - 'state': MORSE_RUNNING, - 'command': full_cmd, - 'detect_mode': detect_mode, - 'modulation': modulation, - 'tone_freq': tone_freq, - 'wpm': wpm, - 'config': runtime_config, - 'session_id': morse_session_id, - }) + return jsonify( + { + "status": "started", + "state": MORSE_RUNNING, + "command": full_cmd, + "detect_mode": detect_mode, + "modulation": modulation, + "tone_freq": tone_freq, + "wpm": wpm, + "config": runtime_config, + "session_id": morse_session_id, + } + ) except FileNotFoundError as e: _cleanup_attempt( @@ -735,12 +743,12 @@ def start_morse() -> Response: ) with app_module.morse_lock: if morse_active_device is not None: - app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or "rtlsdr") morse_active_device = None morse_active_sdr_type = None - morse_last_error = f'Tool not found: {e.filename}' + morse_last_error = f"Tool not found: {e.filename}" _set_state(MORSE_ERROR, morse_last_error) - _set_state(MORSE_IDLE, 'Idle') + _set_state(MORSE_IDLE, "Idle") return api_error(morse_last_error, 400) except Exception as e: @@ -753,16 +761,16 @@ def start_morse() -> Response: ) with app_module.morse_lock: if morse_active_device is not None: - app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or "rtlsdr") morse_active_device = None morse_active_sdr_type = None morse_last_error = str(e) _set_state(MORSE_ERROR, morse_last_error) - _set_state(MORSE_IDLE, 'Idle') + _set_state(MORSE_IDLE, "Idle") return api_error(str(e), 500) -@morse_bp.route('/morse/stop', methods=['POST']) +@morse_bp.route("/morse/stop", methods=["POST"]) def stop_morse() -> Response: global morse_active_device, morse_active_sdr_type, morse_decoder_worker, morse_stderr_worker global morse_stop_event, morse_control_queue @@ -771,26 +779,21 @@ def stop_morse() -> Response: with app_module.morse_lock: if morse_state == MORSE_STOPPING: - return jsonify({'status': 'stopping', 'state': MORSE_STOPPING}), 202 + return jsonify({"status": "stopping", "state": MORSE_STOPPING}), 202 rtl_proc = app_module.morse_process - stop_event = morse_stop_event or getattr(rtl_proc, '_stop_decoder', None) - decoder_thread = morse_decoder_worker or getattr(rtl_proc, '_decoder_thread', None) - stderr_thread = morse_stderr_worker or getattr(rtl_proc, '_stderr_thread', None) - control_queue = morse_control_queue or getattr(rtl_proc, '_control_queue', None) + stop_event = morse_stop_event or getattr(rtl_proc, "_stop_decoder", None) + decoder_thread = morse_decoder_worker or getattr(rtl_proc, "_decoder_thread", None) + stderr_thread = morse_stderr_worker or getattr(rtl_proc, "_stderr_thread", None) + control_queue = morse_control_queue or getattr(rtl_proc, "_control_queue", None) active_device = morse_active_device active_sdr_type = morse_active_sdr_type - if ( - not rtl_proc - and not stop_event - and not decoder_thread - and not stderr_thread - ): - _set_state(MORSE_IDLE, 'Idle', enqueue=False) - return jsonify({'status': 'not_running', 'state': MORSE_IDLE}) + if not rtl_proc and not stop_event and not decoder_thread and not stderr_thread: + _set_state(MORSE_IDLE, "Idle", enqueue=False) + return jsonify({"status": "not_running", "state": MORSE_IDLE}) - _set_state(MORSE_STOPPING, 'Stopping decoder...') + _set_state(MORSE_STOPPING, "Stopping decoder...") app_module.morse_process = None morse_stop_event = None @@ -802,138 +805,150 @@ def stop_morse() -> Response: def _mark(step: str) -> None: cleanup_steps.append(step) - logger.debug(f'[morse.stop] {step}') + logger.debug(f"[morse.stop] {step}") - _mark('enter stop') + _mark("enter stop") if stop_event is not None: stop_event.set() - _mark('stop_event set') + _mark("stop_event set") if control_queue is not None: with contextlib.suppress(queue.Full): - control_queue.put_nowait({'cmd': 'shutdown'}) - _mark('control_queue shutdown signal sent') + control_queue.put_nowait({"cmd": "shutdown"}) + _mark("control_queue shutdown signal sent") if rtl_proc is not None: - _close_pipe(getattr(rtl_proc, 'stdout', None)) - _close_pipe(getattr(rtl_proc, 'stderr', None)) - _mark('rtl_fm pipes closed') + _close_pipe(getattr(rtl_proc, "stdout", None)) + _close_pipe(getattr(rtl_proc, "stderr", None)) + _mark("rtl_fm pipes closed") if rtl_proc is not None: safe_terminate(rtl_proc, timeout=0.6) unregister_process(rtl_proc) - _mark('rtl_fm process terminated') + _mark("rtl_fm process terminated") decoder_joined = _join_thread(decoder_thread, timeout_s=0.45) stderr_joined = _join_thread(stderr_thread, timeout_s=0.45) - _mark(f'decoder thread joined={decoder_joined}') - _mark(f'stderr thread joined={stderr_joined}') + _mark(f"decoder thread joined={decoder_joined}") + _mark(f"stderr thread joined={stderr_joined}") if active_device is not None: - app_module.release_sdr_device(active_device, active_sdr_type or 'rtlsdr') - _mark(f'SDR device {active_device} released') + app_module.release_sdr_device(active_device, active_sdr_type or "rtlsdr") + _mark(f"SDR device {active_device} released") stop_ms = round((time.perf_counter() - stop_started) * 1000.0, 1) alive_after = [] if not decoder_joined: - alive_after.append('decoder_thread') + alive_after.append("decoder_thread") if not stderr_joined: - alive_after.append('stderr_thread') + alive_after.append("stderr_thread") if rtl_proc is not None and rtl_proc.poll() is None: - alive_after.append('rtl_process') + alive_after.append("rtl_process") with app_module.morse_lock: morse_active_device = None morse_active_sdr_type = None - _set_state(MORSE_IDLE, 'Stopped', extra={ - 'stop_ms': stop_ms, - 'cleanup_steps': cleanup_steps, - 'alive': alive_after, - }) + _set_state( + MORSE_IDLE, + "Stopped", + extra={ + "stop_ms": stop_ms, + "cleanup_steps": cleanup_steps, + "alive": alive_after, + }, + ) with contextlib.suppress(queue.Full): - app_module.morse_queue.put_nowait({ - 'type': 'status', - 'status': 'stopped', - 'state': MORSE_IDLE, - 'stop_ms': stop_ms, - 'cleanup_steps': cleanup_steps, - 'alive': alive_after, - 'timestamp': time.strftime('%H:%M:%S'), - }) + app_module.morse_queue.put_nowait( + { + "type": "status", + "status": "stopped", + "state": MORSE_IDLE, + "stop_ms": stop_ms, + "cleanup_steps": cleanup_steps, + "alive": alive_after, + "timestamp": time.strftime("%H:%M:%S"), + } + ) if stop_ms > 500.0 or alive_after: logger.warning( - '[morse.stop] slow/partial cleanup: stop_ms=%s alive=%s steps=%s', + "[morse.stop] slow/partial cleanup: stop_ms=%s alive=%s steps=%s", stop_ms, - ','.join(alive_after) if alive_after else 'none', - '; '.join(cleanup_steps), + ",".join(alive_after) if alive_after else "none", + "; ".join(cleanup_steps), ) else: - logger.info('[morse.stop] cleanup complete in %sms', stop_ms) + logger.info("[morse.stop] cleanup complete in %sms", stop_ms) - return jsonify({ - 'status': 'stopped', - 'state': MORSE_IDLE, - 'stop_ms': stop_ms, - 'alive': alive_after, - 'cleanup_steps': cleanup_steps, - }) + return jsonify( + { + "status": "stopped", + "state": MORSE_IDLE, + "stop_ms": stop_ms, + "alive": alive_after, + "cleanup_steps": cleanup_steps, + } + ) -@morse_bp.route('/morse/calibrate', methods=['POST']) +@morse_bp.route("/morse/calibrate", methods=["POST"]) def calibrate_morse() -> Response: """Reset decoder threshold/timing estimators without restarting the process.""" with app_module.morse_lock: if morse_state != MORSE_RUNNING or morse_control_queue is None: - return jsonify({ - 'status': 'not_running', - 'state': morse_state, - 'message': 'Morse decoder is not running', - }), 409 + return jsonify( + { + "status": "not_running", + "state": morse_state, + "message": "Morse decoder is not running", + } + ), 409 with contextlib.suppress(queue.Full): - morse_control_queue.put_nowait({'cmd': 'reset'}) + morse_control_queue.put_nowait({"cmd": "reset"}) with contextlib.suppress(queue.Full): - app_module.morse_queue.put_nowait({ - 'type': 'info', - 'text': '[morse] Calibration reset requested', - }) + app_module.morse_queue.put_nowait( + { + "type": "info", + "text": "[morse] Calibration reset requested", + } + ) - return jsonify({'status': 'ok', 'state': morse_state}) + return jsonify({"status": "ok", "state": morse_state}) -@morse_bp.route('/morse/decode-file', methods=['POST']) +@morse_bp.route("/morse/decode-file", methods=["POST"]) def decode_morse_file() -> Response: """Decode Morse from an uploaded WAV file.""" - if 'audio' not in request.files: - return api_error('No audio file provided', 400) + if "audio" not in request.files: + return api_error("No audio file provided", 400) - audio_file = request.files['audio'] + audio_file = request.files["audio"] if not audio_file.filename: - return api_error('No file selected', 400) + return api_error("No file selected", 400) # Parse optional tuning/decoder parameters from form fields. form = request.form or {} try: - tone_freq = _validate_tone_freq(form.get('tone_freq', '700')) - wpm = _validate_wpm(form.get('wpm', '15')) - bandwidth_hz = _validate_bandwidth(form.get('bandwidth_hz', '200')) - threshold_mode = _validate_threshold_mode(form.get('threshold_mode', 'auto')) - wpm_mode = _validate_wpm_mode(form.get('wpm_mode', 'auto')) - threshold_multiplier = _validate_threshold_multiplier(form.get('threshold_multiplier', '2.8')) - manual_threshold = _validate_non_negative_float(form.get('manual_threshold', '0'), 'manual threshold') - threshold_offset = _validate_non_negative_float(form.get('threshold_offset', '0'), 'threshold offset') - signal_gate = _validate_signal_gate(form.get('signal_gate', '0')) - auto_tone_track = _bool_value(form.get('auto_tone_track', 'true'), True) - tone_lock = _bool_value(form.get('tone_lock', 'false'), False) - wpm_lock = _bool_value(form.get('wpm_lock', 'false'), False) + tone_freq = _validate_tone_freq(form.get("tone_freq", "700")) + wpm = _validate_wpm(form.get("wpm", "15")) + bandwidth_hz = _validate_bandwidth(form.get("bandwidth_hz", "200")) + threshold_mode = _validate_threshold_mode(form.get("threshold_mode", "auto")) + wpm_mode = _validate_wpm_mode(form.get("wpm_mode", "auto")) + threshold_multiplier = _validate_threshold_multiplier(form.get("threshold_multiplier", "2.8")) + manual_threshold = _validate_non_negative_float(form.get("manual_threshold", "0"), "manual threshold") + threshold_offset = _validate_non_negative_float(form.get("threshold_offset", "0"), "threshold offset") + signal_gate = _validate_signal_gate(form.get("signal_gate", "0")) + auto_tone_track = _bool_value(form.get("auto_tone_track", "true"), True) + tone_lock = _bool_value(form.get("tone_lock", "false"), False) + wpm_lock = _bool_value(form.get("wpm_lock", "false"), False) except ValueError as e: return api_error(str(e), 400) - with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp: + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: audio_file.save(tmp.name) tmp_path = Path(tmp.name) @@ -955,27 +970,29 @@ def decode_morse_file() -> Response: min_signal_gate=signal_gate, ) - text = str(result.get('text', '')) - raw = str(result.get('raw', '')) - metrics = result.get('metrics', {}) + text = str(result.get("text", "")) + raw = str(result.get("raw", "")) + metrics = result.get("metrics", {}) - return jsonify({ - 'status': 'ok', - 'text': text, - 'raw': raw, - 'char_count': len(text.replace(' ', '')), - 'word_count': len([w for w in text.split(' ') if w]), - 'metrics': metrics, - }) + return jsonify( + { + "status": "ok", + "text": text, + "raw": raw, + "char_count": len(text.replace(" ", "")), + "word_count": len([w for w in text.split(" ") if w]), + "metrics": metrics, + } + ) except Exception as e: - logger.error(f'Morse decode-file error: {e}') + logger.error(f"Morse decode-file error: {e}") return api_error(str(e), 500) finally: with contextlib.suppress(Exception): tmp_path.unlink(missing_ok=True) -@morse_bp.route('/morse/status') +@morse_bp.route("/morse/status") def morse_status() -> Response: with app_module.morse_lock: running = ( @@ -984,34 +1001,36 @@ def morse_status() -> Response: and morse_state in {MORSE_RUNNING, MORSE_STARTING, MORSE_STOPPING} ) since_ms = round((time.monotonic() - morse_state_since) * 1000.0, 1) - return jsonify({ - 'running': running, - 'state': morse_state, - 'message': morse_state_message, - 'since_ms': since_ms, - 'session_id': morse_session_id, - 'config': morse_runtime_config, - 'alive': _snapshot_live_resources(), - 'error': morse_last_error, - }) + return jsonify( + { + "running": running, + "state": morse_state, + "message": morse_state_message, + "since_ms": since_ms, + "session_id": morse_session_id, + "config": morse_runtime_config, + "alive": _snapshot_live_resources(), + "error": morse_last_error, + } + ) -@morse_bp.route('/morse/stream') +@morse_bp.route("/morse/stream") def morse_stream() -> Response: def _on_msg(msg: dict[str, Any]) -> None: - process_event('morse', msg, msg.get('type')) + process_event("morse", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=app_module.morse_queue, - channel_key='morse', + channel_key="morse", timeout=1.0, keepalive_interval=30.0, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response diff --git a/routes/ook.py b/routes/ook.py index 2d05ea3..e10c742 100644 --- a/routes/ook.py +++ b/routes/ook.py @@ -35,7 +35,7 @@ from utils.validation import ( validate_rtl_tcp_port, ) -ook_bp = Blueprint('ook', __name__) +ook_bp = Blueprint("ook", __name__) # Track which device / SDR type is being used ook_active_device: int | None = None @@ -47,9 +47,9 @@ _ook_parser_thread: threading.Thread | None = None # Supported modulation schemes → rtl_433 flex decoder modulation string _MODULATION_MAP = { - 'pwm': 'OOK_PWM', - 'ppm': 'OOK_PPM', - 'manchester': 'OOK_MC_ZEROBIT', + "pwm": "OOK_PWM", + "ppm": "OOK_PPM", + "manchester": "OOK_MC_ZEROBIT", } @@ -60,7 +60,7 @@ def _validate_encoding(value: Any) -> str: return enc -@ook_bp.route('/ook/start', methods=['POST']) +@ook_bp.route("/ook/start", methods=["POST"]) def start_ook() -> Response: global ook_active_device, ook_active_sdr_type, _ook_stop_event, _ook_parser_thread @@ -70,55 +70,55 @@ def start_ook() -> Response: if app_module.ook_process.poll() is not None: cleanup_ook(emit_status=False) else: - return api_error('OOK decoder already running', 409) + return api_error("OOK decoder already running", 409) data = request.json or {} try: - freq = validate_frequency(data.get('frequency', '433.920')) - gain = validate_gain(data.get('gain', '0')) - ppm = validate_ppm(data.get('ppm', '0')) - device = validate_device_index(data.get('device', '0')) + freq = validate_frequency(data.get("frequency", "433.920")) + gain = validate_gain(data.get("gain", "0")) + ppm = validate_ppm(data.get("ppm", "0")) + device = validate_device_index(data.get("device", "0")) except ValueError as e: return api_error(str(e), 400) try: - encoding = _validate_encoding(data.get('encoding', 'pwm')) + encoding = _validate_encoding(data.get("encoding", "pwm")) except ValueError as e: return api_error(str(e), 400) # OOK flex decoder timing parameters (server-side range validation) try: - short_pulse = validate_positive_int(data.get('short_pulse', 300), 'short_pulse', max_val=100000) - long_pulse = validate_positive_int(data.get('long_pulse', 600), 'long_pulse', max_val=100000) - reset_limit = validate_positive_int(data.get('reset_limit', 8000), 'reset_limit', max_val=1000000) - gap_limit = validate_positive_int(data.get('gap_limit', 5000), 'gap_limit', max_val=1000000) - tolerance = validate_positive_int(data.get('tolerance', 150), 'tolerance', max_val=50000) - min_bits = validate_positive_int(data.get('min_bits', 8), 'min_bits', max_val=4096) + short_pulse = validate_positive_int(data.get("short_pulse", 300), "short_pulse", max_val=100000) + long_pulse = validate_positive_int(data.get("long_pulse", 600), "long_pulse", max_val=100000) + reset_limit = validate_positive_int(data.get("reset_limit", 8000), "reset_limit", max_val=1000000) + gap_limit = validate_positive_int(data.get("gap_limit", 5000), "gap_limit", max_val=1000000) + tolerance = validate_positive_int(data.get("tolerance", 150), "tolerance", max_val=50000) + min_bits = validate_positive_int(data.get("min_bits", 8), "min_bits", max_val=4096) except ValueError as e: - return api_error(f'Invalid timing parameter: {e}', 400) + return api_error(f"Invalid timing parameter: {e}", 400) if min_bits < 1: - return api_error('min_bits must be >= 1', 400) + return api_error("min_bits must be >= 1", 400) if short_pulse < 1 or long_pulse < 1: - return api_error('Pulse widths must be >= 1', 400) - deduplicate = bool(data.get('deduplicate', False)) + return api_error("Pulse widths must be >= 1", 400) + deduplicate = bool(data.get("deduplicate", False)) # Parse SDR type early — needed for device claim - sdr_type_str = data.get('sdr_type', 'rtlsdr') + sdr_type_str = data.get("sdr_type", "rtlsdr") try: sdr_type = SDRType(sdr_type_str) except ValueError: sdr_type = SDRType.RTL_SDR - sdr_type_str = 'rtlsdr' + sdr_type_str = "rtlsdr" - rtl_tcp_host = data.get('rtl_tcp_host') or None - rtl_tcp_port = data.get('rtl_tcp_port', 1234) + rtl_tcp_host = data.get("rtl_tcp_host") or None + rtl_tcp_port = data.get("rtl_tcp_port", 1234) if not rtl_tcp_host: device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'ook', sdr_type_str) + error = app_module.claim_sdr_device(device_int, "ook", sdr_type_str) if error: - return api_error(error, 409, error_type='DEVICE_BUSY') + return api_error(error, 409, error_type="DEVICE_BUSY") ook_active_device = device_int ook_active_sdr_type = sdr_type_str @@ -135,12 +135,12 @@ def start_ook() -> Response: except ValueError as e: return api_error(str(e), 400) sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) - logger.info(f'Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}') + logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") else: sdr_device = SDRFactory.create_default_device(sdr_type, index=device) builder = SDRFactory.get_builder(sdr_device.sdr_type) - bias_t = data.get('bias_t', False) + bias_t = data.get("bias_t", False) # Build base ISM command then replace protocol flags with flex decoder cmd = builder.build_ism_command( @@ -153,10 +153,10 @@ def start_ook() -> Response: modulation = _MODULATION_MAP[encoding] flex_spec = ( - f'n=ook,m={modulation},' - f's={short_pulse},l={long_pulse},' - f'r={reset_limit},g={gap_limit},' - f't={tolerance},bits>={min_bits}' + f"n=ook,m={modulation}," + f"s={short_pulse},l={long_pulse}," + f"r={reset_limit},g={gap_limit}," + f"t={tolerance},bits>={min_bits}" ) # Strip any existing -R flags from the base command @@ -166,15 +166,15 @@ def start_ook() -> Response: if skip_next: skip_next = False continue - if arg == '-R': + if arg == "-R": skip_next = True continue filtered_cmd.append(arg) - filtered_cmd.extend(['-M', 'level', '-R', '0', '-X', flex_spec]) + filtered_cmd.extend(["-M", "level", "-R", "0", "-X", flex_spec]) - full_cmd = ' '.join(filtered_cmd) - logger.info(f'OOK decoder running: {full_cmd}') + full_cmd = " ".join(filtered_cmd) + logger.info(f"OOK decoder running: {full_cmd}") try: rtl_process = subprocess.Popen( @@ -185,13 +185,13 @@ def start_ook() -> Response: ) register_process(rtl_process) - _stderr_noise = ('bitbuffer_add_bit', 'row count limit') + _stderr_noise = ("bitbuffer_add_bit", "row count limit") def monitor_stderr() -> None: for line in rtl_process.stderr: - err_text = line.decode('utf-8', errors='replace').strip() + err_text = line.decode("utf-8", errors="replace").strip() if err_text and not any(n in err_text for n in _stderr_noise): - logger.debug(f'[rtl_433/ook] {err_text}') + logger.debug(f"[rtl_433/ook] {err_text}") stderr_thread = threading.Thread(target=monitor_stderr) stderr_thread.daemon = True @@ -216,25 +216,27 @@ def start_ook() -> Response: _ook_parser_thread = parser_thread try: - app_module.ook_queue.put_nowait({'type': 'status', 'text': 'started'}) + app_module.ook_queue.put_nowait({"type": "status", "text": "started"}) except queue.Full: logger.warning("OOK 'started' status dropped — queue full") - return jsonify({ - 'status': 'started', - 'command': full_cmd, - 'encoding': encoding, - 'modulation': modulation, - 'flex_spec': flex_spec, - 'deduplicate': deduplicate, - }) + return jsonify( + { + "status": "started", + "command": full_cmd, + "encoding": encoding, + "modulation": modulation, + "flex_spec": flex_spec, + "deduplicate": deduplicate, + } + ) except FileNotFoundError as e: if ook_active_device is not None: - app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or "rtlsdr") ook_active_device = None ook_active_sdr_type = None - return api_error(f'Tool not found: {e.filename}', 400) + return api_error(f"Tool not found: {e.filename}", 400) except Exception as e: try: @@ -245,7 +247,7 @@ def start_ook() -> Response: rtl_process.kill() unregister_process(rtl_process) if ook_active_device is not None: - app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or "rtlsdr") ook_active_device = None ook_active_sdr_type = None return api_error(str(e), 500) @@ -275,8 +277,8 @@ def cleanup_ook(*, emit_status: bool = True) -> None: _ook_stop_event.set() # Close pipes so parser thread unblocks from readline() - _close_pipe(getattr(proc, 'stdout', None)) - _close_pipe(getattr(proc, 'stderr', None)) + _close_pipe(getattr(proc, "stdout", None)) + _close_pipe(getattr(proc, "stderr", None)) # Kill the entire process group so child processes are cleaned up try: @@ -301,53 +303,50 @@ def cleanup_ook(*, emit_status: bool = True) -> None: _ook_parser_thread = None if ook_active_device is not None: - app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(ook_active_device, ook_active_sdr_type or "rtlsdr") ook_active_device = None ook_active_sdr_type = None if emit_status: try: - app_module.ook_queue.put_nowait({'type': 'status', 'text': 'stopped'}) + app_module.ook_queue.put_nowait({"type": "status", "text": "stopped"}) except queue.Full: logger.warning("OOK 'stopped' status dropped — queue full") -@ook_bp.route('/ook/stop', methods=['POST']) +@ook_bp.route("/ook/stop", methods=["POST"]) def stop_ook() -> Response: with app_module.ook_lock: if app_module.ook_process: cleanup_ook() - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) - return jsonify({'status': 'not_running'}) + return jsonify({"status": "not_running"}) -@ook_bp.route('/ook/status') +@ook_bp.route("/ook/status") def ook_status() -> Response: with app_module.ook_lock: - running = ( - app_module.ook_process is not None - and app_module.ook_process.poll() is None - ) - return jsonify({'running': running}) + running = app_module.ook_process is not None and app_module.ook_process.poll() is None + return jsonify({"running": running}) -@ook_bp.route('/ook/stream') +@ook_bp.route("/ook/stream") def ook_stream() -> Response: def _on_msg(msg: dict[str, Any]) -> None: - process_event('ook', msg, msg.get('type')) + process_event("ook", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=app_module.ook_queue, - channel_key='ook', + channel_key="ook", timeout=1.0, keepalive_interval=30.0, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response diff --git a/routes/pager.py b/routes/pager.py index 1c4ee26..4ffc628 100644 --- a/routes/pager.py +++ b/routes/pager.py @@ -36,7 +36,7 @@ from utils.validation import ( validate_rtl_tcp_port, ) -pager_bp = Blueprint('pager', __name__) +pager_bp = Blueprint("pager", __name__) # Track which device is being used pager_active_device: int | None = None @@ -48,70 +48,61 @@ def parse_multimon_output(line: str) -> dict[str, str] | None: line = line.strip() # POCSAG parsing - with message content - pocsag_match = re.match( - r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(Alpha|Numeric):\s*(.*)', - line - ) + pocsag_match = re.match(r"(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(Alpha|Numeric):\s*(.*)", line) if pocsag_match: return { - 'protocol': pocsag_match.group(1), - 'address': pocsag_match.group(2), - 'function': pocsag_match.group(3), - 'msg_type': pocsag_match.group(4), - 'message': pocsag_match.group(5).strip() or '[No Message]' + "protocol": pocsag_match.group(1), + "address": pocsag_match.group(2), + "function": pocsag_match.group(3), + "msg_type": pocsag_match.group(4), + "message": pocsag_match.group(5).strip() or "[No Message]", } # POCSAG parsing - other content types (catch-all for non-Alpha/Numeric labels) - pocsag_other_match = re.match( - r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(\w+):\s*(.*)', - line - ) + pocsag_other_match = re.match(r"(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(\w+):\s*(.*)", line) if pocsag_other_match: return { - 'protocol': pocsag_other_match.group(1), - 'address': pocsag_other_match.group(2), - 'function': pocsag_other_match.group(3), - 'msg_type': pocsag_other_match.group(4), - 'message': pocsag_other_match.group(5).strip() or '[No Message]' + "protocol": pocsag_other_match.group(1), + "address": pocsag_other_match.group(2), + "function": pocsag_other_match.group(3), + "msg_type": pocsag_other_match.group(4), + "message": pocsag_other_match.group(5).strip() or "[No Message]", } # POCSAG parsing - address only (no message content) - pocsag_addr_match = re.match( - r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$', - line - ) + pocsag_addr_match = re.match(r"(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$", line) if pocsag_addr_match: return { - 'protocol': pocsag_addr_match.group(1), - 'address': pocsag_addr_match.group(2), - 'function': pocsag_addr_match.group(3), - 'msg_type': 'Tone', - 'message': '[Tone Only]' + "protocol": pocsag_addr_match.group(1), + "address": pocsag_addr_match.group(2), + "function": pocsag_addr_match.group(3), + "msg_type": "Tone", + "message": "[Tone Only]", } # FLEX parsing (standard format) flex_match = re.match( - r'FLEX[:\|]\s*[\d\-]+[\s\|]+[\d:]+[\s\|]+([\d/A-Z]+)[\s\|]+([\d.]+)[\s\|]+\[?(\d+)\]?[\s\|]+(\w+)[\s\|]+(.*)', - line + r"FLEX[:\|]\s*[\d\-]+[\s\|]+[\d:]+[\s\|]+([\d/A-Z]+)[\s\|]+([\d.]+)[\s\|]+\[?(\d+)\]?[\s\|]+(\w+)[\s\|]+(.*)", + line, ) if flex_match: return { - 'protocol': 'FLEX', - 'address': flex_match.group(3), - 'function': flex_match.group(1), - 'msg_type': flex_match.group(4), - 'message': flex_match.group(5).strip() or '[No Message]' + "protocol": "FLEX", + "address": flex_match.group(3), + "function": flex_match.group(1), + "msg_type": flex_match.group(4), + "message": flex_match.group(5).strip() or "[No Message]", } # Simple FLEX format - flex_simple = re.match(r'FLEX:\s*(.+)', line) + flex_simple = re.match(r"FLEX:\s*(.+)", line) if flex_simple: return { - 'protocol': 'FLEX', - 'address': 'Unknown', - 'function': '', - 'msg_type': 'Unknown', - 'message': flex_simple.group(1).strip() + "protocol": "FLEX", + "address": "Unknown", + "function": "", + "msg_type": "Unknown", + "message": flex_simple.group(1).strip(), } return None @@ -122,9 +113,11 @@ def log_message(msg: dict[str, Any]) -> None: if not app_module.logging_enabled: return try: - with open(app_module.log_file_path, 'a') as f: - timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n") + with open(app_module.log_file_path, "a") as f: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + f.write( + f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n" + ) except Exception as e: logger.error(f"Failed to log message: {e}") @@ -180,15 +173,17 @@ def audio_relay_thread( n_samples = len(data) // 2 if n_samples == 0: continue - samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2]) + samples = struct.unpack(f"<{n_samples}h", data[: n_samples * 2]) peak = max(abs(s) for s in samples) rms = int(math.sqrt(sum(s * s for s in samples) / n_samples)) - output_queue.put_nowait({ - 'type': 'scope', - 'rms': rms, - 'peak': peak, - 'waveform': _encode_scope_waveform(samples), - }) + output_queue.put_nowait( + { + "type": "scope", + "rms": rms, + "peak": peak, + "waveform": _encode_scope_waveform(samples), + } + ) except (struct.error, ValueError, queue.Full): pass except Exception as e: @@ -201,7 +196,7 @@ def audio_relay_thread( def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None: """Stream decoder output to queue using PTY for unbuffered output.""" try: - app_module.output_queue.put({'type': 'status', 'text': 'started'}) + app_module.output_queue.put({"type": "status", "text": "started"}) buffer = "" while True: @@ -215,21 +210,21 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None: data = os.read(master_fd, 1024) if not data: break - buffer += data.decode('utf-8', errors='replace') + buffer += data.decode("utf-8", errors="replace") - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) line = line.strip() if not line: continue parsed = parse_multimon_output(line) if parsed: - parsed['timestamp'] = datetime.now().strftime('%H:%M:%S') - app_module.output_queue.put({'type': 'message', **parsed}) + parsed["timestamp"] = datetime.now().strftime("%H:%M:%S") + app_module.output_queue.put({"type": "message", **parsed}) log_message(parsed) else: - app_module.output_queue.put({'type': 'raw', 'text': line}) + app_module.output_queue.put({"type": "raw", "text": line}) except OSError: break @@ -237,19 +232,19 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None: break except Exception as e: - app_module.output_queue.put({'type': 'error', 'text': str(e)}) + app_module.output_queue.put({"type": "error", "text": str(e)}) finally: global pager_active_device, pager_active_sdr_type with contextlib.suppress(OSError): os.close(master_fd) # Signal relay thread to stop with app_module.process_lock: - stop_relay = getattr(app_module.current_process, '_stop_relay', None) + stop_relay = getattr(app_module.current_process, "_stop_relay", None) if stop_relay: stop_relay.set() # Cleanup companion rtl_fm process and decoder with app_module.process_lock: - rtl_proc = getattr(app_module.current_process, '_rtl_process', None) + rtl_proc = getattr(app_module.current_process, "_rtl_process", None) for proc in [rtl_proc, process]: if proc: try: @@ -259,68 +254,68 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None: with contextlib.suppress(Exception): proc.kill() unregister_process(proc) - app_module.output_queue.put({'type': 'status', 'text': 'stopped'}) + app_module.output_queue.put({"type": "status", "text": "stopped"}) with app_module.process_lock: app_module.current_process = None # Release SDR device if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or "rtlsdr") pager_active_device = None pager_active_sdr_type = None -@pager_bp.route('/start', methods=['POST']) +@pager_bp.route("/start", methods=["POST"]) def start_decoding() -> Response: global pager_active_device, pager_active_sdr_type with app_module.process_lock: if app_module.current_process: - return api_error('Already running', 409) + return api_error("Already running", 409) data = request.json or {} # Validate inputs try: - freq = validate_frequency(data.get('frequency', '929.6125')) - gain = validate_gain(data.get('gain', '0')) - ppm = validate_ppm(data.get('ppm', '0')) - device = validate_device_index(data.get('device', '0')) + freq = validate_frequency(data.get("frequency", "929.6125")) + gain = validate_gain(data.get("gain", "0")) + ppm = validate_ppm(data.get("ppm", "0")) + device = validate_device_index(data.get("device", "0")) except ValueError as e: return api_error(str(e), 400) - squelch = data.get('squelch', '0') + squelch = data.get("squelch", "0") try: squelch = int(squelch) if not 0 <= squelch <= 1000: raise ValueError("Squelch must be between 0 and 1000") except (ValueError, TypeError): - return api_error('Invalid squelch value', 400) + return api_error("Invalid squelch value", 400) # Check for rtl_tcp (remote SDR) connection - rtl_tcp_host = data.get('rtl_tcp_host') - rtl_tcp_port = data.get('rtl_tcp_port', 1234) + rtl_tcp_host = data.get("rtl_tcp_host") + rtl_tcp_port = data.get("rtl_tcp_port", 1234) # Get SDR type early so we can pass it to claim/release - sdr_type_str = data.get('sdr_type', 'rtlsdr') + sdr_type_str = data.get("sdr_type", "rtlsdr") # Claim local device if not using remote rtl_tcp if not rtl_tcp_host: device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str) + error = app_module.claim_sdr_device(device_int, "pager", sdr_type_str) if error: - return api_error(error, 409, error_type='DEVICE_BUSY') + return api_error(error, 409, error_type="DEVICE_BUSY") pager_active_device = device_int pager_active_sdr_type = sdr_type_str # Validate protocols - valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX'] - protocols = data.get('protocols', valid_protocols) + valid_protocols = ["POCSAG512", "POCSAG1200", "POCSAG2400", "FLEX"] + protocols = data.get("protocols", valid_protocols) if not isinstance(protocols, list): if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or "rtlsdr") pager_active_device = None pager_active_sdr_type = None - return api_error('Protocols must be a list', 400) + return api_error("Protocols must be a list", 400) protocols = [p for p in protocols if p in valid_protocols] if not protocols: protocols = valid_protocols @@ -335,14 +330,14 @@ def start_decoding() -> Response: # Build multimon-ng decoder arguments decoders = [] for proto in protocols: - if proto == 'POCSAG512': - decoders.extend(['-a', 'POCSAG512']) - elif proto == 'POCSAG1200': - decoders.extend(['-a', 'POCSAG1200']) - elif proto == 'POCSAG2400': - decoders.extend(['-a', 'POCSAG2400']) - elif proto == 'FLEX': - decoders.extend(['-a', 'FLEX']) + if proto == "POCSAG512": + decoders.extend(["-a", "POCSAG512"]) + elif proto == "POCSAG1200": + decoders.extend(["-a", "POCSAG1200"]) + elif proto == "POCSAG2400": + decoders.extend(["-a", "POCSAG2400"]) + elif proto == "FLEX": + decoders.extend(["-a", "FLEX"]) # Build command via SDR abstraction layer try: @@ -367,42 +362,38 @@ def start_decoding() -> Response: builder = SDRFactory.get_builder(sdr_device.sdr_type) # Build FM demodulation command - bias_t = data.get('bias_t', False) + bias_t = data.get("bias_t", False) rtl_cmd = builder.build_fm_demod_command( device=sdr_device, frequency_mhz=freq, sample_rate=22050, - gain=float(gain) if gain and gain != '0' else None, - ppm=int(ppm) if ppm and ppm != '0' else None, - modulation='fm', + gain=float(gain) if gain and gain != "0" else None, + ppm=int(ppm) if ppm and ppm != "0" else None, + modulation="fm", squelch=squelch if squelch and squelch != 0 else None, - bias_t=bias_t + bias_t=bias_t, ) - multimon_path = get_tool_path('multimon-ng') + multimon_path = get_tool_path("multimon-ng") if not multimon_path: - return api_error('multimon-ng not found', 400) - multimon_cmd = [multimon_path, '-t', 'raw'] + decoders + ['-f', 'alpha', '-'] + return api_error("multimon-ng not found", 400) + multimon_cmd = [multimon_path, "-t", "raw"] + decoders + ["-f", "alpha", "-"] - full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd) + full_cmd = " ".join(rtl_cmd) + " | " + " ".join(multimon_cmd) logger.info(f"Running: {full_cmd}") try: # Create pipe: rtl_fm | multimon-ng - rtl_process = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + rtl_process = subprocess.Popen(rtl_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) register_process(rtl_process) # Start a thread to monitor rtl_fm stderr for errors def monitor_rtl_stderr(): for line in rtl_process.stderr: - err_text = line.decode('utf-8', errors='replace').strip() + err_text = line.decode("utf-8", errors="replace").strip() if err_text: logger.debug(f"[RTL_FM] {err_text}") - app_module.output_queue.put({'type': 'raw', 'text': f'[rtl_fm] {err_text}'}) + app_module.output_queue.put({"type": "raw", "text": f"[rtl_fm] {err_text}"}) rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr) rtl_stderr_thread.daemon = True @@ -412,11 +403,7 @@ def start_decoding() -> Response: master_fd, slave_fd = pty.openpty() multimon_process = subprocess.Popen( - multimon_cmd, - stdin=subprocess.PIPE, - stdout=slave_fd, - stderr=slave_fd, - close_fds=True + multimon_cmd, stdin=subprocess.PIPE, stdout=slave_fd, stderr=slave_fd, close_fds=True ) register_process(multimon_process) @@ -426,8 +413,7 @@ def start_decoding() -> Response: stop_relay = threading.Event() relay = threading.Thread( target=audio_relay_thread, - args=(rtl_process.stdout, multimon_process.stdin, - app_module.output_queue, stop_relay), + args=(rtl_process.stdout, multimon_process.stdin, app_module.output_queue, stop_relay), ) relay.daemon = True relay.start() @@ -443,9 +429,9 @@ def start_decoding() -> Response: thread.daemon = True thread.start() - app_module.output_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'}) + app_module.output_queue.put({"type": "info", "text": f"Command: {full_cmd}"}) - return jsonify({'status': 'started', 'command': full_cmd}) + return jsonify({"status": "started", "command": full_cmd}) except FileNotFoundError as e: # Kill orphaned rtl_fm process @@ -457,10 +443,10 @@ def start_decoding() -> Response: rtl_process.kill() # Release device on failure if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or "rtlsdr") pager_active_device = None pager_active_sdr_type = None - return api_error(f'Tool not found: {e.filename}') + return api_error(f"Tool not found: {e.filename}") except Exception as e: # Kill orphaned rtl_fm process if it was started try: @@ -471,24 +457,24 @@ def start_decoding() -> Response: rtl_process.kill() # Release device on failure if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or "rtlsdr") pager_active_device = None pager_active_sdr_type = None return api_error(str(e)) -@pager_bp.route('/stop', methods=['POST']) +@pager_bp.route("/stop", methods=["POST"]) def stop_decoding() -> Response: global pager_active_device, pager_active_sdr_type with app_module.process_lock: if app_module.current_process: # Signal audio relay thread to stop - if hasattr(app_module.current_process, '_stop_relay'): + if hasattr(app_module.current_process, "_stop_relay"): app_module.current_process._stop_relay.set() # Kill rtl_fm process first - if hasattr(app_module.current_process, '_rtl_process'): + if hasattr(app_module.current_process, "_rtl_process"): try: app_module.current_process._rtl_process.terminate() app_module.current_process._rtl_process.wait(timeout=2) @@ -497,7 +483,7 @@ def stop_decoding() -> Response: app_module.current_process._rtl_process.kill() # Close PTY master fd - if hasattr(app_module.current_process, '_master_fd'): + if hasattr(app_module.current_process, "_master_fd"): with contextlib.suppress(OSError): os.close(app_module.current_process._master_fd) @@ -512,74 +498,76 @@ def stop_decoding() -> Response: # Release device from registry if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or "rtlsdr") pager_active_device = None pager_active_sdr_type = None - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) - return jsonify({'status': 'not_running'}) + return jsonify({"status": "not_running"}) -@pager_bp.route('/status') +@pager_bp.route("/status") def get_status() -> Response: """Check if decoder is currently running.""" with app_module.process_lock: if app_module.current_process and app_module.current_process.poll() is None: - return jsonify({'running': True, 'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path}) - return jsonify({'running': False, 'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path}) + return jsonify( + {"running": True, "logging": app_module.logging_enabled, "log_file": app_module.log_file_path} + ) + return jsonify({"running": False, "logging": app_module.logging_enabled, "log_file": app_module.log_file_path}) -@pager_bp.route('/logging', methods=['POST']) +@pager_bp.route("/logging", methods=["POST"]) def toggle_logging() -> Response: """Toggle message logging.""" data = request.json or {} - if 'enabled' in data: - app_module.logging_enabled = bool(data['enabled']) + if "enabled" in data: + app_module.logging_enabled = bool(data["enabled"]) - if 'log_file' in data and data['log_file']: + if "log_file" in data and data["log_file"]: # Validate path to prevent directory traversal try: - requested_path = pathlib.Path(data['log_file']).resolve() + requested_path = pathlib.Path(data["log_file"]).resolve() # Only allow files in the current directory or logs subdirectory - cwd = pathlib.Path('.').resolve() - logs_dir = (cwd / 'logs').resolve() + cwd = pathlib.Path(".").resolve() + logs_dir = (cwd / "logs").resolve() # Check if path is within allowed directories is_in_cwd = str(requested_path).startswith(str(cwd)) is_in_logs = str(requested_path).startswith(str(logs_dir)) if not (is_in_cwd or is_in_logs): - return api_error('Invalid log file path', 400) + return api_error("Invalid log file path", 400) # Ensure it's not a directory if requested_path.is_dir(): - return api_error('Log file path must be a file, not a directory', 400) + return api_error("Log file path must be a file, not a directory", 400) app_module.log_file_path = str(requested_path) except (ValueError, OSError) as e: logger.warning(f"Invalid log file path: {e}") - return api_error('Invalid log file path', 400) + return api_error("Invalid log file path", 400) - return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path}) + return jsonify({"logging": app_module.logging_enabled, "log_file": app_module.log_file_path}) -@pager_bp.route('/stream') +@pager_bp.route("/stream") def stream() -> Response: def _on_msg(msg: dict[str, Any]) -> None: - process_event('pager', msg, msg.get('type')) + process_event("pager", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=app_module.output_queue, - channel_key='pager', + channel_key="pager", timeout=1.0, keepalive_interval=30.0, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response diff --git a/routes/radiosonde.py b/routes/radiosonde.py index 0f67f5e..3d4ed50 100644 --- a/routes/radiosonde.py +++ b/routes/radiosonde.py @@ -7,16 +7,16 @@ telemetry (position, altitude, temperature, humidity, pressure) on the from __future__ import annotations -import contextlib -import json -import os -import queue -import shlex -import shutil -import socket -import subprocess -import sys -import threading +import contextlib +import json +import os +import queue +import shlex +import shutil +import socket +import subprocess +import sys +import threading import time from typing import Any @@ -43,10 +43,10 @@ from utils.validation import ( validate_longitude, ) -logger = get_logger('intercept.radiosonde') - -radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde') -PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +logger = get_logger("intercept.radiosonde") + +radiosonde_bp = Blueprint("radiosonde", __name__, url_prefix="/radiosonde") +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Track radiosonde state radiosonde_running = False @@ -62,16 +62,16 @@ _udp_socket: socket.socket | None = None # Common installation paths for radiosonde_auto_rx AUTO_RX_PATHS = [ - '/opt/radiosonde_auto_rx/auto_rx/auto_rx.py', - '/usr/local/bin/radiosonde_auto_rx', - '/opt/auto_rx/auto_rx.py', + "/opt/radiosonde_auto_rx/auto_rx/auto_rx.py", + "/usr/local/bin/radiosonde_auto_rx", + "/opt/auto_rx/auto_rx.py", ] -def find_auto_rx() -> str | None: - """Find radiosonde_auto_rx script/binary.""" +def find_auto_rx() -> str | None: + """Find radiosonde_auto_rx script/binary.""" # Check PATH first - path = shutil.which('radiosonde_auto_rx') + path = shutil.which("radiosonde_auto_rx") if path: return path # Check common locations @@ -79,123 +79,123 @@ def find_auto_rx() -> str | None: if os.path.isfile(p) and os.access(p, os.X_OK): return p # Check for Python script (not executable but runnable) - for p in AUTO_RX_PATHS: - if os.path.isfile(p): - return p - return None - - -def _resolve_shebang_interpreter(script_path: str) -> str | None: - """Resolve a Python interpreter from a script shebang if possible.""" - try: - with open(script_path, encoding='utf-8', errors='ignore') as handle: - first_line = handle.readline().strip() - except OSError: - return None - - if not first_line.startswith('#!'): - return None - - parts = shlex.split(first_line[2:].strip()) - if not parts: - return None - - if os.path.basename(parts[0]) == 'env' and len(parts) > 1: - return shutil.which(parts[1]) - - return parts[0] - - -def _resolve_pip_python(pip_bin: str | None) -> str | None: - """Resolve the Python interpreter used by a pip executable.""" - if not pip_bin: - return None - return _resolve_shebang_interpreter(pip_bin) - - -def _build_auto_rx_env(auto_rx_dir: str) -> dict[str, str]: - """Build environment for radiosonde_auto_rx with compatibility shims.""" - env = os.environ.copy() - python_path_entries = [PROJECT_ROOT, auto_rx_dir] - existing_pythonpath = env.get('PYTHONPATH', '') - if existing_pythonpath: - python_path_entries.append(existing_pythonpath) - env['PYTHONPATH'] = os.pathsep.join(entry for entry in python_path_entries if entry) - return env - - -def _iter_auto_rx_python_candidates(auto_rx_path: str): - """Yield plausible Python interpreters for radiosonde_auto_rx.""" - auto_rx_abs = os.path.abspath(auto_rx_path) - auto_rx_dir = os.path.dirname(auto_rx_abs) - install_root = os.path.dirname(auto_rx_dir) - install_parent = os.path.dirname(install_root) - - candidates = [ - _resolve_shebang_interpreter(auto_rx_abs), - sys.executable, - os.path.join(install_root, 'venv', 'bin', 'python'), - os.path.join(install_root, 'venv', 'bin', 'python3'), - os.path.join(install_root, '.venv', 'bin', 'python'), - os.path.join(install_root, '.venv', 'bin', 'python3'), - os.path.join(auto_rx_dir, 'venv', 'bin', 'python'), - os.path.join(auto_rx_dir, 'venv', 'bin', 'python3'), - os.path.join(auto_rx_dir, '.venv', 'bin', 'python'), - os.path.join(auto_rx_dir, '.venv', 'bin', 'python3'), - os.path.join(install_parent, 'venv', 'bin', 'python'), - os.path.join(install_parent, 'venv', 'bin', 'python3'), - os.path.join(install_parent, '.venv', 'bin', 'python'), - os.path.join(install_parent, '.venv', 'bin', 'python3'), - _resolve_pip_python(shutil.which('pip3')), - _resolve_pip_python(shutil.which('pip')), - shutil.which('python3'), - shutil.which('python'), - '/usr/local/bin/python3', - '/usr/local/bin/python', - '/usr/bin/python3', - ] - - seen: set[str] = set() - for candidate in candidates: - if not candidate: - continue - candidate_abs = os.path.abspath(candidate) - if candidate_abs in seen: - continue - seen.add(candidate_abs) - if os.path.isfile(candidate_abs) and os.access(candidate_abs, os.X_OK): - yield candidate_abs - - -def _resolve_auto_rx_python(auto_rx_path: str) -> tuple[str | None, str, list[str]]: - """Pick a Python interpreter that can import autorx.scan successfully.""" - auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path)) - auto_rx_env = _build_auto_rx_env(auto_rx_dir) - checked: list[str] = [] - last_error = 'No usable Python interpreter found' - - for python_bin in _iter_auto_rx_python_candidates(auto_rx_path): - checked.append(python_bin) - try: - dep_check = subprocess.run( - [python_bin, '-c', 'import autorx.scan'], - cwd=auto_rx_dir, - env=auto_rx_env, - capture_output=True, - timeout=10, - ) - except Exception as exc: - last_error = str(exc) - continue - - if dep_check.returncode == 0: - return python_bin, '', checked - - stderr_output = dep_check.stderr.decode('utf-8', errors='ignore').strip() - stdout_output = dep_check.stdout.decode('utf-8', errors='ignore').strip() - last_error = stderr_output or stdout_output or f'Interpreter exited with code {dep_check.returncode}' - - return None, last_error, checked + for p in AUTO_RX_PATHS: + if os.path.isfile(p): + return p + return None + + +def _resolve_shebang_interpreter(script_path: str) -> str | None: + """Resolve a Python interpreter from a script shebang if possible.""" + try: + with open(script_path, encoding="utf-8", errors="ignore") as handle: + first_line = handle.readline().strip() + except OSError: + return None + + if not first_line.startswith("#!"): + return None + + parts = shlex.split(first_line[2:].strip()) + if not parts: + return None + + if os.path.basename(parts[0]) == "env" and len(parts) > 1: + return shutil.which(parts[1]) + + return parts[0] + + +def _resolve_pip_python(pip_bin: str | None) -> str | None: + """Resolve the Python interpreter used by a pip executable.""" + if not pip_bin: + return None + return _resolve_shebang_interpreter(pip_bin) + + +def _build_auto_rx_env(auto_rx_dir: str) -> dict[str, str]: + """Build environment for radiosonde_auto_rx with compatibility shims.""" + env = os.environ.copy() + python_path_entries = [PROJECT_ROOT, auto_rx_dir] + existing_pythonpath = env.get("PYTHONPATH", "") + if existing_pythonpath: + python_path_entries.append(existing_pythonpath) + env["PYTHONPATH"] = os.pathsep.join(entry for entry in python_path_entries if entry) + return env + + +def _iter_auto_rx_python_candidates(auto_rx_path: str): + """Yield plausible Python interpreters for radiosonde_auto_rx.""" + auto_rx_abs = os.path.abspath(auto_rx_path) + auto_rx_dir = os.path.dirname(auto_rx_abs) + install_root = os.path.dirname(auto_rx_dir) + install_parent = os.path.dirname(install_root) + + candidates = [ + _resolve_shebang_interpreter(auto_rx_abs), + sys.executable, + os.path.join(install_root, "venv", "bin", "python"), + os.path.join(install_root, "venv", "bin", "python3"), + os.path.join(install_root, ".venv", "bin", "python"), + os.path.join(install_root, ".venv", "bin", "python3"), + os.path.join(auto_rx_dir, "venv", "bin", "python"), + os.path.join(auto_rx_dir, "venv", "bin", "python3"), + os.path.join(auto_rx_dir, ".venv", "bin", "python"), + os.path.join(auto_rx_dir, ".venv", "bin", "python3"), + os.path.join(install_parent, "venv", "bin", "python"), + os.path.join(install_parent, "venv", "bin", "python3"), + os.path.join(install_parent, ".venv", "bin", "python"), + os.path.join(install_parent, ".venv", "bin", "python3"), + _resolve_pip_python(shutil.which("pip3")), + _resolve_pip_python(shutil.which("pip")), + shutil.which("python3"), + shutil.which("python"), + "/usr/local/bin/python3", + "/usr/local/bin/python", + "/usr/bin/python3", + ] + + seen: set[str] = set() + for candidate in candidates: + if not candidate: + continue + candidate_abs = os.path.abspath(candidate) + if candidate_abs in seen: + continue + seen.add(candidate_abs) + if os.path.isfile(candidate_abs) and os.access(candidate_abs, os.X_OK): + yield candidate_abs + + +def _resolve_auto_rx_python(auto_rx_path: str) -> tuple[str | None, str, list[str]]: + """Pick a Python interpreter that can import autorx.scan successfully.""" + auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path)) + auto_rx_env = _build_auto_rx_env(auto_rx_dir) + checked: list[str] = [] + last_error = "No usable Python interpreter found" + + for python_bin in _iter_auto_rx_python_candidates(auto_rx_path): + checked.append(python_bin) + try: + dep_check = subprocess.run( + [python_bin, "-c", "import autorx.scan"], + cwd=auto_rx_dir, + env=auto_rx_env, + capture_output=True, + timeout=10, + ) + except Exception as exc: + last_error = str(exc) + continue + + if dep_check.returncode == 0: + return python_bin, "", checked + + stderr_output = dep_check.stderr.decode("utf-8", errors="ignore").strip() + stdout_output = dep_check.stdout.decode("utf-8", errors="ignore").strip() + last_error = stderr_output or stdout_output or f"Interpreter exited with code {dep_check.returncode}" + + return None, last_error, checked def generate_station_cfg( @@ -212,10 +212,10 @@ def generate_station_cfg( gpsd_enabled: bool = False, ) -> str: """Generate a station.cfg for radiosonde_auto_rx and return the file path.""" - cfg_dir = os.path.abspath(os.path.join('data', 'radiosonde')) - log_dir = os.path.join(cfg_dir, 'logs') + cfg_dir = os.path.abspath(os.path.join("data", "radiosonde")) + log_dir = os.path.join(cfg_dir, "logs") os.makedirs(log_dir, exist_ok=True) - cfg_path = os.path.join(cfg_dir, 'station.cfg') + cfg_path = os.path.join(cfg_dir, "station.cfg") # Full station.cfg based on radiosonde_auto_rx v1.8+ example config. # All sections and keys included to avoid missing-key crashes. @@ -361,7 +361,7 @@ sonde_time_threshold = 3 """ try: - with open(cfg_path, 'w') as f: + with open(cfg_path, "w") as f: f.write(cfg) except OSError as e: logger.error(f"Cannot write station.cfg to {cfg_path}: {e}") @@ -380,8 +380,8 @@ sonde_time_threshold = 3 def _fix_data_ownership(path: str) -> None: """Recursively chown a path to the real (non-root) user when running via sudo.""" - uid = os.environ.get('INTERCEPT_SUDO_UID') - gid = os.environ.get('INTERCEPT_SUDO_GID') + uid = os.environ.get("INTERCEPT_SUDO_UID") + gid = os.environ.get("INTERCEPT_SUDO_GID") if uid is None or gid is None: return try: @@ -403,7 +403,7 @@ def parse_radiosonde_udp(udp_port: int) -> None: try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind(('0.0.0.0', udp_port)) + sock.bind(("0.0.0.0", udp_port)) sock.settimeout(2.0) _udp_socket = sock except OSError as e: @@ -421,21 +421,23 @@ def parse_radiosonde_udp(udp_port: int) -> None: break try: - msg = json.loads(data.decode('utf-8', errors='ignore')) + msg = json.loads(data.decode("utf-8", errors="ignore")) except (json.JSONDecodeError, UnicodeDecodeError): continue balloon = _process_telemetry(msg) if balloon: - serial = balloon.get('id', '') + serial = balloon.get("id", "") if serial: with _balloons_lock: radiosonde_balloons[serial] = balloon with contextlib.suppress(queue.Full): - app_module.radiosonde_queue.put_nowait({ - 'type': 'balloon', - **balloon, - }) + app_module.radiosonde_queue.put_nowait( + { + "type": "balloon", + **balloon, + } + ) with contextlib.suppress(OSError): sock.close() @@ -447,72 +449,72 @@ def _process_telemetry(msg: dict) -> dict | None: """Extract relevant fields from a radiosonde_auto_rx UDP telemetry packet.""" # auto_rx broadcasts packets with a 'type' field # Telemetry packets have type 'payload_summary' or individual sonde data - serial = msg.get('id') or msg.get('serial') + serial = msg.get("id") or msg.get("serial") if not serial: return None - balloon: dict[str, Any] = {'id': str(serial)} + balloon: dict[str, Any] = {"id": str(serial)} # Sonde type (RS41, RS92, DFM, M10, etc.) — prefer subtype if available - if 'subtype' in msg: - balloon['sonde_type'] = msg['subtype'] - elif 'type' in msg: - balloon['sonde_type'] = msg['type'] + if "subtype" in msg: + balloon["sonde_type"] = msg["subtype"] + elif "type" in msg: + balloon["sonde_type"] = msg["type"] # Timestamp - if 'datetime' in msg: - balloon['datetime'] = msg['datetime'] + if "datetime" in msg: + balloon["datetime"] = msg["datetime"] # Position - for key in ('lat', 'latitude'): + for key in ("lat", "latitude"): if key in msg: with contextlib.suppress(ValueError, TypeError): - balloon['lat'] = float(msg[key]) + balloon["lat"] = float(msg[key]) break - for key in ('lon', 'longitude'): + for key in ("lon", "longitude"): if key in msg: with contextlib.suppress(ValueError, TypeError): - balloon['lon'] = float(msg[key]) + balloon["lon"] = float(msg[key]) break # Altitude (metres) - if 'alt' in msg: + if "alt" in msg: with contextlib.suppress(ValueError, TypeError): - balloon['alt'] = float(msg['alt']) + balloon["alt"] = float(msg["alt"]) # Meteorological data - for field in ('temp', 'humidity', 'pressure'): + for field in ("temp", "humidity", "pressure"): if field in msg: with contextlib.suppress(ValueError, TypeError): balloon[field] = float(msg[field]) # Velocity - if 'vel_h' in msg: + if "vel_h" in msg: with contextlib.suppress(ValueError, TypeError): - balloon['vel_h'] = float(msg['vel_h']) - if 'vel_v' in msg: + balloon["vel_h"] = float(msg["vel_h"]) + if "vel_v" in msg: with contextlib.suppress(ValueError, TypeError): - balloon['vel_v'] = float(msg['vel_v']) - if 'heading' in msg: + balloon["vel_v"] = float(msg["vel_v"]) + if "heading" in msg: with contextlib.suppress(ValueError, TypeError): - balloon['heading'] = float(msg['heading']) + balloon["heading"] = float(msg["heading"]) # GPS satellites - if 'sats' in msg: + if "sats" in msg: with contextlib.suppress(ValueError, TypeError): - balloon['sats'] = int(msg['sats']) + balloon["sats"] = int(msg["sats"]) # Battery voltage - if 'batt' in msg: + if "batt" in msg: with contextlib.suppress(ValueError, TypeError): - balloon['batt'] = float(msg['batt']) + balloon["batt"] = float(msg["batt"]) # Frequency - if 'freq' in msg: + if "freq" in msg: with contextlib.suppress(ValueError, TypeError): - balloon['freq'] = float(msg['freq']) + balloon["freq"] = float(msg["freq"]) - balloon['last_seen'] = time.time() + balloon["last_seen"] = time.time() return balloon @@ -520,30 +522,29 @@ def _cleanup_stale_balloons() -> None: """Remove balloons not seen within the retention window.""" now = time.time() with _balloons_lock: - stale = [ - k for k, v in radiosonde_balloons.items() - if now - v.get('last_seen', 0) > MAX_RADIOSONDE_AGE_SECONDS - ] + stale = [k for k, v in radiosonde_balloons.items() if now - v.get("last_seen", 0) > MAX_RADIOSONDE_AGE_SECONDS] for k in stale: del radiosonde_balloons[k] -@radiosonde_bp.route('/tools') +@radiosonde_bp.route("/tools") def check_tools(): """Check for radiosonde decoding tools and hardware.""" auto_rx_path = find_auto_rx() devices = SDRFactory.detect_devices() has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices) - return jsonify({ - 'auto_rx': auto_rx_path is not None, - 'auto_rx_path': auto_rx_path, - 'has_rtlsdr': has_rtlsdr, - 'device_count': len(devices), - }) + return jsonify( + { + "auto_rx": auto_rx_path is not None, + "auto_rx_path": auto_rx_path, + "has_rtlsdr": has_rtlsdr, + "device_count": len(devices), + } + ) -@radiosonde_bp.route('/status') +@radiosonde_bp.route("/status") def radiosonde_status(): """Get radiosonde tracking status.""" process_running = False @@ -554,37 +555,39 @@ def radiosonde_status(): balloon_count = len(radiosonde_balloons) balloons_snapshot = dict(radiosonde_balloons) - return jsonify({ - 'tracking_active': radiosonde_running, - 'active_device': radiosonde_active_device, - 'balloon_count': balloon_count, - 'balloons': balloons_snapshot, - 'queue_size': app_module.radiosonde_queue.qsize(), - 'auto_rx_path': find_auto_rx(), - 'process_running': process_running, - }) + return jsonify( + { + "tracking_active": radiosonde_running, + "active_device": radiosonde_active_device, + "balloon_count": balloon_count, + "balloons": balloons_snapshot, + "queue_size": app_module.radiosonde_queue.qsize(), + "auto_rx_path": find_auto_rx(), + "process_running": process_running, + } + ) -@radiosonde_bp.route('/start', methods=['POST']) +@radiosonde_bp.route("/start", methods=["POST"]) def start_radiosonde(): """Start radiosonde tracking.""" global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type with app_module.radiosonde_lock: if radiosonde_running: - return api_error('Radiosonde tracking already active', 409) + return api_error("Radiosonde tracking already active", 409) data = request.json or {} # Validate inputs try: - gain = float(validate_gain(data.get('gain', '40'))) - device = validate_device_index(data.get('device', '0')) + gain = float(validate_gain(data.get("gain", "40"))) + device = validate_device_index(data.get("device", "0")) except ValueError as e: return api_error(str(e), 400) - freq_min = data.get('freq_min', 400.0) - freq_max = data.get('freq_max', 406.0) + freq_min = data.get("freq_min", 400.0) + freq_max = data.get("freq_max", 406.0) try: freq_min = float(freq_min) freq_max = float(freq_max) @@ -593,18 +596,18 @@ def start_radiosonde(): if freq_min >= freq_max: raise ValueError("Min frequency must be less than max") except (ValueError, TypeError) as e: - return api_error(f'Invalid frequency range: {e}', 400) + return api_error(f"Invalid frequency range: {e}", 400) - bias_t = data.get('bias_t', False) - ppm = int(data.get('ppm', 0)) + bias_t = data.get("bias_t", False) + ppm = int(data.get("ppm", 0)) # Validate optional location latitude = 0.0 longitude = 0.0 - if data.get('latitude') is not None and data.get('longitude') is not None: + if data.get("latitude") is not None and data.get("longitude") is not None: try: - latitude = validate_latitude(data['latitude']) - longitude = validate_longitude(data['longitude']) + latitude = validate_latitude(data["latitude"]) + longitude = validate_longitude(data["longitude"]) except ValueError: latitude = 0.0 longitude = 0.0 @@ -615,10 +618,12 @@ def start_radiosonde(): # Find auto_rx auto_rx_path = find_auto_rx() if not auto_rx_path: - return api_error('radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx', 400) + return api_error( + "radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx", 400 + ) # Get SDR type - sdr_type_str = data.get('sdr_type', 'rtlsdr') + sdr_type_str = data.get("sdr_type", "rtlsdr") # Kill any existing process if app_module.radiosonde_process: @@ -637,9 +642,9 @@ def start_radiosonde(): # Claim SDR device device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str) + error = app_module.claim_sdr_device(device_int, "radiosonde", sdr_type_str) if error: - return api_error(error, 409, error_type='DEVICE_BUSY') + return api_error(error, 409, error_type="DEVICE_BUSY") # Generate config try: @@ -659,72 +664,64 @@ def start_radiosonde(): logger.error(f"Failed to generate radiosonde config: {e}") return api_error(str(e), 500) - # Build command - auto_rx -c expects the path to station.cfg - cfg_abs = os.path.abspath(cfg_path) - if auto_rx_path.endswith('.py'): - selected_python, dep_error, checked_interpreters = _resolve_auto_rx_python(auto_rx_path) - if not selected_python: - logger.error( - "radiosonde_auto_rx dependency check failed across interpreters %s: %s", - checked_interpreters, - dep_error, - ) - app_module.release_sdr_device(device_int, sdr_type_str) - checked_msg = ', '.join(checked_interpreters) if checked_interpreters else 'none' - return api_error( - 'radiosonde_auto_rx dependencies not satisfied. ' - 'Install or repair its Python environment (missing packages such as semver). ' - f'Checked interpreters: {checked_msg}. ' - f'Last error: {dep_error[:500]}', - 500, - ) - cmd = [selected_python, auto_rx_path, '-c', cfg_abs] - else: - cmd = [auto_rx_path, '-c', cfg_abs] - - # Set cwd to the auto_rx directory so 'from autorx.scan import ...' works - auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path)) - auto_rx_env = _build_auto_rx_env(auto_rx_dir) - - try: - logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}") - app_module.radiosonde_process = subprocess.Popen( - cmd, + # Build command - auto_rx -c expects the path to station.cfg + cfg_abs = os.path.abspath(cfg_path) + if auto_rx_path.endswith(".py"): + selected_python, dep_error, checked_interpreters = _resolve_auto_rx_python(auto_rx_path) + if not selected_python: + logger.error( + "radiosonde_auto_rx dependency check failed across interpreters %s: %s", + checked_interpreters, + dep_error, + ) + app_module.release_sdr_device(device_int, sdr_type_str) + checked_msg = ", ".join(checked_interpreters) if checked_interpreters else "none" + return api_error( + "radiosonde_auto_rx dependencies not satisfied. " + "Install or repair its Python environment (missing packages such as semver). " + f"Checked interpreters: {checked_msg}. " + f"Last error: {dep_error[:500]}", + 500, + ) + cmd = [selected_python, auto_rx_path, "-c", cfg_abs] + else: + cmd = [auto_rx_path, "-c", cfg_abs] + + # Set cwd to the auto_rx directory so 'from autorx.scan import ...' works + auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path)) + auto_rx_env = _build_auto_rx_env(auto_rx_dir) + + try: + logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}") + app_module.radiosonde_process = subprocess.Popen( + cmd, stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - start_new_session=True, - cwd=auto_rx_dir, - env=auto_rx_env, - ) + stderr=subprocess.PIPE, + start_new_session=True, + cwd=auto_rx_dir, + env=auto_rx_env, + ) # Wait briefly for process to start time.sleep(2.0) if app_module.radiosonde_process.poll() is not None: app_module.release_sdr_device(device_int, sdr_type_str) - stderr_output = '' + stderr_output = "" if app_module.radiosonde_process.stderr: with contextlib.suppress(Exception): - stderr_output = app_module.radiosonde_process.stderr.read().decode( - 'utf-8', errors='ignore' - ).strip() + stderr_output = app_module.radiosonde_process.stderr.read().decode("utf-8", errors="ignore").strip() if stderr_output: logger.error(f"radiosonde_auto_rx stderr:\n{stderr_output}") - if stderr_output and ( - 'ImportError' in stderr_output - or 'ModuleNotFoundError' in stderr_output - ): + if stderr_output and ("ImportError" in stderr_output or "ModuleNotFoundError" in stderr_output): error_msg = ( - 'radiosonde_auto_rx failed to start due to missing Python ' - 'dependencies. Re-run setup.sh or reinstall radiosonde_auto_rx.' + "radiosonde_auto_rx failed to start due to missing Python " + "dependencies. Re-run setup.sh or reinstall radiosonde_auto_rx." ) else: - error_msg = ( - 'radiosonde_auto_rx failed to start. ' - 'Check SDR device connection.' - ) + error_msg = "radiosonde_auto_rx failed to start. Check SDR device connection." if stderr_output: - error_msg += f' Error: {stderr_output[:500]}' + error_msg += f" Error: {stderr_output[:500]}" return api_error(error_msg, 500) radiosonde_running = True @@ -743,18 +740,20 @@ def start_radiosonde(): ) udp_thread.start() - return jsonify({ - 'status': 'started', - 'message': 'Radiosonde tracking started', - 'device': device, - }) + return jsonify( + { + "status": "started", + "message": "Radiosonde tracking started", + "device": device, + } + ) except Exception as e: app_module.release_sdr_device(device_int, sdr_type_str) logger.error(f"Failed to start radiosonde_auto_rx: {e}") return api_error(str(e), 500) -@radiosonde_bp.route('/stop', methods=['POST']) +@radiosonde_bp.route("/stop", methods=["POST"]) def stop_radiosonde(): """Stop radiosonde tracking.""" global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type, _udp_socket @@ -784,7 +783,7 @@ def stop_radiosonde(): if radiosonde_active_device is not None: app_module.release_sdr_device( radiosonde_active_device, - radiosonde_active_sdr_type or 'rtlsdr', + radiosonde_active_sdr_type or "rtlsdr", ) radiosonde_running = False @@ -794,31 +793,33 @@ def stop_radiosonde(): with _balloons_lock: radiosonde_balloons.clear() - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@radiosonde_bp.route('/stream') +@radiosonde_bp.route("/stream") def stream_radiosonde(): """SSE stream for radiosonde telemetry.""" response = Response( sse_stream_fanout( source_queue=app_module.radiosonde_queue, - channel_key='radiosonde', + channel_key="radiosonde", timeout=SSE_QUEUE_TIMEOUT, keepalive_interval=SSE_KEEPALIVE_INTERVAL, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" return response -@radiosonde_bp.route('/balloons') +@radiosonde_bp.route("/balloons") def get_balloons(): """Get current balloon data.""" with _balloons_lock: - return api_success(data={ - 'count': len(radiosonde_balloons), - 'balloons': dict(radiosonde_balloons), - }) + return api_success( + data={ + "count": len(radiosonde_balloons), + "balloons": dict(radiosonde_balloons), + } + ) diff --git a/routes/recordings.py b/routes/recordings.py index 02b620b..ac12910 100644 --- a/routes/recordings.py +++ b/routes/recordings.py @@ -10,126 +10,136 @@ from flask import Blueprint, request, send_file from utils.recording import RECORDING_ROOT, get_recording_manager from utils.responses import api_error, api_success -recordings_bp = Blueprint('recordings', __name__, url_prefix='/recordings') +recordings_bp = Blueprint("recordings", __name__, url_prefix="/recordings") -@recordings_bp.route('/start', methods=['POST']) +@recordings_bp.route("/start", methods=["POST"]) def start_recording(): data = request.get_json() or {} - mode = (data.get('mode') or '').strip() + mode = (data.get("mode") or "").strip() if not mode: - return api_error('mode is required', 400) + return api_error("mode is required", 400) - label = data.get('label') - metadata = data.get('metadata') if isinstance(data.get('metadata'), dict) else {} + label = data.get("label") + metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} manager = get_recording_manager() session = manager.start_recording(mode=mode, label=label, metadata=metadata) - return api_success(data={'session': { - 'id': session.id, - 'mode': session.mode, - 'label': session.label, - 'started_at': session.started_at.isoformat(), - 'file_path': str(session.file_path), - }}) + return api_success( + data={ + "session": { + "id": session.id, + "mode": session.mode, + "label": session.label, + "started_at": session.started_at.isoformat(), + "file_path": str(session.file_path), + } + } + ) -@recordings_bp.route('/stop', methods=['POST']) +@recordings_bp.route("/stop", methods=["POST"]) def stop_recording(): data = request.get_json() or {} - mode = data.get('mode') - session_id = data.get('id') + mode = data.get("mode") + session_id = data.get("id") manager = get_recording_manager() session = manager.stop_recording(mode=mode, session_id=session_id) if not session: - return api_error('No active recording found', 404) + return api_error("No active recording found", 404) - return api_success(data={'session': { - 'id': session.id, - 'mode': session.mode, - 'label': session.label, - 'started_at': session.started_at.isoformat(), - 'stopped_at': session.stopped_at.isoformat() if session.stopped_at else None, - 'event_count': session.event_count, - 'size_bytes': session.size_bytes, - 'file_path': str(session.file_path), - }}) + return api_success( + data={ + "session": { + "id": session.id, + "mode": session.mode, + "label": session.label, + "started_at": session.started_at.isoformat(), + "stopped_at": session.stopped_at.isoformat() if session.stopped_at else None, + "event_count": session.event_count, + "size_bytes": session.size_bytes, + "file_path": str(session.file_path), + } + } + ) -@recordings_bp.route('', methods=['GET']) +@recordings_bp.route("", methods=["GET"]) def list_recordings(): manager = get_recording_manager() - limit = request.args.get('limit', default=50, type=int) - return api_success(data={ - 'recordings': manager.list_recordings(limit=limit), - 'active': manager.get_active(), - }) + limit = request.args.get("limit", default=50, type=int) + return api_success( + data={ + "recordings": manager.list_recordings(limit=limit), + "active": manager.get_active(), + } + ) -@recordings_bp.route('/', methods=['GET']) +@recordings_bp.route("/", methods=["GET"]) def get_recording(session_id: str): manager = get_recording_manager() rec = manager.get_recording(session_id) if not rec: - return api_error('Recording not found', 404) - return api_success(data={'recording': rec}) + return api_error("Recording not found", 404) + return api_success(data={"recording": rec}) -@recordings_bp.route('//download', methods=['GET']) +@recordings_bp.route("//download", methods=["GET"]) def download_recording(session_id: str): manager = get_recording_manager() rec = manager.get_recording(session_id) if not rec: - return api_error('Recording not found', 404) + return api_error("Recording not found", 404) - file_path = Path(rec['file_path']) + file_path = Path(rec["file_path"]) try: resolved_root = RECORDING_ROOT.resolve() resolved_file = file_path.resolve() if resolved_root not in resolved_file.parents: - return api_error('Invalid recording path', 400) + return api_error("Invalid recording path", 400) except Exception: - return api_error('Invalid recording path', 400) + return api_error("Invalid recording path", 400) if not file_path.exists(): - return api_error('Recording file missing', 404) + return api_error("Recording file missing", 404) return send_file( file_path, - mimetype='application/x-ndjson', + mimetype="application/x-ndjson", as_attachment=True, download_name=file_path.name, ) -@recordings_bp.route('//events', methods=['GET']) +@recordings_bp.route("//events", methods=["GET"]) def get_recording_events(session_id: str): """Return parsed events from a recording for in-app replay.""" manager = get_recording_manager() rec = manager.get_recording(session_id) if not rec: - return api_error('Recording not found', 404) + return api_error("Recording not found", 404) - file_path = Path(rec['file_path']) + file_path = Path(rec["file_path"]) try: resolved_root = RECORDING_ROOT.resolve() resolved_file = file_path.resolve() if resolved_root not in resolved_file.parents: - return api_error('Invalid recording path', 400) + return api_error("Invalid recording path", 400) except Exception: - return api_error('Invalid recording path', 400) + return api_error("Invalid recording path", 400) if not file_path.exists(): - return api_error('Recording file missing', 404) + return api_error("Recording file missing", 404) - limit = max(1, min(5000, request.args.get('limit', default=500, type=int))) - offset = max(0, request.args.get('offset', default=0, type=int)) + limit = max(1, min(5000, request.args.get("limit", default=500, type=int))) + offset = max(0, request.args.get("offset", default=0, type=int)) events: list[dict] = [] seen = 0 - with file_path.open('r', encoding='utf-8', errors='replace') as fh: + with file_path.open("r", encoding="utf-8", errors="replace") as fh: for idx, line in enumerate(fh): if idx < offset: continue @@ -144,16 +154,18 @@ def get_recording_events(session_id: str): except json.JSONDecodeError: continue - return api_success(data={ - 'recording': { - 'id': rec['id'], - 'mode': rec['mode'], - 'started_at': rec['started_at'], - 'stopped_at': rec['stopped_at'], - 'event_count': rec['event_count'], - }, - 'offset': offset, - 'limit': limit, - 'returned': len(events), - 'events': events, - }) + return api_success( + data={ + "recording": { + "id": rec["id"], + "mode": rec["mode"], + "started_at": rec["started_at"], + "stopped_at": rec["stopped_at"], + "event_count": rec["event_count"], + }, + "offset": offset, + "limit": limit, + "returned": len(events), + "events": events, + } + ) diff --git a/routes/rtlamr.py b/routes/rtlamr.py index 05a9fda..32a73aa 100644 --- a/routes/rtlamr.py +++ b/routes/rtlamr.py @@ -20,7 +20,7 @@ from utils.responses import api_error from utils.sse import sse_stream_fanout from utils.validation import validate_device_index, validate_frequency, validate_gain, validate_ppm -rtlamr_bp = Blueprint('rtlamr', __name__) +rtlamr_bp = Blueprint("rtlamr", __name__) # Store rtl_tcp process separately rtl_tcp_process = None @@ -28,39 +28,39 @@ rtl_tcp_lock = threading.Lock() # Track which device is being used rtlamr_active_device: int | None = None -rtlamr_active_sdr_type: str = 'rtlsdr' +rtlamr_active_sdr_type: str = "rtlsdr" def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None: """Stream rtlamr JSON output to queue.""" try: - app_module.rtlamr_queue.put({'type': 'status', 'text': 'started'}) + app_module.rtlamr_queue.put({"type": "status", "text": "started"}) - for line in iter(process.stdout.readline, b''): - line = line.decode('utf-8', errors='replace').strip() + for line in iter(process.stdout.readline, b""): + line = line.decode("utf-8", errors="replace").strip() if not line: continue try: # rtlamr outputs JSON objects, one per line data = json.loads(line) - data['type'] = 'rtlamr' + data["type"] = "rtlamr" app_module.rtlamr_queue.put(data) # Log if enabled if app_module.logging_enabled: try: - with open(app_module.log_file_path, 'a') as f: - timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + with open(app_module.log_file_path, "a") as f: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") f.write(f"{timestamp} | RTLAMR | {json.dumps(data)}\n") except Exception: pass except json.JSONDecodeError: # Not JSON, send as raw - app_module.rtlamr_queue.put({'type': 'raw', 'text': line}) + app_module.rtlamr_queue.put({"type": "raw", "text": line}) except Exception as e: - app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)}) + app_module.rtlamr_queue.put({"type": "error", "text": str(e)}) finally: global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type # Ensure rtlamr process is terminated @@ -82,7 +82,7 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None: rtl_tcp_process.kill() unregister_process(rtl_tcp_process) rtl_tcp_process = None - app_module.rtlamr_queue.put({'type': 'status', 'text': 'stopped'}) + app_module.rtlamr_queue.put({"type": "status", "text": "stopped"}) with app_module.rtlamr_lock: app_module.rtlamr_process = None # Release SDR device @@ -91,34 +91,37 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None: rtlamr_active_device = None -@rtlamr_bp.route('/start_rtlamr', methods=['POST']) +@rtlamr_bp.route("/start_rtlamr", methods=["POST"]) def start_rtlamr() -> Response: global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type with app_module.rtlamr_lock: if app_module.rtlamr_process: - return api_error('RTLAMR already running', 409) + return api_error("RTLAMR already running", 409) data = request.json or {} - sdr_type_str = data.get('sdr_type', 'rtlsdr') + sdr_type_str = data.get("sdr_type", "rtlsdr") - if sdr_type_str != 'rtlsdr': - return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400) + if sdr_type_str != "rtlsdr": + return api_error( + f"{sdr_type_str.replace('_', ' ').title()} is not yet supported for this mode. Please use an RTL-SDR device.", + 400, + ) # Validate inputs try: - freq = validate_frequency(data.get('frequency', '912.0')) - gain = validate_gain(data.get('gain', '0')) - ppm = validate_ppm(data.get('ppm', '0')) - device = validate_device_index(data.get('device', '0')) + freq = validate_frequency(data.get("frequency", "912.0")) + gain = validate_gain(data.get("gain", "0")) + ppm = validate_ppm(data.get("ppm", "0")) + device = validate_device_index(data.get("device", "0")) except ValueError as e: return api_error(str(e), 400) # Check if device is available device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str) + error = app_module.claim_sdr_device(device_int, "rtlamr", sdr_type_str) if error: - return api_error(error, 409, error_type='DEVICE_BUSY') + return api_error(error, 409, error_type="DEVICE_BUSY") rtlamr_active_device = device_int rtlamr_active_sdr_type = sdr_type_str @@ -131,83 +134,75 @@ def start_rtlamr() -> Response: break # Get message type (default to scm) - msgtype = data.get('msgtype', 'scm') - output_format = data.get('format', 'json') + msgtype = data.get("msgtype", "scm") + output_format = data.get("format", "json") # Start rtl_tcp first rtl_tcp_just_started = False - rtl_tcp_cmd_str = '' + rtl_tcp_cmd_str = "" with rtl_tcp_lock: if not rtl_tcp_process: logger.info("Starting rtl_tcp server...") try: - rtl_tcp_cmd = ['rtl_tcp', '-a', '0.0.0.0'] + rtl_tcp_cmd = ["rtl_tcp", "-a", "0.0.0.0"] # Add device index if not 0 - if device and device != '0': - rtl_tcp_cmd.extend(['-d', str(device)]) + if device and device != "0": + rtl_tcp_cmd.extend(["-d", str(device)]) # Add gain if not auto - if gain and gain != '0': - rtl_tcp_cmd.extend(['-g', str(gain)]) + if gain and gain != "0": + rtl_tcp_cmd.extend(["-g", str(gain)]) # Add PPM correction if not 0 - if ppm and ppm != '0': - rtl_tcp_cmd.extend(['-p', str(ppm)]) + if ppm and ppm != "0": + rtl_tcp_cmd.extend(["-p", str(ppm)]) - rtl_tcp_process = subprocess.Popen( - rtl_tcp_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + rtl_tcp_process = subprocess.Popen(rtl_tcp_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) register_process(rtl_tcp_process) rtl_tcp_just_started = True - rtl_tcp_cmd_str = ' '.join(rtl_tcp_cmd) + rtl_tcp_cmd_str = " ".join(rtl_tcp_cmd) except Exception as e: logger.error(f"Failed to start rtl_tcp: {e}") # Release SDR device on rtl_tcp failure if rtlamr_active_device is not None: app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type) rtlamr_active_device = None - return api_error(f'Failed to start rtl_tcp: {e}', 500) + return api_error(f"Failed to start rtl_tcp: {e}", 500) # Wait for rtl_tcp to start outside lock if rtl_tcp_just_started: time.sleep(3) logger.info(f"rtl_tcp started: {rtl_tcp_cmd_str}") - app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {rtl_tcp_cmd_str}'}) + app_module.rtlamr_queue.put({"type": "info", "text": f"rtl_tcp: {rtl_tcp_cmd_str}"}) # Build rtlamr command cmd = [ - 'rtlamr', - '-server=127.0.0.1:1234', - f'-msgtype={msgtype}', - f'-format={output_format}', - f'-centerfreq={int(float(freq) * 1e6)}' + "rtlamr", + "-server=127.0.0.1:1234", + f"-msgtype={msgtype}", + f"-format={output_format}", + f"-centerfreq={int(float(freq) * 1e6)}", ] # Add filter options if provided - filterid = data.get('filterid') + filterid = data.get("filterid") if filterid: - cmd.append(f'-filterid={filterid}') + cmd.append(f"-filterid={filterid}") - filtertype = data.get('filtertype') + filtertype = data.get("filtertype") if filtertype: - cmd.append(f'-filtertype={filtertype}') + cmd.append(f"-filtertype={filtertype}") # Unique messages only - if data.get('unique', True): - cmd.append('-unique=true') + if data.get("unique", True): + cmd.append("-unique=true") - full_cmd = ' '.join(cmd) + full_cmd = " ".join(cmd) logger.info(f"Running: {full_cmd}") try: - app_module.rtlamr_process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + app_module.rtlamr_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) register_process(app_module.rtlamr_process) # Start output thread @@ -218,18 +213,18 @@ def start_rtlamr() -> Response: # Monitor stderr def monitor_stderr(): for line in app_module.rtlamr_process.stderr: - err = line.decode('utf-8', errors='replace').strip() + err = line.decode("utf-8", errors="replace").strip() if err: logger.debug(f"[rtlamr] {err}") - app_module.rtlamr_queue.put({'type': 'info', 'text': f'[rtlamr] {err}'}) + app_module.rtlamr_queue.put({"type": "info", "text": f"[rtlamr] {err}"}) stderr_thread = threading.Thread(target=monitor_stderr) stderr_thread.daemon = True stderr_thread.start() - app_module.rtlamr_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'}) + app_module.rtlamr_queue.put({"type": "info", "text": f"Command: {full_cmd}"}) - return jsonify({'status': 'started', 'command': full_cmd}) + return jsonify({"status": "started", "command": full_cmd}) except FileNotFoundError: # If rtlamr fails, clean up rtl_tcp and release device @@ -241,7 +236,7 @@ def start_rtlamr() -> Response: if rtlamr_active_device is not None: app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type) rtlamr_active_device = None - return api_error('rtlamr not found. Install from https://github.com/bemasher/rtlamr') + return api_error("rtlamr not found. Install from https://github.com/bemasher/rtlamr") except Exception as e: # If rtlamr fails, clean up rtl_tcp and release device with rtl_tcp_lock: @@ -255,7 +250,7 @@ def start_rtlamr() -> Response: return api_error(str(e)) -@rtlamr_bp.route('/stop_rtlamr', methods=['POST']) +@rtlamr_bp.route("/stop_rtlamr", methods=["POST"]) def stop_rtlamr() -> Response: global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type @@ -293,25 +288,25 @@ def stop_rtlamr() -> Response: app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type) rtlamr_active_device = None - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@rtlamr_bp.route('/stream_rtlamr') +@rtlamr_bp.route("/stream_rtlamr") def stream_rtlamr() -> Response: def _on_msg(msg: dict[str, Any]) -> None: - process_event('rtlamr', msg, msg.get('type')) + process_event("rtlamr", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=app_module.rtlamr_queue, - channel_key='rtlamr', + channel_key="rtlamr", timeout=1.0, keepalive_interval=30.0, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response diff --git a/routes/sensor.py b/routes/sensor.py index eaa5432..dda9030 100644 --- a/routes/sensor.py +++ b/routes/sensor.py @@ -30,7 +30,7 @@ from utils.validation import ( validate_rtl_tcp_port, ) -sensor_bp = Blueprint('sensor', __name__) +sensor_bp = Blueprint("sensor", __name__) # Track which device is being used sensor_active_device: int | None = None @@ -64,7 +64,7 @@ def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 2 noise_wobble = math.sin((2.0 * math.pi * (cycles * 7.0) * t) + (phase * 2.1)) sample = amplitude * (base + (harmonic * overtone) + (hiss * noise_wobble)) - sample /= (1.0 + harmonic + hiss) + sample /= 1.0 + harmonic + hiss packed = int(round(max(-1.0, min(1.0, sample)) * 127.0)) waveform.append(max(-127, min(127, packed))) @@ -74,23 +74,23 @@ def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 2 def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: """Stream rtl_433 JSON output to queue.""" try: - app_module.sensor_queue.put({'type': 'status', 'text': 'started'}) + app_module.sensor_queue.put({"type": "status", "text": "started"}) - for line in iter(process.stdout.readline, b''): - line = line.decode('utf-8', errors='replace').strip() + for line in iter(process.stdout.readline, b""): + line = line.decode("utf-8", errors="replace").strip() if not line: continue try: # rtl_433 outputs JSON objects, one per line data = json.loads(line) - data['type'] = 'sensor' + data["type"] = "sensor" app_module.sensor_queue.put(data) # Track RSSI history per device - _model = data.get('model', '') - _dev_id = data.get('id', '') - _rssi_val = data.get('rssi') + _model = data.get("model", "") + _dev_id = data.get("id", "") + _rssi_val = data.get("rssi") if _rssi_val is not None and _model: _hist_key = f"{_model}_{_dev_id}" hist = sensor_rssi_history.setdefault(_hist_key, []) @@ -99,42 +99,44 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: del hist[: len(hist) - _MAX_RSSI_HISTORY] # Push scope event when signal level data is present - rssi = data.get('rssi') - snr = data.get('snr') - noise = data.get('noise') + rssi = data.get("rssi") + snr = data.get("snr") + noise = data.get("noise") if rssi is not None or snr is not None: try: rssi_value = float(rssi) if rssi is not None else 0.0 snr_value = float(snr) if snr is not None else 0.0 noise_value = float(noise) if noise is not None else 0.0 - app_module.sensor_queue.put_nowait({ - 'type': 'scope', - 'rssi': rssi_value, - 'snr': snr_value, - 'noise': noise_value, - 'waveform': _build_scope_waveform( - rssi=rssi_value, - snr=snr_value, - noise=noise_value, - ), - }) + app_module.sensor_queue.put_nowait( + { + "type": "scope", + "rssi": rssi_value, + "snr": snr_value, + "noise": noise_value, + "waveform": _build_scope_waveform( + rssi=rssi_value, + snr=snr_value, + noise=noise_value, + ), + } + ) except (TypeError, ValueError, queue.Full): pass # Log if enabled if app_module.logging_enabled: try: - with open(app_module.log_file_path, 'a') as f: - timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + with open(app_module.log_file_path, "a") as f: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n") except Exception: pass except json.JSONDecodeError: # Not JSON, send as raw - app_module.sensor_queue.put({'type': 'raw', 'text': line}) + app_module.sensor_queue.put({"type": "raw", "text": line}) except Exception as e: - app_module.sensor_queue.put({'type': 'error', 'text': str(e)}) + app_module.sensor_queue.put({"type": "error", "text": str(e)}) finally: global sensor_active_device, sensor_active_sdr_type # Ensure process is terminated @@ -145,56 +147,56 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: with contextlib.suppress(Exception): process.kill() unregister_process(process) - app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'}) + app_module.sensor_queue.put({"type": "status", "text": "stopped"}) with app_module.sensor_lock: app_module.sensor_process = None # Release SDR device if sensor_active_device is not None: - app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or "rtlsdr") sensor_active_device = None sensor_active_sdr_type = None -@sensor_bp.route('/sensor/status') +@sensor_bp.route("/sensor/status") def sensor_status() -> Response: """Check if sensor decoder is currently running.""" with app_module.sensor_lock: running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None - return jsonify({'running': running}) + return jsonify({"running": running}) -@sensor_bp.route('/start_sensor', methods=['POST']) +@sensor_bp.route("/start_sensor", methods=["POST"]) def start_sensor() -> Response: global sensor_active_device, sensor_active_sdr_type with app_module.sensor_lock: if app_module.sensor_process: - return api_error('Sensor already running', 409) + return api_error("Sensor already running", 409) data = request.json or {} # Validate inputs try: - freq = validate_frequency(data.get('frequency', '433.92')) - gain = validate_gain(data.get('gain', '0')) - ppm = validate_ppm(data.get('ppm', '0')) - device = validate_device_index(data.get('device', '0')) + freq = validate_frequency(data.get("frequency", "433.92")) + gain = validate_gain(data.get("gain", "0")) + ppm = validate_ppm(data.get("ppm", "0")) + device = validate_device_index(data.get("device", "0")) except ValueError as e: return api_error(str(e), 400) # Check for rtl_tcp (remote SDR) connection - rtl_tcp_host = data.get('rtl_tcp_host') - rtl_tcp_port = data.get('rtl_tcp_port', 1234) + rtl_tcp_host = data.get("rtl_tcp_host") + rtl_tcp_port = data.get("rtl_tcp_port", 1234) # Get SDR type early so we can pass it to claim/release - sdr_type_str = data.get('sdr_type', 'rtlsdr') + sdr_type_str = data.get("sdr_type", "rtlsdr") # Claim local device if not using remote rtl_tcp if not rtl_tcp_host: device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str) + error = app_module.claim_sdr_device(device_int, "sensor", sdr_type_str) if error: - return api_error(error, 409, error_type='DEVICE_BUSY') + return api_error(error, 409, error_type="DEVICE_BUSY") sensor_active_device = device_int sensor_active_sdr_type = sdr_type_str @@ -228,28 +230,24 @@ def start_sensor() -> Response: builder = SDRFactory.get_builder(sdr_device.sdr_type) # Build ISM band decoder command - bias_t = data.get('bias_t', False) + bias_t = data.get("bias_t", False) cmd = builder.build_ism_command( device=sdr_device, frequency_mhz=freq, gain=float(gain) if gain and gain != 0 else None, ppm=int(ppm) if ppm and ppm != 0 else None, - bias_t=bias_t + bias_t=bias_t, ) - full_cmd = ' '.join(cmd) + full_cmd = " ".join(cmd) logger.info(f"Running: {full_cmd}") # Add signal level metadata so the frontend scope can display RSSI/SNR # Disable stats reporting to suppress "row count limit 50 reached" warnings - cmd.extend(['-M', 'level', '-M', 'stats:0']) + cmd.extend(["-M", "level", "-M", "stats:0"]) try: - app_module.sensor_process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + app_module.sensor_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) register_process(app_module.sensor_process) # Start output thread @@ -260,42 +258,42 @@ def start_sensor() -> Response: # Monitor stderr # Filter noisy rtl_433 diagnostics that aren't useful to display _stderr_noise = ( - 'bitbuffer_add_bit', - 'row count limit', + "bitbuffer_add_bit", + "row count limit", ) def monitor_stderr(): for line in app_module.sensor_process.stderr: - err = line.decode('utf-8', errors='replace').strip() + err = line.decode("utf-8", errors="replace").strip() if err and not any(noise in err for noise in _stderr_noise): logger.debug(f"[rtl_433] {err}") - app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'}) + app_module.sensor_queue.put({"type": "info", "text": f"[rtl_433] {err}"}) stderr_thread = threading.Thread(target=monitor_stderr) stderr_thread.daemon = True stderr_thread.start() - app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'}) + app_module.sensor_queue.put({"type": "info", "text": f"Command: {full_cmd}"}) - return jsonify({'status': 'started', 'command': full_cmd}) + return jsonify({"status": "started", "command": full_cmd}) except FileNotFoundError: # Release device on failure if sensor_active_device is not None: - app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or "rtlsdr") sensor_active_device = None sensor_active_sdr_type = None - return api_error('rtl_433 not found. Install with: brew install rtl_433') + return api_error("rtl_433 not found. Install with: brew install rtl_433") except Exception as e: # Release device on failure if sensor_active_device is not None: - app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or "rtlsdr") sensor_active_device = None sensor_active_sdr_type = None return api_error(str(e)) -@sensor_bp.route('/stop_sensor', methods=['POST']) +@sensor_bp.route("/stop_sensor", methods=["POST"]) def stop_sensor() -> Response: global sensor_active_device, sensor_active_sdr_type @@ -310,40 +308,40 @@ def stop_sensor() -> Response: # Release device from registry if sensor_active_device is not None: - app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or "rtlsdr") sensor_active_device = None sensor_active_sdr_type = None - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) - return jsonify({'status': 'not_running'}) + return jsonify({"status": "not_running"}) -@sensor_bp.route('/stream_sensor') +@sensor_bp.route("/stream_sensor") def stream_sensor() -> Response: def _on_msg(msg: dict[str, Any]) -> None: - process_event('sensor', msg, msg.get('type')) + process_event("sensor", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=app_module.sensor_queue, - channel_key='sensor', + channel_key="sensor", timeout=1.0, keepalive_interval=30.0, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response -@sensor_bp.route('/sensor/rssi_history') +@sensor_bp.route("/sensor/rssi_history") def get_rssi_history() -> Response: """Return RSSI history for all tracked sensor devices.""" result = {} for key, entries in sensor_rssi_history.items(): - result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries] - return api_success(data={'devices': result}) + result[key] = [{"t": round(t, 1), "rssi": rssi} for t, rssi in entries] + return api_success(data={"devices": result}) diff --git a/routes/settings.py b/routes/settings.py index 03c82e0..d88d010 100644 --- a/routes/settings.py +++ b/routes/settings.py @@ -1,229 +1,231 @@ """Settings management routes.""" -from __future__ import annotations - -import contextlib -import os -import re -import subprocess -import sys -import threading -from pathlib import Path - -from flask import Blueprint, Response, jsonify, request - -from utils.database import ( +from __future__ import annotations + +import contextlib +import os +import re +import subprocess +import sys +import threading +from pathlib import Path + +from flask import Blueprint, Response, jsonify, request + +from utils.database import ( delete_setting, get_all_settings, get_correlations, get_setting, set_setting, -) -from utils.logging import get_logger -from utils.responses import api_error, api_success -from utils.validation import validate_latitude, validate_longitude - -logger = get_logger('intercept.settings') - -settings_bp = Blueprint('settings', __name__, url_prefix='/settings') -_env_lock = threading.Lock() - - -def _get_env_file_path() -> Path: - """Return the project .env path.""" - return Path(__file__).resolve().parent.parent / '.env' - - -def _write_env_value(key: str, value: str, env_path: Path | None = None) -> None: - """Create or update a single key in the project .env file.""" - path = env_path or _get_env_file_path() - path.parent.mkdir(parents=True, exist_ok=True) - - with _env_lock: - lines = path.read_text().splitlines() if path.exists() else [ - '# INTERCEPT environment configuration', - '', - ] - - pattern = re.compile(rf'^\s*{re.escape(key)}=') - updated = False - new_lines: list[str] = [] - for line in lines: - if pattern.match(line): - if not updated: - new_lines.append(f'{key}={value}') - updated = True - continue - new_lines.append(line) - - if not updated: - if new_lines and new_lines[-1] != '': - new_lines.append('') - new_lines.append(f'{key}={value}') - - path.write_text('\n'.join(new_lines).rstrip('\n') + '\n') - - sudo_uid = os.environ.get('INTERCEPT_SUDO_UID') - sudo_gid = os.environ.get('INTERCEPT_SUDO_GID') - if os.geteuid() == 0 and sudo_uid and sudo_gid: - with contextlib.suppress(OSError, ValueError): - os.chown(path, int(sudo_uid), int(sudo_gid)) - - -def _apply_runtime_observer_defaults(lat: float, lon: float) -> None: - """Update in-process defaults so refreshed pages use the saved location.""" - lat_str = str(lat) - lon_str = str(lon) - os.environ['INTERCEPT_DEFAULT_LAT'] = lat_str - os.environ['INTERCEPT_DEFAULT_LON'] = lon_str - - import config - - config.DEFAULT_LATITUDE = lat - config.DEFAULT_LONGITUDE = lon - - with contextlib.suppress(Exception): - import app as app_module - app_module.DEFAULT_LATITUDE = lat - app_module.DEFAULT_LONGITUDE = lon - - with contextlib.suppress(Exception): - from routes import adsb as adsb_routes - adsb_routes.DEFAULT_LATITUDE = lat - adsb_routes.DEFAULT_LONGITUDE = lon - - with contextlib.suppress(Exception): - from routes import ais as ais_routes - ais_routes.DEFAULT_LATITUDE = lat - ais_routes.DEFAULT_LONGITUDE = lon +) +from utils.logging import get_logger +from utils.responses import api_error, api_success +from utils.validation import validate_latitude, validate_longitude + +logger = get_logger("intercept.settings") + +settings_bp = Blueprint("settings", __name__, url_prefix="/settings") +_env_lock = threading.Lock() -@settings_bp.route('', methods=['GET']) +def _get_env_file_path() -> Path: + """Return the project .env path.""" + return Path(__file__).resolve().parent.parent / ".env" + + +def _write_env_value(key: str, value: str, env_path: Path | None = None) -> None: + """Create or update a single key in the project .env file.""" + path = env_path or _get_env_file_path() + path.parent.mkdir(parents=True, exist_ok=True) + + with _env_lock: + lines = ( + path.read_text().splitlines() + if path.exists() + else [ + "# INTERCEPT environment configuration", + "", + ] + ) + + pattern = re.compile(rf"^\s*{re.escape(key)}=") + updated = False + new_lines: list[str] = [] + for line in lines: + if pattern.match(line): + if not updated: + new_lines.append(f"{key}={value}") + updated = True + continue + new_lines.append(line) + + if not updated: + if new_lines and new_lines[-1] != "": + new_lines.append("") + new_lines.append(f"{key}={value}") + + path.write_text("\n".join(new_lines).rstrip("\n") + "\n") + + sudo_uid = os.environ.get("INTERCEPT_SUDO_UID") + sudo_gid = os.environ.get("INTERCEPT_SUDO_GID") + if os.geteuid() == 0 and sudo_uid and sudo_gid: + with contextlib.suppress(OSError, ValueError): + os.chown(path, int(sudo_uid), int(sudo_gid)) + + +def _apply_runtime_observer_defaults(lat: float, lon: float) -> None: + """Update in-process defaults so refreshed pages use the saved location.""" + lat_str = str(lat) + lon_str = str(lon) + os.environ["INTERCEPT_DEFAULT_LAT"] = lat_str + os.environ["INTERCEPT_DEFAULT_LON"] = lon_str + + import config + + config.DEFAULT_LATITUDE = lat + config.DEFAULT_LONGITUDE = lon + + with contextlib.suppress(Exception): + import app as app_module + + app_module.DEFAULT_LATITUDE = lat + app_module.DEFAULT_LONGITUDE = lon + + with contextlib.suppress(Exception): + from routes import adsb as adsb_routes + + adsb_routes.DEFAULT_LATITUDE = lat + adsb_routes.DEFAULT_LONGITUDE = lon + + with contextlib.suppress(Exception): + from routes import ais as ais_routes + + ais_routes.DEFAULT_LATITUDE = lat + ais_routes.DEFAULT_LONGITUDE = lon + + +@settings_bp.route("", methods=["GET"]) def get_settings() -> Response: """Get all settings.""" try: settings = get_all_settings() - return api_success(data={'settings': settings}) + return api_success(data={"settings": settings}) except Exception as e: logger.error(f"Error getting settings: {e}") return api_error(str(e), 500) -@settings_bp.route('', methods=['POST']) +@settings_bp.route("", methods=["POST"]) def save_settings() -> Response: """Save one or more settings.""" data = request.json or {} if not data: - return api_error('No settings provided', 400) + return api_error("No settings provided", 400) try: saved = [] for key, value in data.items(): # Validate key (alphanumeric, underscores, dots, hyphens) - if not key or not all(c.isalnum() or c in '_.-' for c in key): + if not key or not all(c.isalnum() or c in "_.-" for c in key): continue set_setting(key, value) saved.append(key) - return api_success(data={'saved': saved}) + return api_success(data={"saved": saved}) except Exception as e: logger.error(f"Error saving settings: {e}") return api_error(str(e), 500) -@settings_bp.route('/', methods=['GET']) +@settings_bp.route("/", methods=["GET"]) def get_single_setting(key: str) -> Response: """Get a single setting by key.""" try: value = get_setting(key) if value is None: - return jsonify({ - 'status': 'not_found', - 'key': key - }), 404 + return jsonify({"status": "not_found", "key": key}), 404 - return api_success(data={'key': key, 'value': value}) + return api_success(data={"key": key, "value": value}) except Exception as e: logger.error(f"Error getting setting {key}: {e}") return api_error(str(e), 500) -@settings_bp.route('/', methods=['PUT']) +@settings_bp.route("/", methods=["PUT"]) def update_single_setting(key: str) -> Response: """Update a single setting.""" data = request.json or {} - value = data.get('value') + value = data.get("value") - if value is None and 'value' not in data: - return api_error('Value is required', 400) + if value is None and "value" not in data: + return api_error("Value is required", 400) try: set_setting(key, value) - return api_success(data={'key': key, 'value': value}) + return api_success(data={"key": key, "value": value}) except Exception as e: logger.error(f"Error updating setting {key}: {e}") return api_error(str(e), 500) -@settings_bp.route('/', methods=['DELETE']) -def delete_single_setting(key: str) -> Response: +@settings_bp.route("/", methods=["DELETE"]) +def delete_single_setting(key: str) -> Response: """Delete a setting.""" try: deleted = delete_setting(key) if deleted: - return api_success(data={'key': key, 'deleted': True}) + return api_success(data={"key": key, "deleted": True}) else: - return jsonify({ - 'status': 'not_found', - 'key': key - }), 404 + return jsonify({"status": "not_found", "key": key}), 404 except Exception as e: logger.error(f"Error deleting setting {key}: {e}") - return api_error(str(e), 500) - - -@settings_bp.route('/observer-location', methods=['POST']) -def save_observer_location() -> Response: - """Persist observer location to .env and refresh in-process defaults.""" - data = request.json or {} - - try: - lat = validate_latitude(data.get('lat')) - lon = validate_longitude(data.get('lon')) - except ValueError as exc: - return api_error(str(exc), 400) - - try: - _write_env_value('INTERCEPT_DEFAULT_LAT', str(lat)) - _write_env_value('INTERCEPT_DEFAULT_LON', str(lon)) - _apply_runtime_observer_defaults(lat, lon) - return api_success( - data={ - 'lat': lat, - 'lon': lon, - 'saved': ['INTERCEPT_DEFAULT_LAT', 'INTERCEPT_DEFAULT_LON'], - }, - message='Observer location saved to .env', - ) - except Exception as exc: - logger.error(f'Error saving observer location to .env: {exc}') - return api_error(str(exc), 500) + return api_error(str(e), 500) + + +@settings_bp.route("/observer-location", methods=["POST"]) +def save_observer_location() -> Response: + """Persist observer location to .env and refresh in-process defaults.""" + data = request.json or {} + + try: + lat = validate_latitude(data.get("lat")) + lon = validate_longitude(data.get("lon")) + except ValueError as exc: + return api_error(str(exc), 400) + + try: + _write_env_value("INTERCEPT_DEFAULT_LAT", str(lat)) + _write_env_value("INTERCEPT_DEFAULT_LON", str(lon)) + _apply_runtime_observer_defaults(lat, lon) + return api_success( + data={ + "lat": lat, + "lon": lon, + "saved": ["INTERCEPT_DEFAULT_LAT", "INTERCEPT_DEFAULT_LON"], + }, + message="Observer location saved to .env", + ) + except Exception as exc: + logger.error(f"Error saving observer location to .env: {exc}") + return api_error(str(exc), 500) # ============================================================================= # Device Correlation Endpoints # ============================================================================= -@settings_bp.route('/correlations', methods=['GET']) + +@settings_bp.route("/correlations", methods=["GET"]) def get_device_correlations() -> Response: """Get device correlations between WiFi and Bluetooth.""" - min_confidence = request.args.get('min_confidence', 0.5, type=float) + min_confidence = request.args.get("min_confidence", 0.5, type=float) try: correlations = get_correlations(min_confidence) - return api_success(data={'correlations': correlations}) + return api_success(data={"correlations": correlations}) except Exception as e: logger.error(f"Error getting correlations: {e}") return api_error(str(e), 500) @@ -233,25 +235,27 @@ def get_device_correlations() -> Response: # RTL-SDR DVB Driver Management # ============================================================================= -DVB_MODULES = ['dvb_usb_rtl28xxu', 'rtl2832_sdr', 'rtl2832', 'rtl2830', 'r820t'] -BLACKLIST_FILE = '/etc/modprobe.d/blacklist-rtlsdr.conf' +DVB_MODULES = ["dvb_usb_rtl28xxu", "rtl2832_sdr", "rtl2832", "rtl2830", "r820t"] +BLACKLIST_FILE = "/etc/modprobe.d/blacklist-rtlsdr.conf" -@settings_bp.route('/rtlsdr/driver-status', methods=['GET']) +@settings_bp.route("/rtlsdr/driver-status", methods=["GET"]) def check_dvb_driver_status() -> Response: """Check if DVB kernel drivers are loaded and blocking RTL-SDR devices.""" - if sys.platform != 'linux': - return jsonify({ - 'status': 'success', - 'platform': sys.platform, - 'issue_detected': False, - 'message': 'DVB driver conflict only affects Linux systems' - }) + if sys.platform != "linux": + return jsonify( + { + "status": "success", + "platform": sys.platform, + "issue_detected": False, + "message": "DVB driver conflict only affects Linux systems", + } + ) # Check which DVB modules are currently loaded loaded_modules = [] try: - result = subprocess.run(['lsmod'], capture_output=True, text=True, timeout=5) + result = subprocess.run(["lsmod"], capture_output=True, text=True, timeout=5) lsmod_output = result.stdout for mod in DVB_MODULES: if mod in lsmod_output: @@ -267,32 +271,39 @@ def check_dvb_driver_status() -> Response: if blacklist_exists: try: with open(BLACKLIST_FILE) as f: - blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith('#')] + blacklist_contents = [line.strip() for line in f if line.strip() and not line.startswith("#")] except Exception: pass issue_detected = len(loaded_modules) > 0 - return jsonify({ - 'status': 'success', - 'platform': 'linux', - 'issue_detected': issue_detected, - 'loaded_modules': loaded_modules, - 'blacklist_file_exists': blacklist_exists, - 'blacklist_contents': blacklist_contents, - 'message': 'DVB drivers are claiming RTL-SDR devices' if issue_detected else 'No DVB driver conflict detected' - }) + return jsonify( + { + "status": "success", + "platform": "linux", + "issue_detected": issue_detected, + "loaded_modules": loaded_modules, + "blacklist_file_exists": blacklist_exists, + "blacklist_contents": blacklist_contents, + "message": "DVB drivers are claiming RTL-SDR devices" + if issue_detected + else "No DVB driver conflict detected", + } + ) -@settings_bp.route('/rtlsdr/blacklist-drivers', methods=['POST']) +@settings_bp.route("/rtlsdr/blacklist-drivers", methods=["POST"]) def blacklist_dvb_drivers() -> Response: """Blacklist DVB kernel drivers to prevent them from claiming RTL-SDR devices.""" - if sys.platform != 'linux': - return api_error('This feature is only available on Linux', 400) + if sys.platform != "linux": + return api_error("This feature is only available on Linux", 400) # Check if we have permission (need to be running as root or with sudo) if os.geteuid() != 0: - return api_error('Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t', 403) + return api_error( + "Root privileges required. Run the app with sudo or manually run: sudo modprobe -r dvb_usb_rtl28xxu rtl2832_sdr rtl2832 r820t", + 403, + ) errors = [] successes = [] @@ -307,37 +318,36 @@ blacklist rtl2832 blacklist rtl2830 blacklist r820t """ - with open(BLACKLIST_FILE, 'w') as f: + with open(BLACKLIST_FILE, "w") as f: f.write(blacklist_content) - successes.append(f'Created {BLACKLIST_FILE}') + successes.append(f"Created {BLACKLIST_FILE}") except Exception as e: - errors.append(f'Failed to create blacklist file: {e}') + errors.append(f"Failed to create blacklist file: {e}") # Unload the modules for mod in DVB_MODULES: try: - result = subprocess.run( - ['modprobe', '-r', mod], - capture_output=True, - text=True, - timeout=10 - ) + result = subprocess.run(["modprobe", "-r", mod], capture_output=True, text=True, timeout=10) if result.returncode == 0: - successes.append(f'Unloaded module: {mod}') + successes.append(f"Unloaded module: {mod}") # returncode != 0 is OK - module might not be loaded except Exception as e: logger.warning(f"Could not unload {mod}: {e}") if errors: - return jsonify({ - 'status': 'partial', - 'message': 'Some operations failed. Please unplug and replug your RTL-SDR device.', - 'successes': successes, - 'errors': errors - }) + return jsonify( + { + "status": "partial", + "message": "Some operations failed. Please unplug and replug your RTL-SDR device.", + "successes": successes, + "errors": errors, + } + ) - return jsonify({ - 'status': 'success', - 'message': 'DVB drivers blacklisted. Please unplug and replug your RTL-SDR device.', - 'successes': successes - }) + return jsonify( + { + "status": "success", + "message": "DVB drivers blacklisted. Please unplug and replug your RTL-SDR device.", + "successes": successes, + } + ) diff --git a/routes/signalid.py b/routes/signalid.py index 48bd2b3..4d3b71a 100644 --- a/routes/signalid.py +++ b/routes/signalid.py @@ -15,12 +15,12 @@ from utils.logging import get_logger from utils.responses import api_error from utils.signal_db import match_signals -logger = get_logger('intercept.signalid') +logger = get_logger("intercept.signalid") -signalid_bp = Blueprint('signalid', __name__, url_prefix='/signalid') +signalid_bp = Blueprint("signalid", __name__, url_prefix="/signalid") -SIGID_API_URL = 'https://www.sigidwiki.com/api.php' -SIGID_USER_AGENT = 'INTERCEPT-SignalID/1.0' +SIGID_API_URL = "https://www.sigidwiki.com/api.php" +SIGID_USER_AGENT = "INTERCEPT-SignalID/1.0" SIGID_TIMEOUT_SECONDS = 12 SIGID_CACHE_TTL_SECONDS = 600 @@ -31,52 +31,56 @@ def _cache_get(key: str) -> Any | None: entry = _cache.get(key) if not entry: return None - if time.time() >= entry['expires']: + if time.time() >= entry["expires"]: _cache.pop(key, None) return None - return entry['data'] + return entry["data"] def _cache_set(key: str, data: Any, ttl_seconds: int = SIGID_CACHE_TTL_SECONDS) -> None: _cache[key] = { - 'data': data, - 'expires': time.time() + ttl_seconds, + "data": data, + "expires": time.time() + ttl_seconds, } def _fetch_api_json(params: dict[str, str]) -> dict[str, Any] | None: query = urllib.parse.urlencode(params, doseq=True) - url = f'{SIGID_API_URL}?{query}' - req = urllib.request.Request(url, headers={'User-Agent': SIGID_USER_AGENT}) + url = f"{SIGID_API_URL}?{query}" + req = urllib.request.Request(url, headers={"User-Agent": SIGID_USER_AGENT}) try: with urllib.request.urlopen(req, timeout=SIGID_TIMEOUT_SECONDS) as resp: - payload = resp.read().decode('utf-8', errors='replace') + payload = resp.read().decode("utf-8", errors="replace") data = json.loads(payload) except Exception as exc: - logger.warning('SigID API request failed: %s', exc) + logger.warning("SigID API request failed: %s", exc) return None - if isinstance(data, dict) and data.get('error'): - logger.warning('SigID API returned error: %s', data.get('error')) + if isinstance(data, dict) and data.get("error"): + logger.warning("SigID API returned error: %s", data.get("error")) return None return data if isinstance(data, dict) else None def _ask_query(query: str) -> dict[str, Any] | None: - return _fetch_api_json({ - 'action': 'ask', - 'query': query, - 'format': 'json', - }) + return _fetch_api_json( + { + "action": "ask", + "query": query, + "format": "json", + } + ) def _search_query(search_text: str, limit: int) -> dict[str, Any] | None: - return _fetch_api_json({ - 'action': 'query', - 'list': 'search', - 'srsearch': search_text, - 'srlimit': str(limit), - 'format': 'json', - }) + return _fetch_api_json( + { + "action": "query", + "list": "search", + "srsearch": search_text, + "srlimit": str(limit), + "format": "json", + } + ) def _to_float_list(values: Any) -> list[float]: @@ -96,7 +100,7 @@ def _to_text_list(values: Any) -> list[str]: return [] out: list[str] = [] for value in values: - text = str(value or '').strip() + text = str(value or "").strip() if text: out.append(text) return out @@ -105,7 +109,7 @@ def _to_text_list(values: Any) -> list[str]: def _normalize_modes(values: list[str]) -> list[str]: out: list[str] = [] for value in values: - for token in str(value).replace('/', ',').split(','): + for token in str(value).replace("/", ",").split(","): mode = token.strip().upper() if mode and mode not in out: out.append(mode) @@ -113,7 +117,7 @@ def _normalize_modes(values: list[str]) -> list[str]: def _extract_matches_from_ask(data: dict[str, Any]) -> list[dict[str, Any]]: - results = data.get('query', {}).get('results', {}) + results = data.get("query", {}).get("results", {}) if not isinstance(results, dict): return [] @@ -122,23 +126,23 @@ def _extract_matches_from_ask(data: dict[str, Any]) -> list[dict[str, Any]]: if not isinstance(entry, dict): continue - printouts = entry.get('printouts', {}) + printouts = entry.get("printouts", {}) if not isinstance(printouts, dict): printouts = {} - frequencies_hz = _to_float_list(printouts.get('Frequencies')) + frequencies_hz = _to_float_list(printouts.get("Frequencies")) frequencies_mhz = [round(v / 1e6, 6) for v in frequencies_hz if v > 0] - modes = _normalize_modes(_to_text_list(printouts.get('Mode'))) - modulations = _normalize_modes(_to_text_list(printouts.get('Modulation'))) + modes = _normalize_modes(_to_text_list(printouts.get("Mode"))) + modulations = _normalize_modes(_to_text_list(printouts.get("Modulation"))) match = { - 'title': str(entry.get('fulltext') or title), - 'url': str(entry.get('fullurl') or ''), - 'frequencies_mhz': frequencies_mhz, - 'modes': modes, - 'modulations': modulations, - 'source': 'SigID Wiki', + "title": str(entry.get("fulltext") or title), + "url": str(entry.get("fullurl") or ""), + "frequencies_mhz": frequencies_mhz, + "modes": modes, + "modulations": modulations, + "source": "SigID Wiki", } matches.append(match) @@ -155,7 +159,7 @@ def _dedupe_matches(matches: list[dict[str, Any]]) -> list[dict[str, Any]]: # Merge frequencies/modes/modulations from duplicates. existing = deduped[key] - for field in ('frequencies_mhz', 'modes', 'modulations'): + for field in ("frequencies_mhz", "modes", "modulations"): base = existing.get(field, []) extra = match.get(field, []) if not isinstance(base, list): @@ -177,11 +181,11 @@ def _rank_matches( modulation: str, ) -> list[dict[str, Any]]: target_hz = frequency_mhz * 1e6 - wanted_mod = str(modulation or '').strip().upper() + wanted_mod = str(modulation or "").strip().upper() def score(match: dict[str, Any]) -> tuple[int, float, str]: score_value = 0 - freqs_mhz = match.get('frequencies_mhz') or [] + freqs_mhz = match.get("frequencies_mhz") or [] distances_hz: list[float] = [] for f_mhz in freqs_mhz: try: @@ -200,16 +204,16 @@ def _rank_matches( score_value += 40 if wanted_mod: - modes = [str(v).upper() for v in (match.get('modes') or [])] - modulations = [str(v).upper() for v in (match.get('modulations') or [])] + modes = [str(v).upper() for v in (match.get("modes") or [])] + modulations = [str(v).upper() for v in (match.get("modulations") or [])] if wanted_mod in modes: score_value += 25 if wanted_mod in modulations: score_value += 25 - title = str(match.get('title') or '') + title = str(match.get("title") or "") title_lower = title.lower() - if 'unidentified' in title_lower or 'unknown' in title_lower: + if "unidentified" in title_lower or "unknown" in title_lower: score_value -= 10 return (score_value, min_distance_hz, title.lower()) @@ -217,18 +221,18 @@ def _rank_matches( ranked = sorted(matches, key=score, reverse=True) for match in ranked: try: - nearest = min(abs((float(f) * 1e6) - target_hz) for f in (match.get('frequencies_mhz') or [])) - match['distance_hz'] = int(round(nearest)) + nearest = min(abs((float(f) * 1e6) - target_hz) for f in (match.get("frequencies_mhz") or [])) + match["distance_hz"] = int(round(nearest)) except Exception: - match['distance_hz'] = None + match["distance_hz"] = None return ranked def _format_freq_variants_mhz(freq_mhz: float) -> list[str]: variants = [ - f'{freq_mhz:.6f}'.rstrip('0').rstrip('.'), - f'{freq_mhz:.4f}'.rstrip('0').rstrip('.'), - f'{freq_mhz:.3f}'.rstrip('0').rstrip('.'), + f"{freq_mhz:.6f}".rstrip("0").rstrip("."), + f"{freq_mhz:.4f}".rstrip("0").rstrip("."), + f"{freq_mhz:.3f}".rstrip("0").rstrip("."), ] out: list[str] = [] for value in variants: @@ -243,8 +247,8 @@ def _lookup_sigidwiki_matches(frequency_mhz: float, modulation: str, limit: int) for freq_token in _format_freq_variants_mhz(frequency_mhz): query = ( - f'[[Category:Signal]][[Frequencies::{freq_token} MHz]]' - f'|?Frequencies|?Mode|?Modulation|limit={max(10, limit * 2)}' + f"[[Category:Signal]][[Frequencies::{freq_token} MHz]]" + f"|?Frequencies|?Mode|?Modulation|limit={max(10, limit * 2)}" ) exact_queries.append(query) data = _ask_query(query) @@ -256,23 +260,23 @@ def _lookup_sigidwiki_matches(frequency_mhz: float, modulation: str, limit: int) search_used = False if not all_matches: search_used = True - search_terms = [f'{frequency_mhz:.4f} MHz'] + search_terms = [f"{frequency_mhz:.4f} MHz"] if modulation: - search_terms.insert(0, f'{frequency_mhz:.4f} MHz {modulation.upper()}') + search_terms.insert(0, f"{frequency_mhz:.4f} MHz {modulation.upper()}") seen_titles: set[str] = set() for term in search_terms: search_data = _search_query(term, max(5, min(limit * 2, 10))) - search_results = search_data.get('query', {}).get('search', []) if isinstance(search_data, dict) else [] + search_results = search_data.get("query", {}).get("search", []) if isinstance(search_data, dict) else [] if not isinstance(search_results, list) or not search_results: continue for item in search_results: - title = str(item.get('title') or '').strip() + title = str(item.get("title") or "").strip() if not title or title in seen_titles: continue seen_titles.add(title) - page_query = f'[[{title}]]|?Frequencies|?Mode|?Modulation|limit=1' + page_query = f"[[{title}]]|?Frequencies|?Mode|?Modulation|limit=1" page_data = _ask_query(page_query) if page_data: all_matches.extend(_extract_matches_from_ask(page_data)) @@ -284,125 +288,131 @@ def _lookup_sigidwiki_matches(frequency_mhz: float, modulation: str, limit: int) deduped = _dedupe_matches(all_matches) ranked = _rank_matches(deduped, frequency_mhz=frequency_mhz, modulation=modulation) return { - 'matches': ranked[:limit], - 'search_used': search_used, - 'exact_queries': exact_queries, + "matches": ranked[:limit], + "search_used": search_used, + "exact_queries": exact_queries, } -@signalid_bp.route('/sigidwiki', methods=['POST']) +@signalid_bp.route("/sigidwiki", methods=["POST"]) def sigidwiki_lookup() -> Response: """Lookup likely signal types from SigID Wiki by tuned frequency.""" payload = request.get_json(silent=True) or {} - freq_raw = payload.get('frequency_mhz') + freq_raw = payload.get("frequency_mhz") if freq_raw is None: - return api_error('frequency_mhz is required', 400) + return api_error("frequency_mhz is required", 400) try: frequency_mhz = float(freq_raw) except (TypeError, ValueError): - return api_error('Invalid frequency_mhz', 400) + return api_error("Invalid frequency_mhz", 400) if frequency_mhz <= 0: - return api_error('frequency_mhz must be positive', 400) + return api_error("frequency_mhz must be positive", 400) - modulation = str(payload.get('modulation') or '').strip().upper() + modulation = str(payload.get("modulation") or "").strip().upper() if modulation and len(modulation) > 16: modulation = modulation[:16] - limit_raw = payload.get('limit', 8) + limit_raw = payload.get("limit", 8) try: limit = int(limit_raw) except (TypeError, ValueError): limit = 8 limit = max(1, min(limit, 20)) - cache_key = f'{round(frequency_mhz, 6)}|{modulation}|{limit}' + cache_key = f"{round(frequency_mhz, 6)}|{modulation}|{limit}" cached = _cache_get(cache_key) if cached is not None: - return jsonify({ - 'status': 'ok', - 'source': 'sigidwiki', - 'frequency_mhz': round(frequency_mhz, 6), - 'modulation': modulation or None, - 'cached': True, - **cached, - }) + return jsonify( + { + "status": "ok", + "source": "sigidwiki", + "frequency_mhz": round(frequency_mhz, 6), + "modulation": modulation or None, + "cached": True, + **cached, + } + ) try: lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit) except Exception as exc: - logger.error('SigID lookup failed: %s', exc) - return api_error('SigID lookup failed', 502) + logger.error("SigID lookup failed: %s", exc) + return api_error("SigID lookup failed", 502) response_payload = { - 'matches': lookup.get('matches', []), - 'match_count': len(lookup.get('matches', [])), - 'search_used': bool(lookup.get('search_used')), - 'exact_queries': lookup.get('exact_queries', []), + "matches": lookup.get("matches", []), + "match_count": len(lookup.get("matches", [])), + "search_used": bool(lookup.get("search_used")), + "exact_queries": lookup.get("exact_queries", []), } _cache_set(cache_key, response_payload) - return jsonify({ - 'status': 'ok', - 'source': 'sigidwiki', - 'frequency_mhz': round(frequency_mhz, 6), - 'modulation': modulation or None, - 'cached': False, - **response_payload, - }) + return jsonify( + { + "status": "ok", + "source": "sigidwiki", + "frequency_mhz": round(frequency_mhz, 6), + "modulation": modulation or None, + "cached": False, + **response_payload, + } + ) _match_cache: dict[str, dict[str, Any]] = {} -@signalid_bp.route('/match', methods=['POST']) +@signalid_bp.route("/match", methods=["POST"]) def signalid_match() -> Response: """Match a signal by frequency, bandwidth, and modulation against the local database.""" payload = request.get_json(silent=True) or {} - freq_raw = payload.get('frequency_mhz') + freq_raw = payload.get("frequency_mhz") if freq_raw is None: - return api_error('frequency_mhz is required', 400) + return api_error("frequency_mhz is required", 400) try: frequency_mhz = float(freq_raw) except (TypeError, ValueError): - return api_error('Invalid frequency_mhz', 400) + return api_error("Invalid frequency_mhz", 400) if frequency_mhz <= 0: - return api_error('frequency_mhz must be positive', 400) + return api_error("frequency_mhz must be positive", 400) - bw_raw = payload.get('bandwidth_hz') + bw_raw = payload.get("bandwidth_hz") bandwidth_hz: int | None = None if bw_raw is not None: try: bandwidth_hz = int(float(bw_raw)) except (TypeError, ValueError): - return api_error('Invalid bandwidth_hz', 400) + return api_error("Invalid bandwidth_hz", 400) if bandwidth_hz <= 0: - return api_error('bandwidth_hz must be positive', 400) + return api_error("bandwidth_hz must be positive", 400) - modulation = str(payload.get('modulation') or '').strip().upper()[:16] or None + modulation = str(payload.get("modulation") or "").strip().upper()[:16] or None - limit_raw = payload.get('limit', 8) + limit_raw = payload.get("limit", 8) try: limit = max(1, min(int(limit_raw), 20)) except (TypeError, ValueError): limit = 8 - region = getattr(config, 'REGION', 'GLOBAL') + region = getattr(config, "REGION", "GLOBAL") - cache_key = f'{round(frequency_mhz, 6)}|{bandwidth_hz}|{modulation}|{limit}|{region}' + cache_key = f"{round(frequency_mhz, 6)}|{bandwidth_hz}|{modulation}|{limit}|{region}" cached = _cache_get_match(cache_key) if cached is not None: - return jsonify({ - 'status': 'ok', - 'frequency_mhz': round(frequency_mhz, 6), - 'bandwidth_hz': bandwidth_hz, - 'modulation': modulation, - 'cached': True, - **cached, - }) + return jsonify( + { + "status": "ok", + "frequency_mhz": round(frequency_mhz, 6), + "bandwidth_hz": bandwidth_hz, + "modulation": modulation, + "cached": True, + **cached, + } + ) try: matches = match_signals( @@ -413,35 +423,36 @@ def signalid_match() -> Response: limit=limit, ) except Exception as exc: - logger.error('Signal match failed: %s', exc) - return api_error('Signal match failed', 503) + logger.error("Signal match failed: %s", exc) + return api_error("Signal match failed", 503) response_data = { - 'matches': matches, - 'match_count': len(matches), + "matches": matches, + "match_count": len(matches), } _cache_set_match(cache_key, response_data) - return jsonify({ - 'status': 'ok', - 'frequency_mhz': round(frequency_mhz, 6), - 'bandwidth_hz': bandwidth_hz, - 'modulation': modulation, - 'cached': False, - **response_data, - }) + return jsonify( + { + "status": "ok", + "frequency_mhz": round(frequency_mhz, 6), + "bandwidth_hz": bandwidth_hz, + "modulation": modulation, + "cached": False, + **response_data, + } + ) def _cache_get_match(key: str) -> Any | None: entry = _match_cache.get(key) if not entry: return None - if time.time() >= entry['expires']: + if time.time() >= entry["expires"]: _match_cache.pop(key, None) return None - return entry['data'] + return entry["data"] def _cache_set_match(key: str, data: Any, ttl: int = 60) -> None: - _match_cache[key] = {'data': data, 'expires': time.time() + ttl} - + _match_cache[key] = {"data": data, "expires": time.time() + ttl} diff --git a/routes/space_weather.py b/routes/space_weather.py index eae3bbd..aa9143f 100644 --- a/routes/space_weather.py +++ b/routes/space_weather.py @@ -15,9 +15,9 @@ from flask import Blueprint, Response, jsonify from utils.logging import get_logger from utils.responses import api_error -logger = get_logger('intercept.space_weather') +logger = get_logger("intercept.space_weather") -space_weather_bp = Blueprint('space_weather', __name__, url_prefix='/space-weather') +space_weather_bp = Blueprint("space_weather", __name__, url_prefix="/space-weather") # --------------------------------------------------------------------------- # TTL Cache @@ -26,21 +26,21 @@ space_weather_bp = Blueprint('space_weather', __name__, url_prefix='/space-weath _cache: dict[str, dict[str, Any]] = {} # Cache TTLs in seconds -TTL_REALTIME = 300 # 5 min for real-time data -TTL_FORECAST = 1800 # 30 min for forecasts -TTL_DAILY = 3600 # 1 hr for daily summaries -TTL_IMAGE = 600 # 10 min for images +TTL_REALTIME = 300 # 5 min for real-time data +TTL_FORECAST = 1800 # 30 min for forecasts +TTL_DAILY = 3600 # 1 hr for daily summaries +TTL_IMAGE = 600 # 10 min for images def _cache_get(key: str) -> Any | None: entry = _cache.get(key) - if entry and time.time() < entry['expires']: - return entry['data'] + if entry and time.time() < entry["expires"]: + return entry["data"] return None def _cache_set(key: str, data: Any, ttl: int) -> None: - _cache[key] = {'data': data, 'expires': time.time() + ttl} + _cache[key] = {"data": data, "expires": time.time() + ttl} # --------------------------------------------------------------------------- @@ -49,37 +49,37 @@ def _cache_set(key: str, data: Any, ttl: int) -> None: _TIMEOUT = 15 # seconds -SWPC_BASE = 'https://services.swpc.noaa.gov' -SWPC_JSON = f'{SWPC_BASE}/products' +SWPC_BASE = "https://services.swpc.noaa.gov" +SWPC_JSON = f"{SWPC_BASE}/products" def _fetch_json(url: str, timeout: int = _TIMEOUT) -> Any | None: try: - req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'}) + req = urllib.request.Request(url, headers={"User-Agent": "INTERCEPT/1.0"}) with urllib.request.urlopen(req, timeout=timeout) as resp: return json.loads(resp.read().decode()) except Exception as exc: - logger.warning('Failed to fetch %s: %s', url, exc) + logger.warning("Failed to fetch %s: %s", url, exc) return None def _fetch_text(url: str, timeout: int = _TIMEOUT) -> str | None: try: - req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'}) + req = urllib.request.Request(url, headers={"User-Agent": "INTERCEPT/1.0"}) with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.read().decode() except Exception as exc: - logger.warning('Failed to fetch %s: %s', url, exc) + logger.warning("Failed to fetch %s: %s", url, exc) return None def _fetch_bytes(url: str, timeout: int = _TIMEOUT) -> bytes | None: try: - req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'}) + req = urllib.request.Request(url, headers={"User-Agent": "INTERCEPT/1.0"}) with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.read() except Exception as exc: - logger.warning('Failed to fetch %s: %s', url, exc) + logger.warning("Failed to fetch %s: %s", url, exc) return None @@ -87,6 +87,7 @@ def _fetch_bytes(url: str, timeout: int = _TIMEOUT) -> bytes | None: # Data source fetchers # --------------------------------------------------------------------------- + def _fetch_cached_json(cache_key: str, url: str, ttl: int) -> Any | None: cached = _cache_get(cache_key) if cached is not None: @@ -98,104 +99,126 @@ def _fetch_cached_json(cache_key: str, url: str, ttl: int) -> Any | None: def _fetch_kp_index() -> Any | None: - return _fetch_cached_json('kp_index', f'{SWPC_JSON}/noaa-planetary-k-index.json', TTL_REALTIME) + return _fetch_cached_json("kp_index", f"{SWPC_JSON}/noaa-planetary-k-index.json", TTL_REALTIME) def _fetch_kp_forecast() -> Any | None: - return _fetch_cached_json('kp_forecast', f'{SWPC_JSON}/noaa-planetary-k-index-forecast.json', TTL_FORECAST) + return _fetch_cached_json("kp_forecast", f"{SWPC_JSON}/noaa-planetary-k-index-forecast.json", TTL_FORECAST) def _fetch_scales() -> Any | None: - return _fetch_cached_json('scales', f'{SWPC_JSON}/noaa-scales.json', TTL_REALTIME) + return _fetch_cached_json("scales", f"{SWPC_JSON}/noaa-scales.json", TTL_REALTIME) def _fetch_flux() -> Any | None: - return _fetch_cached_json('flux', f'{SWPC_JSON}/10cm-flux-30-day.json', TTL_DAILY) + return _fetch_cached_json("flux", f"{SWPC_JSON}/10cm-flux-30-day.json", TTL_DAILY) def _fetch_alerts() -> Any | None: - return _fetch_cached_json('alerts', f'{SWPC_JSON}/alerts.json', TTL_REALTIME) + return _fetch_cached_json("alerts", f"{SWPC_JSON}/alerts.json", TTL_REALTIME) def _fetch_solar_wind_plasma() -> Any | None: - return _fetch_cached_json('sw_plasma', f'{SWPC_JSON}/solar-wind/plasma-6-hour.json', TTL_REALTIME) + return _fetch_cached_json("sw_plasma", f"{SWPC_JSON}/solar-wind/plasma-6-hour.json", TTL_REALTIME) def _fetch_solar_wind_mag() -> Any | None: - return _fetch_cached_json('sw_mag', f'{SWPC_JSON}/solar-wind/mag-6-hour.json', TTL_REALTIME) + return _fetch_cached_json("sw_mag", f"{SWPC_JSON}/solar-wind/mag-6-hour.json", TTL_REALTIME) def _fetch_xrays() -> Any | None: - return _fetch_cached_json('xrays', f'{SWPC_BASE}/json/goes/primary/xrays-1-day.json', TTL_REALTIME) + return _fetch_cached_json("xrays", f"{SWPC_BASE}/json/goes/primary/xrays-1-day.json", TTL_REALTIME) def _fetch_xray_flares() -> Any | None: - return _fetch_cached_json('xray_flares', f'{SWPC_BASE}/json/goes/primary/xray-flares-7-day.json', TTL_REALTIME) + return _fetch_cached_json("xray_flares", f"{SWPC_BASE}/json/goes/primary/xray-flares-7-day.json", TTL_REALTIME) def _fetch_flare_probability() -> Any | None: - return _fetch_cached_json('flare_prob', f'{SWPC_BASE}/json/solar_probabilities.json', TTL_FORECAST) + return _fetch_cached_json("flare_prob", f"{SWPC_BASE}/json/solar_probabilities.json", TTL_FORECAST) def _fetch_solar_regions() -> Any | None: - return _fetch_cached_json('solar_regions', f'{SWPC_BASE}/json/solar_regions.json', TTL_DAILY) + return _fetch_cached_json("solar_regions", f"{SWPC_BASE}/json/solar_regions.json", TTL_DAILY) def _fetch_sunspot_report() -> Any | None: - return _fetch_cached_json('sunspot_report', f'{SWPC_BASE}/json/sunspot_report.json', TTL_DAILY) + return _fetch_cached_json("sunspot_report", f"{SWPC_BASE}/json/sunspot_report.json", TTL_DAILY) def _parse_hamqsl_xml(xml_text: str) -> dict[str, Any] | None: """Parse HamQSL solar XML into a dict of band conditions.""" try: root = ET.fromstring(xml_text) - solar = root.find('.//solardata') + solar = root.find(".//solardata") if solar is None: return None result: dict[str, Any] = {} # Scalar fields - for tag in ('sfi', 'aindex', 'kindex', 'kindexnt', 'xray', 'sunspots', - 'heliumline', 'protonflux', 'electonflux', 'aurora', - 'normalization', 'latdegree', 'solarwind', 'magneticfield', - 'calculatedconditions', 'calculatedvhfconditions', - 'geomagfield', 'signalnoise', 'fof2', 'muffactor', 'muf'): + for tag in ( + "sfi", + "aindex", + "kindex", + "kindexnt", + "xray", + "sunspots", + "heliumline", + "protonflux", + "electonflux", + "aurora", + "normalization", + "latdegree", + "solarwind", + "magneticfield", + "calculatedconditions", + "calculatedvhfconditions", + "geomagfield", + "signalnoise", + "fof2", + "muffactor", + "muf", + ): el = solar.find(tag) if el is not None and el.text: result[tag] = el.text.strip() # Band conditions bands: list[dict[str, str]] = [] - for band_el in solar.findall('.//calculatedconditions/band'): - bands.append({ - 'name': band_el.get('name', ''), - 'time': band_el.get('time', ''), - 'condition': band_el.text.strip() if band_el.text else '' - }) - result['bands'] = bands + for band_el in solar.findall(".//calculatedconditions/band"): + bands.append( + { + "name": band_el.get("name", ""), + "time": band_el.get("time", ""), + "condition": band_el.text.strip() if band_el.text else "", + } + ) + result["bands"] = bands # VHF conditions vhf: list[dict[str, str]] = [] - for phen_el in solar.findall('.//calculatedvhfconditions/phenomenon'): - vhf.append({ - 'name': phen_el.get('name', ''), - 'location': phen_el.get('location', ''), - 'condition': phen_el.text.strip() if phen_el.text else '' - }) - result['vhf'] = vhf + for phen_el in solar.findall(".//calculatedvhfconditions/phenomenon"): + vhf.append( + { + "name": phen_el.get("name", ""), + "location": phen_el.get("location", ""), + "condition": phen_el.text.strip() if phen_el.text else "", + } + ) + result["vhf"] = vhf return result except ET.ParseError as exc: - logger.warning('Failed to parse HamQSL XML: %s', exc) + logger.warning("Failed to parse HamQSL XML: %s", exc) return None def _fetch_band_conditions() -> dict[str, Any] | None: - cached = _cache_get('band_conditions') + cached = _cache_get("band_conditions") if cached is not None: return cached - xml_text = _fetch_text('https://www.hamqsl.com/solarxml.php') + xml_text = _fetch_text("https://www.hamqsl.com/solarxml.php") if xml_text is None: return None data = _parse_hamqsl_xml(xml_text) if data is not None: - _cache_set('band_conditions', data, TTL_FORECAST) + _cache_set("band_conditions", data, TTL_FORECAST) return data @@ -205,51 +228,51 @@ def _fetch_band_conditions() -> dict[str, Any] | None: IMAGE_WHITELIST: dict[str, dict[str, str]] = { # D-RAP absorption maps - 'drap_global': { - 'url': f'{SWPC_BASE}/images/animations/d-rap/global/latest.png', - 'content_type': 'image/png', + "drap_global": { + "url": f"{SWPC_BASE}/images/animations/d-rap/global/latest.png", + "content_type": "image/png", }, - 'drap_5': { - 'url': f'{SWPC_BASE}/images/d-rap/global_f05.png', - 'content_type': 'image/png', + "drap_5": { + "url": f"{SWPC_BASE}/images/d-rap/global_f05.png", + "content_type": "image/png", }, - 'drap_10': { - 'url': f'{SWPC_BASE}/images/d-rap/global_f10.png', - 'content_type': 'image/png', + "drap_10": { + "url": f"{SWPC_BASE}/images/d-rap/global_f10.png", + "content_type": "image/png", }, - 'drap_15': { - 'url': f'{SWPC_BASE}/images/d-rap/global_f15.png', - 'content_type': 'image/png', + "drap_15": { + "url": f"{SWPC_BASE}/images/d-rap/global_f15.png", + "content_type": "image/png", }, - 'drap_20': { - 'url': f'{SWPC_BASE}/images/d-rap/global_f20.png', - 'content_type': 'image/png', + "drap_20": { + "url": f"{SWPC_BASE}/images/d-rap/global_f20.png", + "content_type": "image/png", }, - 'drap_25': { - 'url': f'{SWPC_BASE}/images/d-rap/global_f25.png', - 'content_type': 'image/png', + "drap_25": { + "url": f"{SWPC_BASE}/images/d-rap/global_f25.png", + "content_type": "image/png", }, - 'drap_30': { - 'url': f'{SWPC_BASE}/images/d-rap/global_f30.png', - 'content_type': 'image/png', + "drap_30": { + "url": f"{SWPC_BASE}/images/d-rap/global_f30.png", + "content_type": "image/png", }, # Aurora forecast - 'aurora_north': { - 'url': f'{SWPC_BASE}/images/animations/ovation/north/latest.jpg', - 'content_type': 'image/jpeg', + "aurora_north": { + "url": f"{SWPC_BASE}/images/animations/ovation/north/latest.jpg", + "content_type": "image/jpeg", }, # SDO solar imagery - 'sdo_193': { - 'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0193.jpg', - 'content_type': 'image/jpeg', + "sdo_193": { + "url": "https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0193.jpg", + "content_type": "image/jpeg", }, - 'sdo_304': { - 'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0304.jpg', - 'content_type': 'image/jpeg', + "sdo_304": { + "url": "https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0304.jpg", + "content_type": "image/jpeg", }, - 'sdo_magnetogram': { - 'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_HMIBC.jpg', - 'content_type': 'image/jpeg', + "sdo_magnetogram": { + "url": "https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_HMIBC.jpg", + "content_type": "image/jpeg", }, } @@ -258,83 +281,79 @@ IMAGE_WHITELIST: dict[str, dict[str, str]] = { # Routes # --------------------------------------------------------------------------- -@space_weather_bp.route('/data') + +@space_weather_bp.route("/data") def get_data(): """Return aggregated space weather data from all sources.""" fetchers = { - 'kp_index': _fetch_kp_index, - 'kp_forecast': _fetch_kp_forecast, - 'scales': _fetch_scales, - 'flux': _fetch_flux, - 'alerts': _fetch_alerts, - 'solar_wind_plasma': _fetch_solar_wind_plasma, - 'solar_wind_mag': _fetch_solar_wind_mag, - 'xrays': _fetch_xrays, - 'xray_flares': _fetch_xray_flares, - 'flare_probability': _fetch_flare_probability, - 'solar_regions': _fetch_solar_regions, - 'sunspot_report': _fetch_sunspot_report, - 'band_conditions': _fetch_band_conditions, + "kp_index": _fetch_kp_index, + "kp_forecast": _fetch_kp_forecast, + "scales": _fetch_scales, + "flux": _fetch_flux, + "alerts": _fetch_alerts, + "solar_wind_plasma": _fetch_solar_wind_plasma, + "solar_wind_mag": _fetch_solar_wind_mag, + "xrays": _fetch_xrays, + "xray_flares": _fetch_xray_flares, + "flare_probability": _fetch_flare_probability, + "solar_regions": _fetch_solar_regions, + "sunspot_report": _fetch_sunspot_report, + "band_conditions": _fetch_band_conditions, } data = {} with concurrent.futures.ThreadPoolExecutor(max_workers=13) as executor: futures = {executor.submit(fn): key for key, fn in fetchers.items()} for future in concurrent.futures.as_completed(futures): data[futures[future]] = future.result() - data['timestamp'] = time.time() + data["timestamp"] = time.time() return jsonify(data) -@space_weather_bp.route('/image/') +@space_weather_bp.route("/image/") def get_image(key: str): """Proxy and cache whitelisted space weather images.""" entry = IMAGE_WHITELIST.get(key) if not entry: - return api_error('Unknown image key', 404) + return api_error("Unknown image key", 404) - cache_key = f'img_{key}' + cache_key = f"img_{key}" cached = _cache_get(cache_key) if cached is not None: - return Response(cached, content_type=entry['content_type'], - headers={'Cache-Control': 'public, max-age=300'}) + return Response(cached, content_type=entry["content_type"], headers={"Cache-Control": "public, max-age=300"}) - img_data = _fetch_bytes(entry['url']) + img_data = _fetch_bytes(entry["url"]) if img_data is None: - return api_error('Failed to fetch image', 502) + return api_error("Failed to fetch image", 502) _cache_set(cache_key, img_data, TTL_IMAGE) - return Response(img_data, content_type=entry['content_type'], - headers={'Cache-Control': 'public, max-age=300'}) + return Response(img_data, content_type=entry["content_type"], headers={"Cache-Control": "public, max-age=300"}) -@space_weather_bp.route('/prefetch-images') +@space_weather_bp.route("/prefetch-images") def prefetch_images(): """Warm the image cache by fetching all whitelisted images in parallel.""" # Only fetch images not already cached to_fetch = {} for key, entry in IMAGE_WHITELIST.items(): - cache_key = f'img_{key}' + cache_key = f"img_{key}" if _cache_get(cache_key) is None: to_fetch[key] = entry if not to_fetch: - return jsonify({'status': 'all cached', 'count': 0}) + return jsonify({"status": "all cached", "count": 0}) def _fetch_and_cache(key: str, entry: dict) -> bool: - img_data = _fetch_bytes(entry['url']) + img_data = _fetch_bytes(entry["url"]) if img_data: - _cache_set(f'img_{key}', img_data, TTL_IMAGE) + _cache_set(f"img_{key}", img_data, TTL_IMAGE) return True return False fetched = 0 with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: - futures = { - executor.submit(_fetch_and_cache, k, e): k - for k, e in to_fetch.items() - } + futures = {executor.submit(_fetch_and_cache, k, e): k for k, e in to_fetch.items()} for future in concurrent.futures.as_completed(futures): if future.result(): fetched += 1 - return jsonify({'status': 'ok', 'fetched': fetched, 'cached': len(IMAGE_WHITELIST) - len(to_fetch)}) + return jsonify({"status": "ok", "fetched": fetched, "cached": len(IMAGE_WHITELIST) - len(to_fetch)}) diff --git a/routes/spy_stations.py b/routes/spy_stations.py index e06d13f..bdc4e80 100644 --- a/routes/spy_stations.py +++ b/routes/spy_stations.py @@ -2,7 +2,7 @@ from flask import Blueprint, jsonify, request -spy_stations_bp = Blueprint('spy_stations', __name__, url_prefix='/spy-stations') +spy_stations_bp = Blueprint("spy_stations", __name__, url_prefix="/spy-stations") # Active spy stations data from priyom.org STATIONS = [ @@ -23,7 +23,7 @@ STATIONS = [ "description": "Russian intelligence number station operated by 'Russian 6'. Male voice reads 5-figure groups. Broadcasts from Moscow, Orenburg, Smolensk, and Chita.", "operator": "Russian 6", "schedule": "Weekdays, 2 transmissions 1 hour apart", - "source_url": "https://priyom.org/number-stations/english/e06" + "source_url": "https://priyom.org/number-stations/english/e06", }, { "id": "s06", @@ -41,7 +41,7 @@ STATIONS = [ "description": "Russian language mode of the Russian 6 operator. Male voice reads 5-figure groups in Russian.", "operator": "Russian 6", "schedule": "Same schedule as E06, alternating languages", - "source_url": "https://priyom.org/number-stations/russian/s06" + "source_url": "https://priyom.org/number-stations/russian/s06", }, { "id": "uvb76", @@ -60,7 +60,7 @@ STATIONS = [ "description": "Russian military command network. Continuous buzzing tone with occasional voice messages. Active since 1982. One of the most famous number stations.", "operator": "Russian Military", "schedule": "24/7 continuous operation", - "source_url": "https://priyom.org/number-stations/russia/uvb-76" + "source_url": "https://priyom.org/number-stations/russia/uvb-76", }, { "id": "hm01", @@ -91,7 +91,7 @@ STATIONS = [ "description": "Cuban DGI intelligence station. Spanish female voice 'Atencion' followed by number groups. Also uses RDFT OFDM digital mode.", "operator": "DGI (Cuban Intelligence)", "schedule": "Multiple daily transmissions", - "source_url": "https://priyom.org/number-stations/cuba/hm01" + "source_url": "https://priyom.org/number-stations/cuba/hm01", }, { "id": "e07", @@ -110,7 +110,7 @@ STATIONS = [ "description": "Russian intelligence station using distinctive 7-dash interval signal. Female voice reading 5-figure groups in English. Part of the 'Russian 7' operator network.", "operator": "Russian 7", "schedule": "Irregular, typically evenings UTC", - "source_url": "https://priyom.org/number-stations/english/e07" + "source_url": "https://priyom.org/number-stations/english/e07", }, { "id": "e11", @@ -128,7 +128,7 @@ STATIONS = [ "description": "Polish intelligence number station. Female voice reads 5-figure groups in English. Named after distinctive melody interval signal.", "operator": "ABW (Polish Intelligence)", "schedule": "Weekly transmissions", - "source_url": "https://priyom.org/number-stations/english/e11" + "source_url": "https://priyom.org/number-stations/english/e11", }, { "id": "e17z", @@ -146,7 +146,7 @@ STATIONS = [ "description": "Israeli intelligence number station. Female voice with distinctive Hebrew-accented English. Transmits 5-figure groups with phonetic alphabet.", "operator": "Mossad (suspected)", "schedule": "Irregular schedule", - "source_url": "https://priyom.org/number-stations/english/e17z" + "source_url": "https://priyom.org/number-stations/english/e17z", }, { "id": "g06", @@ -164,7 +164,7 @@ STATIONS = [ "description": "German language mode of Russian 6 operator. Male synthesized voice reads 5-figure groups in German. Shares frequencies with E06/S06.", "operator": "Russian 6", "schedule": "Same schedule as E06", - "source_url": "https://priyom.org/number-stations/german/g06" + "source_url": "https://priyom.org/number-stations/german/g06", }, { "id": "v02a", @@ -182,7 +182,7 @@ STATIONS = [ "description": "Cuban intelligence station using AM mode. Female Spanish voice reading 4-figure groups. Related to HM01 but separate schedule.", "operator": "DGI (Cuban Intelligence)", "schedule": "Evening transmissions, weekdays", - "source_url": "https://priyom.org/number-stations/spanish/v02a" + "source_url": "https://priyom.org/number-stations/spanish/v02a", }, { "id": "v07", @@ -199,7 +199,7 @@ STATIONS = [ "description": "Russian voice number station. Female voice reads 5-figure groups in Russian. Part of Russian 7 operator network. Often shares 4625 kHz with UVB-76.", "operator": "Russian 7", "schedule": "Irregular transmissions", - "source_url": "https://priyom.org/number-stations/russian/v07" + "source_url": "https://priyom.org/number-stations/russian/v07", }, { "id": "s11a", @@ -216,7 +216,7 @@ STATIONS = [ "description": "Russian phonetic alphabet number station. Male voice reads 5-letter groups using Russian phonetic alphabet (Anna, Boris, etc.).", "operator": "GRU (suspected)", "schedule": "Weekly scheduled transmissions", - "source_url": "https://priyom.org/number-stations/russian/s11a" + "source_url": "https://priyom.org/number-stations/russian/s11a", }, { "id": "v13", @@ -233,7 +233,7 @@ STATIONS = [ "description": "Russian military channel marker known as 'The Pip'. Continuous short beep every 1 second with occasional voice messages. Sister station to UVB-76.", "operator": "Russian Military", "schedule": "24/7 continuous operation", - "source_url": "https://priyom.org/military-stations/russia/the-pip" + "source_url": "https://priyom.org/military-stations/russia/the-pip", }, { "id": "v24", @@ -249,7 +249,7 @@ STATIONS = [ "description": "Russian channel marker known as 'Air Horn' due to distinctive foghorn-like sound. Continuous tone with occasional voice messages in Russian.", "operator": "Russian Military", "schedule": "24/7 continuous operation", - "source_url": "https://priyom.org/military-stations/russia/the-air-horn" + "source_url": "https://priyom.org/military-stations/russia/the-air-horn", }, { "id": "vc01", @@ -268,7 +268,7 @@ STATIONS = [ "description": "Chinese intelligence number station. Robotic female voice reading 4-figure groups in Chinese. Distinctive electronic music interval signal.", "operator": "MSS (Chinese Intelligence)", "schedule": "Daily transmissions", - "source_url": "https://priyom.org/number-stations/chinese/vc01" + "source_url": "https://priyom.org/number-stations/chinese/vc01", }, { "id": "v22", @@ -285,7 +285,7 @@ STATIONS = [ "description": "Chinese number station using female voice. Reads 4-figure groups in Mandarin Chinese. Often reported in Southeast Asian target areas.", "operator": "MSS (Chinese Intelligence)", "schedule": "Evening transmissions UTC", - "source_url": "https://priyom.org/number-stations/chinese/v22" + "source_url": "https://priyom.org/number-stations/chinese/v22", }, # Diplomatic Stations { @@ -309,7 +309,7 @@ STATIONS = [ "description": "Bulgarian Ministry of Foreign Affairs diplomatic network. Sofia to 14 embassies worldwide. Uses RFSM-8000 modem with MIL-STD-188-110.", "operator": "Bulgarian MFA", "schedule": "Daily scheduled transmissions", - "source_url": "https://priyom.org/diplomatic/bulgaria" + "source_url": "https://priyom.org/diplomatic/bulgaria", }, { "id": "czechia_mfa", @@ -328,7 +328,7 @@ STATIONS = [ "description": "Czech diplomatic network using PACTOR-III. Callsigns OLZ52-OLZ88. MoD station OL1A also active.", "operator": "Czech MFA / MoD", "schedule": "Regular scheduled traffic", - "source_url": "https://priyom.org/diplomatic/czechia" + "source_url": "https://priyom.org/diplomatic/czechia", }, { "id": "egypt_mfa", @@ -347,7 +347,7 @@ STATIONS = [ "description": "Egyptian diplomatic network. 5-digit station IDs (66601=Washington, 11107=London). Uses SITOR and Codan 3012 modems.", "operator": "Egyptian MFA", "schedule": "Daily traffic windows", - "source_url": "https://priyom.org/diplomatic/egypt" + "source_url": "https://priyom.org/diplomatic/egypt", }, { "id": "dprk_mfa", @@ -370,7 +370,7 @@ STATIONS = [ "description": "North Korean diplomatic network spanning 7-25 MHz. Uses proprietary DPRK-ARQ protocol. Daily encrypted traffic to embassies.", "operator": "DPRK MFA", "schedule": "Daily, multiple time slots", - "source_url": "https://priyom.org/diplomatic/north-korea" + "source_url": "https://priyom.org/diplomatic/north-korea", }, { "id": "russia_mfa", @@ -392,7 +392,7 @@ STATIONS = [ "description": "Extensive Russian diplomatic network using multiple proprietary modes including Perelivt, Serdolik, and OFDM variants.", "operator": "Russian MFA", "schedule": "24/7 network operations", - "source_url": "https://priyom.org/diplomatic/russia" + "source_url": "https://priyom.org/diplomatic/russia", }, { "id": "tunisia_mfa", @@ -428,7 +428,7 @@ STATIONS = [ "description": "Tunisian MFA network. Callsigns STAT151-155. Uses 2G ALE for linking and PACTOR-II for traffic. MAPI email format.", "operator": "Tunisian MFA", "schedule": "Regular diplomatic traffic", - "source_url": "https://priyom.org/diplomatic/tunisia" + "source_url": "https://priyom.org/diplomatic/tunisia", }, { "id": "usa_state", @@ -453,7 +453,7 @@ STATIONS = [ "description": "US State Department diplomatic network. 140+ embassy callsigns (KWX57=Warsaw, KRH50=Tokyo, etc.). Uses 2G ALE linking.", "operator": "US State Department", "schedule": "24/7 global network", - "source_url": "https://priyom.org/diplomatic/united-states" + "source_url": "https://priyom.org/diplomatic/united-states", }, { "id": "morocco_mfa", @@ -471,7 +471,7 @@ STATIONS = [ "description": "Moroccan Ministry of Foreign Affairs diplomatic network. Links Rabat with embassies in Europe and Africa. Uses PACTOR-II and 2G ALE.", "operator": "Moroccan MFA", "schedule": "Daily scheduled traffic", - "source_url": "https://priyom.org/diplomatic/morocco" + "source_url": "https://priyom.org/diplomatic/morocco", }, { "id": "poland_mfa", @@ -489,7 +489,7 @@ STATIONS = [ "description": "Polish Ministry of Foreign Affairs HF network. Uses NATO STANAG-4285 modem with 2G ALE linking. Connects Warsaw with global embassies.", "operator": "Polish MFA", "schedule": "Regular diplomatic traffic", - "source_url": "https://priyom.org/diplomatic/poland" + "source_url": "https://priyom.org/diplomatic/poland", }, { "id": "france_mfa", @@ -508,7 +508,7 @@ STATIONS = [ "description": "French Ministry of Foreign Affairs network. Extensive global coverage with Paris hub. Uses MIL-STD-188-110 with 2G/3G ALE linking protocols.", "operator": "French MFA", "schedule": "24/7 network operations", - "source_url": "https://priyom.org/diplomatic/france" + "source_url": "https://priyom.org/diplomatic/france", }, { "id": "romania_mfa", @@ -526,7 +526,7 @@ STATIONS = [ "description": "Romanian diplomatic network linking Bucharest with embassies. Uses PACTOR-III for traffic and 2G ALE for channel establishment.", "operator": "Romanian MFA", "schedule": "Scheduled daily windows", - "source_url": "https://priyom.org/diplomatic/romania" + "source_url": "https://priyom.org/diplomatic/romania", }, { "id": "algeria_mfa", @@ -544,7 +544,7 @@ STATIONS = [ "description": "Algerian Ministry of Foreign Affairs network. Links Algiers with African and European embassies. Uses SITOR-B and PACTOR modes.", "operator": "Algerian MFA", "schedule": "Daily scheduled transmissions", - "source_url": "https://priyom.org/diplomatic/algeria" + "source_url": "https://priyom.org/diplomatic/algeria", }, { "id": "egypt_mfa_m14a", @@ -561,65 +561,53 @@ STATIONS = [ "description": "Extended Egyptian diplomatic network frequencies. Higher frequency allocations for long-distance embassy communications to Asia and Americas.", "operator": "Egyptian MFA", "schedule": "Daily traffic windows", - "source_url": "https://priyom.org/diplomatic/egypt" + "source_url": "https://priyom.org/diplomatic/egypt", }, ] -@spy_stations_bp.route('/stations') +@spy_stations_bp.route("/stations") def get_stations(): """Return all spy stations, optionally filtered.""" - station_type = request.args.get('type') - country = request.args.get('country') - mode = request.args.get('mode') + station_type = request.args.get("type") + country = request.args.get("country") + mode = request.args.get("mode") filtered = STATIONS if station_type: - filtered = [s for s in filtered if s['type'] == station_type] + filtered = [s for s in filtered if s["type"] == station_type] if country: - filtered = [s for s in filtered if s['country_code'].upper() == country.upper()] + filtered = [s for s in filtered if s["country_code"].upper() == country.upper()] if mode: mode_lower = mode.lower() - filtered = [s for s in filtered if mode_lower in s['mode'].lower()] + filtered = [s for s in filtered if mode_lower in s["mode"].lower()] - return jsonify({ - 'status': 'success', - 'count': len(filtered), - 'stations': filtered - }) + return jsonify({"status": "success", "count": len(filtered), "stations": filtered}) -@spy_stations_bp.route('/stations/') +@spy_stations_bp.route("/stations/") def get_station(station_id): """Get a single station by ID.""" for station in STATIONS: - if station['id'] == station_id: - return jsonify({ - 'status': 'success', - 'station': station - }) + if station["id"] == station_id: + return jsonify({"status": "success", "station": station}) - return jsonify({ - 'status': 'error', - 'message': 'Station not found' - }), 404 + return jsonify({"status": "error", "message": "Station not found"}), 404 -@spy_stations_bp.route('/filters') +@spy_stations_bp.route("/filters") def get_filters(): """Return available filter options.""" - types = list({s['type'] for s in STATIONS}) - countries = sorted({(s['country'], s['country_code']) for s in STATIONS}) - modes = sorted({s['mode'].split('/')[0] for s in STATIONS}) + types = list({s["type"] for s in STATIONS}) + countries = sorted({(s["country"], s["country_code"]) for s in STATIONS}) + modes = sorted({s["mode"].split("/")[0] for s in STATIONS}) - return jsonify({ - 'status': 'success', - 'filters': { - 'types': types, - 'countries': [{'name': c[0], 'code': c[1]} for c in countries], - 'modes': modes + return jsonify( + { + "status": "success", + "filters": {"types": types, "countries": [{"name": c[0], "code": c[1]} for c in countries], "modes": modes}, } - }) + ) diff --git a/routes/sstv_general.py b/routes/sstv_general.py index a6c4518..4c34397 100644 --- a/routes/sstv_general.py +++ b/routes/sstv_general.py @@ -21,36 +21,108 @@ from utils.sstv import ( get_general_sstv_decoder, ) -logger = get_logger('intercept.sstv_general') +logger = get_logger("intercept.sstv_general") -sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general') +sstv_general_bp = Blueprint("sstv_general", __name__, url_prefix="/sstv-general") # Queue for SSE progress streaming _sstv_general_queue: queue.Queue = queue.Queue(maxsize=100) # Track which device is being used _sstv_general_active_device: int | None = None -_sstv_general_active_sdr_type: str = 'rtlsdr' +_sstv_general_active_sdr_type: str = "rtlsdr" # Predefined SSTV frequencies SSTV_FREQUENCIES = [ - {'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'}, - {'band': '80 m', 'frequency': 3.730, 'modulation': 'lsb', 'notes': 'Europe primary (analog/digital variants)', 'type': 'Terrestrial HF'}, - {'band': '40 m', 'frequency': 7.171, 'modulation': 'lsb', 'notes': 'Common international/US/EU SSTV activity', 'type': 'Terrestrial HF'}, - {'band': '40 m', 'frequency': 7.040, 'modulation': 'lsb', 'notes': 'Alternative US/Europe calling', 'type': 'Terrestrial HF'}, - {'band': '30 m', 'frequency': 10.132, 'modulation': 'usb', 'notes': 'Narrowband SSTV (e.g., MP73-N digital)', 'type': 'Terrestrial HF'}, - {'band': '20 m', 'frequency': 14.230, 'modulation': 'usb', 'notes': 'Most popular international SSTV frequency', 'type': 'Terrestrial HF'}, - {'band': '20 m', 'frequency': 14.233, 'modulation': 'usb', 'notes': 'Digital SSTV calling / alternative activity', 'type': 'Terrestrial HF'}, - {'band': '20 m', 'frequency': 14.240, 'modulation': 'usb', 'notes': 'Europe alternative', 'type': 'Terrestrial HF'}, - {'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'}, - {'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'}, - {'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'}, - {'band': '2 m', 'frequency': 145.500, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'}, - {'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'}, + { + "band": "80 m", + "frequency": 3.845, + "modulation": "lsb", + "notes": "Common US SSTV calling frequency", + "type": "Terrestrial HF", + }, + { + "band": "80 m", + "frequency": 3.730, + "modulation": "lsb", + "notes": "Europe primary (analog/digital variants)", + "type": "Terrestrial HF", + }, + { + "band": "40 m", + "frequency": 7.171, + "modulation": "lsb", + "notes": "Common international/US/EU SSTV activity", + "type": "Terrestrial HF", + }, + { + "band": "40 m", + "frequency": 7.040, + "modulation": "lsb", + "notes": "Alternative US/Europe calling", + "type": "Terrestrial HF", + }, + { + "band": "30 m", + "frequency": 10.132, + "modulation": "usb", + "notes": "Narrowband SSTV (e.g., MP73-N digital)", + "type": "Terrestrial HF", + }, + { + "band": "20 m", + "frequency": 14.230, + "modulation": "usb", + "notes": "Most popular international SSTV frequency", + "type": "Terrestrial HF", + }, + { + "band": "20 m", + "frequency": 14.233, + "modulation": "usb", + "notes": "Digital SSTV calling / alternative activity", + "type": "Terrestrial HF", + }, + {"band": "20 m", "frequency": 14.240, "modulation": "usb", "notes": "Europe alternative", "type": "Terrestrial HF"}, + { + "band": "15 m", + "frequency": 21.340, + "modulation": "usb", + "notes": "International calling frequency", + "type": "Terrestrial HF", + }, + { + "band": "10 m", + "frequency": 28.680, + "modulation": "usb", + "notes": "International calling frequency", + "type": "Terrestrial HF", + }, + { + "band": "6 m", + "frequency": 50.950, + "modulation": "usb", + "notes": "SSTV calling (less common)", + "type": "Terrestrial VHF", + }, + { + "band": "2 m", + "frequency": 145.500, + "modulation": "fm", + "notes": "Australia/common simplex (FM sometimes used)", + "type": "Terrestrial VHF", + }, + { + "band": "70 cm", + "frequency": 433.775, + "modulation": "fm", + "notes": "Australia/common simplex", + "type": "Terrestrial UHF", + }, ] # Build a lookup for auto-detecting modulation from frequency -_FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES} +_FREQ_MODULATION_MAP = {entry["frequency"]: entry["modulation"] for entry in SSTV_FREQUENCIES} def _progress_callback(data: dict) -> None: @@ -65,29 +137,33 @@ def _progress_callback(data: dict) -> None: pass -@sstv_general_bp.route('/frequencies') +@sstv_general_bp.route("/frequencies") def get_frequencies(): """Return the predefined SSTV frequency table.""" - return jsonify({ - 'status': 'ok', - 'frequencies': SSTV_FREQUENCIES, - }) + return jsonify( + { + "status": "ok", + "frequencies": SSTV_FREQUENCIES, + } + ) -@sstv_general_bp.route('/status') +@sstv_general_bp.route("/status") def get_status(): """Get general SSTV decoder status.""" decoder = get_general_sstv_decoder() - return jsonify({ - 'available': decoder.decoder_available is not None, - 'decoder': decoder.decoder_available, - 'running': decoder.is_running, - 'image_count': len(decoder.get_images()), - }) + return jsonify( + { + "available": decoder.decoder_available is not None, + "decoder": decoder.decoder_available, + "running": decoder.is_running, + "image_count": len(decoder.get_images()), + } + ) -@sstv_general_bp.route('/start', methods=['POST']) +@sstv_general_bp.route("/start", methods=["POST"]) def start_decoder(): """ Start general SSTV decoder. @@ -102,12 +178,14 @@ def start_decoder(): decoder = get_general_sstv_decoder() if decoder.decoder_available is None: - return api_error('SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow', 400) + return api_error("SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow", 400) if decoder.is_running: - return jsonify({ - 'status': 'already_running', - }) + return jsonify( + { + "status": "already_running", + } + ) # Clear queue while not _sstv_general_queue.empty(): @@ -117,40 +195,43 @@ def start_decoder(): break data = request.get_json(silent=True) or {} - sdr_type_str = data.get('sdr_type', 'rtlsdr') + sdr_type_str = data.get("sdr_type", "rtlsdr") - if sdr_type_str != 'rtlsdr': - return api_error(f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.', 400) + if sdr_type_str != "rtlsdr": + return api_error( + f"{sdr_type_str.replace('_', ' ').title()} is not yet supported for this mode. Please use an RTL-SDR device.", + 400, + ) - frequency = data.get('frequency') - modulation = data.get('modulation') - device_index = data.get('device', 0) + frequency = data.get("frequency") + modulation = data.get("modulation") + device_index = data.get("device", 0) # Validate frequency if frequency is None: - return api_error('Frequency is required', 400) + return api_error("Frequency is required", 400) try: frequency = float(frequency) if not (1 <= frequency <= 500): - return api_error('Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)', 400) + return api_error("Frequency must be between 1-500 MHz (HF requires upconverter for RTL-SDR)", 400) except (TypeError, ValueError): - return api_error('Invalid frequency', 400) + return api_error("Invalid frequency", 400) # Auto-detect modulation from frequency table if not specified if not modulation: - modulation = _FREQ_MODULATION_MAP.get(frequency, 'usb') + modulation = _FREQ_MODULATION_MAP.get(frequency, "usb") # Validate modulation - if modulation not in ('fm', 'usb', 'lsb'): - return api_error('Modulation must be fm, usb, or lsb', 400) + if modulation not in ("fm", "usb", "lsb"): + return api_error("Modulation must be fm, usb, or lsb", 400) # Claim SDR device global _sstv_general_active_device, _sstv_general_active_sdr_type device_int = int(device_index) - error = app_module.claim_sdr_device(device_int, 'sstv_general', sdr_type_str) + error = app_module.claim_sdr_device(device_int, "sstv_general", sdr_type_str) if error: - return api_error(error, 409, error_type='DEVICE_BUSY') + return api_error(error, 409, error_type="DEVICE_BUSY") # Set callback and start decoder.set_callback(_progress_callback) @@ -163,18 +244,20 @@ def start_decoder(): if success: _sstv_general_active_device = device_int _sstv_general_active_sdr_type = sdr_type_str - return jsonify({ - 'status': 'started', - 'frequency': frequency, - 'modulation': modulation, - 'device': device_index, - }) + return jsonify( + { + "status": "started", + "frequency": frequency, + "modulation": modulation, + "device": device_index, + } + ) else: app_module.release_sdr_device(device_int, sdr_type_str) - return api_error('Failed to start decoder', 500) + return api_error("Failed to start decoder", 500) -@sstv_general_bp.route('/stop', methods=['POST']) +@sstv_general_bp.route("/stop", methods=["POST"]) def stop_decoder(): """Stop general SSTV decoder.""" global _sstv_general_active_device, _sstv_general_active_sdr_type @@ -185,127 +268,131 @@ def stop_decoder(): app_module.release_sdr_device(_sstv_general_active_device, _sstv_general_active_sdr_type) _sstv_general_active_device = None - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@sstv_general_bp.route('/images') +@sstv_general_bp.route("/images") def list_images(): """Get list of decoded SSTV images.""" decoder = get_general_sstv_decoder() images = decoder.get_images() - limit = request.args.get('limit', type=int) + limit = request.args.get("limit", type=int) if limit and limit > 0: images = images[-limit:] - return jsonify({ - 'status': 'ok', - 'images': [img.to_dict() for img in images], - 'count': len(images), - }) + return jsonify( + { + "status": "ok", + "images": [img.to_dict() for img in images], + "count": len(images), + } + ) -@sstv_general_bp.route('/images/') +@sstv_general_bp.route("/images/") def get_image(filename: str): """Get a decoded SSTV image file.""" decoder = get_general_sstv_decoder() # Security: only allow alphanumeric filenames with .png extension - if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): - return api_error('Invalid filename', 400) + if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum(): + return api_error("Invalid filename", 400) - if not filename.endswith('.png'): - return api_error('Only PNG files supported', 400) + if not filename.endswith(".png"): + return api_error("Only PNG files supported", 400) image_path = decoder._output_dir / filename if not image_path.exists(): - return api_error('Image not found', 404) + return api_error("Image not found", 404) - return send_file(image_path, mimetype='image/png') + return send_file(image_path, mimetype="image/png") -@sstv_general_bp.route('/images//download') +@sstv_general_bp.route("/images//download") def download_image(filename: str): """Download a decoded SSTV image file.""" decoder = get_general_sstv_decoder() # Security: only allow alphanumeric filenames with .png extension - if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): - return api_error('Invalid filename', 400) + if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum(): + return api_error("Invalid filename", 400) - if not filename.endswith('.png'): - return api_error('Only PNG files supported', 400) + if not filename.endswith(".png"): + return api_error("Only PNG files supported", 400) image_path = decoder._output_dir / filename if not image_path.exists(): - return api_error('Image not found', 404) + return api_error("Image not found", 404) - return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename) + return send_file(image_path, mimetype="image/png", as_attachment=True, download_name=filename) -@sstv_general_bp.route('/images/', methods=['DELETE']) +@sstv_general_bp.route("/images/", methods=["DELETE"]) def delete_image(filename: str): """Delete a decoded SSTV image.""" decoder = get_general_sstv_decoder() # Security: only allow alphanumeric filenames with .png extension - if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): - return api_error('Invalid filename', 400) + if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum(): + return api_error("Invalid filename", 400) - if not filename.endswith('.png'): - return api_error('Only PNG files supported', 400) + if not filename.endswith(".png"): + return api_error("Only PNG files supported", 400) if decoder.delete_image(filename): - return jsonify({'status': 'ok'}) + return jsonify({"status": "ok"}) else: - return api_error('Image not found', 404) + return api_error("Image not found", 404) -@sstv_general_bp.route('/images', methods=['DELETE']) +@sstv_general_bp.route("/images", methods=["DELETE"]) def delete_all_images(): """Delete all decoded SSTV images.""" decoder = get_general_sstv_decoder() count = decoder.delete_all_images() - return jsonify({'status': 'ok', 'deleted': count}) + return jsonify({"status": "ok", "deleted": count}) -@sstv_general_bp.route('/stream') +@sstv_general_bp.route("/stream") def stream_progress(): """SSE stream of SSTV decode progress.""" + def _on_msg(msg: dict[str, Any]) -> None: - process_event('sstv_general', msg, msg.get('type')) + process_event("sstv_general", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=_sstv_general_queue, - channel_key='sstv_general', + channel_key="sstv_general", timeout=1.0, keepalive_interval=30.0, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response -@sstv_general_bp.route('/decode-file', methods=['POST']) +@sstv_general_bp.route("/decode-file", methods=["POST"]) def decode_file(): """Decode SSTV from an uploaded audio file.""" - if 'audio' not in request.files: - return api_error('No audio file provided', 400) + if "audio" not in request.files: + return api_error("No audio file provided", 400) - audio_file = request.files['audio'] + audio_file = request.files["audio"] if not audio_file.filename: - return api_error('No file selected', 400) + return api_error("No file selected", 400) import tempfile - with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp: + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: audio_file.save(tmp.name) tmp_path = tmp.name @@ -313,11 +400,13 @@ def decode_file(): decoder = get_general_sstv_decoder() images = decoder.decode_file(tmp_path) - return jsonify({ - 'status': 'ok', - 'images': [img.to_dict() for img in images], - 'count': len(images), - }) + return jsonify( + { + "status": "ok", + "images": [img.to_dict() for img in images], + "count": len(images), + } + ) except Exception as e: logger.error(f"Error decoding file: {e}") diff --git a/routes/subghz.py b/routes/subghz.py index 146afc6..b534034 100644 --- a/routes/subghz.py +++ b/routes/subghz.py @@ -27,9 +27,9 @@ from utils.responses import api_error from utils.sse import sse_stream from utils.subghz import get_subghz_manager -logger = get_logger('intercept.subghz') +logger = get_logger("intercept.subghz") -subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz') +subghz_bp = Blueprint("subghz", __name__, url_prefix="/subghz") # SSE queue for streaming events to frontend _subghz_queue: queue.Queue = queue.Queue(maxsize=200) @@ -38,7 +38,7 @@ _subghz_queue: queue.Queue = queue.Queue(maxsize=200) def _event_callback(event: dict) -> None: """Forward SubGhzManager events to the SSE queue.""" with contextlib.suppress(Exception): - process_event('subghz', event, event.get('type')) + process_event("subghz", event, event.get("type")) try: _subghz_queue.put_nowait(event) except queue.Full: @@ -49,29 +49,29 @@ def _event_callback(event: dict) -> None: pass -def _validate_frequency_hz(data: dict, key: str = 'frequency_hz') -> tuple[int | None, str | None]: +def _validate_frequency_hz(data: dict, key: str = "frequency_hz") -> tuple[int | None, str | None]: """Validate frequency in Hz from request data. Returns (freq_hz, error_msg).""" raw = data.get(key) if raw is None: - return None, f'{key} is required' + return None, f"{key} is required" try: freq_hz = int(raw) freq_mhz = freq_hz / 1_000_000 if not (SUBGHZ_FREQ_MIN_MHZ <= freq_mhz <= SUBGHZ_FREQ_MAX_MHZ): - return None, f'Frequency must be between {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz' + return None, f"Frequency must be between {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz" return freq_hz, None except (ValueError, TypeError): - return None, f'Invalid {key}' + return None, f"Invalid {key}" def _validate_serial(data: dict) -> str | None: """Extract and validate optional HackRF device serial.""" - serial = data.get('device_serial', '') + serial = data.get("device_serial", "") if not serial or not isinstance(serial, str): return None # HackRF serials are hex strings serial = serial.strip() - if serial and all(c in '0123456789abcdefABCDEF' for c in serial): + if serial and all(c in "0123456789abcdefABCDEF" for c in serial): return serial return None @@ -85,24 +85,24 @@ def _validate_int(data: dict, key: str, default: int, min_val: int, max_val: int return default -def _validate_decode_profile(data: dict, default: str = 'weather') -> str: - profile = data.get('decode_profile', default) +def _validate_decode_profile(data: dict, default: str = "weather") -> str: + profile = data.get("decode_profile", default) if not isinstance(profile, str): return default profile = profile.strip().lower() - if profile in {'weather', 'all'}: + if profile in {"weather", "all"}: return profile return default def _validate_optional_float(data: dict, key: str) -> tuple[float | None, str | None]: raw = data.get(key) - if raw is None or raw == '': + if raw is None or raw == "": return None, None try: return float(raw), None except (ValueError, TypeError): - return None, f'Invalid {key}' + return None, f"Invalid {key}" def _validate_bool(data: dict, key: str, default: bool = False) -> bool: @@ -112,7 +112,7 @@ def _validate_bool(data: dict, key: str, default: bool = False) -> bool: if isinstance(raw, (int, float)): return bool(raw) if isinstance(raw, str): - return raw.strip().lower() in {'1', 'true', 'yes', 'on', 'enabled'} + return raw.strip().lower() in {"1", "true", "yes", "on", "enabled"} return default @@ -120,22 +120,24 @@ def _validate_bool(data: dict, key: str, default: bool = False) -> bool: # STATUS # ------------------------------------------------------------------ -@subghz_bp.route('/status') + +@subghz_bp.route("/status") def get_status(): manager = get_subghz_manager() return jsonify(manager.get_status()) -@subghz_bp.route('/presets') +@subghz_bp.route("/presets") def get_presets(): - return jsonify({'presets': SUBGHZ_PRESETS, 'sample_rates': SUBGHZ_SAMPLE_RATES}) + return jsonify({"presets": SUBGHZ_PRESETS, "sample_rates": SUBGHZ_SAMPLE_RATES}) # ------------------------------------------------------------------ # RECEIVE # ------------------------------------------------------------------ -@subghz_bp.route('/receive/start', methods=['POST']) + +@subghz_bp.route("/receive/start", methods=["POST"]) def start_receive(): data = request.get_json(silent=True) or {} @@ -143,12 +145,12 @@ def start_receive(): if err: return api_error(err, 400) - sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000) - lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX) - vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX) - trigger_enabled = _validate_bool(data, 'trigger_enabled', False) - trigger_pre_ms = _validate_int(data, 'trigger_pre_ms', 350, 50, 5000) - trigger_post_ms = _validate_int(data, 'trigger_post_ms', 700, 100, 10000) + sample_rate = _validate_int(data, "sample_rate", 2000000, 2000000, 20000000) + lna_gain = _validate_int(data, "lna_gain", 32, 0, SUBGHZ_LNA_GAIN_MAX) + vga_gain = _validate_int(data, "vga_gain", 20, 0, SUBGHZ_VGA_GAIN_MAX) + trigger_enabled = _validate_bool(data, "trigger_enabled", False) + trigger_pre_ms = _validate_int(data, "trigger_pre_ms", 350, 50, 5000) + trigger_post_ms = _validate_int(data, "trigger_post_ms", 700, 100, 10000) device_serial = _validate_serial(data) manager = get_subghz_manager() @@ -165,11 +167,11 @@ def start_receive(): device_serial=device_serial, ) - status_code = 200 if result.get('status') != 'error' else 409 + status_code = 200 if result.get("status") != "error" else 409 return jsonify(result), status_code -@subghz_bp.route('/receive/stop', methods=['POST']) +@subghz_bp.route("/receive/stop", methods=["POST"]) def stop_receive(): manager = get_subghz_manager() result = manager.stop_receive() @@ -180,7 +182,8 @@ def stop_receive(): # DECODE # ------------------------------------------------------------------ -@subghz_bp.route('/decode/start', methods=['POST']) + +@subghz_bp.route("/decode/start", methods=["POST"]) def start_decode(): data = request.get_json(silent=True) or {} @@ -188,9 +191,9 @@ def start_decode(): if err: return api_error(err, 400) - sample_rate = _validate_int(data, 'sample_rate', 2000000, 2000000, 20000000) - lna_gain = _validate_int(data, 'lna_gain', 32, 0, SUBGHZ_LNA_GAIN_MAX) - vga_gain = _validate_int(data, 'vga_gain', 20, 0, SUBGHZ_VGA_GAIN_MAX) + sample_rate = _validate_int(data, "sample_rate", 2000000, 2000000, 20000000) + lna_gain = _validate_int(data, "lna_gain", 32, 0, SUBGHZ_LNA_GAIN_MAX) + vga_gain = _validate_int(data, "vga_gain", 20, 0, SUBGHZ_VGA_GAIN_MAX) decode_profile = _validate_decode_profile(data) device_serial = _validate_serial(data) @@ -206,11 +209,11 @@ def start_decode(): device_serial=device_serial, ) - status_code = 200 if result.get('status') != 'error' else 409 + status_code = 200 if result.get("status") != "error" else 409 return jsonify(result), status_code -@subghz_bp.route('/decode/stop', methods=['POST']) +@subghz_bp.route("/decode/stop", methods=["POST"]) def stop_decode(): manager = get_subghz_manager() result = manager.stop_decode() @@ -221,24 +224,25 @@ def stop_decode(): # TRANSMIT # ------------------------------------------------------------------ -@subghz_bp.route('/transmit', methods=['POST']) + +@subghz_bp.route("/transmit", methods=["POST"]) def start_transmit(): data = request.get_json(silent=True) or {} - capture_id = data.get('capture_id') + capture_id = data.get("capture_id") if not capture_id or not isinstance(capture_id, str): - return api_error('capture_id is required', 400) + return api_error("capture_id is required", 400) # Sanitize capture_id if not capture_id.isalnum(): - return api_error('Invalid capture_id', 400) + return api_error("Invalid capture_id", 400) - tx_gain = _validate_int(data, 'tx_gain', 20, 0, SUBGHZ_TX_VGA_GAIN_MAX) - max_duration = _validate_int(data, 'max_duration', 10, 1, SUBGHZ_TX_MAX_DURATION) - start_seconds, start_err = _validate_optional_float(data, 'start_seconds') + tx_gain = _validate_int(data, "tx_gain", 20, 0, SUBGHZ_TX_VGA_GAIN_MAX) + max_duration = _validate_int(data, "max_duration", 10, 1, SUBGHZ_TX_MAX_DURATION) + start_seconds, start_err = _validate_optional_float(data, "start_seconds") if start_err: return api_error(start_err, 400) - duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds') + duration_seconds, duration_err = _validate_optional_float(data, "duration_seconds") if duration_err: return api_error(duration_err, 400) device_serial = _validate_serial(data) @@ -255,11 +259,11 @@ def start_transmit(): device_serial=device_serial, ) - status_code = 200 if result.get('status') != 'error' else 400 + status_code = 200 if result.get("status") != "error" else 400 return jsonify(result), status_code -@subghz_bp.route('/transmit/stop', methods=['POST']) +@subghz_bp.route("/transmit/stop", methods=["POST"]) def stop_transmit(): manager = get_subghz_manager() result = manager.stop_transmit() @@ -270,21 +274,22 @@ def stop_transmit(): # SWEEP # ------------------------------------------------------------------ -@subghz_bp.route('/sweep/start', methods=['POST']) + +@subghz_bp.route("/sweep/start", methods=["POST"]) def start_sweep(): data = request.get_json(silent=True) or {} try: - freq_start = float(data.get('freq_start_mhz', 300)) - freq_end = float(data.get('freq_end_mhz', 928)) + freq_start = float(data.get("freq_start_mhz", 300)) + freq_end = float(data.get("freq_end_mhz", 928)) if freq_start >= freq_end: - return api_error('freq_start must be less than freq_end', 400) + return api_error("freq_start must be less than freq_end", 400) if freq_start < SUBGHZ_FREQ_MIN_MHZ or freq_end > SUBGHZ_FREQ_MAX_MHZ: - return api_error(f'Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz', 400) + return api_error(f"Frequency range: {SUBGHZ_FREQ_MIN_MHZ}-{SUBGHZ_FREQ_MAX_MHZ} MHz", 400) except (ValueError, TypeError): - return api_error('Invalid frequency range', 400) + return api_error("Invalid frequency range", 400) - bin_width = _validate_int(data, 'bin_width', 100000, 10000, 5000000) + bin_width = _validate_int(data, "bin_width", 100000, 10000, 5000000) device_serial = _validate_serial(data) manager = get_subghz_manager() @@ -297,11 +302,11 @@ def start_sweep(): device_serial=device_serial, ) - status_code = 200 if result.get('status') != 'error' else 409 + status_code = 200 if result.get("status") != "error" else 409 return jsonify(result), status_code -@subghz_bp.route('/sweep/stop', methods=['POST']) +@subghz_bp.route("/sweep/stop", methods=["POST"]) def stop_sweep(): manager = get_subghz_manager() result = manager.stop_sweep() @@ -312,66 +317,69 @@ def stop_sweep(): # CAPTURES LIBRARY # ------------------------------------------------------------------ -@subghz_bp.route('/captures') + +@subghz_bp.route("/captures") def list_captures(): manager = get_subghz_manager() captures = manager.list_captures() - return jsonify({ - 'status': 'ok', - 'captures': [c.to_dict() for c in captures], - 'count': len(captures), - }) + return jsonify( + { + "status": "ok", + "captures": [c.to_dict() for c in captures], + "count": len(captures), + } + ) -@subghz_bp.route('/captures/') +@subghz_bp.route("/captures/") def get_capture(capture_id: str): if not capture_id.isalnum(): - return api_error('Invalid capture_id', 400) + return api_error("Invalid capture_id", 400) manager = get_subghz_manager() capture = manager.get_capture(capture_id) if not capture: - return api_error('Capture not found', 404) + return api_error("Capture not found", 404) - return jsonify({'status': 'ok', 'capture': capture.to_dict()}) + return jsonify({"status": "ok", "capture": capture.to_dict()}) -@subghz_bp.route('/captures//download') +@subghz_bp.route("/captures//download") def download_capture(capture_id: str): if not capture_id.isalnum(): - return api_error('Invalid capture_id', 400) + return api_error("Invalid capture_id", 400) manager = get_subghz_manager() path = manager.get_capture_path(capture_id) if not path: - return api_error('Capture not found', 404) + return api_error("Capture not found", 404) return send_file( path, - mimetype='application/octet-stream', + mimetype="application/octet-stream", as_attachment=True, download_name=path.name, ) -@subghz_bp.route('/captures//trim', methods=['POST']) +@subghz_bp.route("/captures//trim", methods=["POST"]) def trim_capture(capture_id: str): if not capture_id.isalnum(): - return api_error('Invalid capture_id', 400) + return api_error("Invalid capture_id", 400) data = request.get_json(silent=True) or {} - start_seconds, start_err = _validate_optional_float(data, 'start_seconds') + start_seconds, start_err = _validate_optional_float(data, "start_seconds") if start_err: return api_error(start_err, 400) - duration_seconds, duration_err = _validate_optional_float(data, 'duration_seconds') + duration_seconds, duration_err = _validate_optional_float(data, "duration_seconds") if duration_err: return api_error(duration_err, 400) - label = data.get('label', '') + label = data.get("label", "") if label is None: - label = '' + label = "" if not isinstance(label, str) or len(label) > 100: - return api_error('Label must be a string (max 100 chars)', 400) + return api_error("Label must be a string (max 100 chars)", 400) manager = get_subghz_manager() result = manager.trim_capture( @@ -381,49 +389,50 @@ def trim_capture(capture_id: str): label=label, ) - if result.get('status') == 'ok': + if result.get("status") == "ok": return jsonify(result), 200 - message = str(result.get('message') or 'Trim failed') - status_code = 404 if 'not found' in message.lower() else 400 + message = str(result.get("message") or "Trim failed") + status_code = 404 if "not found" in message.lower() else 400 return jsonify(result), status_code -@subghz_bp.route('/captures/', methods=['DELETE']) +@subghz_bp.route("/captures/", methods=["DELETE"]) def delete_capture(capture_id: str): if not capture_id.isalnum(): - return api_error('Invalid capture_id', 400) + return api_error("Invalid capture_id", 400) manager = get_subghz_manager() if manager.delete_capture(capture_id): - return jsonify({'status': 'deleted', 'id': capture_id}) - return api_error('Capture not found', 404) + return jsonify({"status": "deleted", "id": capture_id}) + return api_error("Capture not found", 404) -@subghz_bp.route('/captures/', methods=['PATCH']) +@subghz_bp.route("/captures/", methods=["PATCH"]) def update_capture(capture_id: str): if not capture_id.isalnum(): - return api_error('Invalid capture_id', 400) + return api_error("Invalid capture_id", 400) data = request.get_json(silent=True) or {} - label = data.get('label', '') + label = data.get("label", "") if not isinstance(label, str) or len(label) > 100: - return api_error('Label must be a string (max 100 chars)', 400) + return api_error("Label must be a string (max 100 chars)", 400) manager = get_subghz_manager() if manager.update_capture_label(capture_id, label): - return jsonify({'status': 'updated', 'id': capture_id, 'label': label}) - return api_error('Capture not found', 404) + return jsonify({"status": "updated", "id": capture_id, "label": label}) + return api_error("Capture not found", 404) # ------------------------------------------------------------------ # SSE STREAM # ------------------------------------------------------------------ -@subghz_bp.route('/stream') + +@subghz_bp.route("/stream") def stream(): - response = Response(sse_stream(_subghz_queue), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response = Response(sse_stream(_subghz_queue), mimetype="text/event-stream") + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response diff --git a/routes/tscm/__init__.py b/routes/tscm/__init__.py index 81b5e21..8e1a954 100644 --- a/routes/tscm/__init__.py +++ b/routes/tscm/__init__.py @@ -67,13 +67,14 @@ from utils.tscm.device_identity import ( # Import unified Bluetooth scanner helper for TSCM integration try: from routes.bluetooth_v2 import get_tscm_bluetooth_snapshot + _USE_UNIFIED_BT_SCANNER = True except ImportError: _USE_UNIFIED_BT_SCANNER = False -logger = logging.getLogger('intercept.tscm') +logger = logging.getLogger("intercept.tscm") -tscm_bp = Blueprint('tscm', __name__, url_prefix='/tscm') +tscm_bp = Blueprint("tscm", __name__, url_prefix="/tscm") try: from zoneinfo import ZoneInfo @@ -109,11 +110,7 @@ def _emit_event(event_type: str, data: dict) -> None: """Emit an event to the SSE queue.""" if tscm_queue: try: - tscm_queue.put_nowait({ - 'type': event_type, - 'timestamp': datetime.now().isoformat(), - **data - }) + tscm_queue.put_nowait({"type": event_type, "timestamp": datetime.now().isoformat(), **data}) except queue.Full: logger.warning("TSCM queue full, dropping event") @@ -122,6 +119,7 @@ def _emit_event(event_type: str, data: dict) -> None: # Schedule Helpers # ============================================================================= + def _get_schedule_timezone(zone_name: str | None) -> Any: """Resolve schedule timezone from a zone name or fallback to local.""" if zone_name and ZoneInfo: @@ -139,13 +137,13 @@ def _parse_cron_field(field: str, min_value: int, max_value: int) -> set[int]: raise ValueError("Empty cron field") values: set[int] = set() - parts = field.split(',') + parts = field.split(",") for part in parts: part = part.strip() - if part == '*': + if part == "*": values.update(range(min_value, max_value + 1)) continue - if part.startswith('*/'): + if part.startswith("*/"): step = int(part[2:]) if step <= 0: raise ValueError("Invalid step value") @@ -153,13 +151,13 @@ def _parse_cron_field(field: str, min_value: int, max_value: int) -> set[int]: continue range_part = part step = 1 - if '/' in part: - range_part, step_str = part.split('/', 1) + if "/" in part: + range_part, step_str = part.split("/", 1) step = int(step_str) if step <= 0: raise ValueError("Invalid step value") - if '-' in range_part: - start_str, end_str = range_part.split('-', 1) + if "-" in range_part: + start_str, end_str = range_part.split("-", 1) start = int(start_str) end = int(end_str) if start > end: @@ -173,51 +171,51 @@ def _parse_cron_field(field: str, min_value: int, max_value: int) -> set[int]: def _parse_cron_expression(expr: str) -> tuple[dict[str, set[int]], dict[str, bool]]: """Parse a cron expression into value sets and wildcard flags.""" - fields = (expr or '').split() + fields = (expr or "").split() if len(fields) != 5: raise ValueError("Cron expression must have 5 fields") minute_field, hour_field, dom_field, month_field, dow_field = fields sets = { - 'minute': _parse_cron_field(minute_field, 0, 59), - 'hour': _parse_cron_field(hour_field, 0, 23), - 'dom': _parse_cron_field(dom_field, 1, 31), - 'month': _parse_cron_field(month_field, 1, 12), - 'dow': _parse_cron_field(dow_field, 0, 7), + "minute": _parse_cron_field(minute_field, 0, 59), + "hour": _parse_cron_field(hour_field, 0, 23), + "dom": _parse_cron_field(dom_field, 1, 31), + "month": _parse_cron_field(month_field, 1, 12), + "dow": _parse_cron_field(dow_field, 0, 7), } # Normalize Sunday (7 -> 0) - if 7 in sets['dow']: - sets['dow'].add(0) - sets['dow'].discard(7) + if 7 in sets["dow"]: + sets["dow"].add(0) + sets["dow"].discard(7) wildcards = { - 'dom': dom_field.strip() == '*', - 'dow': dow_field.strip() == '*', + "dom": dom_field.strip() == "*", + "dow": dow_field.strip() == "*", } return sets, wildcards def _cron_matches(dt: datetime, sets: dict[str, set[int]], wildcards: dict[str, bool]) -> bool: """Check if a datetime matches cron sets.""" - if dt.minute not in sets['minute']: + if dt.minute not in sets["minute"]: return False - if dt.hour not in sets['hour']: + if dt.hour not in sets["hour"]: return False - if dt.month not in sets['month']: + if dt.month not in sets["month"]: return False - dom_match = dt.day in sets['dom'] + dom_match = dt.day in sets["dom"] # Cron DOW: Sunday=0 cron_dow = (dt.weekday() + 1) % 7 - dow_match = cron_dow in sets['dow'] + dow_match = cron_dow in sets["dow"] - if wildcards['dom'] and wildcards['dow']: + if wildcards["dom"] and wildcards["dow"]: return True - if wildcards['dom']: + if wildcards["dom"]: return dow_match - if wildcards['dow']: + if wildcards["dow"]: return dom_match return dom_match or dow_match @@ -258,12 +256,12 @@ def _schedule_loop() -> None: now_utc = datetime.now(timezone.utc) for schedule in schedules: - schedule_id = schedule.get('id') - cron_expr = schedule.get('cron_expression') or '' - tz = _get_schedule_timezone(schedule.get('zone_name')) + schedule_id = schedule.get("id") + cron_expr = schedule.get("cron_expression") or "" + tz = _get_schedule_timezone(schedule.get("zone_name")) now_local = datetime.now(tz) - next_run = _parse_schedule_timestamp(schedule.get('next_run')) + next_run = _parse_schedule_timestamp(schedule.get("next_run")) if not next_run: try: @@ -272,10 +270,7 @@ def _schedule_loop() -> None: logger.error(f"Schedule {schedule_id} cron parse error: {e}") continue if computed: - update_tscm_schedule( - schedule_id, - next_run=computed.astimezone(timezone.utc).isoformat() - ) + update_tscm_schedule(schedule_id, next_run=computed.astimezone(timezone.utc).isoformat()) continue if next_run <= now_utc: @@ -287,26 +282,23 @@ def _schedule_loop() -> None: logger.error(f"Schedule {schedule_id} cron parse error: {e}") continue if computed: - update_tscm_schedule( - schedule_id, - next_run=computed.astimezone(timezone.utc).isoformat() - ) + update_tscm_schedule(schedule_id, next_run=computed.astimezone(timezone.utc).isoformat()) continue # Trigger sweep result = _start_sweep_internal( - sweep_type=schedule.get('sweep_type') or 'standard', - baseline_id=schedule.get('baseline_id'), + sweep_type=schedule.get("sweep_type") or "standard", + baseline_id=schedule.get("baseline_id"), wifi_enabled=True, bt_enabled=True, rf_enabled=True, - wifi_interface='', - bt_interface='', + wifi_interface="", + bt_interface="", sdr_device=None, - verbose_results=False + verbose_results=False, ) - if result.get('status') == 'success': + if result.get("status") == "success": try: computed = _next_run_from_cron(cron_expr, now_local) except Exception as e: @@ -316,7 +308,7 @@ def _schedule_loop() -> None: update_tscm_schedule( schedule_id, last_run=now_utc.isoformat(), - next_run=computed.astimezone(timezone.utc).isoformat() if computed else None + next_run=computed.astimezone(timezone.utc).isoformat() if computed else None, ) logger.info(f"Scheduled sweep started for schedule {schedule_id}") else: @@ -326,10 +318,7 @@ def _schedule_loop() -> None: logger.error(f"Schedule {schedule_id} cron parse error: {e}") computed = None if computed: - update_tscm_schedule( - schedule_id, - next_run=computed.astimezone(timezone.utc).isoformat() - ) + update_tscm_schedule(schedule_id, next_run=computed.astimezone(timezone.utc).isoformat()) logger.warning(f"Scheduled sweep failed for schedule {schedule_id}: {result.get('message')}") except Exception as e: @@ -352,6 +341,7 @@ def start_tscm_scheduler() -> None: # Sweep Helpers (used by sweep routes and schedule loop) # ============================================================================= + def _check_available_devices(wifi: bool, bt: bool, rf: bool) -> dict: """Check which scanning devices are available.""" import os @@ -360,122 +350,114 @@ def _check_available_devices(wifi: bool, bt: bool, rf: bool) -> dict: import subprocess available = { - 'wifi': False, - 'bluetooth': False, - 'rf': False, - 'wifi_reason': 'Not checked', - 'bt_reason': 'Not checked', - 'rf_reason': 'Not checked', + "wifi": False, + "bluetooth": False, + "rf": False, + "wifi_reason": "Not checked", + "bt_reason": "Not checked", + "rf_reason": "Not checked", } # Check WiFi - use the same scanner singleton that performs actual scans if wifi: try: from utils.wifi.scanner import get_wifi_scanner + scanner = get_wifi_scanner() interfaces = scanner._detect_interfaces() if interfaces: - available['wifi'] = True - available['wifi_reason'] = f'WiFi available ({interfaces[0]["name"]})' + available["wifi"] = True + available["wifi_reason"] = f"WiFi available ({interfaces[0]['name']})" else: - available['wifi_reason'] = 'No wireless interfaces found' + available["wifi_reason"] = "No wireless interfaces found" except Exception as e: - available['wifi_reason'] = f'WiFi detection error: {e}' + available["wifi_reason"] = f"WiFi detection error: {e}" # Check Bluetooth if bt: - if platform.system() == 'Darwin': + if platform.system() == "Darwin": # macOS: Check for Bluetooth via system_profiler try: result = subprocess.run( - ['system_profiler', 'SPBluetoothDataType'], - capture_output=True, - text=True, - timeout=10 + ["system_profiler", "SPBluetoothDataType"], capture_output=True, text=True, timeout=10 ) - if 'Bluetooth' in result.stdout and result.returncode == 0: - available['bluetooth'] = True - available['bt_reason'] = 'macOS Bluetooth available' + if "Bluetooth" in result.stdout and result.returncode == 0: + available["bluetooth"] = True + available["bt_reason"] = "macOS Bluetooth available" else: - available['bt_reason'] = 'Bluetooth not available' + available["bt_reason"] = "Bluetooth not available" except (subprocess.TimeoutExpired, FileNotFoundError): - available['bt_reason'] = 'Cannot detect Bluetooth' + available["bt_reason"] = "Cannot detect Bluetooth" else: # Linux: Check for Bluetooth tools - if shutil.which('bluetoothctl') or shutil.which('hcitool') or shutil.which('hciconfig'): + if shutil.which("bluetoothctl") or shutil.which("hcitool") or shutil.which("hciconfig"): try: - result = subprocess.run( - ['hciconfig'], - capture_output=True, - text=True, - timeout=5 - ) - if 'hci' in result.stdout.lower(): - available['bluetooth'] = True - available['bt_reason'] = 'Bluetooth adapter detected' + result = subprocess.run(["hciconfig"], capture_output=True, text=True, timeout=5) + if "hci" in result.stdout.lower(): + available["bluetooth"] = True + available["bt_reason"] = "Bluetooth adapter detected" else: - available['bt_reason'] = 'No Bluetooth adapters found' + available["bt_reason"] = "No Bluetooth adapters found" except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError): # Try bluetoothctl as fallback try: - result = subprocess.run( - ['bluetoothctl', 'list'], - capture_output=True, - text=True, - timeout=5 - ) + result = subprocess.run(["bluetoothctl", "list"], capture_output=True, text=True, timeout=5) if result.stdout.strip(): - available['bluetooth'] = True - available['bt_reason'] = 'Bluetooth adapter detected' + available["bluetooth"] = True + available["bt_reason"] = "Bluetooth adapter detected" else: # Check /sys for Bluetooth try: import glob - bt_devs = glob.glob('/sys/class/bluetooth/hci*') + + bt_devs = glob.glob("/sys/class/bluetooth/hci*") if bt_devs: - available['bluetooth'] = True - available['bt_reason'] = 'Bluetooth adapter detected' + available["bluetooth"] = True + available["bt_reason"] = "Bluetooth adapter detected" else: - available['bt_reason'] = 'No Bluetooth adapters found' + available["bt_reason"] = "No Bluetooth adapters found" except Exception: - available['bt_reason'] = 'No Bluetooth adapters found' + available["bt_reason"] = "No Bluetooth adapters found" except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError): # Check /sys for Bluetooth try: import glob - bt_devs = glob.glob('/sys/class/bluetooth/hci*') + + bt_devs = glob.glob("/sys/class/bluetooth/hci*") if bt_devs: - available['bluetooth'] = True - available['bt_reason'] = 'Bluetooth adapter detected' + available["bluetooth"] = True + available["bt_reason"] = "Bluetooth adapter detected" else: - available['bt_reason'] = 'Cannot detect Bluetooth adapters' + available["bt_reason"] = "Cannot detect Bluetooth adapters" except Exception: - available['bt_reason'] = 'Cannot detect Bluetooth adapters' + available["bt_reason"] = "Cannot detect Bluetooth adapters" else: # Fallback: check /sys even without tools try: import glob - bt_devs = glob.glob('/sys/class/bluetooth/hci*') + + bt_devs = glob.glob("/sys/class/bluetooth/hci*") if bt_devs: - available['bluetooth'] = True - available['bt_reason'] = 'Bluetooth adapter detected (no scan tools)' + available["bluetooth"] = True + available["bt_reason"] = "Bluetooth adapter detected (no scan tools)" else: - available['bt_reason'] = 'Bluetooth tools not installed (bluez)' + available["bt_reason"] = "Bluetooth tools not installed (bluez)" except Exception: - available['bt_reason'] = 'Bluetooth tools not installed (bluez)' + available["bt_reason"] = "Bluetooth tools not installed (bluez)" # Check RF/SDR if rf: try: from utils.sdr import SDRFactory + devices = SDRFactory.detect_devices() if devices: - available['rf'] = True - available['rf_reason'] = f'{len(devices)} SDR device(s) detected' + available["rf"] = True + available["rf_reason"] = f"{len(devices)} SDR device(s) detected" else: - available['rf_reason'] = 'No SDR devices found' + available["rf_reason"] = "No SDR devices found" except ImportError: - available['rf_reason'] = 'SDR detection unavailable' + available["rf_reason"] = "SDR detection unavailable" return available @@ -486,8 +468,8 @@ def _start_sweep_internal( wifi_enabled: bool, bt_enabled: bool, rf_enabled: bool, - wifi_interface: str = '', - bt_interface: str = '', + wifi_interface: str = "", + bt_interface: str = "", sdr_device: int | None = None, verbose_results: bool = False, custom_ranges: list[dict] | None = None, @@ -496,26 +478,26 @@ def _start_sweep_internal( global _sweep_running, _sweep_thread, _current_sweep_id if _sweep_running: - return {'status': 'error', 'message': 'Sweep already running', 'http_status': 409} + return {"status": "error", "message": "Sweep already running", "http_status": 409} # Check for available devices devices = _check_available_devices(wifi_enabled, bt_enabled, rf_enabled) warnings = [] - if wifi_enabled and not devices['wifi']: + if wifi_enabled and not devices["wifi"]: warnings.append(f"WiFi: {devices['wifi_reason']}") - if bt_enabled and not devices['bluetooth']: + if bt_enabled and not devices["bluetooth"]: warnings.append(f"Bluetooth: {devices['bt_reason']}") - if rf_enabled and not devices['rf']: + if rf_enabled and not devices["rf"]: warnings.append(f"RF: {devices['rf_reason']}") # If no devices available at all, return error - if not any([devices['wifi'], devices['bluetooth'], devices['rf']]): + if not any([devices["wifi"], devices["bluetooth"], devices["rf"]]): return { - 'status': 'error', - 'message': 'No scanning devices available', - 'details': warnings, - 'http_status': 400, + "status": "error", + "message": "No scanning devices available", + "details": warnings, + "http_status": 400, } # Create sweep record @@ -524,7 +506,7 @@ def _start_sweep_internal( baseline_id=baseline_id, wifi_enabled=wifi_enabled, bt_enabled=bt_enabled, - rf_enabled=rf_enabled + rf_enabled=rf_enabled, ) _sweep_running = True @@ -532,25 +514,31 @@ def _start_sweep_internal( # Start sweep thread _sweep_thread = threading.Thread( target=_run_sweep, - args=(sweep_type, baseline_id, wifi_enabled, bt_enabled, rf_enabled, - wifi_interface, bt_interface, sdr_device, verbose_results, custom_ranges), - daemon=True + args=( + sweep_type, + baseline_id, + wifi_enabled, + bt_enabled, + rf_enabled, + wifi_interface, + bt_interface, + sdr_device, + verbose_results, + custom_ranges, + ), + daemon=True, ) _sweep_thread.start() logger.info(f"Started TSCM sweep: type={sweep_type}, id={_current_sweep_id}") return { - 'status': 'success', - 'message': 'Sweep started', - 'sweep_id': _current_sweep_id, - 'sweep_type': sweep_type, - 'warnings': warnings if warnings else None, - 'devices': { - 'wifi': devices['wifi'], - 'bluetooth': devices['bluetooth'], - 'rf': devices['rf'] - } + "status": "success", + "message": "Sweep started", + "sweep_id": _current_sweep_id, + "sweep_type": sweep_type, + "warnings": warnings if warnings else None, + "devices": {"wifi": devices["wifi"], "bluetooth": devices["bluetooth"], "rf": devices["rf"]}, } @@ -592,10 +580,11 @@ def _scan_wifi_networks(interface: str) -> list[dict]: # Start a short deep scan if not scanner.is_scanning: - scanner.start_deep_scan(interface=interface, band='all') + scanner.start_deep_scan(interface=interface, band="all") # Wait briefly for some results import time + time.sleep(5) # Get current access points @@ -680,30 +669,30 @@ def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]: ble_devices = scan_ble_devices(duration) for ble_dev in ble_devices: - mac = ble_dev.get('mac', '').upper() + mac = ble_dev.get("mac", "").upper() if mac and mac not in seen_macs: seen_macs.add(mac) device = { - 'mac': mac, - 'name': ble_dev.get('name', 'Unknown'), - 'rssi': ble_dev.get('rssi'), - 'type': 'ble', - 'manufacturer': ble_dev.get('manufacturer_name'), - 'manufacturer_id': ble_dev.get('manufacturer_id'), - 'is_tracker': ble_dev.get('is_tracker', False), - 'tracker_type': ble_dev.get('tracker_type'), - 'is_airtag': ble_dev.get('is_airtag', False), - 'is_tile': ble_dev.get('is_tile', False), - 'is_smarttag': ble_dev.get('is_smarttag', False), - 'is_espressif': ble_dev.get('is_espressif', False), - 'service_uuids': ble_dev.get('service_uuids', []), + "mac": mac, + "name": ble_dev.get("name", "Unknown"), + "rssi": ble_dev.get("rssi"), + "type": "ble", + "manufacturer": ble_dev.get("manufacturer_name"), + "manufacturer_id": ble_dev.get("manufacturer_id"), + "is_tracker": ble_dev.get("is_tracker", False), + "tracker_type": ble_dev.get("tracker_type"), + "is_airtag": ble_dev.get("is_airtag", False), + "is_tile": ble_dev.get("is_tile", False), + "is_smarttag": ble_dev.get("is_smarttag", False), + "is_espressif": ble_dev.get("is_espressif", False), + "service_uuids": ble_dev.get("service_uuids", []), } devices.append(device) if devices: logger.info(f"BLE scanner found {len(devices)} devices") - trackers = [d for d in devices if d.get('is_tracker')] + trackers = [d for d in devices if d.get("is_tracker")] if trackers: logger.info(f"Trackers detected: {[d.get('tracker_type') for d in trackers]}") return devices @@ -713,96 +702,92 @@ def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]: except Exception as e: logger.warning(f"BLE scanner failed: {e}, using fallback") - if platform.system() == 'Darwin': + if platform.system() == "Darwin": # macOS: Use system_profiler for basic Bluetooth info try: result = subprocess.run( - ['system_profiler', 'SPBluetoothDataType', '-json'], - capture_output=True, text=True, timeout=15 + ["system_profiler", "SPBluetoothDataType", "-json"], capture_output=True, text=True, timeout=15 ) import json + data = json.loads(result.stdout) - bt_data = data.get('SPBluetoothDataType', [{}])[0] + bt_data = data.get("SPBluetoothDataType", [{}])[0] # Get connected/paired devices - for section in ['device_connected', 'device_title']: + for section in ["device_connected", "device_title"]: section_data = bt_data.get(section, {}) if isinstance(section_data, dict): for name, info in section_data.items(): if isinstance(info, dict): - mac = info.get('device_address', '') + mac = info.get("device_address", "") if mac and mac not in seen_macs: seen_macs.add(mac) - devices.append({ - 'mac': mac.upper(), - 'name': name, - 'type': info.get('device_minorType', 'unknown'), - 'connected': section == 'device_connected' - }) + devices.append( + { + "mac": mac.upper(), + "name": name, + "type": info.get("device_minorType", "unknown"), + "connected": section == "device_connected", + } + ) logger.info(f"macOS Bluetooth scan found {len(devices)} devices") except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError) as e: logger.warning(f"macOS Bluetooth scan failed: {e}") else: # Linux: Try multiple methods - iface = interface or 'hci0' + iface = interface or "hci0" # Method 1: Try hcitool scan (simpler, more reliable) - if shutil.which('hcitool'): + if shutil.which("hcitool"): try: logger.info("Trying hcitool scan...") result = subprocess.run( - ['hcitool', '-i', iface, 'scan', '--flush'], - capture_output=True, text=True, timeout=duration + 5 + ["hcitool", "-i", iface, "scan", "--flush"], capture_output=True, text=True, timeout=duration + 5 ) - for line in result.stdout.split('\n'): + for line in result.stdout.split("\n"): line = line.strip() - if line and '\t' in line: - parts = line.split('\t') - if len(parts) >= 1 and ':' in parts[0]: + if line and "\t" in line: + parts = line.split("\t") + if len(parts) >= 1 and ":" in parts[0]: mac = parts[0].strip().upper() - name = parts[1].strip() if len(parts) > 1 else 'Unknown' + name = parts[1].strip() if len(parts) > 1 else "Unknown" if mac not in seen_macs: seen_macs.add(mac) - devices.append({'mac': mac, 'name': name}) + devices.append({"mac": mac, "name": name}) logger.info(f"hcitool scan found {len(devices)} classic BT devices") except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: logger.warning(f"hcitool scan failed: {e}") # Method 2: Try btmgmt for BLE devices - if shutil.which('btmgmt'): + if shutil.which("btmgmt"): try: logger.info("Trying btmgmt find...") - result = subprocess.run( - ['btmgmt', 'find'], - capture_output=True, text=True, timeout=duration + 5 - ) - for line in result.stdout.split('\n'): + result = subprocess.run(["btmgmt", "find"], capture_output=True, text=True, timeout=duration + 5) + for line in result.stdout.split("\n"): # Parse btmgmt output: "dev_found: XX:XX:XX:XX:XX:XX type LE..." - if 'dev_found' in line.lower() or ('type' in line.lower() and ':' in line): + if "dev_found" in line.lower() or ("type" in line.lower() and ":" in line): mac_match = re.search( - r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:' - r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})', - line + r"([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:" + r"[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})", + line, ) if mac_match: mac = mac_match.group(1).upper() if mac not in seen_macs: seen_macs.add(mac) # Try to extract name - name_match = re.search(r'name\s+(.+?)(?:\s|$)', line, re.I) - name = name_match.group(1) if name_match else 'Unknown BLE' - devices.append({ - 'mac': mac, - 'name': name, - 'type': 'ble' if 'le' in line.lower() else 'classic' - }) + name_match = re.search(r"name\s+(.+?)(?:\s|$)", line, re.I) + name = name_match.group(1) if name_match else "Unknown BLE" + devices.append( + {"mac": mac, "name": name, "type": "ble" if "le" in line.lower() else "classic"} + ) logger.info(f"btmgmt found {len(devices)} total devices") except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: logger.warning(f"btmgmt find failed: {e}") # Method 3: Try bluetoothctl as last resort - if not devices and shutil.which('bluetoothctl'): + if not devices and shutil.which("bluetoothctl"): try: import pty import select @@ -810,23 +795,19 @@ def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]: logger.info("Trying bluetoothctl scan...") master_fd, slave_fd = pty.openpty() process = subprocess.Popen( - ['bluetoothctl'], - stdin=slave_fd, - stdout=slave_fd, - stderr=slave_fd, - close_fds=True + ["bluetoothctl"], stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, close_fds=True ) os.close(slave_fd) # Start scanning time.sleep(0.3) - os.write(master_fd, b'power on\n') + os.write(master_fd, b"power on\n") time.sleep(0.3) - os.write(master_fd, b'scan on\n') + os.write(master_fd, b"scan on\n") # Collect devices for specified duration scan_end = time.time() + min(duration, 10) # Cap at 10 seconds - buffer = '' + buffer = "" while time.time() < scan_end: readable, _, _ = select.select([master_fd], [], [], 1.0) @@ -835,38 +816,35 @@ def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]: data = os.read(master_fd, 4096) if not data: break - buffer += data.decode('utf-8', errors='replace') + buffer += data.decode("utf-8", errors="replace") - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) - line = re.sub(r'\x1b\[[0-9;]*m', '', line).strip() + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = re.sub(r"\x1b\[[0-9;]*m", "", line).strip() - if 'Device' in line: + if "Device" in line: match = re.search( - r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:' - r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)', - line + r"([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:" + r"[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)", + line, ) if match: mac = match.group(1).upper() name = match.group(2).strip() # Remove RSSI from name if present - name = re.sub(r'\s*RSSI:\s*-?\d+\s*', '', name).strip() + name = re.sub(r"\s*RSSI:\s*-?\d+\s*", "", name).strip() if mac not in seen_macs: seen_macs.add(mac) - devices.append({ - 'mac': mac, - 'name': name or '[Unknown]' - }) + devices.append({"mac": mac, "name": name or "[Unknown]"}) except OSError: break # Stop scanning and cleanup try: - os.write(master_fd, b'scan off\n') + os.write(master_fd, b"scan off\n") time.sleep(0.2) - os.write(master_fd, b'quit\n') + os.write(master_fd, b"quit\n") except OSError: pass @@ -891,7 +869,7 @@ def _scan_rf_signals( sdr_device: int | None, duration: int = 30, stop_check: callable | None = None, - sweep_ranges: list[dict] | None = None + sweep_ranges: list[dict] | None = None, ) -> list[dict]: """ Scan for RF signals using SDR (rtl_power or hackrf_sweep). @@ -914,8 +892,10 @@ def _scan_rf_signals( """ # Default stop check uses module-level _sweep_running if stop_check is None: + def stop_check(): return not _sweep_running + import os import shutil import subprocess @@ -926,8 +906,8 @@ def _scan_rf_signals( logger.info(f"Starting RF scan (device={sdr_device})") # Detect available SDR devices and sweep tools - rtl_power_path = shutil.which('rtl_power') - hackrf_sweep_path = shutil.which('hackrf_sweep') + rtl_power_path = shutil.which("rtl_power") + hackrf_sweep_path = shutil.which("hackrf_sweep") sdr_type = None sweep_tool_path = None @@ -935,6 +915,7 @@ def _scan_rf_signals( try: from utils.sdr import SDRFactory from utils.sdr.base import SDRType + devices = SDRFactory.detect_devices() rtlsdr_available = any(d.sdr_type == SDRType.RTL_SDR for d in devices) hackrf_available = any(d.sdr_type == SDRType.HACKRF for d in devices) @@ -944,29 +925,32 @@ def _scan_rf_signals( # Pick the best available SDR + sweep tool combo if rtlsdr_available and rtl_power_path: - sdr_type = 'rtlsdr' + sdr_type = "rtlsdr" sweep_tool_path = rtl_power_path logger.info(f"Using RTL-SDR with rtl_power at: {rtl_power_path}") elif hackrf_available and hackrf_sweep_path: - sdr_type = 'hackrf' + sdr_type = "hackrf" sweep_tool_path = hackrf_sweep_path logger.info(f"Using HackRF with hackrf_sweep at: {hackrf_sweep_path}") elif rtl_power_path: # Tool exists but no device detected — try anyway (detection may have failed) - sdr_type = 'rtlsdr' + sdr_type = "rtlsdr" sweep_tool_path = rtl_power_path logger.info("No SDR detected but rtl_power found, attempting RTL-SDR scan") elif hackrf_sweep_path: - sdr_type = 'hackrf' + sdr_type = "hackrf" sweep_tool_path = hackrf_sweep_path logger.info("No SDR detected but hackrf_sweep found, attempting HackRF scan") if not sweep_tool_path: logger.warning("No supported sweep tool found (rtl_power or hackrf_sweep)") - _emit_event('rf_status', { - 'status': 'error', - 'message': 'No SDR sweep tool installed. Install rtl-sdr (rtl_power) or HackRF (hackrf_sweep) for RF scanning.', - }) + _emit_event( + "rf_status", + { + "status": "error", + "message": "No SDR sweep tool installed. Install rtl-sdr (rtl_power) or HackRF (hackrf_sweep) for RF scanning.", + }, + ) return signals # Define frequency bands to scan (in Hz) @@ -976,35 +960,30 @@ def _scan_rf_signals( if sweep_ranges: for rng in sweep_ranges: try: - start_mhz = float(rng.get('start', 0)) - end_mhz = float(rng.get('end', 0)) - step_mhz = float(rng.get('step', 0.1)) - name = rng.get('name') or f"{start_mhz:.1f}-{end_mhz:.1f} MHz" + start_mhz = float(rng.get("start", 0)) + end_mhz = float(rng.get("end", 0)) + step_mhz = float(rng.get("step", 0.1)) + name = rng.get("name") or f"{start_mhz:.1f}-{end_mhz:.1f} MHz" if start_mhz > 0 and end_mhz > start_mhz: bin_size = max(1000, int(step_mhz * 1_000_000)) - scan_bands.append(( - int(start_mhz * 1_000_000), - int(end_mhz * 1_000_000), - bin_size, - name - )) + scan_bands.append((int(start_mhz * 1_000_000), int(end_mhz * 1_000_000), bin_size, name)) except (TypeError, ValueError): continue if not scan_bands: # Fallback: focus on common bug frequencies scan_bands = [ - (88000000, 108000000, 100000, 'FM Broadcast'), # FM bugs - (315000000, 316000000, 10000, '315 MHz ISM'), # US ISM - (433000000, 434000000, 10000, '433 MHz ISM'), # EU ISM - (868000000, 869000000, 10000, '868 MHz ISM'), # EU ISM - (902000000, 928000000, 100000, '915 MHz ISM'), # US ISM - (1200000000, 1300000000, 100000, '1.2 GHz Video'), # Video TX - (2400000000, 2500000000, 500000, '2.4 GHz ISM'), # WiFi/BT/Video + (88000000, 108000000, 100000, "FM Broadcast"), # FM bugs + (315000000, 316000000, 10000, "315 MHz ISM"), # US ISM + (433000000, 434000000, 10000, "433 MHz ISM"), # EU ISM + (868000000, 869000000, 10000, "868 MHz ISM"), # EU ISM + (902000000, 928000000, 100000, "915 MHz ISM"), # US ISM + (1200000000, 1300000000, 100000, "1.2 GHz Video"), # Video TX + (2400000000, 2500000000, 500000, "2.4 GHz ISM"), # WiFi/BT/Video ] # Create temp file for output - with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as tmp: + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as tmp: tmp_path = tmp.name try: @@ -1016,53 +995,55 @@ def _scan_rf_signals( if stop_check(): break - logger.info(f"Scanning {band_name} ({start_freq/1e6:.1f}-{end_freq/1e6:.1f} MHz)") + logger.info(f"Scanning {band_name} ({start_freq / 1e6:.1f}-{end_freq / 1e6:.1f} MHz)") try: # Build sweep command based on SDR type - if sdr_type == 'hackrf': + if sdr_type == "hackrf": cmd = [ sweep_tool_path, - '-f', f'{int(start_freq / 1e6)}:{int(end_freq / 1e6)}', - '-w', str(bin_size), - '-1', # Single sweep + "-f", + f"{int(start_freq / 1e6)}:{int(end_freq / 1e6)}", + "-w", + str(bin_size), + "-1", # Single sweep ] - output_mode = 'stdout' + output_mode = "stdout" else: cmd = [ sweep_tool_path, - '-f', f'{start_freq}:{end_freq}:{bin_size}', - '-g', '40', # Gain - '-i', '1', # Integration interval (1 second) - '-1', # Single shot mode - '-c', '20%', # Crop 20% of edges - '-d', str(device_idx), + "-f", + f"{start_freq}:{end_freq}:{bin_size}", + "-g", + "40", # Gain + "-i", + "1", # Integration interval (1 second) + "-1", # Single shot mode + "-c", + "20%", # Crop 20% of edges + "-d", + str(device_idx), tmp_path, ] - output_mode = 'file' + output_mode = "file" logger.debug(f"Running: {' '.join(cmd)}") - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=30 - ) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: logger.warning(f"{os.path.basename(sweep_tool_path)} returned {result.returncode}: {result.stderr}") # For HackRF, write stdout CSV data to temp file for unified parsing - if output_mode == 'stdout' and result.stdout: - with open(tmp_path, 'w') as f: + if output_mode == "stdout" and result.stdout: + with open(tmp_path, "w") as f: f.write(result.stdout) # Parse the CSV output (same format for both rtl_power and hackrf_sweep) if os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0: with open(tmp_path) as f: for line in f: - parts = line.strip().split(',') + parts = line.strip().split(",") if len(parts) >= 7: try: # CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values... @@ -1080,19 +1061,21 @@ def _scan_rf_signals( freq_hz = hz_low + (idx * hz_step) freq_mhz = freq_hz / 1000000 - signals.append({ - 'frequency': freq_mhz, - 'frequency_hz': freq_hz, - 'power': db, - 'band': band_name, - 'noise_floor': noise_floor, - 'signal_strength': db - noise_floor - }) + signals.append( + { + "frequency": freq_mhz, + "frequency_hz": freq_hz, + "power": db, + "band": band_name, + "noise_floor": noise_floor, + "signal_strength": db - noise_floor, + } + ) except (ValueError, IndexError): continue # Clear file for next band - open(tmp_path, 'w').close() + open(tmp_path, "w").close() except subprocess.TimeoutExpired: logger.warning(f"RF scan timeout for band {band_name}") @@ -1106,12 +1089,12 @@ def _scan_rf_signals( # Deduplicate nearby frequencies (within 100kHz) if signals: - signals.sort(key=lambda x: x['frequency']) + signals.sort(key=lambda x: x["frequency"]) deduped = [signals[0]] for sig in signals[1:]: - if sig['frequency'] - deduped[-1]['frequency'] > 0.1: # 100 kHz + if sig["frequency"] - deduped[-1]["frequency"] > 0.1: # 100 kHz deduped.append(sig) - elif sig['power'] > deduped[-1]['power']: + elif sig["power"] > deduped[-1]["power"]: deduped[-1] = sig # Keep stronger signal signals = deduped @@ -1125,8 +1108,8 @@ def _run_sweep( wifi_enabled: bool, bt_enabled: bool, rf_enabled: bool, - wifi_interface: str = '', - bt_interface: str = '', + wifi_interface: str = "", + bt_interface: str = "", sdr_device: int | None = None, verbose_results: bool = False, custom_ranges: list[dict] | None = None, @@ -1146,17 +1129,20 @@ def _run_sweep( baseline = get_tscm_baseline(baseline_id) # Get sweep preset - preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get('standard') - duration = preset.get('duration_seconds', 300) + preset = get_sweep_preset(sweep_type) or SWEEP_PRESETS.get("standard") + duration = preset.get("duration_seconds", 300) - _emit_event('sweep_started', { - 'sweep_id': _current_sweep_id, - 'sweep_type': sweep_type, - 'duration': duration, - 'wifi': wifi_enabled, - 'bluetooth': bt_enabled, - 'rf': rf_enabled, - }) + _emit_event( + "sweep_started", + { + "sweep_id": _current_sweep_id, + "sweep_type": sweep_type, + "duration": duration, + "wifi": wifi_enabled, + "bluetooth": bt_enabled, + "rf": rf_enabled, + }, + ) # Initialize detector and correlation engine detector = ThreatDetector(baseline) @@ -1168,6 +1154,7 @@ def _run_sweep( identity_engine = get_identity_engine() identity_engine.clear() # Start fresh for this sweep from utils.tscm.advanced import get_timeline_manager + timeline_manager = get_timeline_manager() try: cleanup_old_timeline_entries(72) @@ -1175,7 +1162,7 @@ def _run_sweep( logger.debug(f"TSCM timeline cleanup skipped: {e}") last_timeline_write: dict[str, float] = {} - timeline_bucket = getattr(timeline_manager, 'bucket_seconds', 30) + timeline_bucket = getattr(timeline_manager, "bucket_seconds", 30) def _maybe_store_timeline( identifier: str, @@ -1183,7 +1170,7 @@ def _run_sweep( rssi: int | None = None, channel: int | None = None, frequency: float | None = None, - attributes: dict | None = None + attributes: dict | None = None, ) -> None: if not identifier: return @@ -1204,17 +1191,17 @@ def _run_sweep( rssi=rssi, channel=channel, frequency=frequency, - attributes=attributes + attributes=attributes, ) except Exception as e: logger.debug(f"TSCM timeline store error: {e}") # Collect and analyze data threats_found = 0 - severity_counts = {'critical': 0, 'high': 0, 'medium': 0, 'low': 0} + severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0} all_wifi = {} # Use dict for deduplication by BSSID all_wifi_clients = {} # Use dict for deduplication by client MAC - all_bt = {} # Use dict for deduplication by MAC + all_bt = {} # Use dict for deduplication by MAC all_rf = [] start_time = time.time() @@ -1222,8 +1209,8 @@ def _run_sweep( last_bt_scan = 0 last_rf_scan = 0 wifi_scan_interval = 15 # Scan WiFi every 15 seconds - bt_scan_interval = 20 # Scan Bluetooth every 20 seconds - rf_scan_interval = 30 # Scan RF every 30 seconds + bt_scan_interval = 20 # Scan Bluetooth every 20 seconds + rf_scan_interval = 30 # Scan RF every 30 seconds while _sweep_running and (time.time() - start_time) < duration: current_time = time.time() @@ -1235,39 +1222,43 @@ def _run_sweep( last_wifi_scan = current_time if not wifi_networks and not all_wifi: logger.warning("TSCM WiFi scan returned 0 networks") - _emit_event('sweep_progress', { - 'progress': min(95, int(((current_time - start_time) / duration) * 100)), - 'status': f'Scanning WiFi... ({len(wifi_networks)} found)', - 'wifi_count': len(all_wifi) + len([n for n in wifi_networks if n.get('bssid') and n.get('bssid') not in all_wifi]), - 'bt_count': len(all_bt), - 'rf_count': len(all_rf), - }) + _emit_event( + "sweep_progress", + { + "progress": min(95, int(((current_time - start_time) / duration) * 100)), + "status": f"Scanning WiFi... ({len(wifi_networks)} found)", + "wifi_count": len(all_wifi) + + len([n for n in wifi_networks if n.get("bssid") and n.get("bssid") not in all_wifi]), + "bt_count": len(all_bt), + "rf_count": len(all_rf), + }, + ) for network in wifi_networks: try: - bssid = network.get('bssid', '') - ssid = network.get('essid', network.get('ssid')) + bssid = network.get("bssid", "") + ssid = network.get("essid", network.get("ssid")) try: - rssi_val = int(network.get('power', network.get('signal'))) + rssi_val = int(network.get("power", network.get("signal"))) except (ValueError, TypeError): rssi_val = None if bssid: try: timeline_manager.add_observation( identifier=bssid, - protocol='wifi', + protocol="wifi", rssi=rssi_val, - channel=network.get('channel'), + channel=network.get("channel"), name=ssid, - attributes={'ssid': ssid, 'encryption': network.get('privacy')} + attributes={"ssid": ssid, "encryption": network.get("privacy")}, ) except Exception as e: logger.debug(f"WiFi timeline observation error: {e}") _maybe_store_timeline( identifier=bssid, - protocol='wifi', + protocol="wifi", rssi=rssi_val, - channel=network.get('channel'), - attributes={'ssid': ssid, 'encryption': network.get('privacy')} + channel=network.get("channel"), + attributes={"ssid": ssid, "encryption": network.get("privacy")}, ) if bssid and bssid not in all_wifi: all_wifi[bssid] = network @@ -1279,7 +1270,7 @@ def _run_sweep( _handle_threat(threat) threats_found += 1 is_threat = True - sev = threat.get('severity', 'low').lower() + sev = threat.get("severity", "low").lower() if sev in severity_counts: severity_counts[sev] += 1 # Classify device and get correlation profile @@ -1290,38 +1281,43 @@ def _run_sweep( # Note: WiFi APs don't typically use randomized MACs, but clients do try: wifi_obs = { - 'timestamp': datetime.now().isoformat(), - 'src_mac': bssid, - 'bssid': bssid, - 'ssid': network.get('essid'), - 'rssi': network.get('power'), - 'channel': network.get('channel'), - 'encryption': network.get('privacy'), - 'frame_type': 'beacon', + "timestamp": datetime.now().isoformat(), + "src_mac": bssid, + "bssid": bssid, + "ssid": network.get("essid"), + "rssi": network.get("power"), + "channel": network.get("channel"), + "encryption": network.get("privacy"), + "frame_type": "beacon", } ingest_wifi_dict(wifi_obs) except Exception as e: logger.debug(f"Identity engine WiFi ingest error: {e}") # Send device to frontend - _emit_event('wifi_device', { - 'bssid': bssid, - 'ssid': network.get('essid', 'Hidden'), - 'channel': network.get('channel', ''), - 'signal': network.get('power', ''), - 'security': network.get('privacy', ''), - 'vendor': network.get('vendor'), - 'is_threat': is_threat, - 'is_new': not classification.get('in_baseline', False), - 'classification': profile.risk_level.value, - 'reasons': classification.get('reasons', []), - 'score': profile.total_score, - 'score_modifier': profile.score_modifier, - 'known_device': profile.known_device, - 'known_device_name': profile.known_device_name, - 'indicators': [{'type': i.type.value, 'desc': i.description} for i in profile.indicators], - 'recommended_action': profile.recommended_action, - }) + _emit_event( + "wifi_device", + { + "bssid": bssid, + "ssid": network.get("essid", "Hidden"), + "channel": network.get("channel", ""), + "signal": network.get("power", ""), + "security": network.get("privacy", ""), + "vendor": network.get("vendor"), + "is_threat": is_threat, + "is_new": not classification.get("in_baseline", False), + "classification": profile.risk_level.value, + "reasons": classification.get("reasons", []), + "score": profile.total_score, + "score_modifier": profile.score_modifier, + "known_device": profile.known_device, + "known_device_name": profile.known_device_name, + "indicators": [ + {"type": i.type.value, "desc": i.description} for i in profile.indicators + ], + "recommended_action": profile.recommended_action, + }, + ) except Exception as e: logger.error(f"WiFi device processing error for {network.get('bssid', '?')}: {e}") @@ -1329,70 +1325,72 @@ def _run_sweep( try: wifi_clients = _scan_wifi_clients(wifi_interface) for client in wifi_clients: - mac = (client.get('mac') or '').upper() + mac = (client.get("mac") or "").upper() if not mac or mac in all_wifi_clients: continue all_wifi_clients[mac] = client - rssi_val = client.get('rssi_current') + rssi_val = client.get("rssi_current") if rssi_val is None: - rssi_val = client.get('rssi_median') or client.get('rssi_ema') + rssi_val = client.get("rssi_median") or client.get("rssi_ema") client_device = { - 'mac': mac, - 'vendor': client.get('vendor'), - 'name': client.get('vendor') or 'WiFi Client', - 'rssi': rssi_val, - 'associated_bssid': client.get('associated_bssid'), - 'probed_ssids': client.get('probed_ssids', []), - 'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))), - 'is_client': True, + "mac": mac, + "vendor": client.get("vendor"), + "name": client.get("vendor") or "WiFi Client", + "rssi": rssi_val, + "associated_bssid": client.get("associated_bssid"), + "probed_ssids": client.get("probed_ssids", []), + "probe_count": client.get("probe_count", len(client.get("probed_ssids", []))), + "is_client": True, } try: timeline_manager.add_observation( identifier=mac, - protocol='wifi', + protocol="wifi", rssi=rssi_val, - name=client_device.get('vendor') or f'WiFi Client {mac[-5:]}', - attributes={'client': True, 'associated_bssid': client_device.get('associated_bssid')} + name=client_device.get("vendor") or f"WiFi Client {mac[-5:]}", + attributes={ + "client": True, + "associated_bssid": client_device.get("associated_bssid"), + }, ) except Exception as e: logger.debug(f"WiFi client timeline observation error: {e}") _maybe_store_timeline( identifier=mac, - protocol='wifi', + protocol="wifi", rssi=rssi_val, - attributes={'client': True, 'associated_bssid': client_device.get('associated_bssid')} + attributes={"client": True, "associated_bssid": client_device.get("associated_bssid")}, ) profile = correlation.analyze_wifi_device(client_device) - client_device['classification'] = profile.risk_level.value - client_device['score'] = profile.total_score - client_device['score_modifier'] = profile.score_modifier - client_device['known_device'] = profile.known_device - client_device['known_device_name'] = profile.known_device_name - client_device['indicators'] = [ - {'type': i.type.value, 'desc': i.description} - for i in profile.indicators + client_device["classification"] = profile.risk_level.value + client_device["score"] = profile.total_score + client_device["score_modifier"] = profile.score_modifier + client_device["known_device"] = profile.known_device + client_device["known_device_name"] = profile.known_device_name + client_device["indicators"] = [ + {"type": i.type.value, "desc": i.description} for i in profile.indicators ] - client_device['recommended_action'] = profile.recommended_action + client_device["recommended_action"] = profile.recommended_action # Feed to identity engine for MAC-randomization resistant clustering try: wifi_obs = { - 'timestamp': datetime.now().isoformat(), - 'src_mac': mac, - 'bssid': client_device.get('associated_bssid'), - 'rssi': rssi_val, - 'frame_type': 'probe_request', - 'probed_ssids': client_device.get('probed_ssids', []), + "timestamp": datetime.now().isoformat(), + "src_mac": mac, + "bssid": client_device.get("associated_bssid"), + "rssi": rssi_val, + "frame_type": "probe_request", + "probed_ssids": client_device.get("probed_ssids", []), } ingest_wifi_dict(wifi_obs) except Exception as e: logger.debug(f"Identity engine WiFi client ingest error: {e}") - _emit_event('wifi_client', client_device) + _emit_event("wifi_client", client_device) except Exception as e: logger.debug(f"WiFi client scan error: {e}") except Exception as e: @@ -1414,27 +1412,27 @@ def _run_sweep( last_bt_scan = current_time for device in bt_devices: try: - mac = device.get('mac', '') + mac = device.get("mac", "") try: - rssi_val = int(device.get('rssi', device.get('signal'))) + rssi_val = int(device.get("rssi", device.get("signal"))) except (ValueError, TypeError): rssi_val = None if mac: try: timeline_manager.add_observation( identifier=mac, - protocol='bluetooth', + protocol="bluetooth", rssi=rssi_val, - name=device.get('name'), - attributes={'device_type': device.get('type')} + name=device.get("name"), + attributes={"device_type": device.get("type")}, ) except Exception as e: logger.debug(f"BT timeline observation error: {e}") _maybe_store_timeline( identifier=mac, - protocol='bluetooth', + protocol="bluetooth", rssi=rssi_val, - attributes={'device_type': device.get('type')} + attributes={"device_type": device.get("type")}, ) if mac and mac not in all_bt: all_bt[mac] = device @@ -1445,7 +1443,7 @@ def _run_sweep( _handle_threat(threat) threats_found += 1 is_threat = True - sev = threat.get('severity', 'low').lower() + sev = threat.get("severity", "low").lower() if sev in severity_counts: severity_counts[sev] += 1 # Classify device and get correlation profile @@ -1455,89 +1453,101 @@ def _run_sweep( # Feed to identity engine for MAC-randomization resistant clustering try: ble_obs = { - 'timestamp': datetime.now().isoformat(), - 'addr': mac, - 'rssi': device.get('rssi'), - 'manufacturer_id': device.get('manufacturer_id') or device.get('company_id'), - 'manufacturer_data': device.get('manufacturer_data'), - 'service_uuids': device.get('services', []), - 'local_name': device.get('name'), + "timestamp": datetime.now().isoformat(), + "addr": mac, + "rssi": device.get("rssi"), + "manufacturer_id": device.get("manufacturer_id") or device.get("company_id"), + "manufacturer_data": device.get("manufacturer_data"), + "service_uuids": device.get("services", []), + "local_name": device.get("name"), } ingest_ble_dict(ble_obs) except Exception as e: logger.debug(f"Identity engine BLE ingest error: {e}") # Send device to frontend - _emit_event('bt_device', { - 'mac': mac, - 'name': device.get('name', 'Unknown'), - 'device_type': device.get('type', ''), - 'rssi': device.get('rssi', ''), - 'manufacturer': device.get('manufacturer'), - 'tracker': device.get('tracker'), - 'tracker_type': device.get('tracker_type'), - 'is_threat': is_threat, - 'is_new': not classification.get('in_baseline', False), - 'classification': profile.risk_level.value, - 'reasons': classification.get('reasons', []), - 'is_audio_capable': classification.get('is_audio_capable', False), - 'score': profile.total_score, - 'score_modifier': profile.score_modifier, - 'known_device': profile.known_device, - 'known_device_name': profile.known_device_name, - 'indicators': [{'type': i.type.value, 'desc': i.description} for i in profile.indicators], - 'recommended_action': profile.recommended_action, - }) + _emit_event( + "bt_device", + { + "mac": mac, + "name": device.get("name", "Unknown"), + "device_type": device.get("type", ""), + "rssi": device.get("rssi", ""), + "manufacturer": device.get("manufacturer"), + "tracker": device.get("tracker"), + "tracker_type": device.get("tracker_type"), + "is_threat": is_threat, + "is_new": not classification.get("in_baseline", False), + "classification": profile.risk_level.value, + "reasons": classification.get("reasons", []), + "is_audio_capable": classification.get("is_audio_capable", False), + "score": profile.total_score, + "score_modifier": profile.score_modifier, + "known_device": profile.known_device, + "known_device_name": profile.known_device_name, + "indicators": [ + {"type": i.type.value, "desc": i.description} for i in profile.indicators + ], + "recommended_action": profile.recommended_action, + }, + ) except Exception as e: logger.error(f"BT device processing error for {device.get('mac', '?')}: {e}") except Exception as e: last_bt_scan = current_time import traceback + logger.error(f"Bluetooth scan error: {e}\n{traceback.format_exc()}") # Perform RF scan using SDR if rf_enabled and (current_time - last_rf_scan) >= rf_scan_interval: try: - _emit_event('sweep_progress', { - 'progress': min(100, int(((current_time - start_time) / duration) * 100)), - 'status': 'Scanning RF spectrum...', - 'wifi_count': len(all_wifi), - 'bt_count': len(all_bt), - 'rf_count': len(all_rf), - }) + _emit_event( + "sweep_progress", + { + "progress": min(100, int(((current_time - start_time) / duration) * 100)), + "status": "Scanning RF spectrum...", + "wifi_count": len(all_wifi), + "bt_count": len(all_bt), + "rf_count": len(all_rf), + }, + ) # Try RF scan even if sdr_device is None (will use device 0) - rf_signals = _scan_rf_signals(sdr_device, sweep_ranges=custom_ranges or preset.get('ranges')) + rf_signals = _scan_rf_signals(sdr_device, sweep_ranges=custom_ranges or preset.get("ranges")) # If no signals and this is first RF scan, send info event if not rf_signals and last_rf_scan == 0: - _emit_event('rf_status', { - 'status': 'no_signals', - 'message': 'RF scan completed - no signals above threshold. This may be normal in a quiet RF environment.', - }) + _emit_event( + "rf_status", + { + "status": "no_signals", + "message": "RF scan completed - no signals above threshold. This may be normal in a quiet RF environment.", + }, + ) for signal in rf_signals: freq_key = f"{signal['frequency']:.3f}" try: - power_val = int(float(signal.get('power', signal.get('level')))) + power_val = int(float(signal.get("power", signal.get("level")))) except (ValueError, TypeError): power_val = None try: timeline_manager.add_observation( identifier=freq_key, - protocol='rf', + protocol="rf", rssi=power_val, - frequency=signal.get('frequency'), + frequency=signal.get("frequency"), name=f"{freq_key} MHz", - attributes={'band': signal.get('band')} + attributes={"band": signal.get("band")}, ) except Exception as e: logger.debug(f"RF timeline observation error: {e}") _maybe_store_timeline( identifier=freq_key, - protocol='rf', + protocol="rf", rssi=power_val, - frequency=signal.get('frequency'), - attributes={'band': signal.get('band')} + frequency=signal.get("frequency"), + attributes={"band": signal.get("band")}, ) if freq_key not in [f"{s['frequency']:.3f}" for s in all_rf]: all_rf.append(signal) @@ -1548,29 +1558,34 @@ def _run_sweep( _handle_threat(threat) threats_found += 1 is_threat = True - sev = threat.get('severity', 'low').lower() + sev = threat.get("severity", "low").lower() if sev in severity_counts: severity_counts[sev] += 1 # Classify signal and get correlation profile classification = detector.classify_rf_signal(signal) profile = correlation.analyze_rf_signal(signal) # Send signal to frontend - _emit_event('rf_signal', { - 'frequency': signal['frequency'], - 'power': signal['power'], - 'band': signal['band'], - 'signal_strength': signal.get('signal_strength', 0), - 'is_threat': is_threat, - 'is_new': not classification.get('in_baseline', False), - 'classification': profile.risk_level.value, - 'reasons': classification.get('reasons', []), - 'score': profile.total_score, - 'score_modifier': profile.score_modifier, - 'known_device': profile.known_device, - 'known_device_name': profile.known_device_name, - 'indicators': [{'type': i.type.value, 'desc': i.description} for i in profile.indicators], - 'recommended_action': profile.recommended_action, - }) + _emit_event( + "rf_signal", + { + "frequency": signal["frequency"], + "power": signal["power"], + "band": signal["band"], + "signal_strength": signal.get("signal_strength", 0), + "is_threat": is_threat, + "is_new": not classification.get("in_baseline", False), + "classification": profile.risk_level.value, + "reasons": classification.get("reasons", []), + "score": profile.total_score, + "score_modifier": profile.score_modifier, + "known_device": profile.known_device, + "known_device_name": profile.known_device_name, + "indicators": [ + {"type": i.type.value, "desc": i.description} for i in profile.indicators + ], + "recommended_action": profile.recommended_action, + }, + ) last_rf_scan = current_time except Exception as e: logger.error(f"RF scan error: {e}") @@ -1579,16 +1594,19 @@ def _run_sweep( elapsed = time.time() - start_time progress = min(100, int((elapsed / duration) * 100)) - _emit_event('sweep_progress', { - 'progress': progress, - 'elapsed': int(elapsed), - 'duration': duration, - 'wifi_count': len(all_wifi), - 'bt_count': len(all_bt), - 'rf_count': len(all_rf), - 'threats_found': threats_found, - 'severity_counts': severity_counts, - }) + _emit_event( + "sweep_progress", + { + "progress": progress, + "elapsed": int(elapsed), + "duration": duration, + "wifi_count": len(all_wifi), + "bt_count": len(all_bt), + "rf_count": len(all_rf), + "threats_found": threats_found, + "severity_counts": severity_counts, + }, + ) time.sleep(2) # Update every 2 seconds @@ -1606,7 +1624,7 @@ def _run_sweep( wifi_devices=list(all_wifi.values()), wifi_clients=list(all_wifi_clients.values()), bt_devices=list(all_bt.values()), - rf_signals=all_rf + rf_signals=all_rf, ) logger.info( f"Baseline comparison: {baseline_comparison['total_new']} new, " @@ -1626,129 +1644,143 @@ def _run_sweep( else: wifi_payload = [ { - 'bssid': d.get('bssid') or d.get('mac'), - 'essid': d.get('essid') or d.get('ssid'), - 'ssid': d.get('ssid') or d.get('essid'), - 'channel': d.get('channel'), - 'power': d.get('power', d.get('signal')), - 'privacy': d.get('privacy', d.get('encryption')), - 'encryption': d.get('encryption', d.get('privacy')), + "bssid": d.get("bssid") or d.get("mac"), + "essid": d.get("essid") or d.get("ssid"), + "ssid": d.get("ssid") or d.get("essid"), + "channel": d.get("channel"), + "power": d.get("power", d.get("signal")), + "privacy": d.get("privacy", d.get("encryption")), + "encryption": d.get("encryption", d.get("privacy")), } for d in all_wifi.values() ] wifi_client_payload = [] for client in all_wifi_clients.values(): - mac = client.get('mac') or client.get('address') + mac = client.get("mac") or client.get("address") if isinstance(mac, str): mac = mac.upper() - probed_ssids = client.get('probed_ssids') or [] - rssi = client.get('rssi') + probed_ssids = client.get("probed_ssids") or [] + rssi = client.get("rssi") if rssi is None: - rssi = client.get('rssi_current') + rssi = client.get("rssi_current") if rssi is None: - rssi = client.get('rssi_median') + rssi = client.get("rssi_median") if rssi is None: - rssi = client.get('rssi_ema') - wifi_client_payload.append({ - 'mac': mac, - 'vendor': client.get('vendor'), - 'rssi': rssi, - 'associated_bssid': client.get('associated_bssid'), - 'is_associated': client.get('is_associated'), - 'probed_ssids': probed_ssids, - 'probe_count': client.get('probe_count', len(probed_ssids)), - }) + rssi = client.get("rssi_ema") + wifi_client_payload.append( + { + "mac": mac, + "vendor": client.get("vendor"), + "rssi": rssi, + "associated_bssid": client.get("associated_bssid"), + "is_associated": client.get("is_associated"), + "probed_ssids": probed_ssids, + "probe_count": client.get("probe_count", len(probed_ssids)), + } + ) bt_payload = [ { - 'mac': d.get('mac') or d.get('address'), - 'name': d.get('name'), - 'rssi': d.get('rssi'), - 'manufacturer': d.get('manufacturer', d.get('manufacturer_name')), + "mac": d.get("mac") or d.get("address"), + "name": d.get("name"), + "rssi": d.get("rssi"), + "manufacturer": d.get("manufacturer", d.get("manufacturer_name")), } for d in all_bt.values() ] rf_payload = [ { - 'frequency': s.get('frequency'), - 'power': s.get('power', s.get('level')), - 'modulation': s.get('modulation'), - 'band': s.get('band'), + "frequency": s.get("frequency"), + "power": s.get("power", s.get("level")), + "modulation": s.get("modulation"), + "band": s.get("band"), } for s in all_rf ] update_tscm_sweep( _current_sweep_id, - status='completed', + status="completed", results={ - 'wifi_devices': wifi_payload, - 'wifi_clients': wifi_client_payload, - 'bt_devices': bt_payload, - 'rf_signals': rf_payload, - 'wifi_count': len(all_wifi), - 'wifi_client_count': len(all_wifi_clients), - 'bt_count': len(all_bt), - 'rf_count': len(all_rf), - 'severity_counts': severity_counts, - 'correlation_summary': findings.get('summary', {}), - 'identity_summary': identity_summary.get('statistics', {}), - 'baseline_comparison': baseline_comparison, - 'results_detail_level': 'full' if verbose_results else 'compact', + "wifi_devices": wifi_payload, + "wifi_clients": wifi_client_payload, + "bt_devices": bt_payload, + "rf_signals": rf_payload, + "wifi_count": len(all_wifi), + "wifi_client_count": len(all_wifi_clients), + "bt_count": len(all_bt), + "rf_count": len(all_rf), + "severity_counts": severity_counts, + "correlation_summary": findings.get("summary", {}), + "identity_summary": identity_summary.get("statistics", {}), + "baseline_comparison": baseline_comparison, + "results_detail_level": "full" if verbose_results else "compact", }, threats_found=threats_found, - completed=True + completed=True, ) # Emit correlation findings - _emit_event('correlation_findings', { - 'correlations': correlations, - 'high_interest_count': findings['summary'].get('high_interest', 0), - 'needs_review_count': findings['summary'].get('needs_review', 0), - }) + _emit_event( + "correlation_findings", + { + "correlations": correlations, + "high_interest_count": findings["summary"].get("high_interest", 0), + "needs_review_count": findings["summary"].get("needs_review", 0), + }, + ) # Emit baseline comparison if a baseline was used if baseline_comparison: - _emit_event('baseline_comparison', { - 'baseline_id': baseline.get('id'), - 'baseline_name': baseline.get('name'), - 'total_new': baseline_comparison['total_new'], - 'total_missing': baseline_comparison['total_missing'], - 'wifi': baseline_comparison.get('wifi'), - 'wifi_clients': baseline_comparison.get('wifi_clients'), - 'bluetooth': baseline_comparison.get('bluetooth'), - 'rf': baseline_comparison.get('rf'), - }) + _emit_event( + "baseline_comparison", + { + "baseline_id": baseline.get("id"), + "baseline_name": baseline.get("name"), + "total_new": baseline_comparison["total_new"], + "total_missing": baseline_comparison["total_missing"], + "wifi": baseline_comparison.get("wifi"), + "wifi_clients": baseline_comparison.get("wifi_clients"), + "bluetooth": baseline_comparison.get("bluetooth"), + "rf": baseline_comparison.get("rf"), + }, + ) # Emit device identity cluster findings (MAC-randomization resistant) - _emit_event('identity_clusters', { - 'total_clusters': identity_summary.get('statistics', {}).get('total_clusters', 0), - 'high_risk_count': identity_summary.get('statistics', {}).get('high_risk_count', 0), - 'medium_risk_count': identity_summary.get('statistics', {}).get('medium_risk_count', 0), - 'unique_fingerprints': identity_summary.get('statistics', {}).get('unique_fingerprints', 0), - 'clusters': identity_clusters, - }) + _emit_event( + "identity_clusters", + { + "total_clusters": identity_summary.get("statistics", {}).get("total_clusters", 0), + "high_risk_count": identity_summary.get("statistics", {}).get("high_risk_count", 0), + "medium_risk_count": identity_summary.get("statistics", {}).get("medium_risk_count", 0), + "unique_fingerprints": identity_summary.get("statistics", {}).get("unique_fingerprints", 0), + "clusters": identity_clusters, + }, + ) - _emit_event('sweep_completed', { - 'sweep_id': _current_sweep_id, - 'threats_found': threats_found, - 'wifi_count': len(all_wifi), - 'wifi_client_count': len(all_wifi_clients), - 'bt_count': len(all_bt), - 'rf_count': len(all_rf), - 'severity_counts': severity_counts, - 'high_interest_devices': findings['summary'].get('high_interest', 0), - 'needs_review_devices': findings['summary'].get('needs_review', 0), - 'correlations_found': len(correlations), - 'identity_clusters': identity_summary['statistics'].get('total_clusters', 0), - 'baseline_new_devices': baseline_comparison['total_new'] if baseline_comparison else 0, - 'baseline_missing_devices': baseline_comparison['total_missing'] if baseline_comparison else 0, - }) + _emit_event( + "sweep_completed", + { + "sweep_id": _current_sweep_id, + "threats_found": threats_found, + "wifi_count": len(all_wifi), + "wifi_client_count": len(all_wifi_clients), + "bt_count": len(all_bt), + "rf_count": len(all_rf), + "severity_counts": severity_counts, + "high_interest_devices": findings["summary"].get("high_interest", 0), + "needs_review_devices": findings["summary"].get("needs_review", 0), + "correlations_found": len(correlations), + "identity_clusters": identity_summary["statistics"].get("total_clusters", 0), + "baseline_new_devices": baseline_comparison["total_new"] if baseline_comparison else 0, + "baseline_missing_devices": baseline_comparison["total_missing"] if baseline_comparison else 0, + }, + ) except Exception as e: logger.error(f"Sweep error: {e}") - _emit_event('sweep_error', {'error': str(e)}) + _emit_event("sweep_error", {"error": str(e)}) if _current_sweep_id: - update_tscm_sweep(_current_sweep_id, status='error', completed=True) + update_tscm_sweep(_current_sweep_id, status="error", completed=True) finally: _sweep_running = False @@ -1762,33 +1794,27 @@ def _handle_threat(threat: dict) -> None: # Add to database threat_id = add_tscm_threat( sweep_id=_current_sweep_id, - threat_type=threat['threat_type'], - severity=threat['severity'], - source=threat['source'], - identifier=threat['identifier'], - name=threat.get('name'), - signal_strength=threat.get('signal_strength'), - frequency=threat.get('frequency'), - details=threat.get('details') + threat_type=threat["threat_type"], + severity=threat["severity"], + source=threat["source"], + identifier=threat["identifier"], + name=threat.get("name"), + signal_strength=threat.get("signal_strength"), + frequency=threat.get("frequency"), + details=threat.get("details"), ) # Emit event - _emit_event('threat_detected', { - 'threat_id': threat_id, - **threat - }) + _emit_event("threat_detected", {"threat_id": threat_id, **threat}) - logger.warning( - f"TSCM threat detected: {threat['threat_type']} - " - f"{threat['identifier']} ({threat['severity']})" - ) + logger.warning(f"TSCM threat detected: {threat['threat_type']} - {threat['identifier']} ({threat['severity']})") def _generate_assessment(summary: dict) -> str: """Generate an assessment summary based on findings.""" - high = summary.get('high_interest', 0) - review = summary.get('needs_review', 0) - correlations = summary.get('correlations_found', 0) + high = summary.get("high_interest", 0) + review = summary.get("needs_review", 0) + correlations = summary.get("correlations_found", 0) if high > 0 or correlations > 0: return ( @@ -1802,10 +1828,7 @@ def _generate_assessment(summary: dict) -> str: "Further analysis recommended to characterize unknown devices." ) elif review > 0: - return ( - f"LOW CONCERN: {review} item(s) flagged for review. " - "Likely benign but verification recommended." - ) + return f"LOW CONCERN: {review} item(s) flagged for review. Likely benign but verification recommended." else: return ( "BASELINE ENVIRONMENT: No significant anomalies detected. " diff --git a/routes/tscm/analysis.py b/routes/tscm/analysis.py index 66e5aa0..1444119 100644 --- a/routes/tscm/analysis.py +++ b/routes/tscm/analysis.py @@ -26,9 +26,9 @@ from utils.database import ( ) from utils.tscm.correlation import get_correlation_engine -logger = logging.getLogger('intercept.tscm') +logger = logging.getLogger("intercept.tscm") -_VALID_CATEGORIES = {'high_interest', 'needs_review', 'informational'} +_VALID_CATEGORIES = {"high_interest", "needs_review", "informational"} def _parse_categories_param(raw: str) -> list[str] | None: @@ -39,7 +39,7 @@ def _parse_categories_param(raw: str) -> list[str] | None: """ if not raw: return None - cats = [c.strip() for c in raw.split(',') if c.strip() in _VALID_CATEGORIES] + cats = [c.strip() for c in raw.split(",") if c.strip() in _VALID_CATEGORIES] if not cats or set(cats) == _VALID_CATEGORIES: return None return cats @@ -49,54 +49,51 @@ def _parse_categories_param(raw: str) -> list[str] | None: # Threat Endpoints # ============================================================================= -@tscm_bp.route('/threats') + +@tscm_bp.route("/threats") def list_threats(): """List threats with optional filters.""" - sweep_id = request.args.get('sweep_id', type=int) - severity = request.args.get('severity') - acknowledged = request.args.get('acknowledged') - limit = request.args.get('limit', 100, type=int) + sweep_id = request.args.get("sweep_id", type=int) + severity = request.args.get("severity") + acknowledged = request.args.get("acknowledged") + limit = request.args.get("limit", 100, type=int) ack_filter = None if acknowledged is not None: - ack_filter = acknowledged.lower() in ('true', '1', 'yes') + ack_filter = acknowledged.lower() in ("true", "1", "yes") - threats = get_tscm_threats( - sweep_id=sweep_id, - severity=severity, - acknowledged=ack_filter, - limit=limit - ) + threats = get_tscm_threats(sweep_id=sweep_id, severity=severity, acknowledged=ack_filter, limit=limit) - return jsonify({'status': 'success', 'threats': threats}) + return jsonify({"status": "success", "threats": threats}) -@tscm_bp.route('/threats/summary') +@tscm_bp.route("/threats/summary") def threat_summary(): """Get threat count summary by severity.""" summary = get_tscm_threat_summary() - return jsonify({'status': 'success', 'summary': summary}) + return jsonify({"status": "success", "summary": summary}) -@tscm_bp.route('/threats/', methods=['PUT']) +@tscm_bp.route("/threats/", methods=["PUT"]) def update_threat(threat_id: int): """Update a threat (acknowledge, add notes).""" data = request.get_json() or {} - if data.get('acknowledge'): - notes = data.get('notes') + if data.get("acknowledge"): + notes = data.get("notes") success = acknowledge_tscm_threat(threat_id, notes) if not success: - return jsonify({'status': 'error', 'message': 'Threat not found'}), 404 + return jsonify({"status": "error", "message": "Threat not found"}), 404 - return jsonify({'status': 'success', 'message': 'Threat updated'}) + return jsonify({"status": "success", "message": "Threat updated"}) # ============================================================================= # Correlation & Findings Endpoints # ============================================================================= -@tscm_bp.route('/findings') + +@tscm_bp.route("/findings") def get_findings(): """ Get comprehensive TSCM findings from the correlation engine. @@ -108,7 +105,7 @@ def get_findings(): findings = correlation.get_all_findings() # Add client-safe disclaimer - findings['legal_disclaimer'] = ( + findings["legal_disclaimer"] = ( "DISCLAIMER: This TSCM screening system identifies wireless and RF anomalies " "and indicators. Results represent potential items of interest, NOT confirmed " "surveillance devices. No content has been intercepted or decoded. Findings " @@ -116,73 +113,69 @@ def get_findings(): "malicious intent or illegal activity." ) - return jsonify({ - 'status': 'success', - 'findings': findings - }) + return jsonify({"status": "success", "findings": findings}) -@tscm_bp.route('/findings/high-interest') +@tscm_bp.route("/findings/high-interest") def get_high_interest(): """Get only high-interest devices (score >= 6).""" correlation = get_correlation_engine() high_interest = correlation.get_high_interest_devices() - return jsonify({ - 'status': 'success', - 'count': len(high_interest), - 'devices': [d.to_dict() for d in high_interest], - 'disclaimer': ( - "High-interest classification indicates multiple indicators warrant " - "investigation. This does NOT confirm surveillance activity." - ) - }) + return jsonify( + { + "status": "success", + "count": len(high_interest), + "devices": [d.to_dict() for d in high_interest], + "disclaimer": ( + "High-interest classification indicates multiple indicators warrant " + "investigation. This does NOT confirm surveillance activity." + ), + } + ) -@tscm_bp.route('/findings/correlations') +@tscm_bp.route("/findings/correlations") def get_correlations(): """Get cross-protocol correlation analysis.""" correlation = get_correlation_engine() correlations = correlation.correlate_devices() - return jsonify({ - 'status': 'success', - 'count': len(correlations), - 'correlations': correlations, - 'explanation': ( - "Correlations identify devices across different protocols (Bluetooth, " - "WiFi, RF) that exhibit related behavior patterns. Cross-protocol " - "activity is one indicator among many in TSCM analysis." - ) - }) + return jsonify( + { + "status": "success", + "count": len(correlations), + "correlations": correlations, + "explanation": ( + "Correlations identify devices across different protocols (Bluetooth, " + "WiFi, RF) that exhibit related behavior patterns. Cross-protocol " + "activity is one indicator among many in TSCM analysis." + ), + } + ) -@tscm_bp.route('/findings/device/') +@tscm_bp.route("/findings/device/") def get_device_profile(identifier: str): """Get detailed profile for a specific device.""" correlation = get_correlation_engine() # Search all protocols for the identifier - for protocol in ['bluetooth', 'wifi', 'rf']: + for protocol in ["bluetooth", "wifi", "rf"]: key = f"{protocol}:{identifier}" if key in correlation.device_profiles: profile = correlation.device_profiles[key] - return jsonify({ - 'status': 'success', - 'profile': profile.to_dict() - }) + return jsonify({"status": "success", "profile": profile.to_dict()}) - return jsonify({ - 'status': 'error', - 'message': 'Device not found' - }), 404 + return jsonify({"status": "error", "message": "Device not found"}), 404 # ============================================================================= # Report Generation Endpoints # ============================================================================= -@tscm_bp.route('/report') + +@tscm_bp.route("/report") def generate_report(): """ Generate a comprehensive TSCM sweep report. @@ -195,43 +188,38 @@ def generate_report(): # Build the report structure report = { - 'generated_at': datetime.now().isoformat(), - 'report_type': 'TSCM Wireless Surveillance Screening', - - 'executive_summary': { - 'total_devices_analyzed': findings['summary']['total_devices'], - 'high_interest_items': findings['summary']['high_interest'], - 'items_requiring_review': findings['summary']['needs_review'], - 'cross_protocol_correlations': findings['summary']['correlations_found'], - 'assessment': _generate_assessment(findings['summary']), + "generated_at": datetime.now().isoformat(), + "report_type": "TSCM Wireless Surveillance Screening", + "executive_summary": { + "total_devices_analyzed": findings["summary"]["total_devices"], + "high_interest_items": findings["summary"]["high_interest"], + "items_requiring_review": findings["summary"]["needs_review"], + "cross_protocol_correlations": findings["summary"]["correlations_found"], + "assessment": _generate_assessment(findings["summary"]), }, - - 'methodology': { - 'protocols_scanned': ['Bluetooth Low Energy', 'WiFi 802.11', 'RF Spectrum'], - 'analysis_techniques': [ - 'Device fingerprinting', - 'Signal stability analysis', - 'Cross-protocol correlation', - 'Time-based pattern detection', - 'Manufacturer identification', + "methodology": { + "protocols_scanned": ["Bluetooth Low Energy", "WiFi 802.11", "RF Spectrum"], + "analysis_techniques": [ + "Device fingerprinting", + "Signal stability analysis", + "Cross-protocol correlation", + "Time-based pattern detection", + "Manufacturer identification", ], - 'scoring_model': { - 'informational': '0-2 points - Known or expected devices', - 'needs_review': '3-5 points - Unusual devices requiring assessment', - 'high_interest': '6+ points - Multiple indicators warrant investigation', - } + "scoring_model": { + "informational": "0-2 points - Known or expected devices", + "needs_review": "3-5 points - Unusual devices requiring assessment", + "high_interest": "6+ points - Multiple indicators warrant investigation", + }, }, - - 'findings': { - 'high_interest': findings['devices']['high_interest'], - 'needs_review': findings['devices']['needs_review'], - 'informational': findings['devices']['informational'], + "findings": { + "high_interest": findings["devices"]["high_interest"], + "needs_review": findings["devices"]["needs_review"], + "informational": findings["devices"]["informational"], }, - - 'correlations': findings['correlations'], - - 'disclaimers': { - 'legal': ( + "correlations": findings["correlations"], + "disclaimers": { + "legal": ( "This report documents findings from a wireless and RF surveillance " "screening. Results indicate anomalies and items of interest, NOT " "confirmed surveillance devices. No communications content has been " @@ -239,27 +227,24 @@ def generate_report(): "malicious intent, illegal activity, or the presence of surveillance " "equipment. All findings require professional verification." ), - 'technical': ( + "technical": ( "Detection capabilities are limited by equipment sensitivity, " "environmental factors, and the technical sophistication of any " "potential devices. Absence of findings does NOT guarantee absence " "of surveillance equipment." ), - 'recommendations': ( + "recommendations": ( "High-interest items should be investigated by qualified TSCM " "professionals using appropriate physical inspection techniques. " "This electronic sweep is one component of comprehensive TSCM." - ) - } + ), + }, } - return jsonify({ - 'status': 'success', - 'report': report - }) + return jsonify({"status": "success", "report": report}) -@tscm_bp.route('/report/pdf') +@tscm_bp.route("/report/pdf") def get_pdf_report(): """ Generate client-safe PDF report. @@ -272,17 +257,17 @@ def get_pdf_report(): from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager from utils.tscm.reports import generate_report, get_pdf_report - sweep_id = request.args.get('sweep_id', _current_sweep_id, type=int) + sweep_id = request.args.get("sweep_id", _current_sweep_id, type=int) if not sweep_id: - return jsonify({'status': 'error', 'message': 'No sweep specified'}), 400 + return jsonify({"status": "error", "message": "No sweep specified"}), 400 sweep = get_tscm_sweep(sweep_id) if not sweep: - return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404 + return jsonify({"status": "error", "message": "Sweep not found"}), 404 - categories = _parse_categories_param(request.args.get('categories', '')) - site_name = request.args.get('site_name', '').strip()[:200] - examiner_name = request.args.get('examiner_name', '').strip()[:200] + categories = _parse_categories_param(request.args.get("categories", "")) + site_name = request.args.get("site_name", "").strip()[:200] + examiner_name = request.args.get("examiner_name", "").strip()[:200] # Get data for report correlation = get_correlation_engine() @@ -308,18 +293,16 @@ def get_pdf_report(): return Response( pdf_content, - mimetype='text/plain', - headers={ - 'Content-Disposition': f'attachment; filename=tscm_report_{sweep_id}.txt' - } + mimetype="text/plain", + headers={"Content-Disposition": f"attachment; filename=tscm_report_{sweep_id}.txt"}, ) except Exception as e: logger.error(f"Generate PDF report error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/report/annex') +@tscm_bp.route("/report/annex") def get_technical_annex(): """ Generate technical annex (JSON + CSV). @@ -332,19 +315,19 @@ def get_technical_annex(): from utils.tscm.advanced import detect_sweep_capabilities, get_timeline_manager from utils.tscm.reports import generate_report, get_csv_annex, get_json_annex - sweep_id = request.args.get('sweep_id', _current_sweep_id, type=int) - format_type = request.args.get('format', 'json') + sweep_id = request.args.get("sweep_id", _current_sweep_id, type=int) + format_type = request.args.get("format", "json") if not sweep_id: - return jsonify({'status': 'error', 'message': 'No sweep specified'}), 400 + return jsonify({"status": "error", "message": "No sweep specified"}), 400 sweep = get_tscm_sweep(sweep_id) if not sweep: - return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404 + return jsonify({"status": "error", "message": "Sweep not found"}), 404 - categories = _parse_categories_param(request.args.get('categories', '')) - site_name = request.args.get('site_name', '').strip()[:200] - examiner_name = request.args.get('examiner_name', '').strip()[:200] + categories = _parse_categories_param(request.args.get("categories", "")) + site_name = request.args.get("site_name", "").strip()[:200] + examiner_name = request.args.get("examiner_name", "").strip()[:200] # Get data for report correlation = get_correlation_engine() @@ -366,32 +349,28 @@ def get_technical_annex(): examiner_name=examiner_name, ) - if format_type == 'csv': + if format_type == "csv": csv_content = get_csv_annex(report) return Response( csv_content, - mimetype='text/csv', - headers={ - 'Content-Disposition': f'attachment; filename=tscm_annex_{sweep_id}.csv' - } + mimetype="text/csv", + headers={"Content-Disposition": f"attachment; filename=tscm_annex_{sweep_id}.csv"}, ) else: annex = get_json_annex(report) - return jsonify({ - 'status': 'success', - 'annex': annex - }) + return jsonify({"status": "success", "annex": annex}) except Exception as e: logger.error(f"Generate technical annex error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 # ============================================================================= # WiFi Advanced Indicators Endpoints # ============================================================================= -@tscm_bp.route('/wifi/advanced-indicators') + +@tscm_bp.route("/wifi/advanced-indicators") def get_wifi_advanced_indicators(): """ Get advanced WiFi indicators (Evil Twin, Probes, Deauth). @@ -404,22 +383,24 @@ def get_wifi_advanced_indicators(): detector = get_wifi_detector() - return jsonify({ - 'status': 'success', - 'indicators': detector.get_all_indicators(), - 'unavailable_features': detector.get_unavailable_features(), - 'disclaimer': ( - "All indicators represent pattern detections, NOT confirmed attacks. " - "Further investigation is required." - ) - }) + return jsonify( + { + "status": "success", + "indicators": detector.get_all_indicators(), + "unavailable_features": detector.get_unavailable_features(), + "disclaimer": ( + "All indicators represent pattern detections, NOT confirmed attacks. " + "Further investigation is required." + ), + } + ) except Exception as e: logger.error(f"Get WiFi indicators error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/wifi/analyze-network', methods=['POST']) +@tscm_bp.route("/wifi/analyze-network", methods=["POST"]) def analyze_wifi_network(): """ Analyze a WiFi network for evil twin patterns. @@ -435,25 +416,23 @@ def analyze_wifi_network(): # Set known networks from baseline if available baseline = get_active_tscm_baseline() if baseline: - detector.set_known_networks(baseline.get('wifi_networks', [])) + detector.set_known_networks(baseline.get("wifi_networks", [])) indicators = detector.analyze_network(data) - return jsonify({ - 'status': 'success', - 'indicators': [i.to_dict() for i in indicators] - }) + return jsonify({"status": "success", "indicators": [i.to_dict() for i in indicators]}) except Exception as e: logger.error(f"Analyze WiFi network error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 # ============================================================================= # Bluetooth Risk Explainability Endpoints # ============================================================================= -@tscm_bp.route('/bluetooth//explain') + +@tscm_bp.route("/bluetooth//explain") def explain_bluetooth_risk(identifier: str): """ Get human-readable risk explanation for a BLE device. @@ -472,33 +451,30 @@ def explain_bluetooth_risk(identifier: str): profile = correlation.device_profiles[key].to_dict() # Try to find device info - device = {'mac': identifier} + device = {"mac": identifier} if profile: - device['name'] = profile.get('name') - device['rssi'] = profile.get('rssi_samples', [None])[-1] if profile.get('rssi_samples') else None + device["name"] = profile.get("name") + device["rssi"] = profile.get("rssi_samples", [None])[-1] if profile.get("rssi_samples") else None # Check meeting status is_meeting = correlation.is_during_meeting() explanation = generate_ble_risk_explanation(device, profile, is_meeting) - return jsonify({ - 'status': 'success', - 'explanation': explanation.to_dict() - }) + return jsonify({"status": "success", "explanation": explanation.to_dict()}) except Exception as e: logger.error(f"Explain BLE risk error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/bluetooth//proximity') +@tscm_bp.route("/bluetooth//proximity") def get_bluetooth_proximity(identifier: str): """Get proximity estimate for a BLE device.""" try: from utils.tscm.advanced import estimate_ble_proximity - rssi = request.args.get('rssi', type=int) + rssi = request.args.get("rssi", type=int) if rssi is None: # Try to get from correlation engine correlation = get_correlation_engine() @@ -509,37 +485,37 @@ def get_bluetooth_proximity(identifier: str): rssi = profile.rssi_samples[-1] if rssi is None: - return jsonify({ - 'status': 'error', - 'message': 'RSSI value required' - }), 400 + return jsonify({"status": "error", "message": "RSSI value required"}), 400 proximity, explanation, distance = estimate_ble_proximity(rssi) - return jsonify({ - 'status': 'success', - 'proximity': { - 'estimate': proximity.value, - 'explanation': explanation, - 'estimated_distance': distance, - 'rssi_used': rssi, - }, - 'disclaimer': ( - "Proximity estimates are approximate and affected by " - "environment, obstacles, and device characteristics." - ) - }) + return jsonify( + { + "status": "success", + "proximity": { + "estimate": proximity.value, + "explanation": explanation, + "estimated_distance": distance, + "rssi_used": rssi, + }, + "disclaimer": ( + "Proximity estimates are approximate and affected by " + "environment, obstacles, and device characteristics." + ), + } + ) except Exception as e: logger.error(f"Get BLE proximity error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 # ============================================================================= # Operator Playbook Endpoints # ============================================================================= -@tscm_bp.route('/playbooks') + +@tscm_bp.route("/playbooks") def list_playbooks(): """List all available operator playbooks.""" try: @@ -549,41 +525,35 @@ def list_playbooks(): playbooks_list = [] for pid, pb in PLAYBOOKS.items(): pb_dict = pb.to_dict() - pb_dict['id'] = pid - pb_dict['name'] = pb_dict.get('title', pid) - pb_dict['category'] = pb_dict.get('risk_level', 'general') + pb_dict["id"] = pid + pb_dict["name"] = pb_dict.get("title", pid) + pb_dict["category"] = pb_dict.get("risk_level", "general") playbooks_list.append(pb_dict) - return jsonify({ - 'status': 'success', - 'playbooks': playbooks_list - }) + return jsonify({"status": "success", "playbooks": playbooks_list}) except Exception as e: logger.error(f"List playbooks error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/playbooks/') +@tscm_bp.route("/playbooks/") def get_playbook(playbook_id: str): """Get a specific playbook.""" try: from utils.tscm.advanced import PLAYBOOKS if playbook_id not in PLAYBOOKS: - return jsonify({'status': 'error', 'message': 'Playbook not found'}), 404 + return jsonify({"status": "error", "message": "Playbook not found"}), 404 - return jsonify({ - 'status': 'success', - 'playbook': PLAYBOOKS[playbook_id].to_dict() - }) + return jsonify({"status": "success", "playbook": PLAYBOOKS[playbook_id].to_dict()}) except Exception as e: logger.error(f"Get playbook error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/findings//playbook') +@tscm_bp.route("/findings//playbook") def get_finding_playbook(identifier: str): """Get recommended playbook for a specific finding.""" try: @@ -593,39 +563,38 @@ def get_finding_playbook(identifier: str): correlation = get_correlation_engine() profile = None - for protocol in ['bluetooth', 'wifi', 'rf']: + for protocol in ["bluetooth", "wifi", "rf"]: key = f"{protocol}:{identifier.upper()}" if key in correlation.device_profiles: profile = correlation.device_profiles[key].to_dict() break if not profile: - return jsonify({'status': 'error', 'message': 'Finding not found'}), 404 + return jsonify({"status": "error", "message": "Finding not found"}), 404 playbook = get_playbook_for_finding( - risk_level=profile.get('risk_level', 'informational'), - indicators=profile.get('indicators', []) + risk_level=profile.get("risk_level", "informational"), indicators=profile.get("indicators", []) ) - return jsonify({ - 'status': 'success', - 'playbook': playbook.to_dict(), - 'suggested_next_steps': [ - f"Step {s.step_number}: {s.action}" - for s in playbook.steps[:3] - ] - }) + return jsonify( + { + "status": "success", + "playbook": playbook.to_dict(), + "suggested_next_steps": [f"Step {s.step_number}: {s.action}" for s in playbook.steps[:3]], + } + ) except Exception as e: logger.error(f"Get finding playbook error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 # ============================================================================= # Device Identity Endpoints (MAC-Randomization Resistant Detection) # ============================================================================= -@tscm_bp.route('/identity/ingest/ble', methods=['POST']) + +@tscm_bp.route("/identity/ingest/ble", methods=["POST"]) def ingest_ble_observation(): """ Ingest a BLE observation for device identity clustering. @@ -654,22 +623,24 @@ def ingest_ble_observation(): data = request.get_json() if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + return jsonify({"status": "error", "message": "No data provided"}), 400 session = ingest_ble_dict(data) - return jsonify({ - 'status': 'success', - 'session_id': session.session_id, - 'observation_count': len(session.observations), - }) + return jsonify( + { + "status": "success", + "session_id": session.session_id, + "observation_count": len(session.observations), + } + ) except Exception as e: logger.error(f"BLE ingestion error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/identity/ingest/wifi', methods=['POST']) +@tscm_bp.route("/identity/ingest/wifi", methods=["POST"]) def ingest_wifi_observation(): """ Ingest a WiFi observation for device identity clustering. @@ -697,22 +668,24 @@ def ingest_wifi_observation(): data = request.get_json() if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + return jsonify({"status": "error", "message": "No data provided"}), 400 session = ingest_wifi_dict(data) - return jsonify({ - 'status': 'success', - 'session_id': session.session_id, - 'observation_count': len(session.observations), - }) + return jsonify( + { + "status": "success", + "session_id": session.session_id, + "observation_count": len(session.observations), + } + ) except Exception as e: logger.error(f"WiFi ingestion error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/identity/ingest/batch', methods=['POST']) +@tscm_bp.route("/identity/ingest/batch", methods=["POST"]) def ingest_batch_observations(): """ Ingest multiple observations in a single request. @@ -728,31 +701,33 @@ def ingest_batch_observations(): data = request.get_json() if not data: - return jsonify({'status': 'error', 'message': 'No data provided'}), 400 + return jsonify({"status": "error", "message": "No data provided"}), 400 ble_count = 0 wifi_count = 0 - for ble_obs in data.get('ble', []): + for ble_obs in data.get("ble", []): ingest_ble_dict(ble_obs) ble_count += 1 - for wifi_obs in data.get('wifi', []): + for wifi_obs in data.get("wifi", []): ingest_wifi_dict(wifi_obs) wifi_count += 1 - return jsonify({ - 'status': 'success', - 'ble_ingested': ble_count, - 'wifi_ingested': wifi_count, - }) + return jsonify( + { + "status": "success", + "ble_ingested": ble_count, + "wifi_ingested": wifi_count, + } + ) except Exception as e: logger.error(f"Batch ingestion error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/identity/clusters') +@tscm_bp.route("/identity/clusters") def get_device_clusters(): """ Get all device clusters (probable physical device identities). @@ -766,9 +741,9 @@ def get_device_clusters(): from utils.tscm.device_identity import get_identity_engine engine = get_identity_engine() - min_conf = request.args.get('min_confidence', 0, type=float) - protocol = request.args.get('protocol') - risk_filter = request.args.get('risk_level') + min_conf = request.args.get("min_confidence", 0, type=float) + protocol = request.args.get("protocol") + risk_filter = request.args.get("risk_level") clusters = engine.get_clusters(min_confidence=min_conf) @@ -778,23 +753,25 @@ def get_device_clusters(): if risk_filter: clusters = [c for c in clusters if c.risk_level.value == risk_filter] - return jsonify({ - 'status': 'success', - 'count': len(clusters), - 'clusters': [c.to_dict() for c in clusters], - 'disclaimer': ( - "Clusters represent PROBABLE device identities based on passive " - "fingerprinting. Results are statistical correlations, not " - "confirmed matches. False positives/negatives are expected." - ) - }) + return jsonify( + { + "status": "success", + "count": len(clusters), + "clusters": [c.to_dict() for c in clusters], + "disclaimer": ( + "Clusters represent PROBABLE device identities based on passive " + "fingerprinting. Results are statistical correlations, not " + "confirmed matches. False positives/negatives are expected." + ), + } + ) except Exception as e: logger.error(f"Get clusters error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/identity/clusters/high-risk') +@tscm_bp.route("/identity/clusters/high-risk") def get_high_risk_clusters(): """Get device clusters with HIGH risk level.""" try: @@ -803,23 +780,25 @@ def get_high_risk_clusters(): engine = get_identity_engine() clusters = engine.get_high_risk_clusters() - return jsonify({ - 'status': 'success', - 'count': len(clusters), - 'clusters': [c.to_dict() for c in clusters], - 'disclaimer': ( - "High-risk classification indicates multiple behavioral indicators " - "consistent with potential surveillance devices. This does NOT " - "confirm surveillance activity. Professional verification required." - ) - }) + return jsonify( + { + "status": "success", + "count": len(clusters), + "clusters": [c.to_dict() for c in clusters], + "disclaimer": ( + "High-risk classification indicates multiple behavioral indicators " + "consistent with potential surveillance devices. This does NOT " + "confirm surveillance activity. Professional verification required." + ), + } + ) except Exception as e: logger.error(f"Get high-risk clusters error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/identity/summary') +@tscm_bp.route("/identity/summary") def get_identity_summary(): """ Get summary of device identity analysis. @@ -832,17 +811,14 @@ def get_identity_summary(): engine = get_identity_engine() summary = engine.get_summary() - return jsonify({ - 'status': 'success', - 'summary': summary - }) + return jsonify({"status": "success", "summary": summary}) except Exception as e: logger.error(f"Get identity summary error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/identity/finalize', methods=['POST']) +@tscm_bp.route("/identity/finalize", methods=["POST"]) def finalize_identity_sessions(): """ Finalize all active sessions and complete clustering. @@ -857,18 +833,14 @@ def finalize_identity_sessions(): engine.finalize_all_sessions() summary = engine.get_summary() - return jsonify({ - 'status': 'success', - 'message': 'All sessions finalized', - 'summary': summary - }) + return jsonify({"status": "success", "message": "All sessions finalized", "summary": summary}) except Exception as e: logger.error(f"Finalize sessions error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/identity/reset', methods=['POST']) +@tscm_bp.route("/identity/reset", methods=["POST"]) def reset_identity_engine(): """ Reset the device identity engine. @@ -880,17 +852,14 @@ def reset_identity_engine(): reset_engine() - return jsonify({ - 'status': 'success', - 'message': 'Device identity engine reset' - }) + return jsonify({"status": "success", "message": "Device identity engine reset"}) except Exception as e: logger.error(f"Reset identity engine error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/identity/cluster/') +@tscm_bp.route("/identity/cluster/") def get_cluster_detail(cluster_id: str): """Get detailed information for a specific cluster.""" try: @@ -899,28 +868,23 @@ def get_cluster_detail(cluster_id: str): engine = get_identity_engine() if cluster_id not in engine.clusters: - return jsonify({ - 'status': 'error', - 'message': 'Cluster not found' - }), 404 + return jsonify({"status": "error", "message": "Cluster not found"}), 404 cluster = engine.clusters[cluster_id] - return jsonify({ - 'status': 'success', - 'cluster': cluster.to_dict() - }) + return jsonify({"status": "success", "cluster": cluster.to_dict()}) except Exception as e: logger.error(f"Get cluster detail error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 # ============================================================================= # Device Timeline Endpoints # ============================================================================= -@tscm_bp.route('/device//timeline') + +@tscm_bp.route("/device//timeline") def get_device_timeline_endpoint(identifier: str): """ Get timeline of observations for a device. @@ -932,8 +896,8 @@ def get_device_timeline_endpoint(identifier: str): from utils.database import get_device_timeline from utils.tscm.advanced import get_timeline_manager - protocol = request.args.get('protocol', 'bluetooth') - since_hours = request.args.get('since_hours', 24, type=int) + protocol = request.args.get("protocol", "bluetooth") + since_hours = request.args.get("since_hours", 24, type=int) # Try in-memory timeline first manager = get_timeline_manager() @@ -943,44 +907,41 @@ def get_device_timeline_endpoint(identifier: str): stored = get_device_timeline(identifier, since_hours=since_hours) result = { - 'identifier': identifier, - 'protocol': protocol, - 'observations': stored, + "identifier": identifier, + "protocol": protocol, + "observations": stored, } if timeline: - result['metrics'] = { - 'first_seen': timeline.first_seen.isoformat() if timeline.first_seen else None, - 'last_seen': timeline.last_seen.isoformat() if timeline.last_seen else None, - 'total_observations': timeline.total_observations, - 'presence_ratio': round(timeline.presence_ratio, 2), + result["metrics"] = { + "first_seen": timeline.first_seen.isoformat() if timeline.first_seen else None, + "last_seen": timeline.last_seen.isoformat() if timeline.last_seen else None, + "total_observations": timeline.total_observations, + "presence_ratio": round(timeline.presence_ratio, 2), } - result['signal'] = { - 'rssi_min': timeline.rssi_min, - 'rssi_max': timeline.rssi_max, - 'rssi_mean': round(timeline.rssi_mean, 1) if timeline.rssi_mean else None, - 'stability': round(timeline.rssi_stability, 2), + result["signal"] = { + "rssi_min": timeline.rssi_min, + "rssi_max": timeline.rssi_max, + "rssi_mean": round(timeline.rssi_mean, 1) if timeline.rssi_mean else None, + "stability": round(timeline.rssi_stability, 2), } - result['movement'] = { - 'appears_stationary': timeline.appears_stationary, - 'pattern': timeline.movement_pattern, + result["movement"] = { + "appears_stationary": timeline.appears_stationary, + "pattern": timeline.movement_pattern, } - result['meeting_correlation'] = { - 'correlated': timeline.meeting_correlated, - 'observations_during_meeting': timeline.meeting_observations, + result["meeting_correlation"] = { + "correlated": timeline.meeting_correlated, + "observations_during_meeting": timeline.meeting_observations, } - return jsonify({ - 'status': 'success', - 'timeline': result - }) + return jsonify({"status": "success", "timeline": result}) except Exception as e: logger.error(f"Get device timeline error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/timelines') +@tscm_bp.route("/timelines") def get_all_device_timelines(): """Get all device timelines.""" try: @@ -989,39 +950,32 @@ def get_all_device_timelines(): manager = get_timeline_manager() timelines = manager.get_all_timelines() - return jsonify({ - 'status': 'success', - 'count': len(timelines), - 'timelines': [t.to_dict() for t in timelines] - }) + return jsonify({"status": "success", "count": len(timelines), "timelines": [t.to_dict() for t in timelines]}) except Exception as e: logger.error(f"Get all timelines error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 # ============================================================================= # Known-Good Registry (Whitelist) Endpoints # ============================================================================= -@tscm_bp.route('/known-devices', methods=['GET']) + +@tscm_bp.route("/known-devices", methods=["GET"]) def list_known_devices(): """List all known-good devices.""" from utils.database import get_all_known_devices - location = request.args.get('location') - scope = request.args.get('scope') + location = request.args.get("location") + scope = request.args.get("scope") devices = get_all_known_devices(location=location, scope=scope) - return jsonify({ - 'status': 'success', - 'count': len(devices), - 'devices': devices - }) + return jsonify({"status": "success", "count": len(devices), "devices": devices}) -@tscm_bp.route('/known-devices', methods=['POST']) +@tscm_bp.route("/known-devices", methods=["POST"]) def add_known_device_endpoint(): """ Add a device to the known-good registry. @@ -1033,74 +987,57 @@ def add_known_device_endpoint(): data = request.get_json() or {} - identifier = data.get('identifier') - protocol = data.get('protocol') + identifier = data.get("identifier") + protocol = data.get("protocol") if not identifier or not protocol: - return jsonify({ - 'status': 'error', - 'message': 'identifier and protocol are required' - }), 400 + return jsonify({"status": "error", "message": "identifier and protocol are required"}), 400 device_id = add_known_device( identifier=identifier, protocol=protocol, - name=data.get('name'), - description=data.get('description'), - location=data.get('location'), - scope=data.get('scope', 'global'), - added_by=data.get('added_by'), - score_modifier=data.get('score_modifier', -2), - metadata=data.get('metadata') + name=data.get("name"), + description=data.get("description"), + location=data.get("location"), + scope=data.get("scope", "global"), + added_by=data.get("added_by"), + score_modifier=data.get("score_modifier", -2), + metadata=data.get("metadata"), ) - return jsonify({ - 'status': 'success', - 'message': 'Device added to known-good registry', - 'device_id': device_id - }) + return jsonify({"status": "success", "message": "Device added to known-good registry", "device_id": device_id}) -@tscm_bp.route('/known-devices/', methods=['GET']) +@tscm_bp.route("/known-devices/", methods=["GET"]) def get_known_device_endpoint(identifier: str): """Get a known device by identifier.""" from utils.database import get_known_device device = get_known_device(identifier) if not device: - return jsonify({'status': 'error', 'message': 'Device not found'}), 404 + return jsonify({"status": "error", "message": "Device not found"}), 404 - return jsonify({ - 'status': 'success', - 'device': device - }) + return jsonify({"status": "success", "device": device}) -@tscm_bp.route('/known-devices/', methods=['DELETE']) +@tscm_bp.route("/known-devices/", methods=["DELETE"]) def delete_known_device_endpoint(identifier: str): """Remove a device from the known-good registry.""" from utils.database import delete_known_device success = delete_known_device(identifier) if not success: - return jsonify({'status': 'error', 'message': 'Device not found'}), 404 + return jsonify({"status": "error", "message": "Device not found"}), 404 - return jsonify({ - 'status': 'success', - 'message': 'Device removed from known-good registry' - }) + return jsonify({"status": "success", "message": "Device removed from known-good registry"}) -@tscm_bp.route('/known-devices/check/') +@tscm_bp.route("/known-devices/check/") def check_known_device(identifier: str): """Check if a device is in the known-good registry.""" from utils.database import is_known_good_device - location = request.args.get('location') + location = request.args.get("location") result = is_known_good_device(identifier, location=location) - return jsonify({ - 'status': 'success', - 'is_known': result is not None, - 'details': result - }) + return jsonify({"status": "success", "is_known": result is not None, "details": result}) diff --git a/routes/tscm/baseline.py b/routes/tscm/baseline.py index 4680b09..a555899 100644 --- a/routes/tscm/baseline.py +++ b/routes/tscm/baseline.py @@ -28,95 +28,87 @@ from utils.tscm.baseline import ( get_comparison_for_active_baseline, ) -logger = logging.getLogger('intercept.tscm') +logger = logging.getLogger("intercept.tscm") -@tscm_bp.route('/baseline/record', methods=['POST']) +@tscm_bp.route("/baseline/record", methods=["POST"]) def record_baseline(): """Start recording a new baseline.""" data = request.get_json() or {} - name = data.get('name', f'Baseline {datetime.now().strftime("%Y-%m-%d %H:%M")}') - location = data.get('location') - description = data.get('description') + name = data.get("name", f"Baseline {datetime.now().strftime('%Y-%m-%d %H:%M')}") + location = data.get("location") + description = data.get("description") baseline_id = _baseline_recorder.start_recording(name, location, description) - return jsonify({ - 'status': 'success', - 'message': 'Baseline recording started', - 'baseline_id': baseline_id - }) + return jsonify({"status": "success", "message": "Baseline recording started", "baseline_id": baseline_id}) -@tscm_bp.route('/baseline/stop', methods=['POST']) +@tscm_bp.route("/baseline/stop", methods=["POST"]) def stop_baseline(): """Stop baseline recording.""" result = _baseline_recorder.stop_recording() - if 'error' in result: - return jsonify({'status': 'error', 'message': result['error']}) + if "error" in result: + return jsonify({"status": "error", "message": result["error"]}) - return jsonify({ - 'status': 'success', - 'message': 'Baseline recording complete', - **result - }) + return jsonify({"status": "success", "message": "Baseline recording complete", **result}) -@tscm_bp.route('/baseline/status') +@tscm_bp.route("/baseline/status") def baseline_status(): """Get baseline recording status.""" return jsonify(_baseline_recorder.get_recording_status()) -@tscm_bp.route('/baselines') +@tscm_bp.route("/baselines") def list_baselines(): """List all baselines.""" baselines = get_all_tscm_baselines() - return jsonify({'status': 'success', 'baselines': baselines}) + return jsonify({"status": "success", "baselines": baselines}) -@tscm_bp.route('/baseline/') +@tscm_bp.route("/baseline/") def get_baseline(baseline_id: int): """Get a specific baseline.""" baseline = get_tscm_baseline(baseline_id) if not baseline: - return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 + return jsonify({"status": "error", "message": "Baseline not found"}), 404 - return jsonify({'status': 'success', 'baseline': baseline}) + return jsonify({"status": "success", "baseline": baseline}) -@tscm_bp.route('/baseline//activate', methods=['POST']) +@tscm_bp.route("/baseline//activate", methods=["POST"]) def activate_baseline(baseline_id: int): """Set a baseline as active.""" success = set_active_tscm_baseline(baseline_id) if not success: - return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 + return jsonify({"status": "error", "message": "Baseline not found"}), 404 - return jsonify({'status': 'success', 'message': 'Baseline activated'}) + return jsonify({"status": "success", "message": "Baseline activated"}) -@tscm_bp.route('/baseline/', methods=['DELETE']) +@tscm_bp.route("/baseline/", methods=["DELETE"]) def remove_baseline(baseline_id: int): """Delete a baseline.""" success = delete_tscm_baseline(baseline_id) if not success: - return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 + return jsonify({"status": "error", "message": "Baseline not found"}), 404 - return jsonify({'status': 'success', 'message': 'Baseline deleted'}) + return jsonify({"status": "success", "message": "Baseline deleted"}) -@tscm_bp.route('/baseline/active') +@tscm_bp.route("/baseline/active") def get_active_baseline(): """Get the currently active baseline.""" baseline = get_active_tscm_baseline() if not baseline: - return jsonify({'status': 'success', 'baseline': None}) + return jsonify({"status": "success", "baseline": None}) - return jsonify({'status': 'success', 'baseline': baseline}) + return jsonify({"status": "success", "baseline": baseline}) -@tscm_bp.route('/baseline/compare', methods=['POST']) +@tscm_bp.route("/baseline/compare", methods=["POST"]) def compare_against_baseline(): """ Compare provided device data against the active baseline. @@ -131,36 +123,28 @@ def compare_against_baseline(): """ data = request.get_json() or {} - wifi_devices = data.get('wifi_devices') - wifi_clients = data.get('wifi_clients') - bt_devices = data.get('bt_devices') - rf_signals = data.get('rf_signals') + wifi_devices = data.get("wifi_devices") + wifi_clients = data.get("wifi_clients") + bt_devices = data.get("bt_devices") + rf_signals = data.get("rf_signals") # Use the convenience function that gets active baseline comparison = get_comparison_for_active_baseline( - wifi_devices=wifi_devices, - wifi_clients=wifi_clients, - bt_devices=bt_devices, - rf_signals=rf_signals + wifi_devices=wifi_devices, wifi_clients=wifi_clients, bt_devices=bt_devices, rf_signals=rf_signals ) if comparison is None: - return jsonify({ - 'status': 'error', - 'message': 'No active baseline set' - }), 400 + return jsonify({"status": "error", "message": "No active baseline set"}), 400 - return jsonify({ - 'status': 'success', - 'comparison': comparison - }) + return jsonify({"status": "success", "comparison": comparison}) # ============================================================================= # Baseline Diff & Health Endpoints # ============================================================================= -@tscm_bp.route('/baseline/diff//') + +@tscm_bp.route("/baseline/diff//") def get_baseline_diff(baseline_id: int, sweep_id: int): """ Get comprehensive diff between a baseline and a sweep. @@ -173,21 +157,21 @@ def get_baseline_diff(baseline_id: int, sweep_id: int): baseline = get_tscm_baseline(baseline_id) if not baseline: - return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 + return jsonify({"status": "error", "message": "Baseline not found"}), 404 sweep = get_tscm_sweep(sweep_id) if not sweep: - return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404 + return jsonify({"status": "error", "message": "Sweep not found"}), 404 # Get current devices from sweep results - results = sweep.get('results', {}) + results = sweep.get("results", {}) if isinstance(results, str): results = json.loads(results) - current_wifi = results.get('wifi_devices', []) - current_wifi_clients = results.get('wifi_clients', []) - current_bt = results.get('bt_devices', []) - current_rf = results.get('rf_signals', []) + current_wifi = results.get("wifi_devices", []) + current_wifi_clients = results.get("wifi_clients", []) + current_bt = results.get("bt_devices", []) + current_rf = results.get("rf_signals", []) diff = calculate_baseline_diff( baseline=baseline, @@ -195,76 +179,74 @@ def get_baseline_diff(baseline_id: int, sweep_id: int): current_wifi_clients=current_wifi_clients, current_bt=current_bt, current_rf=current_rf, - sweep_id=sweep_id + sweep_id=sweep_id, ) - return jsonify({ - 'status': 'success', - 'diff': diff.to_dict() - }) + return jsonify({"status": "success", "diff": diff.to_dict()}) except Exception as e: logger.error(f"Get baseline diff error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/baseline//health') +@tscm_bp.route("/baseline//health") def get_baseline_health(baseline_id: int): """Get health assessment for a baseline.""" try: - baseline = get_tscm_baseline(baseline_id) if not baseline: - return jsonify({'status': 'error', 'message': 'Baseline not found'}), 404 + return jsonify({"status": "error", "message": "Baseline not found"}), 404 # Calculate age - created_at = baseline.get('created_at') + created_at = baseline.get("created_at") age_hours = 0 if created_at: if isinstance(created_at, str): - created = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + created = datetime.fromisoformat(created_at.replace("Z", "+00:00")) age_hours = (datetime.now() - created.replace(tzinfo=None)).total_seconds() / 3600 elif isinstance(created_at, datetime): age_hours = (datetime.now() - created_at).total_seconds() / 3600 # Count devices total_devices = ( - len(baseline.get('wifi_networks', [])) + - len(baseline.get('bt_devices', [])) + - len(baseline.get('rf_frequencies', [])) + len(baseline.get("wifi_networks", [])) + + len(baseline.get("bt_devices", [])) + + len(baseline.get("rf_frequencies", [])) ) # Determine health - health = 'healthy' + health = "healthy" score = 1.0 reasons = [] if age_hours > 168: - health = 'stale' + health = "stale" score = 0.3 - reasons.append(f'Baseline is {age_hours:.0f} hours old (over 1 week)') + reasons.append(f"Baseline is {age_hours:.0f} hours old (over 1 week)") elif age_hours > 72: - health = 'noisy' + health = "noisy" score = 0.6 - reasons.append(f'Baseline is {age_hours:.0f} hours old (over 3 days)') + reasons.append(f"Baseline is {age_hours:.0f} hours old (over 3 days)") if total_devices < 3: score -= 0.2 - reasons.append(f'Baseline has few devices ({total_devices})') - if health == 'healthy': - health = 'noisy' + reasons.append(f"Baseline has few devices ({total_devices})") + if health == "healthy": + health = "noisy" - return jsonify({ - 'status': 'success', - 'health': { - 'status': health, - 'score': round(max(0, score), 2), - 'age_hours': round(age_hours, 1), - 'total_devices': total_devices, - 'reasons': reasons, + return jsonify( + { + "status": "success", + "health": { + "status": health, + "score": round(max(0, score), 2), + "age_hours": round(age_hours, 1), + "total_devices": total_devices, + "reasons": reasons, + }, } - }) + ) except Exception as e: logger.error(f"Get baseline health error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 diff --git a/routes/tscm/cases.py b/routes/tscm/cases.py index 59c14af..bb1af60 100644 --- a/routes/tscm/cases.py +++ b/routes/tscm/cases.py @@ -12,69 +12,58 @@ from flask import jsonify, request from routes.tscm import tscm_bp -logger = logging.getLogger('intercept.tscm') +logger = logging.getLogger("intercept.tscm") -@tscm_bp.route('/cases', methods=['GET']) +@tscm_bp.route("/cases", methods=["GET"]) def list_cases(): """List all TSCM cases.""" from utils.database import get_all_tscm_cases - status = request.args.get('status') - limit = request.args.get('limit', 50, type=int) + status = request.args.get("status") + limit = request.args.get("limit", 50, type=int) cases = get_all_tscm_cases(status=status, limit=limit) - return jsonify({ - 'status': 'success', - 'count': len(cases), - 'cases': cases - }) + return jsonify({"status": "success", "count": len(cases), "cases": cases}) -@tscm_bp.route('/cases', methods=['POST']) +@tscm_bp.route("/cases", methods=["POST"]) def create_case(): """Create a new TSCM case.""" from utils.database import create_tscm_case data = request.get_json() or {} - name = data.get('name') + name = data.get("name") if not name: - return jsonify({'status': 'error', 'message': 'name is required'}), 400 + return jsonify({"status": "error", "message": "name is required"}), 400 case_id = create_tscm_case( name=name, - description=data.get('description'), - location=data.get('location'), - priority=data.get('priority', 'normal'), - created_by=data.get('created_by'), - metadata=data.get('metadata') + description=data.get("description"), + location=data.get("location"), + priority=data.get("priority", "normal"), + created_by=data.get("created_by"), + metadata=data.get("metadata"), ) - return jsonify({ - 'status': 'success', - 'message': 'Case created', - 'case_id': case_id - }) + return jsonify({"status": "success", "message": "Case created", "case_id": case_id}) -@tscm_bp.route('/cases/', methods=['GET']) +@tscm_bp.route("/cases/", methods=["GET"]) def get_case(case_id: int): """Get a TSCM case with all linked sweeps, threats, and notes.""" from utils.database import get_tscm_case case = get_tscm_case(case_id) if not case: - return jsonify({'status': 'error', 'message': 'Case not found'}), 404 + return jsonify({"status": "error", "message": "Case not found"}), 404 - return jsonify({ - 'status': 'success', - 'case': case - }) + return jsonify({"status": "success", "case": case}) -@tscm_bp.route('/cases/', methods=['PUT']) +@tscm_bp.route("/cases/", methods=["PUT"]) def update_case(case_id: int): """Update a TSCM case.""" from utils.database import update_tscm_case @@ -83,67 +72,61 @@ def update_case(case_id: int): success = update_tscm_case( case_id=case_id, - status=data.get('status'), - priority=data.get('priority'), - assigned_to=data.get('assigned_to'), - notes=data.get('notes') + status=data.get("status"), + priority=data.get("priority"), + assigned_to=data.get("assigned_to"), + notes=data.get("notes"), ) if not success: - return jsonify({'status': 'error', 'message': 'Case not found'}), 404 + return jsonify({"status": "error", "message": "Case not found"}), 404 - return jsonify({ - 'status': 'success', - 'message': 'Case updated' - }) + return jsonify({"status": "success", "message": "Case updated"}) -@tscm_bp.route('/cases//sweeps/', methods=['POST']) +@tscm_bp.route("/cases//sweeps/", methods=["POST"]) def link_sweep_to_case(case_id: int, sweep_id: int): """Link a sweep to a case.""" from utils.database import add_sweep_to_case success = add_sweep_to_case(case_id, sweep_id) - return jsonify({ - 'status': 'success' if success else 'error', - 'message': 'Sweep linked to case' if success else 'Already linked or not found' - }) + return jsonify( + { + "status": "success" if success else "error", + "message": "Sweep linked to case" if success else "Already linked or not found", + } + ) -@tscm_bp.route('/cases//threats/', methods=['POST']) +@tscm_bp.route("/cases//threats/", methods=["POST"]) def link_threat_to_case(case_id: int, threat_id: int): """Link a threat to a case.""" from utils.database import add_threat_to_case success = add_threat_to_case(case_id, threat_id) - return jsonify({ - 'status': 'success' if success else 'error', - 'message': 'Threat linked to case' if success else 'Already linked or not found' - }) + return jsonify( + { + "status": "success" if success else "error", + "message": "Threat linked to case" if success else "Already linked or not found", + } + ) -@tscm_bp.route('/cases//notes', methods=['POST']) +@tscm_bp.route("/cases//notes", methods=["POST"]) def add_note_to_case(case_id: int): """Add a note to a case.""" from utils.database import add_case_note data = request.get_json() or {} - content = data.get('content') + content = data.get("content") if not content: - return jsonify({'status': 'error', 'message': 'content is required'}), 400 + return jsonify({"status": "error", "message": "content is required"}), 400 note_id = add_case_note( - case_id=case_id, - content=content, - note_type=data.get('note_type', 'general'), - created_by=data.get('created_by') + case_id=case_id, content=content, note_type=data.get("note_type", "general"), created_by=data.get("created_by") ) - return jsonify({ - 'status': 'success', - 'message': 'Note added', - 'note_id': note_id - }) + return jsonify({"status": "success", "message": "Note added", "note_id": note_id}) diff --git a/routes/tscm/meeting.py b/routes/tscm/meeting.py index 04ae3bd..a697199 100644 --- a/routes/tscm/meeting.py +++ b/routes/tscm/meeting.py @@ -18,10 +18,10 @@ from routes.tscm import ( ) from utils.tscm.correlation import get_correlation_engine -logger = logging.getLogger('intercept.tscm') +logger = logging.getLogger("intercept.tscm") -@tscm_bp.route('/meeting/start', methods=['POST']) +@tscm_bp.route("/meeting/start", methods=["POST"]) def start_meeting(): """ Mark the start of a sensitive period (meeting, briefing, etc.). @@ -32,57 +32,48 @@ def start_meeting(): correlation = get_correlation_engine() correlation.start_meeting_window() - _emit_event('meeting_started', { - 'timestamp': datetime.now().isoformat(), - 'message': 'Sensitive period monitoring active' - }) + _emit_event( + "meeting_started", {"timestamp": datetime.now().isoformat(), "message": "Sensitive period monitoring active"} + ) - return jsonify({ - 'status': 'success', - 'message': 'Meeting window started - devices detected now will be flagged' - }) + return jsonify({"status": "success", "message": "Meeting window started - devices detected now will be flagged"}) -@tscm_bp.route('/meeting/end', methods=['POST']) +@tscm_bp.route("/meeting/end", methods=["POST"]) def end_meeting(): """Mark the end of a sensitive period.""" correlation = get_correlation_engine() correlation.end_meeting_window() - _emit_event('meeting_ended', { - 'timestamp': datetime.now().isoformat() - }) + _emit_event("meeting_ended", {"timestamp": datetime.now().isoformat()}) - return jsonify({ - 'status': 'success', - 'message': 'Meeting window ended' - }) + return jsonify({"status": "success", "message": "Meeting window ended"}) -@tscm_bp.route('/meeting/status') +@tscm_bp.route("/meeting/status") def meeting_status(): """Check if currently in a meeting window.""" correlation = get_correlation_engine() in_meeting = correlation.is_during_meeting() - return jsonify({ - 'status': 'success', - 'in_meeting': in_meeting, - 'windows': [ - { - 'start': start.isoformat(), - 'end': end.isoformat() if end else None - } - for start, end in correlation.meeting_windows - ] - }) + return jsonify( + { + "status": "success", + "in_meeting": in_meeting, + "windows": [ + {"start": start.isoformat(), "end": end.isoformat() if end else None} + for start, end in correlation.meeting_windows + ], + } + ) # ============================================================================= # Meeting Window Enhanced Endpoints # ============================================================================= -@tscm_bp.route('/meeting/start-tracked', methods=['POST']) + +@tscm_bp.route("/meeting/start-tracked", methods=["POST"]) def start_tracked_meeting(): """ Start a tracked meeting window with database persistence. @@ -95,10 +86,7 @@ def start_tracked_meeting(): data = request.get_json() or {} meeting_id = start_meeting_window( - sweep_id=_current_sweep_id, - name=data.get('name'), - location=data.get('location'), - notes=data.get('notes') + sweep_id=_current_sweep_id, name=data.get("name"), location=data.get("location"), notes=data.get("notes") ) # Start meeting in correlation engine @@ -109,20 +97,19 @@ def start_tracked_meeting(): manager = get_timeline_manager() manager.start_meeting_window() - _emit_event('meeting_started', { - 'meeting_id': meeting_id, - 'timestamp': datetime.now().isoformat(), - 'name': data.get('name'), - }) + _emit_event( + "meeting_started", + { + "meeting_id": meeting_id, + "timestamp": datetime.now().isoformat(), + "name": data.get("name"), + }, + ) - return jsonify({ - 'status': 'success', - 'message': 'Tracked meeting window started', - 'meeting_id': meeting_id - }) + return jsonify({"status": "success", "message": "Tracked meeting window started", "meeting_id": meeting_id}) -@tscm_bp.route('/meeting//end', methods=['POST']) +@tscm_bp.route("/meeting//end", methods=["POST"]) def end_tracked_meeting(meeting_id: int): """End a tracked meeting window.""" from utils.database import end_meeting_window @@ -130,7 +117,7 @@ def end_tracked_meeting(meeting_id: int): success = end_meeting_window(meeting_id) if not success: - return jsonify({'status': 'error', 'message': 'Meeting not found or already ended'}), 404 + return jsonify({"status": "error", "message": "Meeting not found or already ended"}), 404 # End in correlation engine correlation = get_correlation_engine() @@ -140,18 +127,12 @@ def end_tracked_meeting(meeting_id: int): manager = get_timeline_manager() manager.end_meeting_window() - _emit_event('meeting_ended', { - 'meeting_id': meeting_id, - 'timestamp': datetime.now().isoformat() - }) + _emit_event("meeting_ended", {"meeting_id": meeting_id, "timestamp": datetime.now().isoformat()}) - return jsonify({ - 'status': 'success', - 'message': 'Meeting window ended' - }) + return jsonify({"status": "success", "message": "Meeting window ended"}) -@tscm_bp.route('/meeting//summary') +@tscm_bp.route("/meeting//summary") def get_meeting_summary_endpoint(meeting_id: int): """Get detailed summary of device activity during a meeting.""" try: @@ -163,12 +144,12 @@ def get_meeting_summary_endpoint(meeting_id: int): windows = get_meeting_windows(_current_sweep_id or 0) meeting = None for w in windows: - if w.get('id') == meeting_id: + if w.get("id") == meeting_id: meeting = w break if not meeting: - return jsonify({'status': 'error', 'message': 'Meeting not found'}), 404 + return jsonify({"status": "error", "message": "Meeting not found"}), 404 # Get timelines and profiles manager = get_timeline_manager() @@ -179,25 +160,18 @@ def get_meeting_summary_endpoint(meeting_id: int): summary = generate_meeting_summary(meeting, timelines, profiles) - return jsonify({ - 'status': 'success', - 'summary': summary.to_dict() - }) + return jsonify({"status": "success", "summary": summary.to_dict()}) except Exception as e: logger.error(f"Get meeting summary error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/meeting/active') +@tscm_bp.route("/meeting/active") def get_active_meeting(): """Get currently active meeting window.""" from utils.database import get_active_meeting_window meeting = get_active_meeting_window(_current_sweep_id) - return jsonify({ - 'status': 'success', - 'meeting': meeting, - 'is_active': meeting is not None - }) + return jsonify({"status": "success", "meeting": meeting, "is_active": meeting is not None}) diff --git a/routes/tscm/schedules.py b/routes/tscm/schedules.py index 44dc3c2..a167fd7 100644 --- a/routes/tscm/schedules.py +++ b/routes/tscm/schedules.py @@ -26,42 +26,44 @@ from utils.database import ( update_tscm_schedule, ) -logger = logging.getLogger('intercept.tscm') +logger = logging.getLogger("intercept.tscm") -@tscm_bp.route('/schedules', methods=['GET']) +@tscm_bp.route("/schedules", methods=["GET"]) def list_schedules(): """List all TSCM sweep schedules.""" - enabled_param = request.args.get('enabled') + enabled_param = request.args.get("enabled") enabled = None if enabled_param is not None: - enabled = enabled_param.lower() in ('1', 'true', 'yes') + enabled = enabled_param.lower() in ("1", "true", "yes") schedules = get_all_tscm_schedules(enabled=enabled, limit=200) - return jsonify({ - 'status': 'success', - 'count': len(schedules), - 'schedules': schedules, - }) + return jsonify( + { + "status": "success", + "count": len(schedules), + "schedules": schedules, + } + ) -@tscm_bp.route('/schedules', methods=['POST']) +@tscm_bp.route("/schedules", methods=["POST"]) def create_schedule(): """Create a new sweep schedule.""" data = request.get_json() or {} - name = (data.get('name') or '').strip() - cron_expression = (data.get('cron_expression') or '').strip() - sweep_type = data.get('sweep_type', 'standard') - baseline_id = data.get('baseline_id') - zone_name = data.get('zone_name') - enabled = bool(data.get('enabled', True)) - notify_on_threat = bool(data.get('notify_on_threat', True)) - notify_email = data.get('notify_email') + name = (data.get("name") or "").strip() + cron_expression = (data.get("cron_expression") or "").strip() + sweep_type = data.get("sweep_type", "standard") + baseline_id = data.get("baseline_id") + zone_name = data.get("zone_name") + enabled = bool(data.get("enabled", True)) + notify_on_threat = bool(data.get("notify_on_threat", True)) + notify_email = data.get("notify_email") if not name: - return jsonify({'status': 'error', 'message': 'Schedule name required'}), 400 + return jsonify({"status": "error", "message": "Schedule name required"}), 400 if not cron_expression: - return jsonify({'status': 'error', 'message': 'cron_expression required'}), 400 + return jsonify({"status": "error", "message": "cron_expression required"}), 400 next_run = None if enabled: @@ -70,7 +72,7 @@ def create_schedule(): next_local = _next_run_from_cron(cron_expression, datetime.now(tz)) next_run = next_local.astimezone(timezone.utc).isoformat() if next_local else None except Exception as e: - return jsonify({'status': 'error', 'message': f'Invalid cron: {e}'}), 400 + return jsonify({"status": "error", "message": f"Invalid cron: {e}"}), 400 schedule_id = create_tscm_schedule( name=name, @@ -84,92 +86,88 @@ def create_schedule(): next_run=next_run, ) schedule = get_tscm_schedule(schedule_id) - return jsonify({ - 'status': 'success', - 'message': 'Schedule created', - 'schedule': schedule - }) + return jsonify({"status": "success", "message": "Schedule created", "schedule": schedule}) -@tscm_bp.route('/schedules/', methods=['PUT', 'PATCH']) +@tscm_bp.route("/schedules/", methods=["PUT", "PATCH"]) def update_schedule(schedule_id: int): """Update a sweep schedule.""" schedule = get_tscm_schedule(schedule_id) if not schedule: - return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404 + return jsonify({"status": "error", "message": "Schedule not found"}), 404 data = request.get_json() or {} updates: dict[str, Any] = {} - for key in ('name', 'cron_expression', 'sweep_type', 'baseline_id', 'zone_name', 'notify_email'): + for key in ("name", "cron_expression", "sweep_type", "baseline_id", "zone_name", "notify_email"): if key in data: updates[key] = data[key] - if 'baseline_id' in updates and updates['baseline_id'] in ('', None): - updates['baseline_id'] = None + if "baseline_id" in updates and updates["baseline_id"] in ("", None): + updates["baseline_id"] = None - if 'enabled' in data: - updates['enabled'] = 1 if data['enabled'] else 0 - if 'notify_on_threat' in data: - updates['notify_on_threat'] = 1 if data['notify_on_threat'] else 0 + if "enabled" in data: + updates["enabled"] = 1 if data["enabled"] else 0 + if "notify_on_threat" in data: + updates["notify_on_threat"] = 1 if data["notify_on_threat"] else 0 # Recalculate next_run when cron/zone/enabled changes - if any(k in updates for k in ('cron_expression', 'zone_name', 'enabled')): - if updates.get('enabled', schedule.get('enabled', 1)): - cron_expr = updates.get('cron_expression', schedule.get('cron_expression', '')) - zone_name = updates.get('zone_name', schedule.get('zone_name')) + if any(k in updates for k in ("cron_expression", "zone_name", "enabled")): + if updates.get("enabled", schedule.get("enabled", 1)): + cron_expr = updates.get("cron_expression", schedule.get("cron_expression", "")) + zone_name = updates.get("zone_name", schedule.get("zone_name")) try: tz = _get_schedule_timezone(zone_name) next_local = _next_run_from_cron(cron_expr, datetime.now(tz)) - updates['next_run'] = next_local.astimezone(timezone.utc).isoformat() if next_local else None + updates["next_run"] = next_local.astimezone(timezone.utc).isoformat() if next_local else None except Exception as e: - return jsonify({'status': 'error', 'message': f'Invalid cron: {e}'}), 400 + return jsonify({"status": "error", "message": f"Invalid cron: {e}"}), 400 else: - updates['next_run'] = None + updates["next_run"] = None if not updates: - return jsonify({'status': 'error', 'message': 'No updates provided'}), 400 + return jsonify({"status": "error", "message": "No updates provided"}), 400 update_tscm_schedule(schedule_id, **updates) schedule = get_tscm_schedule(schedule_id) - return jsonify({'status': 'success', 'schedule': schedule}) + return jsonify({"status": "success", "schedule": schedule}) -@tscm_bp.route('/schedules/', methods=['DELETE']) +@tscm_bp.route("/schedules/", methods=["DELETE"]) def delete_schedule(schedule_id: int): """Delete a sweep schedule.""" success = delete_tscm_schedule(schedule_id) if not success: - return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404 - return jsonify({'status': 'success', 'message': 'Schedule deleted'}) + return jsonify({"status": "error", "message": "Schedule not found"}), 404 + return jsonify({"status": "success", "message": "Schedule deleted"}) -@tscm_bp.route('/schedules//run', methods=['POST']) +@tscm_bp.route("/schedules//run", methods=["POST"]) def run_schedule_now(schedule_id: int): """Trigger a scheduled sweep immediately.""" schedule = get_tscm_schedule(schedule_id) if not schedule: - return jsonify({'status': 'error', 'message': 'Schedule not found'}), 404 + return jsonify({"status": "error", "message": "Schedule not found"}), 404 result = _start_sweep_internal( - sweep_type=schedule.get('sweep_type') or 'standard', - baseline_id=schedule.get('baseline_id'), + sweep_type=schedule.get("sweep_type") or "standard", + baseline_id=schedule.get("baseline_id"), wifi_enabled=True, bt_enabled=True, rf_enabled=True, - wifi_interface='', - bt_interface='', + wifi_interface="", + bt_interface="", sdr_device=None, verbose_results=False, ) - if result.get('status') != 'success': - status_code = result.pop('http_status', 400) + if result.get("status") != "success": + status_code = result.pop("http_status", 400) return jsonify(result), status_code # Update schedule run timestamps - cron_expr = schedule.get('cron_expression') or '' - tz = _get_schedule_timezone(schedule.get('zone_name')) + cron_expr = schedule.get("cron_expression") or "" + tz = _get_schedule_timezone(schedule.get("zone_name")) now_utc = datetime.now(timezone.utc) try: next_local = _next_run_from_cron(cron_expr, datetime.now(tz)) diff --git a/routes/tscm/sweep.py b/routes/tscm/sweep.py index 6dea2ee..43f2683 100644 --- a/routes/tscm/sweep.py +++ b/routes/tscm/sweep.py @@ -27,51 +27,58 @@ from utils.database import get_tscm_sweep, update_tscm_sweep from utils.event_pipeline import process_event from utils.sse import sse_stream_fanout -logger = logging.getLogger('intercept.tscm') +logger = logging.getLogger("intercept.tscm") -@tscm_bp.route('/status') +@tscm_bp.route("/status") def tscm_status(): """Check if any TSCM operation is currently running.""" import routes.tscm as _tscm_pkg - return jsonify({'running': _tscm_pkg._sweep_running}) + + return jsonify({"running": _tscm_pkg._sweep_running}) -@tscm_bp.route('/sweep/start', methods=['POST']) +@tscm_bp.route("/sweep/start", methods=["POST"]) def start_sweep(): """Start a TSCM sweep.""" data = request.get_json() or {} - sweep_type = data.get('sweep_type', 'standard') - baseline_id = data.get('baseline_id') - if baseline_id in ('', None): + sweep_type = data.get("sweep_type", "standard") + baseline_id = data.get("baseline_id") + if baseline_id in ("", None): baseline_id = None - wifi_enabled = data.get('wifi', True) - bt_enabled = data.get('bluetooth', True) - rf_enabled = data.get('rf', True) - verbose_results = bool(data.get('verbose_results', False)) + wifi_enabled = data.get("wifi", True) + bt_enabled = data.get("bluetooth", True) + rf_enabled = data.get("rf", True) + verbose_results = bool(data.get("verbose_results", False)) # Get interface selections - wifi_interface = data.get('wifi_interface', '') - bt_interface = data.get('bt_interface', '') - sdr_device = data.get('sdr_device') + wifi_interface = data.get("wifi_interface", "") + bt_interface = data.get("bt_interface", "") + sdr_device = data.get("sdr_device") # Validate custom frequency ranges if provided custom_ranges = None - if sweep_type == 'custom': - raw_ranges = data.get('custom_ranges') or [] + if sweep_type == "custom": + raw_ranges = data.get("custom_ranges") or [] validated = [] for rng in raw_ranges: try: - start = float(rng.get('start', 0)) - end = float(rng.get('end', 0)) - step = float(rng.get('step', 0.1)) + start = float(rng.get("start", 0)) + end = float(rng.get("end", 0)) + step = float(rng.get("step", 0.1)) if 0 < start < end <= 6000: - validated.append({'start': start, 'end': end, 'step': step, - 'name': rng.get('name') or f'{start:.0f}–{end:.0f} MHz'}) + validated.append( + { + "start": start, + "end": end, + "step": step, + "name": rng.get("name") or f"{start:.0f}–{end:.0f} MHz", + } + ) except (TypeError, ValueError): pass if not validated: - return jsonify({'status': 'error', 'message': 'custom sweep requires valid start/end MHz'}), 400 + return jsonify({"status": "error", "message": "custom sweep requires valid start/end MHz"}), 400 custom_ranges = validated result = _start_sweep_internal( @@ -86,236 +93,226 @@ def start_sweep(): verbose_results=verbose_results, custom_ranges=custom_ranges, ) - http_status = result.pop('http_status', 200) + http_status = result.pop("http_status", 200) return jsonify(result), http_status -@tscm_bp.route('/sweep/stop', methods=['POST']) +@tscm_bp.route("/sweep/stop", methods=["POST"]) def stop_sweep(): """Stop the current TSCM sweep.""" import routes.tscm as _tscm_pkg if not _tscm_pkg._sweep_running: - return jsonify({'status': 'error', 'message': 'No sweep running'}) + return jsonify({"status": "error", "message": "No sweep running"}) _tscm_pkg._sweep_running = False if _tscm_pkg._current_sweep_id: - update_tscm_sweep(_tscm_pkg._current_sweep_id, status='aborted', completed=True) + update_tscm_sweep(_tscm_pkg._current_sweep_id, status="aborted", completed=True) - _emit_event('sweep_stopped', {'reason': 'user_requested'}) + _emit_event("sweep_stopped", {"reason": "user_requested"}) logger.info("TSCM sweep stopped by user") - return jsonify({'status': 'success', 'message': 'Sweep stopped'}) + return jsonify({"status": "success", "message": "Sweep stopped"}) -@tscm_bp.route('/sweep/status') +@tscm_bp.route("/sweep/status") def sweep_status(): """Get current sweep status.""" import routes.tscm as _tscm_pkg status = { - 'running': _tscm_pkg._sweep_running, - 'sweep_id': _tscm_pkg._current_sweep_id, + "running": _tscm_pkg._sweep_running, + "sweep_id": _tscm_pkg._current_sweep_id, } if _tscm_pkg._current_sweep_id: sweep = get_tscm_sweep(_tscm_pkg._current_sweep_id) if sweep: - status['sweep'] = sweep + status["sweep"] = sweep return jsonify(status) -@tscm_bp.route('/sweep/stream') +@tscm_bp.route("/sweep/stream") def sweep_stream(): """SSE stream for real-time sweep updates.""" import routes.tscm as _tscm_pkg def _on_msg(msg: dict[str, Any]) -> None: - process_event('tscm', msg, msg.get('type')) + process_event("tscm", msg, msg.get("type")) return Response( sse_stream_fanout( source_queue=_tscm_pkg.tscm_queue, - channel_key='tscm', + channel_key="tscm", timeout=1.0, keepalive_interval=30.0, on_message=_on_msg, ), - mimetype='text/event-stream', - headers={ - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no' - } + mimetype="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, ) -@tscm_bp.route('/devices') +@tscm_bp.route("/devices") def get_tscm_devices(): """Get available scanning devices for TSCM sweeps.""" - devices = { - 'wifi_interfaces': [], - 'bt_adapters': [], - 'sdr_devices': [] - } + devices = {"wifi_interfaces": [], "bt_adapters": [], "sdr_devices": []} # Detect WiFi interfaces - if platform.system() == 'Darwin': # macOS + if platform.system() == "Darwin": # macOS try: result = subprocess.run( - ['networksetup', '-listallhardwareports'], - capture_output=True, text=True, timeout=5 + ["networksetup", "-listallhardwareports"], capture_output=True, text=True, timeout=5 ) - lines = result.stdout.split('\n') + lines = result.stdout.split("\n") for i, line in enumerate(lines): - if 'Wi-Fi' in line or 'AirPort' in line: + if "Wi-Fi" in line or "AirPort" in line: # Get the hardware port name (e.g., "Wi-Fi") - port_name = line.replace('Hardware Port:', '').strip() + port_name = line.replace("Hardware Port:", "").strip() for j in range(i + 1, min(i + 3, len(lines))): - if 'Device:' in lines[j]: - device = lines[j].split('Device:')[1].strip() - devices['wifi_interfaces'].append({ - 'name': device, - 'display_name': f'{port_name} ({device})', - 'type': 'internal', - 'monitor_capable': False - }) + if "Device:" in lines[j]: + device = lines[j].split("Device:")[1].strip() + devices["wifi_interfaces"].append( + { + "name": device, + "display_name": f"{port_name} ({device})", + "type": "internal", + "monitor_capable": False, + } + ) break except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): pass else: # Linux try: - result = subprocess.run( - ['iw', 'dev'], - capture_output=True, text=True, timeout=5 - ) + result = subprocess.run(["iw", "dev"], capture_output=True, text=True, timeout=5) current_iface = None - for line in result.stdout.split('\n'): + for line in result.stdout.split("\n"): line = line.strip() - if line.startswith('Interface'): + if line.startswith("Interface"): current_iface = line.split()[1] - elif current_iface and 'type' in line: + elif current_iface and "type" in line: iface_type = line.split()[-1] - devices['wifi_interfaces'].append({ - 'name': current_iface, - 'display_name': f'Wireless ({current_iface}) - {iface_type}', - 'type': iface_type, - 'monitor_capable': True - }) + devices["wifi_interfaces"].append( + { + "name": current_iface, + "display_name": f"Wireless ({current_iface}) - {iface_type}", + "type": iface_type, + "monitor_capable": True, + } + ) current_iface = None except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): # Fall back to iwconfig try: - result = subprocess.run( - ['iwconfig'], - capture_output=True, text=True, timeout=5 - ) - for line in result.stdout.split('\n'): - if 'IEEE 802.11' in line: + result = subprocess.run(["iwconfig"], capture_output=True, text=True, timeout=5) + for line in result.stdout.split("\n"): + if "IEEE 802.11" in line: iface = line.split()[0] - devices['wifi_interfaces'].append({ - 'name': iface, - 'display_name': f'Wireless ({iface})', - 'type': 'managed', - 'monitor_capable': True - }) + devices["wifi_interfaces"].append( + { + "name": iface, + "display_name": f"Wireless ({iface})", + "type": "managed", + "monitor_capable": True, + } + ) except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): pass # Detect Bluetooth adapters - if platform.system() == 'Linux': + if platform.system() == "Linux": try: - result = subprocess.run( - ['hciconfig'], - capture_output=True, text=True, timeout=5 - ) - blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE) + result = subprocess.run(["hciconfig"], capture_output=True, text=True, timeout=5) + blocks = re.split(r"(?=^hci\d+:)", result.stdout, flags=re.MULTILINE) for _idx, block in enumerate(blocks): if block.strip(): - first_line = block.split('\n')[0] - match = re.match(r'(hci\d+):', first_line) + first_line = block.split("\n")[0] + match = re.match(r"(hci\d+):", first_line) if match: iface_name = match.group(1) - is_up = 'UP RUNNING' in block or '\tUP ' in block - devices['bt_adapters'].append({ - 'name': iface_name, - 'display_name': f'Bluetooth Adapter ({iface_name})', - 'type': 'hci', - 'status': 'up' if is_up else 'down' - }) + is_up = "UP RUNNING" in block or "\tUP " in block + devices["bt_adapters"].append( + { + "name": iface_name, + "display_name": f"Bluetooth Adapter ({iface_name})", + "type": "hci", + "status": "up" if is_up else "down", + } + ) except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): # Try bluetoothctl as fallback try: - result = subprocess.run( - ['bluetoothctl', 'list'], - capture_output=True, text=True, timeout=5 - ) - for line in result.stdout.split('\n'): - if 'Controller' in line: + result = subprocess.run(["bluetoothctl", "list"], capture_output=True, text=True, timeout=5) + for line in result.stdout.split("\n"): + if "Controller" in line: # Format: Controller XX:XX:XX:XX:XX:XX Name parts = line.split() if len(parts) >= 3: addr = parts[1] - name = ' '.join(parts[2:]) if len(parts) > 2 else 'Bluetooth' - devices['bt_adapters'].append({ - 'name': addr, - 'display_name': f'{name} ({addr[-8:]})', - 'type': 'controller', - 'status': 'available' - }) + name = " ".join(parts[2:]) if len(parts) > 2 else "Bluetooth" + devices["bt_adapters"].append( + { + "name": addr, + "display_name": f"{name} ({addr[-8:]})", + "type": "controller", + "status": "available", + } + ) except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): pass - elif platform.system() == 'Darwin': + elif platform.system() == "Darwin": # macOS has built-in Bluetooth - get more info via system_profiler try: result = subprocess.run( - ['system_profiler', 'SPBluetoothDataType'], - capture_output=True, text=True, timeout=10 + ["system_profiler", "SPBluetoothDataType"], capture_output=True, text=True, timeout=10 ) # Extract controller info - bt_name = 'Built-in Bluetooth' - bt_addr = '' - for line in result.stdout.split('\n'): - if 'Address:' in line: - bt_addr = line.split('Address:')[1].strip() + bt_name = "Built-in Bluetooth" + bt_addr = "" + for line in result.stdout.split("\n"): + if "Address:" in line: + bt_addr = line.split("Address:")[1].strip() break - devices['bt_adapters'].append({ - 'name': 'default', - 'display_name': f'{bt_name}' + (f' ({bt_addr[-8:]})' if bt_addr else ''), - 'type': 'macos', - 'status': 'available' - }) + devices["bt_adapters"].append( + { + "name": "default", + "display_name": f"{bt_name}" + (f" ({bt_addr[-8:]})" if bt_addr else ""), + "type": "macos", + "status": "available", + } + ) except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): - devices['bt_adapters'].append({ - 'name': 'default', - 'display_name': 'Built-in Bluetooth', - 'type': 'macos', - 'status': 'available' - }) + devices["bt_adapters"].append( + {"name": "default", "display_name": "Built-in Bluetooth", "type": "macos", "status": "available"} + ) # Detect SDR devices try: from utils.sdr import SDRFactory + sdr_list = SDRFactory.detect_devices() for sdr in sdr_list: # SDRDevice is a dataclass with attributes, not a dict - sdr_type_name = sdr.sdr_type.value if hasattr(sdr.sdr_type, 'value') else str(sdr.sdr_type) + sdr_type_name = sdr.sdr_type.value if hasattr(sdr.sdr_type, "value") else str(sdr.sdr_type) # Create a friendly display name display_name = sdr.name - if sdr.serial and sdr.serial not in ('N/A', 'Unknown'): - display_name = f'{sdr.name} (SN: {sdr.serial[-8:]})' - devices['sdr_devices'].append({ - 'index': sdr.index, - 'name': sdr.name, - 'display_name': display_name, - 'type': sdr_type_name, - 'serial': sdr.serial, - 'driver': sdr.driver - }) + if sdr.serial and sdr.serial not in ("N/A", "Unknown"): + display_name = f"{sdr.name} (SN: {sdr.serial[-8:]})" + devices["sdr_devices"].append( + { + "index": sdr.index, + "name": sdr.name, + "display_name": display_name, + "type": sdr_type_name, + "serial": sdr.serial, + "driver": sdr.driver, + } + ) except ImportError: logger.debug("SDR module not available") except Exception as e: @@ -323,87 +320,88 @@ def get_tscm_devices(): # Check if running as root from flask import current_app - running_as_root = current_app.config.get('RUNNING_AS_ROOT', os.geteuid() == 0) + + running_as_root = current_app.config.get("RUNNING_AS_ROOT", os.geteuid() == 0) warnings = [] if not running_as_root: - warnings.append({ - 'type': 'privileges', - 'message': 'Not running as root. WiFi monitor mode and some Bluetooth features require sudo.', - 'action': 'Run with: sudo -E venv/bin/python intercept.py' - }) + warnings.append( + { + "type": "privileges", + "message": "Not running as root. WiFi monitor mode and some Bluetooth features require sudo.", + "action": "Run with: sudo -E venv/bin/python intercept.py", + } + ) - return jsonify({ - 'status': 'success', - 'devices': devices, - 'running_as_root': running_as_root, - 'warnings': warnings - }) + return jsonify({"status": "success", "devices": devices, "running_as_root": running_as_root, "warnings": warnings}) # ============================================================================= # Preset Endpoints # ============================================================================= -@tscm_bp.route('/presets') + +@tscm_bp.route("/presets") def list_presets(): """List available sweep presets.""" presets = get_all_sweep_presets() - return jsonify({'status': 'success', 'presets': presets}) + return jsonify({"status": "success", "presets": presets}) -@tscm_bp.route('/presets/') +@tscm_bp.route("/presets/") def get_preset(preset_name: str): """Get details for a specific preset.""" preset = get_sweep_preset(preset_name) if not preset: - return jsonify({'status': 'error', 'message': 'Preset not found'}), 404 + return jsonify({"status": "error", "message": "Preset not found"}), 404 - return jsonify({'status': 'success', 'preset': preset}) + return jsonify({"status": "success", "preset": preset}) # ============================================================================= # Data Feed Endpoints (for adding data during sweeps/baselines) # ============================================================================= -@tscm_bp.route('/feed/wifi', methods=['POST']) + +@tscm_bp.route("/feed/wifi", methods=["POST"]) def feed_wifi(): """Feed WiFi device data for baseline recording.""" data = request.get_json() if data: - if data.get('is_client'): + if data.get("is_client"): _baseline_recorder.add_wifi_client(data) else: _baseline_recorder.add_wifi_device(data) - return jsonify({'status': 'success'}) + return jsonify({"status": "success"}) -@tscm_bp.route('/feed/bluetooth', methods=['POST']) +@tscm_bp.route("/feed/bluetooth", methods=["POST"]) def feed_bluetooth(): """Feed Bluetooth device data for baseline recording.""" data = request.get_json() if data: _baseline_recorder.add_bt_device(data) - return jsonify({'status': 'success'}) + return jsonify({"status": "success"}) -@tscm_bp.route('/feed/rf', methods=['POST']) +@tscm_bp.route("/feed/rf", methods=["POST"]) def feed_rf(): """Feed RF signal data for baseline recording.""" data = request.get_json() if data: _baseline_recorder.add_rf_signal(data) - return jsonify({'status': 'success'}) + return jsonify({"status": "success"}) # ============================================================================= # Capabilities & Coverage Endpoints # ============================================================================= -@tscm_bp.route('/capabilities') + +@tscm_bp.route("/capabilities") def get_capabilities(): """ Get current system capabilities for TSCM sweeping. @@ -414,34 +412,25 @@ def get_capabilities(): try: from utils.tscm.advanced import detect_sweep_capabilities - wifi_interface = request.args.get('wifi_interface', '') - bt_adapter = request.args.get('bt_adapter', '') + wifi_interface = request.args.get("wifi_interface", "") + bt_adapter = request.args.get("bt_adapter", "") - caps = detect_sweep_capabilities( - wifi_interface=wifi_interface, - bt_adapter=bt_adapter - ) + caps = detect_sweep_capabilities(wifi_interface=wifi_interface, bt_adapter=bt_adapter) - return jsonify({ - 'status': 'success', - 'capabilities': caps.to_dict() - }) + return jsonify({"status": "success", "capabilities": caps.to_dict()}) except Exception as e: logger.error(f"Get capabilities error: {e}") - return jsonify({'status': 'error', 'message': str(e)}), 500 + return jsonify({"status": "error", "message": str(e)}), 500 -@tscm_bp.route('/sweep//capabilities') +@tscm_bp.route("/sweep//capabilities") def get_sweep_stored_capabilities(sweep_id: int): """Get stored capabilities for a specific sweep.""" from utils.database import get_sweep_capabilities caps = get_sweep_capabilities(sweep_id) if not caps: - return jsonify({'status': 'error', 'message': 'No capabilities stored for this sweep'}), 404 + return jsonify({"status": "error", "message": "No capabilities stored for this sweep"}), 404 - return jsonify({ - 'status': 'success', - 'capabilities': caps - }) + return jsonify({"status": "success", "capabilities": caps}) diff --git a/routes/updater.py b/routes/updater.py index a8f9e33..84d10d6 100644 --- a/routes/updater.py +++ b/routes/updater.py @@ -14,12 +14,12 @@ from utils.updater import ( restart_application, ) -logger = get_logger('intercept.routes.updater') +logger = get_logger("intercept.routes.updater") -updater_bp = Blueprint('updater', __name__, url_prefix='/updater') +updater_bp = Blueprint("updater", __name__, url_prefix="/updater") -@updater_bp.route('/check', methods=['GET']) +@updater_bp.route("/check", methods=["GET"]) def check_updates() -> Response: """ Check for updates from GitHub. @@ -33,7 +33,7 @@ def check_updates() -> Response: Returns: JSON with update status information """ - force = request.args.get('force', '').lower() == 'true' + force = request.args.get("force", "").lower() == "true" try: result = check_for_updates(force=force) @@ -43,7 +43,7 @@ def check_updates() -> Response: return api_error(str(e), 500) -@updater_bp.route('/status', methods=['GET']) +@updater_bp.route("/status", methods=["GET"]) def update_status() -> Response: """ Get current update status from cache. @@ -62,7 +62,7 @@ def update_status() -> Response: return api_error(str(e), 500) -@updater_bp.route('/update', methods=['POST']) +@updater_bp.route("/update", methods=["POST"]) def do_update() -> Response: """ Perform a git pull to update the application. @@ -74,21 +74,21 @@ def do_update() -> Response: JSON with update result information """ data = request.json or {} - stash_changes = data.get('stash_changes', False) + stash_changes = data.get("stash_changes", False) try: result = perform_update(stash_changes=stash_changes) - if result.get('success'): + if result.get("success"): return jsonify(result) else: # Return appropriate status code based on error type - error = result.get('error', '') - if error == 'local_changes': + error = result.get("error", "") + if error == "local_changes": return jsonify(result), 409 # Conflict - elif error == 'merge_conflict': + elif error == "merge_conflict": return jsonify(result), 409 - elif result.get('manual_update'): + elif result.get("manual_update"): return jsonify(result), 400 else: return jsonify(result), 500 @@ -98,7 +98,7 @@ def do_update() -> Response: return api_error(str(e), 500) -@updater_bp.route('/dismiss', methods=['POST']) +@updater_bp.route("/dismiss", methods=["POST"]) def dismiss_notification() -> Response: """ Dismiss update notification for a specific version. @@ -113,10 +113,10 @@ def dismiss_notification() -> Response: JSON with success status """ data = request.json or {} - version = data.get('version') + version = data.get("version") if not version: - return api_error('Version is required', 400) + return api_error("Version is required", 400) try: result = dismiss_update(version) @@ -126,7 +126,7 @@ def dismiss_notification() -> Response: return api_error(str(e), 500) -@updater_bp.route('/restart', methods=['POST']) +@updater_bp.route("/restart", methods=["POST"]) def restart_app() -> Response: """ Restart the application. @@ -151,6 +151,7 @@ def restart_app() -> Response: # Use a short delay to allow the response to be sent def delayed_restart(): import time + time.sleep(0.5) # Allow response to be sent restart_application() @@ -158,8 +159,4 @@ def restart_app() -> Response: restart_thread = threading.Thread(target=delayed_restart, daemon=False) restart_thread.start() - return jsonify({ - 'success': True, - 'message': 'Application is restarting. Please wait...', - 'action': 'restart' - }) + return jsonify({"success": True, "message": "Application is restarting. Please wait...", "action": "restart"}) diff --git a/routes/vdl2.py b/routes/vdl2.py index 9c7fc7b..e09fa2d 100644 --- a/routes/vdl2.py +++ b/routes/vdl2.py @@ -34,15 +34,15 @@ from utils.sdr import SDRFactory, SDRType from utils.sse import sse_stream_fanout from utils.validation import validate_device_index, validate_gain, validate_ppm -vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2') +vdl2_bp = Blueprint("vdl2", __name__, url_prefix="/vdl2") # Default VDL2 frequencies (MHz) - common worldwide DEFAULT_VDL2_FREQUENCIES = [ - '136975000', # Primary worldwide - '136725000', # Europe - '136775000', # Europe - '136800000', # Multi-region - '136875000', # Multi-region + "136975000", # Primary worldwide + "136725000", # Europe + "136775000", # Europe + "136800000", # Multi-region + "136875000", # Multi-region ] # Message counter for statistics @@ -56,7 +56,7 @@ vdl2_active_sdr_type: str | None = None def find_dumpvdl2(): """Find dumpvdl2 binary.""" - return shutil.which('dumpvdl2') + return shutil.which("dumpvdl2") def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None: @@ -64,15 +64,15 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> global vdl2_message_count, vdl2_last_message_time try: - app_module.vdl2_queue.put({'type': 'status', 'status': 'started'}) + app_module.vdl2_queue.put({"type": "status", "status": "started"}) # Use appropriate sentinel based on mode (text mode for pty on macOS) - sentinel = '' if is_text_mode else b'' + sentinel = "" if is_text_mode else b"" for line in iter(process.stdout.readline, sentinel): if is_text_mode: line = line.strip() else: - line = line.decode('utf-8', errors='replace').strip() + line = line.decode("utf-8", errors="replace").strip() if not line: continue @@ -80,45 +80,47 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> data = json.loads(line) # Add our metadata - data['type'] = 'vdl2' - data['timestamp'] = datetime.utcnow().isoformat() + 'Z' + data["type"] = "vdl2" + data["timestamp"] = datetime.utcnow().isoformat() + "Z" # Flatten nested VDL2 identifying fields to top level for correlator matching # dumpvdl2 nests flight/reg inside vdl2.avlc.acars and ICAO in avlc.src.addr try: - vdl2_inner = data.get('vdl2', data) - avlc = vdl2_inner.get('avlc') or {} - acars_payload = avlc.get('acars') or {} + vdl2_inner = data.get("vdl2", data) + avlc = vdl2_inner.get("avlc") or {} + acars_payload = avlc.get("acars") or {} # Promote AVLC source address — this is the aircraft ICAO hex # Do this FIRST so even non-ACARS VDL2 frames can be correlated - src = avlc.get('src') or {} - src_addr = src.get('addr', '') - src_type = src.get('type', '') - if src_addr and src_type == 'Aircraft': - data['icao'] = src_addr.upper() - data['addr'] = src_addr.upper() + src = avlc.get("src") or {} + src_addr = src.get("addr", "") + src_type = src.get("type", "") + if src_addr and src_type == "Aircraft": + data["icao"] = src_addr.upper() + data["addr"] = src_addr.upper() # Promote ACARS fields to top level so FlightCorrelator can match them - if acars_payload.get('flight'): - data['flight'] = acars_payload['flight'] - if acars_payload.get('reg'): - data['reg'] = acars_payload['reg'] - data['tail'] = acars_payload['reg'] - if acars_payload.get('label'): - data['label'] = acars_payload['label'] - if acars_payload.get('msg_text'): - data['text'] = acars_payload['msg_text'] + if acars_payload.get("flight"): + data["flight"] = acars_payload["flight"] + if acars_payload.get("reg"): + data["reg"] = acars_payload["reg"] + data["tail"] = acars_payload["reg"] + if acars_payload.get("label"): + data["label"] = acars_payload["label"] + if acars_payload.get("msg_text"): + data["text"] = acars_payload["msg_text"] # Enrich with translated ACARS label (consistent with ACARS route) - if acars_payload.get('label'): - translation = translate_message({ - 'label': acars_payload.get('label'), - 'text': acars_payload.get('msg_text', ''), - }) - data['label_description'] = translation['label_description'] - data['message_type'] = translation['message_type'] - data['parsed'] = translation['parsed'] + if acars_payload.get("label"): + translation = translate_message( + { + "label": acars_payload.get("label"), + "text": acars_payload.get("msg_text", ""), + } + ) + data["label_description"] = translation["label_description"] + data["message_type"] = translation["message_type"] + data["parsed"] = translation["parsed"] except Exception: pass @@ -135,8 +137,8 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> # Log if enabled if app_module.logging_enabled: try: - with open(app_module.log_file_path, 'a') as f: - ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + with open(app_module.log_file_path, "a") as f: + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") f.write(f"{ts} | VDL2 | {json.dumps(data)}\n") except Exception: pass @@ -148,7 +150,7 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> except Exception as e: logger.error(f"VDL2 stream error: {e}") - app_module.vdl2_queue.put({'type': 'error', 'message': str(e)}) + app_module.vdl2_queue.put({"type": "error", "message": str(e)}) finally: global vdl2_active_device, vdl2_active_sdr_type # Ensure process is terminated @@ -159,68 +161,67 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> with contextlib.suppress(Exception): process.kill() unregister_process(process) - app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'}) + app_module.vdl2_queue.put({"type": "status", "status": "stopped"}) with app_module.vdl2_lock: app_module.vdl2_process = None # Release SDR device if vdl2_active_device is not None: - app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or "rtlsdr") vdl2_active_device = None vdl2_active_sdr_type = None -@vdl2_bp.route('/tools') +@vdl2_bp.route("/tools") def check_vdl2_tools() -> Response: """Check for VDL2 decoding tools.""" has_dumpvdl2 = find_dumpvdl2() is not None - return jsonify({ - 'dumpvdl2': has_dumpvdl2, - 'ready': has_dumpvdl2 - }) + return jsonify({"dumpvdl2": has_dumpvdl2, "ready": has_dumpvdl2}) -@vdl2_bp.route('/status') +@vdl2_bp.route("/status") def vdl2_status() -> Response: """Get VDL2 decoder status.""" running = False if app_module.vdl2_process: running = app_module.vdl2_process.poll() is None - return jsonify({ - 'running': running, - 'message_count': vdl2_message_count, - 'last_message_time': vdl2_last_message_time, - 'queue_size': app_module.vdl2_queue.qsize() - }) + return jsonify( + { + "running": running, + "message_count": vdl2_message_count, + "last_message_time": vdl2_last_message_time, + "queue_size": app_module.vdl2_queue.qsize(), + } + ) -@vdl2_bp.route('/start', methods=['POST']) +@vdl2_bp.route("/start", methods=["POST"]) def start_vdl2() -> Response: """Start VDL2 decoder.""" global vdl2_message_count, vdl2_last_message_time, vdl2_active_device, vdl2_active_sdr_type with app_module.vdl2_lock: if app_module.vdl2_process and app_module.vdl2_process.poll() is None: - return api_error('VDL2 decoder already running', 409) + return api_error("VDL2 decoder already running", 409) # Check for dumpvdl2 dumpvdl2_path = find_dumpvdl2() if not dumpvdl2_path: - return api_error('dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2', 400) + return api_error("dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2", 400) data = request.json or {} # Validate inputs try: - device = validate_device_index(data.get('device', '0')) - gain = validate_gain(data.get('gain', '40')) - ppm = validate_ppm(data.get('ppm', '0')) + device = validate_device_index(data.get("device", "0")) + gain = validate_gain(data.get("gain", "40")) + ppm = validate_ppm(data.get("ppm", "0")) except ValueError as e: return api_error(str(e), 400) # Resolve SDR type for device selection - sdr_type_str = data.get('sdr_type', 'rtlsdr') + sdr_type_str = data.get("sdr_type", "rtlsdr") try: sdr_type = SDRType(sdr_type_str) except ValueError: @@ -228,18 +229,18 @@ def start_vdl2() -> Response: # Check if device is available device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str) + error = app_module.claim_sdr_device(device_int, "vdl2", sdr_type_str) if error: - return api_error(error, 409, error_type='DEVICE_BUSY') + return api_error(error, 409, error_type="DEVICE_BUSY") vdl2_active_device = device_int vdl2_active_sdr_type = sdr_type_str # Get frequencies - use provided or defaults # dumpvdl2 expects frequencies in Hz (integers) - frequencies = data.get('frequencies', DEFAULT_VDL2_FREQUENCIES) + frequencies = data.get("frequencies", DEFAULT_VDL2_FREQUENCIES) if isinstance(frequencies, str): - frequencies = [f.strip() for f in frequencies.split(',')] + frequencies = [f.strip() for f in frequencies.split(",")] # Clear queue while not app_module.vdl2_queue.empty(): @@ -257,24 +258,24 @@ def start_vdl2() -> Response: # Build dumpvdl2 command # dumpvdl2 --output decoded:json --rtlsdr --gain --correction ... cmd = [dumpvdl2_path] - cmd.extend(['--output', 'decoded:json:file:path=-']) + cmd.extend(["--output", "decoded:json:file:path=-"]) if is_soapy: # SoapySDR device sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int) builder = SDRFactory.get_builder(sdr_type) device_str = builder._build_device_string(sdr_device) - cmd.extend(['--soapysdr', device_str]) + cmd.extend(["--soapysdr", device_str]) else: - cmd.extend(['--rtlsdr', str(device)]) + cmd.extend(["--rtlsdr", str(device)]) # Add gain - if gain and str(gain) != '0': - cmd.extend(['--gain', str(gain)]) + if gain and str(gain) != "0": + cmd.extend(["--gain", str(gain)]) # Add PPM correction if specified - if ppm and str(ppm) != '0': - cmd.extend(['--correction', str(ppm)]) + if ppm and str(ppm) != "0": + cmd.extend(["--correction", str(ppm)]) # Add frequencies (dumpvdl2 takes them as positional args in Hz) cmd.extend(frequencies) @@ -285,25 +286,15 @@ def start_vdl2() -> Response: is_text_mode = False # On macOS, use pty to avoid stdout buffering issues - if platform.system() == 'Darwin': + if platform.system() == "Darwin": master_fd, slave_fd = pty.openpty() - process = subprocess.Popen( - cmd, - stdout=slave_fd, - stderr=subprocess.PIPE, - start_new_session=True - ) + process = subprocess.Popen(cmd, stdout=slave_fd, stderr=subprocess.PIPE, start_new_session=True) os.close(slave_fd) # Wrap master_fd as a text file for line-buffered reading process.stdout = open(master_fd, buffering=1) is_text_mode = True else: - process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - start_new_session=True - ) + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) # Wait briefly to check if process started time.sleep(PROCESS_START_WAIT) @@ -311,17 +302,17 @@ def start_vdl2() -> Response: if process.poll() is not None: # Process died - release device if vdl2_active_device is not None: - app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or "rtlsdr") vdl2_active_device = None vdl2_active_sdr_type = None - stderr = '' + stderr = "" if process.stderr: - stderr = process.stderr.read().decode('utf-8', errors='replace') + stderr = process.stderr.read().decode("utf-8", errors="replace") if stderr: logger.error(f"dumpvdl2 stderr:\n{stderr}") - error_msg = 'dumpvdl2 failed to start' + error_msg = "dumpvdl2 failed to start" if stderr: - error_msg += f': {stderr[:500]}' + error_msg += f": {stderr[:500]}" logger.error(error_msg) return api_error(error_msg, 500) @@ -329,38 +320,29 @@ def start_vdl2() -> Response: register_process(process) # Start output streaming thread - thread = threading.Thread( - target=stream_vdl2_output, - args=(process, is_text_mode), - daemon=True - ) + thread = threading.Thread(target=stream_vdl2_output, args=(process, is_text_mode), daemon=True) thread.start() - return jsonify({ - 'status': 'started', - 'frequencies': frequencies, - 'device': device, - 'gain': gain - }) + return jsonify({"status": "started", "frequencies": frequencies, "device": device, "gain": gain}) except Exception as e: # Release device on failure if vdl2_active_device is not None: - app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or "rtlsdr") vdl2_active_device = None vdl2_active_sdr_type = None logger.error(f"Failed to start VDL2 decoder: {e}") return api_error(str(e), 500) -@vdl2_bp.route('/stop', methods=['POST']) +@vdl2_bp.route("/stop", methods=["POST"]) def stop_vdl2() -> Response: """Stop VDL2 decoder.""" global vdl2_active_device, vdl2_active_sdr_type with app_module.vdl2_lock: if not app_module.vdl2_process: - return api_error('VDL2 decoder not running', 400) + return api_error("VDL2 decoder not running", 400) try: app_module.vdl2_process.terminate() @@ -374,62 +356,64 @@ def stop_vdl2() -> Response: # Release device from registry if vdl2_active_device is not None: - app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or "rtlsdr") vdl2_active_device = None vdl2_active_sdr_type = None - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@vdl2_bp.route('/stream') +@vdl2_bp.route("/stream") def stream_vdl2() -> Response: """SSE stream for VDL2 messages.""" + def _on_msg(msg: dict[str, Any]) -> None: - process_event('vdl2', msg, msg.get('type')) + process_event("vdl2", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=app_module.vdl2_queue, - channel_key='vdl2', + channel_key="vdl2", timeout=SSE_QUEUE_TIMEOUT, keepalive_interval=SSE_KEEPALIVE_INTERVAL, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" return response - -@vdl2_bp.route('/messages') +@vdl2_bp.route("/messages") def get_vdl2_messages() -> Response: """Get recent VDL2 messages from correlator (for history reload).""" - limit = request.args.get('limit', 50, type=int) + limit = request.args.get("limit", 50, type=int) limit = max(1, min(limit, 200)) - msgs = get_flight_correlator().get_recent_messages('vdl2', limit) + msgs = get_flight_correlator().get_recent_messages("vdl2", limit) return jsonify(msgs) -@vdl2_bp.route('/clear', methods=['POST']) +@vdl2_bp.route("/clear", methods=["POST"]) def clear_vdl2_messages() -> Response: """Clear stored VDL2 messages and reset counter.""" global vdl2_message_count, vdl2_last_message_time get_flight_correlator().clear_vdl2() vdl2_message_count = 0 vdl2_last_message_time = None - return jsonify({'status': 'cleared'}) + return jsonify({"status": "cleared"}) -@vdl2_bp.route('/frequencies') +@vdl2_bp.route("/frequencies") def get_frequencies() -> Response: """Get default VDL2 frequencies.""" - return jsonify({ - 'default': DEFAULT_VDL2_FREQUENCIES, - 'regions': { - 'north_america': ['136975000', '136100000', '136650000', '136700000', '136800000'], - 'europe': ['136975000', '136675000', '136725000', '136775000', '136825000'], - 'asia_pacific': ['136975000', '136900000'], + return jsonify( + { + "default": DEFAULT_VDL2_FREQUENCIES, + "regions": { + "north_america": ["136975000", "136100000", "136650000", "136700000", "136800000"], + "europe": ["136975000", "136675000", "136725000", "136775000", "136825000"], + "asia_pacific": ["136975000", "136900000"], + }, } - }) + ) diff --git a/routes/waterfall_websocket.py b/routes/waterfall_websocket.py index 991804f..ca929b5 100644 --- a/routes/waterfall_websocket.py +++ b/routes/waterfall_websocket.py @@ -17,6 +17,7 @@ from flask import Flask try: from flask_sock import Sock + WEBSOCKET_AVAILABLE = True except ImportError: WEBSOCKET_AVAILABLE = False @@ -33,21 +34,21 @@ from utils.waterfall_fft import ( quantize_to_uint8, ) -logger = get_logger('intercept.waterfall_ws') +logger = get_logger("intercept.waterfall_ws") AUDIO_SAMPLE_RATE = 48000 _shared_state_lock = threading.Lock() _shared_audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=20) _shared_state: dict[str, Any] = { - 'running': False, - 'device': None, - 'center_mhz': 0.0, - 'span_mhz': 0.0, - 'sample_rate': 0, - 'monitor_enabled': False, - 'monitor_freq_mhz': 0.0, - 'monitor_modulation': 'wfm', - 'monitor_squelch': 0, + "running": False, + "device": None, + "center_mhz": 0.0, + "span_mhz": 0.0, + "sample_rate": 0, + "monitor_enabled": False, + "monitor_freq_mhz": 0.0, + "monitor_modulation": "wfm", + "monitor_squelch": 0, } # Generation counter to prevent stale WebSocket handlers from clobbering # shared state set by a newer handler (e.g. old handler's finally block @@ -96,16 +97,16 @@ def _set_shared_capture_state( return _capture_generation if running and generation is None: _capture_generation += 1 - _shared_state['running'] = bool(running) - _shared_state['device'] = device if running else None + _shared_state["running"] = bool(running) + _shared_state["device"] = device if running else None if center_mhz is not None: - _shared_state['center_mhz'] = float(center_mhz) + _shared_state["center_mhz"] = float(center_mhz) if span_mhz is not None: - _shared_state['span_mhz'] = float(span_mhz) + _shared_state["span_mhz"] = float(span_mhz) if sample_rate is not None: - _shared_state['sample_rate'] = int(sample_rate) + _shared_state["sample_rate"] = int(sample_rate) if not running: - _shared_state['monitor_enabled'] = False + _shared_state["monitor_enabled"] = False gen = _capture_generation if not running: _clear_shared_audio_queue() @@ -122,17 +123,17 @@ def _set_shared_monitor( was_enabled = False freq_changed = False with _shared_state_lock: - was_enabled = bool(_shared_state.get('monitor_enabled')) - _shared_state['monitor_enabled'] = bool(enabled) + was_enabled = bool(_shared_state.get("monitor_enabled")) + _shared_state["monitor_enabled"] = bool(enabled) if frequency_mhz is not None: - old_freq = float(_shared_state.get('monitor_freq_mhz', 0.0) or 0.0) - _shared_state['monitor_freq_mhz'] = float(frequency_mhz) + old_freq = float(_shared_state.get("monitor_freq_mhz", 0.0) or 0.0) + _shared_state["monitor_freq_mhz"] = float(frequency_mhz) if abs(float(frequency_mhz) - old_freq) > 1e-6: freq_changed = True if modulation is not None: - _shared_state['monitor_modulation'] = str(modulation).lower().strip() + _shared_state["monitor_modulation"] = str(modulation).lower().strip() if squelch is not None: - _shared_state['monitor_squelch'] = max(0, min(100, int(squelch))) + _shared_state["monitor_squelch"] = max(0, min(100, int(squelch))) if (was_enabled and not enabled) or (enabled and freq_changed): _clear_shared_audio_queue() @@ -140,15 +141,15 @@ def _set_shared_monitor( def get_shared_capture_status() -> dict[str, Any]: with _shared_state_lock: return { - 'running': bool(_shared_state['running']), - 'device': _shared_state['device'], - 'center_mhz': float(_shared_state.get('center_mhz', 0.0) or 0.0), - 'span_mhz': float(_shared_state.get('span_mhz', 0.0) or 0.0), - 'sample_rate': int(_shared_state.get('sample_rate', 0) or 0), - 'monitor_enabled': bool(_shared_state.get('monitor_enabled')), - 'monitor_freq_mhz': float(_shared_state.get('monitor_freq_mhz', 0.0) or 0.0), - 'monitor_modulation': str(_shared_state.get('monitor_modulation', 'wfm')), - 'monitor_squelch': int(_shared_state.get('monitor_squelch', 0) or 0), + "running": bool(_shared_state["running"]), + "device": _shared_state["device"], + "center_mhz": float(_shared_state.get("center_mhz", 0.0) or 0.0), + "span_mhz": float(_shared_state.get("span_mhz", 0.0) or 0.0), + "sample_rate": int(_shared_state.get("sample_rate", 0) or 0), + "monitor_enabled": bool(_shared_state.get("monitor_enabled")), + "monitor_freq_mhz": float(_shared_state.get("monitor_freq_mhz", 0.0) or 0.0), + "monitor_modulation": str(_shared_state.get("monitor_modulation", "wfm")), + "monitor_squelch": int(_shared_state.get("monitor_squelch", 0) or 0), } @@ -160,16 +161,16 @@ def start_shared_monitor_from_capture( squelch: int, ) -> tuple[bool, str]: with _shared_state_lock: - if not _shared_state['running']: - return False, 'Waterfall IQ stream not active' - if _shared_state['device'] != device: - return False, 'Waterfall stream is using a different SDR device' - _shared_state['monitor_enabled'] = True - _shared_state['monitor_freq_mhz'] = float(frequency_mhz) - _shared_state['monitor_modulation'] = str(modulation).lower().strip() - _shared_state['monitor_squelch'] = max(0, min(100, int(squelch))) + if not _shared_state["running"]: + return False, "Waterfall IQ stream not active" + if _shared_state["device"] != device: + return False, "Waterfall stream is using a different SDR device" + _shared_state["monitor_enabled"] = True + _shared_state["monitor_freq_mhz"] = float(frequency_mhz) + _shared_state["monitor_modulation"] = str(modulation).lower().strip() + _shared_state["monitor_squelch"] = max(0, min(100, int(squelch))) _clear_shared_audio_queue() - return True, 'started' + return True, "started" def stop_shared_monitor_from_capture() -> None: @@ -178,7 +179,7 @@ def stop_shared_monitor_from_capture() -> None: def read_shared_monitor_audio_chunk(timeout: float = 1.0) -> bytes | None: with _shared_state_lock: - if not _shared_state['running'] or not _shared_state['monitor_enabled']: + if not _shared_state["running"] or not _shared_state["monitor_enabled"]: return None try: return _shared_audio_queue.get(timeout=max(0.0, float(timeout))) @@ -188,13 +189,13 @@ def read_shared_monitor_audio_chunk(timeout: float = 1.0) -> bytes | None: def _snapshot_monitor_config() -> dict[str, Any] | None: with _shared_state_lock: - if not (_shared_state['running'] and _shared_state['monitor_enabled']): + if not (_shared_state["running"] and _shared_state["monitor_enabled"]): return None return { - 'center_mhz': float(_shared_state['center_mhz']), - 'monitor_freq_mhz': float(_shared_state['monitor_freq_mhz']), - 'modulation': str(_shared_state['monitor_modulation']), - 'squelch': int(_shared_state['monitor_squelch']), + "center_mhz": float(_shared_state["center_mhz"]), + "monitor_freq_mhz": float(_shared_state["monitor_freq_mhz"]), + "modulation": str(_shared_state["monitor_modulation"]), + "squelch": int(_shared_state["monitor_squelch"]), } @@ -232,8 +233,8 @@ def _demodulate_monitor_audio( next_phase = float((float(rotator_phase) + phase_inc * samples.size) % (2.0 * np.pi)) shifted = samples * rotator - mod = str(modulation or 'wfm').lower().strip() - target_bb = 220000.0 if mod == 'wfm' else 48000.0 + mod = str(modulation or "wfm").lower().strip() + target_bb = 220000.0 if mod == "wfm" else 48000.0 pre_decim = max(1, int(fs // target_bb)) if pre_decim > 1: usable = (shifted.size // pre_decim) * pre_decim @@ -244,14 +245,14 @@ def _demodulate_monitor_audio( if shifted.size < 16: return None, next_phase - if mod in ('wfm', 'fm'): + if mod in ("wfm", "fm"): audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32) - elif mod == 'am': + elif mod == "am": envelope = np.abs(shifted).astype(np.float32) audio = envelope - float(np.mean(envelope)) - elif mod == 'usb': + elif mod == "usb": audio = np.real(shifted).astype(np.float32) - elif mod == 'lsb': + elif mod == "lsb": audio = -np.real(shifted).astype(np.float32) else: audio = np.real(shifted).astype(np.float32) @@ -261,11 +262,11 @@ def _demodulate_monitor_audio( audio = audio - float(np.mean(audio)) - if mod in ('fm', 'am', 'usb', 'lsb'): + if mod in ("fm", "am", "usb", "lsb"): taps = int(max(1, min(31, fs1 / 12000.0))) if taps > 1: kernel = np.ones(taps, dtype=np.float32) / float(taps) - audio = np.convolve(audio, kernel, mode='same') + audio = np.convolve(audio, kernel, mode="same") out_len = int(audio.size * AUDIO_SAMPLE_RATE / fs1) if out_len < 32: @@ -289,13 +290,13 @@ def _demodulate_monitor_audio( def _parse_center_freq_mhz(payload: dict[str, Any]) -> float: """Parse center frequency from mixed legacy/new payload formats.""" - if payload.get('center_freq_mhz') is not None: - return float(payload['center_freq_mhz']) + if payload.get("center_freq_mhz") is not None: + return float(payload["center_freq_mhz"]) - if payload.get('center_freq_hz') is not None: - return float(payload['center_freq_hz']) / 1e6 + if payload.get("center_freq_hz") is not None: + return float(payload["center_freq_hz"]) / 1e6 - raw = float(payload.get('center_freq', 100.0)) + raw = float(payload.get("center_freq", 100.0)) # Backward compatibility: some clients still send center_freq in Hz. if raw > 100000: return raw / 1e6 @@ -304,9 +305,9 @@ def _parse_center_freq_mhz(payload: dict[str, Any]) -> float: def _parse_span_mhz(payload: dict[str, Any]) -> float: """Parse display span in MHz from mixed payload formats.""" - if payload.get('span_hz') is not None: - return float(payload['span_hz']) / 1e6 - return float(payload.get('span_mhz', 2.0)) + if payload.get("span_hz") is not None: + return float(payload["span_hz"]) / 1e6 + return float(payload.get("span_mhz", 2.0)) def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> int: @@ -322,13 +323,13 @@ def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> def _resolve_sdr_type(sdr_type_str: str) -> SDRType: """Convert client sdr_type string to SDRType enum.""" mapping = { - 'rtlsdr': SDRType.RTL_SDR, - 'rtl_sdr': SDRType.RTL_SDR, - 'hackrf': SDRType.HACKRF, - 'limesdr': SDRType.LIME_SDR, - 'lime_sdr': SDRType.LIME_SDR, - 'airspy': SDRType.AIRSPY, - 'sdrplay': SDRType.SDRPLAY, + "rtlsdr": SDRType.RTL_SDR, + "rtl_sdr": SDRType.RTL_SDR, + "hackrf": SDRType.HACKRF, + "limesdr": SDRType.LIME_SDR, + "lime_sdr": SDRType.LIME_SDR, + "airspy": SDRType.AIRSPY, + "sdrplay": SDRType.SDRPLAY, } return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR) @@ -340,8 +341,8 @@ def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice: return SDRDevice( sdr_type=sdr_type, index=device_index, - name=f'{sdr_type.value}-{device_index}', - serial='N/A', + name=f"{sdr_type.value}-{device_index}", + serial="N/A", driver=sdr_type.value, capabilities=caps, ) @@ -355,7 +356,7 @@ def init_waterfall_websocket(app: Flask): sock = Sock(app) - @sock.route('/ws/waterfall') + @sock.route("/ws/waterfall") def waterfall_stream(ws): """WebSocket endpoint for real-time waterfall streaming.""" logger.info("WebSocket waterfall client connected") @@ -367,7 +368,7 @@ def init_waterfall_websocket(app: Flask): reader_thread = None stop_event = threading.Event() claimed_device = None - claimed_sdr_type = 'rtlsdr' + claimed_sdr_type = "rtlsdr" my_generation = None # tracks which capture generation this handler owns capture_center_mhz = 0.0 capture_start_freq = 0.0 @@ -413,13 +414,13 @@ def init_waterfall_websocket(app: Flask): except (json.JSONDecodeError, TypeError): continue - cmd = data.get('cmd') + cmd = data.get("cmd") - if cmd == 'start': + if cmd == "start": shared_before = get_shared_capture_status() - keep_monitor_enabled = bool(shared_before.get('monitor_enabled')) - keep_monitor_modulation = str(shared_before.get('monitor_modulation', 'wfm')) - keep_monitor_squelch = int(shared_before.get('monitor_squelch', 0) or 0) + keep_monitor_enabled = bool(shared_before.get("monitor_enabled")) + keep_monitor_modulation = str(shared_before.get("monitor_modulation", "wfm")) + keep_monitor_squelch = int(shared_before.get("monitor_squelch", 0) or 0) # Stop any existing capture was_restarting = iq_process is not None stop_event.set() @@ -432,7 +433,7 @@ def init_waterfall_websocket(app: Flask): if claimed_device is not None: app_module.release_sdr_device(claimed_device, claimed_sdr_type) claimed_device = None - claimed_sdr_type = 'rtlsdr' + claimed_sdr_type = "rtlsdr" _set_shared_capture_state(running=False, generation=my_generation) my_generation = None stop_event.clear() @@ -451,36 +452,40 @@ def init_waterfall_websocket(app: Flask): center_freq_mhz = _parse_center_freq_mhz(data) requested_vfo_mhz = float( data.get( - 'vfo_freq_mhz', - data.get('frequency_mhz', center_freq_mhz), + "vfo_freq_mhz", + data.get("frequency_mhz", center_freq_mhz), ) ) span_mhz = _parse_span_mhz(data) - gain_raw = data.get('gain') - if gain_raw is None or str(gain_raw).lower() == 'auto': + gain_raw = data.get("gain") + if gain_raw is None or str(gain_raw).lower() == "auto": gain = None else: gain = float(gain_raw) - device_index = int(data.get('device', 0)) - sdr_type_str = data.get('sdr_type', 'rtlsdr') - fft_size = int(data.get('fft_size', 1024)) - fps = int(data.get('fps', 25)) - avg_count = int(data.get('avg_count', 4)) - ppm = data.get('ppm') + device_index = int(data.get("device", 0)) + sdr_type_str = data.get("sdr_type", "rtlsdr") + fft_size = int(data.get("fft_size", 1024)) + fps = int(data.get("fps", 25)) + avg_count = int(data.get("avg_count", 4)) + ppm = data.get("ppm") if ppm is not None: ppm = int(ppm) - bias_t = bool(data.get('bias_t', False)) - db_min = data.get('db_min') - db_max = data.get('db_max') + bias_t = bool(data.get("bias_t", False)) + db_min = data.get("db_min") + db_max = data.get("db_max") if db_min is not None: db_min = float(db_min) if db_max is not None: db_max = float(db_max) except (TypeError, ValueError) as exc: - ws.send(json.dumps({ - 'status': 'error', - 'message': f'Invalid waterfall configuration: {exc}', - })) + ws.send( + json.dumps( + { + "status": "error", + "message": f"Invalid waterfall configuration: {exc}", + } + ) + ) continue # Clamp and normalize runtime settings @@ -488,10 +493,14 @@ def init_waterfall_websocket(app: Flask): fps = max(2, min(60, fps)) avg_count = max(1, min(32, avg_count)) if center_freq_mhz <= 0 or span_mhz <= 0: - ws.send(json.dumps({ - 'status': 'error', - 'message': 'center_freq_mhz and span_mhz must be > 0', - })) + ws.send( + json.dumps( + { + "status": "error", + "message": "center_freq_mhz and span_mhz must be > 0", + } + ) + ) continue # Resolve SDR type and choose a valid sample rate @@ -514,17 +523,21 @@ def init_waterfall_websocket(app: Flask): max_claim_attempts = 4 if was_restarting else 1 claim_err = None for _claim_attempt in range(max_claim_attempts): - claim_err = app_module.claim_sdr_device(device_index, 'waterfall', sdr_type_str) + claim_err = app_module.claim_sdr_device(device_index, "waterfall", sdr_type_str) if not claim_err: break if _claim_attempt < max_claim_attempts - 1: time.sleep(0.4) if claim_err: - ws.send(json.dumps({ - 'status': 'error', - 'message': claim_err, - 'error_type': 'DEVICE_BUSY', - })) + ws.send( + json.dumps( + { + "status": "error", + "message": claim_err, + "error_type": "DEVICE_BUSY", + } + ) + ) continue claimed_device = device_index claimed_sdr_type = sdr_type_str @@ -543,22 +556,30 @@ def init_waterfall_websocket(app: Flask): except NotImplementedError as e: app_module.release_sdr_device(device_index, sdr_type_str) claimed_device = None - claimed_sdr_type = 'rtlsdr' - ws.send(json.dumps({ - 'status': 'error', - 'message': str(e), - })) + claimed_sdr_type = "rtlsdr" + ws.send( + json.dumps( + { + "status": "error", + "message": str(e), + } + ) + ) continue # Pre-flight: check the capture binary exists if not shutil.which(iq_cmd[0]): app_module.release_sdr_device(device_index, sdr_type_str) claimed_device = None - claimed_sdr_type = 'rtlsdr' - ws.send(json.dumps({ - 'status': 'error', - 'message': f'Required tool "{iq_cmd[0]}" not found. Install SoapySDR tools (rx_sdr).', - })) + claimed_sdr_type = "rtlsdr" + ws.send( + json.dumps( + { + "status": "error", + "message": f'Required tool "{iq_cmd[0]}" not found. Install SoapySDR tools (rx_sdr).', + } + ) + ) continue # Spawn I/Q capture process (retry to handle USB release lag) @@ -581,10 +602,10 @@ def init_waterfall_websocket(app: Flask): # Brief check that process started time.sleep(0.3) if iq_process.poll() is not None: - stderr_out = '' + stderr_out = "" if iq_process.stderr: with suppress(Exception): - stderr_out = iq_process.stderr.read().decode('utf-8', errors='replace').strip() + stderr_out = iq_process.stderr.read().decode("utf-8", errors="replace").strip() unregister_process(iq_process) iq_process = None if attempt < max_attempts - 1: @@ -596,9 +617,7 @@ def init_waterfall_websocket(app: Flask): time.sleep(0.5) continue detail = f": {stderr_out}" if stderr_out else "" - raise RuntimeError( - f"I/Q capture process exited immediately{detail}" - ) + raise RuntimeError(f"I/Q capture process exited immediately{detail}") break # Process started successfully except Exception as e: logger.error(f"Failed to start I/Q capture: {e}") @@ -608,11 +627,15 @@ def init_waterfall_websocket(app: Flask): iq_process = None app_module.release_sdr_device(device_index, sdr_type_str) claimed_device = None - claimed_sdr_type = 'rtlsdr' - ws.send(json.dumps({ - 'status': 'error', - 'message': f'Failed to start I/Q capture: {e}', - })) + claimed_sdr_type = "rtlsdr" + ws.send( + json.dumps( + { + "status": "error", + "message": f"Failed to start I/Q capture: {e}", + } + ) + ) continue capture_center_mhz = center_freq_mhz @@ -634,25 +657,37 @@ def init_waterfall_websocket(app: Flask): ) # Send started confirmation - ws.send(json.dumps({ - 'status': 'started', - 'center_mhz': center_freq_mhz, - 'start_freq': start_freq, - 'end_freq': end_freq, - 'fft_size': fft_size, - 'sample_rate': sample_rate, - 'effective_span_mhz': effective_span_mhz, - 'db_min': db_min, - 'db_max': db_max, - 'vfo_freq_mhz': target_vfo_mhz, - })) + ws.send( + json.dumps( + { + "status": "started", + "center_mhz": center_freq_mhz, + "start_freq": start_freq, + "end_freq": end_freq, + "fft_size": fft_size, + "sample_rate": sample_rate, + "effective_span_mhz": effective_span_mhz, + "db_min": db_min, + "db_max": db_max, + "vfo_freq_mhz": target_vfo_mhz, + } + ) + ) # Start reader thread — puts frames on queue, never calls ws.send() def fft_reader( - proc, _send_q, stop_evt, - _fft_size, _avg_count, _fps, _sample_rate, - _start_freq, _end_freq, _center_mhz, - _db_min=None, _db_max=None, + proc, + _send_q, + stop_evt, + _fft_size, + _avg_count, + _fps, + _sample_rate, + _start_freq, + _end_freq, + _center_mhz, + _db_min=None, + _db_max=None, ): """Read I/Q from subprocess, compute FFT, enqueue binary frames.""" required_fft_samples = _fft_size * _avg_count @@ -670,7 +705,7 @@ def init_waterfall_websocket(app: Flask): frame_start = time.monotonic() # Read raw I/Q bytes - raw = b'' + raw = b"" remaining = bytes_per_frame while remaining > 0 and not stop_evt.is_set(): chunk = proc.stdout.read(min(remaining, 65536)) @@ -684,7 +719,9 @@ def init_waterfall_websocket(app: Flask): # Process FFT pipeline samples = cu8_to_complex(raw) - fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples + fft_samples = ( + samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples + ) power_db = compute_power_spectrum( fft_samples, fft_size=_fft_size, @@ -696,7 +733,9 @@ def init_waterfall_websocket(app: Flask): db_max=_db_max, ) frame = build_binary_frame( - _start_freq, _end_freq, quantized, + _start_freq, + _end_freq, + quantized, ) # Drop frame if main loop cannot keep up. @@ -705,13 +744,10 @@ def init_waterfall_websocket(app: Flask): monitor_cfg = _snapshot_monitor_config() if monitor_cfg: - center_mhz_cfg = float(monitor_cfg.get('center_mhz', _center_mhz)) - monitor_mhz_cfg = float(monitor_cfg.get('monitor_freq_mhz', _center_mhz)) + center_mhz_cfg = float(monitor_cfg.get("center_mhz", _center_mhz)) + monitor_mhz_cfg = float(monitor_cfg.get("monitor_freq_mhz", _center_mhz)) offset_hz = (monitor_mhz_cfg - center_mhz_cfg) * 1e6 - if ( - last_monitor_offset_hz is None - or abs(offset_hz - last_monitor_offset_hz) > 1.0 - ): + if last_monitor_offset_hz is None or abs(offset_hz - last_monitor_offset_hz) > 1.0: monitor_rotator_phase = 0.0 last_monitor_offset_hz = offset_hz @@ -720,8 +756,8 @@ def init_waterfall_websocket(app: Flask): sample_rate=_sample_rate, center_mhz=center_mhz_cfg, monitor_freq_mhz=monitor_mhz_cfg, - modulation=monitor_cfg.get('modulation', 'wfm'), - squelch=int(monitor_cfg.get('squelch', 0)), + modulation=monitor_cfg.get("modulation", "wfm"), + squelch=int(monitor_cfg.get("squelch", 0)), rotator_phase=monitor_rotator_phase, ) if audio_chunk: @@ -742,65 +778,89 @@ def init_waterfall_websocket(app: Flask): reader_thread = threading.Thread( target=fft_reader, args=( - iq_process, send_queue, stop_event, - fft_size, avg_count, fps, sample_rate, - start_freq, end_freq, center_freq_mhz, - db_min, db_max, + iq_process, + send_queue, + stop_event, + fft_size, + avg_count, + fps, + sample_rate, + start_freq, + end_freq, + center_freq_mhz, + db_min, + db_max, ), daemon=True, ) reader_thread.start() - elif cmd in ('tune', 'set_vfo'): + elif cmd in ("tune", "set_vfo"): if not iq_process or claimed_device is None or iq_process.poll() is not None: - ws.send(json.dumps({ - 'status': 'error', - 'message': 'Waterfall capture is not running', - })) + ws.send( + json.dumps( + { + "status": "error", + "message": "Waterfall capture is not running", + } + ) + ) continue try: shared = get_shared_capture_status() vfo_freq_mhz = float( data.get( - 'vfo_freq_mhz', - data.get('frequency_mhz', data.get('center_freq_mhz', capture_center_mhz)), + "vfo_freq_mhz", + data.get("frequency_mhz", data.get("center_freq_mhz", capture_center_mhz)), ) ) - squelch = int(data.get('squelch', shared.get('monitor_squelch', 0))) - modulation = str(data.get('modulation', shared.get('monitor_modulation', 'wfm'))) + squelch = int(data.get("squelch", shared.get("monitor_squelch", 0))) + modulation = str(data.get("modulation", shared.get("monitor_modulation", "wfm"))) except (TypeError, ValueError) as exc: - ws.send(json.dumps({ - 'status': 'error', - 'message': f'Invalid tune request: {exc}', - })) + ws.send( + json.dumps( + { + "status": "error", + "message": f"Invalid tune request: {exc}", + } + ) + ) continue if not (capture_start_freq <= vfo_freq_mhz <= capture_end_freq): - ws.send(json.dumps({ - 'status': 'retune_required', - 'message': 'Frequency outside current capture span', - 'capture_start_freq': capture_start_freq, - 'capture_end_freq': capture_end_freq, - 'vfo_freq_mhz': vfo_freq_mhz, - })) + ws.send( + json.dumps( + { + "status": "retune_required", + "message": "Frequency outside current capture span", + "capture_start_freq": capture_start_freq, + "capture_end_freq": capture_end_freq, + "vfo_freq_mhz": vfo_freq_mhz, + } + ) + ) continue - monitor_enabled = bool(shared.get('monitor_enabled')) + monitor_enabled = bool(shared.get("monitor_enabled")) _set_shared_monitor( enabled=monitor_enabled, frequency_mhz=vfo_freq_mhz, modulation=modulation, squelch=squelch, ) - ws.send(json.dumps({ - 'status': 'tuned', - 'vfo_freq_mhz': vfo_freq_mhz, - 'start_freq': capture_start_freq, - 'end_freq': capture_end_freq, - 'center_mhz': capture_center_mhz, - })) + ws.send( + json.dumps( + { + "status": "tuned", + "vfo_freq_mhz": vfo_freq_mhz, + "start_freq": capture_start_freq, + "end_freq": capture_end_freq, + "center_mhz": capture_center_mhz, + } + ) + ) - elif cmd == 'stop': + elif cmd == "stop": stop_event.set() if reader_thread and reader_thread.is_alive(): reader_thread.join(timeout=2) @@ -812,11 +872,11 @@ def init_waterfall_websocket(app: Flask): if claimed_device is not None: app_module.release_sdr_device(claimed_device, claimed_sdr_type) claimed_device = None - claimed_sdr_type = 'rtlsdr' + claimed_sdr_type = "rtlsdr" _set_shared_capture_state(running=False, generation=my_generation) my_generation = None stop_event.clear() - ws.send(json.dumps({'status': 'stopped'})) + ws.send(json.dumps({"status": "stopped"})) except Exception as e: logger.info(f"WebSocket waterfall closed: {e}") diff --git a/routes/weather_sat.py b/routes/weather_sat.py index ffa923a..dcaa916 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -33,20 +33,20 @@ from utils.weather_sat import ( is_weather_sat_available, ) -logger = get_logger('intercept.weather_sat') +logger = get_logger("intercept.weather_sat") -weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat') +weather_sat_bp = Blueprint("weather_sat", __name__, url_prefix="/weather-sat") # Queue for SSE progress streaming _weather_sat_queue: queue.Queue = queue.Queue(maxsize=100) METEOR_NORAD_IDS = { - 'METEOR-M2-3': 57166, - 'METEOR-M2-4': 59051, + "METEOR-M2-3": 57166, + "METEOR-M2-4": 59051, } ALLOWED_TEST_DECODE_DIRS = ( - Path(__file__).resolve().parent.parent / 'data', - Path(__file__).resolve().parent.parent / 'instance' / 'ground_station' / 'recordings', + Path(__file__).resolve().parent.parent / "data", + Path(__file__).resolve().parent.parent / "instance" / "ground_station" / "recordings", ) @@ -73,16 +73,16 @@ def _release_weather_sat_device(device_index: int) -> None: return owner = None - get_status = getattr(app_module, 'get_sdr_device_status', None) + get_status = getattr(app_module, "get_sdr_device_status", None) if callable(get_status): try: owner = get_status().get(device_index) except Exception: owner = None - if owner and owner != 'weather_sat': + if owner and owner != "weather_sat": logger.debug( - 'Skipping SDR release for device %s owned by %s', + "Skipping SDR release for device %s owned by %s", device_index, owner, ) @@ -91,7 +91,7 @@ def _release_weather_sat_device(device_index: int) -> None: app_module.release_sdr_device(device_index) -@weather_sat_bp.route('/status') +@weather_sat_bp.route("/status") def get_status(): """Get weather satellite decoder status. @@ -102,7 +102,7 @@ def get_status(): return jsonify(decoder.get_status()) -@weather_sat_bp.route('/satellites') +@weather_sat_bp.route("/satellites") def list_satellites(): """Get list of supported weather satellites with frequencies. @@ -111,22 +111,26 @@ def list_satellites(): """ satellites = [] for key, info in WEATHER_SATELLITES.items(): - satellites.append({ - 'key': key, - 'name': info['name'], - 'frequency': info['frequency'], - 'mode': info['mode'], - 'description': info['description'], - 'active': info['active'], - }) + satellites.append( + { + "key": key, + "name": info["name"], + "frequency": info["frequency"], + "mode": info["mode"], + "description": info["description"], + "active": info["active"], + } + ) - return jsonify({ - 'status': 'ok', - 'satellites': satellites, - }) + return jsonify( + { + "status": "ok", + "satellites": satellites, + } + ) -@weather_sat_bp.route('/start', methods=['POST']) +@weather_sat_bp.route("/start", methods=["POST"]) def start_capture(): """Start weather satellite capture and decode. @@ -142,53 +146,55 @@ def start_capture(): JSON with start status. """ if not is_weather_sat_available(): - return jsonify({ - 'status': 'error', - 'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump' - }), 400 + return jsonify( + { + "status": "error", + "message": "SatDump not installed. Build from source: https://github.com/SatDump/SatDump", + } + ), 400 decoder = get_weather_sat_decoder() if decoder.is_running: - return jsonify({ - 'status': 'already_running', - 'satellite': decoder.current_satellite, - 'frequency': decoder.current_frequency, - }) + return jsonify( + { + "status": "already_running", + "satellite": decoder.current_satellite, + "frequency": decoder.current_frequency, + } + ) data = request.get_json(silent=True) or {} - sdr_type_str = data.get('sdr_type', 'rtlsdr') + sdr_type_str = data.get("sdr_type", "rtlsdr") - if sdr_type_str != 'rtlsdr': - return jsonify({ - 'status': 'error', - 'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.' - }), 400 + if sdr_type_str != "rtlsdr": + return jsonify( + { + "status": "error", + "message": f"{sdr_type_str.replace('_', ' ').title()} is not yet supported for this mode. Please use an RTL-SDR device.", + } + ), 400 # Validate satellite - satellite = data.get('satellite') + satellite = data.get("satellite") if not satellite or satellite not in WEATHER_SATELLITES: - return jsonify({ - 'status': 'error', - 'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}' - }), 400 + return jsonify( + {"status": "error", "message": f"Invalid satellite. Must be one of: {', '.join(WEATHER_SATELLITES.keys())}"} + ), 400 # Validate device index and gain try: - device_index = validate_device_index(data.get('device', 0)) - gain = validate_gain(data.get('gain', 30.0)) + device_index = validate_device_index(data.get("device", 0)) + gain = validate_gain(data.get("gain", 30.0)) except ValueError as e: - logger.warning('Invalid parameter in start_capture: %s', e) - return jsonify({ - 'status': 'error', - 'message': 'Invalid parameter value' - }), 400 + logger.warning("Invalid parameter in start_capture: %s", e) + return jsonify({"status": "error", "message": "Invalid parameter value"}), 400 - bias_t = bool(data.get('bias_t', False)) + bias_t = bool(data.get("bias_t", False)) # Check for rtl_tcp (remote SDR) connection - rtl_tcp_host = data.get('rtl_tcp_host') - rtl_tcp_port = data.get('rtl_tcp_port', 1234) + rtl_tcp_host = data.get("rtl_tcp_host") + rtl_tcp_port = data.get("rtl_tcp_port", 1234) if rtl_tcp_host: try: @@ -201,9 +207,10 @@ def start_capture(): if not rtl_tcp_host: try: import app as app_module - error = app_module.claim_sdr_device(device_index, 'weather_sat', sdr_type_str) + + error = app_module.claim_sdr_device(device_index, "weather_sat", sdr_type_str) if error: - return api_error(error, 409, error_type='DEVICE_BUSY') + return api_error(error, 409, error_type="DEVICE_BUSY") except ImportError: pass @@ -235,23 +242,22 @@ def start_capture(): if success: sat_info = WEATHER_SATELLITES[satellite] - return jsonify({ - 'status': 'started', - 'satellite': satellite, - 'frequency': sat_info['frequency'], - 'mode': sat_info['mode'], - 'device': device_index, - }) + return jsonify( + { + "status": "started", + "satellite": satellite, + "frequency": sat_info["frequency"], + "mode": sat_info["mode"], + "device": device_index, + } + ) else: # Release device on failure _release_device() - return jsonify({ - 'status': 'error', - 'message': error_msg or 'Failed to start capture' - }), 500 + return jsonify({"status": "error", "message": error_msg or "Failed to start capture"}), 500 -@weather_sat_bp.route('/test-decode', methods=['POST']) +@weather_sat_bp.route("/test-decode", methods=["POST"]) def test_decode(): """Start weather satellite decode from a pre-recorded file. @@ -269,73 +275,64 @@ def test_decode(): JSON with start status. """ if not is_weather_sat_available(): - return jsonify({ - 'status': 'error', - 'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump' - }), 400 + return jsonify( + { + "status": "error", + "message": "SatDump not installed. Build from source: https://github.com/SatDump/SatDump", + } + ), 400 decoder = get_weather_sat_decoder() if decoder.is_running: - return jsonify({ - 'status': 'already_running', - 'satellite': decoder.current_satellite, - 'frequency': decoder.current_frequency, - }) + return jsonify( + { + "status": "already_running", + "satellite": decoder.current_satellite, + "frequency": decoder.current_frequency, + } + ) data = request.get_json(silent=True) or {} # Validate satellite - satellite = data.get('satellite') + satellite = data.get("satellite") if not satellite or satellite not in WEATHER_SATELLITES: - return jsonify({ - 'status': 'error', - 'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}' - }), 400 + return jsonify( + {"status": "error", "message": f"Invalid satellite. Must be one of: {', '.join(WEATHER_SATELLITES.keys())}"} + ), 400 # Validate input file - input_file = data.get('input_file') + input_file = data.get("input_file") if not input_file: - return jsonify({ - 'status': 'error', - 'message': 'input_file is required' - }), 400 + return jsonify({"status": "error", "message": "input_file is required"}), 400 from pathlib import Path + input_path = Path(input_file) # Restrict test-decode to application-owned sample and recording paths. try: resolved = input_path.resolve() if not any(resolved.is_relative_to(base) for base in ALLOWED_TEST_DECODE_DIRS): - return jsonify({ - 'status': 'error', - 'message': 'input_file must be under INTERCEPT data or ground-station recordings' - }), 403 + return jsonify( + {"status": "error", "message": "input_file must be under INTERCEPT data or ground-station recordings"} + ), 403 except (OSError, ValueError): - return jsonify({ - 'status': 'error', - 'message': 'Invalid file path' - }), 400 + return jsonify({"status": "error", "message": "Invalid file path"}), 400 if not input_path.is_file(): logger.warning("Test-decode file not found") - return jsonify({ - 'status': 'error', - 'message': 'File not found' - }), 404 + return jsonify({"status": "error", "message": "File not found"}), 404 # Validate sample rate - sample_rate = data.get('sample_rate', 1000000) + sample_rate = data.get("sample_rate", 1000000) try: sample_rate = int(sample_rate) if sample_rate < 1000 or sample_rate > 20000000: raise ValueError except (TypeError, ValueError): - return jsonify({ - 'status': 'error', - 'message': 'Invalid sample_rate (1000-20000000)' - }), 400 + return jsonify({"status": "error", "message": "Invalid sample_rate (1000-20000000)"}), 400 # Clear queue while not _weather_sat_queue.empty(): @@ -356,22 +353,21 @@ def test_decode(): if success: sat_info = WEATHER_SATELLITES[satellite] - return jsonify({ - 'status': 'started', - 'satellite': satellite, - 'frequency': sat_info['frequency'], - 'mode': sat_info['mode'], - 'source': 'file', - 'input_file': str(input_file), - }) + return jsonify( + { + "status": "started", + "satellite": satellite, + "frequency": sat_info["frequency"], + "mode": sat_info["mode"], + "source": "file", + "input_file": str(input_file), + } + ) else: - return jsonify({ - 'status': 'error', - 'message': error_msg or 'Failed to start file decode' - }), 500 + return jsonify({"status": "error", "message": error_msg or "Failed to start file decode"}), 500 -@weather_sat_bp.route('/stop', methods=['POST']) +@weather_sat_bp.route("/stop", methods=["POST"]) def stop_capture(): """Stop weather satellite capture. @@ -385,10 +381,10 @@ def stop_capture(): _release_weather_sat_device(device_index) - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@weather_sat_bp.route('/images') +@weather_sat_bp.route("/images") def list_images(): """Get list of decoded weather satellite images. @@ -403,36 +399,35 @@ def list_images(): images = [ { **img.to_dict(), - 'source': 'weather_sat', - 'deletable': True, + "source": "weather_sat", + "deletable": True, } for img in decoder.get_images() ] images.extend(_get_ground_station_images()) # Filter by satellite if specified - satellite_filter = request.args.get('satellite') + satellite_filter = request.args.get("satellite") if satellite_filter: - images = [ - img for img in images - if str(img.get('satellite', '')).upper() == satellite_filter.upper() - ] + images = [img for img in images if str(img.get("satellite", "")).upper() == satellite_filter.upper()] - images.sort(key=lambda img: img.get('timestamp') or '', reverse=True) + images.sort(key=lambda img: img.get("timestamp") or "", reverse=True) # Apply limit - limit = request.args.get('limit', type=int) + limit = request.args.get("limit", type=int) if limit and limit > 0: images = images[:limit] - return jsonify({ - 'status': 'ok', - 'images': images, - 'count': len(images), - }) + return jsonify( + { + "status": "ok", + "images": images, + "count": len(images), + } + ) -@weather_sat_bp.route('/images/') +@weather_sat_bp.route("/images/") def get_image(filename: str): """Serve a decoded weather satellite image file. @@ -445,22 +440,22 @@ def get_image(filename: str): decoder = get_weather_sat_decoder() # Security: only allow safe filenames - if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): - return api_error('Invalid filename', 400) + if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum(): + return api_error("Invalid filename", 400) - if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')): - return api_error('Only PNG/JPG files supported', 400) + if not (filename.endswith(".png") or filename.endswith(".jpg") or filename.endswith(".jpeg")): + return api_error("Only PNG/JPG files supported", 400) image_path = decoder._output_dir / filename if not image_path.exists(): - return api_error('Image not found', 404) + return api_error("Image not found", 404) - mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg' + mimetype = "image/png" if filename.endswith(".png") else "image/jpeg" return send_file(image_path, mimetype=mimetype) -@weather_sat_bp.route('/images/shared/') +@weather_sat_bp.route("/images/shared/") def get_shared_image(output_id: int): """Serve a Meteor image stored in ground-station outputs.""" try: @@ -468,29 +463,29 @@ def get_shared_image(output_id: int): with get_db() as conn: row = conn.execute( - ''' + """ SELECT file_path FROM ground_station_outputs WHERE id=? AND output_type='image' - ''', + """, (output_id,), ).fetchone() except Exception as e: logger.warning("Failed to load shared weather image %s: %s", output_id, e) - return api_error('Image not found', 404) + return api_error("Image not found", 404) if not row: - return api_error('Image not found', 404) + return api_error("Image not found", 404) - image_path = Path(row['file_path']) + image_path = Path(row["file_path"]) if not image_path.exists(): - return api_error('Image not found', 404) + return api_error("Image not found", 404) suffix = image_path.suffix.lower() - mimetype = 'image/png' if suffix == '.png' else 'image/jpeg' + mimetype = "image/png" if suffix == ".png" else "image/jpeg" return send_file(image_path, mimetype=mimetype) -@weather_sat_bp.route('/images/', methods=['DELETE']) +@weather_sat_bp.route("/images/", methods=["DELETE"]) def delete_image(filename: str): """Delete a decoded image. @@ -502,16 +497,16 @@ def delete_image(filename: str): """ decoder = get_weather_sat_decoder() - if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): - return api_error('Invalid filename', 400) + if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum(): + return api_error("Invalid filename", 400) if decoder.delete_image(filename): - return jsonify({'status': 'deleted', 'filename': filename}) + return jsonify({"status": "deleted", "filename": filename}) else: - return api_error('Image not found', 404) + return api_error("Image not found", 404) -@weather_sat_bp.route('/images', methods=['DELETE']) +@weather_sat_bp.route("/images", methods=["DELETE"]) def delete_all_images(): """Delete all decoded weather satellite images. @@ -520,7 +515,7 @@ def delete_all_images(): """ decoder = get_weather_sat_decoder() count = decoder.delete_all_images() - return jsonify({'status': 'ok', 'deleted': count}) + return jsonify({"status": "ok", "deleted": count}) def _get_ground_station_images() -> list[dict]: @@ -529,13 +524,13 @@ def _get_ground_station_images() -> list[dict]: with get_db() as conn: rows = conn.execute( - ''' + """ SELECT id, norad_id, file_path, metadata_json, created_at FROM ground_station_outputs WHERE output_type='image' AND backend='meteor_lrpt' ORDER BY created_at DESC LIMIT 200 - ''' + """ ).fetchall() except Exception as e: logger.debug("Failed to fetch ground-station weather outputs: %s", e) @@ -543,32 +538,34 @@ def _get_ground_station_images() -> list[dict]: images: list[dict] = [] for row in rows: - file_path = Path(row['file_path']) + file_path = Path(row["file_path"]) if not file_path.exists(): continue metadata = {} - raw_metadata = row['metadata_json'] + raw_metadata = row["metadata_json"] if raw_metadata: try: metadata = json.loads(raw_metadata) except json.JSONDecodeError: metadata = {} - satellite = metadata.get('satellite') or _satellite_from_norad(row['norad_id']) - images.append({ - 'filename': file_path.name, - 'satellite': satellite, - 'mode': metadata.get('mode', 'LRPT'), - 'timestamp': metadata.get('timestamp') or row['created_at'], - 'frequency': metadata.get('frequency', 137.9), - 'size_bytes': metadata.get('size_bytes') or file_path.stat().st_size, - 'product': metadata.get('product', ''), - 'url': f"/weather-sat/images/shared/{row['id']}", - 'source': 'ground_station', - 'deletable': False, - 'output_id': row['id'], - }) + satellite = metadata.get("satellite") or _satellite_from_norad(row["norad_id"]) + images.append( + { + "filename": file_path.name, + "satellite": satellite, + "mode": metadata.get("mode", "LRPT"), + "timestamp": metadata.get("timestamp") or row["created_at"], + "frequency": metadata.get("frequency", 137.9), + "size_bytes": metadata.get("size_bytes") or file_path.stat().st_size, + "product": metadata.get("product", ""), + "url": f"/weather-sat/images/shared/{row['id']}", + "source": "ground_station", + "deletable": False, + "output_id": row["id"], + } + ) return images @@ -576,24 +573,24 @@ def _satellite_from_norad(norad_id: int | None) -> str: for satellite, known_norad in METEOR_NORAD_IDS.items(): if known_norad == norad_id: return satellite - return 'METEOR' + return "METEOR" -@weather_sat_bp.route('/stream') +@weather_sat_bp.route("/stream") def stream_progress(): """SSE stream of capture/decode progress. Returns: SSE stream (text/event-stream) """ - response = Response(sse_stream(_weather_sat_queue), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response = Response(sse_stream(_weather_sat_queue), mimetype="text/event-stream") + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response -@weather_sat_bp.route('/passes') +@weather_sat_bp.route("/passes") def get_passes(): """Get upcoming weather satellite passes for observer location. @@ -608,24 +605,24 @@ def get_passes(): Returns: JSON with upcoming passes for all weather satellites. """ - include_trajectory = request.args.get('trajectory', 'false').lower() in ('true', '1') - include_ground_track = request.args.get('ground_track', 'false').lower() in ('true', '1') + include_trajectory = request.args.get("trajectory", "false").lower() in ("true", "1") + include_ground_track = request.args.get("ground_track", "false").lower() in ("true", "1") - raw_lat = request.args.get('latitude') - raw_lon = request.args.get('longitude') + raw_lat = request.args.get("latitude") + raw_lon = request.args.get("longitude") if raw_lat is None or raw_lon is None: - return api_error('latitude and longitude parameters required', 400) + return api_error("latitude and longitude parameters required", 400) try: lat = validate_latitude(raw_lat) lon = validate_longitude(raw_lon) except ValueError as e: - logger.warning('Invalid coordinates in get_passes: %s', e) - return api_error('Invalid coordinates', 400) + logger.warning("Invalid coordinates in get_passes: %s", e) + return api_error("Invalid coordinates", 400) - hours = max(1, min(request.args.get('hours', 24, type=int), 72)) - min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90)) + hours = max(1, min(request.args.get("hours", 24, type=int), 72)) + min_elevation = max(0, min(request.args.get("min_elevation", 15, type=float), 90)) try: from utils.weather_sat_predict import predict_passes @@ -639,27 +636,23 @@ def get_passes(): include_ground_track=include_ground_track, ) - return jsonify({ - 'status': 'ok', - 'passes': all_passes, - 'count': len(all_passes), - 'observer': {'latitude': lat, 'longitude': lon}, - 'prediction_hours': hours, - 'min_elevation': min_elevation, - }) + return jsonify( + { + "status": "ok", + "passes": all_passes, + "count": len(all_passes), + "observer": {"latitude": lat, "longitude": lon}, + "prediction_hours": hours, + "min_elevation": min_elevation, + } + ) except ImportError: - return jsonify({ - 'status': 'error', - 'message': 'skyfield library not installed' - }), 503 + return jsonify({"status": "error", "message": "skyfield library not installed"}), 503 except Exception as e: logger.error(f"Error predicting passes: {e}") - return jsonify({ - 'status': 'error', - 'message': 'Pass prediction failed' - }), 500 + return jsonify({"status": "error", "message": "Pass prediction failed"}), 500 # ======================== @@ -679,7 +672,7 @@ def _scheduler_event_callback(event: dict) -> None: pass -@weather_sat_bp.route('/schedule/enable', methods=['POST']) +@weather_sat_bp.route("/schedule/enable", methods=["POST"]) def enable_schedule(): """Enable auto-scheduling of weather satellite captures. @@ -700,24 +693,18 @@ def enable_schedule(): data = request.get_json(silent=True) or {} - if data.get('latitude') is None or data.get('longitude') is None: - return jsonify({ - 'status': 'error', - 'message': 'latitude and longitude required' - }), 400 + if data.get("latitude") is None or data.get("longitude") is None: + return jsonify({"status": "error", "message": "latitude and longitude required"}), 400 try: - lat = validate_latitude(data.get('latitude')) - lon = validate_longitude(data.get('longitude')) - min_elev = validate_elevation(data.get('min_elevation', 15)) - device = validate_device_index(data.get('device', 0)) - gain_val = validate_gain(data.get('gain', 40.0)) + lat = validate_latitude(data.get("latitude")) + lon = validate_longitude(data.get("longitude")) + min_elev = validate_elevation(data.get("min_elevation", 15)) + device = validate_device_index(data.get("device", 0)) + gain_val = validate_gain(data.get("gain", 40.0)) except ValueError as e: - logger.warning('Invalid parameter in enable_schedule: %s', e) - return jsonify({ - 'status': 'error', - 'message': 'Invalid parameter value' - }), 400 + logger.warning("Invalid parameter in enable_schedule: %s", e) + return jsonify({"status": "error", "message": "Invalid parameter value"}), 400 scheduler = get_weather_sat_scheduler() scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) @@ -729,19 +716,16 @@ def enable_schedule(): min_elevation=min_elev, device=device, gain=gain_val, - bias_t=bool(data.get('bias_t', False)), + bias_t=bool(data.get("bias_t", False)), ) except Exception: logger.exception("Failed to enable weather sat scheduler") - return jsonify({ - 'status': 'error', - 'message': 'Failed to enable scheduler' - }), 500 + return jsonify({"status": "error", "message": "Failed to enable scheduler"}), 500 - return jsonify({'status': 'ok', **result}) + return jsonify({"status": "ok", **result}) -@weather_sat_bp.route('/schedule/disable', methods=['POST']) +@weather_sat_bp.route("/schedule/disable", methods=["POST"]) def disable_schedule(): """Disable auto-scheduling.""" from utils.weather_sat_scheduler import get_weather_sat_scheduler @@ -751,7 +735,7 @@ def disable_schedule(): return jsonify(result) -@weather_sat_bp.route('/schedule/status') +@weather_sat_bp.route("/schedule/status") def schedule_status(): """Get current scheduler state.""" from utils.weather_sat_scheduler import get_weather_sat_scheduler @@ -760,30 +744,32 @@ def schedule_status(): return jsonify(scheduler.get_status()) -@weather_sat_bp.route('/schedule/passes') +@weather_sat_bp.route("/schedule/passes") def schedule_passes(): """List scheduled passes.""" from utils.weather_sat_scheduler import get_weather_sat_scheduler scheduler = get_weather_sat_scheduler() passes = scheduler.get_passes() - return jsonify({ - 'status': 'ok', - 'passes': passes, - 'count': len(passes), - }) + return jsonify( + { + "status": "ok", + "passes": passes, + "count": len(passes), + } + ) -@weather_sat_bp.route('/schedule/skip/', methods=['POST']) +@weather_sat_bp.route("/schedule/skip/", methods=["POST"]) def skip_pass(pass_id: str): """Skip a scheduled pass.""" from utils.weather_sat_scheduler import get_weather_sat_scheduler - if not pass_id.replace('_', '').replace('-', '').isalnum(): - return api_error('Invalid pass ID', 400) + if not pass_id.replace("_", "").replace("-", "").isalnum(): + return api_error("Invalid pass ID", 400) scheduler = get_weather_sat_scheduler() if scheduler.skip_pass(pass_id): - return jsonify({'status': 'skipped', 'pass_id': pass_id}) + return jsonify({"status": "skipped", "pass_id": pass_id}) else: - return api_error('Pass not found or already processed', 404) + return api_error("Pass not found or already processed", 404) diff --git a/routes/websdr.py b/routes/websdr.py index bc27a02..f16351c 100644 --- a/routes/websdr.py +++ b/routes/websdr.py @@ -16,6 +16,7 @@ from utils.responses import api_error, api_success try: from flask_sock import Sock + WEBSOCKET_AVAILABLE = True except ImportError: WEBSOCKET_AVAILABLE = False @@ -25,9 +26,9 @@ import contextlib from utils.kiwisdr import KIWI_SAMPLE_RATE, VALID_MODES, KiwiSDRClient, parse_host_port from utils.logging import get_logger -logger = get_logger('intercept.websdr') +logger = get_logger("intercept.websdr") -websdr_bp = Blueprint('websdr', __name__, url_prefix='/websdr') +websdr_bp = Blueprint("websdr", __name__, url_prefix="/websdr") # ============================================ # RECEIVER CACHE @@ -44,7 +45,7 @@ def _parse_gps_coord(coord_str: str) -> float | None: if not coord_str: return None # Remove parentheses and whitespace - cleaned = coord_str.strip().strip('()').strip() + cleaned = coord_str.strip().strip("()").strip() try: return float(cleaned) except (ValueError, TypeError): @@ -56,16 +57,14 @@ def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: R = 6371 # Earth radius in km dlat = math.radians(lat2 - lat1) dlon = math.radians(lon2 - lon1) - a = (math.sin(dlat / 2) ** 2 + - math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * - math.sin(dlon / 2) ** 2) + a = math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2 c = 2 * math.asin(math.sqrt(a)) return R * c KIWI_DATA_URLS = [ - 'https://rx.skywavelinux.com/kiwisdr_com.js', - 'http://rx.linkfanel.net/kiwisdr_com.js', + "https://rx.skywavelinux.com/kiwisdr_com.js", + "http://rx.linkfanel.net/kiwisdr_com.js", ] @@ -80,11 +79,14 @@ def _fetch_kiwi_receivers() -> list[dict]: # Try each data source until one works for data_url in KIWI_DATA_URLS: try: - req = urllib.request.Request(data_url, headers={ - 'User-Agent': 'INTERCEPT-SIGINT/1.0', - }) + req = urllib.request.Request( + data_url, + headers={ + "User-Agent": "INTERCEPT-SIGINT/1.0", + }, + ) with urllib.request.urlopen(req, timeout=20) as resp: - raw = resp.read().decode('utf-8', errors='replace') + raw = resp.read().decode("utf-8", errors="replace") if raw and len(raw) > 100: logger.info(f"Fetched KiwiSDR data from {data_url}") break @@ -99,10 +101,10 @@ def _fetch_kiwi_receivers() -> list[dict]: # The JS file contains: var kiwisdr_com = [ {...}, {...}, ... ]; # Extract the JSON array - match = re.search(r'var\s+kiwisdr_com\s*=\s*(\[.*\])\s*;?', raw, re.DOTALL) + match = re.search(r"var\s+kiwisdr_com\s*=\s*(\[.*\])\s*;?", raw, re.DOTALL) if not match: # Try bare array - match = re.search(r'(\[\s*\{.*\}\s*\])', raw, re.DOTALL) + match = re.search(r"(\[\s*\{.*\}\s*\])", raw, re.DOTALL) if not match: logger.warning("Could not find receiver array in KiwiSDR data") return receivers @@ -114,8 +116,8 @@ def _fetch_kiwi_receivers() -> list[dict]: raw_list = json.loads(arr_str) except json.JSONDecodeError: # Fix common JS → JSON issues (trailing commas) - fixed = re.sub(r',\s*}', '}', arr_str) - fixed = re.sub(r',\s*]', ']', fixed) + fixed = re.sub(r",\s*}", "}", arr_str) + fixed = re.sub(r",\s*]", "]", fixed) try: raw_list = json.loads(fixed) except json.JSONDecodeError: @@ -127,32 +129,32 @@ def _fetch_kiwi_receivers() -> list[dict]: continue # Skip offline receivers - if entry.get('offline') == 'yes' or entry.get('status') != 'active': + if entry.get("offline") == "yes" or entry.get("status") != "active": continue - name = entry.get('name', 'Unknown') - url = entry.get('url', '') - gps = entry.get('gps', '') - antenna = entry.get('antenna', '') - location = entry.get('loc', '') + name = entry.get("name", "Unknown") + url = entry.get("url", "") + gps = entry.get("gps", "") + antenna = entry.get("antenna", "") + location = entry.get("loc", "") # Parse users (strings in actual data) try: - users = int(entry.get('users', 0)) + users = int(entry.get("users", 0)) except (ValueError, TypeError): users = 0 try: - users_max = int(entry.get('users_max', 4)) + users_max = int(entry.get("users_max", 4)) except (ValueError, TypeError): users_max = 4 # Parse bands field: "0-30000000" (Hz) → freq_lo/freq_hi in kHz - bands_str = entry.get('bands', '0-30000000') + bands_str = entry.get("bands", "0-30000000") freq_lo = 0 freq_hi = 30000 - if bands_str and '-' in str(bands_str): + if bands_str and "-" in str(bands_str): try: - parts = str(bands_str).split('-') + parts = str(bands_str).split("-") freq_lo = int(parts[0]) / 1000 # Hz to kHz freq_hi = int(parts[1]) / 1000 # Hz to kHz except (ValueError, IndexError): @@ -161,7 +163,7 @@ def _fetch_kiwi_receivers() -> list[dict]: # Parse GPS: "(51.317266, -2.950479)" format lat, lon = None, None if gps: - parts = str(gps).replace('(', '').replace(')', '').split(',') + parts = str(gps).replace("(", "").replace(")", "").split(",") if len(parts) >= 2: lat = _parse_gps_coord(parts[0]) lon = _parse_gps_coord(parts[1]) @@ -170,23 +172,25 @@ def _fetch_kiwi_receivers() -> list[dict]: continue # Ensure URL has protocol - if not url.startswith('http'): - url = 'http://' + url + if not url.startswith("http"): + url = "http://" + url - receivers.append({ - 'name': name, - 'url': url.rstrip('/'), - 'lat': lat, - 'lon': lon, - 'location': location, - 'users': users, - 'users_max': users_max, - 'antenna': antenna, - 'bands': bands_str, - 'freq_lo': freq_lo, - 'freq_hi': freq_hi, - 'available': users < users_max, - }) + receivers.append( + { + "name": name, + "url": url.rstrip("/"), + "lat": lat, + "lon": lon, + "location": location, + "users": users, + "users_max": users_max, + "antenna": antenna, + "bands": bands_str, + "freq_lo": freq_lo, + "freq_hi": freq_hi, + "available": users < users_max, + } + ) return receivers @@ -210,126 +214,126 @@ def get_receivers(force_refresh: bool = False) -> list[dict]: # API ENDPOINTS # ============================================ -@websdr_bp.route('/receivers') + +@websdr_bp.route("/receivers") def list_receivers() -> Response: """List KiwiSDR receivers, with optional filters.""" - freq_khz = request.args.get('freq_khz', type=float) - available = request.args.get('available', type=str) - refresh = request.args.get('refresh', type=str) + freq_khz = request.args.get("freq_khz", type=float) + available = request.args.get("available", type=str) + refresh = request.args.get("refresh", type=str) - receivers = get_receivers(force_refresh=(refresh == 'true')) + receivers = get_receivers(force_refresh=(refresh == "true")) filtered = receivers - if available == 'true': - filtered = [r for r in filtered if r.get('available', True)] + if available == "true": + filtered = [r for r in filtered if r.get("available", True)] if freq_khz is not None: - filtered = [ - r for r in filtered - if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) - ] + filtered = [r for r in filtered if r.get("freq_lo", 0) <= freq_khz <= r.get("freq_hi", 30000)] - return api_success(data={ - 'receivers': filtered[:100], - 'total': len(filtered), - 'cached_total': len(receivers), - }) + return api_success( + data={ + "receivers": filtered[:100], + "total": len(filtered), + "cached_total": len(receivers), + } + ) -@websdr_bp.route('/receivers/nearest') +@websdr_bp.route("/receivers/nearest") def nearest_receivers() -> Response: """Find receivers nearest to a given location.""" - lat = request.args.get('lat', type=float) - lon = request.args.get('lon', type=float) - freq_khz = request.args.get('freq_khz', type=float) + lat = request.args.get("lat", type=float) + lon = request.args.get("lon", type=float) + freq_khz = request.args.get("freq_khz", type=float) if lat is None or lon is None: - return api_error('lat and lon are required', 400) + return api_error("lat and lon are required", 400) receivers = get_receivers() # Filter by frequency if specified if freq_khz is not None: - receivers = [ - r for r in receivers - if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) - ] + receivers = [r for r in receivers if r.get("freq_lo", 0) <= freq_khz <= r.get("freq_hi", 30000)] # Calculate distances and sort with_distance = [] for r in receivers: - if r.get('lat') is not None and r.get('lon') is not None: - dist = _haversine(lat, lon, r['lat'], r['lon']) + if r.get("lat") is not None and r.get("lon") is not None: + dist = _haversine(lat, lon, r["lat"], r["lon"]) entry = dict(r) - entry['distance_km'] = round(dist, 1) + entry["distance_km"] = round(dist, 1) with_distance.append(entry) - with_distance.sort(key=lambda x: x['distance_km']) + with_distance.sort(key=lambda x: x["distance_km"]) - return api_success(data={'receivers': with_distance[:10]}) + return api_success(data={"receivers": with_distance[:10]}) -@websdr_bp.route('/spy-station//receivers') +@websdr_bp.route("/spy-station//receivers") def spy_station_receivers(station_id: str) -> Response: """Find receivers that can tune to a spy station's frequency.""" try: from routes.spy_stations import STATIONS except ImportError: - return api_error('Spy stations module not available', 503) + return api_error("Spy stations module not available", 503) # Find the station station = None for s in STATIONS: - if s.get('id') == station_id: + if s.get("id") == station_id: station = s break if not station: - return api_error('Station not found', 404) + return api_error("Station not found", 404) # Get primary frequency freq_khz = None - for f in station.get('frequencies', []): - if f.get('primary'): - freq_khz = f.get('freq_khz') + for f in station.get("frequencies", []): + if f.get("primary"): + freq_khz = f.get("freq_khz") break - if freq_khz is None and station.get('frequencies'): - freq_khz = station['frequencies'][0].get('freq_khz') + if freq_khz is None and station.get("frequencies"): + freq_khz = station["frequencies"][0].get("freq_khz") if freq_khz is None: - return api_error('No frequency found for station', 404) + return api_error("No frequency found for station", 404) receivers = get_receivers() # Filter receivers that cover this frequency and are available matching = [ - r for r in receivers - if r.get('freq_lo', 0) <= freq_khz <= r.get('freq_hi', 30000) and r.get('available', True) + r for r in receivers if r.get("freq_lo", 0) <= freq_khz <= r.get("freq_hi", 30000) and r.get("available", True) ] - return api_success(data={ - 'station': { - 'id': station['id'], - 'name': station.get('name', ''), - 'nickname': station.get('nickname', ''), - 'freq_khz': freq_khz, - 'mode': station.get('mode', 'USB'), - }, - 'receivers': matching[:20], - 'total': len(matching), - }) + return api_success( + data={ + "station": { + "id": station["id"], + "name": station.get("name", ""), + "nickname": station.get("nickname", ""), + "freq_khz": freq_khz, + "mode": station.get("mode", "USB"), + }, + "receivers": matching[:20], + "total": len(matching), + } + ) -@websdr_bp.route('/status') +@websdr_bp.route("/status") def websdr_status() -> Response: """Get WebSDR connection and cache status.""" - return jsonify({ - 'status': 'ok', - 'cached_receivers': len(_receiver_cache), - 'cache_age_seconds': round(time.time() - _cache_timestamp, 0) if _cache_timestamp > 0 else None, - 'cache_ttl': CACHE_TTL, - 'audio_connected': _kiwi_client is not None and _kiwi_client.connected if _kiwi_client else False, - }) + return jsonify( + { + "status": "ok", + "cached_receivers": len(_receiver_cache), + "cache_age_seconds": round(time.time() - _cache_timestamp, 0) if _cache_timestamp > 0 else None, + "cache_ttl": CACHE_TTL, + "audio_connected": _kiwi_client is not None and _kiwi_client.connected if _kiwi_client else False, + } + ) # ============================================ @@ -360,31 +364,31 @@ def _handle_kiwi_command(ws, cmd: str, data: dict) -> None: """Handle a command from the browser client.""" global _kiwi_client - if cmd == 'connect': - receiver_url = data.get('url', '') - host = data.get('host', '') - port = int(data.get('port', 8073)) - freq_khz = float(data.get('freq_khz', 7000)) - mode = data.get('mode', 'am').lower() - password = data.get('password', '') + if cmd == "connect": + receiver_url = data.get("url", "") + host = data.get("host", "") + port = int(data.get("port", 8073)) + freq_khz = float(data.get("freq_khz", 7000)) + mode = data.get("mode", "am").lower() + password = data.get("password", "") # Parse host/port from URL if provided if receiver_url and not host: host, port = parse_host_port(receiver_url) if mode not in VALID_MODES: - ws.send(json.dumps({'type': 'error', 'message': f'Invalid mode: {mode}'})) + ws.send(json.dumps({"type": "error", "message": f"Invalid mode: {mode}"})) return - if not host or ';' in host or '&' in host or '|' in host: - ws.send(json.dumps({'type': 'error', 'message': 'Invalid host'})) + if not host or ";" in host or "&" in host or "|" in host: + ws.send(json.dumps({"type": "error", "message": "Invalid host"})) return _disconnect_kiwi() def on_audio(pcm_bytes, smeter): # Package: 2 bytes smeter (big-endian int16) + PCM data - header = struct.pack('>h', smeter) + header = struct.pack(">h", smeter) try: _kiwi_audio_queue.put_nowait(header + pcm_bytes) except queue.Full: @@ -395,15 +399,16 @@ def _handle_kiwi_command(ws, cmd: str, data: dict) -> None: def on_error(msg): with contextlib.suppress(Exception): - ws.send(json.dumps({'type': 'error', 'message': msg})) + ws.send(json.dumps({"type": "error", "message": msg})) def on_disconnect(): with contextlib.suppress(Exception): - ws.send(json.dumps({'type': 'disconnected'})) + ws.send(json.dumps({"type": "disconnected"})) with _kiwi_lock: _kiwi_client = KiwiSDRClient( - host=host, port=port, + host=host, + port=port, on_audio=on_audio, on_error=on_error, on_disconnect=on_disconnect, @@ -412,42 +417,47 @@ def _handle_kiwi_command(ws, cmd: str, data: dict) -> None: success = _kiwi_client.connect(freq_khz, mode) if success: - ws.send(json.dumps({ - 'type': 'connected', - 'host': host, - 'port': port, - 'freq_khz': freq_khz, - 'mode': mode, - 'sample_rate': KIWI_SAMPLE_RATE, - })) + ws.send( + json.dumps( + { + "type": "connected", + "host": host, + "port": port, + "freq_khz": freq_khz, + "mode": mode, + "sample_rate": KIWI_SAMPLE_RATE, + } + ) + ) else: - ws.send(json.dumps({'type': 'error', 'message': 'Connection to KiwiSDR failed'})) + ws.send(json.dumps({"type": "error", "message": "Connection to KiwiSDR failed"})) _disconnect_kiwi() - elif cmd == 'tune': - freq_khz = float(data.get('freq_khz', 0)) - mode = data.get('mode', '').lower() or None + elif cmd == "tune": + freq_khz = float(data.get("freq_khz", 0)) + mode = data.get("mode", "").lower() or None with _kiwi_lock: if _kiwi_client and _kiwi_client.connected: - success = _kiwi_client.tune( - freq_khz, - mode or _kiwi_client.mode - ) + success = _kiwi_client.tune(freq_khz, mode or _kiwi_client.mode) if success: - ws.send(json.dumps({ - 'type': 'tuned', - 'freq_khz': freq_khz, - 'mode': mode or _kiwi_client.mode, - })) + ws.send( + json.dumps( + { + "type": "tuned", + "freq_khz": freq_khz, + "mode": mode or _kiwi_client.mode, + } + ) + ) else: - ws.send(json.dumps({'type': 'error', 'message': 'Retune failed'})) + ws.send(json.dumps({"type": "error", "message": "Retune failed"})) else: - ws.send(json.dumps({'type': 'error', 'message': 'Not connected'})) + ws.send(json.dumps({"type": "error", "message": "Not connected"})) - elif cmd == 'disconnect': + elif cmd == "disconnect": _disconnect_kiwi() - ws.send(json.dumps({'type': 'disconnected'})) + ws.send(json.dumps({"type": "disconnected"})) def init_websdr_audio(app: Flask) -> None: @@ -458,7 +468,7 @@ def init_websdr_audio(app: Flask) -> None: sock = Sock(app) - @sock.route('/ws/kiwi-audio') + @sock.route("/ws/kiwi-audio") def kiwi_audio_stream(ws): """WebSocket endpoint: proxy audio between browser and KiwiSDR.""" logger.info("KiwiSDR audio client connected") @@ -470,14 +480,14 @@ def init_websdr_audio(app: Flask) -> None: msg = ws.receive(timeout=0.005) if msg: data = json.loads(msg) - cmd = data.get('cmd', '') + cmd = data.get("cmd", "") _handle_kiwi_command(ws, cmd, data) except TimeoutError: pass except Exception as e: - if 'closed' in str(e).lower(): + if "closed" in str(e).lower(): break - if 'timed out' not in str(e).lower(): + if "timed out" not in str(e).lower(): logger.error(f"KiwiSDR WS receive error: {e}") # Forward audio from KiwiSDR to browser diff --git a/routes/wefax.py b/routes/wefax.py index 150a958..a36144e 100644 --- a/routes/wefax.py +++ b/routes/wefax.py @@ -26,9 +26,9 @@ from utils.wefax_stations import ( resolve_tuning_frequency_khz, ) -logger = get_logger('intercept.wefax') +logger = get_logger("intercept.wefax") -wefax_bp = Blueprint('wefax', __name__, url_prefix='/wefax') +wefax_bp = Blueprint("wefax", __name__, url_prefix="/wefax") # SSE progress queue _wefax_queue: queue.Queue = queue.Queue(maxsize=100) @@ -55,27 +55,29 @@ def _progress_callback(data: dict) -> None: # decode session ends on its own (complete/error/stopped). if ( isinstance(data, dict) - and data.get('type') == 'wefax_progress' - and data.get('status') in ('complete', 'error', 'stopped') + and data.get("type") == "wefax_progress" + and data.get("status") in ("complete", "error", "stopped") and wefax_active_device is not None ): - app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or "rtlsdr") wefax_active_device = None wefax_active_sdr_type = None -@wefax_bp.route('/status') +@wefax_bp.route("/status") def get_status(): """Get WeFax decoder status.""" decoder = get_wefax_decoder() - return jsonify({ - 'available': True, - 'running': decoder.is_running, - 'image_count': len(decoder.get_images()), - }) + return jsonify( + { + "available": True, + "running": decoder.is_running, + "image_count": len(decoder.get_images()), + } + ) -@wefax_bp.route('/start', methods=['POST']) +@wefax_bp.route("/start", methods=["POST"]) def start_decoder(): """Start WeFax decoder. @@ -94,10 +96,12 @@ def start_decoder(): decoder = get_wefax_decoder() if decoder.is_running: - return jsonify({ - 'status': 'already_running', - 'message': 'WeFax decoder is already running', - }) + return jsonify( + { + "status": "already_running", + "message": "WeFax decoder is already running", + } + ) # Clear queue while not _wefax_queue.empty(): @@ -109,9 +113,9 @@ def start_decoder(): data = request.get_json(silent=True) or {} # Validate frequency (required) - frequency_khz = data.get('frequency_khz') + frequency_khz = data.get("frequency_khz") if frequency_khz is None: - return api_error('frequency_khz is required', 400) + return api_error("frequency_khz is required", 400) try: frequency_khz = float(frequency_khz) @@ -119,48 +123,46 @@ def start_decoder(): freq_mhz = frequency_khz / 1000.0 validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0) except (TypeError, ValueError) as e: - return api_error(f'Invalid frequency: {e}', 400) + return api_error(f"Invalid frequency: {e}", 400) - station = str(data.get('station', '')).strip() - device_index = data.get('device', 0) - gain = float(data.get('gain', 40.0)) - ioc = int(data.get('ioc', 576)) - lpm = int(data.get('lpm', 120)) - direct_sampling = bool(data.get('direct_sampling', True)) - frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower() + station = str(data.get("station", "")).strip() + device_index = data.get("device", 0) + gain = float(data.get("gain", 40.0)) + ioc = int(data.get("ioc", 576)) + lpm = int(data.get("lpm", 120)) + direct_sampling = bool(data.get("direct_sampling", True)) + frequency_reference = str(data.get("frequency_reference", "auto")).strip().lower() - sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower() + sdr_type_str = str(data.get("sdr_type", "rtlsdr")).lower() with contextlib.suppress(ValueError): SDRType(sdr_type_str) if not frequency_reference: - frequency_reference = 'auto' + frequency_reference = "auto" try: - tuned_frequency_khz, resolved_reference, usb_offset_applied = ( - resolve_tuning_frequency_khz( - listed_frequency_khz=frequency_khz, - station_callsign=station, - frequency_reference=frequency_reference, - ) + tuned_frequency_khz, resolved_reference, usb_offset_applied = resolve_tuning_frequency_khz( + listed_frequency_khz=frequency_khz, + station_callsign=station, + frequency_reference=frequency_reference, ) tuned_mhz = tuned_frequency_khz / 1000.0 validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0) except ValueError as e: - return api_error(f'Invalid frequency settings: {e}', 400) + return api_error(f"Invalid frequency settings: {e}", 400) # Validate IOC and LPM if ioc not in (288, 576): - return api_error('IOC must be 288 or 576', 400) + return api_error("IOC must be 288 or 576", 400) if lpm not in (60, 120): - return api_error('LPM must be 60 or 120', 400) + return api_error("LPM must be 60 or 120", 400) # Claim SDR device global wefax_active_device, wefax_active_sdr_type device_int = int(device_index) - error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str) + error = app_module.claim_sdr_device(device_int, "wefax", sdr_type_str) if error: - return api_error(error, 409, error_type='DEVICE_BUSY') + return api_error(error, 409, error_type="DEVICE_BUSY") # Set callback and start decoder.set_callback(_progress_callback) @@ -178,26 +180,26 @@ def start_decoder(): if success: wefax_active_device = device_int wefax_active_sdr_type = sdr_type_str - return jsonify({ - 'status': 'started', - 'frequency_khz': frequency_khz, - 'tuned_frequency_khz': tuned_frequency_khz, - 'frequency_reference': resolved_reference, - 'usb_offset_applied': usb_offset_applied, - 'usb_offset_khz': ( - WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0 - ), - 'station': station, - 'ioc': ioc, - 'lpm': lpm, - 'device': device_int, - }) + return jsonify( + { + "status": "started", + "frequency_khz": frequency_khz, + "tuned_frequency_khz": tuned_frequency_khz, + "frequency_reference": resolved_reference, + "usb_offset_applied": usb_offset_applied, + "usb_offset_khz": (WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0), + "station": station, + "ioc": ioc, + "lpm": lpm, + "device": device_int, + } + ) else: app_module.release_sdr_device(device_int, sdr_type_str) - return api_error('Failed to start decoder', 500) + return api_error("Failed to start decoder", 500) -@wefax_bp.route('/stop', methods=['POST']) +@wefax_bp.route("/stop", methods=["POST"]) def stop_decoder(): """Stop WeFax decoder.""" global wefax_active_device, wefax_active_sdr_type @@ -205,89 +207,91 @@ def stop_decoder(): decoder.stop() if wefax_active_device is not None: - app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr') + app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or "rtlsdr") wefax_active_device = None wefax_active_sdr_type = None - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@wefax_bp.route('/stream') +@wefax_bp.route("/stream") def stream_progress(): """SSE stream of WeFax decode progress.""" response = Response( sse_stream_fanout( source_queue=_wefax_queue, - channel_key='wefax', + channel_key="wefax", timeout=1.0, keepalive_interval=30.0, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response -@wefax_bp.route('/images') +@wefax_bp.route("/images") def list_images(): """Get list of decoded WeFax images.""" decoder = get_wefax_decoder() images = decoder.get_images() - limit = request.args.get('limit', type=int) + limit = request.args.get("limit", type=int) if limit and limit > 0: images = images[-limit:] - return jsonify({ - 'status': 'ok', - 'images': [img.to_dict() for img in images], - 'count': len(images), - }) + return jsonify( + { + "status": "ok", + "images": [img.to_dict() for img in images], + "count": len(images), + } + ) -@wefax_bp.route('/images/') +@wefax_bp.route("/images/") def get_image(filename: str): """Get a decoded WeFax image file.""" decoder = get_wefax_decoder() - if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): - return api_error('Invalid filename', 400) + if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum(): + return api_error("Invalid filename", 400) - if not filename.endswith('.png'): - return api_error('Only PNG files supported', 400) + if not filename.endswith(".png"): + return api_error("Only PNG files supported", 400) image_path = decoder._output_dir / filename if not image_path.exists(): - return api_error('Image not found', 404) + return api_error("Image not found", 404) - return send_file(image_path, mimetype='image/png') + return send_file(image_path, mimetype="image/png") -@wefax_bp.route('/images/', methods=['DELETE']) +@wefax_bp.route("/images/", methods=["DELETE"]) def delete_image(filename: str): """Delete a decoded WeFax image.""" decoder = get_wefax_decoder() - if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): - return api_error('Invalid filename', 400) + if not filename.replace("_", "").replace("-", "").replace(".", "").isalnum(): + return api_error("Invalid filename", 400) - if not filename.endswith('.png'): - return api_error('Only PNG files supported', 400) + if not filename.endswith(".png"): + return api_error("Only PNG files supported", 400) if decoder.delete_image(filename): - return jsonify({'status': 'ok'}) + return jsonify({"status": "ok"}) else: - return api_error('Image not found', 404) + return api_error("Image not found", 404) -@wefax_bp.route('/images', methods=['DELETE']) +@wefax_bp.route("/images", methods=["DELETE"]) def delete_all_images(): """Delete all decoded WeFax images.""" decoder = get_wefax_decoder() count = decoder.delete_all_images() - return jsonify({'status': 'ok', 'deleted': count}) + return jsonify({"status": "ok", "deleted": count}) # ======================== @@ -307,7 +311,7 @@ def _scheduler_event_callback(event: dict) -> None: pass -@wefax_bp.route('/schedule/enable', methods=['POST']) +@wefax_bp.route("/schedule/enable", methods=["POST"]) def enable_schedule(): """Enable auto-scheduling of WeFax broadcast captures. @@ -330,42 +334,40 @@ def enable_schedule(): data = request.get_json(silent=True) or {} - station = str(data.get('station', '')).strip() + station = str(data.get("station", "")).strip() if not station: - return api_error('station is required', 400) + return api_error("station is required", 400) - frequency_khz = data.get('frequency_khz') + frequency_khz = data.get("frequency_khz") if frequency_khz is None: - return api_error('frequency_khz is required', 400) + return api_error("frequency_khz is required", 400) try: frequency_khz = float(frequency_khz) freq_mhz = frequency_khz / 1000.0 validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0) except (TypeError, ValueError) as e: - return api_error(f'Invalid frequency: {e}', 400) + return api_error(f"Invalid frequency: {e}", 400) - device = int(data.get('device', 0)) - gain = float(data.get('gain', 40.0)) - ioc = int(data.get('ioc', 576)) - lpm = int(data.get('lpm', 120)) - direct_sampling = bool(data.get('direct_sampling', True)) - frequency_reference = str(data.get('frequency_reference', 'auto')).strip().lower() + device = int(data.get("device", 0)) + gain = float(data.get("gain", 40.0)) + ioc = int(data.get("ioc", 576)) + lpm = int(data.get("lpm", 120)) + direct_sampling = bool(data.get("direct_sampling", True)) + frequency_reference = str(data.get("frequency_reference", "auto")).strip().lower() if not frequency_reference: - frequency_reference = 'auto' + frequency_reference = "auto" try: - tuned_frequency_khz, resolved_reference, usb_offset_applied = ( - resolve_tuning_frequency_khz( - listed_frequency_khz=frequency_khz, - station_callsign=station, - frequency_reference=frequency_reference, - ) + tuned_frequency_khz, resolved_reference, usb_offset_applied = resolve_tuning_frequency_khz( + listed_frequency_khz=frequency_khz, + station_callsign=station, + frequency_reference=frequency_reference, ) tuned_mhz = tuned_frequency_khz / 1000.0 validate_frequency(tuned_mhz, min_mhz=2.0, max_mhz=30.0) except ValueError as e: - return api_error(f'Invalid frequency settings: {e}', 400) + return api_error(f"Invalid frequency settings: {e}", 400) scheduler = get_wefax_scheduler() scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) @@ -382,22 +384,22 @@ def enable_schedule(): ) except Exception: logger.exception("Failed to enable WeFax scheduler") - return api_error('Failed to enable scheduler', 500) + return api_error("Failed to enable scheduler", 500) - return jsonify({ - 'status': 'ok', - **result, - 'frequency_khz': frequency_khz, - 'tuned_frequency_khz': tuned_frequency_khz, - 'frequency_reference': resolved_reference, - 'usb_offset_applied': usb_offset_applied, - 'usb_offset_khz': ( - WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0 - ), - }) + return jsonify( + { + "status": "ok", + **result, + "frequency_khz": frequency_khz, + "tuned_frequency_khz": tuned_frequency_khz, + "frequency_reference": resolved_reference, + "usb_offset_applied": usb_offset_applied, + "usb_offset_khz": (WEFAX_USB_ALIGNMENT_OFFSET_KHZ if usb_offset_applied else 0.0), + } + ) -@wefax_bp.route('/schedule/disable', methods=['POST']) +@wefax_bp.route("/schedule/disable", methods=["POST"]) def disable_schedule(): """Disable auto-scheduling.""" from utils.wefax_scheduler import get_wefax_scheduler @@ -407,7 +409,7 @@ def disable_schedule(): return jsonify(result) -@wefax_bp.route('/schedule/status') +@wefax_bp.route("/schedule/status") def schedule_status(): """Get current scheduler state.""" from utils.wefax_scheduler import get_wefax_scheduler @@ -416,57 +418,63 @@ def schedule_status(): return jsonify(scheduler.get_status()) -@wefax_bp.route('/schedule/broadcasts') +@wefax_bp.route("/schedule/broadcasts") def schedule_broadcasts(): """List scheduled broadcasts.""" from utils.wefax_scheduler import get_wefax_scheduler scheduler = get_wefax_scheduler() broadcasts = scheduler.get_broadcasts() - return jsonify({ - 'status': 'ok', - 'broadcasts': broadcasts, - 'count': len(broadcasts), - }) + return jsonify( + { + "status": "ok", + "broadcasts": broadcasts, + "count": len(broadcasts), + } + ) -@wefax_bp.route('/schedule/skip/', methods=['POST']) +@wefax_bp.route("/schedule/skip/", methods=["POST"]) def skip_broadcast(broadcast_id: str): """Skip a scheduled broadcast.""" from utils.wefax_scheduler import get_wefax_scheduler - if not broadcast_id.replace('_', '').replace('-', '').isalnum(): - return api_error('Invalid broadcast ID', 400) + if not broadcast_id.replace("_", "").replace("-", "").isalnum(): + return api_error("Invalid broadcast ID", 400) scheduler = get_wefax_scheduler() if scheduler.skip_broadcast(broadcast_id): - return jsonify({'status': 'skipped', 'broadcast_id': broadcast_id}) + return jsonify({"status": "skipped", "broadcast_id": broadcast_id}) else: - return api_error('Broadcast not found or already processed', 404) + return api_error("Broadcast not found or already processed", 404) -@wefax_bp.route('/stations') +@wefax_bp.route("/stations") def list_stations(): """Get all WeFax stations from the database.""" stations = load_stations() - return jsonify({ - 'status': 'ok', - 'stations': stations, - 'count': len(stations), - }) + return jsonify( + { + "status": "ok", + "stations": stations, + "count": len(stations), + } + ) -@wefax_bp.route('/stations/') +@wefax_bp.route("/stations/") def station_detail(callsign: str): """Get station detail including current schedule info.""" station = get_station(callsign) if not station: - return api_error(f'Station {callsign} not found', 404) + return api_error(f"Station {callsign} not found", 404) current = get_current_broadcasts(callsign) - return jsonify({ - 'status': 'ok', - 'station': station, - 'current_broadcasts': current, - }) + return jsonify( + { + "status": "ok", + "station": station, + "current_broadcasts": current, + } + ) diff --git a/routes/wifi.py b/routes/wifi.py index c0260dc..25a7184 100644 --- a/routes/wifi.py +++ b/routes/wifi.py @@ -32,7 +32,7 @@ from utils.responses import api_error, api_success from utils.sse import format_sse, sse_stream_fanout from utils.validation import validate_network_interface, validate_wifi_channel -wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi') +wifi_bp = Blueprint("wifi", __name__, url_prefix="/wifi") # --- v1 deprecation --- # These endpoints are deprecated in favor of /wifi/v2/*. @@ -44,8 +44,8 @@ _v1_deprecation_logged = set() @wifi_bp.after_request def _add_deprecation_header(response): """Add X-Deprecated header to all v1 WiFi responses.""" - response.headers['X-Deprecated'] = 'Use /wifi/v2/* endpoints instead' - endpoint = request.endpoint or '' + response.headers["X-Deprecated"] = "Use /wifi/v2/* endpoints instead" + endpoint = request.endpoint or "" if endpoint not in _v1_deprecation_logged: _v1_deprecation_logged.add(endpoint) logger.warning(f"Deprecated v1 WiFi endpoint called: {request.path} — migrate to /wifi/v2/*") @@ -59,11 +59,11 @@ pmkid_lock = threading.Lock() def _parse_channel_list(raw_channels: Any) -> list[int] | None: """Parse a channel list from string/list input.""" - if raw_channels in (None, '', []): + if raw_channels in (None, "", []): return None if isinstance(raw_channels, str): - parts = [p.strip() for p in re.split(r'[\s,]+', raw_channels) if p.strip()] + parts = [p.strip() for p in re.split(r"[\s,]+", raw_channels) if p.strip()] elif isinstance(raw_channels, (list, tuple, set)): parts = list(raw_channels) else: @@ -72,7 +72,7 @@ def _parse_channel_list(raw_channels: Any) -> list[int] | None: channels: list[int] = [] seen = set() for part in parts: - if part in (None, ''): + if part in (None, ""): continue ch = validate_wifi_channel(part) if ch not in seen: @@ -86,22 +86,23 @@ def detect_wifi_interfaces(): """Detect available WiFi interfaces.""" interfaces = [] - if platform.system() == 'Darwin': # macOS + if platform.system() == "Darwin": # macOS try: - result = subprocess.run(['networksetup', '-listallhardwareports'], - capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT) - lines = result.stdout.split('\n') + result = subprocess.run( + ["networksetup", "-listallhardwareports"], + capture_output=True, + text=True, + timeout=SUBPROCESS_TIMEOUT_SHORT, + ) + lines = result.stdout.split("\n") for i, line in enumerate(lines): - if 'Wi-Fi' in line or 'AirPort' in line: - for j in range(i+1, min(i+3, len(lines))): - if 'Device:' in lines[j]: - device = lines[j].split('Device:')[1].strip() - interfaces.append({ - 'name': device, - 'type': 'internal', - 'monitor_capable': False, - 'status': 'up' - }) + if "Wi-Fi" in line or "AirPort" in line: + for j in range(i + 1, min(i + 3, len(lines))): + if "Device:" in lines[j]: + device = lines[j].split("Device:")[1].strip() + interfaces.append( + {"name": device, "type": "internal", "monitor_capable": False, "status": "up"} + ) break except FileNotFoundError: logger.debug("networksetup not found") @@ -111,15 +112,13 @@ def detect_wifi_interfaces(): logger.error(f"Error detecting macOS interfaces: {e}") try: - result = subprocess.run(['system_profiler', 'SPUSBDataType'], - capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_MEDIUM) - if 'Wireless' in result.stdout or 'WLAN' in result.stdout or '802.11' in result.stdout: - interfaces.append({ - 'name': 'USB WiFi Adapter', - 'type': 'usb', - 'monitor_capable': True, - 'status': 'detected' - }) + result = subprocess.run( + ["system_profiler", "SPUSBDataType"], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_MEDIUM + ) + if "Wireless" in result.stdout or "WLAN" in result.stdout or "802.11" in result.stdout: + interfaces.append( + {"name": "USB WiFi Adapter", "type": "usb", "monitor_capable": True, "status": "detected"} + ) except FileNotFoundError: logger.debug("system_profiler not found") except subprocess.TimeoutExpired: @@ -129,22 +128,22 @@ def detect_wifi_interfaces(): else: # Linux try: - result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT) + result = subprocess.run(["iw", "dev"], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT) current_iface = None - for line in result.stdout.split('\n'): + for line in result.stdout.split("\n"): line = line.strip() - if line.startswith('Interface'): + if line.startswith("Interface"): current_iface = line.split()[1] - elif current_iface and 'type' in line: + elif current_iface and "type" in line: iface_type = line.split()[-1] iface_info = { - 'name': current_iface, - 'type': iface_type, - 'monitor_capable': True, - 'status': 'up', - 'driver': '', - 'chipset': '', - 'mac': '' + "name": current_iface, + "type": iface_type, + "monitor_capable": True, + "status": "up", + "driver": "", + "chipset": "", + "mac": "", } # Get additional interface details iface_info.update(_get_interface_details(current_iface)) @@ -153,18 +152,18 @@ def detect_wifi_interfaces(): except FileNotFoundError: # Fall back to iwconfig if iw is not available try: - result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT) - for line in result.stdout.split('\n'): - if 'IEEE 802.11' in line: + result = subprocess.run(["iwconfig"], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT) + for line in result.stdout.split("\n"): + if "IEEE 802.11" in line: iface = line.split()[0] iface_info = { - 'name': iface, - 'type': 'managed', - 'monitor_capable': True, - 'status': 'up', - 'driver': '', - 'chipset': '', - 'mac': '' + "name": iface, + "type": "managed", + "monitor_capable": True, + "status": "up", + "driver": "", + "chipset": "", + "mac": "", } iface_info.update(_get_interface_details(iface)) interfaces.append(iface_info) @@ -183,86 +182,89 @@ def detect_wifi_interfaces(): def _get_interface_details(iface_name): """Get additional details about a WiFi interface (driver, chipset, MAC).""" import os - details = {'driver': '', 'chipset': '', 'mac': ''} + + details = {"driver": "", "chipset": "", "mac": ""} # Get MAC address try: - mac_path = f'/sys/class/net/{iface_name}/address' + mac_path = f"/sys/class/net/{iface_name}/address" with open(mac_path) as f: - details['mac'] = f.read().strip().upper() + details["mac"] = f.read().strip().upper() except (OSError, FileNotFoundError): pass # Get driver name try: - driver_link = f'/sys/class/net/{iface_name}/device/driver' + driver_link = f"/sys/class/net/{iface_name}/device/driver" if os.path.islink(driver_link): driver_path = os.readlink(driver_link) - details['driver'] = os.path.basename(driver_path) + details["driver"] = os.path.basename(driver_path) except (FileNotFoundError, OSError): pass # Try airmon-ng first for chipset info (most reliable for WiFi adapters) try: - result = subprocess.run(['airmon-ng'], capture_output=True, text=True, timeout=5) - for line in result.stdout.split('\n'): + result = subprocess.run(["airmon-ng"], capture_output=True, text=True, timeout=5) + for line in result.stdout.split("\n"): # airmon-ng output format: PHY Interface Driver Chipset - parts = line.split('\t') + parts = line.split("\t") if len(parts) >= 4: if parts[1].strip() == iface_name or parts[1].strip().startswith(iface_name): if parts[2].strip(): - details['driver'] = parts[2].strip() + details["driver"] = parts[2].strip() if parts[3].strip(): - details['chipset'] = parts[3].strip() + details["chipset"] = parts[3].strip() break # Also try space-separated format parts = line.split() if len(parts) >= 4 and (parts[1] == iface_name or parts[1].startswith(iface_name)): - details['driver'] = parts[2] - details['chipset'] = ' '.join(parts[3:]) + details["driver"] = parts[2] + details["chipset"] = " ".join(parts[3:]) break except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): pass # Fallback: Get chipset info from USB or PCI sysfs - if not details['chipset']: + if not details["chipset"]: try: - device_path = f'/sys/class/net/{iface_name}/device' + device_path = f"/sys/class/net/{iface_name}/device" if os.path.exists(device_path): # Try to get USB product name - for usb_path in [f'{device_path}/product', f'{device_path}/../product']: + for usb_path in [f"{device_path}/product", f"{device_path}/../product"]: try: with open(usb_path) as f: - details['chipset'] = f.read().strip() + details["chipset"] = f.read().strip() break except (OSError, FileNotFoundError): pass # If no USB product, try lsusb for USB devices - if not details['chipset']: + if not details["chipset"]: try: # Get USB bus/device info - uevent_path = f'{device_path}/uevent' + uevent_path = f"{device_path}/uevent" with open(uevent_path) as f: for line in f: - if line.startswith('PRODUCT='): + if line.startswith("PRODUCT="): # PRODUCT format: vendor/product/bcdDevice - product = line.split('=')[1].strip() - parts = product.split('/') + product = line.split("=")[1].strip() + parts = product.split("/") if len(parts) >= 2: vid = parts[0].zfill(4) pid = parts[1].zfill(4) # Try lsusb to get device name try: lsusb = subprocess.run( - ['lsusb', '-d', f'{vid}:{pid}'], - capture_output=True, text=True, timeout=5 + ["lsusb", "-d", f"{vid}:{pid}"], + capture_output=True, + text=True, + timeout=5, ) if lsusb.stdout: # Format: Bus XXX Device YYY: ID vid:pid Name - usb_parts = lsusb.stdout.split(f'{vid}:{pid}') + usb_parts = lsusb.stdout.split(f"{vid}:{pid}") if len(usb_parts) > 1: - details['chipset'] = usb_parts[1].strip() + details["chipset"] = usb_parts[1].strip() except (FileNotFoundError, subprocess.TimeoutExpired): pass break @@ -280,56 +282,56 @@ def parse_airodump_csv(csv_path): clients = {} try: - with open(csv_path, errors='replace') as f: + with open(csv_path, errors="replace") as f: content = f.read() - sections = content.split('\n\n') + sections = content.split("\n\n") for section in sections: - lines = section.strip().split('\n') + lines = section.strip().split("\n") if not lines: continue - header = lines[0] if lines else '' + header = lines[0] if lines else "" - if 'BSSID' in header and 'ESSID' in header: + if "BSSID" in header and "ESSID" in header: for line in lines[1:]: - parts = [p.strip() for p in line.split(',')] + parts = [p.strip() for p in line.split(",")] if len(parts) >= 14: bssid = parts[0] - if bssid and ':' in bssid: + if bssid and ":" in bssid: networks[bssid] = { - 'bssid': bssid, - 'first_seen': parts[1], - 'last_seen': parts[2], - 'channel': parts[3], - 'speed': parts[4], - 'privacy': parts[5], - 'cipher': parts[6], - 'auth': parts[7], - 'power': parts[8], - 'beacons': parts[9], - 'ivs': parts[10], - 'lan_ip': parts[11], - 'essid': parts[13] or 'Hidden' + "bssid": bssid, + "first_seen": parts[1], + "last_seen": parts[2], + "channel": parts[3], + "speed": parts[4], + "privacy": parts[5], + "cipher": parts[6], + "auth": parts[7], + "power": parts[8], + "beacons": parts[9], + "ivs": parts[10], + "lan_ip": parts[11], + "essid": parts[13] or "Hidden", } - elif 'Station MAC' in header: + elif "Station MAC" in header: for line in lines[1:]: - parts = [p.strip() for p in line.split(',')] + parts = [p.strip() for p in line.split(",")] if len(parts) >= 6: station = parts[0] - if station and ':' in station: + if station and ":" in station: vendor = get_manufacturer(station) clients[station] = { - 'mac': station, - 'first_seen': parts[1], - 'last_seen': parts[2], - 'power': parts[3], - 'packets': parts[4], - 'bssid': parts[5], - 'probes': parts[6] if len(parts) > 6 else '', - 'vendor': vendor + "mac": station, + "first_seen": parts[1], + "last_seen": parts[2], + "power": parts[3], + "packets": parts[4], + "bssid": parts[5], + "probes": parts[6] if len(parts) > 6 else "", + "vendor": vendor, } except Exception as e: logger.error(f"Error parsing CSV: {e}") @@ -340,7 +342,7 @@ def parse_airodump_csv(csv_path): def stream_airodump_output(process, csv_path): """Stream airodump-ng output to queue.""" try: - app_module.wifi_queue.put({'type': 'status', 'text': 'started'}) + app_module.wifi_queue.put({"type": "status", "text": "started"}) last_parse = 0 start_time = time.time() csv_found = False @@ -353,64 +355,53 @@ def stream_airodump_output(process, csv_path): stderr_data = process.stderr.read() if stderr_data: - stderr_text = stderr_data.decode('utf-8', errors='replace').strip() + stderr_text = stderr_data.decode("utf-8", errors="replace").strip() if stderr_text: - for line in stderr_text.split('\n'): + for line in stderr_text.split("\n"): line = line.strip() - if line and not line.startswith('CH') and not line.startswith('Elapsed'): - app_module.wifi_queue.put({'type': 'error', 'text': f'airodump-ng: {line}'}) + if line and not line.startswith("CH") and not line.startswith("Elapsed"): + app_module.wifi_queue.put({"type": "error", "text": f"airodump-ng: {line}"}) except Exception: pass current_time = time.time() if current_time - last_parse >= 2: - csv_file = csv_path + '-01.csv' + csv_file = csv_path + "-01.csv" if os.path.exists(csv_file): csv_found = True networks, clients = parse_airodump_csv(csv_file) for bssid, net in networks.items(): if bssid not in app_module.wifi_networks: - app_module.wifi_queue.put({ - 'type': 'network', - 'action': 'new', - **net - }) + app_module.wifi_queue.put({"type": "network", "action": "new", **net}) else: - app_module.wifi_queue.put({ - 'type': 'network', - 'action': 'update', - **net - }) + app_module.wifi_queue.put({"type": "network", "action": "update", **net}) for mac, client in clients.items(): if mac not in app_module.wifi_clients: - app_module.wifi_queue.put({ - 'type': 'client', - 'action': 'new', - **client - }) + app_module.wifi_queue.put({"type": "client", "action": "new", **client}) else: # Send update if probes changed or signal changed significantly old_client = app_module.wifi_clients[mac] - old_probes = old_client.get('probes', '') - new_probes = client.get('probes', '') - old_power = int(old_client.get('power', -100) or -100) - new_power = int(client.get('power', -100) or -100) + old_probes = old_client.get("probes", "") + new_probes = client.get("probes", "") + old_power = int(old_client.get("power", -100) or -100) + new_power = int(client.get("power", -100) or -100) if new_probes != old_probes or abs(new_power - old_power) >= 5: - app_module.wifi_queue.put({ - 'type': 'client', - 'action': 'update', - **client - }) + app_module.wifi_queue.put({"type": "client", "action": "update", **client}) app_module.wifi_networks = networks app_module.wifi_clients = clients last_parse = current_time if current_time - start_time > 5 and not csv_found: - app_module.wifi_queue.put({'type': 'error', 'text': 'No scan data after 5 seconds. Check if monitor mode is properly enabled.'}) + app_module.wifi_queue.put( + { + "type": "error", + "text": "No scan data after 5 seconds. Check if monitor mode is properly enabled.", + } + ) start_time = current_time + 30 time.sleep(0.5) @@ -418,59 +409,60 @@ def stream_airodump_output(process, csv_path): try: remaining_stderr = process.stderr.read() if remaining_stderr: - stderr_text = remaining_stderr.decode('utf-8', errors='replace').strip() + stderr_text = remaining_stderr.decode("utf-8", errors="replace").strip() if stderr_text: - app_module.wifi_queue.put({'type': 'error', 'text': f'airodump-ng exited: {stderr_text}'}) + app_module.wifi_queue.put({"type": "error", "text": f"airodump-ng exited: {stderr_text}"}) except Exception: pass exit_code = process.returncode if exit_code != 0 and exit_code is not None: - app_module.wifi_queue.put({'type': 'error', 'text': f'airodump-ng exited with code {exit_code}'}) + app_module.wifi_queue.put({"type": "error", "text": f"airodump-ng exited with code {exit_code}"}) except Exception as e: - app_module.wifi_queue.put({'type': 'error', 'text': str(e)}) + app_module.wifi_queue.put({"type": "error", "text": str(e)}) finally: process.wait() - app_module.wifi_queue.put({'type': 'status', 'text': 'stopped'}) + app_module.wifi_queue.put({"type": "status", "text": "stopped"}) with app_module.wifi_lock: app_module.wifi_process = None -@wifi_bp.route('/interfaces') +@wifi_bp.route("/interfaces") def get_wifi_interfaces(): """Get available WiFi interfaces.""" interfaces = detect_wifi_interfaces() tools = { - 'airmon': check_tool('airmon-ng'), - 'airodump': check_tool('airodump-ng'), - 'aireplay': check_tool('aireplay-ng'), - 'iw': check_tool('iw') + "airmon": check_tool("airmon-ng"), + "airodump": check_tool("airodump-ng"), + "aireplay": check_tool("aireplay-ng"), + "iw": check_tool("iw"), } - return jsonify({'interfaces': interfaces, 'tools': tools, 'monitor_interface': app_module.wifi_monitor_interface}) + return jsonify({"interfaces": interfaces, "tools": tools, "monitor_interface": app_module.wifi_monitor_interface}) -@wifi_bp.route('/monitor', methods=['POST']) +@wifi_bp.route("/monitor", methods=["POST"]) def toggle_monitor_mode(): """Enable or disable monitor mode on an interface.""" data = request.json - action = data.get('action', 'start') + action = data.get("action", "start") # Validate interface name to prevent command injection try: - interface = validate_network_interface(data.get('interface')) + interface = validate_network_interface(data.get("interface")) except ValueError as e: return api_error(str(e), 400) - if action == 'start': - if check_tool('airmon-ng'): + if action == "start": + if check_tool("airmon-ng"): try: + def get_wireless_interfaces(): interfaces = set() try: - result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5) - for line in result.stdout.split('\n'): - if line and not line.startswith(' ') and 'no wireless' not in line.lower(): + result = subprocess.run(["iwconfig"], capture_output=True, text=True, timeout=5) + for line in result.stdout.split("\n"): + if line and not line.startswith(" ") and "no wireless" not in line.lower(): iface = line.split()[0] if line.split() else None if iface: interfaces.add(iface) @@ -478,17 +470,17 @@ def toggle_monitor_mode(): pass try: - for iface in os.listdir('/sys/class/net'): - if os.path.exists(f'/sys/class/net/{iface}/wireless'): + for iface in os.listdir("/sys/class/net"): + if os.path.exists(f"/sys/class/net/{iface}/wireless"): interfaces.add(iface) except OSError: pass try: - result = subprocess.run(['ip', 'link', 'show'], capture_output=True, text=True, timeout=5) - for match in re.finditer(r'^\d+:\s+(\S+):', result.stdout, re.MULTILINE): - iface = match.group(1).rstrip(':') - if iface.startswith('wl') or 'mon' in iface: + result = subprocess.run(["ip", "link", "show"], capture_output=True, text=True, timeout=5) + for match in re.finditer(r"^\d+:\s+(\S+):", result.stdout, re.MULTILINE): + iface = match.group(1).rstrip(":") + if iface.startswith("wl") or "mon" in iface: interfaces.add(iface) except (subprocess.SubprocessError, OSError): pass @@ -497,13 +489,12 @@ def toggle_monitor_mode(): interfaces_before = get_wireless_interfaces() - kill_processes = data.get('kill_processes', False) - airmon_path = get_tool_path('airmon-ng') + kill_processes = data.get("kill_processes", False) + airmon_path = get_tool_path("airmon-ng") if kill_processes: - subprocess.run([airmon_path, 'check', 'kill'], capture_output=True, timeout=10) + subprocess.run([airmon_path, "check", "kill"], capture_output=True, timeout=10) - result = subprocess.run([airmon_path, 'start', interface], - capture_output=True, text=True, timeout=15) + result = subprocess.run([airmon_path, "start", interface], capture_output=True, text=True, timeout=15) output = result.stdout + result.stderr @@ -515,7 +506,7 @@ def toggle_monitor_mode(): if new_interfaces: for iface in new_interfaces: - if 'mon' in iface: + if "mon" in iface: monitor_iface = iface break if not monitor_iface: @@ -526,50 +517,51 @@ def toggle_monitor_mode(): # Interface names: start with letter, contain alphanumeric/underscore/dash patterns = [ # Look for interface names ending in 'mon' (most reliable) - r'\b([a-zA-Z][a-zA-Z0-9_-]*mon)\b', + r"\b([a-zA-Z][a-zA-Z0-9_-]*mon)\b", # Airmon-ng format: [phyX]interfacename - r'\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*mon)', + r"\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*mon)", # "enabled for/on [phyX]interface" format - r'enabled.*?\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*)', + r"enabled.*?\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*)", # Original interface with 'mon' appended - r'\b(' + re.escape(interface) + r'mon)\b', + r"\b(" + re.escape(interface) + r"mon)\b", ] for pattern in patterns: match = re.search(pattern, output, re.IGNORECASE) if match: candidate = match.group(1) # Validate it looks like an interface name (not channel info like "10)") - if candidate and not candidate[0].isdigit() and ')' not in candidate: + if candidate and not candidate[0].isdigit() and ")" not in candidate: monitor_iface = candidate break if not monitor_iface: try: - result = subprocess.run(['iwconfig', interface], capture_output=True, text=True, timeout=5) - if 'Mode:Monitor' in result.stdout: + result = subprocess.run(["iwconfig", interface], capture_output=True, text=True, timeout=5) + if "Mode:Monitor" in result.stdout: monitor_iface = interface except (subprocess.SubprocessError, OSError): pass if not monitor_iface: - potential = interface + 'mon' + potential = interface + "mon" if potential in interfaces_after: monitor_iface = potential if not monitor_iface: - monitor_iface = interface + 'mon' + monitor_iface = interface + "mon" # Verify the interface actually exists def interface_exists(iface_name): - return os.path.exists(f'/sys/class/net/{iface_name}') + return os.path.exists(f"/sys/class/net/{iface_name}") if not interface_exists(monitor_iface): # Try common naming patterns candidates = [ - interface + 'mon', - interface.replace('wlan', 'wlan') + 'mon', - 'wlan0mon', 'wlan1mon', - interface # Maybe it stayed the same but in monitor mode + interface + "mon", + interface.replace("wlan", "wlan") + "mon", + "wlan0mon", + "wlan1mon", + interface, # Maybe it stayed the same but in monitor mode ] for candidate in candidates: if interface_exists(candidate): @@ -577,69 +569,80 @@ def toggle_monitor_mode(): break else: # List all wireless interfaces to help debug - all_wireless = [f for f in os.listdir('/sys/class/net') - if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')] + all_wireless = [ + f + for f in os.listdir("/sys/class/net") + if os.path.exists(f"/sys/class/net/{f}/wireless") or "mon" in f or f.startswith("wl") + ] logger.error(f"Monitor interface not found. Tried: {monitor_iface}. Available: {all_wireless}") - return api_error(f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}') + return api_error( + f"Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}" + ) app_module.wifi_monitor_interface = monitor_iface - app_module.wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {app_module.wifi_monitor_interface}'}) + app_module.wifi_queue.put( + {"type": "info", "text": f"Monitor mode enabled on {app_module.wifi_monitor_interface}"} + ) logger.info(f"Monitor mode enabled on {monitor_iface}") - return api_success(data={'monitor_interface': app_module.wifi_monitor_interface}) + return api_success(data={"monitor_interface": app_module.wifi_monitor_interface}) except Exception as e: logger.error(f"Error enabling monitor mode: {e}", exc_info=True) return api_error(str(e)) - elif check_tool('iw'): + elif check_tool("iw"): try: - subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True) - subprocess.run(['iw', interface, 'set', 'monitor', 'control'], capture_output=True) - subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True) + subprocess.run(["ip", "link", "set", interface, "down"], capture_output=True) + subprocess.run(["iw", interface, "set", "monitor", "control"], capture_output=True) + subprocess.run(["ip", "link", "set", interface, "up"], capture_output=True) app_module.wifi_monitor_interface = interface - return api_success(data={'monitor_interface': interface}) + return api_success(data={"monitor_interface": interface}) except Exception as e: return api_error(str(e)) else: - return api_error('No monitor mode tools available.') + return api_error("No monitor mode tools available.") else: # stop - if check_tool('airmon-ng'): + if check_tool("airmon-ng"): try: - airmon_path = get_tool_path('airmon-ng') - subprocess.run([airmon_path, 'stop', app_module.wifi_monitor_interface or interface], - capture_output=True, text=True, timeout=15) + airmon_path = get_tool_path("airmon-ng") + subprocess.run( + [airmon_path, "stop", app_module.wifi_monitor_interface or interface], + capture_output=True, + text=True, + timeout=15, + ) app_module.wifi_monitor_interface = None - return api_success(message='Monitor mode disabled') + return api_success(message="Monitor mode disabled") except Exception as e: return api_error(str(e)) - elif check_tool('iw'): + elif check_tool("iw"): try: - subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True) - subprocess.run(['iw', interface, 'set', 'type', 'managed'], capture_output=True) - subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True) + subprocess.run(["ip", "link", "set", interface, "down"], capture_output=True) + subprocess.run(["iw", interface, "set", "type", "managed"], capture_output=True) + subprocess.run(["ip", "link", "set", interface, "up"], capture_output=True) app_module.wifi_monitor_interface = None - return api_success(message='Monitor mode disabled') + return api_success(message="Monitor mode disabled") except Exception as e: return api_error(str(e)) - return api_error('Unknown action') + return api_error("Unknown action") -@wifi_bp.route('/scan/start', methods=['POST']) +@wifi_bp.route("/scan/start", methods=["POST"]) def start_wifi_scan(): """Start WiFi scanning with airodump-ng.""" with app_module.wifi_lock: if app_module.wifi_process: - return api_error('Scan already running') + return api_error("Scan already running") data = request.json - channel = data.get('channel') - channels = data.get('channels') - band = data.get('band', 'abg') + channel = data.get("channel") + channels = data.get("channels") + band = data.get("band", "abg") # Use provided interface or fall back to stored monitor interface - interface = data.get('interface') + interface = data.get("interface") if interface: try: interface = validate_network_interface(interface) @@ -649,12 +652,15 @@ def start_wifi_scan(): interface = app_module.wifi_monitor_interface if not interface: - return api_error('No monitor interface available.') + return api_error("No monitor interface available.") # Verify interface exists - if not os.path.exists(f'/sys/class/net/{interface}'): - all_wireless = [f for f in os.listdir('/sys/class/net') - if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')] + if not os.path.exists(f"/sys/class/net/{interface}"): + all_wireless = [ + f + for f in os.listdir("/sys/class/net") + if os.path.exists(f"/sys/class/net/{f}/wireless") or "mon" in f or f.startswith("wl") + ] return api_error(f'Interface "{interface}" does not exist. Available: {all_wireless}') app_module.wifi_networks = {} @@ -666,13 +672,13 @@ def start_wifi_scan(): except queue.Empty: break - csv_path = '/tmp/intercept_wifi' + csv_path = "/tmp/intercept_wifi" - for f in ['/tmp/intercept_wifi-01.csv', '/tmp/intercept_wifi-01.cap']: + for f in ["/tmp/intercept_wifi-01.csv", "/tmp/intercept_wifi-01.cap"]: with contextlib.suppress(OSError): os.remove(f) - airodump_path = get_tool_path('airodump-ng') + airodump_path = get_tool_path("airodump-ng") channel_list = None if channels: @@ -683,45 +689,43 @@ def start_wifi_scan(): cmd = [ airodump_path, - '-w', csv_path, - '--output-format', 'csv,pcap', + "-w", + csv_path, + "--output-format", + "csv,pcap", ] # --band and -c are mutually exclusive: only add --band when not # locking to specific channels, and always place the interface last. if channel_list: - cmd.extend(['-c', ','.join(str(c) for c in channel_list)]) + cmd.extend(["-c", ",".join(str(c) for c in channel_list)]) elif channel: - cmd.extend(['-c', str(channel)]) + cmd.extend(["-c", str(channel)]) else: - cmd.extend(['--band', band]) + cmd.extend(["--band", band]) cmd.append(interface) logger.info(f"Running: {' '.join(cmd)}") try: - app_module.wifi_process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + app_module.wifi_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) time.sleep(0.5) if app_module.wifi_process.poll() is not None: - stderr_output = app_module.wifi_process.stderr.read().decode('utf-8', errors='replace').strip() - stdout_output = app_module.wifi_process.stdout.read().decode('utf-8', errors='replace').strip() + stderr_output = app_module.wifi_process.stderr.read().decode("utf-8", errors="replace").strip() + stdout_output = app_module.wifi_process.stdout.read().decode("utf-8", errors="replace").strip() exit_code = app_module.wifi_process.returncode app_module.wifi_process = None - error_msg = stderr_output or stdout_output or f'Process exited with code {exit_code}' - error_msg = re.sub(r'\x1b\[[0-9;]*m', '', error_msg) + error_msg = stderr_output or stdout_output or f"Process exited with code {exit_code}" + error_msg = re.sub(r"\x1b\[[0-9;]*m", "", error_msg) - if 'No such device' in error_msg or 'No such interface' in error_msg: + if "No such device" in error_msg or "No such interface" in error_msg: error_msg = f'Interface "{interface}" not found. Make sure monitor mode is enabled.' - elif 'Operation not permitted' in error_msg: - error_msg = 'Permission denied. Try running with sudo.' + elif "Operation not permitted" in error_msg: + error_msg = "Permission denied. Try running with sudo." logger.error(f"airodump-ng failed for interface '{interface}': {error_msg}") return api_error(error_msg) @@ -730,17 +734,17 @@ def start_wifi_scan(): thread.daemon = True thread.start() - app_module.wifi_queue.put({'type': 'info', 'text': f'Started scanning on {interface}'}) + app_module.wifi_queue.put({"type": "info", "text": f"Started scanning on {interface}"}) - return jsonify({'status': 'started', 'interface': interface}) + return jsonify({"status": "started", "interface": interface}) except FileNotFoundError: - return api_error('airodump-ng not found.') + return api_error("airodump-ng not found.") except Exception as e: return api_error(str(e)) -@wifi_bp.route('/scan/stop', methods=['POST']) +@wifi_bp.route("/scan/stop", methods=["POST"]) def stop_wifi_scan(): """Stop WiFi scanning.""" with app_module.wifi_lock: @@ -751,20 +755,20 @@ def stop_wifi_scan(): except subprocess.TimeoutExpired: app_module.wifi_process.kill() app_module.wifi_process = None - return jsonify({'status': 'stopped'}) - return jsonify({'status': 'not_running'}) + return jsonify({"status": "stopped"}) + return jsonify({"status": "not_running"}) -@wifi_bp.route('/deauth', methods=['POST']) +@wifi_bp.route("/deauth", methods=["POST"]) def send_deauth(): """Send deauthentication packets.""" data = request.json - target_bssid = data.get('bssid') - target_client = data.get('client', 'FF:FF:FF:FF:FF:FF') - count = data.get('count', 5) + target_bssid = data.get("bssid") + target_client = data.get("client", "FF:FF:FF:FF:FF:FF") + count = data.get("count", 5) # Validate interface - interface = data.get('interface') + interface = data.get("interface") if interface: try: interface = validate_network_interface(interface) @@ -774,13 +778,13 @@ def send_deauth(): interface = app_module.wifi_monitor_interface if not target_bssid: - return api_error('Target BSSID required') + return api_error("Target BSSID required") if not is_valid_mac(target_bssid): - return api_error('Invalid BSSID format') + return api_error("Invalid BSSID format") if not is_valid_mac(target_client): - return api_error('Invalid client MAC format') + return api_error("Invalid client MAC format") try: count = int(count) @@ -790,45 +794,39 @@ def send_deauth(): count = 5 if not interface: - return api_error('No monitor interface') + return api_error("No monitor interface") - if not check_tool('aireplay-ng'): - return api_error('aireplay-ng not found') + if not check_tool("aireplay-ng"): + return api_error("aireplay-ng not found") try: - aireplay_path = get_tool_path('aireplay-ng') - cmd = [ - aireplay_path, - '--deauth', str(count), - '-a', target_bssid, - '-c', target_client, - interface - ] + aireplay_path = get_tool_path("aireplay-ng") + cmd = [aireplay_path, "--deauth", str(count), "-a", target_bssid, "-c", target_client, interface] - app_module.wifi_queue.put({'type': 'info', 'text': f'Sending {count} deauth packets to {target_bssid}'}) + app_module.wifi_queue.put({"type": "info", "text": f"Sending {count} deauth packets to {target_bssid}"}) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode == 0: - return api_success(message=f'Sent {count} deauth packets') + return api_success(message=f"Sent {count} deauth packets") else: return api_error(result.stderr) except subprocess.TimeoutExpired: - return api_success(message='Deauth sent (timed out)') + return api_success(message="Deauth sent (timed out)") except Exception as e: return api_error(str(e)) -@wifi_bp.route('/handshake/capture', methods=['POST']) +@wifi_bp.route("/handshake/capture", methods=["POST"]) def capture_handshake(): """Start targeted handshake capture.""" data = request.json - target_bssid = data.get('bssid') - channel = data.get('channel') + target_bssid = data.get("bssid") + channel = data.get("channel") # Validate interface - interface = data.get('interface') + interface = data.get("interface") if interface: try: interface = validate_network_interface(interface) @@ -838,54 +836,58 @@ def capture_handshake(): interface = app_module.wifi_monitor_interface if not target_bssid or not channel: - return api_error('BSSID and channel required') + return api_error("BSSID and channel required") if not is_valid_mac(target_bssid): - return api_error('Invalid BSSID format') + return api_error("Invalid BSSID format") if not is_valid_channel(channel): - return api_error('Invalid channel') + return api_error("Invalid channel") with app_module.wifi_lock: if app_module.wifi_process: - return api_error('Scan already running.') + return api_error("Scan already running.") - capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}' + capture_path = f"/tmp/intercept_handshake_{target_bssid.replace(':', '')}" - airodump_path = get_tool_path('airodump-ng') + airodump_path = get_tool_path("airodump-ng") cmd = [ airodump_path, - '-c', str(channel), - '--bssid', target_bssid, - '-w', capture_path, - '--output-format', 'pcap', - interface + "-c", + str(channel), + "--bssid", + target_bssid, + "-w", + capture_path, + "--output-format", + "pcap", + interface, ] try: app_module.wifi_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - app_module.wifi_queue.put({'type': 'info', 'text': f'Capturing handshakes for {target_bssid}'}) - return jsonify({'status': 'started', 'capture_file': capture_path + '-01.cap'}) + app_module.wifi_queue.put({"type": "info", "text": f"Capturing handshakes for {target_bssid}"}) + return jsonify({"status": "started", "capture_file": capture_path + "-01.cap"}) except Exception as e: return api_error(str(e)) -@wifi_bp.route('/handshake/status', methods=['POST']) +@wifi_bp.route("/handshake/status", methods=["POST"]) def check_handshake_status(): """Check if a handshake has been captured.""" data = request.json - capture_file = data.get('file', '') - target_bssid = data.get('bssid', '') + capture_file = data.get("file", "") + target_bssid = data.get("bssid", "") - if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file: - return api_error('Invalid capture file path') + if not capture_file.startswith("/tmp/intercept_handshake_") or ".." in capture_file: + return api_error("Invalid capture file path") if not os.path.exists(capture_file): with app_module.wifi_lock: if app_module.wifi_process and app_module.wifi_process.poll() is None: - return jsonify({'status': 'running', 'file_exists': False, 'handshake_found': False}) + return jsonify({"status": "running", "file_exists": False, "handshake_found": False}) else: - return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False}) + return jsonify({"status": "stopped", "file_exists": False, "handshake_found": False}) file_size = os.path.getsize(capture_file) handshake_found = False @@ -895,22 +897,24 @@ def check_handshake_status(): try: if target_bssid and is_valid_mac(target_bssid): - aircrack_path = get_tool_path('aircrack-ng') + aircrack_path = get_tool_path("aircrack-ng") if aircrack_path: result = subprocess.run( - [aircrack_path, '-a', '2', '-b', target_bssid, capture_file], - capture_output=True, text=True, timeout=10 + [aircrack_path, "-a", "2", "-b", target_bssid, capture_file], + capture_output=True, + text=True, + timeout=10, ) output = result.stdout + result.stderr output_lower = output.lower() handshake_checked = True - if 'no valid wpa handshakes found' in output_lower: + if "no valid wpa handshakes found" in output_lower: handshake_valid = False - handshake_reason = 'No valid WPA handshake found' - elif '0 handshake' in output_lower: + handshake_reason = "No valid WPA handshake found" + elif "0 handshake" in output_lower: handshake_valid = False - elif '1 handshake' in output_lower or ('handshake' in output_lower and 'wpa' in output_lower): + elif "1 handshake" in output_lower or ("handshake" in output_lower and "wpa" in output_lower): handshake_valid = True else: handshake_valid = False @@ -925,29 +929,31 @@ def check_handshake_status(): if normalized_bssid and normalized_bssid not in app_module.wifi_handshakes: app_module.wifi_handshakes.append(normalized_bssid) - return jsonify({ - 'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped', - 'file_exists': True, - 'file_size': file_size, - 'file': capture_file, - 'handshake_found': handshake_found, - 'handshake_valid': handshake_valid, - 'handshake_checked': handshake_checked, - 'handshake_reason': handshake_reason - }) + return jsonify( + { + "status": "running" if app_module.wifi_process and app_module.wifi_process.poll() is None else "stopped", + "file_exists": True, + "file_size": file_size, + "file": capture_file, + "handshake_found": handshake_found, + "handshake_valid": handshake_valid, + "handshake_checked": handshake_checked, + "handshake_reason": handshake_reason, + } + ) -@wifi_bp.route('/pmkid/capture', methods=['POST']) +@wifi_bp.route("/pmkid/capture", methods=["POST"]) def capture_pmkid(): """Start PMKID capture using hcxdumptool.""" global pmkid_process data = request.json - target_bssid = data.get('bssid') - channel = data.get('channel') + target_bssid = data.get("bssid") + channel = data.get("channel") # Validate interface - interface = data.get('interface') + interface = data.get("interface") if interface: try: interface = validate_network_interface(interface) @@ -957,62 +963,64 @@ def capture_pmkid(): interface = app_module.wifi_monitor_interface if not target_bssid: - return api_error('BSSID required') + return api_error("BSSID required") if not is_valid_mac(target_bssid): - return api_error('Invalid BSSID format') + return api_error("Invalid BSSID format") with pmkid_lock: if pmkid_process and pmkid_process.poll() is None: - return api_error('PMKID capture already running') + return api_error("PMKID capture already running") - capture_path = f'/tmp/intercept_pmkid_{target_bssid.replace(":", "")}.pcapng' - filter_file = f'/tmp/pmkid_filter_{target_bssid.replace(":", "")}' - with open(filter_file, 'w') as f: - f.write(target_bssid.replace(':', '').lower()) + capture_path = f"/tmp/intercept_pmkid_{target_bssid.replace(':', '')}.pcapng" + filter_file = f"/tmp/pmkid_filter_{target_bssid.replace(':', '')}" + with open(filter_file, "w") as f: + f.write(target_bssid.replace(":", "").lower()) cmd = [ - 'hcxdumptool', - '-i', interface, - '-o', capture_path, - '--filterlist_ap', filter_file, - '--filtermode', '2', - '--enable_status', '1' + "hcxdumptool", + "-i", + interface, + "-o", + capture_path, + "--filterlist_ap", + filter_file, + "--filtermode", + "2", + "--enable_status", + "1", ] if channel: - cmd.extend(['-c', str(channel)]) + cmd.extend(["-c", str(channel)]) try: pmkid_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - return jsonify({'status': 'started', 'file': capture_path}) + return jsonify({"status": "started", "file": capture_path}) except FileNotFoundError: - return api_error('hcxdumptool not found.') + return api_error("hcxdumptool not found.") except Exception as e: return api_error(str(e)) -@wifi_bp.route('/pmkid/status', methods=['POST']) +@wifi_bp.route("/pmkid/status", methods=["POST"]) def check_pmkid_status(): """Check if PMKID has been captured.""" data = request.json - capture_file = data.get('file', '') + capture_file = data.get("file", "") - if not capture_file.startswith('/tmp/intercept_pmkid_') or '..' in capture_file: - return api_error('Invalid capture file path') + if not capture_file.startswith("/tmp/intercept_pmkid_") or ".." in capture_file: + return api_error("Invalid capture file path") if not os.path.exists(capture_file): - return jsonify({'pmkid_found': False, 'file_exists': False}) + return jsonify({"pmkid_found": False, "file_exists": False}) file_size = os.path.getsize(capture_file) pmkid_found = False try: - hash_file = capture_file.replace('.pcapng', '.22000') - subprocess.run( - ['hcxpcapngtool', '-o', hash_file, capture_file], - capture_output=True, text=True, timeout=10 - ) + hash_file = capture_file.replace(".pcapng", ".22000") + subprocess.run(["hcxpcapngtool", "-o", hash_file, capture_file], capture_output=True, text=True, timeout=10) if os.path.exists(hash_file) and os.path.getsize(hash_file) > 0: pmkid_found = True except FileNotFoundError: @@ -1020,15 +1028,10 @@ def check_pmkid_status(): except Exception: pass - return jsonify({ - 'pmkid_found': pmkid_found, - 'file_exists': True, - 'file_size': file_size, - 'file': capture_file - }) + return jsonify({"pmkid_found": pmkid_found, "file_exists": True, "file_size": file_size, "file": capture_file}) -@wifi_bp.route('/pmkid/stop', methods=['POST']) +@wifi_bp.route("/pmkid/stop", methods=["POST"]) def stop_pmkid(): """Stop PMKID capture.""" global pmkid_process @@ -1042,41 +1045,41 @@ def stop_pmkid(): pmkid_process.kill() pmkid_process = None - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) -@wifi_bp.route('/handshake/crack', methods=['POST']) +@wifi_bp.route("/handshake/crack", methods=["POST"]) def crack_handshake(): """Crack a captured handshake using aircrack-ng.""" data = request.json - capture_file = data.get('capture_file', '') - target_bssid = data.get('bssid', '') - wordlist = data.get('wordlist', '') + capture_file = data.get("capture_file", "") + target_bssid = data.get("bssid", "") + wordlist = data.get("wordlist", "") # Validate paths to prevent path traversal - if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file: - return api_error('Invalid capture file path', 400) + if not capture_file.startswith("/tmp/intercept_handshake_") or ".." in capture_file: + return api_error("Invalid capture file path", 400) - if '..' in wordlist: - return api_error('Invalid wordlist path', 400) + if ".." in wordlist: + return api_error("Invalid wordlist path", 400) if not os.path.exists(capture_file): - return api_error('Capture file not found', 404) + return api_error("Capture file not found", 404) if not os.path.exists(wordlist): - return api_error('Wordlist file not found', 404) + return api_error("Wordlist file not found", 404) if target_bssid and not is_valid_mac(target_bssid): - return api_error('Invalid BSSID format', 400) + return api_error("Invalid BSSID format", 400) - aircrack_path = get_tool_path('aircrack-ng') + aircrack_path = get_tool_path("aircrack-ng") if not aircrack_path: - return api_error('aircrack-ng not found', 500) + return api_error("aircrack-ng not found", 500) try: - cmd = [aircrack_path, '-a', '2', '-w', wordlist] + cmd = [aircrack_path, "-a", "2", "-w", wordlist] if target_bssid: - cmd.extend(['-b', target_bssid]) + cmd.extend(["-b", target_bssid]) cmd.append(capture_file) logger.info(f"Starting aircrack-ng: {' '.join(cmd)}") @@ -1086,71 +1089,71 @@ def crack_handshake(): cmd, capture_output=True, text=True, - timeout=300 # 5 minute timeout + timeout=300, # 5 minute timeout ) output = result.stdout + result.stderr # Check if password was found # Aircrack-ng outputs "KEY FOUND! [ password ]" when successful - if 'KEY FOUND!' in output: + if "KEY FOUND!" in output: # Extract the password import re - match = re.search(r'KEY FOUND!\s*\[\s*(.+?)\s*\]', output) + + match = re.search(r"KEY FOUND!\s*\[\s*(.+?)\s*\]", output) if match: password = match.group(1) logger.info(f"Password cracked for {target_bssid}: {password}") - return api_success(data={ - 'password': password, - 'bssid': target_bssid - }) + return api_success(data={"password": password, "bssid": target_bssid}) # Password not found - return jsonify({ - 'status': 'not_found', - 'message': 'Password not in wordlist' - }) + return jsonify({"status": "not_found", "message": "Password not in wordlist"}) except subprocess.TimeoutExpired: - return jsonify({ - 'status': 'timeout', - 'message': 'Cracking timed out after 5 minutes. Try a smaller wordlist or use hashcat.' - }) + return jsonify( + { + "status": "timeout", + "message": "Cracking timed out after 5 minutes. Try a smaller wordlist or use hashcat.", + } + ) except Exception as e: logger.error(f"Crack error: {e}") return api_error(str(e), 500) -@wifi_bp.route('/networks') +@wifi_bp.route("/networks") def get_wifi_networks(): """Get current list of discovered networks.""" - return jsonify({ - 'networks': list(app_module.wifi_networks.values()), - 'clients': list(app_module.wifi_clients.values()), - 'handshakes': app_module.wifi_handshakes, - 'monitor_interface': app_module.wifi_monitor_interface - }) + return jsonify( + { + "networks": list(app_module.wifi_networks.values()), + "clients": list(app_module.wifi_clients.values()), + "handshakes": app_module.wifi_handshakes, + "monitor_interface": app_module.wifi_monitor_interface, + } + ) -@wifi_bp.route('/stream') +@wifi_bp.route("/stream") def stream_wifi(): """SSE stream for WiFi events.""" + def _on_msg(msg: dict[str, Any]) -> None: - process_event('wifi', msg, msg.get('type')) + process_event("wifi", msg, msg.get("type")) response = Response( sse_stream_fanout( source_queue=app_module.wifi_queue, - channel_key='wifi', + channel_key="wifi", timeout=1.0, keepalive_interval=30.0, on_message=_on_msg, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response @@ -1161,138 +1164,148 @@ def stream_wifi(): from utils.wifi.scanner import get_wifi_scanner -@wifi_bp.route('/v2/capabilities') +@wifi_bp.route("/v2/capabilities") def get_v2_capabilities(): """Get WiFi scanning capabilities on this system.""" try: scanner = get_wifi_scanner() caps = scanner.check_capabilities() - return jsonify({ - 'platform': caps.platform, - 'is_root': caps.is_root, - 'can_quick_scan': caps.can_quick_scan, - 'can_deep_scan': caps.can_deep_scan, - 'preferred_quick_tool': caps.preferred_quick_tool, - 'interfaces': caps.interfaces, - 'default_interface': caps.default_interface, - 'has_monitor_capable_interface': caps.has_monitor_capable_interface, - 'monitor_interface': caps.monitor_interface, - 'issues': caps.issues, - 'tools': { - 'nmcli': caps.has_nmcli, - 'iw': caps.has_iw, - 'iwlist': caps.has_iwlist, - 'airport': caps.has_airport, - 'airmon_ng': caps.has_airmon_ng, - 'airodump_ng': caps.has_airodump_ng, - }, - }) + return jsonify( + { + "platform": caps.platform, + "is_root": caps.is_root, + "can_quick_scan": caps.can_quick_scan, + "can_deep_scan": caps.can_deep_scan, + "preferred_quick_tool": caps.preferred_quick_tool, + "interfaces": caps.interfaces, + "default_interface": caps.default_interface, + "has_monitor_capable_interface": caps.has_monitor_capable_interface, + "monitor_interface": caps.monitor_interface, + "issues": caps.issues, + "tools": { + "nmcli": caps.has_nmcli, + "iw": caps.has_iw, + "iwlist": caps.has_iwlist, + "airport": caps.has_airport, + "airmon_ng": caps.has_airmon_ng, + "airodump_ng": caps.has_airodump_ng, + }, + } + ) except Exception as e: logger.exception("Error checking capabilities") return api_error(str(e), 500) -@wifi_bp.route('/v2/scan/quick', methods=['POST']) +@wifi_bp.route("/v2/scan/quick", methods=["POST"]) def v2_quick_scan(): """Perform a quick one-shot WiFi scan using system tools.""" try: data = request.json or {} - interface = data.get('interface') - timeout = data.get('timeout', 10.0) + interface = data.get("interface") + timeout = data.get("timeout", 10.0) scanner = get_wifi_scanner() result = scanner.quick_scan(interface=interface, timeout=timeout) if result.error: - return jsonify({ - 'error': result.error, - 'access_points': [], - 'channel_stats': [], - 'recommendations': [], - }), 200 # Return 200 with error in body for cleaner handling + return jsonify( + { + "error": result.error, + "access_points": [], + "channel_stats": [], + "recommendations": [], + } + ), 200 # Return 200 with error in body for cleaner handling - return jsonify({ - 'access_points': [ap.to_summary_dict() for ap in result.access_points], - 'channel_stats': [s.to_dict() for s in result.channel_stats], - 'recommendations': [r.to_dict() for r in result.recommendations], - 'duration_seconds': result.duration_seconds, - 'warnings': result.warnings, - }) + return jsonify( + { + "access_points": [ap.to_summary_dict() for ap in result.access_points], + "channel_stats": [s.to_dict() for s in result.channel_stats], + "recommendations": [r.to_dict() for r in result.recommendations], + "duration_seconds": result.duration_seconds, + "warnings": result.warnings, + } + ) except Exception as e: logger.exception("Error in quick scan") return api_error(str(e), 500) -@wifi_bp.route('/v2/scan/start', methods=['POST']) +@wifi_bp.route("/v2/scan/start", methods=["POST"]) def v2_start_scan(): """Start continuous deep scan with airodump-ng.""" try: data = request.json or {} - interface = data.get('interface') - band = data.get('band', 'all') - channel = data.get('channel') + interface = data.get("interface") + band = data.get("band", "all") + channel = data.get("channel") scanner = get_wifi_scanner() success = scanner.start_deep_scan(interface=interface, band=band, channel=channel) if success: - return jsonify({'status': 'started'}) + return jsonify({"status": "started"}) else: status = scanner.get_status() - return api_error(status.error or 'Failed to start scan', 400) + return api_error(status.error or "Failed to start scan", 400) except Exception as e: logger.exception("Error starting deep scan") return api_error(str(e), 500) -@wifi_bp.route('/v2/scan/stop', methods=['POST']) +@wifi_bp.route("/v2/scan/stop", methods=["POST"]) def v2_stop_scan(): """Stop the current scan.""" try: scanner = get_wifi_scanner() scanner.stop_deep_scan() - return jsonify({'status': 'stopped'}) + return jsonify({"status": "stopped"}) except Exception as e: logger.exception("Error stopping scan") return api_error(str(e), 500) -@wifi_bp.route('/v2/scan/status') +@wifi_bp.route("/v2/scan/status") def v2_scan_status(): """Get current scan status.""" try: scanner = get_wifi_scanner() status = scanner.get_status() - return jsonify({ - 'is_scanning': status.is_scanning, - 'scan_mode': status.scan_mode, - 'interface': status.interface, - 'started_at': status.started_at.isoformat() if status.started_at else None, - 'networks_found': status.networks_found, - 'clients_found': status.clients_found, - 'error': status.error, - }) + return jsonify( + { + "is_scanning": status.is_scanning, + "scan_mode": status.scan_mode, + "interface": status.interface, + "started_at": status.started_at.isoformat() if status.started_at else None, + "networks_found": status.networks_found, + "clients_found": status.clients_found, + "error": status.error, + } + ) except Exception as e: logger.exception("Error getting scan status") return api_error(str(e), 500) -@wifi_bp.route('/v2/networks') +@wifi_bp.route("/v2/networks") def v2_get_networks(): """Get all discovered networks.""" try: scanner = get_wifi_scanner() networks = scanner.access_points - return jsonify({ - 'networks': [ap.to_summary_dict() for ap in networks], - 'total': len(networks), - }) + return jsonify( + { + "networks": [ap.to_summary_dict() for ap in networks], + "total": len(networks), + } + ) except Exception as e: logger.exception("Error getting networks") return api_error(str(e), 500) -@wifi_bp.route('/v2/clients') +@wifi_bp.route("/v2/clients") def v2_get_clients(): """Get discovered clients with optional filtering.""" try: @@ -1300,19 +1313,19 @@ def v2_get_clients(): clients = scanner.clients # Filter by association status - associated = request.args.get('associated') - if associated == 'true': + associated = request.args.get("associated") + if associated == "true": clients = [c for c in clients if c.is_associated] - elif associated == 'false': + elif associated == "false": clients = [c for c in clients if not c.is_associated] # Filter by associated BSSID - bssid = request.args.get('bssid') + bssid = request.args.get("bssid") if bssid: clients = [c for c in clients if c.associated_bssid == bssid.upper()] # Filter by minimum RSSI - min_rssi = request.args.get('min_rssi') + min_rssi = request.args.get("min_rssi") if min_rssi: try: min_rssi = int(min_rssi) @@ -1320,87 +1333,94 @@ def v2_get_clients(): except ValueError: pass - return jsonify({ - 'clients': [c.to_dict() for c in clients], - 'total': len(clients), - }) + return jsonify( + { + "clients": [c.to_dict() for c in clients], + "total": len(clients), + } + ) except Exception as e: logger.exception("Error getting clients") return api_error(str(e), 500) -@wifi_bp.route('/v2/probes') +@wifi_bp.route("/v2/probes") def v2_get_probes(): """Get probe requests.""" try: scanner = get_wifi_scanner() probes = scanner.probe_requests - return jsonify({ - 'probes': [p.to_dict() for p in probes[-100:]], # Last 100 - 'total': len(probes), - }) + return jsonify( + { + "probes": [p.to_dict() for p in probes[-100:]], # Last 100 + "total": len(probes), + } + ) except Exception as e: logger.exception("Error getting probes") return api_error(str(e), 500) -@wifi_bp.route('/v2/channels') +@wifi_bp.route("/v2/channels") def v2_get_channels(): """Get channel statistics and recommendations.""" try: scanner = get_wifi_scanner() stats = scanner._calculate_channel_stats() recommendations = scanner._generate_recommendations(stats) - return jsonify({ - 'channel_stats': [s.to_dict() for s in stats], - 'recommendations': [r.to_dict() for r in recommendations], - }) + return jsonify( + { + "channel_stats": [s.to_dict() for s in stats], + "recommendations": [r.to_dict() for r in recommendations], + } + ) except Exception as e: logger.exception("Error getting channel stats") return api_error(str(e), 500) -@wifi_bp.route('/v2/stream') +@wifi_bp.route("/v2/stream") def v2_stream(): """SSE stream for real-time WiFi events.""" + def generate(): scanner = get_wifi_scanner() for event in scanner.get_event_stream(): yield format_sse(event) - response = Response(generate(), mimetype='text/event-stream') - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + 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 -@wifi_bp.route('/v2/export') +@wifi_bp.route("/v2/export") def v2_export(): """Export scan data as CSV or JSON.""" try: - format_type = request.args.get('format', 'json') - data_type = request.args.get('type', 'all') + format_type = request.args.get("format", "json") + data_type = request.args.get("type", "all") scanner = get_wifi_scanner() - if format_type == 'json': + if format_type == "json": data = {} - if data_type in ('all', 'networks'): - data['networks'] = [ap.to_summary_dict() for ap in scanner.access_points] - if data_type in ('all', 'clients'): - data['clients'] = [c.to_dict() for c in scanner.clients] - if data_type in ('all', 'probes'): - data['probes'] = [p.to_dict() for p in scanner.probe_requests] + if data_type in ("all", "networks"): + data["networks"] = [ap.to_summary_dict() for ap in scanner.access_points] + if data_type in ("all", "clients"): + data["clients"] = [c.to_dict() for c in scanner.clients] + if data_type in ("all", "probes"): + data["probes"] = [p.to_dict() for p in scanner.probe_requests] response = Response( json.dumps(data, indent=2, default=str), - mimetype='application/json', + mimetype="application/json", ) - response.headers['Content-Disposition'] = 'attachment; filename=wifi_scan.json' + response.headers["Content-Disposition"] = "attachment; filename=wifi_scan.json" return response - elif format_type == 'csv': + elif format_type == "csv": import csv import io @@ -1408,84 +1428,101 @@ def v2_export(): writer = csv.writer(output) # Write networks - writer.writerow(['Networks']) - writer.writerow(['BSSID', 'ESSID', 'Channel', 'Band', 'RSSI', 'Security', 'Vendor', 'Clients', 'First Seen', 'Last Seen']) + writer.writerow(["Networks"]) + writer.writerow( + [ + "BSSID", + "ESSID", + "Channel", + "Band", + "RSSI", + "Security", + "Vendor", + "Clients", + "First Seen", + "Last Seen", + ] + ) for ap in scanner.access_points: - writer.writerow([ - ap.bssid, - ap.essid or '[Hidden]', - ap.channel, - ap.band, - ap.rssi_current, - ap.security, - ap.vendor, - ap.client_count, - ap.first_seen.isoformat() if ap.first_seen else '', - ap.last_seen.isoformat() if ap.last_seen else '', - ]) + writer.writerow( + [ + ap.bssid, + ap.essid or "[Hidden]", + ap.channel, + ap.band, + ap.rssi_current, + ap.security, + ap.vendor, + ap.client_count, + ap.first_seen.isoformat() if ap.first_seen else "", + ap.last_seen.isoformat() if ap.last_seen else "", + ] + ) writer.writerow([]) # Write clients - writer.writerow(['Clients']) - writer.writerow(['MAC', 'BSSID', 'Vendor', 'RSSI', 'Probed SSIDs', 'First Seen', 'Last Seen']) + writer.writerow(["Clients"]) + writer.writerow(["MAC", "BSSID", "Vendor", "RSSI", "Probed SSIDs", "First Seen", "Last Seen"]) for c in scanner.clients: - writer.writerow([ - c.mac, - c.associated_bssid or '', - c.vendor, - c.rssi_current, - ', '.join(c.probed_ssids), - c.first_seen.isoformat() if c.first_seen else '', - c.last_seen.isoformat() if c.last_seen else '', - ]) + writer.writerow( + [ + c.mac, + c.associated_bssid or "", + c.vendor, + c.rssi_current, + ", ".join(c.probed_ssids), + c.first_seen.isoformat() if c.first_seen else "", + c.last_seen.isoformat() if c.last_seen else "", + ] + ) response = Response( output.getvalue(), - mimetype='text/csv', + mimetype="text/csv", ) - response.headers['Content-Disposition'] = 'attachment; filename=wifi_scan.csv' + response.headers["Content-Disposition"] = "attachment; filename=wifi_scan.csv" return response else: - return api_error(f'Unknown format: {format_type}', 400) + return api_error(f"Unknown format: {format_type}", 400) except Exception as e: logger.exception("Error exporting data") return api_error(str(e), 500) -@wifi_bp.route('/v2/baseline/set', methods=['POST']) +@wifi_bp.route("/v2/baseline/set", methods=["POST"]) def v2_set_baseline(): """Set current networks as baseline.""" try: scanner = get_wifi_scanner() scanner.set_baseline() - return jsonify({'status': 'baseline_set', 'count': len(scanner._baseline_networks)}) + return jsonify({"status": "baseline_set", "count": len(scanner._baseline_networks)}) except Exception as e: logger.exception("Error setting baseline") return api_error(str(e), 500) -@wifi_bp.route('/v2/baseline/clear', methods=['POST']) +@wifi_bp.route("/v2/baseline/clear", methods=["POST"]) def v2_clear_baseline(): """Clear the baseline.""" try: scanner = get_wifi_scanner() scanner.clear_baseline() - return jsonify({'status': 'baseline_cleared'}) + return jsonify({"status": "baseline_cleared"}) except Exception as e: logger.exception("Error clearing baseline") return api_error(str(e), 500) -@wifi_bp.route('/v2/clear', methods=['POST']) +@wifi_bp.route("/v2/clear", methods=["POST"]) def v2_clear_data(): """Clear all discovered data.""" try: scanner = get_wifi_scanner() scanner.clear_data() - return jsonify({'status': 'cleared'}) + return jsonify({"status": "cleared"}) except Exception as e: logger.exception("Error clearing data") return api_error(str(e), 500) @@ -1495,7 +1532,8 @@ def v2_clear_data(): # V2 Deauth Detection Endpoints # ============================================================================= -@wifi_bp.route('/v2/deauth/status') + +@wifi_bp.route("/v2/deauth/status") def v2_deauth_status(): """ Get deauth detection status and recent alerts. @@ -1515,30 +1553,32 @@ def v2_deauth_status(): alerts = detector.get_alerts(limit=50) else: stats = { - 'is_running': False, - 'interface': None, - 'packets_captured': 0, - 'alerts_generated': 0, + "is_running": False, + "interface": None, + "packets_captured": 0, + "alerts_generated": 0, } alerts = [] - return jsonify({ - 'is_running': stats.get('is_running', False), - 'interface': stats.get('interface'), - 'started_at': stats.get('started_at'), - 'stats': { - 'packets_captured': stats.get('packets_captured', 0), - 'alerts_generated': stats.get('alerts_generated', 0), - 'active_trackers': stats.get('active_trackers', 0), - }, - 'recent_alerts': alerts, - }) + return jsonify( + { + "is_running": stats.get("is_running", False), + "interface": stats.get("interface"), + "started_at": stats.get("started_at"), + "stats": { + "packets_captured": stats.get("packets_captured", 0), + "alerts_generated": stats.get("alerts_generated", 0), + "active_trackers": stats.get("active_trackers", 0), + }, + "recent_alerts": alerts, + } + ) except Exception as e: logger.exception("Error getting deauth status") return api_error(str(e), 500) -@wifi_bp.route('/v2/deauth/stream') +@wifi_bp.route("/v2/deauth/stream") def v2_deauth_stream(): """ SSE stream for real-time deauth alerts. @@ -1553,19 +1593,19 @@ def v2_deauth_stream(): response = Response( sse_stream_fanout( source_queue=app_module.deauth_detector_queue, - channel_key='wifi_deauth', + channel_key="wifi_deauth", timeout=SSE_QUEUE_TIMEOUT, keepalive_interval=SSE_KEEPALIVE_INTERVAL, ), - mimetype='text/event-stream', + mimetype="text/event-stream", ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + response.headers["Connection"] = "keep-alive" return response -@wifi_bp.route('/v2/deauth/alerts') +@wifi_bp.route("/v2/deauth/alerts") def v2_deauth_alerts(): """ Get historical deauth alerts. @@ -1574,7 +1614,7 @@ def v2_deauth_alerts(): - limit: Maximum number of alerts to return (default 100) """ try: - limit = request.args.get('limit', 100, type=int) + limit = request.args.get("limit", 100, type=int) limit = max(1, min(limit, 1000)) # Clamp between 1 and 1000 scanner = get_wifi_scanner() @@ -1584,26 +1624,28 @@ def v2_deauth_alerts(): try: stored_alerts = list(app_module.deauth_alerts.values()) # Merge and deduplicate by ID - alert_ids = {a.get('id') for a in alerts} + alert_ids = {a.get("id") for a in alerts} for alert in stored_alerts: - if alert.get('id') not in alert_ids: + if alert.get("id") not in alert_ids: alerts.append(alert) # Sort by timestamp descending - alerts.sort(key=lambda a: a.get('timestamp', 0), reverse=True) + alerts.sort(key=lambda a: a.get("timestamp", 0), reverse=True) alerts = alerts[:limit] except Exception: pass - return jsonify({ - 'alerts': alerts, - 'count': len(alerts), - }) + return jsonify( + { + "alerts": alerts, + "count": len(alerts), + } + ) except Exception as e: logger.exception("Error getting deauth alerts") return api_error(str(e), 500) -@wifi_bp.route('/v2/deauth/clear', methods=['POST']) +@wifi_bp.route("/v2/deauth/clear", methods=["POST"]) def v2_deauth_clear(): """Clear deauth alert history.""" try: @@ -1617,7 +1659,7 @@ def v2_deauth_clear(): except queue.Empty: break - return jsonify({'status': 'cleared'}) + return jsonify({"status": "cleared"}) except Exception as e: logger.exception("Error clearing deauth alerts") return api_error(str(e), 500) diff --git a/semver.py b/semver.py index d1a5eb1..cae5efc 100644 --- a/semver.py +++ b/semver.py @@ -137,7 +137,7 @@ def match(version: str | VersionInfo, expr: str) -> bool: expression = str(expr).strip() for operator in ("<=", ">=", "==", "!=", "<", ">"): if expression.startswith(operator): - other = parse(expression[len(operator):].strip()) + other = parse(expression[len(operator) :].strip()) result = compare(version_info, other) return { "<": result < 0, diff --git a/tests/mock_agent.py b/tests/mock_agent.py index 4a27753..528d17c 100644 --- a/tests/mock_agent.py +++ b/tests/mock_agent.py @@ -27,98 +27,109 @@ running_modes: set[str] = set() start_time = time.time() agent_name = "mock-agent-1" + # Simulated data generators def generate_aircraft() -> list[dict]: """Generate fake ADS-B aircraft data.""" aircraft = [] for _ in range(random.randint(3, 10)): - icao = ''.join(random.choices(string.hexdigits.upper()[:6], k=6)) - callsign = random.choice(['UAL', 'DAL', 'AAL', 'SWA', 'JBU']) + str(random.randint(100, 9999)) - aircraft.append({ - 'icao': icao, - 'callsign': callsign, - 'altitude': random.randint(5000, 45000), - 'speed': random.randint(200, 550), - 'heading': random.randint(0, 359), - 'lat': round(40.0 + random.uniform(-2, 2), 4), - 'lon': round(-74.0 + random.uniform(-2, 2), 4), - 'vertical_rate': random.randint(-2000, 2000), - 'squawk': str(random.randint(1000, 7777)), - 'last_seen': datetime.now(timezone.utc).isoformat() - }) + icao = "".join(random.choices(string.hexdigits.upper()[:6], k=6)) + callsign = random.choice(["UAL", "DAL", "AAL", "SWA", "JBU"]) + str(random.randint(100, 9999)) + aircraft.append( + { + "icao": icao, + "callsign": callsign, + "altitude": random.randint(5000, 45000), + "speed": random.randint(200, 550), + "heading": random.randint(0, 359), + "lat": round(40.0 + random.uniform(-2, 2), 4), + "lon": round(-74.0 + random.uniform(-2, 2), 4), + "vertical_rate": random.randint(-2000, 2000), + "squawk": str(random.randint(1000, 7777)), + "last_seen": datetime.now(timezone.utc).isoformat(), + } + ) return aircraft def generate_sensors() -> list[dict]: """Generate fake 433MHz sensor data.""" sensors = [] - models = ['Acurite-Tower', 'Oregon-THGR122N', 'LaCrosse-TX141W', 'Ambient-F007TH'] + models = ["Acurite-Tower", "Oregon-THGR122N", "LaCrosse-TX141W", "Ambient-F007TH"] for _i in range(random.randint(2, 5)): - sensors.append({ - 'time': datetime.now(timezone.utc).isoformat(), - 'model': random.choice(models), - 'id': random.randint(1, 255), - 'channel': random.randint(1, 3), - 'temperature_C': round(random.uniform(-10, 35), 1), - 'humidity': random.randint(20, 95), - 'battery_ok': random.choice([0, 1]) - }) + sensors.append( + { + "time": datetime.now(timezone.utc).isoformat(), + "model": random.choice(models), + "id": random.randint(1, 255), + "channel": random.randint(1, 3), + "temperature_C": round(random.uniform(-10, 35), 1), + "humidity": random.randint(20, 95), + "battery_ok": random.choice([0, 1]), + } + ) return sensors def generate_wifi_networks() -> list[dict]: """Generate fake WiFi network data.""" networks = [] - ssids = ['HomeNetwork', 'Linksys', 'NETGEAR', 'xfinitywifi', 'ATT-WIFI', 'CoffeeShop-Guest'] + ssids = ["HomeNetwork", "Linksys", "NETGEAR", "xfinitywifi", "ATT-WIFI", "CoffeeShop-Guest"] for ssid in random.sample(ssids, random.randint(3, 6)): - bssid = ':'.join([f'{random.randint(0, 255):02X}' for _ in range(6)]) - networks.append({ - 'ssid': ssid, - 'bssid': bssid, - 'channel': random.choice([1, 6, 11, 36, 40, 44, 48]), - 'signal': random.randint(-80, -30), - 'encryption': random.choice(['WPA2', 'WPA3', 'WEP', 'Open']), - 'clients': random.randint(0, 10), - 'last_seen': datetime.now(timezone.utc).isoformat() - }) + bssid = ":".join([f"{random.randint(0, 255):02X}" for _ in range(6)]) + networks.append( + { + "ssid": ssid, + "bssid": bssid, + "channel": random.choice([1, 6, 11, 36, 40, 44, 48]), + "signal": random.randint(-80, -30), + "encryption": random.choice(["WPA2", "WPA3", "WEP", "Open"]), + "clients": random.randint(0, 10), + "last_seen": datetime.now(timezone.utc).isoformat(), + } + ) return networks def generate_bluetooth_devices() -> list[dict]: """Generate fake Bluetooth device data.""" devices = [] - names = ['iPhone', 'Galaxy S21', 'AirPods', 'Tile Tracker', 'Fitbit', 'Unknown'] + names = ["iPhone", "Galaxy S21", "AirPods", "Tile Tracker", "Fitbit", "Unknown"] for _ in range(random.randint(2, 8)): - mac = ':'.join([f'{random.randint(0, 255):02X}' for _ in range(6)]) - devices.append({ - 'address': mac, - 'name': random.choice(names), - 'rssi': random.randint(-90, -40), - 'type': random.choice(['LE', 'Classic', 'Dual']), - 'manufacturer': random.choice(['Apple', 'Samsung', 'Unknown']), - 'last_seen': datetime.now(timezone.utc).isoformat() - }) + mac = ":".join([f"{random.randint(0, 255):02X}" for _ in range(6)]) + devices.append( + { + "address": mac, + "name": random.choice(names), + "rssi": random.randint(-90, -40), + "type": random.choice(["LE", "Classic", "Dual"]), + "manufacturer": random.choice(["Apple", "Samsung", "Unknown"]), + "last_seen": datetime.now(timezone.utc).isoformat(), + } + ) return devices def generate_vessels() -> list[dict]: """Generate fake AIS vessel data.""" vessels = [] - vessel_names = ['EVERGREEN', 'MAERSK WINNER', 'OOCL HONG KONG', 'MSC GULSUN', 'CMA CGM MARCO POLO'] + vessel_names = ["EVERGREEN", "MAERSK WINNER", "OOCL HONG KONG", "MSC GULSUN", "CMA CGM MARCO POLO"] for name in random.sample(vessel_names, random.randint(2, 4)): mmsi = str(random.randint(200000000, 800000000)) - vessels.append({ - 'mmsi': mmsi, - 'name': name, - 'callsign': ''.join(random.choices(string.ascii_uppercase, k=5)), - 'ship_type': random.choice(['Cargo', 'Tanker', 'Passenger', 'Fishing']), - 'lat': round(40.5 + random.uniform(-0.5, 0.5), 4), - 'lon': round(-73.9 + random.uniform(-0.5, 0.5), 4), - 'speed': round(random.uniform(0, 25), 1), - 'course': random.randint(0, 359), - 'destination': random.choice(['NEW YORK', 'NEWARK', 'BALTIMORE', 'BOSTON']), - 'last_seen': datetime.now(timezone.utc).isoformat() - }) + vessels.append( + { + "mmsi": mmsi, + "name": name, + "callsign": "".join(random.choices(string.ascii_uppercase, k=5)), + "ship_type": random.choice(["Cargo", "Tanker", "Passenger", "Fishing"]), + "lat": round(40.5 + random.uniform(-0.5, 0.5), 4), + "lon": round(-73.9 + random.uniform(-0.5, 0.5), 4), + "speed": round(random.uniform(0, 25), 1), + "course": random.randint(0, 359), + "destination": random.choice(["NEW YORK", "NEWARK", "BALTIMORE", "BOSTON"]), + "last_seen": datetime.now(timezone.utc).isoformat(), + } + ) return vessels @@ -128,15 +139,15 @@ data_snapshots: dict[str, list] = {} def update_data_snapshot(mode: str): """Update data snapshot for a mode.""" - if mode == 'adsb': + if mode == "adsb": data_snapshots[mode] = generate_aircraft() - elif mode == 'sensor': + elif mode == "sensor": data_snapshots[mode] = generate_sensors() - elif mode == 'wifi': + elif mode == "wifi": data_snapshots[mode] = generate_wifi_networks() - elif mode == 'bluetooth': + elif mode == "bluetooth": data_snapshots[mode] = generate_bluetooth_devices() - elif mode == 'ais': + elif mode == "ais": data_snapshots[mode] = generate_vessels() else: data_snapshots[mode] = [] @@ -157,67 +168,72 @@ def data_generator_loop(mode: str, stop_event: threading.Event): # Routes # ============================================================================= -@app.route('/capabilities') + +@app.route("/capabilities") def capabilities(): """Return mock capabilities.""" - return jsonify({ - 'modes': { - 'pager': True, - 'sensor': True, - 'adsb': True, - 'ais': True, - 'acars': True, - 'aprs': True, - 'wifi': True, - 'bluetooth': True, - 'dsc': True, - 'rtlamr': True, - 'tscm': True, - 'satellite': True, - 'listening_post': True - }, - 'devices': [ - {'index': 0, 'name': 'Mock RTL-SDR', 'type': 'rtlsdr', 'serial': 'MOCK001'} - ], - 'agent_version': '1.0.0-mock' - }) + return jsonify( + { + "modes": { + "pager": True, + "sensor": True, + "adsb": True, + "ais": True, + "acars": True, + "aprs": True, + "wifi": True, + "bluetooth": True, + "dsc": True, + "rtlamr": True, + "tscm": True, + "satellite": True, + "listening_post": True, + }, + "devices": [{"index": 0, "name": "Mock RTL-SDR", "type": "rtlsdr", "serial": "MOCK001"}], + "agent_version": "1.0.0-mock", + } + ) -@app.route('/status') +@app.route("/status") def status(): """Return agent status.""" - return jsonify({ - 'running_modes': list(running_modes), - 'uptime': time.time() - start_time, - 'push_enabled': False, - 'push_connected': False - }) + return jsonify( + { + "running_modes": list(running_modes), + "uptime": time.time() - start_time, + "push_enabled": False, + "push_connected": False, + } + ) -@app.route('/health') +@app.route("/health") def health(): """Health check.""" - return jsonify({'status': 'healthy', 'version': '1.0.0-mock'}) + return jsonify({"status": "healthy", "version": "1.0.0-mock"}) -@app.route('/config', methods=['GET', 'POST']) +@app.route("/config", methods=["GET", "POST"]) def config(): """Config endpoint.""" - if request.method == 'POST': - return jsonify({'status': 'updated', 'config': {}}) - return jsonify({ - 'name': agent_name, - 'port': request.environ.get('SERVER_PORT', 8021), - 'push_enabled': False, - 'modes_enabled': dict.fromkeys(['pager', 'sensor', 'adsb', 'ais', 'wifi', 'bluetooth'], True) - }) + if request.method == "POST": + return jsonify({"status": "updated", "config": {}}) + return jsonify( + { + "name": agent_name, + "port": request.environ.get("SERVER_PORT", 8021), + "push_enabled": False, + "modes_enabled": dict.fromkeys(["pager", "sensor", "adsb", "ais", "wifi", "bluetooth"], True), + } + ) -@app.route('//start', methods=['POST']) +@app.route("//start", methods=["POST"]) def start_mode(mode: str): """Start a mode.""" if mode in running_modes: - return jsonify({'status': 'error', 'message': f'{mode} already running'}), 409 + return jsonify({"status": "error", "message": f"{mode} already running"}), 409 running_modes.add(mode) @@ -231,14 +247,14 @@ def start_mode(mode: str): # Generate initial data update_data_snapshot(mode) - return jsonify({'status': 'started', 'mode': mode}) + return jsonify({"status": "started", "mode": mode}) -@app.route('//stop', methods=['POST']) +@app.route("//stop", methods=["POST"]) def stop_mode(mode: str): """Stop a mode.""" if mode not in running_modes: - return jsonify({'status': 'not_running'}) + return jsonify({"status": "not_running"}) running_modes.discard(mode) @@ -251,44 +267,44 @@ def stop_mode(mode: str): if mode in data_snapshots: del data_snapshots[mode] - return jsonify({'status': 'stopped', 'mode': mode}) + return jsonify({"status": "stopped", "mode": mode}) -@app.route('//status') +@app.route("//status") def mode_status(mode: str): """Get mode status.""" - return jsonify({ - 'running': mode in running_modes, - 'data_count': len(data_snapshots.get(mode, [])) - }) + return jsonify({"running": mode in running_modes, "data_count": len(data_snapshots.get(mode, []))}) -@app.route('//data') +@app.route("//data") def mode_data(mode: str): """Get current data snapshot.""" # Generate fresh data if mode is running but no snapshot exists if mode in running_modes and mode not in data_snapshots: update_data_snapshot(mode) - return jsonify({ - 'mode': mode, - 'data': data_snapshots.get(mode, []), - 'timestamp': datetime.now(timezone.utc).isoformat(), - 'agent_name': agent_name - }) + return jsonify( + { + "mode": mode, + "data": data_snapshots.get(mode, []), + "timestamp": datetime.now(timezone.utc).isoformat(), + "agent_name": agent_name, + } + ) # ============================================================================= # Main # ============================================================================= + def main(): global agent_name, start_time - parser = argparse.ArgumentParser(description='Mock Intercept Agent') - parser.add_argument('--port', '-p', type=int, default=8021, help='Port (default: 8021)') - parser.add_argument('--name', '-n', default='mock-agent-1', help='Agent name') - parser.add_argument('--debug', action='store_true', help='Enable debug mode') + parser = argparse.ArgumentParser(description="Mock Intercept Agent") + parser.add_argument("--port", "-p", type=int, default=8021, help="Port (default: 8021)") + parser.add_argument("--name", "-n", default="mock-agent-1", help="Agent name") + parser.add_argument("--debug", action="store_true", help="Enable debug mode") args = parser.parse_args() agent_name = args.name @@ -309,8 +325,8 @@ def main(): print(" Press Ctrl+C to stop") print() - app.run(host='0.0.0.0', port=args.port, debug=args.debug) + app.run(host="0.0.0.0", port=args.port, debug=args.debug) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/smoke_test_bluetooth.py b/tests/smoke_test_bluetooth.py index 6213cf5..ecaed27 100644 --- a/tests/smoke_test_bluetooth.py +++ b/tests/smoke_test_bluetooth.py @@ -38,23 +38,22 @@ DEFAULT_PORT = 5000 # SCHEMA VALIDATORS # ============================================================================= + def validate_device_schema(device: dict, context: str = "") -> list[str]: """Validate that a device dict has expected fields (backwards compatible).""" errors = [] - required_fields = [ - 'device_id', 'address', 'rssi_current', 'last_seen', 'seen_count' - ] + required_fields = ["device_id", "address", "rssi_current", "last_seen", "seen_count"] for field in required_fields: if field not in device: errors.append(f"{context}Missing required field: {field}") # New tracker fields should be present (v2) but are optional - tracker_fields = ['is_tracker', 'tracker_type', 'tracker_confidence'] + tracker_fields = ["is_tracker", "tracker_type", "tracker_confidence"] for field in tracker_fields: if field in device: # Field exists, check type - if field == 'is_tracker' and not isinstance(device[field], bool): + if field == "is_tracker" and not isinstance(device[field], bool): errors.append(f"{context}is_tracker should be bool, got {type(device[field])}") return errors @@ -64,17 +63,15 @@ def validate_tracker_schema(tracker: dict, context: str = "") -> list[str]: """Validate tracker endpoint response schema.""" errors = [] - required_fields = [ - 'device_id', 'address', 'tracker' - ] + required_fields = ["device_id", "address", "tracker"] for field in required_fields: if field not in tracker: errors.append(f"{context}Missing required field: {field}") # Tracker sub-object - if 'tracker' in tracker: - tracker_obj = tracker['tracker'] - tracker_required = ['type', 'confidence', 'evidence'] + if "tracker" in tracker: + tracker_obj = tracker["tracker"] + tracker_required = ["type", "confidence", "evidence"] for field in tracker_required: if field not in tracker_obj: errors.append(f"{context}tracker.{field} missing") @@ -86,12 +83,12 @@ def validate_diagnostics_schema(diagnostics: dict) -> list[str]: """Validate diagnostics endpoint response schema.""" errors = [] - required_sections = ['system', 'bluez', 'adapters', 'permissions', 'backends'] + required_sections = ["system", "bluez", "adapters", "permissions", "backends"] for section in required_sections: if section not in diagnostics: errors.append(f"Missing diagnostics section: {section}") - if 'can_scan' not in diagnostics: + if "can_scan" not in diagnostics: errors.append("Missing can_scan field") return errors @@ -101,6 +98,7 @@ def validate_diagnostics_schema(diagnostics: dict) -> list[str]: # TEST CASES # ============================================================================= + class SmokeTests: """Smoke test runner.""" @@ -128,9 +126,9 @@ class SmokeTests: self._check("Status code 200", resp.status_code == 200, f"Got {resp.status_code}") data = resp.json() - self._check("Has 'available' field", 'available' in data or 'can_scan' in data) - self._check("Has 'adapters' field", 'adapters' in data) - self._check("Has 'recommended_backend' field", 'recommended_backend' in data or 'preferred_backend' in data) + self._check("Has 'available' field", "available" in data or "can_scan" in data) + self._check("Has 'adapters' field", "adapters" in data) + self._check("Has 'recommended_backend' field", "recommended_backend" in data or "preferred_backend" in data) except requests.RequestException as e: self._check("Request succeeded", False, str(e)) @@ -143,18 +141,19 @@ class SmokeTests: self._check("Status code 200", resp.status_code == 200, f"Got {resp.status_code}") data = resp.json() - self._check("Has 'count' field", 'count' in data) - self._check("Has 'devices' array", 'devices' in data and isinstance(data['devices'], list)) + self._check("Has 'count' field", "count" in data) + self._check("Has 'devices' array", "devices" in data and isinstance(data["devices"], list)) # If devices exist, validate schema - if data.get('devices'): - device = data['devices'][0] + if data.get("devices"): + device = data["devices"][0] errors = validate_device_schema(device, "First device: ") self._check("Device schema valid", len(errors) == 0, "; ".join(errors)) # Check for new tracker fields (should exist even if empty) - self._check("Has tracker fields", 'is_tracker' in device, - "New tracker field missing (backwards compat issue)") + self._check( + "Has tracker fields", "is_tracker" in device, "New tracker field missing (backwards compat issue)" + ) except requests.RequestException as e: self._check("Request succeeded", False, str(e)) @@ -167,13 +166,13 @@ class SmokeTests: self._check("Status code 200", resp.status_code == 200, f"Got {resp.status_code}") data = resp.json() - self._check("Has 'count' field", 'count' in data) - self._check("Has 'trackers' array", 'trackers' in data and isinstance(data['trackers'], list)) - self._check("Has 'summary' field", 'summary' in data) + self._check("Has 'count' field", "count" in data) + self._check("Has 'trackers' array", "trackers" in data and isinstance(data["trackers"], list)) + self._check("Has 'summary' field", "summary" in data) # If trackers exist, validate schema - if data.get('trackers'): - tracker = data['trackers'][0] + if data.get("trackers"): + tracker = data["trackers"][0] errors = validate_tracker_schema(tracker, "First tracker: ") self._check("Tracker schema valid", len(errors) == 0, "; ".join(errors)) @@ -191,7 +190,7 @@ class SmokeTests: errors = validate_diagnostics_schema(data) self._check("Diagnostics schema valid", len(errors) == 0, "; ".join(errors)) - self._check("Has recommendations", 'recommendations' in data) + self._check("Has recommendations", "recommendations" in data) except requests.RequestException as e: self._check("Request succeeded", False, str(e)) @@ -204,7 +203,7 @@ class SmokeTests: self._check("Status code 200", resp.status_code == 200, f"Got {resp.status_code}") data = resp.json() - self._check("Has 'is_scanning' field", 'is_scanning' in data) + self._check("Has 'is_scanning' field", "is_scanning" in data) except requests.RequestException as e: self._check("Request succeeded", False, str(e)) @@ -218,7 +217,7 @@ class SmokeTests: self._check("List baselines: Status 200", resp.status_code == 200, f"Got {resp.status_code}") data = resp.json() - self._check("Has 'baselines' array", 'baselines' in data) + self._check("Has 'baselines' array", "baselines" in data) except requests.RequestException as e: self._check("Request succeeded", False, str(e)) @@ -242,22 +241,22 @@ class SmokeTests: # JSON export resp = requests.get(f"{self.base_url}/api/bluetooth/export?format=json", timeout=5) self._check("JSON export: Status 200", resp.status_code == 200, f"Got {resp.status_code}") - self._check("JSON export: Content-Type", 'application/json' in resp.headers.get('Content-Type', '')) + self._check("JSON export: Content-Type", "application/json" in resp.headers.get("Content-Type", "")) # CSV export resp = requests.get(f"{self.base_url}/api/bluetooth/export?format=csv", timeout=5) self._check("CSV export: Status 200", resp.status_code == 200, f"Got {resp.status_code}") - self._check("CSV export: Content-Type", 'text/csv' in resp.headers.get('Content-Type', '')) + self._check("CSV export: Content-Type", "text/csv" in resp.headers.get("Content-Type", "")) except requests.RequestException as e: self._check("Request succeeded", False, str(e)) def run_all(self): """Run all smoke tests.""" - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print("BLUETOOTH API SMOKE TESTS") print(f"Target: {self.base_url}") - print(f"{'='*60}") + print(f"{'=' * 60}") self.test_capabilities_endpoint() self.test_devices_endpoint() @@ -268,9 +267,9 @@ class SmokeTests: self.test_export_endpoint() self.test_tscm_integration() - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f"RESULTS: {self.passed} passed, {self.failed} failed") - print(f"{'='*60}") + print(f"{'=' * 60}") if self.errors: print("\nFailed tests:") @@ -284,6 +283,7 @@ class SmokeTests: # MAIN # ============================================================================= + def main(): parser = argparse.ArgumentParser(description="Bluetooth API smoke tests") parser.add_argument("--host", default=DEFAULT_HOST, help="Server host") diff --git a/tests/test_acars_translator.py b/tests/test_acars_translator.py index caccb9e..690ce4c 100644 --- a/tests/test_acars_translator.py +++ b/tests/test_acars_translator.py @@ -1,6 +1,5 @@ """Tests for ACARS message translator.""" - from utils.acars_translator import ( classify_message_type, parse_engine_data, @@ -13,250 +12,257 @@ from utils.acars_translator import ( # --- translate_label --- + class TestTranslateLabel: def test_known_labels(self): - assert translate_label('H1') == 'Position report (HF data link)' - assert translate_label('DF') == 'Engine data / DFDR' - assert translate_label('_d') == 'Demand mode (link test)' - assert translate_label('5Z') == 'OOOI (gate times)' - assert translate_label('B9') == 'ATC message' - assert translate_label('SQ') == 'Squawk assignment' + assert translate_label("H1") == "Position report (HF data link)" + assert translate_label("DF") == "Engine data / DFDR" + assert translate_label("_d") == "Demand mode (link test)" + assert translate_label("5Z") == "OOOI (gate times)" + assert translate_label("B9") == "ATC message" + assert translate_label("SQ") == "Squawk assignment" def test_unknown_label(self): - assert translate_label('ZZ') == 'Label ZZ' + assert translate_label("ZZ") == "Label ZZ" def test_empty_label(self): - assert translate_label('') == 'Unknown label' + assert translate_label("") == "Unknown label" def test_none_label(self): - assert translate_label(None) == 'Unknown label' + assert translate_label(None) == "Unknown label" def test_q_prefix_unknown(self): """Q-prefix labels not in table should get generic link management desc.""" - assert 'Link management' in translate_label('QZ') + assert "Link management" in translate_label("QZ") def test_whitespace_stripped(self): - assert translate_label(' H1 ') == 'Position report (HF data link)' + assert translate_label(" H1 ") == "Position report (HF data link)" # --- classify_message_type --- + class TestClassifyMessageType: def test_h1_is_position(self): - assert classify_message_type('H1') == 'position' + assert classify_message_type("H1") == "position" def test_df_is_engine_data(self): - assert classify_message_type('DF') == 'engine_data' + assert classify_message_type("DF") == "engine_data" def test_h2_is_weather(self): - assert classify_message_type('H2') == 'weather' + assert classify_message_type("H2") == "weather" def test_b9_is_ats(self): - assert classify_message_type('B9') == 'ats' + assert classify_message_type("B9") == "ats" def test_5z_is_oooi(self): - assert classify_message_type('5Z') == 'oooi' + assert classify_message_type("5Z") == "oooi" def test_sq_is_squawk(self): - assert classify_message_type('SQ') == 'squawk' + assert classify_message_type("SQ") == "squawk" def test_underscore_d_is_handshake(self): - assert classify_message_type('_d') == 'handshake' + assert classify_message_type("_d") == "handshake" def test_q0_is_link_test(self): - assert classify_message_type('Q0') == 'link_test' + assert classify_message_type("Q0") == "link_test" def test_aa_is_cpdlc(self): - assert classify_message_type('AA') == 'cpdlc' + assert classify_message_type("AA") == "cpdlc" def test_unknown_is_other(self): - assert classify_message_type('ZZ') == 'other' + assert classify_message_type("ZZ") == "other" def test_none_is_other(self): - assert classify_message_type(None) == 'other' + assert classify_message_type(None) == "other" def test_text_with_bpos_override(self): """H1 with #M1BPOS text should be position.""" - assert classify_message_type('H1', '#M1BPOSN42411W086034') == 'position' + assert classify_message_type("H1", "#M1BPOSN42411W086034") == "position" # --- parse_position_report --- + class TestParsePositionReport: def test_real_h1_bpos(self): - text = '#M1BPOSN42411W086034,CSG,070852,340,N42441W087074,DTW,0757,224A8C' + text = "#M1BPOSN42411W086034,CSG,070852,340,N42441W087074,DTW,0757,224A8C" result = parse_position_report(text) assert result is not None - assert result['lat'] > 42 - assert result['lon'] < -86 - assert result['waypoint'] == 'CSG' - assert result['flight_level'] == 'FL340' - assert result['destination'] == 'DTW' + assert result["lat"] > 42 + assert result["lon"] < -86 + assert result["waypoint"] == "CSG" + assert result["flight_level"] == "FL340" + assert result["destination"] == "DTW" def test_none_text(self): assert parse_position_report(None) is None def test_empty_text(self): - assert parse_position_report('') is None + assert parse_position_report("") is None def test_no_bpos_data(self): - assert parse_position_report('SOME RANDOM TEXT') is None + assert parse_position_report("SOME RANDOM TEXT") is None def test_temperature_field(self): - text = '#M1BPOSN42411W086034,CSG,070852,340,N42441W087074,DTW,0757/TSM045' + text = "#M1BPOSN42411W086034,CSG,070852,340,N42441W087074,DTW,0757/TSM045" result = parse_position_report(text) assert result is not None - assert result.get('temperature') == '-045 C' + assert result.get("temperature") == "-045 C" def test_southern_hemisphere(self): - text = '#M1BPOSS33500E018200,CPT,120000,350,S33500E018200,CPT,1230,ABC123' + text = "#M1BPOSS33500E018200,CPT,120000,350,S33500E018200,CPT,1230,ABC123" result = parse_position_report(text) assert result is not None - assert result['lat'] < 0 # South + assert result["lat"] < 0 # South # --- parse_engine_data --- + class TestParseEngineData: def test_real_dfdr_message(self): - text = '#DFB SM/0 AC0/85.2 AC1/84.9 FL/350 FU/12450 ES/15' + text = "#DFB SM/0 AC0/85.2 AC1/84.9 FL/350 FU/12450 ES/15" result = parse_engine_data(text) assert result is not None - assert 'AC0' in result - assert result['AC0']['value'] == '85.2' - assert 'FL' in result - assert result['FL']['value'] == '350' + assert "AC0" in result + assert result["AC0"]["value"] == "85.2" + assert "FL" in result + assert result["FL"]["value"] == "350" def test_none_text(self): assert parse_engine_data(None) is None def test_empty_text(self): - assert parse_engine_data('') is None + assert parse_engine_data("") is None def test_no_engine_keys(self): - assert parse_engine_data('HELLO WORLD') is None + assert parse_engine_data("HELLO WORLD") is None def test_n1_n2_values(self): - text = 'N1/92.3 N2/88.1 EGT/425' + text = "N1/92.3 N2/88.1 EGT/425" result = parse_engine_data(text) assert result is not None - assert result['N1']['value'] == '92.3' - assert result['N2']['value'] == '88.1' - assert result['EGT']['value'] == '425' + assert result["N1"]["value"] == "92.3" + assert result["N2"]["value"] == "88.1" + assert result["EGT"]["value"] == "425" # --- parse_weather_data --- + class TestParseWeatherData: def test_wind_data(self): - text = 'WND270015 KJFK VIS10' + text = "WND270015 KJFK VIS10" result = parse_weather_data(text) assert result is not None - assert result['wind_dir'] == '270 deg' - assert result['wind_speed'] == '015 kts' + assert result["wind_dir"] == "270 deg" + assert result["wind_speed"] == "015 kts" def test_airports(self): - text = '/WX KJFK KLAX TMP24' + text = "/WX KJFK KLAX TMP24" result = parse_weather_data(text) assert result is not None - assert 'KJFK' in result['airports'] - assert 'KLAX' in result['airports'] + assert "KJFK" in result["airports"] + assert "KLAX" in result["airports"] def test_none_text(self): assert parse_weather_data(None) is None def test_empty_text(self): - assert parse_weather_data('') is None + assert parse_weather_data("") is None # --- parse_oooi --- + class TestParseOooi: def test_full_oooi(self): - text = 'KJFK KLAX 1423 1435 1812 1824' + text = "KJFK KLAX 1423 1435 1812 1824" result = parse_oooi(text) assert result is not None - assert result['origin'] == 'KJFK' - assert result['destination'] == 'KLAX' - assert result['out'] == '1423' - assert result['off'] == '1435' - assert result['on'] == '1812' - assert result['in'] == '1824' + assert result["origin"] == "KJFK" + assert result["destination"] == "KLAX" + assert result["out"] == "1423" + assert result["off"] == "1435" + assert result["on"] == "1812" + assert result["in"] == "1824" def test_partial_oooi(self): - text = 'KJFK KLAX 1423 1435' + text = "KJFK KLAX 1423 1435" result = parse_oooi(text) assert result is not None - assert result['origin'] == 'KJFK' - assert result['destination'] == 'KLAX' + assert result["origin"] == "KJFK" + assert result["destination"] == "KLAX" def test_none_text(self): assert parse_oooi(None) is None def test_empty_text(self): - assert parse_oooi('') is None + assert parse_oooi("") is None # --- translate_message (integration) --- + class TestTranslateMessage: def test_h1_position(self): msg = { - 'label': 'H1', - 'text': '#M1BPOSN42411W086034,CSG,070852,340,N42441W087074,DTW,0757,224A8C', + "label": "H1", + "text": "#M1BPOSN42411W086034,CSG,070852,340,N42441W087074,DTW,0757,224A8C", } result = translate_message(msg) - assert result['label_description'] == 'Position report (HF data link)' - assert result['message_type'] == 'position' - assert result['parsed'] is not None - assert 'lat' in result['parsed'] + assert result["label_description"] == "Position report (HF data link)" + assert result["message_type"] == "position" + assert result["parsed"] is not None + assert "lat" in result["parsed"] def test_df_engine(self): msg = { - 'label': 'DF', - 'text': '#DFB SM/0 AC0/85.2 AC1/84.9 FL/350', + "label": "DF", + "text": "#DFB SM/0 AC0/85.2 AC1/84.9 FL/350", } result = translate_message(msg) - assert result['message_type'] == 'engine_data' - assert result['parsed'] is not None - assert 'AC0' in result['parsed'] + assert result["message_type"] == "engine_data" + assert result["parsed"] is not None + assert "AC0" in result["parsed"] def test_underscore_d_handshake(self): - msg = {'label': '_d', 'text': ''} + msg = {"label": "_d", "text": ""} result = translate_message(msg) - assert result['label_description'] == 'Demand mode (link test)' - assert result['message_type'] == 'handshake' + assert result["label_description"] == "Demand mode (link test)" + assert result["message_type"] == "handshake" def test_unknown_label(self): - msg = {'label': 'ZZ', 'text': 'SOME DATA'} + msg = {"label": "ZZ", "text": "SOME DATA"} result = translate_message(msg) - assert result['label_description'] == 'Label ZZ' - assert result['message_type'] == 'other' - assert result['parsed'] is None + assert result["label_description"] == "Label ZZ" + assert result["message_type"] == "other" + assert result["parsed"] is None def test_missing_fields(self): """Handles messages with no label or text gracefully.""" result = translate_message({}) - assert result['label_description'] == 'Unknown label' - assert result['message_type'] == 'other' - assert result['parsed'] is None + assert result["label_description"] == "Unknown label" + assert result["message_type"] == "other" + assert result["parsed"] is None def test_msg_field_fallback(self): """Uses 'msg' field when 'text' is missing.""" msg = { - 'label': 'DF', - 'msg': '#DFB N1/92.3 N2/88.1', + "label": "DF", + "msg": "#DFB N1/92.3 N2/88.1", } result = translate_message(msg) - assert result['parsed'] is not None - assert 'N1' in result['parsed'] + assert result["parsed"] is not None + assert "N1" in result["parsed"] def test_5z_oooi(self): msg = { - 'label': '5Z', - 'text': 'KJFK KLAX 1423 1435 1812 1824', + "label": "5Z", + "text": "KJFK KLAX 1423 1435 1812 1824", } result = translate_message(msg) - assert result['message_type'] == 'oooi' - assert result['parsed'] is not None - assert result['parsed']['origin'] == 'KJFK' + assert result["message_type"] == "oooi" + assert result["parsed"] is not None + assert result["parsed"]["origin"] == "KJFK" diff --git a/tests/test_agent.py b/tests/test_agent.py index 3833229..c45edcf 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -35,22 +35,24 @@ from utils.database import ( # AgentConfig Tests # ============================================================================= + class TestAgentConfig: """Tests for AgentConfig class.""" def test_default_values(self): """AgentConfig should have sensible defaults.""" from intercept_agent import AgentConfig + config = AgentConfig() assert config.port == 8020 assert config.allow_cors is False assert config.push_enabled is False assert config.push_interval == 5 - assert config.controller_url == '' - assert 'adsb' in config.modes_enabled - assert 'wifi' in config.modes_enabled - assert config.modes_enabled['adsb'] is True + assert config.controller_url == "" + assert "adsb" in config.modes_enabled + assert "wifi" in config.modes_enabled + assert config.modes_enabled["adsb"] is True def test_load_from_file_valid(self): """AgentConfig should load from valid INI file.""" @@ -75,7 +77,7 @@ adsb = true wifi = true bluetooth = false """ - with tempfile.NamedTemporaryFile(mode='w', suffix='.cfg', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".cfg", delete=False) as f: f.write(config_content) config_path = f.name @@ -84,220 +86,220 @@ bluetooth = false result = config.load_from_file(config_path) assert result is True - assert config.name == 'test-sensor' + assert config.name == "test-sensor" assert config.port == 8025 - assert '192.168.1.0/24' in config.allowed_ips + assert "192.168.1.0/24" in config.allowed_ips assert config.allow_cors is True - assert config.controller_url == 'http://192.168.1.100:5050' - assert config.controller_api_key == 'secret123' + assert config.controller_url == "http://192.168.1.100:5050" + assert config.controller_api_key == "secret123" assert config.push_enabled is True assert config.push_interval == 10 - assert config.modes_enabled['pager'] is False - assert config.modes_enabled['adsb'] is True - assert config.modes_enabled['bluetooth'] is False + assert config.modes_enabled["pager"] is False + assert config.modes_enabled["adsb"] is True + assert config.modes_enabled["bluetooth"] is False finally: os.unlink(config_path) def test_load_from_file_missing(self): """AgentConfig should handle missing file gracefully.""" from intercept_agent import AgentConfig + config = AgentConfig() - result = config.load_from_file('/nonexistent/path.cfg') + result = config.load_from_file("/nonexistent/path.cfg") assert result is False def test_to_dict(self): """AgentConfig should convert to dictionary.""" from intercept_agent import AgentConfig + config = AgentConfig() - config.name = 'test' + config.name = "test" config.port = 9000 d = config.to_dict() - assert d['name'] == 'test' - assert d['port'] == 9000 - assert 'modes_enabled' in d - assert isinstance(d['modes_enabled'], dict) + assert d["name"] == "test" + assert d["port"] == 9000 + assert "modes_enabled" in d + assert isinstance(d["modes_enabled"], dict) # ============================================================================= # AgentClient Tests # ============================================================================= + class TestAgentClient: """Tests for AgentClient HTTP operations.""" def test_init(self): """AgentClient should initialize correctly.""" - client = AgentClient('http://192.168.1.50:8020', api_key='secret') - assert client.base_url == 'http://192.168.1.50:8020' - assert client.api_key == 'secret' + client = AgentClient("http://192.168.1.50:8020", api_key="secret") + assert client.base_url == "http://192.168.1.50:8020" + assert client.api_key == "secret" assert client.timeout == 60.0 def test_init_strips_trailing_slash(self): """AgentClient should strip trailing slash from URL.""" - client = AgentClient('http://192.168.1.50:8020/') - assert client.base_url == 'http://192.168.1.50:8020' + client = AgentClient("http://192.168.1.50:8020/") + assert client.base_url == "http://192.168.1.50:8020" def test_headers_without_api_key(self): """Headers should not include API key if not provided.""" - client = AgentClient('http://localhost:8020') + client = AgentClient("http://localhost:8020") headers = client._headers() - assert 'X-API-Key' not in headers - assert 'Content-Type' in headers + assert "X-API-Key" not in headers + assert "Content-Type" in headers def test_headers_with_api_key(self): """Headers should include API key if provided.""" - client = AgentClient('http://localhost:8020', api_key='test-key') + client = AgentClient("http://localhost:8020", api_key="test-key") headers = client._headers() - assert headers['X-API-Key'] == 'test-key' + assert headers["X-API-Key"] == "test-key" - @patch('utils.agent_client.requests.get') + @patch("utils.agent_client.requests.get") def test_get_capabilities(self, mock_get): """get_capabilities should parse JSON response.""" mock_response = Mock() mock_response.json.return_value = { - 'modes': {'adsb': True, 'wifi': True}, - 'devices': [{'name': 'RTL-SDR'}], - 'agent_version': '1.0.0' + "modes": {"adsb": True, "wifi": True}, + "devices": [{"name": "RTL-SDR"}], + "agent_version": "1.0.0", } - mock_response.content = b'{}' + mock_response.content = b"{}" mock_response.raise_for_status = Mock() mock_get.return_value = mock_response - client = AgentClient('http://localhost:8020') + client = AgentClient("http://localhost:8020") caps = client.get_capabilities() - assert caps['modes']['adsb'] is True - assert len(caps['devices']) == 1 + assert caps["modes"]["adsb"] is True + assert len(caps["devices"]) == 1 mock_get.assert_called_once() - @patch('utils.agent_client.requests.get') + @patch("utils.agent_client.requests.get") def test_get_status(self, mock_get): """get_status should return status dict.""" mock_response = Mock() - mock_response.json.return_value = { - 'running_modes': ['adsb', 'sensor'], - 'uptime': 3600, - 'push_enabled': True - } - mock_response.content = b'{}' + mock_response.json.return_value = {"running_modes": ["adsb", "sensor"], "uptime": 3600, "push_enabled": True} + mock_response.content = b"{}" mock_response.raise_for_status = Mock() mock_get.return_value = mock_response - client = AgentClient('http://localhost:8020') + client = AgentClient("http://localhost:8020") status = client.get_status() - assert 'adsb' in status['running_modes'] - assert status['uptime'] == 3600 + assert "adsb" in status["running_modes"] + assert status["uptime"] == 3600 - @patch('utils.agent_client.requests.get') + @patch("utils.agent_client.requests.get") def test_health_check_healthy(self, mock_get): """health_check should return True for healthy agent.""" mock_response = Mock() - mock_response.json.return_value = {'status': 'healthy'} - mock_response.content = b'{}' + mock_response.json.return_value = {"status": "healthy"} + mock_response.content = b"{}" mock_response.raise_for_status = Mock() mock_get.return_value = mock_response - client = AgentClient('http://localhost:8020') + client = AgentClient("http://localhost:8020") assert client.health_check() is True - @patch('utils.agent_client.requests.get') + @patch("utils.agent_client.requests.get") def test_health_check_unhealthy(self, mock_get): """health_check should return False for connection error.""" import requests + mock_get.side_effect = requests.ConnectionError("Connection refused") - client = AgentClient('http://localhost:8020') + client = AgentClient("http://localhost:8020") assert client.health_check() is False - @patch('utils.agent_client.requests.post') + @patch("utils.agent_client.requests.post") def test_start_mode(self, mock_post): """start_mode should POST to correct endpoint.""" mock_response = Mock() - mock_response.json.return_value = {'status': 'started', 'mode': 'adsb'} - mock_response.content = b'{}' + mock_response.json.return_value = {"status": "started", "mode": "adsb"} + mock_response.content = b"{}" mock_response.raise_for_status = Mock() mock_post.return_value = mock_response - client = AgentClient('http://localhost:8020') - result = client.start_mode('adsb', {'device_index': 0}) + client = AgentClient("http://localhost:8020") + result = client.start_mode("adsb", {"device_index": 0}) - assert result['status'] == 'started' + assert result["status"] == "started" mock_post.assert_called_once() call_url = mock_post.call_args[0][0] - assert '/adsb/start' in call_url + assert "/adsb/start" in call_url - @patch('utils.agent_client.requests.post') + @patch("utils.agent_client.requests.post") def test_stop_mode(self, mock_post): """stop_mode should POST to stop endpoint.""" mock_response = Mock() - mock_response.json.return_value = {'status': 'stopped'} - mock_response.content = b'{}' + mock_response.json.return_value = {"status": "stopped"} + mock_response.content = b"{}" mock_response.raise_for_status = Mock() mock_post.return_value = mock_response - client = AgentClient('http://localhost:8020') - result = client.stop_mode('wifi') + client = AgentClient("http://localhost:8020") + result = client.stop_mode("wifi") - assert result['status'] == 'stopped' + assert result["status"] == "stopped" - @patch('utils.agent_client.requests.get') + @patch("utils.agent_client.requests.get") def test_get_mode_data(self, mock_get): """get_mode_data should return data snapshot.""" mock_response = Mock() mock_response.json.return_value = { - 'mode': 'adsb', - 'data': [ - {'icao': 'ABC123', 'altitude': 35000}, - {'icao': 'DEF456', 'altitude': 28000} - ] + "mode": "adsb", + "data": [{"icao": "ABC123", "altitude": 35000}, {"icao": "DEF456", "altitude": 28000}], } - mock_response.content = b'{}' + mock_response.content = b"{}" mock_response.raise_for_status = Mock() mock_get.return_value = mock_response - client = AgentClient('http://localhost:8020') - result = client.get_mode_data('adsb') + client = AgentClient("http://localhost:8020") + result = client.get_mode_data("adsb") - assert len(result['data']) == 2 - assert result['data'][0]['icao'] == 'ABC123' + assert len(result["data"]) == 2 + assert result["data"][0]["icao"] == "ABC123" - @patch('utils.agent_client.requests.get') + @patch("utils.agent_client.requests.get") def test_connection_error_handling(self, mock_get): """Client should raise AgentConnectionError on connection failure.""" import requests + mock_get.side_effect = requests.ConnectionError("Connection refused") - client = AgentClient('http://localhost:8020') + client = AgentClient("http://localhost:8020") with pytest.raises(AgentConnectionError) as exc_info: client.get_capabilities() - assert 'Cannot connect' in str(exc_info.value) + assert "Cannot connect" in str(exc_info.value) - @patch('utils.agent_client.requests.get') + @patch("utils.agent_client.requests.get") def test_timeout_error_handling(self, mock_get): """Client should raise AgentConnectionError on timeout.""" import requests + mock_get.side_effect = requests.Timeout("Request timed out") - client = AgentClient('http://localhost:8020', timeout=5.0) + client = AgentClient("http://localhost:8020", timeout=5.0) with pytest.raises(AgentConnectionError) as exc_info: client.get_status() - assert 'timed out' in str(exc_info.value) + assert "timed out" in str(exc_info.value) - @patch('utils.agent_client.requests.get') + @patch("utils.agent_client.requests.get") def test_http_error_handling(self, mock_get): """Client should raise AgentHTTPError on HTTP errors.""" import requests + mock_response = Mock() mock_response.status_code = 500 mock_response.raise_for_status.side_effect = requests.HTTPError(response=mock_response) mock_get.return_value = mock_response - client = AgentClient('http://localhost:8020') + client = AgentClient("http://localhost:8020") with pytest.raises(AgentHTTPError) as exc_info: client.get_capabilities() @@ -305,23 +307,19 @@ class TestAgentClient: def test_create_client_from_agent(self): """create_client_from_agent should create configured client.""" - agent = { - 'id': 1, - 'name': 'test-agent', - 'base_url': 'http://192.168.1.50:8020', - 'api_key': 'secret123' - } + agent = {"id": 1, "name": "test-agent", "base_url": "http://192.168.1.50:8020", "api_key": "secret123"} client = create_client_from_agent(agent) - assert client.base_url == 'http://192.168.1.50:8020' - assert client.api_key == 'secret123' + assert client.base_url == "http://192.168.1.50:8020" + assert client.api_key == "secret123" # ============================================================================= # Database Agent CRUD Tests # ============================================================================= + class TestDatabaseAgentCRUD: """Tests for database agent operations.""" @@ -331,13 +329,13 @@ class TestDatabaseAgentCRUD: import utils.database as db_module # Create temp database - test_db_path = tmp_path / 'test.db' + test_db_path = tmp_path / "test.db" original_db_path = db_module.DB_PATH db_module.DB_PATH = test_db_path db_module.DB_DIR = tmp_path # Clear any existing connection - if hasattr(db_module._local, 'connection') and db_module._local.connection: + if hasattr(db_module._local, "connection") and db_module._local.connection: db_module._local.connection.close() db_module._local.connection = None @@ -347,7 +345,7 @@ class TestDatabaseAgentCRUD: yield # Cleanup - if hasattr(db_module._local, 'connection') and db_module._local.connection: + if hasattr(db_module._local, "connection") and db_module._local.connection: db_module._local.connection.close() db_module._local.connection = None db_module.DB_PATH = original_db_path @@ -355,10 +353,7 @@ class TestDatabaseAgentCRUD: def test_create_agent(self): """create_agent should insert new agent.""" agent_id = create_agent( - name='sensor-1', - base_url='http://192.168.1.50:8020', - api_key='secret', - description='Test sensor node' + name="sensor-1", base_url="http://192.168.1.50:8020", api_key="secret", description="Test sensor node" ) assert agent_id is not None @@ -366,17 +361,14 @@ class TestDatabaseAgentCRUD: def test_get_agent(self): """get_agent should retrieve agent by ID.""" - agent_id = create_agent( - name='sensor-1', - base_url='http://192.168.1.50:8020' - ) + agent_id = create_agent(name="sensor-1", base_url="http://192.168.1.50:8020") agent = get_agent(agent_id) assert agent is not None - assert agent['name'] == 'sensor-1' - assert agent['base_url'] == 'http://192.168.1.50:8020' - assert agent['is_active'] is True + assert agent["name"] == "sensor-1" + assert agent["base_url"] == "http://192.168.1.50:8020" + assert agent["is_active"] is True def test_get_agent_not_found(self): """get_agent should return None for missing agent.""" @@ -385,85 +377,81 @@ class TestDatabaseAgentCRUD: def test_get_agent_by_name(self): """get_agent_by_name should find agent by name.""" - create_agent(name='unique-sensor', base_url='http://localhost:8020') + create_agent(name="unique-sensor", base_url="http://localhost:8020") - agent = get_agent_by_name('unique-sensor') + agent = get_agent_by_name("unique-sensor") assert agent is not None - assert agent['name'] == 'unique-sensor' + assert agent["name"] == "unique-sensor" def test_get_agent_by_name_not_found(self): """get_agent_by_name should return None for missing name.""" - agent = get_agent_by_name('nonexistent-sensor') + agent = get_agent_by_name("nonexistent-sensor") assert agent is None def test_list_agents(self): """list_agents should return all active agents.""" - create_agent(name='sensor-1', base_url='http://192.168.1.51:8020') - create_agent(name='sensor-2', base_url='http://192.168.1.52:8020') - create_agent(name='sensor-3', base_url='http://192.168.1.53:8020') + create_agent(name="sensor-1", base_url="http://192.168.1.51:8020") + create_agent(name="sensor-2", base_url="http://192.168.1.52:8020") + create_agent(name="sensor-3", base_url="http://192.168.1.53:8020") agents = list_agents() assert len(agents) >= 3 - names = [a['name'] for a in agents] - assert 'sensor-1' in names - assert 'sensor-2' in names + names = [a["name"] for a in agents] + assert "sensor-1" in names + assert "sensor-2" in names def test_list_agents_active_only(self): """list_agents should filter inactive agents by default.""" - agent_id = create_agent(name='inactive-sensor', base_url='http://localhost:8020') + agent_id = create_agent(name="inactive-sensor", base_url="http://localhost:8020") update_agent(agent_id, is_active=False) agents = list_agents(active_only=True) - names = [a['name'] for a in agents] - assert 'inactive-sensor' not in names + names = [a["name"] for a in agents] + assert "inactive-sensor" not in names def test_update_agent(self): """update_agent should modify agent fields.""" - agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020') + agent_id = create_agent(name="sensor-1", base_url="http://localhost:8020") - result = update_agent( - agent_id, - base_url='http://192.168.1.100:8020', - description='Updated description' - ) + result = update_agent(agent_id, base_url="http://192.168.1.100:8020", description="Updated description") assert result is True agent = get_agent(agent_id) - assert agent['base_url'] == 'http://192.168.1.100:8020' - assert agent['description'] == 'Updated description' + assert agent["base_url"] == "http://192.168.1.100:8020" + assert agent["description"] == "Updated description" def test_update_agent_capabilities(self): """update_agent should update capabilities JSON.""" - agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020') + agent_id = create_agent(name="sensor-1", base_url="http://localhost:8020") - caps = {'adsb': True, 'wifi': True, 'bluetooth': False} + caps = {"adsb": True, "wifi": True, "bluetooth": False} update_agent(agent_id, capabilities=caps) agent = get_agent(agent_id) - assert agent['capabilities']['adsb'] is True - assert agent['capabilities']['bluetooth'] is False + assert agent["capabilities"]["adsb"] is True + assert agent["capabilities"]["bluetooth"] is False def test_update_agent_gps_coords(self): """update_agent should update GPS coordinates.""" - agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020') + agent_id = create_agent(name="sensor-1", base_url="http://localhost:8020") - gps = {'lat': 40.7128, 'lon': -74.0060, 'altitude': 10} + gps = {"lat": 40.7128, "lon": -74.0060, "altitude": 10} update_agent(agent_id, gps_coords=gps) agent = get_agent(agent_id) - assert agent['gps_coords']['lat'] == 40.7128 - assert agent['gps_coords']['lon'] == -74.0060 + assert agent["gps_coords"]["lat"] == 40.7128 + assert agent["gps_coords"]["lon"] == -74.0060 def test_delete_agent(self): """delete_agent should remove agent and payloads.""" - agent_id = create_agent(name='to-delete', base_url='http://localhost:8020') + agent_id = create_agent(name="to-delete", base_url="http://localhost:8020") # Add a payload - store_push_payload(agent_id, 'adsb', {'aircraft': []}) + store_push_payload(agent_id, "adsb", {"aircraft": []}) # Delete result = delete_agent(agent_id) @@ -481,6 +469,7 @@ class TestDatabaseAgentCRUD: # Database Push Payload Tests # ============================================================================= + class TestDatabasePayloads: """Tests for push payload storage.""" @@ -489,12 +478,12 @@ class TestDatabasePayloads: """Set up a temporary database for each test.""" import utils.database as db_module - test_db_path = tmp_path / 'test.db' + test_db_path = tmp_path / "test.db" original_db_path = db_module.DB_PATH db_module.DB_PATH = test_db_path db_module.DB_DIR = tmp_path - if hasattr(db_module._local, 'connection') and db_module._local.connection: + if hasattr(db_module._local, "connection") and db_module._local.connection: db_module._local.connection.close() db_module._local.connection = None @@ -502,52 +491,52 @@ class TestDatabasePayloads: yield - if hasattr(db_module._local, 'connection') and db_module._local.connection: + if hasattr(db_module._local, "connection") and db_module._local.connection: db_module._local.connection.close() db_module._local.connection = None db_module.DB_PATH = original_db_path def test_store_push_payload(self): """store_push_payload should insert payload.""" - agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020') + agent_id = create_agent(name="sensor-1", base_url="http://localhost:8020") - payload = {'aircraft': [{'icao': 'ABC123', 'altitude': 35000}]} - payload_id = store_push_payload(agent_id, 'adsb', payload, 'rtlsdr0') + payload = {"aircraft": [{"icao": "ABC123", "altitude": 35000}]} + payload_id = store_push_payload(agent_id, "adsb", payload, "rtlsdr0") assert payload_id > 0 def test_get_recent_payloads(self): """get_recent_payloads should return stored payloads.""" - agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020') + agent_id = create_agent(name="sensor-1", base_url="http://localhost:8020") - store_push_payload(agent_id, 'adsb', {'aircraft': [{'icao': 'A'}]}) - store_push_payload(agent_id, 'adsb', {'aircraft': [{'icao': 'B'}]}) - store_push_payload(agent_id, 'wifi', {'networks': []}) + store_push_payload(agent_id, "adsb", {"aircraft": [{"icao": "A"}]}) + store_push_payload(agent_id, "adsb", {"aircraft": [{"icao": "B"}]}) + store_push_payload(agent_id, "wifi", {"networks": []}) # Get all payloads = get_recent_payloads(agent_id=agent_id) assert len(payloads) == 3 # Filter by scan_type - adsb_payloads = get_recent_payloads(agent_id=agent_id, scan_type='adsb') + adsb_payloads = get_recent_payloads(agent_id=agent_id, scan_type="adsb") assert len(adsb_payloads) == 2 def test_get_recent_payloads_includes_agent_name(self): """Payloads should include agent name.""" - agent_id = create_agent(name='my-sensor', base_url='http://localhost:8020') - store_push_payload(agent_id, 'sensor', {'temperature': 22.5}) + agent_id = create_agent(name="my-sensor", base_url="http://localhost:8020") + store_push_payload(agent_id, "sensor", {"temperature": 22.5}) payloads = get_recent_payloads(agent_id=agent_id) assert len(payloads) > 0 - assert payloads[0]['agent_name'] == 'my-sensor' + assert payloads[0]["agent_name"] == "my-sensor" def test_get_recent_payloads_limit(self): """get_recent_payloads should respect limit.""" - agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020') + agent_id = create_agent(name="sensor-1", base_url="http://localhost:8020") for i in range(10): - store_push_payload(agent_id, 'sensor', {'temp': i}) + store_push_payload(agent_id, "sensor", {"temp": i}) payloads = get_recent_payloads(agent_id=agent_id, limit=5) assert len(payloads) == 5 @@ -557,6 +546,7 @@ class TestDatabasePayloads: # Integration Tests # ============================================================================= + class TestAgentClientIntegration: """Integration tests using mock agent server.""" @@ -567,63 +557,65 @@ class TestAgentClientIntegration: from tests.mock_agent import app as mock_app # Run mock agent in background - mock_app.config['TESTING'] = True + mock_app.config["TESTING"] = True # Using Flask's test client instead of actual server return mock_app.test_client() def test_mock_agent_capabilities(self, mock_agent): """Mock agent should return capabilities.""" - response = mock_agent.get('/capabilities') + response = mock_agent.get("/capabilities") assert response.status_code == 200 data = json.loads(response.data) - assert 'modes' in data - assert data['modes']['adsb'] is True + assert "modes" in data + assert data["modes"]["adsb"] is True def test_mock_agent_start_stop_mode(self, mock_agent): """Mock agent should start/stop modes.""" # Start - response = mock_agent.post('/adsb/start', json={}) + response = mock_agent.post("/adsb/start", json={}) assert response.status_code == 200 data = json.loads(response.data) - assert data['status'] == 'started' + assert data["status"] == "started" # Check status - response = mock_agent.get('/status') + response = mock_agent.get("/status") data = json.loads(response.data) - assert 'adsb' in data['running_modes'] + assert "adsb" in data["running_modes"] # Stop - response = mock_agent.post('/adsb/stop', json={}) + response = mock_agent.post("/adsb/stop", json={}) assert response.status_code == 200 def test_mock_agent_data(self, mock_agent): """Mock agent should return data when mode is running.""" # Start mode first - mock_agent.post('/adsb/start', json={}) + mock_agent.post("/adsb/start", json={}) - response = mock_agent.get('/adsb/data') + response = mock_agent.get("/adsb/data") assert response.status_code == 200 data = json.loads(response.data) - assert 'data' in data + assert "data" in data # Data should be a list of aircraft - assert isinstance(data['data'], list) + assert isinstance(data["data"], list) # Cleanup - mock_agent.post('/adsb/stop', json={}) + mock_agent.post("/adsb/stop", json={}) # ============================================================================= # GPS Manager Tests # ============================================================================= + class TestGPSManager: """Tests for GPS integration in agent.""" def test_gps_manager_init(self): """GPSManager should initialize without error.""" from intercept_agent import GPSManager + gps = GPSManager() assert gps.position is None assert gps._running is False @@ -647,6 +639,6 @@ class TestGPSManager: pos = gps.position assert pos is not None - assert pos['lat'] == 40.7128 - assert pos['lon'] == -74.0060 - assert pos['altitude'] == 10.5 + assert pos["lat"] == 40.7128 + assert pos["lon"] == -74.0060 + assert pos["altitude"] == 10.5 diff --git a/tests/test_agent_integration.py b/tests/test_agent_integration.py index 52393c3..1db9137 100644 --- a/tests/test_agent_integration.py +++ b/tests/test_agent_integration.py @@ -44,14 +44,14 @@ RTL_433_SAMPLES = [ # Sample SBS (BaseStation) format lines from dump1090 SBS_SAMPLES = [ - 'MSG,1,1,1,A1B2C3,1,2024/01/15,10:30:00.000,2024/01/15,10:30:00.000,UAL123,,,,,,,,,,0', - 'MSG,3,1,1,A1B2C3,1,2024/01/15,10:30:01.000,2024/01/15,10:30:01.000,,35000,,,40.7128,-74.0060,,,0,0,0,0', - 'MSG,4,1,1,A1B2C3,1,2024/01/15,10:30:02.000,2024/01/15,10:30:02.000,,,450,180,,,1500,,,,,', - 'MSG,5,1,1,A1B2C3,1,2024/01/15,10:30:03.000,2024/01/15,10:30:03.000,UAL123,35000,,,,,,,,,', - 'MSG,6,1,1,A1B2C3,1,2024/01/15,10:30:04.000,2024/01/15,10:30:04.000,,,,,,,,,,1200', + "MSG,1,1,1,A1B2C3,1,2024/01/15,10:30:00.000,2024/01/15,10:30:00.000,UAL123,,,,,,,,,,0", + "MSG,3,1,1,A1B2C3,1,2024/01/15,10:30:01.000,2024/01/15,10:30:01.000,,35000,,,40.7128,-74.0060,,,0,0,0,0", + "MSG,4,1,1,A1B2C3,1,2024/01/15,10:30:02.000,2024/01/15,10:30:02.000,,,450,180,,,1500,,,,,", + "MSG,5,1,1,A1B2C3,1,2024/01/15,10:30:03.000,2024/01/15,10:30:03.000,UAL123,35000,,,,,,,,,", + "MSG,6,1,1,A1B2C3,1,2024/01/15,10:30:04.000,2024/01/15,10:30:04.000,,,,,,,,,,1200", # Second aircraft - 'MSG,1,1,1,D4E5F6,1,2024/01/15,10:30:05.000,2024/01/15,10:30:05.000,DAL456,,,,,,,,,,0', - 'MSG,3,1,1,D4E5F6,1,2024/01/15,10:30:06.000,2024/01/15,10:30:06.000,,28000,,,40.8000,-73.9500,,,0,0,0,0', + "MSG,1,1,1,D4E5F6,1,2024/01/15,10:30:05.000,2024/01/15,10:30:05.000,DAL456,,,,,,,,,,0", + "MSG,3,1,1,D4E5F6,1,2024/01/15,10:30:06.000,2024/01/15,10:30:06.000,,28000,,,40.8000,-73.9500,,,0,0,0,0", ] # Sample airodump-ng CSV output (matches real airodump format - no blank line between header and data) @@ -70,17 +70,19 @@ DE:AD:BE:EF:00:02, 2024-01-15 10:20:00, 2024-01-15 10:30:00, -75, 25, AA:B # Fixtures # ============================================================================= + @pytest.fixture def agent(): """Create a ModeManager instance for testing.""" from intercept_agent import ModeManager + return ModeManager() @pytest.fixture def temp_csv_file(): """Create a temp airodump CSV file.""" - with tempfile.NamedTemporaryFile(mode='w', suffix='-01.csv', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix="-01.csv", delete=False) as f: f.write(AIRODUMP_CSV_SAMPLE) path = f.name yield path[:-7] # Return base path without -01.csv suffix @@ -93,38 +95,41 @@ def temp_csv_file(): # Tool Detection Tests # ============================================================================= + class TestToolDetection: """Tests for tool availability detection.""" def test_rtl_433_available(self): """rtl_433 should be installed.""" - assert shutil.which('rtl_433') is not None + assert shutil.which("rtl_433") is not None def test_dump1090_available(self): """dump1090 should be installed.""" - assert shutil.which('dump1090') is not None or \ - shutil.which('dump1090-fa') is not None or \ - shutil.which('readsb') is not None + assert ( + shutil.which("dump1090") is not None + or shutil.which("dump1090-fa") is not None + or shutil.which("readsb") is not None + ) def test_airodump_available(self): """airodump-ng should be installed.""" - assert shutil.which('airodump-ng') is not None + assert shutil.which("airodump-ng") is not None def test_multimon_available(self): """multimon-ng should be installed.""" - assert shutil.which('multimon-ng') is not None + assert shutil.which("multimon-ng") is not None def test_acarsdec_available(self): """acarsdec should be installed.""" - assert shutil.which('acarsdec') is not None + assert shutil.which("acarsdec") is not None def test_agent_detects_tools(self, agent): """Agent should detect available tools.""" caps = agent.detect_capabilities() # These should all be True given the tools are installed - assert caps['modes']['sensor'] is True - assert caps['modes']['adsb'] is True + assert caps["modes"]["sensor"] is True + assert caps["modes"]["adsb"] is True # wifi requires airmon-ng too # bluetooth requires bluetoothctl @@ -134,11 +139,7 @@ class TestRTLSDRDetection: def test_rtl_test_runs(self): """rtl_test should run (even if no device).""" - result = subprocess.run( - ['rtl_test', '-t'], - capture_output=True, - timeout=5 - ) + result = subprocess.run(["rtl_test", "-t"], capture_output=True, timeout=5) # Will return 0 if device found, non-zero if not # We just verify it runs without crashing assert result.returncode in [0, 1, 255] @@ -149,25 +150,22 @@ class TestRTLSDRDetection: # If RTL-SDR is connected, devices list should be non-empty # This is hardware-dependent, so we just verify the key exists - assert 'devices' in caps + assert "devices" in caps @pytest.mark.live def test_rtl_sdr_present(self): """Verify RTL-SDR device is present (for live tests).""" - result = subprocess.run( - ['rtl_test', '-t'], - capture_output=True, - timeout=5 - ) - if b'Found 0 device' in result.stdout or b'No supported devices found' in result.stderr: + result = subprocess.run(["rtl_test", "-t"], capture_output=True, timeout=5) + if b"Found 0 device" in result.stdout or b"No supported devices found" in result.stderr: pytest.skip("No RTL-SDR device connected") - assert b'Found' in result.stdout + assert b"Found" in result.stdout # ============================================================================= # Parsing Tests (No Hardware Required) # ============================================================================= + class TestRTL433Parsing: """Tests for rtl_433 JSON output parsing.""" @@ -175,37 +173,37 @@ class TestRTL433Parsing: """Parse Acurite temperature sensor data.""" data = json.loads(RTL_433_SAMPLES[0]) - assert data['model'] == 'Acurite-Tower' - assert data['id'] == 12345 - assert data['temperature_C'] == 22.5 - assert data['humidity'] == 45 - assert data['battery_ok'] == 1 + assert data["model"] == "Acurite-Tower" + assert data["id"] == 12345 + assert data["temperature_C"] == 22.5 + assert data["humidity"] == 45 + assert data["battery_ok"] == 1 def test_parse_oregon_sensor(self): """Parse Oregon Scientific sensor data.""" data = json.loads(RTL_433_SAMPLES[1]) - assert data['model'] == 'Oregon-THGR122N' - assert data['temperature_C'] == 18.3 + assert data["model"] == "Oregon-THGR122N" + assert data["temperature_C"] == 18.3 def test_parse_negative_temperature(self): """Parse sensor with negative temperature.""" data = json.loads(RTL_433_SAMPLES[2]) - assert data['model'] == 'LaCrosse-TX141W' - assert data['temperature_C'] == -5.2 + assert data["model"] == "LaCrosse-TX141W" + assert data["temperature_C"] == -5.2 def test_agent_sensor_data_format(self, agent): """Agent should format sensor data correctly for controller.""" # Simulate processing sample = json.loads(RTL_433_SAMPLES[0]) - sample['type'] = 'sensor' - sample['received_at'] = '2024-01-15T10:30:00Z' + sample["type"] = "sensor" + sample["received_at"] = "2024-01-15T10:30:00Z" # Verify required fields for controller - assert 'model' in sample - assert 'temperature_C' in sample or 'temperature_F' in sample - assert 'received_at' in sample + assert "model" in sample + assert "temperature_C" in sample or "temperature_F" in sample + assert "received_at" in sample class TestSBSParsing: @@ -216,63 +214,63 @@ class TestSBSParsing: line = SBS_SAMPLES[0] agent._parse_sbs_line(line) - aircraft = agent.adsb_aircraft.get('A1B2C3') + aircraft = agent.adsb_aircraft.get("A1B2C3") assert aircraft is not None - assert aircraft['callsign'] == 'UAL123' + assert aircraft["callsign"] == "UAL123" def test_parse_msg3_position(self, agent): """MSG,3 should extract altitude and position.""" agent._parse_sbs_line(SBS_SAMPLES[0]) # First need MSG,1 for ICAO agent._parse_sbs_line(SBS_SAMPLES[1]) - aircraft = agent.adsb_aircraft.get('A1B2C3') + aircraft = agent.adsb_aircraft.get("A1B2C3") assert aircraft is not None - assert aircraft['altitude'] == 35000 - assert abs(aircraft['lat'] - 40.7128) < 0.0001 - assert abs(aircraft['lon'] - (-74.0060)) < 0.0001 + assert aircraft["altitude"] == 35000 + assert abs(aircraft["lat"] - 40.7128) < 0.0001 + assert abs(aircraft["lon"] - (-74.0060)) < 0.0001 def test_parse_msg4_velocity(self, agent): """MSG,4 should extract speed and heading.""" agent._parse_sbs_line(SBS_SAMPLES[0]) agent._parse_sbs_line(SBS_SAMPLES[2]) - aircraft = agent.adsb_aircraft.get('A1B2C3') + aircraft = agent.adsb_aircraft.get("A1B2C3") assert aircraft is not None - assert aircraft['speed'] == 450 - assert aircraft['heading'] == 180 - assert aircraft['vertical_rate'] == 1500 + assert aircraft["speed"] == 450 + assert aircraft["heading"] == 180 + assert aircraft["vertical_rate"] == 1500 def test_parse_msg6_squawk(self, agent): """MSG,6 should extract squawk code.""" agent._parse_sbs_line(SBS_SAMPLES[0]) agent._parse_sbs_line(SBS_SAMPLES[4]) - aircraft = agent.adsb_aircraft.get('A1B2C3') + aircraft = agent.adsb_aircraft.get("A1B2C3") assert aircraft is not None # Squawk may not be present if MSG,6 format doesn't have enough fields # The sample line may need adjustment - check if squawk was parsed - if 'squawk' in aircraft: - assert aircraft['squawk'] == '1200' + if "squawk" in aircraft: + assert aircraft["squawk"] == "1200" def test_parse_multiple_aircraft(self, agent): """Should track multiple aircraft simultaneously.""" for line in SBS_SAMPLES: agent._parse_sbs_line(line) - assert 'A1B2C3' in agent.adsb_aircraft - assert 'D4E5F6' in agent.adsb_aircraft - assert agent.adsb_aircraft['D4E5F6']['callsign'] == 'DAL456' + assert "A1B2C3" in agent.adsb_aircraft + assert "D4E5F6" in agent.adsb_aircraft + assert agent.adsb_aircraft["D4E5F6"]["callsign"] == "DAL456" def test_parse_malformed_sbs(self, agent): """Should handle malformed SBS lines gracefully.""" # Too few fields - agent._parse_sbs_line('MSG,1,1') + agent._parse_sbs_line("MSG,1,1") # Not MSG type - agent._parse_sbs_line('SEL,1,1,1,ABC123,1') + agent._parse_sbs_line("SEL,1,1,1,ABC123,1") # Empty line - agent._parse_sbs_line('') + agent._parse_sbs_line("") # Garbage - agent._parse_sbs_line('not,valid,sbs,data') + agent._parse_sbs_line("not,valid,sbs,data") # Should not crash, aircraft dict should be empty assert len(agent.adsb_aircraft) == 0 @@ -284,51 +282,52 @@ class TestAirodumpParsing: def test_intercept_parser_available(self): """Intercept's airodump parser should be importable.""" from utils.wifi.parsers.airodump import parse_airodump_csv + assert callable(parse_airodump_csv) def test_parse_csv_networks_with_intercept_parser(self, temp_csv_file): """Intercept parser should parse network section of CSV.""" from utils.wifi.parsers.airodump import parse_airodump_csv - networks, clients = parse_airodump_csv(temp_csv_file + '-01.csv') + networks, clients = parse_airodump_csv(temp_csv_file + "-01.csv") assert len(networks) >= 3 # Find HomeWiFi network by BSSID - home_wifi = next((n for n in networks if n.bssid == '00:11:22:33:44:55'), None) + home_wifi = next((n for n in networks if n.bssid == "00:11:22:33:44:55"), None) assert home_wifi is not None - assert home_wifi.essid == 'HomeWiFi' + assert home_wifi.essid == "HomeWiFi" assert home_wifi.channel == 6 assert home_wifi.rssi == -55 - assert 'WPA2' in home_wifi.security # Could be 'WPA2' or 'WPA/WPA2' + assert "WPA2" in home_wifi.security # Could be 'WPA2' or 'WPA/WPA2' def test_parse_csv_clients_with_intercept_parser(self, temp_csv_file): """Intercept parser should parse client section of CSV.""" from utils.wifi.parsers.airodump import parse_airodump_csv - networks, clients = parse_airodump_csv(temp_csv_file + '-01.csv') + networks, clients = parse_airodump_csv(temp_csv_file + "-01.csv") assert len(clients) >= 2 # Client should have MAC and associated BSSID - assert any(c.get('mac') == 'CA:FE:BA:BE:00:01' for c in clients) + assert any(c.get("mac") == "CA:FE:BA:BE:00:01" for c in clients) def test_agent_uses_intercept_parser(self, agent, temp_csv_file): """Agent should use Intercept's parser when available.""" - networks, clients = agent._parse_airodump_csv(temp_csv_file + '-01.csv', None) + networks, clients = agent._parse_airodump_csv(temp_csv_file + "-01.csv", None) # Should return dict format assert isinstance(networks, dict) assert len(networks) >= 3 # Check a network entry - home_wifi = networks.get('00:11:22:33:44:55') + home_wifi = networks.get("00:11:22:33:44:55") assert home_wifi is not None - assert home_wifi['essid'] == 'HomeWiFi' - assert home_wifi['channel'] == 6 + assert home_wifi["essid"] == "HomeWiFi" + assert home_wifi["channel"] == 6 def test_parse_csv_clients(self, agent, temp_csv_file): """Agent should parse clients correctly.""" - networks, clients = agent._parse_airodump_csv(temp_csv_file + '-01.csv', None) + networks, clients = agent._parse_airodump_csv(temp_csv_file + "-01.csv", None) assert len(clients) >= 2 @@ -337,6 +336,7 @@ class TestAirodumpParsing: # Live Tool Tests (Require Hardware) # ============================================================================= + @pytest.mark.live class TestLiveRTL433: """Live tests with rtl_433 (requires RTL-SDR).""" @@ -344,9 +344,9 @@ class TestLiveRTL433: def test_rtl_433_runs(self): """rtl_433 should start and produce output.""" proc = subprocess.Popen( - ['rtl_433', '-F', 'json', '-T', '3'], # Run for 3 seconds + ["rtl_433", "-F", "json", "-T", "3"], # Run for 3 seconds stdout=subprocess.PIPE, - stderr=subprocess.PIPE + stderr=subprocess.PIPE, ) try: @@ -360,21 +360,17 @@ class TestLiveRTL433: def test_rtl_433_json_output(self): """rtl_433 JSON output should be parseable.""" - proc = subprocess.Popen( - ['rtl_433', '-F', 'json', '-T', '5'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + proc = subprocess.Popen(["rtl_433", "-F", "json", "-T", "5"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) try: stdout, _ = proc.communicate(timeout=10) # If we got any output, verify it's valid JSON - for line in stdout.decode('utf-8', errors='ignore').split('\n'): + for line in stdout.decode("utf-8", errors="ignore").split("\n"): line = line.strip() if line: try: data = json.loads(line) - assert 'model' in data or 'time' in data + assert "model" in data or "time" in data except json.JSONDecodeError: pass # May be startup messages except subprocess.TimeoutExpired: @@ -387,28 +383,25 @@ class TestLiveDump1090: def test_dump1090_starts(self): """dump1090 should start successfully.""" - dump1090_path = shutil.which('dump1090') or shutil.which('dump1090-fa') + dump1090_path = shutil.which("dump1090") or shutil.which("dump1090-fa") if not dump1090_path: pytest.skip("dump1090 not installed") - proc = subprocess.Popen( - [dump1090_path, '--net', '--quiet'], - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE - ) + proc = subprocess.Popen([dump1090_path, "--net", "--quiet"], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) try: time.sleep(2) if proc.poll() is not None: stderr = proc.stderr.read().decode() - if 'No supported RTLSDR devices found' in stderr: + if "No supported RTLSDR devices found" in stderr: pytest.skip("No RTL-SDR for ADS-B") pytest.fail(f"dump1090 exited: {stderr}") # Verify SBS port is open import socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - result = sock.connect_ex(('localhost', 30003)) + result = sock.connect_ex(("localhost", 30003)) sock.close() assert result == 0, "SBS port 30003 not open" @@ -424,56 +417,57 @@ class TestLiveAgentModes: def test_agent_sensor_mode(self, agent): """Agent should start and stop sensor mode.""" - result = agent.start_mode('sensor', {}) + result = agent.start_mode("sensor", {}) - if result.get('status') == 'error': - if 'not found' in result.get('message', ''): + if result.get("status") == "error": + if "not found" in result.get("message", ""): pytest.skip("rtl_433 not found") - if 'device' in result.get('message', '').lower(): + if "device" in result.get("message", "").lower(): pytest.skip("No RTL-SDR device") - assert result['status'] == 'started' - assert 'sensor' in agent.running_modes + assert result["status"] == "started" + assert "sensor" in agent.running_modes # Let it run briefly time.sleep(2) # Check status - status = agent.get_mode_status('sensor') - assert status['running'] is True + status = agent.get_mode_status("sensor") + assert status["running"] is True # Stop - stop_result = agent.stop_mode('sensor') - assert stop_result['status'] == 'stopped' - assert 'sensor' not in agent.running_modes + stop_result = agent.stop_mode("sensor") + assert stop_result["status"] == "stopped" + assert "sensor" not in agent.running_modes def test_agent_adsb_mode(self, agent): """Agent should start and stop ADS-B mode.""" - result = agent.start_mode('adsb', {}) + result = agent.start_mode("adsb", {}) - if result.get('status') == 'error': - if 'not found' in result.get('message', ''): + if result.get("status") == "error": + if "not found" in result.get("message", ""): pytest.skip("dump1090 not found") - if 'device' in result.get('message', '').lower(): + if "device" in result.get("message", "").lower(): pytest.skip("No RTL-SDR device") - assert result['status'] == 'started' + assert result["status"] == "started" # Let it run briefly time.sleep(3) # Get data (may be empty if no aircraft) - data = agent.get_mode_data('adsb') - assert 'data' in data + data = agent.get_mode_data("adsb") + assert "data" in data # Stop - agent.stop_mode('adsb') + agent.stop_mode("adsb") # ============================================================================= # Controller Integration Tests # ============================================================================= + class TestAgentControllerFormat: """Tests that agent output matches controller expectations.""" @@ -481,18 +475,18 @@ class TestAgentControllerFormat: """Sensor data should have required fields for controller.""" # Simulate parsed data sample = { - 'model': 'Acurite-Tower', - 'id': 12345, - 'temperature_C': 22.5, - 'humidity': 45, - 'type': 'sensor', - 'received_at': '2024-01-15T10:30:00Z' + "model": "Acurite-Tower", + "id": 12345, + "temperature_C": 22.5, + "humidity": 45, + "type": "sensor", + "received_at": "2024-01-15T10:30:00Z", } # Should be serializable json_str = json.dumps(sample) restored = json.loads(json_str) - assert restored['model'] == 'Acurite-Tower' + assert restored["model"] == "Acurite-Tower" def test_adsb_data_format(self, agent): """ADS-B data should have required fields for controller.""" @@ -501,35 +495,31 @@ class TestAgentControllerFormat: agent._parse_sbs_line(SBS_SAMPLES[1]) agent._parse_sbs_line(SBS_SAMPLES[2]) - data = agent.get_mode_data('adsb') + data = agent.get_mode_data("adsb") # Should be list format - assert isinstance(data['data'], list) + assert isinstance(data["data"], list) - if data['data']: - aircraft = data['data'][0] - assert 'icao' in aircraft - assert 'last_seen' in aircraft + if data["data"]: + aircraft = data["data"][0] + assert "icao" in aircraft + assert "last_seen" in aircraft def test_push_payload_format(self, agent): """Push payload should match controller ingest format.""" # Simulate what agent sends to controller payload = { - 'agent_name': 'test-sensor', - 'scan_type': 'adsb', - 'interface': 'rtlsdr0', - 'payload': { - 'aircraft': [ - {'icao': 'A1B2C3', 'callsign': 'UAL123', 'altitude': 35000} - ] - }, - 'received_at': '2024-01-15T10:30:00Z' + "agent_name": "test-sensor", + "scan_type": "adsb", + "interface": "rtlsdr0", + "payload": {"aircraft": [{"icao": "A1B2C3", "callsign": "UAL123", "altitude": 35000}]}, + "received_at": "2024-01-15T10:30:00Z", } # Verify structure - assert 'agent_name' in payload - assert 'scan_type' in payload - assert 'payload' in payload + assert "agent_name" in payload + assert "scan_type" in payload + assert "payload" in payload # Should be JSON serializable json_str = json.dumps(payload) @@ -540,15 +530,16 @@ class TestAgentControllerFormat: # GPS Integration Tests # ============================================================================= + class TestGPSIntegration: """Tests for GPS data in agent output.""" def test_data_includes_gps_field(self, agent): """Data should include GPS position if available.""" - data = agent.get_mode_data('sensor') + data = agent.get_mode_data("sensor") # agent_gps field should exist (may be None if no GPS) - assert 'agent_gps' in data or data.get('agent_gps') is None + assert "agent_gps" in data or data.get("agent_gps") is None def test_gps_position_format(self): """GPS position should have lat/lon fields.""" @@ -569,15 +560,15 @@ class TestGPSIntegration: pos = gps.position assert pos is not None - assert 'lat' in pos - assert 'lon' in pos - assert pos['lat'] == 40.7128 - assert pos['lon'] == -74.0060 + assert "lat" in pos + assert "lon" in pos + assert pos["lat"] == 40.7128 + assert pos["lon"] == -74.0060 # ============================================================================= # Run Tests # ============================================================================= -if __name__ == '__main__': - pytest.main([__file__, '-v', '-m', 'not live']) +if __name__ == "__main__": + pytest.main([__file__, "-v", "-m", "not live"]) diff --git a/tests/test_bluetooth_proximity.py b/tests/test_bluetooth_proximity.py index 2158d04..c33d08a 100644 --- a/tests/test_bluetooth_proximity.py +++ b/tests/test_bluetooth_proximity.py @@ -30,52 +30,52 @@ class TestDeviceKey: def test_identity_address_takes_priority(self): """Identity address should always be used when available.""" key = generate_device_key( - address='AA:BB:CC:DD:EE:FF', - address_type='rpa', - identity_address='11:22:33:44:55:66', - name='Test Device', + address="AA:BB:CC:DD:EE:FF", + address_type="rpa", + identity_address="11:22:33:44:55:66", + name="Test Device", manufacturer_id=76, ) - assert key == 'id:11:22:33:44:55:66' + assert key == "id:11:22:33:44:55:66" def test_public_mac_used_directly(self): """Public MAC addresses should be used directly.""" key = generate_device_key( - address='AA:BB:CC:DD:EE:FF', - address_type='public', + address="AA:BB:CC:DD:EE:FF", + address_type="public", ) - assert key == 'mac:AA:BB:CC:DD:EE:FF' + assert key == "mac:AA:BB:CC:DD:EE:FF" def test_static_random_mac_used_directly(self): """Random static addresses should be used directly.""" key = generate_device_key( - address='CA:BB:CC:DD:EE:FF', - address_type='random_static', + address="CA:BB:CC:DD:EE:FF", + address_type="random_static", ) - assert key == 'mac:CA:BB:CC:DD:EE:FF' + assert key == "mac:CA:BB:CC:DD:EE:FF" def test_random_address_fingerprint_with_name(self): """Random addresses should generate fingerprint from name.""" key = generate_device_key( - address='AA:BB:CC:DD:EE:FF', - address_type='rpa', - name='AirPods Pro', + address="AA:BB:CC:DD:EE:FF", + address_type="rpa", + name="AirPods Pro", ) - assert key.startswith('fp:') + assert key.startswith("fp:") assert len(key) == 19 # 'fp:' + 16 hex chars def test_random_address_fingerprint_stability(self): """Same name/mfr/services should produce same fingerprint key.""" key1 = generate_device_key( - address='AA:BB:CC:DD:EE:FF', - address_type='rpa', - name='AirPods Pro', + address="AA:BB:CC:DD:EE:FF", + address_type="rpa", + name="AirPods Pro", manufacturer_id=76, ) key2 = generate_device_key( - address='11:22:33:44:55:66', # Different address - address_type='nrpa', - name='AirPods Pro', + address="11:22:33:44:55:66", # Different address + address_type="nrpa", + name="AirPods Pro", manufacturer_id=76, ) assert key1 == key2 @@ -83,53 +83,53 @@ class TestDeviceKey: def test_different_names_produce_different_keys(self): """Different names should produce different fingerprint keys.""" key1 = generate_device_key( - address='AA:BB:CC:DD:EE:FF', - address_type='rpa', - name='AirPods Pro', + address="AA:BB:CC:DD:EE:FF", + address_type="rpa", + name="AirPods Pro", ) key2 = generate_device_key( - address='AA:BB:CC:DD:EE:FF', - address_type='rpa', - name='AirPods Max', + address="AA:BB:CC:DD:EE:FF", + address_type="rpa", + name="AirPods Max", ) assert key1 != key2 def test_random_address_fallback_to_mac(self): """Random addresses without fingerprint data fall back to MAC.""" key = generate_device_key( - address='AA:BB:CC:DD:EE:FF', - address_type='rpa', + address="AA:BB:CC:DD:EE:FF", + address_type="rpa", # No name, manufacturer, or services ) - assert key == 'mac:AA:BB:CC:DD:EE:FF' + assert key == "mac:AA:BB:CC:DD:EE:FF" def test_is_randomized_mac_public(self): """Public addresses are not randomized.""" - assert is_randomized_mac('public') is False + assert is_randomized_mac("public") is False def test_is_randomized_mac_random_static(self): """Random static addresses are not randomized.""" - assert is_randomized_mac('random_static') is False + assert is_randomized_mac("random_static") is False def test_is_randomized_mac_rpa(self): """RPA addresses are randomized.""" - assert is_randomized_mac('rpa') is True + assert is_randomized_mac("rpa") is True def test_is_randomized_mac_nrpa(self): """NRPA addresses are randomized.""" - assert is_randomized_mac('nrpa') is True + assert is_randomized_mac("nrpa") is True def test_extract_key_type_id(self): """Extract type from identity key.""" - assert extract_key_type('id:11:22:33:44:55:66') == 'id' + assert extract_key_type("id:11:22:33:44:55:66") == "id" def test_extract_key_type_mac(self): """Extract type from MAC key.""" - assert extract_key_type('mac:AA:BB:CC:DD:EE:FF') == 'mac' + assert extract_key_type("mac:AA:BB:CC:DD:EE:FF") == "mac" def test_extract_key_type_fingerprint(self): """Extract type from fingerprint key.""" - assert extract_key_type('fp:abcd1234efgh5678') == 'fp' + assert extract_key_type("fp:abcd1234efgh5678") == "fp" class TestDistanceEstimator: @@ -257,38 +257,38 @@ class TestRingBuffer: def test_ingest_new_device(self, buffer): """Ingesting a new device should succeed.""" now = datetime.now() - result = buffer.ingest('device:1', rssi=-50, timestamp=now) + result = buffer.ingest("device:1", rssi=-50, timestamp=now) assert result is True assert buffer.get_device_count() == 1 - assert buffer.get_observation_count('device:1') == 1 + assert buffer.get_observation_count("device:1") == 1 def test_ingest_rate_limited(self, buffer): """Ingestion should be rate-limited to min_interval.""" now = datetime.now() - buffer.ingest('device:1', rssi=-50, timestamp=now) + buffer.ingest("device:1", rssi=-50, timestamp=now) # Try to ingest again within rate limit (1 second later) - result = buffer.ingest('device:1', rssi=-55, timestamp=now + timedelta(seconds=1)) + result = buffer.ingest("device:1", rssi=-55, timestamp=now + timedelta(seconds=1)) assert result is False - assert buffer.get_observation_count('device:1') == 1 + assert buffer.get_observation_count("device:1") == 1 def test_ingest_after_interval(self, buffer): """Ingestion should succeed after min_interval.""" now = datetime.now() - buffer.ingest('device:1', rssi=-50, timestamp=now) + buffer.ingest("device:1", rssi=-50, timestamp=now) # Ingest after rate limit passes (3 seconds later) - result = buffer.ingest('device:1', rssi=-55, timestamp=now + timedelta(seconds=3)) + result = buffer.ingest("device:1", rssi=-55, timestamp=now + timedelta(seconds=3)) assert result is True - assert buffer.get_observation_count('device:1') == 2 + assert buffer.get_observation_count("device:1") == 2 def test_prune_old_observations(self, buffer): """Old observations should be pruned.""" now = datetime.now() old_time = now - timedelta(minutes=45) # Older than retention - buffer.ingest('device:1', rssi=-50, timestamp=old_time) - buffer.ingest('device:2', rssi=-60, timestamp=now) + buffer.ingest("device:1", rssi=-50, timestamp=old_time) + buffer.ingest("device:2", rssi=-60, timestamp=now) removed = buffer.prune_old() assert removed == 1 @@ -301,46 +301,46 @@ class TestRingBuffer: # Add observations for i in range(10): ts = now - timedelta(seconds=i * 5) - buffer.ingest('device:1', rssi=-50 - i, timestamp=ts) + buffer.ingest("device:1", rssi=-50 - i, timestamp=ts) - timeseries = buffer.get_timeseries('device:1', window_minutes=5, downsample_seconds=10) + timeseries = buffer.get_timeseries("device:1", window_minutes=5, downsample_seconds=10) assert isinstance(timeseries, list) assert len(timeseries) > 0 for point in timeseries: - assert 'timestamp' in point - assert 'rssi' in point + assert "timestamp" in point + assert "rssi" in point def test_get_timeseries_empty_device(self, buffer): """Unknown device should return empty timeseries.""" - timeseries = buffer.get_timeseries('unknown:device') + timeseries = buffer.get_timeseries("unknown:device") assert timeseries == [] def test_get_all_timeseries_sorted_by_recency(self, buffer): """All timeseries should be sorted by recency.""" now = datetime.now() - buffer.ingest('device:old', rssi=-50, timestamp=now - timedelta(minutes=5)) - buffer.ingest('device:new', rssi=-60, timestamp=now) + buffer.ingest("device:old", rssi=-50, timestamp=now - timedelta(minutes=5)) + buffer.ingest("device:new", rssi=-60, timestamp=now) - all_ts = buffer.get_all_timeseries(sort_by='recency') + all_ts = buffer.get_all_timeseries(sort_by="recency") keys = list(all_ts.keys()) - assert keys[0] == 'device:new' # Most recent first + assert keys[0] == "device:new" # Most recent first def test_get_all_timeseries_sorted_by_strength(self, buffer): """All timeseries should be sortable by signal strength.""" now = datetime.now() - buffer.ingest('device:weak', rssi=-80, timestamp=now) - buffer.ingest('device:strong', rssi=-40, timestamp=now + timedelta(seconds=3)) + buffer.ingest("device:weak", rssi=-80, timestamp=now) + buffer.ingest("device:strong", rssi=-40, timestamp=now + timedelta(seconds=3)) - all_ts = buffer.get_all_timeseries(sort_by='strength') + all_ts = buffer.get_all_timeseries(sort_by="strength") keys = list(all_ts.keys()) - assert keys[0] == 'device:strong' # Strongest first + assert keys[0] == "device:strong" # Strongest first def test_get_all_timeseries_top_n_limit(self, buffer): """Top N should limit returned devices.""" now = datetime.now() for i in range(10): - buffer.ingest(f'device:{i}', rssi=-50, timestamp=now + timedelta(seconds=i * 3)) + buffer.ingest(f"device:{i}", rssi=-50, timestamp=now + timedelta(seconds=i * 3)) all_ts = buffer.get_all_timeseries(top_n=5) assert len(all_ts) == 5 @@ -348,8 +348,8 @@ class TestRingBuffer: def test_clear(self, buffer): """Clear should remove all observations.""" now = datetime.now() - buffer.ingest('device:1', rssi=-50, timestamp=now) - buffer.ingest('device:2', rssi=-60, timestamp=now) + buffer.ingest("device:1", rssi=-50, timestamp=now) + buffer.ingest("device:2", rssi=-60, timestamp=now) buffer.clear() assert buffer.get_device_count() == 0 @@ -359,37 +359,37 @@ class TestRingBuffer: now = datetime.now() # Add multiple observations in same 10s bucket - buffer._observations['device:1'] = [ + buffer._observations["device:1"] = [ (now, -50), (now + timedelta(seconds=1), -60), (now + timedelta(seconds=2), -55), ] - buffer._last_ingested['device:1'] = now + timedelta(seconds=2) + buffer._last_ingested["device:1"] = now + timedelta(seconds=2) - timeseries = buffer.get_timeseries('device:1', window_minutes=5, downsample_seconds=10) + timeseries = buffer.get_timeseries("device:1", window_minutes=5, downsample_seconds=10) assert len(timeseries) == 1 # Average of -50, -60, -55 = -55 - assert timeseries[0]['rssi'] == -55.0 + assert timeseries[0]["rssi"] == -55.0 def test_get_device_stats(self, buffer): """Device stats should return correct values.""" now = datetime.now() - buffer._observations['device:1'] = [ + buffer._observations["device:1"] = [ (now - timedelta(seconds=10), -50), (now - timedelta(seconds=5), -60), (now, -55), ] - stats = buffer.get_device_stats('device:1') + stats = buffer.get_device_stats("device:1") assert stats is not None - assert stats['observation_count'] == 3 - assert stats['rssi_min'] == -60 - assert stats['rssi_max'] == -50 - assert stats['rssi_avg'] == -55.0 + assert stats["observation_count"] == 3 + assert stats["rssi_min"] == -60 + assert stats["rssi_max"] == -50 + assert stats["rssi_avg"] == -55.0 def test_get_device_stats_unknown_device(self, buffer): """Unknown device should return None.""" - stats = buffer.get_device_stats('unknown:device') + stats = buffer.get_device_stats("unknown:device") assert stats is None @@ -398,17 +398,17 @@ class TestProximityBand: def test_proximity_band_str(self): """ProximityBand should convert to string correctly.""" - assert str(ProximityBand.IMMEDIATE) == 'immediate' - assert str(ProximityBand.NEAR) == 'near' - assert str(ProximityBand.FAR) == 'far' - assert str(ProximityBand.UNKNOWN) == 'unknown' + assert str(ProximityBand.IMMEDIATE) == "immediate" + assert str(ProximityBand.NEAR) == "near" + assert str(ProximityBand.FAR) == "far" + assert str(ProximityBand.UNKNOWN) == "unknown" def test_proximity_band_values(self): """ProximityBand values should match expected strings.""" - assert ProximityBand.IMMEDIATE.value == 'immediate' - assert ProximityBand.NEAR.value == 'near' - assert ProximityBand.FAR.value == 'far' - assert ProximityBand.UNKNOWN.value == 'unknown' + assert ProximityBand.IMMEDIATE.value == "immediate" + assert ProximityBand.NEAR.value == "near" + assert ProximityBand.FAR.value == "far" + assert ProximityBand.UNKNOWN.value == "unknown" class TestRssiThresholds: diff --git a/tests/test_bt_locate.py b/tests/test_bt_locate.py index 12a4eee..57d8768 100644 --- a/tests/test_bt_locate.py +++ b/tests/test_bt_locate.py @@ -7,6 +7,7 @@ import pytest try: from cryptography.hazmat.primitives.ciphers import Cipher, algorithms from cryptography.hazmat.primitives.ciphers import modes as cipher_modes + HAS_CRYPTOGRAPHY = True except ImportError: HAS_CRYPTOGRAPHY = False @@ -34,10 +35,10 @@ class TestResolveRPA: """ # The ah() function: encrypt(IRK, 0x00..00 || prand) then take last 3 bytes - irk = b'\x00' * 16 + irk = b"\x00" * 16 # Choose prand with upper 2 bits = 01 (resolvable) prand = bytes([0x40, 0x00, 0x01]) - plaintext = b'\x00' * 13 + prand + plaintext = b"\x00" * 13 + prand c = Cipher(algorithms.AES(irk), cipher_modes.ECB()) enc = c.encryptor() encrypted = enc.update(plaintext) + enc.finalize() @@ -45,41 +46,41 @@ class TestResolveRPA: # Build address: prand || hash addr_bytes = prand + hash_bytes - address = ':'.join(f'{b:02X}' for b in addr_bytes) + address = ":".join(f"{b:02X}" for b in addr_bytes) assert resolve_rpa(irk, address) is True def test_resolve_rpa_invalid_address(self): """Test RPA resolution with non-matching address.""" - irk = b'\x00' * 16 + irk = b"\x00" * 16 # Non-resolvable address (upper 2 bits != 01) - assert resolve_rpa(irk, 'FF:FF:FF:FF:FF:FF') is False + assert resolve_rpa(irk, "FF:FF:FF:FF:FF:FF") is False @pytest.mark.skipif(not HAS_CRYPTOGRAPHY, reason="cryptography not installed") def test_resolve_rpa_wrong_irk(self): """Test RPA resolution with wrong IRK.""" - irk = b'\x00' * 16 + irk = b"\x00" * 16 prand = bytes([0x40, 0x00, 0x01]) - plaintext = b'\x00' * 13 + prand + plaintext = b"\x00" * 13 + prand c = Cipher(algorithms.AES(irk), cipher_modes.ECB()) enc = c.encryptor() encrypted = enc.update(plaintext) + enc.finalize() hash_bytes = encrypted[13:16] addr_bytes = prand + hash_bytes - address = ':'.join(f'{b:02X}' for b in addr_bytes) + address = ":".join(f"{b:02X}" for b in addr_bytes) # Different IRK should fail - wrong_irk = b'\x01' * 16 + wrong_irk = b"\x01" * 16 assert resolve_rpa(wrong_irk, address) is False def test_resolve_rpa_short_address(self): """Test with invalid short address.""" - irk = b'\x00' * 16 - assert resolve_rpa(irk, 'AA:BB') is False + irk = b"\x00" * 16 + assert resolve_rpa(irk, "AA:BB") is False def test_resolve_rpa_empty(self): """Test with empty inputs.""" - assert resolve_rpa(b'\x00' * 16, '') is False + assert resolve_rpa(b"\x00" * 16, "") is False class TestDistanceEstimator: @@ -108,82 +109,82 @@ class TestDistanceEstimator: assert d_indoor < d_free def test_proximity_band_immediate(self): - assert DistanceEstimator.proximity_band(0.5) == 'IMMEDIATE' + assert DistanceEstimator.proximity_band(0.5) == "IMMEDIATE" def test_proximity_band_near(self): - assert DistanceEstimator.proximity_band(3.0) == 'NEAR' + assert DistanceEstimator.proximity_band(3.0) == "NEAR" def test_proximity_band_far(self): - assert DistanceEstimator.proximity_band(10.0) == 'FAR' + assert DistanceEstimator.proximity_band(10.0) == "FAR" class TestLocateTarget: """Test target matching.""" def test_match_by_mac(self): - target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF') + target = LocateTarget(mac_address="AA:BB:CC:DD:EE:FF") device = MagicMock() - device.device_id = 'other' - device.address = 'AA:BB:CC:DD:EE:FF' + device.device_id = "other" + device.address = "AA:BB:CC:DD:EE:FF" device.name = None assert target.matches(device) is True - def test_match_by_mac_case_insensitive(self): - target = LocateTarget(mac_address='aa:bb:cc:dd:ee:ff') - device = MagicMock() - device.device_id = 'other' - device.address = 'AA:BB:CC:DD:EE:FF' - device.name = None - assert target.matches(device) is True - - def test_match_by_mac_without_separators(self): - target = LocateTarget(mac_address='aabbccddeeff') - device = MagicMock() - device.device_id = 'other' - device.address = 'AA:BB:CC:DD:EE:FF' - device.name = None - assert target.matches(device) is True + def test_match_by_mac_case_insensitive(self): + target = LocateTarget(mac_address="aa:bb:cc:dd:ee:ff") + device = MagicMock() + device.device_id = "other" + device.address = "AA:BB:CC:DD:EE:FF" + device.name = None + assert target.matches(device) is True + + def test_match_by_mac_without_separators(self): + target = LocateTarget(mac_address="aabbccddeeff") + device = MagicMock() + device.device_id = "other" + device.address = "AA:BB:CC:DD:EE:FF" + device.name = None + assert target.matches(device) is True def test_match_by_name_pattern(self): - target = LocateTarget(name_pattern='iPhone') + target = LocateTarget(name_pattern="iPhone") device = MagicMock() - device.device_id = 'other' - device.address = '00:00:00:00:00:00' + device.device_id = "other" + device.address = "00:00:00:00:00:00" device.name = "John's iPhone 15" assert target.matches(device) is True def test_no_match(self): - target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF') + target = LocateTarget(mac_address="AA:BB:CC:DD:EE:FF") device = MagicMock() - device.device_id = 'other' - device.address = '11:22:33:44:55:66' + device.device_id = "other" + device.address = "11:22:33:44:55:66" device.name = None assert target.matches(device) is False def test_match_by_device_id(self): - target = LocateTarget(device_id='my-device-123') + target = LocateTarget(device_id="my-device-123") device = MagicMock() - device.device_id = 'my-device-123' - device.address = '00:00:00:00:00:00' + device.device_id = "my-device-123" + device.address = "00:00:00:00:00:00" device.name = None assert target.matches(device) is True def test_to_dict(self): - target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF', known_name='Test') + target = LocateTarget(mac_address="AA:BB:CC:DD:EE:FF", known_name="Test") d = target.to_dict() - assert d['mac_address'] == 'AA:BB:CC:DD:EE:FF' - assert d['known_name'] == 'Test' + assert d["mac_address"] == "AA:BB:CC:DD:EE:FF" + assert d["known_name"] == "Test" class TestLocateSession: """Test locate session lifecycle.""" - @patch('utils.bt_locate.get_bluetooth_scanner') + @patch("utils.bt_locate.get_bluetooth_scanner") def test_start_stop(self, mock_get_scanner): mock_scanner = MagicMock() mock_get_scanner.return_value = mock_scanner - target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF') + target = LocateTarget(mac_address="AA:BB:CC:DD:EE:FF") session = LocateSession(target, Environment.OUTDOOR) session.start() @@ -194,22 +195,22 @@ class TestLocateSession: assert session.active is False mock_scanner.remove_device_callback.assert_called_once() - @patch('utils.bt_locate.get_bluetooth_scanner') - @patch('utils.bt_locate.get_current_position') + @patch("utils.bt_locate.get_bluetooth_scanner") + @patch("utils.bt_locate.get_current_position") def test_detection_creates_trail_point(self, mock_gps, mock_get_scanner): mock_scanner = MagicMock() mock_get_scanner.return_value = mock_scanner mock_gps.return_value = None # No GPS - target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF') + target = LocateTarget(mac_address="AA:BB:CC:DD:EE:FF") session = LocateSession(target, Environment.OUTDOOR) session.start() # Simulate device callback device = MagicMock() - device.device_id = 'test' - device.address = 'AA:BB:CC:DD:EE:FF' - device.name = 'Test Device' + device.device_id = "test" + device.address = "AA:BB:CC:DD:EE:FF" + device.name = "Test Device" device.rssi_current = -65 session._on_device(device) @@ -218,48 +219,48 @@ class TestLocateSession: assert len(session.trail) == 1 assert session.trail[0].rssi == -65 - @patch('utils.bt_locate.get_bluetooth_scanner') + @patch("utils.bt_locate.get_bluetooth_scanner") def test_non_matching_device_ignored(self, mock_get_scanner): mock_scanner = MagicMock() mock_get_scanner.return_value = mock_scanner - target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF') + target = LocateTarget(mac_address="AA:BB:CC:DD:EE:FF") session = LocateSession(target, Environment.OUTDOOR) session.start() device = MagicMock() - device.device_id = 'other' - device.address = '11:22:33:44:55:66' + device.device_id = "other" + device.address = "11:22:33:44:55:66" device.name = None device.rssi_current = -70 session._on_device(device) assert session.detection_count == 0 - @patch('utils.bt_locate.get_bluetooth_scanner') + @patch("utils.bt_locate.get_bluetooth_scanner") def test_get_status(self, mock_get_scanner): mock_scanner = MagicMock() mock_get_scanner.return_value = mock_scanner - target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF') + target = LocateTarget(mac_address="AA:BB:CC:DD:EE:FF") session = LocateSession(target, Environment.FREE_SPACE) session.start() status = session.get_status() - assert status['active'] is True - assert status['environment'] == 'FREE_SPACE' - assert status['detection_count'] == 0 + assert status["active"] is True + assert status["environment"] == "FREE_SPACE" + assert status["detection_count"] == 0 -class TestModuleLevelSessionManagement: +class TestModuleLevelSessionManagement: """Test module-level session functions.""" - @patch('utils.bt_locate.get_bluetooth_scanner') + @patch("utils.bt_locate.get_bluetooth_scanner") def test_start_and_get_session(self, mock_get_scanner): mock_scanner = MagicMock() mock_get_scanner.return_value = mock_scanner - target = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF') + target = LocateTarget(mac_address="AA:BB:CC:DD:EE:FF") session = start_locate_session(target) assert get_locate_session() is session @@ -268,32 +269,32 @@ class TestModuleLevelSessionManagement: stop_locate_session() assert get_locate_session() is None - @patch('utils.bt_locate.get_bluetooth_scanner') - def test_start_replaces_existing_session(self, mock_get_scanner): - mock_scanner = MagicMock() - mock_get_scanner.return_value = mock_scanner + @patch("utils.bt_locate.get_bluetooth_scanner") + def test_start_replaces_existing_session(self, mock_get_scanner): + mock_scanner = MagicMock() + mock_get_scanner.return_value = mock_scanner - target1 = LocateTarget(mac_address='AA:BB:CC:DD:EE:FF') + target1 = LocateTarget(mac_address="AA:BB:CC:DD:EE:FF") session1 = start_locate_session(target1) - target2 = LocateTarget(mac_address='11:22:33:44:55:66') + target2 = LocateTarget(mac_address="11:22:33:44:55:66") session2 = start_locate_session(target2) assert get_locate_session() is session2 assert session1.active is False - assert session2.active is True - - stop_locate_session() - - @patch('utils.bt_locate.get_bluetooth_scanner') - def test_start_raises_when_scanner_cannot_start(self, mock_get_scanner): - mock_scanner = MagicMock() - mock_scanner.is_scanning = False - mock_scanner.start_scan.return_value = False - status = MagicMock() - status.error = 'No adapter' - mock_scanner.get_status.return_value = status - mock_get_scanner.return_value = mock_scanner - - with pytest.raises(RuntimeError): - start_locate_session(LocateTarget(mac_address='AA:BB:CC:DD:EE:FF')) + assert session2.active is True + + stop_locate_session() + + @patch("utils.bt_locate.get_bluetooth_scanner") + def test_start_raises_when_scanner_cannot_start(self, mock_get_scanner): + mock_scanner = MagicMock() + mock_scanner.is_scanning = False + mock_scanner.start_scan.return_value = False + status = MagicMock() + status.error = "No adapter" + mock_scanner.get_status.return_value = status + mock_get_scanner.return_value = mock_scanner + + with pytest.raises(RuntimeError): + start_locate_session(LocateTarget(mac_address="AA:BB:CC:DD:EE:FF")) diff --git a/tests/test_config.py b/tests/test_config.py index 82965b4..9060ae0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,6 @@ """Tests for configuration module.""" - class TestConfigEnvVars: """Tests for environment variable configuration.""" @@ -10,39 +9,41 @@ class TestConfigEnvVars: from config import DEBUG, HOST, PORT assert PORT == 5050 - assert HOST == '0.0.0.0' + assert HOST == "0.0.0.0" assert DEBUG is False def test_env_override(self, monkeypatch): """Test that environment variables override defaults.""" - monkeypatch.setenv('INTERCEPT_PORT', '8080') - monkeypatch.setenv('INTERCEPT_DEBUG', 'true') + monkeypatch.setenv("INTERCEPT_PORT", "8080") + monkeypatch.setenv("INTERCEPT_DEBUG", "true") # Re-import to get new values import importlib import config + importlib.reload(config) assert config.PORT == 8080 assert config.DEBUG is True # Reset - monkeypatch.delenv('INTERCEPT_PORT', raising=False) - monkeypatch.delenv('INTERCEPT_DEBUG', raising=False) + monkeypatch.delenv("INTERCEPT_PORT", raising=False) + monkeypatch.delenv("INTERCEPT_DEBUG", raising=False) importlib.reload(config) def test_invalid_env_values(self, monkeypatch): """Test that invalid env values fall back to defaults.""" - monkeypatch.setenv('INTERCEPT_PORT', 'invalid') + monkeypatch.setenv("INTERCEPT_PORT", "invalid") import importlib import config + importlib.reload(config) # Should fall back to default assert config.PORT == 5050 - monkeypatch.delenv('INTERCEPT_PORT', raising=False) + monkeypatch.delenv("INTERCEPT_PORT", raising=False) importlib.reload(config) diff --git a/tests/test_correlation.py b/tests/test_correlation.py index e5cfc70..50b123b 100644 --- a/tests/test_correlation.py +++ b/tests/test_correlation.py @@ -14,29 +14,29 @@ class TestDeviceCorrelator: correlator = DeviceCorrelator(time_window_seconds=60) wifi_devices = { - 'AA:BB:CC:11:22:33': { - 'first_seen': datetime.now(), - 'last_seen': datetime.now(), - 'essid': 'TestNetwork', - 'power': -65 + "AA:BB:CC:11:22:33": { + "first_seen": datetime.now(), + "last_seen": datetime.now(), + "essid": "TestNetwork", + "power": -65, } } bt_devices = { - 'AA:BB:CC:44:55:66': { - 'first_seen': datetime.now(), - 'last_seen': datetime.now(), - 'name': 'TestPhone', - 'rssi': -60 + "AA:BB:CC:44:55:66": { + "first_seen": datetime.now(), + "last_seen": datetime.now(), + "name": "TestPhone", + "rssi": -60, } } correlations = correlator.correlate(wifi_devices, bt_devices) assert len(correlations) >= 1 - assert correlations[0]['wifi_mac'] == 'AA:BB:CC:11:22:33' - assert correlations[0]['bt_mac'] == 'AA:BB:CC:44:55:66' - assert correlations[0]['confidence'] > 0 + assert correlations[0]["wifi_mac"] == "AA:BB:CC:11:22:33" + assert correlations[0]["bt_mac"] == "AA:BB:CC:44:55:66" + assert correlations[0]["confidence"] > 0 def test_correlate_timing(self): """Test correlation considers timing.""" @@ -46,55 +46,28 @@ class TestDeviceCorrelator: now = datetime.now() # Devices appearing at the same time - wifi_devices = { - '11:22:33:44:55:66': { - 'first_seen': now, - 'last_seen': now, - 'essid': 'Network1' - } - } + wifi_devices = {"11:22:33:44:55:66": {"first_seen": now, "last_seen": now, "essid": "Network1"}} - bt_devices = { - '77:88:99:AA:BB:CC': { - 'first_seen': now, - 'last_seen': now, - 'name': 'Device1' - } - } + bt_devices = {"77:88:99:AA:BB:CC": {"first_seen": now, "last_seen": now, "name": "Device1"}} correlations = correlator.correlate(wifi_devices, bt_devices) # Should have some confidence from timing correlation if correlations: - assert correlations[0]['confidence'] > 0 + assert correlations[0]["confidence"] > 0 def test_correlate_no_overlap(self): """Test no correlation when devices don't overlap.""" from utils.correlation import DeviceCorrelator - correlator = DeviceCorrelator( - time_window_seconds=30, - min_confidence=0.6 - ) + correlator = DeviceCorrelator(time_window_seconds=30, min_confidence=0.6) now = datetime.now() old = now - timedelta(hours=1) - wifi_devices = { - '11:22:33:44:55:66': { - 'first_seen': old, - 'last_seen': old, - 'essid': 'OldNetwork' - } - } + wifi_devices = {"11:22:33:44:55:66": {"first_seen": old, "last_seen": old, "essid": "OldNetwork"}} - bt_devices = { - '77:88:99:AA:BB:CC': { - 'first_seen': now, - 'last_seen': now, - 'name': 'NewDevice' - } - } + bt_devices = {"77:88:99:AA:BB:CC": {"first_seen": now, "last_seen": now, "name": "NewDevice"}} correlations = correlator.correlate(wifi_devices, bt_devices) @@ -109,21 +82,11 @@ class TestDeviceCorrelator: now = datetime.now() wifi_devices = { - '11:22:33:44:55:66': { - 'first_seen': now, - 'last_seen': now, - 'manufacturer': 'Apple', - 'essid': 'Network' - } + "11:22:33:44:55:66": {"first_seen": now, "last_seen": now, "manufacturer": "Apple", "essid": "Network"} } bt_devices = { - '77:88:99:AA:BB:CC': { - 'first_seen': now, - 'last_seen': now, - 'manufacturer': 'Apple', - 'name': 'iPhone' - } + "77:88:99:AA:BB:CC": {"first_seen": now, "last_seen": now, "manufacturer": "Apple", "name": "iPhone"} } correlations = correlator.correlate(wifi_devices, bt_devices) @@ -138,10 +101,10 @@ class TestDeviceCorrelator: correlator = DeviceCorrelator() # Empty WiFi - assert correlator.correlate({}, {'AA:BB:CC:DD:EE:FF': {}}) == [] + assert correlator.correlate({}, {"AA:BB:CC:DD:EE:FF": {}}) == [] # Empty Bluetooth - assert correlator.correlate({'AA:BB:CC:DD:EE:FF': {}}, {}) == [] + assert correlator.correlate({"AA:BB:CC:DD:EE:FF": {}}, {}) == [] # Both empty assert correlator.correlate({}, {}) == [] @@ -150,75 +113,50 @@ class TestDeviceCorrelator: """Test correlations are sorted by confidence.""" from utils.correlation import DeviceCorrelator - correlator = DeviceCorrelator( - time_window_seconds=60, - min_confidence=0.0 - ) + correlator = DeviceCorrelator(time_window_seconds=60, min_confidence=0.0) now = datetime.now() wifi_devices = { - 'AA:BB:CC:11:11:11': { - 'first_seen': now, - 'last_seen': now, - 'manufacturer': 'Apple' - }, - '11:22:33:44:55:66': { - 'first_seen': now, - 'last_seen': now - } + "AA:BB:CC:11:11:11": {"first_seen": now, "last_seen": now, "manufacturer": "Apple"}, + "11:22:33:44:55:66": {"first_seen": now, "last_seen": now}, } bt_devices = { - 'AA:BB:CC:22:22:22': { - 'first_seen': now, - 'last_seen': now, - 'manufacturer': 'Apple' - }, - '77:88:99:AA:BB:CC': { - 'first_seen': now, - 'last_seen': now - } + "AA:BB:CC:22:22:22": {"first_seen": now, "last_seen": now, "manufacturer": "Apple"}, + "77:88:99:AA:BB:CC": {"first_seen": now, "last_seen": now}, } correlations = correlator.correlate(wifi_devices, bt_devices) if len(correlations) >= 2: # Should be sorted by confidence (highest first) - assert correlations[0]['confidence'] >= correlations[1]['confidence'] + assert correlations[0]["confidence"] >= correlations[1]["confidence"] class TestGetCorrelations: """Tests for get_correlations function.""" - @patch('utils.correlation.correlator') - @patch('utils.correlation.db_get_correlations') + @patch("utils.correlation.correlator") + @patch("utils.correlation.db_get_correlations") def test_get_correlations_live(self, mock_db, mock_correlator): """Test get_correlations with live data.""" from utils.correlation import get_correlations mock_correlator.correlate.return_value = [ - { - 'wifi_mac': 'AA:AA:AA:AA:AA:AA', - 'bt_mac': 'BB:BB:BB:BB:BB:BB', - 'confidence': 0.8 - } + {"wifi_mac": "AA:AA:AA:AA:AA:AA", "bt_mac": "BB:BB:BB:BB:BB:BB", "confidence": 0.8} ] mock_db.return_value = [] - wifi = {'AA:AA:AA:AA:AA:AA': {}} - bt = {'BB:BB:BB:BB:BB:BB': {}} + wifi = {"AA:AA:AA:AA:AA:AA": {}} + bt = {"BB:BB:BB:BB:BB:BB": {}} - results = get_correlations( - wifi_devices=wifi, - bt_devices=bt, - include_historical=False - ) + results = get_correlations(wifi_devices=wifi, bt_devices=bt, include_historical=False) assert len(results) == 1 mock_correlator.correlate.assert_called_once() - @patch('utils.correlation.correlator') - @patch('utils.correlation.db_get_correlations') + @patch("utils.correlation.correlator") + @patch("utils.correlation.db_get_correlations") def test_get_correlations_historical(self, mock_db, mock_correlator): """Test get_correlations includes historical data.""" from utils.correlation import get_correlations @@ -226,59 +164,46 @@ class TestGetCorrelations: mock_correlator.correlate.return_value = [] mock_db.return_value = [ { - 'wifi_mac': 'CC:CC:CC:CC:CC:CC', - 'bt_mac': 'DD:DD:DD:DD:DD:DD', - 'confidence': 0.7, - 'first_seen': '2024-01-01', - 'last_seen': '2024-01-02' + "wifi_mac": "CC:CC:CC:CC:CC:CC", + "bt_mac": "DD:DD:DD:DD:DD:DD", + "confidence": 0.7, + "first_seen": "2024-01-01", + "last_seen": "2024-01-02", } ] - results = get_correlations( - wifi_devices={}, - bt_devices={}, - include_historical=True - ) + results = get_correlations(wifi_devices={}, bt_devices={}, include_historical=True) assert len(results) == 1 - assert results[0]['wifi_mac'] == 'CC:CC:CC:CC:CC:CC' + assert results[0]["wifi_mac"] == "CC:CC:CC:CC:CC:CC" - @patch('utils.correlation.correlator') - @patch('utils.correlation.db_get_correlations') + @patch("utils.correlation.correlator") + @patch("utils.correlation.db_get_correlations") def test_get_correlations_deduplication(self, mock_db, mock_correlator): """Test get_correlations deduplicates live and historical.""" from utils.correlation import get_correlations # Same correlation from both sources mock_correlator.correlate.return_value = [ - { - 'wifi_mac': 'AA:AA:AA:AA:AA:AA', - 'bt_mac': 'BB:BB:BB:BB:BB:BB', - 'confidence': 0.8 - } + {"wifi_mac": "AA:AA:AA:AA:AA:AA", "bt_mac": "BB:BB:BB:BB:BB:BB", "confidence": 0.8} ] mock_db.return_value = [ { - 'wifi_mac': 'AA:AA:AA:AA:AA:AA', - 'bt_mac': 'BB:BB:BB:BB:BB:BB', - 'confidence': 0.7, - 'first_seen': '2024-01-01', - 'last_seen': '2024-01-02' + "wifi_mac": "AA:AA:AA:AA:AA:AA", + "bt_mac": "BB:BB:BB:BB:BB:BB", + "confidence": 0.7, + "first_seen": "2024-01-01", + "last_seen": "2024-01-02", } ] - wifi = {'AA:AA:AA:AA:AA:AA': {}} - bt = {'BB:BB:BB:BB:BB:BB': {}} + wifi = {"AA:AA:AA:AA:AA:AA": {}} + bt = {"BB:BB:BB:BB:BB:BB": {}} - results = get_correlations( - wifi_devices=wifi, - bt_devices=bt, - include_historical=True - ) + results = get_correlations(wifi_devices=wifi, bt_devices=bt, include_historical=True) # Should deduplicate - only one entry for the same device pair - matching = [r for r in results - if r['wifi_mac'] == 'AA:AA:AA:AA:AA:AA'] + matching = [r for r in results if r["wifi_mac"] == "AA:AA:AA:AA:AA:AA"] assert len(matching) == 1 @@ -292,24 +217,14 @@ class TestCorrelationReason: correlator = DeviceCorrelator() now = datetime.now() - wifi_devices = { - 'AA:BB:CC:11:22:33': { - 'first_seen': now, - 'last_seen': now - } - } + wifi_devices = {"AA:BB:CC:11:22:33": {"first_seen": now, "last_seen": now}} - bt_devices = { - 'AA:BB:CC:44:55:66': { - 'first_seen': now, - 'last_seen': now - } - } + bt_devices = {"AA:BB:CC:44:55:66": {"first_seen": now, "last_seen": now}} correlations = correlator.correlate(wifi_devices, bt_devices) if correlations: - assert 'OUI' in correlations[0]['reason'] or 'same' in correlations[0]['reason'].lower() + assert "OUI" in correlations[0]["reason"] or "same" in correlations[0]["reason"].lower() def test_reason_timing(self): """Test reason includes timing information.""" @@ -318,22 +233,14 @@ class TestCorrelationReason: correlator = DeviceCorrelator(time_window_seconds=60) now = datetime.now() - wifi_devices = { - '11:22:33:44:55:66': { - 'first_seen': now, - 'last_seen': now - } - } + wifi_devices = {"11:22:33:44:55:66": {"first_seen": now, "last_seen": now}} bt_devices = { - '77:88:99:AA:BB:CC': { - 'first_seen': now + timedelta(seconds=5), - 'last_seen': now + timedelta(seconds=5) - } + "77:88:99:AA:BB:CC": {"first_seen": now + timedelta(seconds=5), "last_seen": now + timedelta(seconds=5)} } correlations = correlator.correlate(wifi_devices, bt_devices) # If correlation found, should mention timing - if correlations and correlations[0]['confidence'] > 0.3: - assert 'appeared' in correlations[0]['reason'] or 'timing' in correlations[0]['reason'] + if correlations and correlations[0]["confidence"] > 0.3: + assert "appeared" in correlations[0]["reason"] or "timing" in correlations[0]["reason"] diff --git a/tests/test_database.py b/tests/test_database.py index 6e75e04..fb9cd0b 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -12,11 +12,10 @@ import pytest def temp_db(): """Use a temporary database for each test.""" with tempfile.TemporaryDirectory() as tmpdir: - test_db_path = Path(tmpdir) / 'test_intercept.db' + test_db_path = Path(tmpdir) / "test_intercept.db" test_db_dir = Path(tmpdir) - with patch('utils.database.DB_PATH', test_db_path), \ - patch('utils.database.DB_DIR', test_db_dir): + with patch("utils.database.DB_PATH", test_db_path), patch("utils.database.DB_DIR", test_db_dir): # Import after patching from utils.database import close_db, init_db @@ -32,15 +31,15 @@ class TestSettingsCRUD: """Test setting and getting string values.""" from utils.database import get_setting, set_setting - set_setting('test_key', 'test_value') - assert get_setting('test_key') == 'test_value' + set_setting("test_key", "test_value") + assert get_setting("test_key") == "test_value" def test_set_and_get_int(self, temp_db): """Test setting and getting integer values.""" from utils.database import get_setting, set_setting - set_setting('int_key', 42) - result = get_setting('int_key') + set_setting("int_key", 42) + result = get_setting("int_key") assert result == 42 assert isinstance(result, int) @@ -48,8 +47,8 @@ class TestSettingsCRUD: """Test setting and getting float values.""" from utils.database import get_setting, set_setting - set_setting('float_key', 3.14) - result = get_setting('float_key') + set_setting("float_key", 3.14) + result = get_setting("float_key") assert result == 3.14 assert isinstance(result, float) @@ -57,30 +56,30 @@ class TestSettingsCRUD: """Test setting and getting boolean values.""" from utils.database import get_setting, set_setting - set_setting('bool_true', True) - set_setting('bool_false', False) + set_setting("bool_true", True) + set_setting("bool_false", False) - assert get_setting('bool_true') is True - assert get_setting('bool_false') is False + assert get_setting("bool_true") is True + assert get_setting("bool_false") is False def test_set_and_get_dict(self, temp_db): """Test setting and getting dictionary values.""" from utils.database import get_setting, set_setting - test_dict = {'name': 'test', 'value': 123, 'nested': {'a': 1}} - set_setting('dict_key', test_dict) - result = get_setting('dict_key') + test_dict = {"name": "test", "value": 123, "nested": {"a": 1}} + set_setting("dict_key", test_dict) + result = get_setting("dict_key") assert result == test_dict - assert result['nested']['a'] == 1 + assert result["nested"]["a"] == 1 def test_set_and_get_list(self, temp_db): """Test setting and getting list values.""" from utils.database import get_setting, set_setting - test_list = [1, 2, 3, 'four', {'five': 5}] - set_setting('list_key', test_list) - result = get_setting('list_key') + test_list = [1, 2, 3, "four", {"five": 5}] + set_setting("list_key", test_list) + result = get_setting("list_key") assert result == test_list @@ -88,51 +87,51 @@ class TestSettingsCRUD: """Test getting a key that doesn't exist.""" from utils.database import get_setting - assert get_setting('nonexistent') is None - assert get_setting('nonexistent', 'default') == 'default' + assert get_setting("nonexistent") is None + assert get_setting("nonexistent", "default") == "default" def test_update_existing_setting(self, temp_db): """Test updating an existing setting.""" from utils.database import get_setting, set_setting - set_setting('update_key', 'original') - assert get_setting('update_key') == 'original' + set_setting("update_key", "original") + assert get_setting("update_key") == "original" - set_setting('update_key', 'updated') - assert get_setting('update_key') == 'updated' + set_setting("update_key", "updated") + assert get_setting("update_key") == "updated" def test_delete_setting(self, temp_db): """Test deleting a setting.""" from utils.database import delete_setting, get_setting, set_setting - set_setting('delete_key', 'value') - assert get_setting('delete_key') == 'value' + set_setting("delete_key", "value") + assert get_setting("delete_key") == "value" - result = delete_setting('delete_key') + result = delete_setting("delete_key") assert result is True - assert get_setting('delete_key') is None + assert get_setting("delete_key") is None def test_delete_nonexistent_setting(self, temp_db): """Test deleting a setting that doesn't exist.""" from utils.database import delete_setting - result = delete_setting('nonexistent_key') + result = delete_setting("nonexistent_key") assert result is False def test_get_all_settings(self, temp_db): """Test getting all settings.""" from utils.database import get_all_settings, set_setting - set_setting('key1', 'value1') - set_setting('key2', 42) - set_setting('key3', True) + set_setting("key1", "value1") + set_setting("key2", 42) + set_setting("key3", True) all_settings = get_all_settings() - assert 'key1' in all_settings - assert all_settings['key1'] == 'value1' - assert all_settings['key2'] == 42 - assert all_settings['key3'] is True + assert "key1" in all_settings + assert all_settings["key1"] == "value1" + assert all_settings["key2"] == 42 + assert all_settings["key3"] is True class TestSignalHistory: @@ -142,60 +141,60 @@ class TestSignalHistory: """Test adding and retrieving signal readings.""" from utils.database import add_signal_reading, get_signal_history - add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65) - add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -62) - add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -70) + add_signal_reading("wifi", "AA:BB:CC:DD:EE:FF", -65) + add_signal_reading("wifi", "AA:BB:CC:DD:EE:FF", -62) + add_signal_reading("wifi", "AA:BB:CC:DD:EE:FF", -70) - history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF') + history = get_signal_history("wifi", "AA:BB:CC:DD:EE:FF") assert len(history) == 3 # Results should be in chronological order - assert history[0]['signal'] == -65 - assert history[1]['signal'] == -62 - assert history[2]['signal'] == -70 + assert history[0]["signal"] == -65 + assert history[1]["signal"] == -62 + assert history[2]["signal"] == -70 def test_signal_history_with_metadata(self, temp_db): """Test signal readings with metadata.""" from utils.database import add_signal_reading, get_signal_history - metadata = {'channel': 6, 'ssid': 'TestNetwork'} - add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65, metadata) + metadata = {"channel": 6, "ssid": "TestNetwork"} + add_signal_reading("wifi", "AA:BB:CC:DD:EE:FF", -65, metadata) - history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF') + history = get_signal_history("wifi", "AA:BB:CC:DD:EE:FF") assert len(history) == 1 - assert history[0]['metadata'] == metadata + assert history[0]["metadata"] == metadata def test_signal_history_limit(self, temp_db): """Test signal history respects limit parameter.""" from utils.database import add_signal_reading, get_signal_history for i in range(10): - add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -60 - i) + add_signal_reading("wifi", "AA:BB:CC:DD:EE:FF", -60 - i) - history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF', limit=5) + history = get_signal_history("wifi", "AA:BB:CC:DD:EE:FF", limit=5) assert len(history) == 5 def test_signal_history_different_devices(self, temp_db): """Test signal history isolates different devices.""" from utils.database import add_signal_reading, get_signal_history - add_signal_reading('wifi', 'AA:AA:AA:AA:AA:AA', -65) - add_signal_reading('wifi', 'BB:BB:BB:BB:BB:BB', -70) + add_signal_reading("wifi", "AA:AA:AA:AA:AA:AA", -65) + add_signal_reading("wifi", "BB:BB:BB:BB:BB:BB", -70) - history_a = get_signal_history('wifi', 'AA:AA:AA:AA:AA:AA') - history_b = get_signal_history('wifi', 'BB:BB:BB:BB:BB:BB') + history_a = get_signal_history("wifi", "AA:AA:AA:AA:AA:AA") + history_b = get_signal_history("wifi", "BB:BB:BB:BB:BB:BB") assert len(history_a) == 1 assert len(history_b) == 1 - assert history_a[0]['signal'] == -65 - assert history_b[0]['signal'] == -70 + assert history_a[0]["signal"] == -65 + assert history_b[0]["signal"] == -70 def test_cleanup_old_signal_history(self, temp_db): """Test cleanup of old signal history.""" from utils.database import add_signal_reading, cleanup_old_signal_history - add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65) + add_signal_reading("wifi", "AA:BB:CC:DD:EE:FF", -65) # Cleanup with 0 hours should remove everything deleted = cleanup_old_signal_history(max_age_hours=0) @@ -211,30 +210,23 @@ class TestDeviceCorrelations: from utils.database import add_correlation, get_correlations add_correlation( - wifi_mac='AA:AA:AA:AA:AA:AA', - bt_mac='BB:BB:BB:BB:BB:BB', - confidence=0.85, - metadata={'reason': 'timing'} + wifi_mac="AA:AA:AA:AA:AA:AA", bt_mac="BB:BB:BB:BB:BB:BB", confidence=0.85, metadata={"reason": "timing"} ) correlations = get_correlations(min_confidence=0.5) assert len(correlations) >= 1 - found = next( - (c for c in correlations - if c['wifi_mac'] == 'AA:AA:AA:AA:AA:AA'), - None - ) + found = next((c for c in correlations if c["wifi_mac"] == "AA:AA:AA:AA:AA:AA"), None) assert found is not None - assert found['bt_mac'] == 'BB:BB:BB:BB:BB:BB' - assert found['confidence'] == 0.85 + assert found["bt_mac"] == "BB:BB:BB:BB:BB:BB" + assert found["confidence"] == 0.85 def test_correlation_confidence_filter(self, temp_db): """Test correlation filtering by confidence.""" from utils.database import add_correlation, get_correlations - add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.9) - add_correlation('CC:CC:CC:CC:CC:CC', 'DD:DD:DD:DD:DD:DD', 0.4) + add_correlation("AA:AA:AA:AA:AA:AA", "BB:BB:BB:BB:BB:BB", 0.9) + add_correlation("CC:CC:CC:CC:CC:CC", "DD:DD:DD:DD:DD:DD", 0.4) high_confidence = get_correlations(min_confidence=0.7) all_confidence = get_correlations(min_confidence=0.3) @@ -246,12 +238,11 @@ class TestDeviceCorrelations: """Test that correlations are updated on conflict.""" from utils.database import add_correlation, get_correlations - add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.5) - add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.9) + add_correlation("AA:AA:AA:AA:AA:AA", "BB:BB:BB:BB:BB:BB", 0.5) + add_correlation("AA:AA:AA:AA:AA:AA", "BB:BB:BB:BB:BB:BB", 0.9) correlations = get_correlations(min_confidence=0.0) - matching = [c for c in correlations - if c['wifi_mac'] == 'AA:AA:AA:AA:AA:AA'] + matching = [c for c in correlations if c["wifi_mac"] == "AA:AA:AA:AA:AA:AA"] assert len(matching) == 1 - assert matching[0]['confidence'] == 0.9 + assert matching[0]["confidence"] == 0.9 diff --git a/tests/test_dsc.py b/tests/test_dsc.py index 276a156..65dc71c 100644 --- a/tests/test_dsc.py +++ b/tests/test_dsc.py @@ -13,97 +13,97 @@ class TestDSCParser: from utils.dsc.parser import get_country_from_mmsi # UK ships start with 232-235 - assert get_country_from_mmsi('232123456') == 'United Kingdom' - assert get_country_from_mmsi('235987654') == 'United Kingdom' + assert get_country_from_mmsi("232123456") == "United Kingdom" + assert get_country_from_mmsi("235987654") == "United Kingdom" # US ships start with 303, 338, 366-369 - assert get_country_from_mmsi('366123456') == 'USA' - assert get_country_from_mmsi('369000001') == 'USA' + assert get_country_from_mmsi("366123456") == "USA" + assert get_country_from_mmsi("369000001") == "USA" # Panama (common flag of convenience) - assert get_country_from_mmsi('351234567') == 'Panama' - assert get_country_from_mmsi('370000001') == 'Panama' + assert get_country_from_mmsi("351234567") == "Panama" + assert get_country_from_mmsi("370000001") == "Panama" # Norway - assert get_country_from_mmsi('257123456') == 'Norway' + assert get_country_from_mmsi("257123456") == "Norway" # Germany - assert get_country_from_mmsi('211000001') == 'Germany' + assert get_country_from_mmsi("211000001") == "Germany" def test_get_country_from_mmsi_coast_station(self): """Test country lookup for coast station MMSI (starts with 00).""" from utils.dsc.parser import get_country_from_mmsi # Coast stations: 00 + MID - assert get_country_from_mmsi('002320001') == 'United Kingdom' - assert get_country_from_mmsi('003660001') == 'USA' + assert get_country_from_mmsi("002320001") == "United Kingdom" + assert get_country_from_mmsi("003660001") == "USA" def test_get_country_from_mmsi_group_station(self): """Test country lookup for group station MMSI (starts with 0).""" from utils.dsc.parser import get_country_from_mmsi # Group call: 0 + MID - assert get_country_from_mmsi('023200001') == 'United Kingdom' - assert get_country_from_mmsi('036600001') == 'USA' + assert get_country_from_mmsi("023200001") == "United Kingdom" + assert get_country_from_mmsi("036600001") == "USA" def test_get_country_from_mmsi_unknown(self): """Test country lookup returns None for unknown MID.""" from utils.dsc.parser import get_country_from_mmsi - assert get_country_from_mmsi('999999999') is None - assert get_country_from_mmsi('100000000') is None + assert get_country_from_mmsi("999999999") is None + assert get_country_from_mmsi("100000000") is None def test_get_country_from_mmsi_invalid(self): """Test country lookup handles invalid input.""" from utils.dsc.parser import get_country_from_mmsi - assert get_country_from_mmsi('') is None + assert get_country_from_mmsi("") is None assert get_country_from_mmsi(None) is None - assert get_country_from_mmsi('12') is None + assert get_country_from_mmsi("12") is None def test_get_distress_nature_text(self): """Test distress nature code to text conversion.""" from utils.dsc.parser import get_distress_nature_text - assert get_distress_nature_text(100) == 'UNDESIGNATED' - assert get_distress_nature_text(101) == 'FIRE' - assert get_distress_nature_text(102) == 'FLOODING' - assert get_distress_nature_text(103) == 'COLLISION' - assert get_distress_nature_text(106) == 'SINKING' - assert get_distress_nature_text(109) == 'PIRACY' - assert get_distress_nature_text(110) == 'MOB' # Man overboard + assert get_distress_nature_text(100) == "UNDESIGNATED" + assert get_distress_nature_text(101) == "FIRE" + assert get_distress_nature_text(102) == "FLOODING" + assert get_distress_nature_text(103) == "COLLISION" + assert get_distress_nature_text(106) == "SINKING" + assert get_distress_nature_text(109) == "PIRACY" + assert get_distress_nature_text(110) == "MOB" # Man overboard def test_get_distress_nature_text_unknown(self): """Test distress nature returns formatted unknown for invalid codes.""" from utils.dsc.parser import get_distress_nature_text - assert 'UNKNOWN' in get_distress_nature_text(999) - assert '999' in get_distress_nature_text(999) + assert "UNKNOWN" in get_distress_nature_text(999) + assert "999" in get_distress_nature_text(999) def test_get_distress_nature_text_string_input(self): """Test distress nature accepts string input.""" from utils.dsc.parser import get_distress_nature_text - assert get_distress_nature_text('101') == 'FIRE' - assert get_distress_nature_text('invalid') == 'invalid' + assert get_distress_nature_text("101") == "FIRE" + assert get_distress_nature_text("invalid") == "invalid" def test_get_format_text(self): """Test format code to text conversion per ITU-R M.493.""" from utils.dsc.parser import get_format_text - assert get_format_text(102) == 'ALL_SHIPS' - assert get_format_text(112) == 'INDIVIDUAL' - assert get_format_text(114) == 'INDIVIDUAL_ACK' - assert get_format_text(116) == 'GROUP' - assert get_format_text(120) == 'DISTRESS' - assert get_format_text(123) == 'ALL_SHIPS_URGENCY_SAFETY' + assert get_format_text(102) == "ALL_SHIPS" + assert get_format_text(112) == "INDIVIDUAL" + assert get_format_text(114) == "INDIVIDUAL_ACK" + assert get_format_text(116) == "GROUP" + assert get_format_text(120) == "DISTRESS" + assert get_format_text(123) == "ALL_SHIPS_URGENCY_SAFETY" def test_get_format_text_unknown(self): """Test format code returns unknown for invalid codes.""" from utils.dsc.parser import get_format_text result = get_format_text(999) - assert 'UNKNOWN' in result + assert "UNKNOWN" in result def test_get_format_text_removed_codes(self): """Test that non-ITU format codes are no longer recognized.""" @@ -112,269 +112,287 @@ class TestDSCParser: # These were previously defined but are not ITU-R M.493 specifiers for code in [100, 104, 106, 108, 110, 118]: result = get_format_text(code) - assert 'UNKNOWN' in result + assert "UNKNOWN" in result def test_get_telecommand_text(self): """Test telecommand code to text conversion.""" from utils.dsc.parser import get_telecommand_text - assert get_telecommand_text(100) == 'F3E_G3E_ALL' - assert get_telecommand_text(105) == 'DATA' - assert get_telecommand_text(107) == 'DISTRESS_ACK' - assert get_telecommand_text(111) == 'TEST' + assert get_telecommand_text(100) == "F3E_G3E_ALL" + assert get_telecommand_text(105) == "DATA" + assert get_telecommand_text(107) == "DISTRESS_ACK" + assert get_telecommand_text(111) == "TEST" def test_get_category_priority(self): """Test category priority values.""" from utils.dsc.parser import get_category_priority # Distress has highest priority (0) - assert get_category_priority('DISTRESS') == 0 - assert get_category_priority('distress') == 0 + assert get_category_priority("DISTRESS") == 0 + assert get_category_priority("distress") == 0 # Urgency/safety - assert get_category_priority('ALL_SHIPS_URGENCY_SAFETY') == 2 + assert get_category_priority("ALL_SHIPS_URGENCY_SAFETY") == 2 # Routine-level - assert get_category_priority('ALL_SHIPS') == 5 - assert get_category_priority('GROUP') == 5 - assert get_category_priority('INDIVIDUAL') == 5 + assert get_category_priority("ALL_SHIPS") == 5 + assert get_category_priority("GROUP") == 5 + assert get_category_priority("INDIVIDUAL") == 5 # Unknown gets default high number - assert get_category_priority('UNKNOWN') == 10 + assert get_category_priority("UNKNOWN") == 10 def test_validate_mmsi_valid(self): """Test MMSI validation with valid numbers.""" from utils.dsc.parser import validate_mmsi - assert validate_mmsi('232123456') is True - assert validate_mmsi('366000001') is True - assert validate_mmsi('002320001') is True # Coast station - assert validate_mmsi('023200001') is True # Group station + assert validate_mmsi("232123456") is True + assert validate_mmsi("366000001") is True + assert validate_mmsi("002320001") is True # Coast station + assert validate_mmsi("023200001") is True # Group station def test_validate_mmsi_invalid(self): """Test MMSI validation rejects invalid numbers.""" from utils.dsc.parser import validate_mmsi - assert validate_mmsi('') is False + assert validate_mmsi("") is False assert validate_mmsi(None) is False - assert validate_mmsi('12345678') is False # Too short - assert validate_mmsi('1234567890') is False # Too long - assert validate_mmsi('abcdefghi') is False # Not digits - assert validate_mmsi('000000000') is False # All zeros + assert validate_mmsi("12345678") is False # Too short + assert validate_mmsi("1234567890") is False # Too long + assert validate_mmsi("abcdefghi") is False # Not digits + assert validate_mmsi("000000000") is False # All zeros def test_classify_mmsi(self): """Test MMSI classification.""" from utils.dsc.parser import classify_mmsi # Ship stations (start with 2-7) - assert classify_mmsi('232123456') == 'ship' - assert classify_mmsi('366000001') == 'ship' - assert classify_mmsi('503000001') == 'ship' + assert classify_mmsi("232123456") == "ship" + assert classify_mmsi("366000001") == "ship" + assert classify_mmsi("503000001") == "ship" # Coast stations (start with 00) - assert classify_mmsi('002320001') == 'coast' + assert classify_mmsi("002320001") == "coast" # Group stations (start with 0, not 00) - assert classify_mmsi('023200001') == 'group' + assert classify_mmsi("023200001") == "group" # SAR aircraft (start with 111) - assert classify_mmsi('111232001') == 'sar' + assert classify_mmsi("111232001") == "sar" # Aids to Navigation (start with 99) - assert classify_mmsi('992321001') == 'aton' + assert classify_mmsi("992321001") == "aton" # Unknown - assert classify_mmsi('invalid') == 'unknown' - assert classify_mmsi('812345678') == 'unknown' + assert classify_mmsi("invalid") == "unknown" + assert classify_mmsi("812345678") == "unknown" def test_parse_dsc_message_distress(self): """Test parsing a distress message with ITU format 120.""" from utils.dsc.parser import parse_dsc_message - raw = json.dumps({ - 'type': 'dsc', - 'format': 120, - 'source_mmsi': '232123456', - 'dest_mmsi': '002320001', - 'category': 'DISTRESS', - 'nature': 101, - 'position': {'lat': 51.5, 'lon': -0.1}, - 'telecommand1': 100, - 'timestamp': '2025-01-15T12:00:00Z', - 'raw': '120002032123456101100127', - }) + raw = json.dumps( + { + "type": "dsc", + "format": 120, + "source_mmsi": "232123456", + "dest_mmsi": "002320001", + "category": "DISTRESS", + "nature": 101, + "position": {"lat": 51.5, "lon": -0.1}, + "telecommand1": 100, + "timestamp": "2025-01-15T12:00:00Z", + "raw": "120002032123456101100127", + } + ) msg = parse_dsc_message(raw) assert msg is not None - assert msg['type'] == 'dsc_message' - assert msg['source_mmsi'] == '232123456' - assert msg['category'] == 'DISTRESS' - assert msg['source_country'] == 'United Kingdom' - assert msg['nature_of_distress'] == 'FIRE' - assert msg['latitude'] == 51.5 - assert msg['longitude'] == -0.1 - assert msg['is_critical'] is True - assert msg['priority'] == 0 + assert msg["type"] == "dsc_message" + assert msg["source_mmsi"] == "232123456" + assert msg["category"] == "DISTRESS" + assert msg["source_country"] == "United Kingdom" + assert msg["nature_of_distress"] == "FIRE" + assert msg["latitude"] == 51.5 + assert msg["longitude"] == -0.1 + assert msg["is_critical"] is True + assert msg["priority"] == 0 def test_parse_dsc_message_group(self): """Test parsing a group call message.""" from utils.dsc.parser import parse_dsc_message - raw = json.dumps({ - 'type': 'dsc', - 'format': 116, - 'source_mmsi': '366000001', - 'dest_mmsi': '023200001', - 'category': 'GROUP', - 'timestamp': '2025-01-15T12:00:00Z', - 'raw': '116023200001366000001117', - }) + raw = json.dumps( + { + "type": "dsc", + "format": 116, + "source_mmsi": "366000001", + "dest_mmsi": "023200001", + "category": "GROUP", + "timestamp": "2025-01-15T12:00:00Z", + "raw": "116023200001366000001117", + } + ) msg = parse_dsc_message(raw) assert msg is not None - assert msg['category'] == 'GROUP' - assert msg['source_country'] == 'USA' - assert msg['is_critical'] is False - assert msg['priority'] == 5 + assert msg["category"] == "GROUP" + assert msg["source_country"] == "USA" + assert msg["is_critical"] is False + assert msg["priority"] == 5 def test_parse_dsc_message_individual(self): """Test parsing an individual call message.""" from utils.dsc.parser import parse_dsc_message - raw = json.dumps({ - 'type': 'dsc', - 'format': 112, - 'source_mmsi': '366000001', - 'dest_mmsi': '232123456', - 'category': 'INDIVIDUAL', - 'telecommand1': 100, - 'timestamp': '2025-01-15T12:00:00Z', - 'raw': '112232123456366000001100122', - }) + raw = json.dumps( + { + "type": "dsc", + "format": 112, + "source_mmsi": "366000001", + "dest_mmsi": "232123456", + "category": "INDIVIDUAL", + "telecommand1": 100, + "timestamp": "2025-01-15T12:00:00Z", + "raw": "112232123456366000001100122", + } + ) msg = parse_dsc_message(raw) assert msg is not None - assert msg['category'] == 'INDIVIDUAL' - assert msg['is_critical'] is False + assert msg["category"] == "INDIVIDUAL" + assert msg["is_critical"] is False def test_parse_dsc_message_invalid_json(self): """Test parsing rejects invalid JSON.""" from utils.dsc.parser import parse_dsc_message - assert parse_dsc_message('not json') is None - assert parse_dsc_message('{invalid}') is None + assert parse_dsc_message("not json") is None + assert parse_dsc_message("{invalid}") is None def test_parse_dsc_message_missing_type(self): """Test parsing rejects messages without correct type.""" from utils.dsc.parser import parse_dsc_message - raw = json.dumps({'source_mmsi': '232123456'}) + raw = json.dumps({"source_mmsi": "232123456"}) assert parse_dsc_message(raw) is None - raw = json.dumps({'type': 'other', 'source_mmsi': '232123456'}) + raw = json.dumps({"type": "other", "source_mmsi": "232123456"}) assert parse_dsc_message(raw) is None def test_parse_dsc_message_missing_mmsi(self): """Test parsing rejects messages without source MMSI.""" from utils.dsc.parser import parse_dsc_message - raw = json.dumps({'type': 'dsc'}) + raw = json.dumps({"type": "dsc"}) assert parse_dsc_message(raw) is None def test_parse_dsc_message_empty(self): """Test parsing handles empty input.""" from utils.dsc.parser import parse_dsc_message - assert parse_dsc_message('') is None + assert parse_dsc_message("") is None assert parse_dsc_message(None) is None - assert parse_dsc_message(' ') is None + assert parse_dsc_message(" ") is None def test_parse_dsc_message_rejects_non_itu_format(self): """Test parser rejects records with non-ITU format specifier.""" from utils.dsc.parser import parse_dsc_message for bad_format in [100, 104, 106, 108, 110, 118, 999]: - raw = json.dumps({ - 'type': 'dsc', - 'format': bad_format, - 'source_mmsi': '232123456', - 'category': 'ROUTINE', - 'raw': '120232123456100127', - }) + raw = json.dumps( + { + "type": "dsc", + "format": bad_format, + "source_mmsi": "232123456", + "category": "ROUTINE", + "raw": "120232123456100127", + } + ) assert parse_dsc_message(raw) is None, f"Format {bad_format} should be rejected" def test_parse_dsc_message_rejects_telecommand_out_of_range(self): """Test parser rejects records with telecommand out of 100-127 range.""" from utils.dsc.parser import parse_dsc_message - raw = json.dumps({ - 'type': 'dsc', - 'format': 120, - 'source_mmsi': '232123456', - 'dest_mmsi': '002320001', - 'category': 'DISTRESS', - 'telecommand1': 200, - 'timestamp': '2025-01-15T12:00:00Z', - 'raw': '120002032123456200127', - }) + raw = json.dumps( + { + "type": "dsc", + "format": 120, + "source_mmsi": "232123456", + "dest_mmsi": "002320001", + "category": "DISTRESS", + "telecommand1": 200, + "timestamp": "2025-01-15T12:00:00Z", + "raw": "120002032123456200127", + } + ) assert parse_dsc_message(raw) is None def test_parse_dsc_message_accepts_zero_telecommand(self): """Test parser does not drop telecommand with value 100 (truthiness fix).""" from utils.dsc.parser import parse_dsc_message - raw = json.dumps({ - 'type': 'dsc', - 'format': 112, - 'source_mmsi': '232123456', - 'dest_mmsi': '366000001', - 'category': 'INDIVIDUAL', - 'telecommand1': 100, - 'telecommand2': 100, - 'timestamp': '2025-01-15T12:00:00Z', - 'raw': '112366000001232123456100100122', - }) + raw = json.dumps( + { + "type": "dsc", + "format": 112, + "source_mmsi": "232123456", + "dest_mmsi": "366000001", + "category": "INDIVIDUAL", + "telecommand1": 100, + "telecommand2": 100, + "timestamp": "2025-01-15T12:00:00Z", + "raw": "112366000001232123456100100122", + } + ) msg = parse_dsc_message(raw) assert msg is not None - assert msg['telecommand1'] == 100 - assert msg['telecommand2'] == 100 + assert msg["telecommand1"] == 100 + assert msg["telecommand2"] == 100 def test_parse_dsc_message_validates_raw_field(self): """Test parser validates raw field structure.""" from utils.dsc.parser import parse_dsc_message # Non-digit raw field - raw = json.dumps({ - 'type': 'dsc', - 'format': 120, - 'source_mmsi': '232123456', - 'category': 'DISTRESS', - 'raw': '12abc', - }) + raw = json.dumps( + { + "type": "dsc", + "format": 120, + "source_mmsi": "232123456", + "category": "DISTRESS", + "raw": "12abc", + } + ) assert parse_dsc_message(raw) is None # Raw field length not divisible by 3 - raw = json.dumps({ - 'type': 'dsc', - 'format': 120, - 'source_mmsi': '232123456', - 'category': 'DISTRESS', - 'raw': '1201', - }) + raw = json.dumps( + { + "type": "dsc", + "format": 120, + "source_mmsi": "232123456", + "category": "DISTRESS", + "raw": "1201", + } + ) assert parse_dsc_message(raw) is None # Raw field with non-EOS last token - raw = json.dumps({ - 'type': 'dsc', - 'format': 120, - 'source_mmsi': '232123456', - 'category': 'DISTRESS', - 'raw': '120100', - }) + raw = json.dumps( + { + "type": "dsc", + "format": 120, + "source_mmsi": "232123456", + "category": "DISTRESS", + "raw": "120100", + } + ) assert parse_dsc_message(raw) is None def test_parse_dsc_message_accepts_valid_eos_in_raw(self): @@ -382,15 +400,17 @@ class TestDSCParser: from utils.dsc.parser import parse_dsc_message for eos in [117, 122, 127]: - raw = json.dumps({ - 'type': 'dsc', - 'format': 120, - 'source_mmsi': '232123456', - 'dest_mmsi': '002320001', - 'category': 'DISTRESS', - 'timestamp': '2025-01-15T12:00:00Z', - 'raw': f'120002032123456{eos:03d}', - }) + raw = json.dumps( + { + "type": "dsc", + "format": 120, + "source_mmsi": "232123456", + "dest_mmsi": "002320001", + "category": "DISTRESS", + "timestamp": "2025-01-15T12:00:00Z", + "raw": f"120002032123456{eos:03d}", + } + ) msg = parse_dsc_message(raw) assert msg is not None, f"EOS {eos} should be accepted" @@ -399,89 +419,97 @@ class TestDSCParser: from utils.dsc.parser import parse_dsc_message # All-zeros MMSI - raw = json.dumps({ - 'type': 'dsc', - 'format': 120, - 'source_mmsi': '000000000', - 'category': 'DISTRESS', - 'raw': '120000000000127', - }) + raw = json.dumps( + { + "type": "dsc", + "format": 120, + "source_mmsi": "000000000", + "category": "DISTRESS", + "raw": "120000000000127", + } + ) assert parse_dsc_message(raw) is None # Short MMSI - raw = json.dumps({ - 'type': 'dsc', - 'format': 120, - 'source_mmsi': '12345', - 'category': 'DISTRESS', - 'raw': '120127', - }) + raw = json.dumps( + { + "type": "dsc", + "format": 120, + "source_mmsi": "12345", + "category": "DISTRESS", + "raw": "120127", + } + ) assert parse_dsc_message(raw) is None def test_parse_dsc_message_nature_zero_not_dropped(self): """Test that nature code 0 is not dropped by truthiness check.""" from utils.dsc.parser import parse_dsc_message - raw = json.dumps({ - 'type': 'dsc', - 'format': 120, - 'source_mmsi': '232123456', - 'dest_mmsi': '002320001', - 'category': 'DISTRESS', - 'nature': 0, - 'timestamp': '2025-01-15T12:00:00Z', - 'raw': '120002032123456000127', - }) + raw = json.dumps( + { + "type": "dsc", + "format": 120, + "source_mmsi": "232123456", + "dest_mmsi": "002320001", + "category": "DISTRESS", + "nature": 0, + "timestamp": "2025-01-15T12:00:00Z", + "raw": "120002032123456000127", + } + ) msg = parse_dsc_message(raw) assert msg is not None - assert msg['nature_code'] == 0 + assert msg["nature_code"] == 0 def test_parse_dsc_message_channel_zero_not_dropped(self): """Test that channel value 0 is not dropped by truthiness check.""" from utils.dsc.parser import parse_dsc_message - raw = json.dumps({ - 'type': 'dsc', - 'format': 112, - 'source_mmsi': '232123456', - 'dest_mmsi': '366000001', - 'category': 'INDIVIDUAL', - 'channel': 0, - 'telecommand1': 100, - 'timestamp': '2025-01-15T12:00:00Z', - 'raw': '112366000001232123456100122', - }) + raw = json.dumps( + { + "type": "dsc", + "format": 112, + "source_mmsi": "232123456", + "dest_mmsi": "366000001", + "category": "INDIVIDUAL", + "channel": 0, + "telecommand1": 100, + "timestamp": "2025-01-15T12:00:00Z", + "raw": "112366000001232123456100122", + } + ) msg = parse_dsc_message(raw) assert msg is not None - assert msg['channel'] == 0 + assert msg["channel"] == 0 def test_format_dsc_for_display(self): """Test message formatting for display.""" from utils.dsc.parser import format_dsc_for_display msg = { - 'category': 'DISTRESS', - 'source_mmsi': '232123456', - 'source_country': 'United Kingdom', - 'dest_mmsi': '002320001', - 'nature_of_distress': 'FIRE', - 'latitude': 51.5074, - 'longitude': -0.1278, - 'telecommand1_text': 'F3E_G3E_ALL', - 'channel': 16, - 'timestamp': '2025-01-15T12:00:00Z' + "category": "DISTRESS", + "source_mmsi": "232123456", + "source_country": "United Kingdom", + "dest_mmsi": "002320001", + "nature_of_distress": "FIRE", + "latitude": 51.5074, + "longitude": -0.1278, + "telecommand1_text": "F3E_G3E_ALL", + "channel": 16, + "timestamp": "2025-01-15T12:00:00Z", } output = format_dsc_for_display(msg) - assert 'DISTRESS' in output - assert '232123456' in output - assert 'United Kingdom' in output - assert 'FIRE' in output - assert '51.5074' in output - assert 'Channel: 16' in output + assert "DISTRESS" in output + assert "232123456" in output + assert "United Kingdom" in output + assert "FIRE" in output + assert "51.5074" in output + assert "Channel: 16" in output class TestDSCDecoder: @@ -491,9 +519,10 @@ class TestDSCDecoder: def decoder(self): """Create a DSC decoder instance.""" # Skip if scipy not available - pytest.importorskip('scipy') - pytest.importorskip('numpy') + pytest.importorskip("scipy") + pytest.importorskip("numpy") from utils.dsc.decoder import DSCDecoder + return DSCDecoder() def test_decode_mmsi_valid(self, decoder): @@ -503,7 +532,7 @@ class TestDSCDecoder: # 02-32-12-34-56 -> symbols [2, 32, 12, 34, 56] symbols = [2, 32, 12, 34, 56] result = decoder._decode_mmsi(symbols) - assert result == '232123456' + assert result == "232123456" def test_decode_mmsi_with_leading_zeros(self, decoder): """Test MMSI decoding handles leading zeros.""" @@ -512,7 +541,7 @@ class TestDSCDecoder: # BCD pairs: 00-02-32-00-01 -> [0, 2, 32, 0, 1] symbols = [0, 2, 32, 0, 1] result = decoder._decode_mmsi(symbols) - assert result == '002320001' + assert result == "002320001" def test_decode_mmsi_short_symbols(self, decoder): """Test MMSI decoding returns None for short symbol list.""" @@ -535,8 +564,8 @@ class TestDSCDecoder: result = decoder._decode_position(symbols) assert result is not None - assert result['lat'] == pytest.approx(51.5, rel=0.01) - assert result['lon'] == pytest.approx(0.1667, rel=0.01) + assert result["lat"] == pytest.approx(51.5, rel=0.01) + assert result["lon"] == pytest.approx(0.1667, rel=0.01) def test_decode_position_northwest(self, decoder): """Test position decoding for NW quadrant.""" @@ -546,8 +575,8 @@ class TestDSCDecoder: result = decoder._decode_position(symbols) assert result is not None - assert result['lat'] > 0 # North - assert result['lon'] < 0 # West + assert result["lat"] > 0 # North + assert result["lon"] < 0 # West def test_decode_position_southeast(self, decoder): """Test position decoding for SE quadrant.""" @@ -556,8 +585,8 @@ class TestDSCDecoder: result = decoder._decode_position(symbols) assert result is not None - assert result['lat'] < 0 # South - assert result['lon'] > 0 # East + assert result["lat"] < 0 # South + assert result["lon"] > 0 # East def test_decode_position_short_symbols(self, decoder): """Test position decoding handles short symbol list.""" @@ -570,7 +599,7 @@ class TestDSCDecoder: symbols = [10, 95, 30, 0, 10, 0, 0, 0, 0, 0] result = decoder._decode_position(symbols) assert result is not None - assert result['lat'] == pytest.approx(0.5, rel=0.01) # 0 deg + 30 min + assert result["lat"] == pytest.approx(0.5, rel=0.01) # 0 deg + 30 min def test_bits_to_symbol(self, decoder): """Test bit to symbol conversion.""" @@ -628,6 +657,7 @@ class TestDSCDecoder: # Create 5 normal symbols + EOS at position 5 — should be skipped # Followed by more symbols and a real EOS at position 15 from utils.dsc.decoder import DSCDecoder + d = DSCDecoder() # Build symbols manually: we need _try_decode_message to find EOS too early @@ -648,7 +678,7 @@ class TestDSCDecoder: # message truncated at position 5. # Just verify no crash and either None or a valid longer message # (not truncated at the early EOS) - assert result is None or len(result.get('raw', '')) > 18 + assert result is None or len(result.get("raw", "")) > 18 def test_bits_to_symbol_check_bit_validation(self, decoder): """Test that _bits_to_symbol rejects symbols with invalid check bits.""" @@ -668,17 +698,19 @@ class TestDSCDecoder: from utils.dsc.parser import parse_dsc_message - raw = json.dumps({ - 'type': 'dsc', - 'format': 123, - 'source_mmsi': '232123456', - 'category': 'SAFETY', - 'timestamp': '2025-01-15T12:00:00Z', - 'raw': '123232123456100122', - }) + raw = json.dumps( + { + "type": "dsc", + "format": 123, + "source_mmsi": "232123456", + "category": "SAFETY", + "timestamp": "2025-01-15T12:00:00Z", + "raw": "123232123456100122", + } + ) msg = parse_dsc_message(raw) assert msg is not None - assert msg['is_critical'] is True + assert msg["is_critical"] is True class TestDSCConstants: @@ -722,13 +754,13 @@ class TestDSCConstants: from utils.dsc.constants import MID_COUNTRY_MAP # Verify some key maritime nations - assert '232' in MID_COUNTRY_MAP # UK - assert '366' in MID_COUNTRY_MAP # USA - assert '351' in MID_COUNTRY_MAP # Panama - assert '257' in MID_COUNTRY_MAP # Norway - assert '211' in MID_COUNTRY_MAP # Germany - assert '503' in MID_COUNTRY_MAP # Australia - assert '431' in MID_COUNTRY_MAP # Japan + assert "232" in MID_COUNTRY_MAP # UK + assert "366" in MID_COUNTRY_MAP # USA + assert "351" in MID_COUNTRY_MAP # Panama + assert "257" in MID_COUNTRY_MAP # Norway + assert "211" in MID_COUNTRY_MAP # Germany + assert "503" in MID_COUNTRY_MAP # Australia + assert "431" in MID_COUNTRY_MAP # Japan def test_vhf_channel_70_frequency(self): """Test DSC Channel 70 frequency constant.""" @@ -754,11 +786,11 @@ class TestDSCConstants: assert len(TELECOMMAND_CODES_FULL) == 128 # Known codes map correctly - assert TELECOMMAND_CODES_FULL[100] == 'F3E_G3E_ALL' - assert TELECOMMAND_CODES_FULL[107] == 'DISTRESS_ACK' + assert TELECOMMAND_CODES_FULL[100] == "F3E_G3E_ALL" + assert TELECOMMAND_CODES_FULL[107] == "DISTRESS_ACK" # Unknown codes map to "UNKNOWN" - assert TELECOMMAND_CODES_FULL[0] == 'UNKNOWN' - assert TELECOMMAND_CODES_FULL[99] == 'UNKNOWN' + assert TELECOMMAND_CODES_FULL[0] == "UNKNOWN" + assert TELECOMMAND_CODES_FULL[99] == "UNKNOWN" def test_telecommand_formats(self): """Test TELECOMMAND_FORMATS contains correct format codes.""" diff --git a/tests/test_dsc_database.py b/tests/test_dsc_database.py index d9ec911..993ee86 100644 --- a/tests/test_dsc_database.py +++ b/tests/test_dsc_database.py @@ -11,11 +11,10 @@ import pytest def temp_db(): """Use a temporary database for each test.""" with tempfile.TemporaryDirectory() as tmpdir: - test_db_path = Path(tmpdir) / 'test_intercept.db' + test_db_path = Path(tmpdir) / "test_intercept.db" test_db_dir = Path(tmpdir) - with patch('utils.database.DB_PATH', test_db_path), \ - patch('utils.database.DB_DIR', test_db_dir): + with patch("utils.database.DB_PATH", test_db_path), patch("utils.database.DB_DIR", test_db_dir): from utils.database import close_db, init_db init_db() @@ -31,13 +30,13 @@ class TestDSCAlertsCRUD: from utils.database import get_dsc_alert, store_dsc_alert alert_id = store_dsc_alert( - source_mmsi='232123456', - format_code='100', - category='DISTRESS', - source_name='MV Test Ship', - nature_of_distress='FIRE', + source_mmsi="232123456", + format_code="100", + category="DISTRESS", + source_name="MV Test Ship", + nature_of_distress="FIRE", latitude=51.5, - longitude=-0.1 + longitude=-0.1, ) assert alert_id is not None @@ -46,32 +45,28 @@ class TestDSCAlertsCRUD: alert = get_dsc_alert(alert_id) assert alert is not None - assert alert['source_mmsi'] == '232123456' - assert alert['format_code'] == '100' - assert alert['category'] == 'DISTRESS' - assert alert['source_name'] == 'MV Test Ship' - assert alert['nature_of_distress'] == 'FIRE' - assert alert['latitude'] == 51.5 - assert alert['longitude'] == -0.1 - assert alert['acknowledged'] is False + assert alert["source_mmsi"] == "232123456" + assert alert["format_code"] == "100" + assert alert["category"] == "DISTRESS" + assert alert["source_name"] == "MV Test Ship" + assert alert["nature_of_distress"] == "FIRE" + assert alert["latitude"] == 51.5 + assert alert["longitude"] == -0.1 + assert alert["acknowledged"] is False def test_store_minimal_alert(self, temp_db): """Test storing alert with only required fields.""" from utils.database import get_dsc_alert, store_dsc_alert - alert_id = store_dsc_alert( - source_mmsi='366000001', - format_code='116', - category='ROUTINE' - ) + alert_id = store_dsc_alert(source_mmsi="366000001", format_code="116", category="ROUTINE") alert = get_dsc_alert(alert_id) assert alert is not None - assert alert['source_mmsi'] == '366000001' - assert alert['category'] == 'ROUTINE' - assert alert['latitude'] is None - assert alert['longitude'] is None + assert alert["source_mmsi"] == "366000001" + assert alert["category"] == "ROUTINE" + assert alert["latitude"] is None + assert alert["longitude"] is None def test_get_nonexistent_alert(self, temp_db): """Test getting an alert that doesn't exist.""" @@ -84,9 +79,9 @@ class TestDSCAlertsCRUD: """Test getting all alerts.""" from utils.database import get_dsc_alerts, store_dsc_alert - store_dsc_alert('232123456', '100', 'DISTRESS') - store_dsc_alert('366000001', '120', 'URGENCY') - store_dsc_alert('351234567', '116', 'ROUTINE') + store_dsc_alert("232123456", "100", "DISTRESS") + store_dsc_alert("366000001", "120", "URGENCY") + store_dsc_alert("351234567", "116", "ROUTINE") alerts = get_dsc_alerts() @@ -96,13 +91,13 @@ class TestDSCAlertsCRUD: """Test filtering alerts by category.""" from utils.database import get_dsc_alerts, store_dsc_alert - store_dsc_alert('232123456', '100', 'DISTRESS') - store_dsc_alert('232123457', '100', 'DISTRESS') - store_dsc_alert('366000001', '120', 'URGENCY') - store_dsc_alert('351234567', '116', 'ROUTINE') + store_dsc_alert("232123456", "100", "DISTRESS") + store_dsc_alert("232123457", "100", "DISTRESS") + store_dsc_alert("366000001", "120", "URGENCY") + store_dsc_alert("351234567", "116", "ROUTINE") - distress_alerts = get_dsc_alerts(category='DISTRESS') - urgency_alerts = get_dsc_alerts(category='URGENCY') + distress_alerts = get_dsc_alerts(category="DISTRESS") + urgency_alerts = get_dsc_alerts(category="URGENCY") assert len(distress_alerts) == 2 assert len(urgency_alerts) == 1 @@ -111,9 +106,9 @@ class TestDSCAlertsCRUD: """Test filtering alerts by acknowledgement status.""" from utils.database import acknowledge_dsc_alert, get_dsc_alerts, store_dsc_alert - id1 = store_dsc_alert('232123456', '100', 'DISTRESS') - id2 = store_dsc_alert('366000001', '100', 'DISTRESS') - store_dsc_alert('351234567', '100', 'DISTRESS') + id1 = store_dsc_alert("232123456", "100", "DISTRESS") + id2 = store_dsc_alert("366000001", "100", "DISTRESS") + store_dsc_alert("351234567", "100", "DISTRESS") acknowledge_dsc_alert(id1) acknowledge_dsc_alert(id2) @@ -128,15 +123,15 @@ class TestDSCAlertsCRUD: """Test filtering alerts by source MMSI.""" from utils.database import get_dsc_alerts, store_dsc_alert - store_dsc_alert('232123456', '100', 'DISTRESS') - store_dsc_alert('232123456', '120', 'URGENCY') - store_dsc_alert('366000001', '100', 'DISTRESS') + store_dsc_alert("232123456", "100", "DISTRESS") + store_dsc_alert("232123456", "120", "URGENCY") + store_dsc_alert("366000001", "100", "DISTRESS") - alerts = get_dsc_alerts(source_mmsi='232123456') + alerts = get_dsc_alerts(source_mmsi="232123456") assert len(alerts) == 2 for alert in alerts: - assert alert['source_mmsi'] == '232123456' + assert alert["source_mmsi"] == "232123456" def test_get_dsc_alerts_pagination(self, temp_db): """Test alert pagination.""" @@ -144,7 +139,7 @@ class TestDSCAlertsCRUD: # Create 10 alerts for i in range(10): - store_dsc_alert(f'23212345{i}', '100', 'DISTRESS') + store_dsc_alert(f"23212345{i}", "100", "DISTRESS") # Get first page page1 = get_dsc_alerts(limit=5, offset=0) @@ -155,17 +150,17 @@ class TestDSCAlertsCRUD: assert len(page2) == 5 # Ensure no overlap - page1_ids = {a['id'] for a in page1} - page2_ids = {a['id'] for a in page2} + page1_ids = {a["id"] for a in page1} + page2_ids = {a["id"] for a in page2} assert page1_ids.isdisjoint(page2_ids) def test_get_dsc_alerts_order(self, temp_db): """Test alerts are returned in reverse chronological order.""" from utils.database import get_dsc_alerts, store_dsc_alert - id1 = store_dsc_alert('232123456', '100', 'DISTRESS') - id2 = store_dsc_alert('366000001', '100', 'DISTRESS') - id3 = store_dsc_alert('351234567', '100', 'DISTRESS') + id1 = store_dsc_alert("232123456", "100", "DISTRESS") + id2 = store_dsc_alert("366000001", "100", "DISTRESS") + id3 = store_dsc_alert("351234567", "100", "DISTRESS") alerts = get_dsc_alerts() @@ -174,18 +169,18 @@ class TestDSCAlertsCRUD: # The actual order depends on the DB implementation # We just verify all 3 are present and it's a list assert len(alerts) == 3 - alert_ids = {a['id'] for a in alerts} + alert_ids = {a["id"] for a in alerts} assert alert_ids == {id1, id2, id3} def test_acknowledge_dsc_alert(self, temp_db): """Test acknowledging a DSC alert.""" from utils.database import acknowledge_dsc_alert, get_dsc_alert, store_dsc_alert - alert_id = store_dsc_alert('232123456', '100', 'DISTRESS') + alert_id = store_dsc_alert("232123456", "100", "DISTRESS") # Initially not acknowledged alert = get_dsc_alert(alert_id) - assert alert['acknowledged'] is False + assert alert["acknowledged"] is False # Acknowledge it result = acknowledge_dsc_alert(alert_id) @@ -193,19 +188,19 @@ class TestDSCAlertsCRUD: # Now acknowledged alert = get_dsc_alert(alert_id) - assert alert['acknowledged'] is True + assert alert["acknowledged"] is True def test_acknowledge_dsc_alert_with_notes(self, temp_db): """Test acknowledging with notes.""" from utils.database import acknowledge_dsc_alert, get_dsc_alert, store_dsc_alert - alert_id = store_dsc_alert('232123456', '100', 'DISTRESS') + alert_id = store_dsc_alert("232123456", "100", "DISTRESS") - acknowledge_dsc_alert(alert_id, notes='Vessel located, rescue underway') + acknowledge_dsc_alert(alert_id, notes="Vessel located, rescue underway") alert = get_dsc_alert(alert_id) - assert alert['acknowledged'] is True - assert alert['notes'] == 'Vessel located, rescue underway' + assert alert["acknowledged"] is True + assert alert["notes"] == "Vessel located, rescue underway" def test_acknowledge_nonexistent_alert(self, temp_db): """Test acknowledging an alert that doesn't exist.""" @@ -219,21 +214,21 @@ class TestDSCAlertsCRUD: from utils.database import acknowledge_dsc_alert, get_dsc_alert_summary, store_dsc_alert # Create various alerts - store_dsc_alert('232123456', '100', 'DISTRESS') - store_dsc_alert('232123457', '100', 'DISTRESS') - store_dsc_alert('366000001', '120', 'URGENCY') - store_dsc_alert('351234567', '118', 'SAFETY') - acked_id = store_dsc_alert('257000001', '100', 'DISTRESS') + store_dsc_alert("232123456", "100", "DISTRESS") + store_dsc_alert("232123457", "100", "DISTRESS") + store_dsc_alert("366000001", "120", "URGENCY") + store_dsc_alert("351234567", "118", "SAFETY") + acked_id = store_dsc_alert("257000001", "100", "DISTRESS") # Acknowledge one distress acknowledge_dsc_alert(acked_id) summary = get_dsc_alert_summary() - assert summary['distress'] == 2 # 3 - 1 acknowledged - assert summary['urgency'] == 1 - assert summary['safety'] == 1 - assert summary['total'] == 4 + assert summary["distress"] == 2 # 3 - 1 acknowledged + assert summary["urgency"] == 1 + assert summary["safety"] == 1 + assert summary["total"] == 4 def test_get_dsc_alert_summary_empty(self, temp_db): """Test alert summary with no alerts.""" @@ -241,20 +236,20 @@ class TestDSCAlertsCRUD: summary = get_dsc_alert_summary() - assert summary['distress'] == 0 - assert summary['urgency'] == 0 - assert summary['safety'] == 0 - assert summary['routine'] == 0 - assert summary['total'] == 0 + assert summary["distress"] == 0 + assert summary["urgency"] == 0 + assert summary["safety"] == 0 + assert summary["routine"] == 0 + assert summary["total"] == 0 def test_cleanup_old_dsc_alerts(self, temp_db): """Test cleanup function behavior.""" from utils.database import acknowledge_dsc_alert, cleanup_old_dsc_alerts, get_dsc_alerts, store_dsc_alert # Create and acknowledge some alerts - id1 = store_dsc_alert('232123456', '100', 'DISTRESS') - id2 = store_dsc_alert('366000001', '100', 'DISTRESS') - id3 = store_dsc_alert('351234567', '100', 'DISTRESS') # Unacknowledged + id1 = store_dsc_alert("232123456", "100", "DISTRESS") + id2 = store_dsc_alert("366000001", "100", "DISTRESS") + id3 = store_dsc_alert("351234567", "100", "DISTRESS") # Unacknowledged acknowledge_dsc_alert(id1) acknowledge_dsc_alert(id2) @@ -270,15 +265,15 @@ class TestDSCAlertsCRUD: # Verify unacknowledged one is still unacknowledged unacked = get_dsc_alerts(acknowledged=False) assert len(unacked) == 1 - assert unacked[0]['id'] == id3 + assert unacked[0]["id"] == id3 def test_cleanup_preserves_unacknowledged(self, temp_db): """Test cleanup preserves unacknowledged alerts regardless of age.""" from utils.database import cleanup_old_dsc_alerts, get_dsc_alerts, store_dsc_alert # Create unacknowledged alerts - store_dsc_alert('232123456', '100', 'DISTRESS') - store_dsc_alert('366000001', '100', 'DISTRESS') + store_dsc_alert("232123456", "100", "DISTRESS") + store_dsc_alert("366000001", "100", "DISTRESS") # Cleanup with 0 days deleted = cleanup_old_dsc_alerts(max_age_days=0) @@ -292,31 +287,23 @@ class TestDSCAlertsCRUD: """Test storing alert with raw message data.""" from utils.database import get_dsc_alert, store_dsc_alert - raw = '100023212345603660000110010010000000000127' + raw = "100023212345603660000110010010000000000127" - alert_id = store_dsc_alert( - source_mmsi='232123456', - format_code='100', - category='DISTRESS', - raw_message=raw - ) + alert_id = store_dsc_alert(source_mmsi="232123456", format_code="100", category="DISTRESS", raw_message=raw) alert = get_dsc_alert(alert_id) - assert alert['raw_message'] == raw + assert alert["raw_message"] == raw def test_store_alert_with_destination(self, temp_db): """Test storing alert with destination MMSI.""" from utils.database import get_dsc_alert, store_dsc_alert alert_id = store_dsc_alert( - source_mmsi='232123456', - format_code='112', - category='INDIVIDUAL', - dest_mmsi='366000001' + source_mmsi="232123456", format_code="112", category="INDIVIDUAL", dest_mmsi="366000001" ) alert = get_dsc_alert(alert_id) - assert alert['dest_mmsi'] == '366000001' + assert alert["dest_mmsi"] == "366000001" class TestDSCDatabaseIntegration: @@ -334,37 +321,37 @@ class TestDSCDatabaseIntegration: # 1. Store a distress alert alert_id = store_dsc_alert( - source_mmsi='232123456', - format_code='100', - category='DISTRESS', - source_name='MV Mayday', - nature_of_distress='SINKING', + source_mmsi="232123456", + format_code="100", + category="DISTRESS", + source_name="MV Mayday", + nature_of_distress="SINKING", latitude=50.0, - longitude=-5.0 + longitude=-5.0, ) # 2. Verify it appears in summary summary = get_dsc_alert_summary() - assert summary['distress'] == 1 - assert summary['total'] == 1 + assert summary["distress"] == 1 + assert summary["total"] == 1 # 3. Verify it appears in unacknowledged list unacked = get_dsc_alerts(acknowledged=False) assert len(unacked) == 1 - assert unacked[0]['source_mmsi'] == '232123456' + assert unacked[0]["source_mmsi"] == "232123456" # 4. Acknowledge with notes - acknowledge_dsc_alert(alert_id, 'Rescue helicopter dispatched') + acknowledge_dsc_alert(alert_id, "Rescue helicopter dispatched") # 5. Verify it's now acknowledged alert = get_dsc_alert(alert_id) - assert alert['acknowledged'] is True - assert alert['notes'] == 'Rescue helicopter dispatched' + assert alert["acknowledged"] is True + assert alert["notes"] == "Rescue helicopter dispatched" # 6. Verify summary updated summary = get_dsc_alert_summary() - assert summary['distress'] == 0 - assert summary['total'] == 0 + assert summary["distress"] == 0 + assert summary["total"] == 0 # 7. Verify it appears in acknowledged list acked = get_dsc_alerts(acknowledged=True) @@ -376,23 +363,18 @@ class TestDSCDatabaseIntegration: # Simulate multiple vessels in distress vessels = [ - ('232123456', 'United Kingdom', 'FIRE'), - ('366000001', 'USA', 'FLOODING'), - ('351234567', 'Panama', 'COLLISION'), + ("232123456", "United Kingdom", "FIRE"), + ("366000001", "USA", "FLOODING"), + ("351234567", "Panama", "COLLISION"), ] for mmsi, _country, nature in vessels: - store_dsc_alert( - source_mmsi=mmsi, - format_code='100', - category='DISTRESS', - nature_of_distress=nature - ) + store_dsc_alert(source_mmsi=mmsi, format_code="100", category="DISTRESS", nature_of_distress=nature) # Verify all alerts stored - alerts = get_dsc_alerts(category='DISTRESS') + alerts = get_dsc_alerts(category="DISTRESS") assert len(alerts) == 3 # Verify each has correct nature - natures = {a['nature_of_distress'] for a in alerts} - assert natures == {'FIRE', 'FLOODING', 'COLLISION'} + natures = {a["nature_of_distress"] for a in alerts} + assert natures == {"FIRE", "FLOODING", "COLLISION"} diff --git a/tests/test_flight_correlator.py b/tests/test_flight_correlator.py index d881d44..a50c938 100644 --- a/tests/test_flight_correlator.py +++ b/tests/test_flight_correlator.py @@ -13,87 +13,106 @@ class TestFlightCorrelator: self.correlator = FlightCorrelator(max_messages=100) def test_add_acars_message(self): - self.correlator.add_acars_message({ - 'flight': 'BAW123', 'tail': 'G-ABCD', 'text': 'Hello', - }) + self.correlator.add_acars_message( + { + "flight": "BAW123", + "tail": "G-ABCD", + "text": "Hello", + } + ) assert self.correlator.acars_count == 1 def test_add_vdl2_message(self): - self.correlator.add_vdl2_message({ - 'flight': 'DLH456', 'text': 'World', - }) + self.correlator.add_vdl2_message( + { + "flight": "DLH456", + "text": "World", + } + ) assert self.correlator.vdl2_count == 1 def test_match_by_callsign(self): - self.correlator.add_acars_message({ - 'flight': 'BAW123', 'text': 'msg1', - }) - self.correlator.add_acars_message({ - 'flight': 'DLH456', 'text': 'msg2', - }) + self.correlator.add_acars_message( + { + "flight": "BAW123", + "text": "msg1", + } + ) + self.correlator.add_acars_message( + { + "flight": "DLH456", + "text": "msg2", + } + ) - result = self.correlator.get_messages_for_aircraft(callsign='BAW123') - assert len(result['acars']) == 1 - assert result['acars'][0]['text'] == 'msg1' + result = self.correlator.get_messages_for_aircraft(callsign="BAW123") + assert len(result["acars"]) == 1 + assert result["acars"][0]["text"] == "msg1" def test_match_by_icao(self): - self.correlator.add_vdl2_message({ - 'icao': 'ABC123', 'text': 'vdl2 msg', - }) + self.correlator.add_vdl2_message( + { + "icao": "ABC123", + "text": "vdl2 msg", + } + ) - result = self.correlator.get_messages_for_aircraft(icao='ABC123') - assert len(result['vdl2']) == 1 - assert result['vdl2'][0]['text'] == 'vdl2 msg' + result = self.correlator.get_messages_for_aircraft(icao="ABC123") + assert len(result["vdl2"]) == 1 + assert result["vdl2"][0]["text"] == "vdl2 msg" def test_no_match_returns_empty(self): - self.correlator.add_acars_message({'flight': 'BAW123', 'text': 'msg'}) + self.correlator.add_acars_message({"flight": "BAW123", "text": "msg"}) - result = self.correlator.get_messages_for_aircraft(callsign='NOMATCH') - assert result['acars'] == [] - assert result['vdl2'] == [] + result = self.correlator.get_messages_for_aircraft(callsign="NOMATCH") + assert result["acars"] == [] + assert result["vdl2"] == [] def test_empty_search_returns_empty(self): result = self.correlator.get_messages_for_aircraft() - assert result == {'acars': [], 'vdl2': []} + assert result == {"acars": [], "vdl2": []} def test_ring_buffer_limit(self): correlator = FlightCorrelator(max_messages=5) for i in range(10): - correlator.add_acars_message({'flight': f'FL{i}', 'text': f'msg{i}'}) + correlator.add_acars_message({"flight": f"FL{i}", "text": f"msg{i}"}) assert correlator.acars_count == 5 # First 5 messages should have been evicted - result = correlator.get_messages_for_aircraft(callsign='FL0') - assert len(result['acars']) == 0 + result = correlator.get_messages_for_aircraft(callsign="FL0") + assert len(result["acars"]) == 0 # Last message should still be there - result = correlator.get_messages_for_aircraft(callsign='FL9') - assert len(result['acars']) == 1 + result = correlator.get_messages_for_aircraft(callsign="FL9") + assert len(result["acars"]) == 1 def test_case_insensitive_matching(self): - self.correlator.add_acars_message({'flight': 'baw123', 'text': 'lowercase'}) + self.correlator.add_acars_message({"flight": "baw123", "text": "lowercase"}) - result = self.correlator.get_messages_for_aircraft(callsign='BAW123') - assert len(result['acars']) == 1 + result = self.correlator.get_messages_for_aircraft(callsign="BAW123") + assert len(result["acars"]) == 1 def test_match_by_tail_field(self): - self.correlator.add_acars_message({ - 'tail': 'G-ABCD', 'text': 'tail match', - }) + self.correlator.add_acars_message( + { + "tail": "G-ABCD", + "text": "tail match", + } + ) - result = self.correlator.get_messages_for_aircraft(callsign='G-ABCD') - assert len(result['acars']) == 1 + result = self.correlator.get_messages_for_aircraft(callsign="G-ABCD") + assert len(result["acars"]) == 1 def test_internal_fields_not_returned(self): - self.correlator.add_acars_message({'flight': 'TEST', 'text': 'msg'}) + self.correlator.add_acars_message({"flight": "TEST", "text": "msg"}) - result = self.correlator.get_messages_for_aircraft(callsign='TEST') - msg = result['acars'][0] - assert '_corr_time' not in msg + result = self.correlator.get_messages_for_aircraft(callsign="TEST") + msg = result["acars"][0] + assert "_corr_time" not in msg def test_both_acars_and_vdl2_returned(self): - self.correlator.add_acars_message({'flight': 'UAL789', 'text': 'acars'}) - self.correlator.add_vdl2_message({'flight': 'UAL789', 'text': 'vdl2'}) + self.correlator.add_acars_message({"flight": "UAL789", "text": "acars"}) + self.correlator.add_vdl2_message({"flight": "UAL789", "text": "vdl2"}) - result = self.correlator.get_messages_for_aircraft(callsign='UAL789') - assert len(result['acars']) == 1 - assert len(result['vdl2']) == 1 + result = self.correlator.get_messages_for_aircraft(callsign="UAL789") + assert len(result["acars"]) == 1 + assert len(result["vdl2"]) == 1 diff --git a/tests/test_geofence.py b/tests/test_geofence.py index b4b67dc..e969190 100644 --- a/tests/test_geofence.py +++ b/tests/test_geofence.py @@ -10,22 +10,26 @@ class TestHaversineDistance: def test_same_point_zero_distance(self): from utils.geofence import haversine_distance + assert haversine_distance(51.5, -0.1, 51.5, -0.1) == 0.0 def test_known_distance_london_paris(self): from utils.geofence import haversine_distance + # London to Paris ~340km dist = haversine_distance(51.5074, -0.1278, 48.8566, 2.3522) assert 340_000 < dist < 345_000 def test_short_distance(self): from utils.geofence import haversine_distance + # Two points ~111m apart (0.001 degrees latitude at equator) dist = haversine_distance(0.0, 0.0, 0.001, 0.0) assert 100 < dist < 120 def test_antipodal_distance(self): from utils.geofence import haversine_distance + # North pole to south pole ~20015km dist = haversine_distance(90.0, 0.0, -90.0, 0.0) assert 20_000_000 < dist < 20_050_000 @@ -39,7 +43,7 @@ class TestGeofenceManager: """Provide a fresh GeofenceManager with mocked DB.""" from utils.geofence import GeofenceManager - with patch('utils.geofence._ensure_table'), patch('utils.geofence.get_db') as mock_db: + with patch("utils.geofence._ensure_table"), patch("utils.geofence.get_db") as mock_db: # Mock the context manager mock_conn = MagicMock() mock_db.return_value.__enter__ = MagicMock(return_value=mock_conn) @@ -51,64 +55,93 @@ class TestGeofenceManager: self.manager.list_zones = lambda: self._zones def test_no_zones_returns_empty(self): - events = self.manager.check_position('TEST1', 'aircraft', 51.5, -0.1) + events = self.manager.check_position("TEST1", "aircraft", 51.5, -0.1) assert events == [] def test_enter_event(self): - self._zones = [{ - 'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278, - 'radius_m': 10000, 'alert_on': 'enter_exit', - }] + self._zones = [ + { + "id": 1, + "name": "London", + "lat": 51.5074, + "lon": -0.1278, + "radius_m": 10000, + "alert_on": "enter_exit", + } + ] # First position inside zone - events = self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278) + events = self.manager.check_position("AC1", "aircraft", 51.5074, -0.1278) assert len(events) == 1 - assert events[0]['type'] == 'geofence_enter' - assert events[0]['zone_name'] == 'London' + assert events[0]["type"] == "geofence_enter" + assert events[0]["zone_name"] == "London" def test_no_duplicate_enter(self): - self._zones = [{ - 'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278, - 'radius_m': 10000, 'alert_on': 'enter_exit', - }] + self._zones = [ + { + "id": 1, + "name": "London", + "lat": 51.5074, + "lon": -0.1278, + "radius_m": 10000, + "alert_on": "enter_exit", + } + ] # First enter - self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278) + self.manager.check_position("AC1", "aircraft", 51.5074, -0.1278) # Second check still inside - should not fire enter again - events = self.manager.check_position('AC1', 'aircraft', 51.508, -0.128) + events = self.manager.check_position("AC1", "aircraft", 51.508, -0.128) assert len(events) == 0 def test_exit_event(self): - self._zones = [{ - 'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278, - 'radius_m': 1000, 'alert_on': 'enter_exit', - }] + self._zones = [ + { + "id": 1, + "name": "London", + "lat": 51.5074, + "lon": -0.1278, + "radius_m": 1000, + "alert_on": "enter_exit", + } + ] # Enter - self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278) + self.manager.check_position("AC1", "aircraft", 51.5074, -0.1278) # Exit (far away) - events = self.manager.check_position('AC1', 'aircraft', 52.0, 0.0) + events = self.manager.check_position("AC1", "aircraft", 52.0, 0.0) assert len(events) == 1 - assert events[0]['type'] == 'geofence_exit' + assert events[0]["type"] == "geofence_exit" def test_enter_only_mode(self): - self._zones = [{ - 'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278, - 'radius_m': 1000, 'alert_on': 'enter', - }] + self._zones = [ + { + "id": 1, + "name": "London", + "lat": 51.5074, + "lon": -0.1278, + "radius_m": 1000, + "alert_on": "enter", + } + ] # Enter - events = self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278) + events = self.manager.check_position("AC1", "aircraft", 51.5074, -0.1278) assert len(events) == 1 - assert events[0]['type'] == 'geofence_enter' + assert events[0]["type"] == "geofence_enter" # Exit should not fire - events = self.manager.check_position('AC1', 'aircraft', 52.0, 0.0) + events = self.manager.check_position("AC1", "aircraft", 52.0, 0.0) assert len(events) == 0 def test_metadata_included_in_event(self): - self._zones = [{ - 'id': 1, 'name': 'Zone', 'lat': 0.0, 'lon': 0.0, - 'radius_m': 100000, 'alert_on': 'enter_exit', - }] + self._zones = [ + { + "id": 1, + "name": "Zone", + "lat": 0.0, + "lon": 0.0, + "radius_m": 100000, + "alert_on": "enter_exit", + } + ] events = self.manager.check_position( - 'AC1', 'aircraft', 0.0, 0.0, - metadata={'callsign': 'TEST01', 'altitude': 35000} + "AC1", "aircraft", 0.0, 0.0, metadata={"callsign": "TEST01", "altitude": 35000} ) - assert events[0]['callsign'] == 'TEST01' - assert events[0]['altitude'] == 35000 + assert events[0]["callsign"] == "TEST01" + assert events[0]["altitude"] == 35000 diff --git a/tests/test_gps_routes.py b/tests/test_gps_routes.py index 4d1a68a..79dca4b 100644 --- a/tests/test_gps_routes.py +++ b/tests/test_gps_routes.py @@ -49,30 +49,30 @@ def test_auto_connect_attaches_callbacks_when_reader_already_running(client, mon self.sky_callbacks.append(callback) reader = FakeReader() - monkeypatch.setattr(gps_routes, 'get_gps_reader', lambda: reader) + monkeypatch.setattr(gps_routes, "get_gps_reader", lambda: reader) with client.session_transaction() as sess: - sess['logged_in'] = True + sess["logged_in"] = True - response = client.post('/gps/auto-connect') + response = client.post("/gps/auto-connect") payload = response.get_json() assert response.status_code == 200 - assert payload['status'] == 'connected' + assert payload["status"] == "connected" assert reader.position_callbacks == [gps_routes._position_callback] assert reader.sky_callbacks == [gps_routes._sky_callback] def test_satellites_returns_waiting_when_reader_not_running(client, monkeypatch): """Satellite endpoint should return a non-error waiting state when reader is down.""" - monkeypatch.setattr(gps_routes, 'get_gps_reader', lambda: None) + monkeypatch.setattr(gps_routes, "get_gps_reader", lambda: None) with client.session_transaction() as sess: - sess['logged_in'] = True + sess["logged_in"] = True - response = client.get('/gps/satellites') + response = client.get("/gps/satellites") payload = response.get_json() assert response.status_code == 200 - assert payload['status'] == 'waiting' - assert payload['running'] is False + assert payload["status"] == "waiting" + assert payload["running"] is False diff --git a/tests/test_hackrf_commands.py b/tests/test_hackrf_commands.py index 8331dd9..427c3f8 100644 --- a/tests/test_hackrf_commands.py +++ b/tests/test_hackrf_commands.py @@ -4,13 +4,13 @@ from utils.sdr.base import SDRDevice, SDRType from utils.sdr.hackrf import HackRFCommandBuilder -def _make_device(serial: str = 'abc123') -> SDRDevice: +def _make_device(serial: str = "abc123") -> SDRDevice: return SDRDevice( sdr_type=SDRType.HACKRF, index=0, - name='HackRF One', + name="HackRF One", serial=serial, - driver='hackrf', + driver="hackrf", capabilities=HackRFCommandBuilder.CAPABILITIES, ) @@ -59,30 +59,26 @@ class TestBuildAdsbCommand: def test_contains_soapysdr_device_type(self): builder = HackRFCommandBuilder() cmd = builder.build_adsb_command(_make_device(), gain=40) - assert '--device-type' in cmd - assert 'soapysdr' in cmd + assert "--device-type" in cmd + assert "soapysdr" in cmd def test_includes_serial_in_device_string(self): builder = HackRFCommandBuilder() - cmd = builder.build_adsb_command(_make_device(serial='deadbeef'), gain=40) - device_idx = cmd.index('--device') - assert 'deadbeef' in cmd[device_idx + 1] + cmd = builder.build_adsb_command(_make_device(serial="deadbeef"), gain=40) + device_idx = cmd.index("--device") + assert "deadbeef" in cmd[device_idx + 1] class TestBuildIQCaptureCommand: def test_outputs_cu8_to_stdout(self): builder = HackRFCommandBuilder() - cmd = builder.build_iq_capture_command( - _make_device(), frequency_mhz=100.0, sample_rate=2048000, gain=40 - ) - assert '-F' in cmd - assert 'CU8' in cmd - assert cmd[-1] == '-' + cmd = builder.build_iq_capture_command(_make_device(), frequency_mhz=100.0, sample_rate=2048000, gain=40) + assert "-F" in cmd + assert "CU8" in cmd + assert cmd[-1] == "-" def test_gain_split_in_command(self): builder = HackRFCommandBuilder() - cmd = builder.build_iq_capture_command( - _make_device(), frequency_mhz=100.0, gain=80 - ) - gain_idx = cmd.index('-g') - assert cmd[gain_idx + 1] == 'LNA=40,VGA=40' + cmd = builder.build_iq_capture_command(_make_device(), frequency_mhz=100.0, gain=80) + gain_idx = cmd.index("-g") + assert cmd[gain_idx + 1] == "LNA=40,VGA=40" diff --git a/tests/test_kiwisdr.py b/tests/test_kiwisdr.py index 8cdba43..4eaad4e 100644 --- a/tests/test_kiwisdr.py +++ b/tests/test_kiwisdr.py @@ -17,69 +17,71 @@ from utils.kiwisdr import ( # parse_host_port tests # ============================================ + def test_parse_host_port_basic(): """Should parse host:port from a simple URL.""" - assert parse_host_port('http://kiwi.example.com:8073') == ('kiwi.example.com', 8073) + assert parse_host_port("http://kiwi.example.com:8073") == ("kiwi.example.com", 8073) def test_parse_host_port_no_port(): """Should default to 8073 when port is missing.""" - assert parse_host_port('http://kiwi.example.com') == ('kiwi.example.com', KIWI_DEFAULT_PORT) + assert parse_host_port("http://kiwi.example.com") == ("kiwi.example.com", KIWI_DEFAULT_PORT) def test_parse_host_port_https(): """Should strip https:// prefix.""" - assert parse_host_port('https://secure.kiwi.com:9090') == ('secure.kiwi.com', 9090) + assert parse_host_port("https://secure.kiwi.com:9090") == ("secure.kiwi.com", 9090) def test_parse_host_port_ws(): """Should strip ws:// prefix.""" - assert parse_host_port('ws://kiwi.local:8074') == ('kiwi.local', 8074) + assert parse_host_port("ws://kiwi.local:8074") == ("kiwi.local", 8074) def test_parse_host_port_with_path(): """Should strip trailing path from URL.""" - assert parse_host_port('http://kiwi.com:8073/some/path') == ('kiwi.com', 8073) + assert parse_host_port("http://kiwi.com:8073/some/path") == ("kiwi.com", 8073) def test_parse_host_port_bare_host(): """Should handle bare hostname without protocol.""" - assert parse_host_port('kiwi.local') == ('kiwi.local', KIWI_DEFAULT_PORT) + assert parse_host_port("kiwi.local") == ("kiwi.local", KIWI_DEFAULT_PORT) def test_parse_host_port_bare_host_with_port(): """Should handle bare hostname with port.""" - assert parse_host_port('kiwi.local:8074') == ('kiwi.local', 8074) + assert parse_host_port("kiwi.local:8074") == ("kiwi.local", 8074) def test_parse_host_port_empty(): """Should handle empty/None input.""" - assert parse_host_port('') == ('', KIWI_DEFAULT_PORT) + assert parse_host_port("") == ("", KIWI_DEFAULT_PORT) def test_parse_host_port_invalid_port(): """Should default port for non-numeric port.""" - assert parse_host_port('http://kiwi.com:abc') == ('kiwi.com', KIWI_DEFAULT_PORT) + assert parse_host_port("http://kiwi.com:abc") == ("kiwi.com", KIWI_DEFAULT_PORT) # ============================================ # SND frame parsing tests # ============================================ + def _make_snd_frame(smeter_raw: int, pcm_samples: list[int]) -> bytes: """Build a mock KiwiSDR SND binary frame.""" - header = b'SND' # 3 bytes: magic - header += b'\x00' # 1 byte: flags - header += struct.pack('>I', 42) # 4 bytes: sequence number - header += struct.pack('>h', smeter_raw) # 2 bytes: S-meter + header = b"SND" # 3 bytes: magic + header += b"\x00" # 1 byte: flags + header += struct.pack(">I", 42) # 4 bytes: sequence number + header += struct.pack(">h", smeter_raw) # 2 bytes: S-meter # PCM data: 16-bit signed LE - pcm = b''.join(struct.pack(' 0 assert high > low def test_mode_filter_lsb_negative(): """LSB filter should be in negative passband.""" - low, high = MODE_FILTERS['lsb'] + low, high = MODE_FILTERS["lsb"] assert low < 0 assert high < 0 @@ -217,20 +221,21 @@ def test_mode_filter_lsb_negative(): # Connection tests with mocked WebSocket # ============================================ -@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) -@patch('utils.kiwisdr.websocket') + +@patch("utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE", True) +@patch("utils.kiwisdr.websocket") def test_client_connect_success(mock_ws_module): """Connect should establish a WebSocket connection.""" mock_ws = MagicMock() mock_ws_module.WebSocket.return_value = mock_ws - client = KiwiSDRClient(host='kiwi.local', port=8073) - result = client.connect(7000, 'am') + client = KiwiSDRClient(host="kiwi.local", port=8073) + result = client.connect(7000, "am") assert result is True assert client.connected is True assert client.frequency_khz == 7000 - assert client.mode == 'am' + assert client.mode == "am" # Verify WebSocket was created and connected mock_ws_module.WebSocket.assert_called_once() @@ -238,9 +243,9 @@ def test_client_connect_success(mock_ws_module): # Verify protocol messages were sent calls = [str(c) for c in mock_ws.send.call_args_list] - auth_sent = any('SET auth' in c for c in calls) - compression_sent = any('SET compression=0' in c for c in calls) - mod_sent = any('SET mod=am' in c and 'freq=7000' in c for c in calls) + auth_sent = any("SET auth" in c for c in calls) + compression_sent = any("SET compression=0" in c for c in calls) + mod_sent = any("SET mod=am" in c and "freq=7000" in c for c in calls) assert auth_sent, "Auth message not sent" assert compression_sent, "Compression message not sent" assert mod_sent, "Tune message not sent" @@ -249,70 +254,70 @@ def test_client_connect_success(mock_ws_module): client.disconnect() -@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) -@patch('utils.kiwisdr.websocket') +@patch("utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE", True) +@patch("utils.kiwisdr.websocket") def test_client_connect_failure(mock_ws_module): """Connect should handle connection failures.""" mock_ws = MagicMock() mock_ws.connect.side_effect = ConnectionRefusedError("Connection refused") mock_ws_module.WebSocket.return_value = mock_ws - client = KiwiSDRClient(host='unreachable.local', port=8073) - result = client.connect(7000, 'am') + client = KiwiSDRClient(host="unreachable.local", port=8073) + result = client.connect(7000, "am") assert result is False assert client.connected is False -@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) -@patch('utils.kiwisdr.websocket') +@patch("utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE", True) +@patch("utils.kiwisdr.websocket") def test_client_tune_success(mock_ws_module): """Tune should send the correct SET mod command.""" mock_ws = MagicMock() mock_ws_module.WebSocket.return_value = mock_ws - client = KiwiSDRClient(host='kiwi.local', port=8073) - client.connect(7000, 'am') + client = KiwiSDRClient(host="kiwi.local", port=8073) + client.connect(7000, "am") mock_ws.send.reset_mock() - result = client.tune(14000, 'usb') + result = client.tune(14000, "usb") assert result is True assert client.frequency_khz == 14000 - assert client.mode == 'usb' + assert client.mode == "usb" tune_calls = [str(c) for c in mock_ws.send.call_args_list] - assert any('SET mod=usb' in c and 'freq=14000' in c for c in tune_calls) + assert any("SET mod=usb" in c and "freq=14000" in c for c in tune_calls) client.disconnect() -@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) -@patch('utils.kiwisdr.websocket') +@patch("utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE", True) +@patch("utils.kiwisdr.websocket") def test_client_invalid_mode_fallback(mock_ws_module): """Connect with invalid mode should fall back to AM.""" mock_ws = MagicMock() mock_ws_module.WebSocket.return_value = mock_ws - client = KiwiSDRClient(host='kiwi.local', port=8073) - client.connect(7000, 'invalid_mode') + client = KiwiSDRClient(host="kiwi.local", port=8073) + client.connect(7000, "invalid_mode") - assert client.mode == 'am' + assert client.mode == "am" client.disconnect() -@patch('utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE', True) -@patch('utils.kiwisdr.websocket') +@patch("utils.kiwisdr.WEBSOCKET_CLIENT_AVAILABLE", True) +@patch("utils.kiwisdr.websocket") def test_client_ws_url_format(mock_ws_module): """WebSocket URL should follow KiwiSDR format.""" mock_ws = MagicMock() mock_ws_module.WebSocket.return_value = mock_ws - client = KiwiSDRClient(host='test.kiwi.com', port=8074) - client.connect(7000, 'am') + client = KiwiSDRClient(host="test.kiwi.com", port=8074) + client.connect(7000, "am") ws_url = mock_ws.connect.call_args[0][0] - assert ws_url.startswith('ws://test.kiwi.com:8074/') - assert ws_url.endswith('/SND') + assert ws_url.startswith("ws://test.kiwi.com:8074/") + assert ws_url.endswith("/SND") client.disconnect() diff --git a/tests/test_meteor_detector.py b/tests/test_meteor_detector.py index bebb471..b5ec5dc 100644 --- a/tests/test_meteor_detector.py +++ b/tests/test_meteor_detector.py @@ -50,11 +50,20 @@ class TestMeteorDetectorBasic: def test_reset(self, detector): detector._pings_total = 5 - detector._events.append(MeteorEvent( - id='test', start_ts=0, end_ts=1, duration_ms=100, - peak_db=-40, snr_db=20, center_freq_hz=143e6, - peak_freq_hz=143e6, freq_offset_hz=0, confidence=0.8, - )) + detector._events.append( + MeteorEvent( + id="test", + start_ts=0, + end_ts=1, + duration_ms=100, + peak_db=-40, + snr_db=20, + center_freq_hz=143e6, + peak_freq_hz=143e6, + freq_offset_hz=0, + confidence=0.8, + ) + ) detector.reset() assert detector._pings_total == 0 assert detector._events == [] @@ -206,14 +215,14 @@ class TestEventProperties: events = self._generate_event(detector) d = events[0].to_dict() assert isinstance(d, dict) - assert 'id' in d - assert 'snr_db' in d - assert 'tags' in d + assert "id" in d + assert "snr_db" in d + assert "tags" in d def test_strong_tag(self, detector): events = self._generate_event(detector, snr_offset=60) assert len(events) >= 1 - assert 'strong' in events[0].tags + assert "strong" in events[0].tags class TestStats: @@ -222,13 +231,13 @@ class TestStats: def test_stats_structure(self, detector): frame = _make_noise() stats, _ = detector.process_frame(frame, 142e6, 144e6, timestamp=time.time()) - assert stats['type'] == 'stats' - assert 'pings_total' in stats - assert 'pings_last_10min' in stats - assert 'strongest_snr' in stats - assert 'current_noise_floor' in stats - assert 'uptime_s' in stats - assert 'state' in stats + assert stats["type"] == "stats" + assert "pings_total" in stats + assert "pings_last_10min" in stats + assert "strongest_snr" in stats + assert "current_noise_floor" in stats + assert "uptime_s" in stats + assert "state" in stats def test_pings_total_increments(self, detector): rng = np.random.default_rng(42) @@ -262,43 +271,78 @@ class TestExport: """Export functionality tests.""" def test_export_csv(self, detector): - detector._events.append(MeteorEvent( - id='abc', start_ts=1000.0, end_ts=1000.5, duration_ms=500, - peak_db=-40, snr_db=20, center_freq_hz=143e6, - peak_freq_hz=143.001e6, freq_offset_hz=1000, confidence=0.85, - tags=['strong', 'medium'], - )) + detector._events.append( + MeteorEvent( + id="abc", + start_ts=1000.0, + end_ts=1000.5, + duration_ms=500, + peak_db=-40, + snr_db=20, + center_freq_hz=143e6, + peak_freq_hz=143.001e6, + freq_offset_hz=1000, + confidence=0.85, + tags=["strong", "medium"], + ) + ) csv = detector.export_events_csv() - assert 'abc' in csv - assert 'strong;medium' in csv + assert "abc" in csv + assert "strong;medium" in csv def test_export_json(self, detector): - detector._events.append(MeteorEvent( - id='def', start_ts=2000.0, end_ts=2001.0, duration_ms=1000, - peak_db=-35, snr_db=25, center_freq_hz=143e6, - peak_freq_hz=143e6, freq_offset_hz=0, confidence=0.9, - )) + detector._events.append( + MeteorEvent( + id="def", + start_ts=2000.0, + end_ts=2001.0, + duration_ms=1000, + peak_db=-35, + snr_db=25, + center_freq_hz=143e6, + peak_freq_hz=143e6, + freq_offset_hz=0, + confidence=0.9, + ) + ) data = json.loads(detector.export_events_json()) assert len(data) == 1 - assert data[0]['id'] == 'def' + assert data[0]["id"] == "def" def test_get_events(self, detector): for i in range(10): - detector._events.append(MeteorEvent( - id=str(i), start_ts=float(i), end_ts=float(i) + 0.1, - duration_ms=100, peak_db=-40, snr_db=15, - center_freq_hz=143e6, peak_freq_hz=143e6, - freq_offset_hz=0, confidence=0.7, - )) + detector._events.append( + MeteorEvent( + id=str(i), + start_ts=float(i), + end_ts=float(i) + 0.1, + duration_ms=100, + peak_db=-40, + snr_db=15, + center_freq_hz=143e6, + peak_freq_hz=143e6, + freq_offset_hz=0, + confidence=0.7, + ) + ) events = detector.get_events(limit=5) assert len(events) == 5 def test_clear_events(self, detector): - detector._events.append(MeteorEvent( - id='x', start_ts=0, end_ts=1, duration_ms=100, - peak_db=-40, snr_db=15, center_freq_hz=143e6, - peak_freq_hz=143e6, freq_offset_hz=0, confidence=0.7, - )) + detector._events.append( + MeteorEvent( + id="x", + start_ts=0, + end_ts=1, + duration_ms=100, + peak_db=-40, + snr_db=15, + center_freq_hz=143e6, + peak_freq_hz=143e6, + freq_offset_hz=0, + confidence=0.7, + ) + ) detector._pings_total = 1 count = detector.clear_events() assert count == 1 diff --git a/tests/test_morse.py b/tests/test_morse.py index 52b3443..5dfc0eb 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -28,12 +28,13 @@ from utils.morse import ( # Helpers # --------------------------------------------------------------------------- + def _login_session(client) -> None: """Mark the Flask test session as authenticated.""" with client.session_transaction() as sess: - sess['logged_in'] = True - sess['username'] = 'test' - sess['role'] = 'admin' + sess["logged_in"] = True + sess["username"] = "test" + sess["role"] = "admin" def generate_tone(freq: float, duration: float, sample_rate: int = 8000, amplitude: float = 0.8) -> bytes: @@ -44,13 +45,13 @@ def generate_tone(freq: float, duration: float, sample_rate: int = 8000, amplitu t = i / sample_rate val = int(amplitude * 32767 * math.sin(2 * math.pi * freq * t)) samples.append(max(-32768, min(32767, val))) - return struct.pack(f'<{len(samples)}h', *samples) + return struct.pack(f"<{len(samples)}h", *samples) def generate_silence(duration: float, sample_rate: int = 8000) -> bytes: """Generate silence as 16-bit LE PCM bytes.""" n_samples = int(sample_rate * duration) - return b'\x00\x00' * n_samples + return b"\x00\x00" * n_samples def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes: @@ -61,7 +62,7 @@ def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sam char_gap = 3 * dit_dur word_gap = 7 * dit_dur - audio = b'' + audio = b"" words = text.upper().split() for wi, word in enumerate(words): for ci, char in enumerate(word): @@ -70,9 +71,9 @@ def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sam continue for ei, element in enumerate(morse): - if element == '.': + if element == ".": audio += generate_tone(tone_freq, dit_dur, sample_rate) - elif element == '-': + elif element == "-": audio += generate_tone(tone_freq, dah_dur, sample_rate) if ei < len(morse) - 1: @@ -90,7 +91,7 @@ def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sam def write_wav(path, pcm_bytes: bytes, sample_rate: int = 8000) -> None: """Write mono 16-bit PCM bytes to a WAV file.""" - with wave.open(str(path), 'wb') as wf: + with wave.open(str(path), "wb") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(sample_rate) @@ -100,21 +101,22 @@ def write_wav(path, pcm_bytes: bytes, sample_rate: int = 8000) -> None: def decode_text_from_events(events) -> str: out = [] for ev in events: - if ev.get('type') == 'morse_char': - out.append(str(ev.get('char', ''))) - elif ev.get('type') == 'morse_space': - out.append(' ') - return ''.join(out) + if ev.get("type") == "morse_char": + out.append(str(ev.get("char", ""))) + elif ev.get("type") == "morse_space": + out.append(" ") + return "".join(out) # --------------------------------------------------------------------------- # Unit tests # --------------------------------------------------------------------------- + class TestMorseTable: def test_morse_table_contains_letters_and_digits(self): chars = set(MORSE_TABLE.values()) - for ch in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789': + for ch in "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789": assert ch in chars def test_round_trip_morse_lookup(self): @@ -152,6 +154,7 @@ class TestEnvelopeDetector: def test_magnitude_with_numpy_array(self): import numpy as np + det = EnvelopeDetector(block_size=100) arr = np.ones(100, dtype=np.float64) * 0.6 assert abs(det.magnitude(arr) - 0.6) < 0.01 @@ -170,52 +173,46 @@ class TestEnvelopeMorseDecoder: def ook_on(duration): n = int(sample_rate * duration) - return struct.pack(f'<{n}h', *([int(0.7 * 32767)] * n)) + return struct.pack(f"<{n}h", *([int(0.7 * 32767)] * n)) def ook_off(duration): n = int(sample_rate * duration) - return b'\x00\x00' * n + return b"\x00\x00" * n # Generate dit-dah (A = .-) - audio = ( - ook_off(0.3) - + ook_on(dit_dur) - + ook_off(dit_dur) - + ook_on(3 * dit_dur) - + ook_off(0.5) - ) + audio = ook_off(0.3) + ook_on(dit_dur) + ook_off(dit_dur) + ook_on(3 * dit_dur) + ook_off(0.5) decoder = MorseDecoder( sample_rate=sample_rate, tone_freq=700.0, wpm=wpm, - detect_mode='envelope', + detect_mode="envelope", ) events = decoder.process_block(audio) events.extend(decoder.flush()) - elements = [e['element'] for e in events if e.get('type') == 'morse_element'] + elements = [e["element"] for e in events if e.get("type") == "morse_element"] - assert '.' in elements - assert '-' in elements + assert "." in elements + assert "-" in elements def test_envelope_metrics_have_zero_snr(self): """Envelope mode metrics should report zero SNR fields.""" decoder = MorseDecoder( sample_rate=8000, - detect_mode='envelope', + detect_mode="envelope", ) metrics = decoder.get_metrics() - assert metrics['detect_mode'] == 'envelope' - assert metrics['snr'] == 0.0 - assert metrics['noise_ref'] == 0.0 + assert metrics["detect_mode"] == "envelope" + assert metrics["snr"] == 0.0 + assert metrics["noise_ref"] == 0.0 def test_goertzel_mode_unchanged(self): """Default goertzel mode still works as before.""" decoder = MorseDecoder(sample_rate=8000, wpm=15) - assert decoder.detect_mode == 'goertzel' + assert decoder.detect_mode == "goertzel" metrics = decoder.get_metrics() - assert 'detect_mode' in metrics - assert metrics['detect_mode'] == 'goertzel' + assert "detect_mode" in metrics + assert metrics["detect_mode"] == "goertzel" class TestDropoutTolerance: @@ -252,10 +249,10 @@ class TestDropoutTolerance: events = decoder.process_block(audio) events.extend(decoder.flush()) - elements = [e['element'] for e in events if e.get('type') == 'morse_element'] + elements = [e["element"] for e in events if e.get("type") == "morse_element"] # Should produce a single dah, not two dits. - assert elements == ['-'], f'Expected single dah but got {elements}' + assert elements == ["-"], f"Expected single dah but got {elements}" class TestTimingAndWpmEstimator: @@ -274,35 +271,36 @@ class TestTimingAndWpmEstimator: events = decoder.process_block(audio) events.extend(decoder.flush()) - elements = [e['element'] for e in events if e.get('type') == 'morse_element'] + elements = [e["element"] for e in events if e.get("type") == "morse_element"] - assert '.' in elements - assert '-' in elements + assert "." in elements + assert "-" in elements def test_wpm_estimator_sanity(self): target_wpm = 18 - audio = generate_morse_audio('PARIS PARIS PARIS', wpm=target_wpm) - decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=12, wpm_mode='auto') + audio = generate_morse_audio("PARIS PARIS PARIS", wpm=target_wpm) + decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=12, wpm_mode="auto") events = decoder.process_block(audio) events.extend(decoder.flush()) metrics = decoder.get_metrics() - assert metrics['wpm'] >= 10.0 - assert metrics['wpm'] <= 35.0 + assert metrics["wpm"] >= 10.0 + assert metrics["wpm"] <= 35.0 # --------------------------------------------------------------------------- # Decoder thread tests # --------------------------------------------------------------------------- + class TestMorseDecoderThread: def test_thread_emits_waiting_heartbeat_on_no_data(self): stop_event = threading.Event() output_queue = queue.Queue(maxsize=64) read_fd, write_fd = os.pipe() - read_file = os.fdopen(read_fd, 'rb', 0) + read_file = os.fdopen(read_fd, "rb", 0) worker = threading.Thread( target=morse_decoder_thread, @@ -318,7 +316,7 @@ class TestMorseDecoderThread: msg = output_queue.get(timeout=0.3) except queue.Empty: continue - if msg.get('type') == 'scope' and msg.get('waiting'): + if msg.get("type") == "scope" and msg.get("waiting"): got_waiting = True break @@ -333,7 +331,7 @@ class TestMorseDecoderThread: def test_thread_produces_character_events(self): stop_event = threading.Event() output_queue = queue.Queue(maxsize=512) - audio = generate_morse_audio('SOS', wpm=15) + audio = generate_morse_audio("SOS", wpm=15) worker = threading.Thread( target=morse_decoder_thread, @@ -347,7 +345,7 @@ class TestMorseDecoderThread: while not output_queue.empty(): events.append(output_queue.get_nowait()) - chars = [e for e in events if e.get('type') == 'morse_char'] + chars = [e for e in events if e.get("type") == "morse_char"] assert len(chars) >= 1 @@ -355,6 +353,7 @@ class TestMorseDecoderThread: # Route lifecycle regression # --------------------------------------------------------------------------- + class TestMorseLifecycleRoutes: def _reset_route_state(self): with app_module.morse_lock: @@ -372,9 +371,9 @@ class TestMorseLifecycleRoutes: morse_routes.morse_stop_event = None morse_routes.morse_control_queue = None morse_routes.morse_runtime_config = {} - morse_routes.morse_last_error = '' + morse_routes.morse_last_error = "" morse_routes.morse_state = morse_routes.MORSE_IDLE - morse_routes.morse_state_message = 'Idle' + morse_routes.morse_state_message = "Idle" def test_start_stop_reaches_idle_and_releases_resources(self, client, monkeypatch): _login_session(client) @@ -382,26 +381,30 @@ class TestMorseLifecycleRoutes: released_devices = [] - monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None) - monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx)) + monkeypatch.setattr(app_module, "claim_sdr_device", lambda idx, mode, sdr_type="rtlsdr": None) + monkeypatch.setattr( + app_module, "release_sdr_device", lambda idx, sdr_type="rtlsdr": released_devices.append(idx) + ) class DummyDevice: sdr_type = morse_routes.SDRType.RTL_SDR class DummyBuilder: def build_fm_demod_command(self, **kwargs): - return ['rtl_fm', '-f', '14060000', '-'] + return ["rtl_fm", "-f", "14060000", "-"] - monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice())) - monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) - monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: [])) + monkeypatch.setattr( + morse_routes.SDRFactory, "create_default_device", staticmethod(lambda sdr_type, index: DummyDevice()) + ) + monkeypatch.setattr(morse_routes.SDRFactory, "get_builder", staticmethod(lambda sdr_type: DummyBuilder())) + monkeypatch.setattr(morse_routes.SDRFactory, "detect_devices", staticmethod(lambda: [])) - pcm = generate_morse_audio('E', wpm=15, sample_rate=22050) + pcm = generate_morse_audio("E", wpm=15, sample_rate=22050) class FakeRtlProc: def __init__(self, payload: bytes): self.stdout = io.BytesIO(payload) - self.stderr = io.BytesIO(b'') + self.stderr = io.BytesIO(b"") self.returncode = None def poll(self): @@ -420,40 +423,43 @@ class TestMorseLifecycleRoutes: def fake_popen(cmd, *args, **kwargs): return FakeRtlProc(pcm) - monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen) - monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None) - monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None) + monkeypatch.setattr(morse_routes.subprocess, "Popen", fake_popen) + monkeypatch.setattr(morse_routes, "register_process", lambda _proc: None) + monkeypatch.setattr(morse_routes, "unregister_process", lambda _proc: None) monkeypatch.setattr( morse_routes, - 'safe_terminate', - lambda proc, timeout=0.0: setattr(proc, 'returncode', 0), + "safe_terminate", + lambda proc, timeout=0.0: setattr(proc, "returncode", 0), ) - start_resp = client.post('/morse/start', json={ - 'frequency': '14.060', - 'gain': '20', - 'ppm': '0', - 'device': '0', - 'tone_freq': '700', - 'wpm': '15', - }) + start_resp = client.post( + "/morse/start", + json={ + "frequency": "14.060", + "gain": "20", + "ppm": "0", + "device": "0", + "tone_freq": "700", + "wpm": "15", + }, + ) assert start_resp.status_code == 200 - assert start_resp.get_json()['status'] == 'started' + assert start_resp.get_json()["status"] == "started" - status_resp = client.get('/morse/status') + status_resp = client.get("/morse/status") assert status_resp.status_code == 200 - assert status_resp.get_json()['state'] in {'running', 'starting', 'stopping', 'idle'} + assert status_resp.get_json()["state"] in {"running", "starting", "stopping", "idle"} - stop_resp = client.post('/morse/stop') + stop_resp = client.post("/morse/stop") assert stop_resp.status_code == 200 stop_data = stop_resp.get_json() - assert stop_data['status'] == 'stopped' - assert stop_data['state'] == 'idle' - assert stop_data['alive'] == [] + assert stop_data["status"] == "stopped" + assert stop_data["state"] == "idle" + assert stop_data["alive"] == [] - final_status = client.get('/morse/status').get_json() - assert final_status['running'] is False - assert final_status['state'] == 'idle' + final_status = client.get("/morse/status").get_json() + assert final_status["running"] is False + assert final_status["state"] == "idle" assert 0 in released_devices def test_start_retries_after_early_process_exit(self, client, monkeypatch): @@ -462,31 +468,35 @@ class TestMorseLifecycleRoutes: released_devices = [] - monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None) - monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx)) + monkeypatch.setattr(app_module, "claim_sdr_device", lambda idx, mode, sdr_type="rtlsdr": None) + monkeypatch.setattr( + app_module, "release_sdr_device", lambda idx, sdr_type="rtlsdr": released_devices.append(idx) + ) class DummyDevice: sdr_type = morse_routes.SDRType.RTL_SDR class DummyBuilder: def build_fm_demod_command(self, **kwargs): - cmd = ['rtl_fm', '-f', '14.060M', '-M', 'usb', '-s', '22050'] - if kwargs.get('direct_sampling') is not None: - cmd.extend(['--direct', str(kwargs['direct_sampling'])]) - cmd.append('-') + cmd = ["rtl_fm", "-f", "14.060M", "-M", "usb", "-s", "22050"] + if kwargs.get("direct_sampling") is not None: + cmd.extend(["--direct", str(kwargs["direct_sampling"])]) + cmd.append("-") return cmd - monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice())) - monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) - monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: [])) + monkeypatch.setattr( + morse_routes.SDRFactory, "create_default_device", staticmethod(lambda sdr_type, index: DummyDevice()) + ) + monkeypatch.setattr(morse_routes.SDRFactory, "get_builder", staticmethod(lambda sdr_type: DummyBuilder())) + monkeypatch.setattr(morse_routes.SDRFactory, "detect_devices", staticmethod(lambda: [])) - pcm = generate_morse_audio('E', wpm=15, sample_rate=22050) + pcm = generate_morse_audio("E", wpm=15, sample_rate=22050) rtl_cmds = [] class FakeRtlProc: def __init__(self, stdout_bytes: bytes, returncode: int | None): self.stdout = io.BytesIO(stdout_bytes) - self.stderr = io.BytesIO(b'') + self.stderr = io.BytesIO(b"") self.returncode = returncode def poll(self): @@ -505,39 +515,42 @@ class TestMorseLifecycleRoutes: def fake_popen(cmd, *args, **kwargs): rtl_cmds.append(cmd) if len(rtl_cmds) == 1: - return FakeRtlProc(b'', 1) + return FakeRtlProc(b"", 1) return FakeRtlProc(pcm, None) - monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen) - monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None) - monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None) + monkeypatch.setattr(morse_routes.subprocess, "Popen", fake_popen) + monkeypatch.setattr(morse_routes, "register_process", lambda _proc: None) + monkeypatch.setattr(morse_routes, "unregister_process", lambda _proc: None) monkeypatch.setattr( morse_routes, - 'safe_terminate', - lambda proc, timeout=0.0: setattr(proc, 'returncode', 0), + "safe_terminate", + lambda proc, timeout=0.0: setattr(proc, "returncode", 0), ) - start_resp = client.post('/morse/start', json={ - 'frequency': '14.060', - 'gain': '20', - 'ppm': '0', - 'device': '0', - 'tone_freq': '700', - 'wpm': '15', - }) + start_resp = client.post( + "/morse/start", + json={ + "frequency": "14.060", + "gain": "20", + "ppm": "0", + "device": "0", + "tone_freq": "700", + "wpm": "15", + }, + ) assert start_resp.status_code == 200 - assert start_resp.get_json()['status'] == 'started' + assert start_resp.get_json()["status"] == "started" assert len(rtl_cmds) >= 2 - assert rtl_cmds[0][0] == 'rtl_fm' - assert '--direct' in rtl_cmds[0] - assert '2' in rtl_cmds[0] - assert rtl_cmds[1][0] == 'rtl_fm' - assert '--direct' in rtl_cmds[1] - assert '1' in rtl_cmds[1] + assert rtl_cmds[0][0] == "rtl_fm" + assert "--direct" in rtl_cmds[0] + assert "2" in rtl_cmds[0] + assert rtl_cmds[1][0] == "rtl_fm" + assert "--direct" in rtl_cmds[1] + assert "1" in rtl_cmds[1] - stop_resp = client.post('/morse/stop') + stop_resp = client.post("/morse/stop") assert stop_resp.status_code == 200 - assert stop_resp.get_json()['status'] == 'stopped' + assert stop_resp.get_json()["status"] == "stopped" assert 0 in released_devices def test_start_falls_back_to_next_device_when_selected_device_has_no_pcm(self, client, monkeypatch): @@ -546,8 +559,10 @@ class TestMorseLifecycleRoutes: released_devices = [] - monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None) - monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx)) + monkeypatch.setattr(app_module, "claim_sdr_device", lambda idx, mode, sdr_type="rtlsdr": None) + monkeypatch.setattr( + app_module, "release_sdr_device", lambda idx, sdr_type="rtlsdr": released_devices.append(idx) + ) class DummyDevice: def __init__(self, index: int): @@ -558,35 +573,35 @@ class TestMorseLifecycleRoutes: def __init__(self, index: int, serial: str): self.sdr_type = morse_routes.SDRType.RTL_SDR self.index = index - self.name = f'RTL {index}' + self.name = f"RTL {index}" self.serial = serial class DummyBuilder: def build_fm_demod_command(self, **kwargs): - cmd = ['rtl_fm', '-d', str(kwargs['device'].index), '-f', '14.060M', '-M', 'usb', '-s', '22050'] - if kwargs.get('direct_sampling') is not None: - cmd.extend(['--direct', str(kwargs['direct_sampling'])]) - cmd.append('-') + cmd = ["rtl_fm", "-d", str(kwargs["device"].index), "-f", "14.060M", "-M", "usb", "-s", "22050"] + if kwargs.get("direct_sampling") is not None: + cmd.extend(["--direct", str(kwargs["direct_sampling"])]) + cmd.append("-") return cmd monkeypatch.setattr( morse_routes.SDRFactory, - 'create_default_device', + "create_default_device", staticmethod(lambda sdr_type, index: DummyDevice(int(index))), ) - monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder())) + monkeypatch.setattr(morse_routes.SDRFactory, "get_builder", staticmethod(lambda sdr_type: DummyBuilder())) monkeypatch.setattr( morse_routes.SDRFactory, - 'detect_devices', - staticmethod(lambda: [DummyDetected(0, 'AAA00000'), DummyDetected(1, 'BBB11111')]), + "detect_devices", + staticmethod(lambda: [DummyDetected(0, "AAA00000"), DummyDetected(1, "BBB11111")]), ) - pcm = generate_morse_audio('E', wpm=15, sample_rate=22050) + pcm = generate_morse_audio("E", wpm=15, sample_rate=22050) class FakeRtlProc: def __init__(self, stdout_bytes: bytes, returncode: int | None): self.stdout = io.BytesIO(stdout_bytes) - self.stderr = io.BytesIO(b'') + self.stderr = io.BytesIO(b"") self.returncode = returncode def poll(self): @@ -604,40 +619,43 @@ class TestMorseLifecycleRoutes: def fake_popen(cmd, *args, **kwargs): try: - dev = int(cmd[cmd.index('-d') + 1]) + dev = int(cmd[cmd.index("-d") + 1]) except Exception: dev = 0 if dev == 0: - return FakeRtlProc(b'', 1) + return FakeRtlProc(b"", 1) return FakeRtlProc(pcm, None) - monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen) - monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None) - monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None) + monkeypatch.setattr(morse_routes.subprocess, "Popen", fake_popen) + monkeypatch.setattr(morse_routes, "register_process", lambda _proc: None) + monkeypatch.setattr(morse_routes, "unregister_process", lambda _proc: None) monkeypatch.setattr( morse_routes, - 'safe_terminate', - lambda proc, timeout=0.0: setattr(proc, 'returncode', 0), + "safe_terminate", + lambda proc, timeout=0.0: setattr(proc, "returncode", 0), ) - start_resp = client.post('/morse/start', json={ - 'frequency': '14.060', - 'gain': '20', - 'ppm': '0', - 'device': '0', - 'tone_freq': '700', - 'wpm': '15', - }) + start_resp = client.post( + "/morse/start", + json={ + "frequency": "14.060", + "gain": "20", + "ppm": "0", + "device": "0", + "tone_freq": "700", + "wpm": "15", + }, + ) assert start_resp.status_code == 200 start_data = start_resp.get_json() - assert start_data['status'] == 'started' - assert start_data['config']['active_device'] == 1 - assert start_data['config']['device_serial'] == 'BBB11111' + assert start_data["status"] == "started" + assert start_data["config"]["active_device"] == 1 + assert start_data["config"]["device_serial"] == "BBB11111" assert 0 in released_devices - stop_resp = client.post('/morse/stop') + stop_resp = client.post("/morse/stop") assert stop_resp.status_code == 200 - assert stop_resp.get_json()['status'] == 'stopped' + assert stop_resp.get_json()["status"] == "stopped" assert 1 in released_devices @@ -645,10 +663,11 @@ class TestMorseLifecycleRoutes: # Integration: synthetic CW -> WAV decode # --------------------------------------------------------------------------- + class TestMorseIntegration: def test_decode_morse_wav_contains_expected_phrase(self, tmp_path): - wav_path = tmp_path / 'cq_test_123.wav' - pcm = generate_morse_audio('CQ TEST 123', wpm=15, tone_freq=700.0) + wav_path = tmp_path / "cq_test_123.wav" + pcm = generate_morse_audio("CQ TEST 123", wpm=15, tone_freq=700.0) write_wav(wav_path, pcm, sample_rate=8000) result = decode_morse_wav_file( @@ -658,14 +677,14 @@ class TestMorseIntegration: wpm=15, bandwidth_hz=200, auto_tone_track=True, - threshold_mode='auto', - wpm_mode='auto', + threshold_mode="auto", + wpm_mode="auto", min_signal_gate=0.0, ) - decoded = ' '.join(str(result.get('text', '')).split()) - assert 'CQ TEST 123' in decoded + decoded = " ".join(str(result.get("text", "")).split()) + assert "CQ TEST 123" in decoded - events = result.get('events', []) - event_counts = Counter(e.get('type') for e in events) - assert event_counts['morse_char'] >= len('CQTEST123') + events = result.get("events", []) + event_counts = Counter(e.get("type") for e in events) + assert event_counts["morse_char"] >= len("CQTEST123") diff --git a/tests/test_ook.py b/tests/test_ook.py index a17fdad..2e7cfdd 100644 --- a/tests/test_ook.py +++ b/tests/test_ook.py @@ -16,76 +16,79 @@ from utils.ook import decode_ook_frame, ook_parser_thread # Helpers # --------------------------------------------------------------------------- + def _login_session(client) -> None: """Mark the Flask test session as authenticated.""" with client.session_transaction() as sess: - sess['logged_in'] = True - sess['username'] = 'test' - sess['role'] = 'admin' + sess["logged_in"] = True + sess["username"] = "test" + sess["role"] = "admin" # --------------------------------------------------------------------------- # decode_ook_frame # --------------------------------------------------------------------------- + class TestDecodeOokFrame: def test_valid_hex_returns_bits_and_hex(self): - result = decode_ook_frame('aa55') + result = decode_ook_frame("aa55") assert result is not None - assert result['hex'] == 'aa55' - assert result['bits'] == '1010101001010101' - assert result['byte_count'] == 2 - assert result['bit_count'] == 16 + assert result["hex"] == "aa55" + assert result["bits"] == "1010101001010101" + assert result["byte_count"] == 2 + assert result["bit_count"] == 16 def test_strips_0x_prefix(self): - result = decode_ook_frame('0xaa55') + result = decode_ook_frame("0xaa55") assert result is not None - assert result['hex'] == 'aa55' + assert result["hex"] == "aa55" def test_strips_0X_uppercase_prefix(self): - result = decode_ook_frame('0Xff') + result = decode_ook_frame("0Xff") assert result is not None - assert result['hex'] == 'ff' - assert result['bits'] == '11111111' + assert result["hex"] == "ff" + assert result["bits"] == "11111111" def test_strips_spaces(self): - result = decode_ook_frame('aa 55') + result = decode_ook_frame("aa 55") assert result is not None - assert result['hex'] == 'aa55' + assert result["hex"] == "aa55" def test_invalid_hex_returns_none(self): - assert decode_ook_frame('zzzz') is None + assert decode_ook_frame("zzzz") is None def test_empty_string_returns_none(self): - assert decode_ook_frame('') is None + assert decode_ook_frame("") is None def test_just_0x_prefix_returns_none(self): - assert decode_ook_frame('0x') is None + assert decode_ook_frame("0x") is None def test_single_byte(self): - result = decode_ook_frame('48') + result = decode_ook_frame("48") assert result is not None - assert result['bits'] == '01001000' - assert result['byte_count'] == 1 + assert result["bits"] == "01001000" + assert result["byte_count"] == 1 def test_hello_ascii(self): """'Hello' in hex is 48656c6c6f.""" - result = decode_ook_frame('48656c6c6f') + result = decode_ook_frame("48656c6c6f") assert result is not None - assert result['hex'] == '48656c6c6f' - assert result['byte_count'] == 5 - assert result['bit_count'] == 40 + assert result["hex"] == "48656c6c6f" + assert result["byte_count"] == 5 + assert result["bit_count"] == 40 # --------------------------------------------------------------------------- # ook_parser_thread # --------------------------------------------------------------------------- + class TestOokParserThread: - def _run_parser(self, json_lines, encoding='pwm', deduplicate=False): + def _run_parser(self, json_lines, encoding="pwm", deduplicate=False): """Feed JSON lines to parser thread and collect output events.""" - raw = '\n'.join(json.dumps(line) for line in json_lines) + '\n' - stdout = io.BytesIO(raw.encode('utf-8')) + raw = "\n".join(json.dumps(line) for line in json_lines) + "\n" + stdout = io.BytesIO(raw.encode("utf-8")) output_queue = queue.Queue() stop_event = threading.Event() @@ -102,75 +105,75 @@ class TestOokParserThread: return events def test_parses_codes_field_list(self): - events = self._run_parser([{'codes': ['aa55']}]) - frames = [e for e in events if e.get('type') == 'ook_frame'] + events = self._run_parser([{"codes": ["aa55"]}]) + frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 - assert frames[0]['hex'] == 'aa55' - assert frames[0]['bits'] == '1010101001010101' - assert frames[0]['inverted'] is False + assert frames[0]["hex"] == "aa55" + assert frames[0]["bits"] == "1010101001010101" + assert frames[0]["inverted"] is False def test_parses_codes_field_string(self): - events = self._run_parser([{'codes': 'ff00'}]) - frames = [e for e in events if e.get('type') == 'ook_frame'] + events = self._run_parser([{"codes": "ff00"}]) + frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 - assert frames[0]['hex'] == 'ff00' + assert frames[0]["hex"] == "ff00" def test_parses_code_field(self): - events = self._run_parser([{'code': 'abcd'}]) - frames = [e for e in events if e.get('type') == 'ook_frame'] + events = self._run_parser([{"code": "abcd"}]) + frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 - assert frames[0]['hex'] == 'abcd' + assert frames[0]["hex"] == "abcd" def test_parses_data_field(self): - events = self._run_parser([{'data': '1234'}]) - frames = [e for e in events if e.get('type') == 'ook_frame'] + events = self._run_parser([{"data": "1234"}]) + frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 - assert frames[0]['hex'] == '1234' + assert frames[0]["hex"] == "1234" def test_strips_brace_bit_count_prefix(self): """rtl_433 sometimes prefixes with {N} bit count.""" - events = self._run_parser([{'codes': ['{16}aa55']}]) - frames = [e for e in events if e.get('type') == 'ook_frame'] + events = self._run_parser([{"codes": ["{16}aa55"]}]) + frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 - assert frames[0]['hex'] == 'aa55' + assert frames[0]["hex"] == "aa55" def test_deduplication_suppresses_consecutive_identical(self): events = self._run_parser( - [{'codes': ['aa55']}, {'codes': ['aa55']}, {'codes': ['aa55']}], + [{"codes": ["aa55"]}, {"codes": ["aa55"]}, {"codes": ["aa55"]}], deduplicate=True, ) - frames = [e for e in events if e.get('type') == 'ook_frame'] + frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 def test_deduplication_allows_different_frames(self): events = self._run_parser( - [{'codes': ['aa55']}, {'codes': ['ff00']}, {'codes': ['aa55']}], + [{"codes": ["aa55"]}, {"codes": ["ff00"]}, {"codes": ["aa55"]}], deduplicate=True, ) - frames = [e for e in events if e.get('type') == 'ook_frame'] + frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 3 def test_no_code_field_emits_ook_raw(self): - events = self._run_parser([{'model': 'unknown', 'id': 42}]) - raw_events = [e for e in events if e.get('type') == 'ook_raw'] + events = self._run_parser([{"model": "unknown", "id": 42}]) + raw_events = [e for e in events if e.get("type") == "ook_raw"] assert len(raw_events) == 1 def test_rssi_extracted_from_snr(self): - events = self._run_parser([{'codes': ['aa55'], 'snr': 12.3}]) - frames = [e for e in events if e.get('type') == 'ook_frame'] + events = self._run_parser([{"codes": ["aa55"], "snr": 12.3}]) + frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 - assert frames[0]['rssi'] == 12.3 + assert frames[0]["rssi"] == 12.3 def test_encoding_passed_through(self): - events = self._run_parser([{'codes': ['aa55']}], encoding='manchester') - frames = [e for e in events if e.get('type') == 'ook_frame'] - assert frames[0]['encoding'] == 'manchester' + events = self._run_parser([{"codes": ["aa55"]}], encoding="manchester") + frames = [e for e in events if e.get("type") == "ook_frame"] + assert frames[0]["encoding"] == "manchester" def test_timestamp_present(self): - events = self._run_parser([{'codes': ['aa55']}]) - frames = [e for e in events if e.get('type') == 'ook_frame'] - assert 'timestamp' in frames[0] - assert len(frames[0]['timestamp']) > 0 + events = self._run_parser([{"codes": ["aa55"]}]) + frames = [e for e in events if e.get("type") == "ook_frame"] + assert "timestamp" in frames[0] + assert len(frames[0]["timestamp"]) > 0 def test_invalid_json_skipped(self): """Non-JSON lines should be silently skipped.""" @@ -189,7 +192,7 @@ class TestOokParserThread: events = [] while not output_queue.empty(): events.append(output_queue.get_nowait()) - frames = [e for e in events if e.get('type') == 'ook_frame'] + frames = [e for e in events if e.get("type") == "ook_frame"] assert len(frames) == 1 @@ -197,73 +200,62 @@ class TestOokParserThread: # Route handlers # --------------------------------------------------------------------------- + class TestOokRoutes: @pytest.fixture def client(self): import app as app_module from routes import register_blueprints - app_module.app.config['TESTING'] = True - if 'ook' not in app_module.app.blueprints: + app_module.app.config["TESTING"] = True + if "ook" not in app_module.app.blueprints: register_blueprints(app_module.app) with app_module.app.test_client() as c: yield c def test_status_returns_not_running(self, client): _login_session(client) - resp = client.get('/ook/status') + resp = client.get("/ook/status") assert resp.status_code == 200 data = resp.get_json() - assert data['running'] is False + assert data["running"] is False def test_stop_when_not_running(self, client): _login_session(client) - resp = client.post('/ook/stop') + resp = client.post("/ook/stop") assert resp.status_code == 200 data = resp.get_json() - assert data['status'] == 'not_running' + assert data["status"] == "not_running" def test_start_validates_frequency(self, client): _login_session(client) - resp = client.post('/ook/start', - json={'frequency': 'invalid'}, - content_type='application/json') + resp = client.post("/ook/start", json={"frequency": "invalid"}, content_type="application/json") assert resp.status_code == 400 def test_start_validates_encoding(self, client): _login_session(client) - resp = client.post('/ook/start', - json={'encoding': 'invalid_enc'}, - content_type='application/json') + resp = client.post("/ook/start", json={"encoding": "invalid_enc"}, content_type="application/json") assert resp.status_code == 400 def test_start_validates_timing_params(self, client): _login_session(client) - resp = client.post('/ook/start', - json={'short_pulse': 'not_a_number'}, - content_type='application/json') + resp = client.post("/ook/start", json={"short_pulse": "not_a_number"}, content_type="application/json") assert resp.status_code == 400 def test_start_rejects_negative_frequency(self, client): _login_session(client) - resp = client.post('/ook/start', - json={'frequency': '-5'}, - content_type='application/json') + resp = client.post("/ook/start", json={"frequency": "-5"}, content_type="application/json") assert resp.status_code == 400 def test_start_rejects_out_of_range_timing(self, client): """Timing params that exceed server-side max should be rejected.""" _login_session(client) - resp = client.post('/ook/start', - json={'short_pulse': 999999}, - content_type='application/json') + resp = client.post("/ook/start", json={"short_pulse": 999999}, content_type="application/json") assert resp.status_code == 400 def test_start_rejects_negative_timing(self, client): _login_session(client) - resp = client.post('/ook/start', - json={'min_bits': -1}, - content_type='application/json') + resp = client.post("/ook/start", json={"min_bits": -1}, content_type="application/json") assert resp.status_code == 400 def test_start_success_mocked(self, client, monkeypatch): @@ -276,21 +268,19 @@ class TestOokRoutes: mock_proc = unittest.mock.MagicMock() mock_proc.poll.return_value = None - mock_proc.stdout = io.BytesIO(b'') - mock_proc.stderr = io.BytesIO(b'') + mock_proc.stdout = io.BytesIO(b"") + mock_proc.stderr = io.BytesIO(b"") mock_proc.pid = 12345 - monkeypatch.setattr(subprocess, 'Popen', lambda *a, **kw: mock_proc) - monkeypatch.setattr(app_module, 'claim_sdr_device', lambda *a, **kw: None) - monkeypatch.setattr(app_module, 'release_sdr_device', lambda *a, **kw: None) + monkeypatch.setattr(subprocess, "Popen", lambda *a, **kw: mock_proc) + monkeypatch.setattr(app_module, "claim_sdr_device", lambda *a, **kw: None) + monkeypatch.setattr(app_module, "release_sdr_device", lambda *a, **kw: None) - resp = client.post('/ook/start', - json={'frequency': '433.920'}, - content_type='application/json') + resp = client.post("/ook/start", json={"frequency": "433.920"}, content_type="application/json") data = resp.get_json() assert resp.status_code == 200 - assert data['status'] == 'started' - assert 'command' in data + assert data["status"] == "started" + assert "command" in data # Cleanup with app_module.ook_lock: @@ -310,39 +300,41 @@ class TestOokRoutes: # Inject a fake running process import routes.ook as ook_module + app_module.ook_process = mock_proc ook_module._ook_stop_event = threading.Event() ook_module._ook_parser_thread = None ook_module.ook_active_device = 0 - ook_module.ook_active_sdr_type = 'rtlsdr' + ook_module.ook_active_sdr_type = "rtlsdr" - monkeypatch.setattr(app_module, 'release_sdr_device', lambda *a, **kw: None) - monkeypatch.setattr('utils.process.safe_terminate', lambda p: None) - monkeypatch.setattr('utils.process.unregister_process', lambda p: None) + monkeypatch.setattr(app_module, "release_sdr_device", lambda *a, **kw: None) + monkeypatch.setattr("utils.process.safe_terminate", lambda p: None) + monkeypatch.setattr("utils.process.unregister_process", lambda p: None) - resp = client.post('/ook/stop') + resp = client.post("/ook/stop") data = resp.get_json() assert resp.status_code == 200 - assert data['status'] == 'stopped' + assert data["status"] == "stopped" assert app_module.ook_process is None assert ook_module.ook_active_device is None def test_stream_endpoint(self, client): """SSE stream endpoint should return text/event-stream.""" _login_session(client) - resp = client.get('/ook/stream') - assert resp.content_type.startswith('text/event-stream') - assert resp.headers.get('Cache-Control') == 'no-cache' + resp = client.get("/ook/stream") + assert resp.content_type.startswith("text/event-stream") + assert resp.headers.get("Cache-Control") == "no-cache" # --------------------------------------------------------------------------- # Parser thread — stopped status on exit # --------------------------------------------------------------------------- + class TestOokParserStoppedEvent: def test_emits_stopped_on_normal_exit(self): """Parser thread should emit a status: stopped event when stream ends.""" - stdout = io.BytesIO(b'') + stdout = io.BytesIO(b"") output_queue = queue.Queue() stop_event = threading.Event() @@ -356,6 +348,6 @@ class TestOokParserStoppedEvent: events = [] while not output_queue.empty(): events.append(output_queue.get_nowait()) - status_events = [e for e in events if e.get('type') == 'status'] + status_events = [e for e in events if e.get("type") == "status"] assert len(status_events) == 1 - assert status_events[0]['text'] == 'stopped' + assert status_events[0]["text"] == "stopped" diff --git a/tests/test_requirements.py b/tests/test_requirements.py index d709231..2eb5b8d 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -9,10 +9,12 @@ import tomllib def get_root_path(): return Path(__file__).parent.parent + def _clean_string(req): """Normalizes a requirement string (lowercase and removes spaces).""" return req.strip().lower().replace(" ", "") + def parse_txt_requirements(file_path): """Extracts full requirement strings (name + version) from a .txt file.""" if not file_path.exists(): @@ -26,6 +28,7 @@ def parse_txt_requirements(file_path): packages.add(_clean_string(line)) return packages + def parse_toml_section(data, section_type="main"): """Extracts full requirement strings from pyproject.toml including optional sections.""" packages = set() @@ -44,6 +47,7 @@ def parse_toml_section(data, section_type="main"): packages.add(_clean_string(req)) return packages + def test_dependency_files_integrity(): """1. Verifies that .txt files and pyproject.toml have identical names AND versions.""" root = get_root_path() @@ -58,20 +62,17 @@ def test_dependency_files_integrity(): toml_main = parse_toml_section(toml_data, "main") | parse_toml_section(toml_data, "optional") assert txt_main == toml_main, ( - f"Production version mismatch!\n" - f"Only in TXT: {txt_main - toml_main}\n" - f"Only in TOML: {toml_main - txt_main}" + f"Production version mismatch!\nOnly in TXT: {txt_main - toml_main}\nOnly in TOML: {toml_main - txt_main}" ) # Validate Development Sync txt_dev = parse_txt_requirements(root / "requirements-dev.txt") toml_dev = parse_toml_section(toml_data, "dev") assert txt_dev == toml_dev, ( - f"Development version mismatch!\n" - f"Only in TXT: {txt_dev - toml_dev}\n" - f"Only in TOML: {toml_dev - txt_dev}" + f"Development version mismatch!\nOnly in TXT: {txt_dev - toml_dev}\nOnly in TOML: {toml_dev - txt_dev}" ) + def test_environment_vs_toml(): """2. Verifies that installed packages satisfy TOML requirements.""" root = get_root_path() @@ -79,32 +80,31 @@ def test_environment_vs_toml(): data = tomllib.load(f) all_declared = ( - parse_toml_section(data, "main") | - parse_toml_section(data, "optional") | - parse_toml_section(data, "dev") + parse_toml_section(data, "main") | parse_toml_section(data, "optional") | parse_toml_section(data, "dev") ) _verify_installation(all_declared, "TOML") + def test_environment_vs_requirements(): """3. Verifies that installed packages satisfy .txt requirements.""" root = get_root_path() - all_txt_deps = ( - parse_txt_requirements(root / "requirements.txt") | - parse_txt_requirements(root / "requirements-dev.txt") + all_txt_deps = parse_txt_requirements(root / "requirements.txt") | parse_txt_requirements( + root / "requirements-dev.txt" ) _verify_installation(all_txt_deps, "requirements.txt") + def _verify_installation(package_set, source_name): """Helper to check if declared versions match installed versions.""" missing_or_wrong = [] for req in package_set: # Split name from version - parts = re.split(r'==|>=|~=|<=|>|<', req) + parts = re.split(r"==|>=|~=|<=|>|<", req) raw_name = parts[0].strip() # CLEAN EXTRAS: "qrcode[pil]" -> "qrcode" - clean_name = re.sub(r'\[.*\]', '', raw_name) + clean_name = re.sub(r"\[.*\]", "", raw_name) try: installed_ver = importlib.metadata.version(clean_name) diff --git a/tests/test_rtl_fm_modulation.py b/tests/test_rtl_fm_modulation.py index b50a4dc..6f63de0 100644 --- a/tests/test_rtl_fm_modulation.py +++ b/tests/test_rtl_fm_modulation.py @@ -10,21 +10,21 @@ def _dummy_rtlsdr_device() -> SDRDevice: return SDRDevice( sdr_type=SDRType.RTL_SDR, index=0, - name='RTL-SDR', - serial='00000001', - driver='rtlsdr', + name="RTL-SDR", + serial="00000001", + driver="rtlsdr", capabilities=RTLSDRCommandBuilder.CAPABILITIES, ) def test_rtl_fm_modulation_maps_wfm_to_wbfm() -> None: - assert listening_post_rtl_mode('wfm') == 'wbfm' - assert builder_rtl_mode('wfm') == 'wbfm' + assert listening_post_rtl_mode("wfm") == "wbfm" + assert builder_rtl_mode("wfm") == "wbfm" def test_rtl_fm_modulation_keeps_other_modes() -> None: - assert listening_post_rtl_mode('fm') == 'fm' - assert builder_rtl_mode('am') == 'am' + assert listening_post_rtl_mode("fm") == "fm" + assert builder_rtl_mode("am") == "am" def test_rtlsdr_builder_uses_wbfm_token_for_wfm() -> None: @@ -32,8 +32,7 @@ def test_rtlsdr_builder_uses_wbfm_token_for_wfm() -> None: cmd = builder.build_fm_demod_command( device=_dummy_rtlsdr_device(), frequency_mhz=98.1, - modulation='wfm', + modulation="wfm", ) - mode_index = cmd.index('-M') - assert cmd[mode_index + 1] == 'wbfm' - + mode_index = cmd.index("-M") + assert cmd[mode_index + 1] == "wbfm" diff --git a/tests/test_sdr_detection.py b/tests/test_sdr_detection.py index 3a3f749..24a5778 100644 --- a/tests/test_sdr_detection.py +++ b/tests/test_sdr_detection.py @@ -17,18 +17,13 @@ def _clear_detection_caches(): yield -@patch('utils.sdr.detection.get_tool_path', return_value='/usr/bin/rtl_test') -@patch('utils.sdr.detection.subprocess.run') +@patch("utils.sdr.detection.get_tool_path", return_value="/usr/bin/rtl_test") +@patch("utils.sdr.detection.subprocess.run") def test_detect_rtlsdr_devices_filters_empty_serial_entries(mock_run, _mock_tool_path): """Ignore malformed rtl_test rows that have an empty SN field.""" mock_result = MagicMock() mock_result.stdout = "" - mock_result.stderr = ( - "Found 3 device(s):\n" - " 0: ??C?, , SN:\n" - " 1: ??C?, , SN:\n" - " 2: RTLSDRBlog, Blog V4, SN: 1\n" - ) + mock_result.stderr = "Found 3 device(s):\n 0: ??C?, , SN:\n 1: ??C?, , SN:\n 2: RTLSDRBlog, Blog V4, SN: 1\n" mock_run.return_value = mock_result devices = detect_rtlsdr_devices() @@ -40,8 +35,8 @@ def test_detect_rtlsdr_devices_filters_empty_serial_entries(mock_run, _mock_tool assert devices[0].serial == "1" -@patch('utils.sdr.detection.get_tool_path', return_value='/usr/bin/rtl_test') -@patch('utils.sdr.detection.subprocess.run') +@patch("utils.sdr.detection.get_tool_path", return_value="/usr/bin/rtl_test") +@patch("utils.sdr.detection.subprocess.run") def test_detect_rtlsdr_devices_uses_replace_decode_mode(mock_run, _mock_tool_path): """Run rtl_test with tolerant decoding for malformed output bytes.""" mock_result = MagicMock() @@ -74,8 +69,8 @@ HACKRF_INFO_OUTPUT = ( ) -@patch('utils.sdr.detection.get_tool_path', return_value='/usr/bin/hackrf_info') -@patch('utils.sdr.detection.subprocess.run') +@patch("utils.sdr.detection.get_tool_path", return_value="/usr/bin/hackrf_info") +@patch("utils.sdr.detection.subprocess.run") def test_detect_hackrf_from_stdout(mock_run, _mock_tool_path): """Parse HackRF device info from stdout.""" mock_result = MagicMock() @@ -92,8 +87,8 @@ def test_detect_hackrf_from_stdout(mock_run, _mock_tool_path): assert devices[0].index == 0 -@patch('utils.sdr.detection.get_tool_path', return_value='/usr/bin/hackrf_info') -@patch('utils.sdr.detection.subprocess.run') +@patch("utils.sdr.detection.get_tool_path", return_value="/usr/bin/hackrf_info") +@patch("utils.sdr.detection.subprocess.run") def test_detect_hackrf_from_stderr(mock_run, _mock_tool_path): """Parse HackRF device info when output goes to stderr (newer firmware).""" mock_result = MagicMock() @@ -109,8 +104,8 @@ def test_detect_hackrf_from_stderr(mock_run, _mock_tool_path): assert devices[0].serial == "0000000000000000a06063c8234e925f" -@patch('utils.sdr.detection.get_tool_path', return_value='/usr/bin/hackrf_info') -@patch('utils.sdr.detection.subprocess.run') +@patch("utils.sdr.detection.get_tool_path", return_value="/usr/bin/hackrf_info") +@patch("utils.sdr.detection.subprocess.run") def test_detect_hackrf_nonzero_exit_with_valid_output(mock_run, _mock_tool_path): """Parse HackRF info even when hackrf_info exits non-zero (device busy).""" mock_result = MagicMock() @@ -125,8 +120,8 @@ def test_detect_hackrf_nonzero_exit_with_valid_output(mock_run, _mock_tool_path) assert devices[0].name == "HackRF One" -@patch('utils.sdr.detection.get_tool_path', return_value='/usr/bin/hackrf_info') -@patch('utils.sdr.detection.subprocess.run') +@patch("utils.sdr.detection.get_tool_path", return_value="/usr/bin/hackrf_info") +@patch("utils.sdr.detection.subprocess.run") def test_detect_hackrf_fallback_no_serial(mock_run, _mock_tool_path): """Fallback detection when serial is missing but 'Found HackRF' present.""" mock_result = MagicMock() @@ -141,8 +136,8 @@ def test_detect_hackrf_fallback_no_serial(mock_run, _mock_tool_path): assert devices[0].serial == "Unknown" -@patch('utils.sdr.detection.get_tool_path', return_value='/usr/bin/hackrf_info') -@patch('utils.sdr.detection.subprocess.run') +@patch("utils.sdr.detection.get_tool_path", return_value="/usr/bin/hackrf_info") +@patch("utils.sdr.detection.subprocess.run") def test_detect_hackrf_parses_legacy_serial_format(mock_run, _mock_tool_path): """Accept legacy 'Serial Number' casing and spaced hex format.""" mock_result = MagicMock() diff --git a/tests/test_signal_guess.py b/tests/test_signal_guess.py index 2596039..ab2f0fb 100644 --- a/tests/test_signal_guess.py +++ b/tests/test_signal_guess.py @@ -106,8 +106,9 @@ class TestAirband: modulation="FM", ) # AM should score higher for airband - assert result_am._scores.get("Airband (Civil Aviation Voice)", 0) > \ - result_fm._scores.get("Airband (Civil Aviation Voice)", 0) + assert result_am._scores.get("Airband (Civil Aviation Voice)", 0) > result_fm._scores.get( + "Airband (Civil Aviation Voice)", 0 + ) class TestISMBands: @@ -224,10 +225,12 @@ class TestTPMSTelemetry: ) # Burst scores should be higher for burst-type signals - burst_score = result_burst._scores.get("TPMS / Vehicle Telemetry", 0) + \ - result_burst._scores.get("Remote Control / Key Fob", 0) - no_burst_score = result_no_burst._scores.get("TPMS / Vehicle Telemetry", 0) + \ - result_no_burst._scores.get("Remote Control / Key Fob", 0) + burst_score = result_burst._scores.get("TPMS / Vehicle Telemetry", 0) + result_burst._scores.get( + "Remote Control / Key Fob", 0 + ) + no_burst_score = result_no_burst._scores.get("TPMS / Vehicle Telemetry", 0) + result_no_burst._scores.get( + "Remote Control / Key Fob", 0 + ) assert burst_score > no_burst_score @@ -569,7 +572,9 @@ class TestSpecificSignalTypes: modulation="FM", bandwidth_hz=38_000, ) - assert "Weather" in result.primary_label or "NOAA" in result.primary_label or "Satellite" in result.primary_label + assert ( + "Weather" in result.primary_label or "NOAA" in result.primary_label or "Satellite" in result.primary_label + ) def test_adsb_1090(self): """Test ADS-B at 1090 MHz.""" diff --git a/tests/test_signalid_match.py b/tests/test_signalid_match.py index 2856955..941cd17 100644 --- a/tests/test_signalid_match.py +++ b/tests/test_signalid_match.py @@ -6,12 +6,14 @@ from __future__ import annotations class TestLoadSignals: def test_returns_list(self): from utils.signal_db import load_signals + signals = load_signals() assert isinstance(signals, list) assert len(signals) > 0 def test_cached_on_second_call(self): from utils.signal_db import load_signals + first = load_signals() second = load_signals() assert first is second # same object — cached @@ -20,12 +22,14 @@ class TestLoadSignals: class TestMatchSignals: def test_fm_broadcast_matched_at_center(self): from utils.signal_db import match_signals + results = match_signals(frequency_mhz=98.5) names = [r["name"] for r in results] assert "FM Broadcast Radio" in names def test_frequency_at_exact_range_boundary_included(self): from utils.signal_db import match_signals + # 87.5 MHz is the lower bound of FM broadcast results = match_signals(frequency_mhz=87.5) names = [r["name"] for r in results] @@ -33,6 +37,7 @@ class TestMatchSignals: def test_frequency_just_outside_range_excluded(self): from utils.signal_db import match_signals + # 87.499 MHz is just below FM broadcast lower bound results = match_signals(frequency_mhz=87.499) names = [r["name"] for r in results] @@ -40,12 +45,14 @@ class TestMatchSignals: def test_no_matches_returns_empty_list(self): from utils.signal_db import match_signals + # 5000 MHz has no signals in our database results = match_signals(frequency_mhz=5000.0) assert results == [] def test_results_have_score_field(self): from utils.signal_db import match_signals + results = match_signals(frequency_mhz=98.5) assert len(results) > 0 for r in results: @@ -55,6 +62,7 @@ class TestMatchSignals: def test_results_have_match_reasons(self): from utils.signal_db import match_signals + results = match_signals(frequency_mhz=98.5) assert len(results) > 0 for r in results: @@ -63,22 +71,26 @@ class TestMatchSignals: def test_results_sorted_by_score_descending(self): from utils.signal_db import match_signals + results = match_signals(frequency_mhz=98.5) scores = [r["score"] for r in results] assert scores == sorted(scores, reverse=True) def test_limit_respected(self): from utils.signal_db import match_signals + results = match_signals(frequency_mhz=98.5, limit=2) assert len(results) <= 2 def test_limit_clamped_to_minimum_1(self): from utils.signal_db import match_signals + results = match_signals(frequency_mhz=98.5, limit=0) assert len(results) >= 1 def test_bandwidth_within_range_scores_higher(self): from utils.signal_db import match_signals + # FM broadcast bandwidth_range is 150k–250k Hz; 200k is within with_bw = match_signals(frequency_mhz=98.5, bandwidth_hz=200_000) without_bw = match_signals(frequency_mhz=98.5) @@ -88,6 +100,7 @@ class TestMatchSignals: def test_bandwidth_outside_2x_scores_zero_for_bw_criterion(self): from utils.signal_db import match_signals + # FM broadcast max_bw is 250k Hz; 600k is > 2× that results = match_signals(frequency_mhz=98.5, bandwidth_hz=600_000) fm = next((r for r in results if r["name"] == "FM Broadcast Radio"), None) @@ -97,6 +110,7 @@ class TestMatchSignals: def test_modulation_exact_match_scores_higher(self): from utils.signal_db import match_signals + with_mod = match_signals(frequency_mhz=98.5, modulation="WFM") without_mod = match_signals(frequency_mhz=98.5) fm_with = next(r for r in with_mod if r["name"] == "FM Broadcast Radio") @@ -105,6 +119,7 @@ class TestMatchSignals: def test_modulation_mismatch_no_mod_reason(self): from utils.signal_db import match_signals + results = match_signals(frequency_mhz=98.5, modulation="LSB") fm = next((r for r in results if r["name"] == "FM Broadcast Radio"), None) if fm: @@ -112,6 +127,7 @@ class TestMatchSignals: def test_multi_range_signal_matched_by_any_range(self): from utils.signal_db import match_signals + # POCSAG has ranges in 138-175 MHz and 450-470 MHz # Use a high limit so the expanded DB doesn't push it out of the window results_vhf = match_signals(frequency_mhz=155.0, limit=20) @@ -123,6 +139,7 @@ class TestMatchSignals: def test_region_mismatch_does_not_exclude_signal(self): from utils.signal_db import match_signals + # PMR446 is EU/UK only; should still appear with US region but may score lower # Use a high limit since the expanded DB has more signals at 446 MHz results = match_signals(frequency_mhz=446.09375, region="US", limit=20) @@ -131,6 +148,7 @@ class TestMatchSignals: def test_original_signal_dict_not_mutated(self): from utils.signal_db import load_signals, match_signals + original = load_signals() first_id_before = original[0]["id"] match_signals(frequency_mhz=98.5, modulation="WFM") diff --git a/tests/test_signalid_match_route.py b/tests/test_signalid_match_route.py index 4d335ec..4fc5518 100644 --- a/tests/test_signalid_match_route.py +++ b/tests/test_signalid_match_route.py @@ -91,6 +91,7 @@ class TestSignalidMatchRoute: def test_cached_true_on_second_identical_request(self, client): import routes.signalid as signalid_module + signalid_module._match_cache.clear() payload = json.dumps({"frequency_mhz": 98.5}) client.post("/signalid/match", data=payload, content_type="application/json") diff --git a/tests/test_signalid_sigidwiki_api.py b/tests/test_signalid_sigidwiki_api.py index 355504e..54b31fd 100644 --- a/tests/test_signalid_sigidwiki_api.py +++ b/tests/test_signalid_sigidwiki_api.py @@ -11,24 +11,24 @@ import routes.signalid as signalid_module def auth_client(client): """Client with logged-in session.""" with client.session_transaction() as sess: - sess['logged_in'] = True + sess["logged_in"] = True return client def test_sigidwiki_lookup_missing_frequency(auth_client): """frequency_mhz is required.""" - resp = auth_client.post('/signalid/sigidwiki', json={}) + resp = auth_client.post("/signalid/sigidwiki", json={}) assert resp.status_code == 400 data = resp.get_json() - assert data['status'] == 'error' + assert data["status"] == "error" def test_sigidwiki_lookup_invalid_frequency(auth_client): """frequency_mhz must be numeric and positive.""" - resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': 'abc'}) + resp = auth_client.post("/signalid/sigidwiki", json={"frequency_mhz": "abc"}) assert resp.status_code == 400 - resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': -1}) + resp = auth_client.post("/signalid/sigidwiki", json={"frequency_mhz": -1}) assert resp.status_code == 400 @@ -36,64 +36,67 @@ def test_sigidwiki_lookup_success(auth_client): """Endpoint returns normalized SigID lookup structure.""" signalid_module._cache.clear() fake_lookup = { - 'matches': [ + "matches": [ { - 'title': 'POCSAG', - 'url': 'https://www.sigidwiki.com/wiki/POCSAG', - 'frequencies_mhz': [929.6625], - 'modes': ['NFM'], - 'modulations': ['FSK'], - 'distance_hz': 0, - 'source': 'SigID Wiki', + "title": "POCSAG", + "url": "https://www.sigidwiki.com/wiki/POCSAG", + "frequencies_mhz": [929.6625], + "modes": ["NFM"], + "modulations": ["FSK"], + "distance_hz": 0, + "source": "SigID Wiki", } ], - 'search_used': False, - 'exact_queries': ['[[Category:Signal]][[Frequencies::929.6625 MHz]]|?Frequencies|?Mode|?Modulation|limit=10'], + "search_used": False, + "exact_queries": ["[[Category:Signal]][[Frequencies::929.6625 MHz]]|?Frequencies|?Mode|?Modulation|limit=10"], } - with patch('routes.signalid._lookup_sigidwiki_matches', return_value=fake_lookup) as lookup_mock: - resp = auth_client.post('/signalid/sigidwiki', json={ - 'frequency_mhz': 929.6625, - 'modulation': 'fm', - 'limit': 5, - }) + with patch("routes.signalid._lookup_sigidwiki_matches", return_value=fake_lookup) as lookup_mock: + resp = auth_client.post( + "/signalid/sigidwiki", + json={ + "frequency_mhz": 929.6625, + "modulation": "fm", + "limit": 5, + }, + ) assert lookup_mock.call_count == 1 assert resp.status_code == 200 data = resp.get_json() - assert data['status'] == 'ok' - assert data['source'] == 'sigidwiki' - assert data['cached'] is False - assert data['match_count'] == 1 - assert data['matches'][0]['title'] == 'POCSAG' + assert data["status"] == "ok" + assert data["source"] == "sigidwiki" + assert data["cached"] is False + assert data["match_count"] == 1 + assert data["matches"][0]["title"] == "POCSAG" def test_sigidwiki_lookup_cached_response(auth_client): """Second identical lookup should be served from cache.""" signalid_module._cache.clear() fake_lookup = { - 'matches': [{'title': 'Test Signal', 'url': 'https://www.sigidwiki.com/wiki/Test_Signal'}], - 'search_used': True, - 'exact_queries': [], + "matches": [{"title": "Test Signal", "url": "https://www.sigidwiki.com/wiki/Test_Signal"}], + "search_used": True, + "exact_queries": [], } - payload = {'frequency_mhz': 433.92, 'modulation': 'nfm', 'limit': 5} - with patch('routes.signalid._lookup_sigidwiki_matches', return_value=fake_lookup) as lookup_mock: - first = auth_client.post('/signalid/sigidwiki', json=payload) - second = auth_client.post('/signalid/sigidwiki', json=payload) + payload = {"frequency_mhz": 433.92, "modulation": "nfm", "limit": 5} + with patch("routes.signalid._lookup_sigidwiki_matches", return_value=fake_lookup) as lookup_mock: + first = auth_client.post("/signalid/sigidwiki", json=payload) + second = auth_client.post("/signalid/sigidwiki", json=payload) assert lookup_mock.call_count == 1 assert first.status_code == 200 assert second.status_code == 200 - assert first.get_json()['cached'] is False - assert second.get_json()['cached'] is True + assert first.get_json()["cached"] is False + assert second.get_json()["cached"] is True def test_sigidwiki_lookup_backend_failure(auth_client): """Unexpected lookup failures should return 502.""" signalid_module._cache.clear() - with patch('routes.signalid._lookup_sigidwiki_matches', side_effect=RuntimeError('boom')): - resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': 433.92}) + with patch("routes.signalid._lookup_sigidwiki_matches", side_effect=RuntimeError("boom")): + resp = auth_client.post("/signalid/sigidwiki", json={"frequency_mhz": 433.92}) assert resp.status_code == 502 data = resp.get_json() - assert data['status'] == 'error' + assert data["status"] == "error" diff --git a/tests/test_sstv_decoder.py b/tests/test_sstv_decoder.py index 6bcae4d..215e5e2 100644 --- a/tests/test_sstv_decoder.py +++ b/tests/test_sstv_decoder.py @@ -62,9 +62,8 @@ from utils.sstv.vis import VISDetector, VISState # Helpers # --------------------------------------------------------------------------- -def generate_tone(freq: float, duration_s: float, - sample_rate: int = SAMPLE_RATE, - amplitude: float = 0.8) -> np.ndarray: + +def generate_tone(freq: float, duration_s: float, sample_rate: int = SAMPLE_RATE, amplitude: float = 0.8) -> np.ndarray: """Generate a pure sine tone.""" t = np.arange(int(duration_s * sample_rate)) / sample_rate return amplitude * np.sin(2 * np.pi * freq * t) @@ -118,6 +117,7 @@ def generate_vis_header(vis_code: int, sample_rate: int = SAMPLE_RATE) -> np.nda # Goertzel / DSP tests # --------------------------------------------------------------------------- + class TestGoertzel: """Tests for the Goertzel algorithm.""" @@ -257,8 +257,7 @@ class TestGoertzelBatch: for i in range(10): for j, f in enumerate(freqs): scalar = goertzel(audio_matrix[i], f) - assert abs(batch_result[i, j] - scalar) < 1e-6, \ - f"Mismatch at pixel {i}, freq {f}" + assert abs(batch_result[i, j] - scalar) < 1e-6, f"Mismatch at pixel {i}, freq {f}" def test_detects_correct_frequency(self): """Batch should find peak at the correct frequency for each pixel. @@ -296,6 +295,7 @@ class TestGoertzelBatch: # VIS detection tests # --------------------------------------------------------------------------- + class TestVISDetector: """Tests for VIS header detection.""" @@ -317,17 +317,19 @@ class TestVISDetector: detector = VISDetector() header = generate_vis_header(8) # Robot36 # Add some silence before and after - audio = np.concatenate([ - np.zeros(2400), - header, - np.zeros(2400), - ]) + audio = np.concatenate( + [ + np.zeros(2400), + header, + np.zeros(2400), + ] + ) result = detector.feed(audio) assert result is not None vis_code, mode_name = result assert vis_code == 8 - assert mode_name == 'Robot36' + assert mode_name == "Robot36" def test_detect_martin1(self): """Should detect Martin1 VIS code (44).""" @@ -339,7 +341,7 @@ class TestVISDetector: assert result is not None vis_code, mode_name = result assert vis_code == 44 - assert mode_name == 'Martin1' + assert mode_name == "Martin1" def test_detect_scottie1(self): """Should detect Scottie1 VIS code (60).""" @@ -351,7 +353,7 @@ class TestVISDetector: assert result is not None vis_code, mode_name = result assert vis_code == 60 - assert mode_name == 'Scottie1' + assert mode_name == "Scottie1" def test_detect_pd120(self): """Should detect PD120 VIS code (95).""" @@ -363,7 +365,7 @@ class TestVISDetector: assert result is not None vis_code, mode_name = result assert vis_code == 95 - assert mode_name == 'PD120' + assert mode_name == "PD120" def test_noise_rejection(self): """Should not falsely detect VIS in noise.""" @@ -384,7 +386,7 @@ class TestVISDetector: result = None offset = 0 while offset < len(audio): - chunk = audio[offset:offset + chunk_size] + chunk = audio[offset : offset + chunk_size] offset += chunk_size result = detector.feed(chunk) if result is not None: @@ -393,7 +395,7 @@ class TestVISDetector: assert result is not None vis_code, mode_name = result assert vis_code == 8 - assert mode_name == 'Robot36' + assert mode_name == "Robot36" def test_noisy_leader_detection(self): """Should detect VIS despite intermittent None windows in leader. @@ -428,15 +430,14 @@ class TestVISDetector: else: parts.append(generate_tone(FREQ_VIS_BIT_0, 0.030)) parity = ones_count % 2 - parts.append(generate_tone( - FREQ_VIS_BIT_1 if parity else FREQ_VIS_BIT_0, 0.030)) + parts.append(generate_tone(FREQ_VIS_BIT_1 if parity else FREQ_VIS_BIT_0, 0.030)) parts.append(generate_tone(FREQ_SYNC, 0.030)) # stop bit audio = np.concatenate([np.zeros(2400)] + parts + [np.zeros(2400)]) result = detector.feed(audio) assert result is not None assert result[0] == 8 - assert result[1] == 'Robot36' + assert result[1] == "Robot36" def test_vis_error_correction_parity_bit(self): """Should recover when only the parity bit is corrupted.""" @@ -461,15 +462,14 @@ class TestVISDetector: # Wrong parity (flip it) correct_parity = ones_count % 2 wrong_parity = 1 - correct_parity - parts.append(generate_tone( - FREQ_VIS_BIT_1 if wrong_parity else FREQ_VIS_BIT_0, 0.030)) + parts.append(generate_tone(FREQ_VIS_BIT_1 if wrong_parity else FREQ_VIS_BIT_0, 0.030)) parts.append(generate_tone(FREQ_SYNC, 0.030)) # stop bit audio = np.concatenate([np.zeros(2400)] + parts + [np.zeros(2400)]) result = detector.feed(audio) assert result is not None assert result[0] == 44 - assert result[1] == 'Martin1' + assert result[1] == "Martin1" def test_vis_error_correction_data_bit(self): """Should recover Martin1 when one data bit is flipped by HF noise. @@ -480,7 +480,7 @@ class TestVISDetector: correction tries flipping each data bit and finds VIS 44. """ detector = VISDetector() - original_code = 44 # Martin1 + original_code = 44 # Martin1 corrupted_code = 44 ^ 1 # flip bit 0 → 45 parts = [] @@ -498,23 +498,23 @@ class TestVISDetector: parts.append(generate_tone(FREQ_VIS_BIT_0, 0.030)) # Parity bit computed for the ORIGINAL code (received correctly) - original_ones = bin(original_code).count('1') + original_ones = bin(original_code).count("1") parity = original_ones % 2 - parts.append(generate_tone( - FREQ_VIS_BIT_1 if parity else FREQ_VIS_BIT_0, 0.030)) + parts.append(generate_tone(FREQ_VIS_BIT_1 if parity else FREQ_VIS_BIT_0, 0.030)) parts.append(generate_tone(FREQ_SYNC, 0.030)) # stop bit audio = np.concatenate([np.zeros(2400)] + parts + [np.zeros(2400)]) result = detector.feed(audio) assert result is not None assert result[0] == 44 - assert result[1] == 'Martin1' + assert result[1] == "Martin1" # --------------------------------------------------------------------------- # Mode spec tests # --------------------------------------------------------------------------- + class TestModes: """Tests for SSTV mode specifications.""" @@ -560,13 +560,13 @@ class TestModes: def test_get_mode_by_name(self): """Should look up modes by name.""" - mode = get_mode_by_name('Robot36') + mode = get_mode_by_name("Robot36") assert mode is not None assert mode.vis_code == 8 def test_mode_by_name_unknown(self): """Unknown mode name should return None.""" - assert get_mode_by_name('FakeMode') is None + assert get_mode_by_name("FakeMode") is None def test_robot72_spec(self): """Robot72 should have 3 channels and full-rate chroma.""" @@ -588,29 +588,33 @@ class TestModes: """PD120 channel durations should sum to line_duration minus sync+porch.""" channel_sum = sum(ch.duration_ms for ch in PD_120.channels) expected = PD_120.line_duration_ms - PD_120.sync_duration_ms - PD_120.sync_porch_ms - assert abs(channel_sum - expected) < 0.1, \ - f"PD120 channels sum to {channel_sum}ms, expected {expected}ms" + assert abs(channel_sum - expected) < 0.1, f"PD120 channels sum to {channel_sum}ms, expected {expected}ms" def test_pd180_channel_timings(self): """PD180 channel durations should sum to line_duration minus sync+porch.""" channel_sum = sum(ch.duration_ms for ch in PD_180.channels) expected = PD_180.line_duration_ms - PD_180.sync_duration_ms - PD_180.sync_porch_ms - assert abs(channel_sum - expected) < 0.1, \ - f"PD180 channels sum to {channel_sum}ms, expected {expected}ms" + assert abs(channel_sum - expected) < 0.1, f"PD180 channels sum to {channel_sum}ms, expected {expected}ms" def test_robot36_timing_consistency(self): """Robot36 total channel + sync + porch + separator should equal line_duration.""" - total = (ROBOT_36.sync_duration_ms + ROBOT_36.sync_porch_ms - + sum(ch.duration_ms for ch in ROBOT_36.channels) - + ROBOT_36.channel_separator_ms) # 1 separator for 2 channels + total = ( + ROBOT_36.sync_duration_ms + + ROBOT_36.sync_porch_ms + + sum(ch.duration_ms for ch in ROBOT_36.channels) + + ROBOT_36.channel_separator_ms + ) # 1 separator for 2 channels assert abs(total - ROBOT_36.line_duration_ms) < 0.1 def test_robot72_timing_consistency(self): """Robot72 total should equal line_duration.""" # 3 channels with 2 separators - total = (ROBOT_72.sync_duration_ms + ROBOT_72.sync_porch_ms - + sum(ch.duration_ms for ch in ROBOT_72.channels) - + ROBOT_72.channel_separator_ms * 2) + total = ( + ROBOT_72.sync_duration_ms + + ROBOT_72.sync_porch_ms + + sum(ch.duration_ms for ch in ROBOT_72.channels) + + ROBOT_72.channel_separator_ms * 2 + ) assert abs(total - ROBOT_72.line_duration_ms) < 0.1 def test_all_modes_have_positive_dimensions(self): @@ -625,12 +629,14 @@ class TestModes: # Image decoder tests # --------------------------------------------------------------------------- + class TestImageDecoder: """Tests for the SSTV image decoder.""" def test_creates_decoder(self): """Should create an image decoder for any supported mode.""" from utils.sstv.image_decoder import SSTVImageDecoder + decoder = SSTVImageDecoder(ROBOT_36) assert decoder.is_complete is False assert decoder.current_line == 0 @@ -639,18 +645,20 @@ class TestImageDecoder: def test_pd120_dual_luminance_lines(self): """PD120 decoder should expect half the image height in audio lines.""" from utils.sstv.image_decoder import SSTVImageDecoder + decoder = SSTVImageDecoder(PD_120) assert decoder.total_lines == 248 # 496 / 2 def test_progress_percent(self): """Progress should start at 0.""" from utils.sstv.image_decoder import SSTVImageDecoder + decoder = SSTVImageDecoder(ROBOT_36) assert decoder.progress_percent == 0 def test_synthetic_robot36_decode(self): """Should decode a synthetic Robot36 image (all white).""" - pytest.importorskip('PIL') + pytest.importorskip("PIL") from utils.sstv.image_decoder import SSTVImageDecoder decoder = SSTVImageDecoder(ROBOT_36) @@ -673,10 +681,7 @@ class TestImageDecoder: line_audio = np.concatenate(parts) line_samples = samples_for_duration(ROBOT_36.line_duration_ms / 1000.0) if len(line_audio) < line_samples: - line_audio = np.concatenate([ - line_audio, - np.zeros(line_samples - len(line_audio)) - ]) + line_audio = np.concatenate([line_audio, np.zeros(line_samples - len(line_audio))]) decoder.feed(line_audio) @@ -687,14 +692,14 @@ class TestImageDecoder: def test_slant_correction_wraps_rows_without_blank_wedge(self): """Slant correction should rotate rows, not introduce black fill.""" - PIL = pytest.importorskip('PIL') + PIL = pytest.importorskip("PIL") from utils.sstv.image_decoder import SSTVImageDecoder decoder = SSTVImageDecoder(SCOTTIE_1) decoder._sync_deviations = [float(i * 4) for i in range(SCOTTIE_1.height)] source = np.full((SCOTTIE_1.height, SCOTTIE_1.width, 3), 128, dtype=np.uint8) - img = PIL.Image.fromarray(source, 'RGB') + img = PIL.Image.fromarray(source, "RGB") corrected = decoder._apply_slant_correction(img) corrected_arr = np.array(corrected) @@ -705,14 +710,14 @@ class TestImageDecoder: def test_slant_correction_skips_implausible_drift(self): """Very large estimated drift should be treated as a bad fit and ignored.""" - PIL = pytest.importorskip('PIL') + PIL = pytest.importorskip("PIL") from utils.sstv.image_decoder import SSTVImageDecoder decoder = SSTVImageDecoder(SCOTTIE_1) decoder._sync_deviations = [float(i * 40) for i in range(SCOTTIE_1.height)] source = np.full((SCOTTIE_1.height, SCOTTIE_1.width, 3), 177, dtype=np.uint8) - img = PIL.Image.fromarray(source, 'RGB') + img = PIL.Image.fromarray(source, "RGB") corrected = decoder._apply_slant_correction(img) @@ -724,13 +729,14 @@ class TestImageDecoder: # SSTVDecoder orchestrator tests # --------------------------------------------------------------------------- + class TestSSTVDecoder: """Tests for the SSTVDecoder orchestrator.""" def test_decoder_available(self): """Python decoder should always be available.""" decoder = SSTVDecoder(output_dir=tempfile.mkdtemp()) - assert decoder.decoder_available == 'python-sstv' + assert decoder.decoder_available == "python-sstv" def test_is_sstv_available(self): """is_sstv_available() should always return True.""" @@ -758,7 +764,7 @@ class TestSSTVDecoder: cb = MagicMock() decoder.set_callback(cb) # Trigger a progress emit - decoder._emit_progress(DecodeProgress(status='detecting')) + decoder._emit_progress(DecodeProgress(status="detecting")) cb.assert_called_once() def test_get_images_empty(self): @@ -771,11 +777,11 @@ class TestSSTVDecoder: """Should raise FileNotFoundError for missing file.""" decoder = SSTVDecoder(output_dir=tempfile.mkdtemp()) with pytest.raises(FileNotFoundError): - decoder.decode_file('/nonexistent/audio.wav') + decoder.decode_file("/nonexistent/audio.wav") def test_decode_file_with_synthetic_wav(self): """Should process a WAV file through the decode pipeline.""" - pytest.importorskip('PIL') + pytest.importorskip("PIL") output_dir = tempfile.mkdtemp() decoder = SSTVDecoder(output_dir=output_dir) @@ -795,23 +801,22 @@ class TestSSTVDecoder: line_audio = np.concatenate(parts) line_samples = samples_for_duration(ROBOT_36.line_duration_ms / 1000.0) if len(line_audio) < line_samples: - line_audio = np.concatenate([ - line_audio, - np.zeros(line_samples - len(line_audio)) - ]) + line_audio = np.concatenate([line_audio, np.zeros(line_samples - len(line_audio))]) image_lines.append(line_audio) - audio = np.concatenate([ - np.zeros(4800), # 100ms silence - vis_header, - *image_lines, - np.zeros(4800), - ]) + audio = np.concatenate( + [ + np.zeros(4800), # 100ms silence + vis_header, + *image_lines, + np.zeros(4800), + ] + ) # Write WAV file - wav_path = Path(output_dir) / 'test_input.wav' + wav_path = Path(output_dir) / "test_input.wav" raw_int16 = (audio * 32767).astype(np.int16) - with wave.open(str(wav_path), 'wb') as wf: + with wave.open(str(wav_path), "wb") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(SAMPLE_RATE) @@ -819,7 +824,7 @@ class TestSSTVDecoder: images = decoder.decode_file(wav_path) assert len(images) >= 1 - assert images[0].mode == 'Robot36' + assert images[0].mode == "Robot36" assert Path(images[0].path).exists() @@ -827,51 +832,54 @@ class TestSSTVDecoder: # Dataclass tests # --------------------------------------------------------------------------- + class TestDataclasses: """Tests for dataclass serialization.""" def test_decode_progress_to_dict(self): """DecodeProgress should serialize correctly.""" progress = DecodeProgress( - status='decoding', - mode='Robot36', + status="decoding", + mode="Robot36", progress_percent=50, - message='Halfway done', + message="Halfway done", ) d = progress.to_dict() - assert d['type'] == 'sstv_progress' - assert d['status'] == 'decoding' - assert d['mode'] == 'Robot36' - assert d['progress'] == 50 - assert d['message'] == 'Halfway done' + assert d["type"] == "sstv_progress" + assert d["status"] == "decoding" + assert d["mode"] == "Robot36" + assert d["progress"] == 50 + assert d["message"] == "Halfway done" def test_decode_progress_minimal(self): """DecodeProgress with only status should omit optional fields.""" - progress = DecodeProgress(status='detecting') + progress = DecodeProgress(status="detecting") d = progress.to_dict() - assert 'mode' not in d - assert 'message' not in d - assert 'image' not in d + assert "mode" not in d + assert "message" not in d + assert "image" not in d def test_sstv_image_to_dict(self): """SSTVImage should serialize with URL.""" from datetime import datetime, timezone + image = SSTVImage( - filename='test.png', - path=Path('/tmp/test.png'), - mode='Robot36', + filename="test.png", + path=Path("/tmp/test.png"), + mode="Robot36", timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc), frequency=145.800, size_bytes=1234, ) d = image.to_dict() - assert d['filename'] == 'test.png' - assert d['mode'] == 'Robot36' - assert d['url'] == '/sstv/images/test.png' + assert d["filename"] == "test.png" + assert d["mode"] == "Robot36" + assert d["url"] == "/sstv/images/test.png" def test_doppler_info_to_dict(self): """DopplerInfo should serialize with rounding.""" from datetime import datetime, timezone + info = DopplerInfo( frequency_hz=145800123.456, shift_hz=123.456, @@ -881,15 +889,16 @@ class TestDataclasses: timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc), ) d = info.to_dict() - assert d['shift_hz'] == 123.5 - assert d['range_rate_km_s'] == -1.235 - assert d['elevation'] == 45.7 + assert d["shift_hz"] == 123.5 + assert d["range_rate_km_s"] == -1.235 + assert d["elevation"] == 45.7 # --------------------------------------------------------------------------- # Integration tests # --------------------------------------------------------------------------- + class TestIntegration: """Integration tests verifying the package works as a drop-in replacement.""" @@ -899,35 +908,38 @@ class TestIntegration: ISS_SSTV_FREQ, is_sstv_available, ) + assert ISS_SSTV_FREQ == 145.800 assert is_sstv_available() is True def test_sstv_modes_constant(self): """SSTV_MODES list should be importable.""" from utils.sstv import SSTV_MODES - assert 'Robot36' in SSTV_MODES - assert 'Martin1' in SSTV_MODES - assert 'PD120' in SSTV_MODES + + assert "Robot36" in SSTV_MODES + assert "Martin1" in SSTV_MODES + assert "PD120" in SSTV_MODES def test_decoder_singleton(self): """get_sstv_decoder should return a valid decoder.""" # Reset the global singleton for test isolation import utils.sstv.sstv_decoder as mod + old = mod._decoder mod._decoder = None try: decoder = get_sstv_decoder() assert decoder is not None - assert decoder.decoder_available == 'python-sstv' + assert decoder.decoder_available == "python-sstv" finally: mod._decoder = old - @patch('subprocess.Popen') + @patch("subprocess.Popen") def test_start_creates_subprocess(self, mock_popen): """start() should create an rtl_fm subprocess.""" mock_process = MagicMock() mock_process.stdout = MagicMock() - mock_process.stdout.read = MagicMock(return_value=b'') + mock_process.stdout.read = MagicMock(return_value=b"") mock_process.stderr = MagicMock() mock_popen.return_value = mock_process @@ -939,9 +951,9 @@ class TestIntegration: # Verify rtl_fm was called mock_popen.assert_called_once() cmd = mock_popen.call_args[0][0] - assert cmd[0] == 'rtl_fm' - assert '-f' in cmd - assert '-M' in cmd + assert cmd[0] == "rtl_fm" + assert "-f" in cmd + assert "-M" in cmd decoder.stop() assert decoder.is_running is False diff --git a/tests/test_sstv_routes.py b/tests/test_sstv_routes.py index 8eb9049..7668e81 100644 --- a/tests/test_sstv_routes.py +++ b/tests/test_sstv_routes.py @@ -13,9 +13,9 @@ from utils.sstv import ISS_SSTV_FREQ def _login_session(client) -> None: """Mark the Flask test session as authenticated.""" with client.session_transaction() as sess: - sess['logged_in'] = True - sess['username'] = 'test' - sess['role'] = 'admin' + sess["logged_in"] = True + sess["username"] = "test" + sess["role"] = "admin" class TestSSTVRoutes: @@ -25,21 +25,23 @@ class TestSSTVRoutes: """GET /sstv/status should report the fixed ISS modulation.""" _login_session(client) mock_decoder = MagicMock() - mock_decoder.decoder_available = 'python-sstv' + mock_decoder.decoder_available = "python-sstv" mock_decoder.is_running = False mock_decoder.get_images.return_value = [] mock_decoder.doppler_enabled = False mock_decoder.last_doppler_info = None - with patch('routes.sstv.is_sstv_available', return_value=True), \ - patch('routes.sstv.get_sstv_decoder', return_value=mock_decoder): - response = client.get('/sstv/status') + with ( + patch("routes.sstv.is_sstv_available", return_value=True), + patch("routes.sstv.get_sstv_decoder", return_value=mock_decoder), + ): + response = client.get("/sstv/status") assert response.status_code == 200 data = response.get_json() - assert data['available'] is True - assert data['modulation'] == 'fm' - assert data['iss_frequency'] == ISS_SSTV_FREQ + assert data["available"] is True + assert data["modulation"] == "fm" + assert data["iss_frequency"] == ISS_SSTV_FREQ def test_start_uses_fm_and_normalizes_supported_iss_frequency(self, client): """POST /sstv/start should enforce FM and snap near ISS values.""" @@ -51,30 +53,32 @@ class TestSSTVRoutes: mock_decoder.last_doppler_info = None payload = { - 'frequency': ISS_SSTV_FREQ + 0.02, # Within tolerance; should normalize. - 'modulation': 'FM', - 'device': 0, + "frequency": ISS_SSTV_FREQ + 0.02, # Within tolerance; should normalize. + "modulation": "FM", + "device": 0, } - with patch('routes.sstv.is_sstv_available', return_value=True), \ - patch('routes.sstv.get_sstv_decoder', return_value=mock_decoder), \ - patch('routes.sstv.app_module.claim_sdr_device', return_value=None): + with ( + patch("routes.sstv.is_sstv_available", return_value=True), + patch("routes.sstv.get_sstv_decoder", return_value=mock_decoder), + patch("routes.sstv.app_module.claim_sdr_device", return_value=None), + ): response = client.post( - '/sstv/start', + "/sstv/start", data=json.dumps(payload), - content_type='application/json', + content_type="application/json", ) assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'started' - assert data['modulation'] == 'fm' - assert data['frequency'] == pytest.approx(ISS_SSTV_FREQ) + assert data["status"] == "started" + assert data["modulation"] == "fm" + assert data["frequency"] == pytest.approx(ISS_SSTV_FREQ) mock_decoder.start.assert_called_once() call_kwargs = mock_decoder.start.call_args.kwargs - assert call_kwargs['modulation'] == 'fm' - assert call_kwargs['frequency'] == pytest.approx(ISS_SSTV_FREQ) + assert call_kwargs["modulation"] == "fm" + assert call_kwargs["frequency"] == pytest.approx(ISS_SSTV_FREQ) def test_start_rejects_non_fm_modulation(self, client): """POST /sstv/start should reject non-FM modulation requests.""" @@ -83,23 +87,25 @@ class TestSSTVRoutes: mock_decoder.is_running = False payload = { - 'frequency': ISS_SSTV_FREQ, - 'modulation': 'usb', - 'device': 0, + "frequency": ISS_SSTV_FREQ, + "modulation": "usb", + "device": 0, } - with patch('routes.sstv.is_sstv_available', return_value=True), \ - patch('routes.sstv.get_sstv_decoder', return_value=mock_decoder): + with ( + patch("routes.sstv.is_sstv_available", return_value=True), + patch("routes.sstv.get_sstv_decoder", return_value=mock_decoder), + ): response = client.post( - '/sstv/start', + "/sstv/start", data=json.dumps(payload), - content_type='application/json', + content_type="application/json", ) assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' - assert 'Modulation must be fm' in data['message'] + assert data["status"] == "error" + assert "Modulation must be fm" in data["message"] mock_decoder.start.assert_not_called() def test_start_rejects_non_iss_frequency(self, client): @@ -109,21 +115,23 @@ class TestSSTVRoutes: mock_decoder.is_running = False payload = { - 'frequency': 14.230, - 'modulation': 'fm', - 'device': 0, + "frequency": 14.230, + "modulation": "fm", + "device": 0, } - with patch('routes.sstv.is_sstv_available', return_value=True), \ - patch('routes.sstv.get_sstv_decoder', return_value=mock_decoder): + with ( + patch("routes.sstv.is_sstv_available", return_value=True), + patch("routes.sstv.get_sstv_decoder", return_value=mock_decoder), + ): response = client.post( - '/sstv/start', + "/sstv/start", data=json.dumps(payload), - content_type='application/json', + content_type="application/json", ) assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' - assert 'Supported ISS SSTV frequency' in data['message'] + assert data["status"] == "error" + assert "Supported ISS SSTV frequency" in data["message"] mock_decoder.start.assert_not_called() diff --git a/tests/test_subghz.py b/tests/test_subghz.py index edc8e52..9d7a8c6 100644 --- a/tests/test_subghz.py +++ b/tests/test_subghz.py @@ -14,9 +14,9 @@ from utils.subghz import SubGhzCapture, SubGhzManager @pytest.fixture def tmp_data_dir(tmp_path): """Create a temporary data directory for SubGhz captures.""" - data_dir = tmp_path / 'subghz' + data_dir = tmp_path / "subghz" data_dir.mkdir() - (data_dir / 'captures').mkdir() + (data_dir / "captures").mkdir() return data_dir @@ -28,57 +28,57 @@ def manager(tmp_data_dir): class TestSubGhzManagerInit: def test_creates_data_dirs(self, tmp_path): - data_dir = tmp_path / 'new_subghz' + data_dir = tmp_path / "new_subghz" SubGhzManager(data_dir=data_dir) - assert (data_dir / 'captures').is_dir() + assert (data_dir / "captures").is_dir() def test_active_mode_idle(self, manager): - assert manager.active_mode == 'idle' + assert manager.active_mode == "idle" def test_get_status_idle(self, manager): status = manager.get_status() - assert status['mode'] == 'idle' + assert status["mode"] == "idle" -class TestToolDetection: +class TestToolDetection: def test_check_hackrf_found(self, manager): - with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'): + with patch("shutil.which", return_value="/usr/bin/hackrf_transfer"): assert manager.check_hackrf() is True - def test_check_hackrf_not_found(self, manager): - with patch('shutil.which', return_value=None), \ - patch('utils.subghz.get_tool_path', return_value=None): - manager._hackrf_available = None # reset cache - assert manager.check_hackrf() is False + def test_check_hackrf_not_found(self, manager): + with patch("shutil.which", return_value=None), patch("utils.subghz.get_tool_path", return_value=None): + manager._hackrf_available = None # reset cache + assert manager.check_hackrf() is False def test_check_rtl433_found(self, manager): - with patch('shutil.which', return_value='/usr/bin/rtl_433'): + with patch("shutil.which", return_value="/usr/bin/rtl_433"): assert manager.check_rtl433() is True def test_check_sweep_found(self, manager): - with patch('shutil.which', return_value='/usr/bin/hackrf_sweep'): + with patch("shutil.which", return_value="/usr/bin/hackrf_sweep"): assert manager.check_sweep() is True -class TestReceive: - def test_start_receive_no_hackrf(self, manager): - with patch('shutil.which', return_value=None), \ - patch('utils.subghz.get_tool_path', return_value=None): - manager._hackrf_available = None - result = manager.start_receive(frequency_hz=433920000) - assert result['status'] == 'error' - assert 'not found' in result['message'] +class TestReceive: + def test_start_receive_no_hackrf(self, manager): + with patch("shutil.which", return_value=None), patch("utils.subghz.get_tool_path", return_value=None): + manager._hackrf_available = None + result = manager.start_receive(frequency_hz=433920000) + assert result["status"] == "error" + assert "not found" in result["message"] def test_start_receive_success(self, manager): mock_proc = MagicMock() mock_proc.poll.return_value = None mock_proc.stderr = MagicMock() - mock_proc.stderr.readline = MagicMock(return_value=b'') + mock_proc.stderr.readline = MagicMock(return_value=b"") - with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \ - patch('subprocess.Popen', return_value=mock_proc), \ - patch.object(manager, 'check_hackrf_device', return_value=True), \ - patch('utils.subghz.register_process'): + with ( + patch("shutil.which", return_value="/usr/bin/hackrf_transfer"), + patch("subprocess.Popen", return_value=mock_proc), + patch.object(manager, "check_hackrf_device", return_value=True), + patch("utils.subghz.register_process"), + ): manager._hackrf_available = None result = manager.start_receive( frequency_hz=433920000, @@ -86,12 +86,13 @@ class TestReceive: lna_gain=32, vga_gain=20, ) - assert result['status'] == 'started' - assert result['frequency_hz'] == 433920000 - assert manager.active_mode == 'rx' + assert result["status"] == "started" + assert result["frequency_hz"] == 433920000 + assert manager.active_mode == "rx" def test_start_receive_already_running(self, manager): import time as _time + mock_proc = MagicMock() mock_proc.poll.return_value = None manager._rx_process = mock_proc @@ -101,17 +102,17 @@ class TestReceive: manager._hackrf_device_cache_ts = _time.time() result = manager.start_receive(frequency_hz=433920000) - assert result['status'] == 'error' - assert 'Already running' in result['message'] + assert result["status"] == "error" + assert "Already running" in result["message"] def test_stop_receive_not_running(self, manager): result = manager.stop_receive() - assert result['status'] == 'not_running' + assert result["status"] == "not_running" def test_stop_receive_creates_metadata(self, manager, tmp_data_dir): # Create a fake IQ file - iq_file = tmp_data_dir / 'captures' / 'test.iq' - iq_file.write_bytes(b'\x00' * 1024) + iq_file = tmp_data_dir / "captures" / "test.iq" + iq_file.write_bytes(b"\x00" * 1024) mock_proc = MagicMock() mock_proc.poll.return_value = None @@ -122,23 +123,22 @@ class TestReceive: manager._rx_lna_gain = 32 manager._rx_vga_gain = 20 manager._rx_start_time = 1000.0 - manager._rx_bursts = [{'start_seconds': 1.23, 'duration_seconds': 0.15, 'peak_level': 42}] + manager._rx_bursts = [{"start_seconds": 1.23, "duration_seconds": 0.15, "peak_level": 42}] - with patch('utils.subghz.safe_terminate'), \ - patch('time.time', return_value=1005.0): + with patch("utils.subghz.safe_terminate"), patch("time.time", return_value=1005.0): result = manager.stop_receive() - assert result['status'] == 'stopped' - assert 'capture' in result - assert result['capture']['frequency_hz'] == 433920000 + assert result["status"] == "stopped" + assert "capture" in result + assert result["capture"]["frequency_hz"] == 433920000 # Verify JSON sidecar was written - meta_path = iq_file.with_suffix('.json') + meta_path = iq_file.with_suffix(".json") assert meta_path.exists() meta = json.loads(meta_path.read_text()) - assert meta['frequency_hz'] == 433920000 - assert isinstance(meta.get('bursts'), list) - assert meta['bursts'][0]['peak_level'] == 42 + assert meta["frequency_hz"] == 433920000 + assert isinstance(meta.get("bursts"), list) + assert meta["bursts"][0]["peak_level"] == 42 class TestTxSafety: @@ -157,51 +157,55 @@ class TestTxSafety: def test_validate_tx_frequency_out_of_band(self): result = SubGhzManager.validate_tx_frequency(100000000) # 100 MHz assert result is not None - assert 'outside allowed TX bands' in result + assert "outside allowed TX bands" in result def test_validate_tx_frequency_between_bands(self): result = SubGhzManager.validate_tx_frequency(500000000) # 500 MHz assert result is not None - def test_transmit_no_hackrf(self, manager): - with patch('shutil.which', return_value=None), \ - patch('utils.subghz.get_tool_path', return_value=None): - manager._hackrf_available = None - result = manager.transmit(capture_id='abc123') - assert result['status'] == 'error' + def test_transmit_no_hackrf(self, manager): + with patch("shutil.which", return_value=None), patch("utils.subghz.get_tool_path", return_value=None): + manager._hackrf_available = None + result = manager.transmit(capture_id="abc123") + assert result["status"] == "error" def test_transmit_capture_not_found(self, manager): - with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \ - patch.object(manager, 'check_hackrf_device', return_value=True): + with ( + patch("shutil.which", return_value="/usr/bin/hackrf_transfer"), + patch.object(manager, "check_hackrf_device", return_value=True), + ): manager._hackrf_available = None - result = manager.transmit(capture_id='nonexistent') - assert result['status'] == 'error' - assert 'not found' in result['message'] + result = manager.transmit(capture_id="nonexistent") + assert result["status"] == "error" + assert "not found" in result["message"] def test_transmit_out_of_band_rejected(self, manager, tmp_data_dir): # Create a capture with out-of-band frequency meta = { - 'id': 'test123', - 'filename': 'test.iq', - 'frequency_hz': 100000000, # 100 MHz - out of ISM - 'sample_rate': 2000000, - 'lna_gain': 32, - 'vga_gain': 20, - 'timestamp': '2026-01-01T00:00:00Z', + "id": "test123", + "filename": "test.iq", + "frequency_hz": 100000000, # 100 MHz - out of ISM + "sample_rate": 2000000, + "lna_gain": 32, + "vga_gain": 20, + "timestamp": "2026-01-01T00:00:00Z", } - meta_path = tmp_data_dir / 'captures' / 'test.json' + meta_path = tmp_data_dir / "captures" / "test.json" meta_path.write_text(json.dumps(meta)) - (tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 100) + (tmp_data_dir / "captures" / "test.iq").write_bytes(b"\x00" * 100) - with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \ - patch.object(manager, 'check_hackrf_device', return_value=True): + with ( + patch("shutil.which", return_value="/usr/bin/hackrf_transfer"), + patch.object(manager, "check_hackrf_device", return_value=True), + ): manager._hackrf_available = None - result = manager.transmit(capture_id='test123') - assert result['status'] == 'error' - assert 'outside allowed TX bands' in result['message'] + result = manager.transmit(capture_id="test123") + assert result["status"] == "error" + assert "outside allowed TX bands" in result["message"] def test_transmit_already_running(self, manager, tmp_data_dir): import time as _time + mock_proc = MagicMock() mock_proc.poll.return_value = None manager._rx_process = mock_proc @@ -211,59 +215,61 @@ class TestTxSafety: manager._hackrf_device_cache_ts = _time.time() # Capture lookup also runs pre-lock now; provide a valid capture + IQ file meta = { - 'id': 'test123', - 'filename': 'test.iq', - 'frequency_hz': 433920000, - 'sample_rate': 2000000, - 'timestamp': '2025-01-01T00:00:00', + "id": "test123", + "filename": "test.iq", + "frequency_hz": 433920000, + "sample_rate": 2000000, + "timestamp": "2025-01-01T00:00:00", } - (tmp_data_dir / 'captures' / 'test.json').write_text(json.dumps(meta)) - (tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 64) + (tmp_data_dir / "captures" / "test.json").write_text(json.dumps(meta)) + (tmp_data_dir / "captures" / "test.iq").write_bytes(b"\x00" * 64) - result = manager.transmit(capture_id='test123') - assert result['status'] == 'error' - assert 'Already running' in result['message'] + result = manager.transmit(capture_id="test123") + assert result["status"] == "error" + assert "Already running" in result["message"] def test_transmit_segment_extracts_range(self, manager, tmp_data_dir): meta = { - 'id': 'seg001', - 'filename': 'seg.iq', - 'frequency_hz': 433920000, - 'sample_rate': 1000, - 'lna_gain': 24, - 'vga_gain': 20, - 'timestamp': '2026-01-01T00:00:00Z', - 'duration_seconds': 1.0, - 'size_bytes': 2000, + "id": "seg001", + "filename": "seg.iq", + "frequency_hz": 433920000, + "sample_rate": 1000, + "lna_gain": 24, + "vga_gain": 20, + "timestamp": "2026-01-01T00:00:00Z", + "duration_seconds": 1.0, + "size_bytes": 2000, } - (tmp_data_dir / 'captures' / 'seg.json').write_text(json.dumps(meta)) - (tmp_data_dir / 'captures' / 'seg.iq').write_bytes(bytes(range(200)) * 10) + (tmp_data_dir / "captures" / "seg.json").write_text(json.dumps(meta)) + (tmp_data_dir / "captures" / "seg.iq").write_bytes(bytes(range(200)) * 10) mock_proc = MagicMock() mock_proc.poll.return_value = None mock_timer = MagicMock() mock_timer.start = MagicMock() - with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \ - patch.object(manager, 'check_hackrf_device', return_value=True), \ - patch('subprocess.Popen', return_value=mock_proc), \ - patch('utils.subghz.register_process'), \ - patch('threading.Timer', return_value=mock_timer), \ - patch('threading.Thread') as mock_thread_cls: + with ( + patch("shutil.which", return_value="/usr/bin/hackrf_transfer"), + patch.object(manager, "check_hackrf_device", return_value=True), + patch("subprocess.Popen", return_value=mock_proc), + patch("utils.subghz.register_process"), + patch("threading.Timer", return_value=mock_timer), + patch("threading.Thread") as mock_thread_cls, + ): mock_thread = MagicMock() mock_thread.start = MagicMock() mock_thread_cls.return_value = mock_thread manager._hackrf_available = None result = manager.transmit( - capture_id='seg001', + capture_id="seg001", start_seconds=0.2, duration_seconds=0.3, ) - assert result['status'] == 'transmitting' - assert result['segment'] is not None - assert result['segment']['duration_seconds'] == pytest.approx(0.3, abs=0.01) + assert result["status"] == "transmitting" + assert result["segment"] is not None + assert result["segment"]["duration_seconds"] == pytest.approx(0.3, abs=0.01) assert manager._tx_temp_file is not None assert manager._tx_temp_file.exists() @@ -275,262 +281,281 @@ class TestCaptureLibrary: def test_list_captures_with_data(self, manager, tmp_data_dir): meta = { - 'id': 'cap001', - 'filename': 'test.iq', - 'frequency_hz': 433920000, - 'sample_rate': 2000000, - 'lna_gain': 32, - 'vga_gain': 20, - 'timestamp': '2026-01-01T00:00:00Z', - 'duration_seconds': 5.0, - 'size_bytes': 1024, - 'label': 'test capture', + "id": "cap001", + "filename": "test.iq", + "frequency_hz": 433920000, + "sample_rate": 2000000, + "lna_gain": 32, + "vga_gain": 20, + "timestamp": "2026-01-01T00:00:00Z", + "duration_seconds": 5.0, + "size_bytes": 1024, + "label": "test capture", } - (tmp_data_dir / 'captures' / 'test.json').write_text(json.dumps(meta)) + (tmp_data_dir / "captures" / "test.json").write_text(json.dumps(meta)) captures = manager.list_captures() assert len(captures) == 1 - assert captures[0].capture_id == 'cap001' - assert captures[0].label == 'test capture' + assert captures[0].capture_id == "cap001" + assert captures[0].label == "test capture" def test_get_capture(self, manager, tmp_data_dir): meta = { - 'id': 'cap002', - 'filename': 'test2.iq', - 'frequency_hz': 315000000, - 'sample_rate': 2000000, - 'timestamp': '2026-01-01T00:00:00Z', + "id": "cap002", + "filename": "test2.iq", + "frequency_hz": 315000000, + "sample_rate": 2000000, + "timestamp": "2026-01-01T00:00:00Z", } - (tmp_data_dir / 'captures' / 'test2.json').write_text(json.dumps(meta)) + (tmp_data_dir / "captures" / "test2.json").write_text(json.dumps(meta)) - cap = manager.get_capture('cap002') + cap = manager.get_capture("cap002") assert cap is not None assert cap.frequency_hz == 315000000 def test_get_capture_not_found(self, manager): - cap = manager.get_capture('nonexistent') + cap = manager.get_capture("nonexistent") assert cap is None def test_delete_capture(self, manager, tmp_data_dir): - captures_dir = tmp_data_dir / 'captures' - iq_path = captures_dir / 'delete_me.iq' - meta_path = captures_dir / 'delete_me.json' - iq_path.write_bytes(b'\x00' * 100) - meta_path.write_text(json.dumps({ - 'id': 'del001', - 'filename': 'delete_me.iq', - 'frequency_hz': 433920000, - 'sample_rate': 2000000, - 'timestamp': '2026-01-01T00:00:00Z', - })) + captures_dir = tmp_data_dir / "captures" + iq_path = captures_dir / "delete_me.iq" + meta_path = captures_dir / "delete_me.json" + iq_path.write_bytes(b"\x00" * 100) + meta_path.write_text( + json.dumps( + { + "id": "del001", + "filename": "delete_me.iq", + "frequency_hz": 433920000, + "sample_rate": 2000000, + "timestamp": "2026-01-01T00:00:00Z", + } + ) + ) - assert manager.delete_capture('del001') is True + assert manager.delete_capture("del001") is True assert not iq_path.exists() assert not meta_path.exists() def test_delete_capture_not_found(self, manager): - assert manager.delete_capture('nonexistent') is False + assert manager.delete_capture("nonexistent") is False def test_update_label(self, manager, tmp_data_dir): meta = { - 'id': 'lbl001', - 'filename': 'label_test.iq', - 'frequency_hz': 433920000, - 'sample_rate': 2000000, - 'timestamp': '2026-01-01T00:00:00Z', - 'label': '', + "id": "lbl001", + "filename": "label_test.iq", + "frequency_hz": 433920000, + "sample_rate": 2000000, + "timestamp": "2026-01-01T00:00:00Z", + "label": "", } - meta_path = tmp_data_dir / 'captures' / 'label_test.json' + meta_path = tmp_data_dir / "captures" / "label_test.json" meta_path.write_text(json.dumps(meta)) - assert manager.update_capture_label('lbl001', 'Garage Remote') is True + assert manager.update_capture_label("lbl001", "Garage Remote") is True updated = json.loads(meta_path.read_text()) - assert updated['label'] == 'Garage Remote' - assert updated['label_source'] == 'manual' + assert updated["label"] == "Garage Remote" + assert updated["label_source"] == "manual" def test_update_label_not_found(self, manager): - assert manager.update_capture_label('nonexistent', 'test') is False + assert manager.update_capture_label("nonexistent", "test") is False def test_get_capture_path(self, manager, tmp_data_dir): - captures_dir = tmp_data_dir / 'captures' - iq_path = captures_dir / 'path_test.iq' - iq_path.write_bytes(b'\x00' * 100) - (captures_dir / 'path_test.json').write_text(json.dumps({ - 'id': 'pth001', - 'filename': 'path_test.iq', - 'frequency_hz': 433920000, - 'sample_rate': 2000000, - 'timestamp': '2026-01-01T00:00:00Z', - })) + captures_dir = tmp_data_dir / "captures" + iq_path = captures_dir / "path_test.iq" + iq_path.write_bytes(b"\x00" * 100) + (captures_dir / "path_test.json").write_text( + json.dumps( + { + "id": "pth001", + "filename": "path_test.iq", + "frequency_hz": 433920000, + "sample_rate": 2000000, + "timestamp": "2026-01-01T00:00:00Z", + } + ) + ) - path = manager.get_capture_path('pth001') + path = manager.get_capture_path("pth001") assert path is not None - assert path.name == 'path_test.iq' + assert path.name == "path_test.iq" def test_get_capture_path_not_found(self, manager): - assert manager.get_capture_path('nonexistent') is None + assert manager.get_capture_path("nonexistent") is None def test_trim_capture_manual_segment(self, manager, tmp_data_dir): - captures_dir = tmp_data_dir / 'captures' - iq_path = captures_dir / 'trim_src.iq' + captures_dir = tmp_data_dir / "captures" + iq_path = captures_dir / "trim_src.iq" iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s - (captures_dir / 'trim_src.json').write_text(json.dumps({ - 'id': 'trim001', - 'filename': 'trim_src.iq', - 'frequency_hz': 433920000, - 'sample_rate': 1000, - 'lna_gain': 24, - 'vga_gain': 20, - 'timestamp': '2026-01-01T00:00:00Z', - 'duration_seconds': 2.0, - 'size_bytes': 4000, - 'label': 'Weather Burst', - 'bursts': [ + (captures_dir / "trim_src.json").write_text( + json.dumps( { - 'start_seconds': 0.55, - 'duration_seconds': 0.2, - 'peak_level': 67, - 'fingerprint': 'abc123', - 'modulation_hint': 'OOK/ASK', - 'modulation_confidence': 0.9, + "id": "trim001", + "filename": "trim_src.iq", + "frequency_hz": 433920000, + "sample_rate": 1000, + "lna_gain": 24, + "vga_gain": 20, + "timestamp": "2026-01-01T00:00:00Z", + "duration_seconds": 2.0, + "size_bytes": 4000, + "label": "Weather Burst", + "bursts": [ + { + "start_seconds": 0.55, + "duration_seconds": 0.2, + "peak_level": 67, + "fingerprint": "abc123", + "modulation_hint": "OOK/ASK", + "modulation_confidence": 0.9, + } + ], } - ], - })) + ) + ) result = manager.trim_capture( - capture_id='trim001', + capture_id="trim001", start_seconds=0.5, duration_seconds=0.4, ) - assert result['status'] == 'ok' - assert result['capture']['id'] != 'trim001' - assert result['capture']['size_bytes'] == 800 - assert result['capture']['label'].endswith('(Trim)') - trimmed_iq = captures_dir / result['capture']['filename'] + assert result["status"] == "ok" + assert result["capture"]["id"] != "trim001" + assert result["capture"]["size_bytes"] == 800 + assert result["capture"]["label"].endswith("(Trim)") + trimmed_iq = captures_dir / result["capture"]["filename"] assert trimmed_iq.exists() - trimmed_meta = trimmed_iq.with_suffix('.json') + trimmed_meta = trimmed_iq.with_suffix(".json") assert trimmed_meta.exists() def test_trim_capture_auto_burst(self, manager, tmp_data_dir): - captures_dir = tmp_data_dir / 'captures' - iq_path = captures_dir / 'auto_src.iq' + captures_dir = tmp_data_dir / "captures" + iq_path = captures_dir / "auto_src.iq" iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes - (captures_dir / 'auto_src.json').write_text(json.dumps({ - 'id': 'trim002', - 'filename': 'auto_src.iq', - 'frequency_hz': 433920000, - 'sample_rate': 1000, - 'lna_gain': 24, - 'vga_gain': 20, - 'timestamp': '2026-01-01T00:00:00Z', - 'duration_seconds': 2.0, - 'size_bytes': 4000, - 'bursts': [ - {'start_seconds': 0.2, 'duration_seconds': 0.1, 'peak_level': 12}, - {'start_seconds': 1.2, 'duration_seconds': 0.25, 'peak_level': 88}, - ], - })) + (captures_dir / "auto_src.json").write_text( + json.dumps( + { + "id": "trim002", + "filename": "auto_src.iq", + "frequency_hz": 433920000, + "sample_rate": 1000, + "lna_gain": 24, + "vga_gain": 20, + "timestamp": "2026-01-01T00:00:00Z", + "duration_seconds": 2.0, + "size_bytes": 4000, + "bursts": [ + {"start_seconds": 0.2, "duration_seconds": 0.1, "peak_level": 12}, + {"start_seconds": 1.2, "duration_seconds": 0.25, "peak_level": 88}, + ], + } + ) + ) - result = manager.trim_capture(capture_id='trim002') - assert result['status'] == 'ok' - assert result['segment']['auto_selected'] is True - assert result['capture']['duration_seconds'] > 0.25 + result = manager.trim_capture(capture_id="trim002") + assert result["status"] == "ok" + assert result["segment"]["auto_selected"] is True + assert result["capture"]["duration_seconds"] > 0.25 def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir): cap_a = { - 'id': 'grp001', - 'filename': 'a.iq', - 'frequency_hz': 433920000, - 'sample_rate': 2000000, - 'timestamp': '2026-01-01T00:00:00Z', - 'dominant_fingerprint': 'deadbeefcafebabe', + "id": "grp001", + "filename": "a.iq", + "frequency_hz": 433920000, + "sample_rate": 2000000, + "timestamp": "2026-01-01T00:00:00Z", + "dominant_fingerprint": "deadbeefcafebabe", } cap_b = { - 'id': 'grp002', - 'filename': 'b.iq', - 'frequency_hz': 433920000, - 'sample_rate': 2000000, - 'timestamp': '2026-01-01T00:01:00Z', - 'dominant_fingerprint': 'deadbeefcafebabe', + "id": "grp002", + "filename": "b.iq", + "frequency_hz": 433920000, + "sample_rate": 2000000, + "timestamp": "2026-01-01T00:01:00Z", + "dominant_fingerprint": "deadbeefcafebabe", } - (tmp_data_dir / 'captures' / 'a.json').write_text(json.dumps(cap_a)) - (tmp_data_dir / 'captures' / 'b.json').write_text(json.dumps(cap_b)) + (tmp_data_dir / "captures" / "a.json").write_text(json.dumps(cap_a)) + (tmp_data_dir / "captures" / "b.json").write_text(json.dumps(cap_b)) captures = manager.list_captures() assert len(captures) == 2 - assert all(c.fingerprint_group.startswith('SIG-') for c in captures) + assert all(c.fingerprint_group.startswith("SIG-") for c in captures) assert all(c.fingerprint_group_size == 2 for c in captures) -class TestSweep: - def test_start_sweep_no_tool(self, manager): - with patch('shutil.which', return_value=None), \ - patch('utils.subghz.get_tool_path', return_value=None): - manager._sweep_available = None - result = manager.start_sweep() - assert result['status'] == 'error' +class TestSweep: + def test_start_sweep_no_tool(self, manager): + with patch("shutil.which", return_value=None), patch("utils.subghz.get_tool_path", return_value=None): + manager._sweep_available = None + result = manager.start_sweep() + assert result["status"] == "error" def test_start_sweep_success(self, manager): import time as _time + mock_proc = MagicMock() mock_proc.poll.return_value = None mock_proc.stdout = MagicMock() - with patch('shutil.which', return_value='/usr/bin/hackrf_sweep'), \ - patch('subprocess.Popen', return_value=mock_proc), \ - patch('utils.subghz.register_process'): + with ( + patch("shutil.which", return_value="/usr/bin/hackrf_sweep"), + patch("subprocess.Popen", return_value=mock_proc), + patch("utils.subghz.register_process"), + ): manager._sweep_available = None manager._hackrf_device_cache = True manager._hackrf_device_cache_ts = _time.time() result = manager.start_sweep(freq_start_mhz=300, freq_end_mhz=928) - assert result['status'] == 'started' + assert result["status"] == "started" # Signal daemon threads to stop so they don't outlive the test manager._sweep_running = False def test_stop_sweep_not_running(self, manager): result = manager.stop_sweep() - assert result['status'] == 'not_running' + assert result["status"] == "not_running" -class TestDecode: - def test_start_decode_no_hackrf(self, manager): - with patch('shutil.which', return_value=None), \ - patch('utils.subghz.get_tool_path', return_value=None): - manager._hackrf_available = None - manager._rtl433_available = None - result = manager.start_decode(frequency_hz=433920000) - assert result['status'] == 'error' - assert 'hackrf_transfer' in result['message'] +class TestDecode: + def test_start_decode_no_hackrf(self, manager): + with patch("shutil.which", return_value=None), patch("utils.subghz.get_tool_path", return_value=None): + manager._hackrf_available = None + manager._rtl433_available = None + result = manager.start_decode(frequency_hz=433920000) + assert result["status"] == "error" + assert "hackrf_transfer" in result["message"] def test_start_decode_no_rtl433(self, manager): def which_side_effect(name): - if name == 'hackrf_transfer': - return '/usr/bin/hackrf_transfer' + if name == "hackrf_transfer": + return "/usr/bin/hackrf_transfer" return None - with patch('shutil.which', side_effect=which_side_effect), \ - patch('utils.subghz.get_tool_path', return_value=None): - manager._hackrf_available = None - manager._rtl433_available = None - result = manager.start_decode(frequency_hz=433920000) - assert result['status'] == 'error' - assert 'rtl_433' in result['message'] + with ( + patch("shutil.which", side_effect=which_side_effect), + patch("utils.subghz.get_tool_path", return_value=None), + ): + manager._hackrf_available = None + manager._rtl433_available = None + result = manager.start_decode(frequency_hz=433920000) + assert result["status"] == "error" + assert "rtl_433" in result["message"] def test_start_decode_success(self, manager): mock_hackrf_proc = MagicMock() mock_hackrf_proc.poll.return_value = None mock_hackrf_proc.stdout = MagicMock() mock_hackrf_proc.stderr = MagicMock() - mock_hackrf_proc.stderr.readline = MagicMock(return_value=b'') + mock_hackrf_proc.stderr.readline = MagicMock(return_value=b"") mock_rtl433_proc = MagicMock() mock_rtl433_proc.poll.return_value = None mock_rtl433_proc.stdout = MagicMock() mock_rtl433_proc.stderr = MagicMock() - mock_rtl433_proc.stderr.readline = MagicMock(return_value=b'') + mock_rtl433_proc.stderr.readline = MagicMock(return_value=b"") call_count = [0] @@ -540,13 +565,16 @@ class TestDecode: return mock_hackrf_proc return mock_rtl433_proc - def which_side_effect(name): - return f'/usr/bin/{name}' - - with patch('shutil.which', side_effect=which_side_effect), \ - patch('subprocess.Popen', side_effect=popen_side_effect) as mock_popen, \ - patch('utils.subghz.register_process'): + def which_side_effect(name): + return f"/usr/bin/{name}" + + with ( + patch("shutil.which", side_effect=which_side_effect), + patch("subprocess.Popen", side_effect=popen_side_effect) as mock_popen, + patch("utils.subghz.register_process"), + ): import time as _time + manager._hackrf_available = None manager._rtl433_available = None manager._hackrf_device_cache = True @@ -555,23 +583,23 @@ class TestDecode: frequency_hz=433920000, sample_rate=2000000, ) - assert result['status'] == 'started' - assert result['frequency_hz'] == 433920000 - assert manager.active_mode == 'decode' + assert result["status"] == "started" + assert result["frequency_hz"] == 433920000 + assert manager.active_mode == "decode" # Two processes: hackrf_transfer + rtl_433 assert mock_popen.call_count == 2 - # Verify hackrf_transfer command - hackrf_cmd = mock_popen.call_args_list[0][0][0] - assert os.path.basename(hackrf_cmd[0]) == 'hackrf_transfer' - assert '-r' in hackrf_cmd - - # Verify rtl_433 command - rtl433_cmd = mock_popen.call_args_list[1][0][0] - assert os.path.basename(rtl433_cmd[0]) == 'rtl_433' - assert '-r' in rtl433_cmd - assert 'cs8:-' in rtl433_cmd + # Verify hackrf_transfer command + hackrf_cmd = mock_popen.call_args_list[0][0][0] + assert os.path.basename(hackrf_cmd[0]) == "hackrf_transfer" + assert "-r" in hackrf_cmd + + # Verify rtl_433 command + rtl433_cmd = mock_popen.call_args_list[1][0][0] + assert os.path.basename(rtl433_cmd[0]) == "rtl_433" + assert "-r" in rtl433_cmd + assert "cs8:-" in rtl433_cmd # Both processes tracked assert manager._decode_hackrf_process is mock_hackrf_proc @@ -582,7 +610,7 @@ class TestDecode: def test_stop_decode_not_running(self, manager): result = manager.stop_decode() - assert result['status'] == 'not_running' + assert result["status"] == "not_running" def test_stop_decode_terminates_both(self, manager): mock_hackrf = MagicMock() @@ -594,11 +622,10 @@ class TestDecode: manager._decode_process = mock_rtl433 manager._decode_frequency_hz = 433920000 - with patch('utils.subghz.safe_terminate') as mock_term, \ - patch('utils.subghz.unregister_process'): + with patch("utils.subghz.safe_terminate") as mock_term, patch("utils.subghz.unregister_process"): result = manager.stop_decode() - assert result['status'] == 'stopped' + assert result["status"] == "stopped" assert manager._decode_hackrf_process is None assert manager._decode_process is None assert mock_term.call_count == 2 @@ -610,7 +637,7 @@ class TestStopAll: mock_proc.poll.return_value = None manager._rx_process = mock_proc - with patch('utils.subghz.safe_terminate'): + with patch("utils.subghz.safe_terminate"): manager.stop_all() assert manager._rx_process is None @@ -623,18 +650,18 @@ class TestStopAll: class TestSubGhzCapture: def test_to_dict(self): cap = SubGhzCapture( - capture_id='abc123', - filename='test.iq', + capture_id="abc123", + filename="test.iq", frequency_hz=433920000, sample_rate=2000000, lna_gain=32, vga_gain=20, - timestamp='2026-01-01T00:00:00Z', + timestamp="2026-01-01T00:00:00Z", duration_seconds=5.0, size_bytes=1024, - label='Test', + label="Test", ) d = cap.to_dict() - assert d['id'] == 'abc123' - assert d['frequency_hz'] == 433920000 - assert d['label'] == 'Test' + assert d["id"] == "abc123" + assert d["frequency_hz"] == 433920000 + assert d["label"] == "Test" diff --git a/tests/test_subghz_routes.py b/tests/test_subghz_routes.py index ae0894f..675b82a 100644 --- a/tests/test_subghz_routes.py +++ b/tests/test_subghz_routes.py @@ -13,7 +13,7 @@ from utils.subghz import SubGhzCapture def auth_client(client): """Client with logged-in session.""" with client.session_transaction() as sess: - sess['logged_in'] = True + sess["logged_in"] = True return client @@ -22,411 +22,459 @@ class TestSubGhzRoutes: def test_get_status(self, client, auth_client): """GET /subghz/status returns manager status.""" - with patch('routes.subghz.get_subghz_manager') as mock_get: + with patch("routes.subghz.get_subghz_manager") as mock_get: mock_mgr = MagicMock() mock_mgr.get_status.return_value = { - 'mode': 'idle', - 'hackrf_available': True, - 'rtl433_available': True, - 'sweep_available': True, + "mode": "idle", + "hackrf_available": True, + "rtl433_available": True, + "sweep_available": True, } mock_get.return_value = mock_mgr - response = auth_client.get('/subghz/status') + response = auth_client.get("/subghz/status") assert response.status_code == 200 data = response.get_json() - assert data['mode'] == 'idle' - assert data['hackrf_available'] is True + assert data["mode"] == "idle" + assert data["hackrf_available"] is True def test_get_presets(self, client, auth_client): """GET /subghz/presets returns frequency presets.""" - response = auth_client.get('/subghz/presets') + response = auth_client.get("/subghz/presets") assert response.status_code == 200 data = response.get_json() - assert 'presets' in data - assert '433.92 MHz' in data['presets'] - assert 'sample_rates' in data + assert "presets" in data + assert "433.92 MHz" in data["presets"] + assert "sample_rates" in data # ------ RECEIVE ------ def test_start_receive_success(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: + with patch("routes.subghz.get_subghz_manager") as mock_get: mock_mgr = MagicMock() mock_mgr.start_receive.return_value = { - 'status': 'started', - 'frequency_hz': 433920000, - 'sample_rate': 2000000, + "status": "started", + "frequency_hz": 433920000, + "sample_rate": 2000000, } mock_get.return_value = mock_mgr - response = auth_client.post('/subghz/receive/start', json={ - 'frequency_hz': 433920000, - 'sample_rate': 2000000, - 'lna_gain': 32, - 'vga_gain': 20, - }) + response = auth_client.post( + "/subghz/receive/start", + json={ + "frequency_hz": 433920000, + "sample_rate": 2000000, + "lna_gain": 32, + "vga_gain": 20, + }, + ) assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'started' + assert data["status"] == "started" def test_start_receive_missing_frequency(self, client, auth_client): - response = auth_client.post('/subghz/receive/start', json={}) + response = auth_client.post("/subghz/receive/start", json={}) assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' + assert data["status"] == "error" def test_start_receive_invalid_frequency(self, client, auth_client): - response = auth_client.post('/subghz/receive/start', json={ - 'frequency_hz': 'not_a_number', - }) + response = auth_client.post( + "/subghz/receive/start", + json={ + "frequency_hz": "not_a_number", + }, + ) assert response.status_code == 400 - def test_stop_receive(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: - mock_mgr = MagicMock() - mock_mgr.stop_receive.return_value = {'status': 'stopped'} - mock_get.return_value = mock_mgr - - response = auth_client.post('/subghz/receive/stop') - assert response.status_code == 200 - - def test_start_receive_trigger_params(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: - mock_mgr = MagicMock() - mock_mgr.start_receive.return_value = {'status': 'started'} - mock_get.return_value = mock_mgr - - response = auth_client.post('/subghz/receive/start', json={ - 'frequency_hz': 433920000, - 'trigger_enabled': True, - 'trigger_pre_ms': 400, - 'trigger_post_ms': 900, - }) - assert response.status_code == 200 - kwargs = mock_mgr.start_receive.call_args.kwargs - assert kwargs['trigger_enabled'] is True - assert kwargs['trigger_pre_ms'] == 400 - assert kwargs['trigger_post_ms'] == 900 + def test_stop_receive(self, client, auth_client): + with patch("routes.subghz.get_subghz_manager") as mock_get: + mock_mgr = MagicMock() + mock_mgr.stop_receive.return_value = {"status": "stopped"} + mock_get.return_value = mock_mgr + + response = auth_client.post("/subghz/receive/stop") + assert response.status_code == 200 + + def test_start_receive_trigger_params(self, client, auth_client): + with patch("routes.subghz.get_subghz_manager") as mock_get: + mock_mgr = MagicMock() + mock_mgr.start_receive.return_value = {"status": "started"} + mock_get.return_value = mock_mgr + + response = auth_client.post( + "/subghz/receive/start", + json={ + "frequency_hz": 433920000, + "trigger_enabled": True, + "trigger_pre_ms": 400, + "trigger_post_ms": 900, + }, + ) + assert response.status_code == 200 + kwargs = mock_mgr.start_receive.call_args.kwargs + assert kwargs["trigger_enabled"] is True + assert kwargs["trigger_pre_ms"] == 400 + assert kwargs["trigger_post_ms"] == 900 # ------ DECODE ------ - def test_start_decode_success(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: - mock_mgr = MagicMock() - mock_mgr.start_decode.return_value = { - 'status': 'started', - 'frequency_hz': 433920000, + def test_start_decode_success(self, client, auth_client): + with patch("routes.subghz.get_subghz_manager") as mock_get: + mock_mgr = MagicMock() + mock_mgr.start_decode.return_value = { + "status": "started", + "frequency_hz": 433920000, } mock_get.return_value = mock_mgr - response = auth_client.post('/subghz/decode/start', json={ - 'frequency_hz': 433920000, - }) + response = auth_client.post( + "/subghz/decode/start", + json={ + "frequency_hz": 433920000, + }, + ) assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'started' - mock_mgr.start_decode.assert_called_once() - kwargs = mock_mgr.start_decode.call_args.kwargs - assert kwargs['decode_profile'] == 'weather' - - def test_start_decode_profile_all(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: - mock_mgr = MagicMock() - mock_mgr.start_decode.return_value = { - 'status': 'started', - 'frequency_hz': 433920000, - } - mock_get.return_value = mock_mgr - - response = auth_client.post('/subghz/decode/start', json={ - 'frequency_hz': 433920000, - 'decode_profile': 'all', - }) - assert response.status_code == 200 - kwargs = mock_mgr.start_decode.call_args.kwargs - assert kwargs['decode_profile'] == 'all' + data = response.get_json() + assert data["status"] == "started" + mock_mgr.start_decode.assert_called_once() + kwargs = mock_mgr.start_decode.call_args.kwargs + assert kwargs["decode_profile"] == "weather" + + def test_start_decode_profile_all(self, client, auth_client): + with patch("routes.subghz.get_subghz_manager") as mock_get: + mock_mgr = MagicMock() + mock_mgr.start_decode.return_value = { + "status": "started", + "frequency_hz": 433920000, + } + mock_get.return_value = mock_mgr + + response = auth_client.post( + "/subghz/decode/start", + json={ + "frequency_hz": 433920000, + "decode_profile": "all", + }, + ) + assert response.status_code == 200 + kwargs = mock_mgr.start_decode.call_args.kwargs + assert kwargs["decode_profile"] == "all" def test_start_decode_missing_freq(self, client, auth_client): - response = auth_client.post('/subghz/decode/start', json={}) + response = auth_client.post("/subghz/decode/start", json={}) assert response.status_code == 400 def test_stop_decode(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: + with patch("routes.subghz.get_subghz_manager") as mock_get: mock_mgr = MagicMock() - mock_mgr.stop_decode.return_value = {'status': 'stopped'} + mock_mgr.stop_decode.return_value = {"status": "stopped"} mock_get.return_value = mock_mgr - response = auth_client.post('/subghz/decode/stop') + response = auth_client.post("/subghz/decode/stop") assert response.status_code == 200 # ------ TRANSMIT ------ def test_transmit_missing_capture_id(self, client, auth_client): - response = auth_client.post('/subghz/transmit', json={}) + response = auth_client.post("/subghz/transmit", json={}) assert response.status_code == 400 data = response.get_json() - assert 'capture_id is required' in data['message'] + assert "capture_id is required" in data["message"] def test_transmit_invalid_capture_id(self, client, auth_client): - response = auth_client.post('/subghz/transmit', json={ - 'capture_id': '../../../etc/passwd', - }) + response = auth_client.post( + "/subghz/transmit", + json={ + "capture_id": "../../../etc/passwd", + }, + ) assert response.status_code == 400 data = response.get_json() - assert 'Invalid' in data['message'] + assert "Invalid" in data["message"] - def test_transmit_success(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: - mock_mgr = MagicMock() - mock_mgr.transmit.return_value = { - 'status': 'transmitting', - 'capture_id': 'abc123', - 'frequency_hz': 433920000, - 'max_duration': 10, + def test_transmit_success(self, client, auth_client): + with patch("routes.subghz.get_subghz_manager") as mock_get: + mock_mgr = MagicMock() + mock_mgr.transmit.return_value = { + "status": "transmitting", + "capture_id": "abc123", + "frequency_hz": 433920000, + "max_duration": 10, } mock_get.return_value = mock_mgr - response = auth_client.post('/subghz/transmit', json={ - 'capture_id': 'abc123', - 'tx_gain': 20, - 'max_duration': 10, - }) + response = auth_client.post( + "/subghz/transmit", + json={ + "capture_id": "abc123", + "tx_gain": 20, + "max_duration": 10, + }, + ) assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'transmitting' - kwargs = mock_mgr.transmit.call_args.kwargs - assert kwargs['start_seconds'] is None - assert kwargs['duration_seconds'] is None - - def test_transmit_segment_params(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: - mock_mgr = MagicMock() - mock_mgr.transmit.return_value = { - 'status': 'transmitting', - 'capture_id': 'abc123', - 'frequency_hz': 433920000, - 'max_duration': 10, - 'segment': {'start_seconds': 0.1, 'duration_seconds': 0.4}, - } - mock_get.return_value = mock_mgr - - response = auth_client.post('/subghz/transmit', json={ - 'capture_id': 'abc123', - 'tx_gain': 20, - 'max_duration': 10, - 'start_seconds': 0.1, - 'duration_seconds': 0.4, - }) - assert response.status_code == 200 - kwargs = mock_mgr.transmit.call_args.kwargs - assert kwargs['start_seconds'] == 0.1 - assert kwargs['duration_seconds'] == 0.4 - - def test_transmit_invalid_segment_param(self, client, auth_client): - response = auth_client.post('/subghz/transmit', json={ - 'capture_id': 'abc123', - 'start_seconds': 'not-a-number', - }) - assert response.status_code == 400 + data = response.get_json() + assert data["status"] == "transmitting" + kwargs = mock_mgr.transmit.call_args.kwargs + assert kwargs["start_seconds"] is None + assert kwargs["duration_seconds"] is None - def test_stop_transmit(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: + def test_transmit_segment_params(self, client, auth_client): + with patch("routes.subghz.get_subghz_manager") as mock_get: mock_mgr = MagicMock() - mock_mgr.stop_transmit.return_value = {'status': 'stopped'} + mock_mgr.transmit.return_value = { + "status": "transmitting", + "capture_id": "abc123", + "frequency_hz": 433920000, + "max_duration": 10, + "segment": {"start_seconds": 0.1, "duration_seconds": 0.4}, + } mock_get.return_value = mock_mgr - response = auth_client.post('/subghz/transmit/stop') + response = auth_client.post( + "/subghz/transmit", + json={ + "capture_id": "abc123", + "tx_gain": 20, + "max_duration": 10, + "start_seconds": 0.1, + "duration_seconds": 0.4, + }, + ) + assert response.status_code == 200 + kwargs = mock_mgr.transmit.call_args.kwargs + assert kwargs["start_seconds"] == 0.1 + assert kwargs["duration_seconds"] == 0.4 + + def test_transmit_invalid_segment_param(self, client, auth_client): + response = auth_client.post( + "/subghz/transmit", + json={ + "capture_id": "abc123", + "start_seconds": "not-a-number", + }, + ) + assert response.status_code == 400 + + def test_stop_transmit(self, client, auth_client): + with patch("routes.subghz.get_subghz_manager") as mock_get: + mock_mgr = MagicMock() + mock_mgr.stop_transmit.return_value = {"status": "stopped"} + mock_get.return_value = mock_mgr + + response = auth_client.post("/subghz/transmit/stop") assert response.status_code == 200 # ------ SWEEP ------ def test_start_sweep_success(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: + with patch("routes.subghz.get_subghz_manager") as mock_get: mock_mgr = MagicMock() mock_mgr.start_sweep.return_value = { - 'status': 'started', - 'freq_start_mhz': 300, - 'freq_end_mhz': 928, + "status": "started", + "freq_start_mhz": 300, + "freq_end_mhz": 928, } mock_get.return_value = mock_mgr - response = auth_client.post('/subghz/sweep/start', json={ - 'freq_start_mhz': 300, - 'freq_end_mhz': 928, - }) + response = auth_client.post( + "/subghz/sweep/start", + json={ + "freq_start_mhz": 300, + "freq_end_mhz": 928, + }, + ) assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'started' + assert data["status"] == "started" def test_start_sweep_invalid_range(self, client, auth_client): - response = auth_client.post('/subghz/sweep/start', json={ - 'freq_start_mhz': 928, - 'freq_end_mhz': 300, # start > end - }) + response = auth_client.post( + "/subghz/sweep/start", + json={ + "freq_start_mhz": 928, + "freq_end_mhz": 300, # start > end + }, + ) assert response.status_code == 400 def test_stop_sweep(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: + with patch("routes.subghz.get_subghz_manager") as mock_get: mock_mgr = MagicMock() - mock_mgr.stop_sweep.return_value = {'status': 'stopped'} + mock_mgr.stop_sweep.return_value = {"status": "stopped"} mock_get.return_value = mock_mgr - response = auth_client.post('/subghz/sweep/stop') + response = auth_client.post("/subghz/sweep/stop") assert response.status_code == 200 # ------ CAPTURES ------ def test_list_captures_empty(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: + with patch("routes.subghz.get_subghz_manager") as mock_get: mock_mgr = MagicMock() mock_mgr.list_captures.return_value = [] mock_get.return_value = mock_mgr - response = auth_client.get('/subghz/captures') + response = auth_client.get("/subghz/captures") assert response.status_code == 200 data = response.get_json() - assert data['count'] == 0 - assert data['captures'] == [] + assert data["count"] == 0 + assert data["captures"] == [] def test_list_captures_with_data(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: + with patch("routes.subghz.get_subghz_manager") as mock_get: mock_mgr = MagicMock() cap = SubGhzCapture( - capture_id='cap1', - filename='test.iq', + capture_id="cap1", + filename="test.iq", frequency_hz=433920000, sample_rate=2000000, lna_gain=32, vga_gain=20, - timestamp='2026-01-01T00:00:00Z', + timestamp="2026-01-01T00:00:00Z", ) mock_mgr.list_captures.return_value = [cap] mock_get.return_value = mock_mgr - response = auth_client.get('/subghz/captures') + response = auth_client.get("/subghz/captures") assert response.status_code == 200 data = response.get_json() - assert data['count'] == 1 - assert data['captures'][0]['id'] == 'cap1' + assert data["count"] == 1 + assert data["captures"][0]["id"] == "cap1" def test_get_capture(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: + with patch("routes.subghz.get_subghz_manager") as mock_get: mock_mgr = MagicMock() cap = SubGhzCapture( - capture_id='cap2', - filename='test2.iq', + capture_id="cap2", + filename="test2.iq", frequency_hz=315000000, sample_rate=2000000, lna_gain=32, vga_gain=20, - timestamp='2026-01-01T00:00:00Z', + timestamp="2026-01-01T00:00:00Z", ) mock_mgr.get_capture.return_value = cap mock_get.return_value = mock_mgr - response = auth_client.get('/subghz/captures/cap2') + response = auth_client.get("/subghz/captures/cap2") assert response.status_code == 200 data = response.get_json() - assert data['capture']['frequency_hz'] == 315000000 + assert data["capture"]["frequency_hz"] == 315000000 def test_get_capture_not_found(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: + with patch("routes.subghz.get_subghz_manager") as mock_get: mock_mgr = MagicMock() mock_mgr.get_capture.return_value = None mock_get.return_value = mock_mgr - response = auth_client.get('/subghz/captures/nonexistent') + response = auth_client.get("/subghz/captures/nonexistent") assert response.status_code == 404 def test_get_capture_invalid_id(self, client, auth_client): - response = auth_client.get('/subghz/captures/bad-id!') + response = auth_client.get("/subghz/captures/bad-id!") assert response.status_code == 400 - def test_delete_capture(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: - mock_mgr = MagicMock() - mock_mgr.delete_capture.return_value = True + def test_delete_capture(self, client, auth_client): + with patch("routes.subghz.get_subghz_manager") as mock_get: + mock_mgr = MagicMock() + mock_mgr.delete_capture.return_value = True mock_get.return_value = mock_mgr - response = auth_client.delete('/subghz/captures/cap1') + response = auth_client.delete("/subghz/captures/cap1") assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'deleted' - - def test_trim_capture_success(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: - mock_mgr = MagicMock() - mock_mgr.trim_capture.return_value = { - 'status': 'ok', - 'capture': { - 'id': 'trim_new', - 'filename': 'trimmed.iq', - 'frequency_hz': 433920000, - 'sample_rate': 2000000, - }, - } - mock_get.return_value = mock_mgr - - response = auth_client.post('/subghz/captures/cap1/trim', json={ - 'start_seconds': 0.1, - 'duration_seconds': 0.3, - }) - assert response.status_code == 200 - kwargs = mock_mgr.trim_capture.call_args.kwargs - assert kwargs['capture_id'] == 'cap1' - assert kwargs['start_seconds'] == 0.1 - assert kwargs['duration_seconds'] == 0.3 - - def test_trim_capture_invalid_param(self, client, auth_client): - response = auth_client.post('/subghz/captures/cap1/trim', json={ - 'start_seconds': 'bad', - }) - assert response.status_code == 400 - - def test_delete_capture_not_found(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: - mock_mgr = MagicMock() - mock_mgr.delete_capture.return_value = False + data = response.get_json() + assert data["status"] == "deleted" + + def test_trim_capture_success(self, client, auth_client): + with patch("routes.subghz.get_subghz_manager") as mock_get: + mock_mgr = MagicMock() + mock_mgr.trim_capture.return_value = { + "status": "ok", + "capture": { + "id": "trim_new", + "filename": "trimmed.iq", + "frequency_hz": 433920000, + "sample_rate": 2000000, + }, + } mock_get.return_value = mock_mgr - response = auth_client.delete('/subghz/captures/nonexistent') + response = auth_client.post( + "/subghz/captures/cap1/trim", + json={ + "start_seconds": 0.1, + "duration_seconds": 0.3, + }, + ) + assert response.status_code == 200 + kwargs = mock_mgr.trim_capture.call_args.kwargs + assert kwargs["capture_id"] == "cap1" + assert kwargs["start_seconds"] == 0.1 + assert kwargs["duration_seconds"] == 0.3 + + def test_trim_capture_invalid_param(self, client, auth_client): + response = auth_client.post( + "/subghz/captures/cap1/trim", + json={ + "start_seconds": "bad", + }, + ) + assert response.status_code == 400 + + def test_delete_capture_not_found(self, client, auth_client): + with patch("routes.subghz.get_subghz_manager") as mock_get: + mock_mgr = MagicMock() + mock_mgr.delete_capture.return_value = False + mock_get.return_value = mock_mgr + + response = auth_client.delete("/subghz/captures/nonexistent") assert response.status_code == 404 def test_update_capture_label(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: + with patch("routes.subghz.get_subghz_manager") as mock_get: mock_mgr = MagicMock() mock_mgr.update_capture_label.return_value = True mock_get.return_value = mock_mgr - response = auth_client.patch('/subghz/captures/cap1', json={ - 'label': 'Garage Remote', - }) + response = auth_client.patch( + "/subghz/captures/cap1", + json={ + "label": "Garage Remote", + }, + ) assert response.status_code == 200 data = response.get_json() - assert data['label'] == 'Garage Remote' + assert data["label"] == "Garage Remote" def test_update_capture_label_too_long(self, client, auth_client): - response = auth_client.patch('/subghz/captures/cap1', json={ - 'label': 'x' * 200, - }) + response = auth_client.patch( + "/subghz/captures/cap1", + json={ + "label": "x" * 200, + }, + ) assert response.status_code == 400 def test_update_capture_not_found(self, client, auth_client): - with patch('routes.subghz.get_subghz_manager') as mock_get: + with patch("routes.subghz.get_subghz_manager") as mock_get: mock_mgr = MagicMock() mock_mgr.update_capture_label.return_value = False mock_get.return_value = mock_mgr - response = auth_client.patch('/subghz/captures/nonexistent', json={ - 'label': 'test', - }) + response = auth_client.patch( + "/subghz/captures/nonexistent", + json={ + "label": "test", + }, + ) assert response.status_code == 404 # ------ SSE STREAM ------ def test_stream_endpoint(self, client, auth_client): """GET /subghz/stream returns SSE response.""" - with patch('routes.subghz.sse_stream', return_value=iter([])): - response = auth_client.get('/subghz/stream') + with patch("routes.subghz.sse_stream", return_value=iter([])): + response = auth_client.get("/subghz/stream") assert response.status_code == 200 - assert response.content_type.startswith('text/event-stream') + assert response.content_type.startswith("text/event-stream") diff --git a/tests/test_waterfall_fft.py b/tests/test_waterfall_fft.py index 722569e..055a03d 100644 --- a/tests/test_waterfall_fft.py +++ b/tests/test_waterfall_fft.py @@ -130,8 +130,8 @@ class TestBuildBinaryFrame: bins = bytes([128] * 1024) frame = build_binary_frame(100.0, 102.0, bins) msg_type = frame[0] - start_freq, end_freq = struct.unpack_from(' None: """Mark the Flask test session as authenticated.""" with client.session_transaction() as sess: - sess['logged_in'] = True - sess['username'] = 'test' - sess['role'] = 'admin' + sess["logged_in"] = True + sess["username"] = "test" + sess["role"] = "admin" # --------------------------------------------------------------------------- # Station database tests # --------------------------------------------------------------------------- + class TestWeFaxStations: """WeFax station database tests.""" def test_load_stations_returns_list(self): """load_stations() should return a non-empty list.""" from utils.wefax_stations import load_stations + stations = load_stations() assert isinstance(stations, list) assert len(stations) >= 10 @@ -35,8 +37,8 @@ class TestWeFaxStations: def test_station_has_required_fields(self): """Each station must have required fields.""" from utils.wefax_stations import load_stations - required = {'name', 'callsign', 'country', 'city', 'coordinates', - 'frequencies', 'ioc', 'lpm', 'schedule'} + + required = {"name", "callsign", "country", "city", "coordinates", "frequencies", "ioc", "lpm", "schedule"} for station in load_stations(): missing = required - set(station.keys()) assert not missing, f"Station {station.get('callsign', '?')} missing: {missing}" @@ -44,20 +46,23 @@ class TestWeFaxStations: def test_get_station_by_callsign(self): """get_station() should return correct station.""" from utils.wefax_stations import get_station - station = get_station('NOJ') + + station = get_station("NOJ") assert station is not None - assert station['callsign'] == 'NOJ' - assert station['country'] == 'US' + assert station["callsign"] == "NOJ" + assert station["country"] == "US" def test_get_station_case_insensitive(self): """get_station() should be case-insensitive.""" from utils.wefax_stations import get_station - assert get_station('noj') is not None + + assert get_station("noj") is not None def test_get_station_not_found(self): """get_station() should return None for unknown callsign.""" from utils.wefax_stations import get_station - assert get_station('XXXXX') is None + + assert get_station("XXXXX") is None def test_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self): """Known station frequencies default to carrier-list behavior in auto mode.""" @@ -65,12 +70,12 @@ class TestWeFaxStations: tuned, reference, offset_applied = resolve_tuning_frequency_khz( listed_frequency_khz=4298.0, - station_callsign='NOJ', - frequency_reference='auto', + station_callsign="NOJ", + frequency_reference="auto", ) assert math.isclose(tuned, 4296.1, abs_tol=1e-6) - assert reference == 'carrier' + assert reference == "carrier" assert offset_applied is True def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self): @@ -79,12 +84,12 @@ class TestWeFaxStations: tuned, reference, offset_applied = resolve_tuning_frequency_khz( listed_frequency_khz=4298.0, - station_callsign='', - frequency_reference='auto', + station_callsign="", + frequency_reference="auto", ) assert math.isclose(tuned, 4298.0, abs_tol=1e-6) - assert reference == 'dial' + assert reference == "dial" assert offset_applied is False def test_resolve_tuning_frequency_dial_override(self): @@ -93,12 +98,12 @@ class TestWeFaxStations: tuned, reference, offset_applied = resolve_tuning_frequency_khz( listed_frequency_khz=4298.0, - station_callsign='NOJ', - frequency_reference='dial', + station_callsign="NOJ", + frequency_reference="dial", ) assert math.isclose(tuned, 4298.0, abs_tol=1e-6) - assert reference == 'dial' + assert reference == "dial" assert offset_applied is False def test_resolve_tuning_frequency_rejects_invalid_reference(self): @@ -108,33 +113,35 @@ class TestWeFaxStations: try: resolve_tuning_frequency_khz( listed_frequency_khz=4298.0, - station_callsign='NOJ', - frequency_reference='invalid', + station_callsign="NOJ", + frequency_reference="invalid", ) raise AssertionError("Expected ValueError for invalid frequency_reference") except ValueError as exc: - assert 'frequency_reference' in str(exc) + assert "frequency_reference" in str(exc) def test_station_frequencies_have_khz(self): """Each frequency entry must have 'khz' and 'description'.""" from utils.wefax_stations import load_stations + for station in load_stations(): - for freq in station['frequencies']: - assert 'khz' in freq, f"{station['callsign']} missing khz" - assert 'description' in freq, f"{station['callsign']} missing description" - assert isinstance(freq['khz'], (int, float)) - assert freq['khz'] > 0 + for freq in station["frequencies"]: + assert "khz" in freq, f"{station['callsign']} missing khz" + assert "description" in freq, f"{station['callsign']} missing description" + assert isinstance(freq["khz"], (int, float)) + assert freq["khz"] > 0 def test_schedule_format(self): """Schedule entries must have utc, duration_min, content.""" from utils.wefax_stations import load_stations + for station in load_stations(): - for entry in station['schedule']: - assert 'utc' in entry - assert 'duration_min' in entry - assert 'content' in entry + for entry in station["schedule"]: + assert "utc" in entry + assert "duration_min" in entry + assert "content" in entry # UTC format: HH:MM - parts = entry['utc'].split(':') + parts = entry["utc"].split(":") assert len(parts) == 2 assert 0 <= int(parts[0]) <= 23 assert 0 <= int(parts[1]) <= 59 @@ -142,45 +149,52 @@ class TestWeFaxStations: def test_get_current_broadcasts(self): """get_current_broadcasts() should return up to 3 entries.""" from utils.wefax_stations import get_current_broadcasts - broadcasts = get_current_broadcasts('NOJ') + + broadcasts = get_current_broadcasts("NOJ") assert isinstance(broadcasts, list) assert len(broadcasts) <= 3 for b in broadcasts: - assert 'utc' in b - assert 'content' in b + assert "utc" in b + assert "content" in b # --------------------------------------------------------------------------- # Decoder unit tests # --------------------------------------------------------------------------- + class TestWeFaxDecoder: """WeFax decoder DSP and data class tests.""" def test_freq_to_pixel_black(self): """1500 Hz should map to 0 (black).""" from utils.wefax import _freq_to_pixel + assert _freq_to_pixel(1500.0) == 0 def test_freq_to_pixel_white(self): """2300 Hz should map to 255 (white).""" from utils.wefax import _freq_to_pixel + assert _freq_to_pixel(2300.0) == 255 def test_freq_to_pixel_mid(self): """1900 Hz (carrier) should map to ~128.""" from utils.wefax import _freq_to_pixel + val = _freq_to_pixel(1900.0) assert 120 <= val <= 135 def test_freq_to_pixel_clamp_low(self): """Below 1500 Hz should clamp to 0.""" from utils.wefax import _freq_to_pixel + assert _freq_to_pixel(1000.0) == 0 def test_freq_to_pixel_clamp_high(self): """Above 2300 Hz should clamp to 255.""" from utils.wefax import _freq_to_pixel + assert _freq_to_pixel(3000.0) == 255 def test_ioc_576_pixel_count(self): @@ -196,6 +210,7 @@ class TestWeFaxDecoder: def test_goertzel_mag_detects_tone(self): """Goertzel should detect a pure tone.""" from utils.wefax import _goertzel_mag + sr = 22050 freq = 1900.0 t = np.arange(sr) / sr @@ -207,6 +222,7 @@ class TestWeFaxDecoder: def test_goertzel_mag_rejects_wrong_freq(self): """Goertzel should be much weaker for non-matching frequency.""" from utils.wefax import _goertzel_mag + sr = 22050 t = np.arange(sr) / sr samples = np.sin(2 * np.pi * 1900.0 * t) @@ -217,6 +233,7 @@ class TestWeFaxDecoder: def test_detect_tone_start(self): """detect_tone should identify a 300 Hz start tone.""" from utils.wefax import _detect_tone + sr = 22050 t = np.arange(sr) / sr samples = np.sin(2 * np.pi * 300.0 * t) @@ -227,10 +244,11 @@ class TestWeFaxDecoder: from datetime import datetime, timezone from utils.wefax import WeFaxImage + img = WeFaxImage( - filename='test.png', - path=Path('/tmp/test.png'), - station='NOJ', + filename="test.png", + path=Path("/tmp/test.png"), + station="NOJ", frequency_khz=4298, timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc), ioc=576, @@ -238,36 +256,39 @@ class TestWeFaxDecoder: size_bytes=1234, ) d = img.to_dict() - assert d['filename'] == 'test.png' - assert d['station'] == 'NOJ' - assert d['frequency_khz'] == 4298 - assert d['ioc'] == 576 - assert d['url'] == '/wefax/images/test.png' + assert d["filename"] == "test.png" + assert d["station"] == "NOJ" + assert d["frequency_khz"] == 4298 + assert d["ioc"] == 576 + assert d["url"] == "/wefax/images/test.png" def test_wefax_progress_to_dict(self): """WeFaxProgress.to_dict() should produce expected format.""" from utils.wefax import WeFaxProgress + p = WeFaxProgress( - status='receiving', - station='NOJ', - message='Receiving: 100 lines', + status="receiving", + station="NOJ", + message="Receiving: 100 lines", progress_percent=50, line_count=100, ) d = p.to_dict() - assert d['type'] == 'wefax_progress' - assert d['status'] == 'receiving' - assert d['progress'] == 50 - assert d['station'] == 'NOJ' - assert d['line_count'] == 100 + assert d["type"] == "wefax_progress" + assert d["status"] == "receiving" + assert d["progress"] == 50 + assert d["station"] == "NOJ" + assert d["line_count"] == 100 def test_singleton_returns_same_instance(self, tmp_path): """get_wefax_decoder() should return a singleton.""" from utils.wefax import WeFaxDecoder + # Use __new__ to avoid __init__ creating dirs d1 = WeFaxDecoder.__new__(WeFaxDecoder) # Test the module-level singleton pattern import utils.wefax as wefax_mod + original = wefax_mod._decoder try: wefax_mod._decoder = d1 @@ -281,6 +302,7 @@ class TestWeFaxDecoder: # Route tests # --------------------------------------------------------------------------- + class TestWeFaxRoutes: """WeFax route endpoint tests.""" @@ -291,37 +313,37 @@ class TestWeFaxRoutes: mock_decoder.is_running = False mock_decoder.get_images.return_value = [] - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): - response = client.get('/wefax/status') + with patch("routes.wefax.get_wefax_decoder", return_value=mock_decoder): + response = client.get("/wefax/status") assert response.status_code == 200 data = response.get_json() - assert data['available'] is True - assert data['running'] is False + assert data["available"] is True + assert data["running"] is False def test_stations_list(self, client): """GET /wefax/stations should return station list.""" _login_session(client) - response = client.get('/wefax/stations') + response = client.get("/wefax/stations") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'ok' - assert data['count'] >= 10 + assert data["status"] == "ok" + assert data["count"] >= 10 def test_station_detail(self, client): """GET /wefax/stations/NOJ should return station detail.""" _login_session(client) - response = client.get('/wefax/stations/NOJ') + response = client.get("/wefax/stations/NOJ") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'ok' - assert data['station']['callsign'] == 'NOJ' - assert 'current_broadcasts' in data + assert data["status"] == "ok" + assert data["station"]["callsign"] == "NOJ" + assert "current_broadcasts" in data def test_station_not_found(self, client): """GET /wefax/stations/XXXXX should return 404.""" _login_session(client) - response = client.get('/wefax/stations/XXXXX') + response = client.get("/wefax/stations/XXXXX") assert response.status_code == 404 def test_start_requires_frequency(self, client): @@ -330,16 +352,16 @@ class TestWeFaxRoutes: mock_decoder = MagicMock() mock_decoder.is_running = False - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + with patch("routes.wefax.get_wefax_decoder", return_value=mock_decoder): response = client.post( - '/wefax/start', + "/wefax/start", data=json.dumps({}), - content_type='application/json', + content_type="application/json", ) assert response.status_code == 400 data = response.get_json() - assert data['status'] == 'error' + assert data["status"] == "error" def test_start_validates_frequency_range(self, client): """POST /wefax/start with out-of-range frequency should fail.""" @@ -347,11 +369,11 @@ class TestWeFaxRoutes: mock_decoder = MagicMock() mock_decoder.is_running = False - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + with patch("routes.wefax.get_wefax_decoder", return_value=mock_decoder): response = client.post( - '/wefax/start', - data=json.dumps({'frequency_khz': 100}), # 0.1 MHz - too low - content_type='application/json', + "/wefax/start", + data=json.dumps({"frequency_khz": 100}), # 0.1 MHz - too low + content_type="application/json", ) assert response.status_code == 400 @@ -362,16 +384,16 @@ class TestWeFaxRoutes: mock_decoder = MagicMock() mock_decoder.is_running = False - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + with patch("routes.wefax.get_wefax_decoder", return_value=mock_decoder): response = client.post( - '/wefax/start', - data=json.dumps({'frequency_khz': 4298, 'ioc': 999}), - content_type='application/json', + "/wefax/start", + data=json.dumps({"frequency_khz": 4298, "ioc": 999}), + content_type="application/json", ) assert response.status_code == 400 data = response.get_json() - assert 'IOC' in data['message'] + assert "IOC" in data["message"] def test_start_validates_lpm(self, client): """POST /wefax/start with invalid LPM should fail.""" @@ -379,16 +401,16 @@ class TestWeFaxRoutes: mock_decoder = MagicMock() mock_decoder.is_running = False - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + with patch("routes.wefax.get_wefax_decoder", return_value=mock_decoder): response = client.post( - '/wefax/start', - data=json.dumps({'frequency_khz': 4298, 'lpm': 999}), - content_type='application/json', + "/wefax/start", + data=json.dumps({"frequency_khz": 4298, "lpm": 999}), + content_type="application/json", ) assert response.status_code == 400 data = response.get_json() - assert 'LPM' in data['message'] + assert "LPM" in data["message"] def test_start_success(self, client): """POST /wefax/start with valid params should succeed.""" @@ -397,31 +419,35 @@ class TestWeFaxRoutes: mock_decoder.is_running = False mock_decoder.start.return_value = True - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ - patch('routes.wefax.app_module.claim_sdr_device', return_value=None): + with ( + patch("routes.wefax.get_wefax_decoder", return_value=mock_decoder), + patch("routes.wefax.app_module.claim_sdr_device", return_value=None), + ): response = client.post( - '/wefax/start', - data=json.dumps({ - 'frequency_khz': 4298, - 'station': 'NOJ', - 'device': 0, - 'ioc': 576, - 'lpm': 120, - }), - content_type='application/json', + "/wefax/start", + data=json.dumps( + { + "frequency_khz": 4298, + "station": "NOJ", + "device": 0, + "ioc": 576, + "lpm": 120, + } + ), + content_type="application/json", ) assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'started' - assert data['frequency_khz'] == 4298 - assert data['usb_offset_applied'] is True - assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6) - assert data['frequency_reference'] == 'carrier' - assert data['station'] == 'NOJ' + assert data["status"] == "started" + assert data["frequency_khz"] == 4298 + assert data["usb_offset_applied"] is True + assert math.isclose(data["tuned_frequency_khz"], 4296.1, abs_tol=1e-6) + assert data["frequency_reference"] == "carrier" + assert data["station"] == "NOJ" mock_decoder.start.assert_called_once() start_kwargs = mock_decoder.start.call_args.kwargs - assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6) + assert math.isclose(start_kwargs["frequency_khz"], 4296.1, abs_tol=1e-6) def test_start_respects_dial_reference_override(self, client): """POST /wefax/start with dial reference should not apply USB offset.""" @@ -430,27 +456,31 @@ class TestWeFaxRoutes: mock_decoder.is_running = False mock_decoder.start.return_value = True - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ - patch('routes.wefax.app_module.claim_sdr_device', return_value=None): + with ( + patch("routes.wefax.get_wefax_decoder", return_value=mock_decoder), + patch("routes.wefax.app_module.claim_sdr_device", return_value=None), + ): response = client.post( - '/wefax/start', - data=json.dumps({ - 'frequency_khz': 4298, - 'station': 'NOJ', - 'device': 0, - 'frequency_reference': 'dial', - }), - content_type='application/json', + "/wefax/start", + data=json.dumps( + { + "frequency_khz": 4298, + "station": "NOJ", + "device": 0, + "frequency_reference": "dial", + } + ), + content_type="application/json", ) assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'started' - assert data['usb_offset_applied'] is False - assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6) - assert data['frequency_reference'] == 'dial' + assert data["status"] == "started" + assert data["usb_offset_applied"] is False + assert math.isclose(data["tuned_frequency_khz"], 4298.0, abs_tol=1e-6) + assert data["frequency_reference"] == "dial" start_kwargs = mock_decoder.start.call_args.kwargs - assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6) + assert math.isclose(start_kwargs["frequency_khz"], 4298.0, abs_tol=1e-6) def test_start_device_busy(self, client): """POST /wefax/start should return 409 when device is busy.""" @@ -458,30 +488,31 @@ class TestWeFaxRoutes: mock_decoder = MagicMock() mock_decoder.is_running = False - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ - patch('routes.wefax.app_module.claim_sdr_device', - return_value='Device 0 in use by pager'): + with ( + patch("routes.wefax.get_wefax_decoder", return_value=mock_decoder), + patch("routes.wefax.app_module.claim_sdr_device", return_value="Device 0 in use by pager"), + ): response = client.post( - '/wefax/start', - data=json.dumps({'frequency_khz': 4298}), - content_type='application/json', + "/wefax/start", + data=json.dumps({"frequency_khz": 4298}), + content_type="application/json", ) assert response.status_code == 409 data = response.get_json() - assert data['error_type'] == 'DEVICE_BUSY' + assert data["error_type"] == "DEVICE_BUSY" def test_stop(self, client): """POST /wefax/stop should stop the decoder.""" _login_session(client) mock_decoder = MagicMock() - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): - response = client.post('/wefax/stop') + with patch("routes.wefax.get_wefax_decoder", return_value=mock_decoder): + response = client.post("/wefax/stop") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'stopped' + assert data["status"] == "stopped" mock_decoder.stop.assert_called_once() def test_images_list(self, client): @@ -490,22 +521,22 @@ class TestWeFaxRoutes: mock_decoder = MagicMock() mock_decoder.get_images.return_value = [] - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): - response = client.get('/wefax/images') + with patch("routes.wefax.get_wefax_decoder", return_value=mock_decoder): + response = client.get("/wefax/images") assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'ok' - assert data['count'] == 0 + assert data["status"] == "ok" + assert data["count"] == 0 def test_delete_image_invalid_filename(self, client): """DELETE /wefax/images/ should reject invalid filenames.""" _login_session(client) mock_decoder = MagicMock() - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + with patch("routes.wefax.get_wefax_decoder", return_value=mock_decoder): # Use a filename with special chars that won't be split by Flask routing - response = client.delete('/wefax/images/te$t!file.png') + response = client.delete("/wefax/images/te$t!file.png") assert response.status_code == 400 @@ -514,8 +545,8 @@ class TestWeFaxRoutes: _login_session(client) mock_decoder = MagicMock() - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): - response = client.delete('/wefax/images/test.jpg') + with patch("routes.wefax.get_wefax_decoder", return_value=mock_decoder): + response = client.delete("/wefax/images/test.jpg") assert response.status_code == 400 @@ -524,29 +555,31 @@ class TestWeFaxRoutes: _login_session(client) mock_scheduler = MagicMock() mock_scheduler.enable.return_value = { - 'enabled': True, - 'scheduled_count': 2, - 'total_broadcasts': 2, + "enabled": True, + "scheduled_count": 2, + "total_broadcasts": 2, } - with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler): + with patch("utils.wefax_scheduler.get_wefax_scheduler", return_value=mock_scheduler): response = client.post( - '/wefax/schedule/enable', - data=json.dumps({ - 'station': 'NOJ', - 'frequency_khz': 4298, - 'device': 0, - }), - content_type='application/json', + "/wefax/schedule/enable", + data=json.dumps( + { + "station": "NOJ", + "frequency_khz": 4298, + "device": 0, + } + ), + content_type="application/json", ) assert response.status_code == 200 data = response.get_json() - assert data['status'] == 'ok' - assert data['usb_offset_applied'] is True - assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6) + assert data["status"] == "ok" + assert data["usb_offset_applied"] is True + assert math.isclose(data["tuned_frequency_khz"], 4296.1, abs_tol=1e-6) enable_kwargs = mock_scheduler.enable.call_args.kwargs - assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6) + assert math.isclose(enable_kwargs["frequency_khz"], 4296.1, abs_tol=1e-6) class TestWeFaxProgressCallback: @@ -559,14 +592,16 @@ class TestWeFaxProgressCallback: original_device = wefax_routes.wefax_active_device try: wefax_routes.wefax_active_device = 3 - with patch('routes.wefax.app_module.release_sdr_device') as mock_release: - wefax_routes._progress_callback({ - 'type': 'wefax_progress', - 'status': 'error', - 'message': 'decode failed', - }) + with patch("routes.wefax.app_module.release_sdr_device") as mock_release: + wefax_routes._progress_callback( + { + "type": "wefax_progress", + "status": "error", + "message": "decode failed", + } + ) - mock_release.assert_called_once_with(3, 'rtlsdr') + mock_release.assert_called_once_with(3, "rtlsdr") assert wefax_routes.wefax_active_device is None finally: wefax_routes.wefax_active_device = original_device @@ -578,12 +613,14 @@ class TestWeFaxProgressCallback: original_device = wefax_routes.wefax_active_device try: wefax_routes.wefax_active_device = 4 - with patch('routes.wefax.app_module.release_sdr_device') as mock_release: - wefax_routes._progress_callback({ - 'type': 'wefax_progress', - 'status': 'receiving', - 'line_count': 120, - }) + with patch("routes.wefax.app_module.release_sdr_device") as mock_release: + wefax_routes._progress_callback( + { + "type": "wefax_progress", + "status": "receiving", + "line_count": 120, + } + ) mock_release.assert_not_called() assert wefax_routes.wefax_active_device == 4 diff --git a/tests/test_wefax_scheduler.py b/tests/test_wefax_scheduler.py index 93dbee2..b67f36a 100644 --- a/tests/test_wefax_scheduler.py +++ b/tests/test_wefax_scheduler.py @@ -11,50 +11,56 @@ from utils.wefax_scheduler import ScheduledBroadcast, WeFaxScheduler class TestWeFaxScheduler: """WeFaxScheduler regression tests.""" - @patch('threading.Timer') + @patch("threading.Timer") def test_refresh_reschedules_same_utc_slot_next_day(self, mock_timer): """Completed broadcasts must not block the next day's same UTC slot.""" scheduler = WeFaxScheduler() scheduler._enabled = True - scheduler._station = 'USCG Kodiak' - scheduler._callsign = 'NOJ' + scheduler._station = "USCG Kodiak" + scheduler._callsign = "NOJ" scheduler._frequency_khz = 4298.0 now = datetime.now(timezone.utc) - utc_time = (now - timedelta(hours=2)).strftime('%H:%M') + utc_time = (now - timedelta(hours=2)).strftime("%H:%M") today = now.date().isoformat() prior = ScheduledBroadcast( - station='USCG Kodiak', - callsign='NOJ', + station="USCG Kodiak", + callsign="NOJ", frequency_khz=4298.0, utc_time=utc_time, duration_min=20, - content='Chart', + content="Chart", occurrence_date=today, ) - prior.status = 'complete' + prior.status = "complete" scheduler._broadcasts = [prior] mock_timer.return_value = MagicMock() - with patch('utils.wefax_scheduler.get_station', return_value={ - 'name': 'USCG Kodiak', - 'schedule': [{ - 'utc': utc_time, - 'duration_min': 20, - 'content': 'Chart', - }], - }): + with patch( + "utils.wefax_scheduler.get_station", + return_value={ + "name": "USCG Kodiak", + "schedule": [ + { + "utc": utc_time, + "duration_min": 20, + "content": "Chart", + } + ], + }, + ): scheduler._refresh_schedule() capture_calls = [ - c for c in mock_timer.call_args_list - if len(c.args) >= 2 and getattr(c.args[1], '__name__', '') == '_execute_capture' + c + for c in mock_timer.call_args_list + if len(c.args) >= 2 and getattr(c.args[1], "__name__", "") == "_execute_capture" ] assert capture_calls, "Expected a capture timer for the next-day occurrence" - scheduled = [b for b in scheduler._broadcasts if b.status == 'scheduled'] + scheduled = [b for b in scheduler._broadcasts if b.status == "scheduled"] assert len(scheduled) == 1 assert scheduled[0].occurrence_date != today @@ -62,7 +68,7 @@ class TestWeFaxScheduler: """If stop delay computes to <= 0, capture should close out immediately.""" scheduler = WeFaxScheduler() scheduler._enabled = True - scheduler._callsign = 'NOJ' + scheduler._callsign = "NOJ" scheduler._frequency_khz = 4298.0 scheduler._device = 0 scheduler._gain = 40.0 @@ -72,34 +78,36 @@ class TestWeFaxScheduler: now = datetime.now(timezone.utc) sb = ScheduledBroadcast( - station='USCG Kodiak', - callsign='NOJ', + station="USCG Kodiak", + callsign="NOJ", frequency_khz=4298.0, - utc_time=now.strftime('%H:%M'), + utc_time=now.strftime("%H:%M"), duration_min=0, - content='Late chart', + content="Late chart", occurrence_date=now.date().isoformat(), ) - sb.status = 'scheduled' + sb.status = "scheduled" mock_decoder = MagicMock() mock_decoder.is_running = False mock_decoder.start.return_value = True - with patch('utils.wefax_scheduler.get_wefax_decoder', return_value=mock_decoder), \ - patch('utils.wefax_scheduler.WEFAX_CAPTURE_BUFFER_SECONDS', 0), \ - patch('app.claim_sdr_device', return_value=None), \ - patch.object(scheduler, '_stop_capture') as mock_stop_capture: + with ( + patch("utils.wefax_scheduler.get_wefax_decoder", return_value=mock_decoder), + patch("utils.wefax_scheduler.WEFAX_CAPTURE_BUFFER_SECONDS", 0), + patch("app.claim_sdr_device", return_value=None), + patch.object(scheduler, "_stop_capture") as mock_stop_capture, + ): scheduler._execute_capture_inner(sb) mock_stop_capture.assert_called_once() - @patch('threading.Timer') + @patch("threading.Timer") def test_terminal_progress_releases_scheduler_device_early(self, mock_timer): """Scheduler captures must release SDR as soon as terminal progress arrives.""" scheduler = WeFaxScheduler() scheduler._enabled = True - scheduler._callsign = 'NOJ' + scheduler._callsign = "NOJ" scheduler._frequency_khz = 4298.0 scheduler._device = 0 scheduler._gain = 40.0 @@ -108,51 +116,55 @@ class TestWeFaxScheduler: scheduler._direct_sampling = True sb = ScheduledBroadcast( - station='USCG Kodiak', - callsign='NOJ', + station="USCG Kodiak", + callsign="NOJ", frequency_khz=4298.0, - utc_time='12:00', + utc_time="12:00", duration_min=20, - content='Chart', - occurrence_date='2026-01-01', + content="Chart", + occurrence_date="2026-01-01", ) - sb.status = 'scheduled' + sb.status = "scheduled" mock_decoder = MagicMock() mock_decoder.is_running = False mock_decoder.start.return_value = True mock_timer.return_value = MagicMock() - with patch('utils.wefax_scheduler.get_wefax_decoder', return_value=mock_decoder), \ - patch('app.claim_sdr_device', return_value=None), \ - patch('app.release_sdr_device') as mock_release: + with ( + patch("utils.wefax_scheduler.get_wefax_decoder", return_value=mock_decoder), + patch("app.claim_sdr_device", return_value=None), + patch("app.release_sdr_device") as mock_release, + ): scheduler._execute_capture_inner(sb) progress_cb = mock_decoder.set_callback.call_args[0][0] - progress_cb({ - 'type': 'wefax_progress', - 'status': 'error', - 'message': 'rtl_fm failed', - }) + progress_cb( + { + "type": "wefax_progress", + "status": "error", + "message": "rtl_fm failed", + } + ) mock_release.assert_called_once_with(0) - assert sb.status == 'skipped' + assert sb.status == "skipped" def test_stop_capture_non_capturing_only_releases(self): """_stop_capture should be idempotent when capture already ended.""" scheduler = WeFaxScheduler() sb = ScheduledBroadcast( - station='USCG Kodiak', - callsign='NOJ', + station="USCG Kodiak", + callsign="NOJ", frequency_khz=4298.0, - utc_time='12:00', + utc_time="12:00", duration_min=20, - content='Chart', - occurrence_date='2026-01-01', + content="Chart", + occurrence_date="2026-01-01", ) - sb.status = 'complete' + sb.status = "complete" release_fn = MagicMock() - with patch('utils.wefax_scheduler.get_wefax_decoder') as mock_get_decoder: + with patch("utils.wefax_scheduler.get_wefax_decoder") as mock_get_decoder: scheduler._stop_capture(sb, release_fn) release_fn.assert_called_once() diff --git a/tests/test_wifi.py b/tests/test_wifi.py index 6fbdbef..2667488 100644 --- a/tests/test_wifi.py +++ b/tests/test_wifi.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, mock_open, patch import pytest from flask import Flask -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from routes.wifi import parse_airodump_csv, wifi_bp @@ -21,16 +21,19 @@ def mock_app_module(mocker): mock_app_module.wifi_clients = {} return mock + @pytest.fixture def app(): app = Flask(__name__) app.register_blueprint(wifi_bp) return app + @pytest.fixture def client(app): return app.test_client() + def test_parse_airodump_csv(mocker): """Test parsing logic for airodump CSV format.""" csv_content = ( @@ -50,19 +53,22 @@ def test_parse_airodump_csv(mocker): assert "11:22:33:44:55:66" in clients assert clients["11:22:33:44:55:66"]["vendor"] == "Apple" + ### --- ROUTE TESTS --- ### + def test_get_interfaces(client, mocker): """Test the /interfaces endpoint.""" - mocker.patch("routes.wifi.detect_wifi_interfaces", return_value=[{'name': 'wlan0', 'type': 'managed'}]) + mocker.patch("routes.wifi.detect_wifi_interfaces", return_value=[{"name": "wlan0", "type": "managed"}]) mocker.patch("routes.wifi.check_tool", return_value=True) - response = client.get('/wifi/interfaces') + response = client.get("/wifi/interfaces") data = response.get_json() assert response.status_code == 200 - assert len(data['interfaces']) == 1 - assert data['tools']['airmon'] is True + assert len(data["interfaces"]) == 1 + assert data["tools"]["airmon"] is True + def test_toggle_monitor_start_success(client, mocker): """Test enabling monitor mode via airmon-ng.""" @@ -72,20 +78,22 @@ def test_toggle_monitor_start_success(client, mocker): mock_run.return_value = MagicMock(stdout="enabled on [phy0]wlan0mon", stderr="", returncode=0) with patch("os.path.exists", return_value=True): - response = client.post('/wifi/monitor', json={'action': 'start', 'interface': 'wlan0'}) + response = client.post("/wifi/monitor", json={"action": "start", "interface": "wlan0"}) assert response.status_code == 200 - assert response.get_json()['status'] == 'success' - assert response.get_json()['monitor_interface'] == 'wlan0mon' + assert response.get_json()["status"] == "success" + assert response.get_json()["monitor_interface"] == "wlan0mon" + def test_start_scan_already_running(client, mock_app_module): """Test that we can't start a scan if one is already active.""" mock_app_module.wifi_process = MagicMock() - response = client.post('/wifi/scan/start', json={'interface': 'wlan0mon'}) + response = client.post("/wifi/scan/start", json={"interface": "wlan0mon"}) data = response.get_json() - assert data['status'] == 'error' - assert 'already running' in data['message'] + assert data["status"] == "error" + assert "already running" in data["message"] + def test_start_scan_execution(client, mock_app_module, mocker): """Test the full command construction of airodump-ng.""" @@ -98,28 +106,30 @@ def test_start_scan_execution(client, mock_app_module, mocker): mock_proc.poll.return_value = None mock_popen.return_value = mock_proc - payload = {'interface': 'wlan0mon', 'channel': 6, 'band': 'g'} - response = client.post('/wifi/scan/start', json=payload) + payload = {"interface": "wlan0mon", "channel": 6, "band": "g"} + response = client.post("/wifi/scan/start", json=payload) assert response.status_code == 200 - assert response.get_json()['status'] == 'started' + assert response.get_json()["status"] == "started" args, _ = mock_popen.call_args cmd = args[0] assert "-c" in cmd and "6" in cmd assert "wlan0mon" in cmd + def test_stop_scan(client, mock_app_module): """Test terminating the scanning process.""" mock_proc = MagicMock() mock_app_module.wifi_process = mock_proc - response = client.post('/wifi/scan/stop') + response = client.post("/wifi/scan/stop") assert response.status_code == 200 - assert response.get_json()['status'] == 'stopped' + assert response.get_json()["status"] == "stopped" mock_proc.terminate.assert_called_once() + def test_send_deauth_success(client, mock_app_module, mocker): """Verify deauth command construction and execution.""" mocker.patch("routes.wifi.check_tool", return_value=True) @@ -127,12 +137,8 @@ def test_send_deauth_success(client, mock_app_module, mocker): mock_run = mocker.patch("routes.wifi.subprocess.run") mock_run.return_value = MagicMock(returncode=0) - payload = { - 'bssid': 'AA:BB:CC:DD:EE:FF', - 'count': 10, - 'interface': 'wlan0mon' - } - response = client.post('/wifi/deauth', json=payload) + payload = {"bssid": "AA:BB:CC:DD:EE:FF", "count": 10, "interface": "wlan0mon"} + response = client.post("/wifi/deauth", json=payload) assert response.status_code == 200 args, _ = mock_run.call_args @@ -141,21 +147,24 @@ def test_send_deauth_success(client, mock_app_module, mocker): assert "10" in cmd assert "AA:BB:CC:DD:EE:FF" in cmd + ### --- HANDSHAKE TESTS --- ### + def test_capture_handshake_start(client, mock_app_module, mocker): """Test starting airodump-ng for handshake capture.""" mock_app_module.wifi_process = None mocker.patch("routes.wifi.get_tool_path", return_value="/usr/bin/airodump-ng") mock_popen = mocker.patch("routes.wifi.subprocess.Popen") - payload = {'bssid': 'AA:BB:CC:DD:EE:FF', 'channel': '6', 'interface': 'wlan0mon'} - response = client.post('/wifi/handshake/capture', json=payload) + payload = {"bssid": "AA:BB:CC:DD:EE:FF", "channel": "6", "interface": "wlan0mon"} + response = client.post("/wifi/handshake/capture", json=payload) assert response.status_code == 200 - assert 'capture_file' in response.get_json() + assert "capture_file" in response.get_json() assert mock_popen.called + def test_check_handshake_status_found(client, mocker): """Verify detection of 'KEY FOUND' in aircrack output.""" mocker.patch("os.path.exists", return_value=True) @@ -165,24 +174,28 @@ def test_check_handshake_status_found(client, mocker): mock_run = mocker.patch("routes.wifi.subprocess.run") mock_run.return_value = MagicMock(stdout="WPA (1 handshake)", stderr="", returncode=0) - payload = {'file': '/tmp/intercept_handshake_test.cap', 'bssid': 'AA:BB:CC:DD:EE:FF'} - response = client.post('/wifi/handshake/status', json=payload) + payload = {"file": "/tmp/intercept_handshake_test.cap", "bssid": "AA:BB:CC:DD:EE:FF"} + response = client.post("/wifi/handshake/status", json=payload) + + assert response.get_json()["handshake_found"] is True - assert response.get_json()['handshake_found'] is True ### --- PMKID TESTS --- ### + def test_capture_pmkid_path_traversal_prevention(client): """Ensure the status check rejects invalid paths.""" - payload = {'file': '/etc/passwd'} # Malicious path - response = client.post('/wifi/pmkid/status', json=payload) + payload = {"file": "/etc/passwd"} # Malicious path + response = client.post("/wifi/pmkid/status", json=payload) assert response.status_code == 400 - assert response.get_json()['status'] == 'error' - assert 'Invalid capture file path' in response.get_json()['message'] + assert response.get_json()["status"] == "error" + assert "Invalid capture file path" in response.get_json()["message"] + ### --- CRACKING TESTS --- ### + def test_crack_handshake_success(client, mocker): """Test successful password extraction using Regex.""" mocker.patch("os.path.exists", return_value=True) @@ -190,35 +203,31 @@ def test_crack_handshake_success(client, mocker): mock_run = mocker.patch("routes.wifi.subprocess.run") # Simulate the actual aircrack-ng success output - mock_run.return_value = MagicMock( - stdout="KEY FOUND! [ secret123 ]", - stderr="", - returncode=0 - ) + mock_run.return_value = MagicMock(stdout="KEY FOUND! [ secret123 ]", stderr="", returncode=0) payload = { - 'capture_file': '/tmp/intercept_handshake_test.cap', - 'wordlist': '/home/user/passwords.txt', - 'bssid': 'AA:BB:CC:DD:EE:FF' + "capture_file": "/tmp/intercept_handshake_test.cap", + "wordlist": "/home/user/passwords.txt", + "bssid": "AA:BB:CC:DD:EE:FF", } - response = client.post('/wifi/handshake/crack', json=payload) + response = client.post("/wifi/handshake/crack", json=payload) data = response.get_json() - assert data['status'] == 'success' - assert data['password'] == 'secret123' + assert data["status"] == "success" + assert data["password"] == "secret123" + ### --- DATA FETCHING TESTS --- ### + def test_get_wifi_networks(client, mock_app_module): """Test that the networks endpoint correctly formats internal data.""" - mock_app_module.wifi_networks = { - 'AA:BB:CC:DD:EE:FF': {'essid': 'Home-WiFi', 'bssid': 'AA:BB:CC:DD:EE:FF'} - } - mock_app_module.wifi_handshakes = ['AA:BB:CC:DD:EE:FF'] + mock_app_module.wifi_networks = {"AA:BB:CC:DD:EE:FF": {"essid": "Home-WiFi", "bssid": "AA:BB:CC:DD:EE:FF"}} + mock_app_module.wifi_handshakes = ["AA:BB:CC:DD:EE:FF"] - response = client.get('/wifi/networks') + response = client.get("/wifi/networks") data = response.get_json() - assert len(data['networks']) == 1 - assert data['networks'][0]['essid'] == 'Home-WiFi' - assert 'AA:BB:CC:DD:EE:FF' in data['handshakes'] + assert len(data["networks"]) == 1 + assert data["networks"][0]["essid"] == "Home-WiFi" + assert "AA:BB:CC:DD:EE:FF" in data["handshakes"] diff --git a/utils/acars_translator.py b/utils/acars_translator.py index 4f2f72c..00b1fb9 100644 --- a/utils/acars_translator.py +++ b/utils/acars_translator.py @@ -8,163 +8,183 @@ import re # Sources: ARINC 618, ARINC 620, airline implementations ACARS_LABELS: dict[str, str] = { # Position & navigation - 'H1': 'Position report (HF data link)', - 'H2': 'Weather report', - '5Z': 'OOOI (gate times)', - '15': 'Departure report', - '16': 'Arrival report', - '20': 'Position report', - '22': 'Fuel report', - '2Z': 'Off-gate report', - '30': 'Progress report', - '44': 'Weather request', - '80': 'Free text (3-char header)', - '83': 'Free text', - '8E': 'ATIS request', - + "H1": "Position report (HF data link)", + "H2": "Weather report", + "5Z": "OOOI (gate times)", + "15": "Departure report", + "16": "Arrival report", + "20": "Position report", + "22": "Fuel report", + "2Z": "Off-gate report", + "30": "Progress report", + "44": "Weather request", + "80": "Free text (3-char header)", + "83": "Free text", + "8E": "ATIS request", # Engine & performance - 'DF': 'Engine data / DFDR', - 'D3': 'Engine exceedance', - 'D6': 'Engine trend data', - + "DF": "Engine data / DFDR", + "D3": "Engine exceedance", + "D6": "Engine trend data", # ATS / air traffic services - 'B1': 'ATC request', - 'B2': 'ATC clearance', - 'B3': 'ATC comm test', - 'B6': 'ATC departure clearance', - 'B9': 'ATC message', - 'BA': 'ATC advisory', - 'BB': 'ATC response', - + "B1": "ATC request", + "B2": "ATC clearance", + "B3": "ATC comm test", + "B6": "ATC departure clearance", + "B9": "ATC message", + "BA": "ATC advisory", + "BB": "ATC response", # CPDLC (Controller-Pilot Data Link Communications) - 'AA': 'CPDLC message', - 'AB': 'CPDLC response', - 'A0': 'CPDLC uplink', - 'A1': 'CPDLC downlink', - 'A2': 'CPDLC connection request', - 'A3': 'CPDLC logon/logoff', - 'A6': 'CPDLC message', - 'A7': 'CPDLC response', - 'AT': 'CPDLC transfer', - + "AA": "CPDLC message", + "AB": "CPDLC response", + "A0": "CPDLC uplink", + "A1": "CPDLC downlink", + "A2": "CPDLC connection request", + "A3": "CPDLC logon/logoff", + "A6": "CPDLC message", + "A7": "CPDLC response", + "AT": "CPDLC transfer", # Handshake & link management - '_d': 'Demand mode (link test)', - 'Q0': 'Link test', - 'QA': 'Link test reply', - 'QB': 'Acknowledgement', - 'QC': 'Link request', - 'QD': 'Link accept', - 'QE': 'Link reject', - 'QF': 'Squitter / heartbeat', - 'QG': 'Abort', - 'QH': 'Version request', - 'QK': 'Mode change', - 'QM': 'Link verification', - 'QN': 'Media advisory', - 'QP': 'Polling', - 'QQ': 'Status', - 'QR': 'General response', - 'QS': 'System table request', - 'QT': 'System table', - 'QX': 'Frequency change', - + "_d": "Demand mode (link test)", + "Q0": "Link test", + "QA": "Link test reply", + "QB": "Acknowledgement", + "QC": "Link request", + "QD": "Link accept", + "QE": "Link reject", + "QF": "Squitter / heartbeat", + "QG": "Abort", + "QH": "Version request", + "QK": "Mode change", + "QM": "Link verification", + "QN": "Media advisory", + "QP": "Polling", + "QQ": "Status", + "QR": "General response", + "QS": "System table request", + "QT": "System table", + "QX": "Frequency change", # Squawk & surveillance - 'SQ': 'Squawk assignment', - 'SA': 'Surveillance data', - 'S1': 'ADS-C report', - + "SQ": "Squawk assignment", + "SA": "Surveillance data", + "S1": "ADS-C report", # Airline operations - 'C1': 'Crew scheduling', - 'C2': 'Crew response', - 'C3': 'Crew message', - 'C4': 'Crew query', - '10': 'Delay message', - '12': 'Clearance request', - '17': 'Cargo/load data', - '4T': 'TWIP (terminal weather)', - '4X': 'Connectivity test', - '50': 'Weather observation', - '51': 'METAR/TAF request', - '52': 'METAR/TAF response', - '54': 'SIGMET / AIRMET', - '70': 'Maintenance report', - '7A': 'Fault message', - '7B': 'Fault clear', - 'F3': 'Flight plan', - 'F5': 'Flight plan amendment', - 'F6': 'Route request', - 'F7': 'Route clearance', - 'RA': 'ATIS report', - 'RB': 'ATIS request', + "C1": "Crew scheduling", + "C2": "Crew response", + "C3": "Crew message", + "C4": "Crew query", + "10": "Delay message", + "12": "Clearance request", + "17": "Cargo/load data", + "4T": "TWIP (terminal weather)", + "4X": "Connectivity test", + "50": "Weather observation", + "51": "METAR/TAF request", + "52": "METAR/TAF response", + "54": "SIGMET / AIRMET", + "70": "Maintenance report", + "7A": "Fault message", + "7B": "Fault clear", + "F3": "Flight plan", + "F5": "Flight plan amendment", + "F6": "Route request", + "F7": "Route clearance", + "RA": "ATIS report", + "RB": "ATIS request", } # Message type classification for UI colour coding MESSAGE_TYPES = { - 'position', 'engine_data', 'weather', 'ats', 'handshake', - 'oooi', 'squawk', 'link_test', 'cpdlc', 'other', + "position", + "engine_data", + "weather", + "ats", + "handshake", + "oooi", + "squawk", + "link_test", + "cpdlc", + "other", } def translate_label(label: str | None) -> str: """Return human-readable description for an ACARS label code.""" if not label: - return 'Unknown label' + return "Unknown label" label = label.strip() if label in ACARS_LABELS: return ACARS_LABELS[label] # Check for Q-prefix group - if len(label) == 2 and label.startswith('Q'): - return f'Link management ({label})' - return f'Label {label}' + if len(label) == 2 and label.startswith("Q"): + return f"Link management ({label})" + return f"Label {label}" def classify_message_type(label: str | None, text: str | None = None) -> str: """Classify an ACARS message into a canonical type for UI display.""" if not label: - return 'other' + return "other" label = label.strip() # Position reports - if label in ('H1', '20', '15', '16', '30', 'S1'): - return 'position' - if text and '#M1BPOS' in text: - return 'position' + if label in ("H1", "20", "15", "16", "30", "S1"): + return "position" + if text and "#M1BPOS" in text: + return "position" # Engine / DFDR data - if label in ('DF', 'D3', 'D6'): - return 'engine_data' + if label in ("DF", "D3", "D6"): + return "engine_data" # Weather - if label in ('H2', '44', '50', '51', '52', '54', '4T'): - return 'weather' + if label in ("H2", "44", "50", "51", "52", "54", "4T"): + return "weather" # ATS / ATC - if label.startswith('B') and len(label) == 2: - return 'ats' + if label.startswith("B") and len(label) == 2: + return "ats" # CPDLC - if label in ('AA', 'AB', 'A0', 'A1', 'A2', 'A3', 'A6', 'A7', 'AT'): - return 'cpdlc' + if label in ("AA", "AB", "A0", "A1", "A2", "A3", "A6", "A7", "AT"): + return "cpdlc" # OOOI (Out/Off/On/In gate times) - if label in ('5Z', '2Z'): - return 'oooi' + if label in ("5Z", "2Z"): + return "oooi" # Squawk - if label in ('SQ', 'SA'): - return 'squawk' + if label in ("SQ", "SA"): + return "squawk" # Link test / handshake - if label in ('Q0', 'QA', 'QB', 'QC', 'QD', 'QE', 'QF', 'QG', - 'QH', 'QK', 'QM', 'QN', 'QP', 'QQ', 'QR', 'QS', 'QT', 'QX', - '4X'): - return 'link_test' + if label in ( + "Q0", + "QA", + "QB", + "QC", + "QD", + "QE", + "QF", + "QG", + "QH", + "QK", + "QM", + "QN", + "QP", + "QQ", + "QR", + "QS", + "QT", + "QX", + "4X", + ): + return "link_test" # Handshake (_d is demand mode) - if label == '_d': - return 'handshake' + if label == "_d": + return "handshake" - return 'other' + return "other" def parse_position_report(text: str | None) -> dict | None: @@ -185,13 +205,13 @@ def parse_position_report(text: str | None) -> dict | None: # Look for BPOS block bpos_match = re.search( - r'#M\d[A-Z]*POS' - r'([NS])(\d{2,5})([EW])(\d{3,6})' - r',([^,]*),(\d{4,6})' - r',(\d{2,3})' - r'(?:,([NS]\d{2,5}[EW]\d{3,6}))?' - r'(?:,([A-Z]{3,4}))?', - text + r"#M\d[A-Z]*POS" + r"([NS])(\d{2,5})([EW])(\d{3,6})" + r",([^,]*),(\d{4,6})" + r",(\d{2,3})" + r"(?:,([NS]\d{2,5}[EW]\d{3,6}))?" + r"(?:,([A-Z]{3,4}))?", + text, ) if bpos_match: lat_dir, lat_val, lon_dir, lon_val = bpos_match.group(1, 2, 3, 4) @@ -202,7 +222,7 @@ def parse_position_report(text: str | None) -> dict | None: lat = lat_deg + lat_min / 60 else: lat = float(lat_val) - if lat_dir == 'S': + if lat_dir == "S": lat = -lat if len(lon_val) >= 5: @@ -211,22 +231,22 @@ def parse_position_report(text: str | None) -> dict | None: lon = lon_deg + lon_min / 60 else: lon = float(lon_val) - if lon_dir == 'W': + if lon_dir == "W": lon = -lon - result['lat'] = round(lat, 4) - result['lon'] = round(lon, 4) - result['waypoint'] = bpos_match.group(5).strip() if bpos_match.group(5) else None - result['time'] = bpos_match.group(6) - result['flight_level'] = f"FL{bpos_match.group(7)}" + result["lat"] = round(lat, 4) + result["lon"] = round(lon, 4) + result["waypoint"] = bpos_match.group(5).strip() if bpos_match.group(5) else None + result["time"] = bpos_match.group(6) + result["flight_level"] = f"FL{bpos_match.group(7)}" if bpos_match.group(9): - result['destination'] = bpos_match.group(9) + result["destination"] = bpos_match.group(9) # Look for temperature (e.g., /TS-045 or M045) - temp_match = re.search(r'/TS([MP]?)(\d{2,3})', text) + temp_match = re.search(r"/TS([MP]?)(\d{2,3})", text) if temp_match: - sign = '-' if temp_match.group(1) == 'M' else '' - result['temperature'] = f"{sign}{temp_match.group(2)} C" + sign = "-" if temp_match.group(1) == "M" else "" + result["temperature"] = f"{sign}{temp_match.group(2)} C" return result if result else None @@ -243,30 +263,30 @@ def parse_engine_data(text: str | None) -> dict | None: result: dict = {} engine_keys = { - 'SM': 'Source mode', - 'AC0': 'Eng 1 N2 (%)', - 'AC1': 'Eng 2 N2 (%)', - 'FL': 'Flight level', - 'FU': 'Fuel used (lbs)', - 'ES': 'EGT spread', - 'BA': 'Bleed air', - 'CO': 'Config', - 'AO': 'Auto', - 'EGT': 'Exhaust gas temp', - 'OIT': 'Oil temp', - 'OIP': 'Oil pressure', - 'N1': 'N1 (%)', - 'N2': 'N2 (%)', - 'FF': 'Fuel flow', - 'VIB': 'Vibration', + "SM": "Source mode", + "AC0": "Eng 1 N2 (%)", + "AC1": "Eng 2 N2 (%)", + "FL": "Flight level", + "FU": "Fuel used (lbs)", + "ES": "EGT spread", + "BA": "Bleed air", + "CO": "Config", + "AO": "Auto", + "EGT": "Exhaust gas temp", + "OIT": "Oil temp", + "OIP": "Oil pressure", + "N1": "N1 (%)", + "N2": "N2 (%)", + "FF": "Fuel flow", + "VIB": "Vibration", } # Match KEY/VALUE or KEY VALUE patterns for key, desc in engine_keys.items(): - pattern = rf'\b{re.escape(key)}[/: ]?\s*([+-]?\d+\.?\d*)' + pattern = rf"\b{re.escape(key)}[/: ]?\s*([+-]?\d+\.?\d*)" m = re.search(pattern, text) if m: - result[key] = {'value': m.group(1), 'description': desc} + result[key] = {"value": m.group(1), "description": desc} return result if result else None @@ -279,26 +299,26 @@ def parse_weather_data(text: str | None) -> dict | None: result: dict = {} # Wind: direction/speed (e.g., 270/15 or WND270015) - wind_match = re.search(r'(?:WND|WIND)\s*(\d{3})[/ ]?(\d{2,3})', text) + wind_match = re.search(r"(?:WND|WIND)\s*(\d{3})[/ ]?(\d{2,3})", text) if wind_match: - result['wind_dir'] = f"{wind_match.group(1)} deg" - result['wind_speed'] = f"{wind_match.group(2)} kts" + result["wind_dir"] = f"{wind_match.group(1)} deg" + result["wind_speed"] = f"{wind_match.group(2)} kts" # Airport codes (3-4 letter ICAO) - airports = re.findall(r'\b([A-Z]{3,4})\b', text) + airports = re.findall(r"\b([A-Z]{3,4})\b", text) if airports: - result['airports'] = list(dict.fromkeys(airports))[:4] + result["airports"] = list(dict.fromkeys(airports))[:4] # Temperature (e.g., T24/D18, TMP24, TEMP -5) - temp_match = re.search(r'(?:TMP|TEMP|T)\s*([MP+-]?\d{1,3})', text) + temp_match = re.search(r"(?:TMP|TEMP|T)\s*([MP+-]?\d{1,3})", text) if temp_match: - val = temp_match.group(1).replace('M', '-').replace('P', '') - result['temperature'] = f"{val} C" + val = temp_match.group(1).replace("M", "-").replace("P", "") + result["temperature"] = f"{val} C" # Visibility - vis_match = re.search(r'VIS\s*(\d+(?:\.\d+)?)', text) + vis_match = re.search(r"VIS\s*(\d+(?:\.\d+)?)", text) if vis_match: - result['visibility'] = f"{vis_match.group(1)} SM" + result["visibility"] = f"{vis_match.group(1)} SM" return result if result else None @@ -315,27 +335,24 @@ def parse_oooi(text: str | None) -> dict | None: result: dict = {} # Try to find airport pair + 4 time blocks - oooi_match = re.search( - r'([A-Z]{3,4})\s+([A-Z]{3,4})\s+(\d{4})\s+(\d{4})\s+(\d{4})\s+(\d{4})', - text - ) + oooi_match = re.search(r"([A-Z]{3,4})\s+([A-Z]{3,4})\s+(\d{4})\s+(\d{4})\s+(\d{4})\s+(\d{4})", text) if oooi_match: - result['origin'] = oooi_match.group(1) - result['destination'] = oooi_match.group(2) - result['out'] = oooi_match.group(3) - result['off'] = oooi_match.group(4) - result['on'] = oooi_match.group(5) - result['in'] = oooi_match.group(6) + result["origin"] = oooi_match.group(1) + result["destination"] = oooi_match.group(2) + result["out"] = oooi_match.group(3) + result["off"] = oooi_match.group(4) + result["on"] = oooi_match.group(5) + result["in"] = oooi_match.group(6) return result # Try partial (just origin/destination and some times) - partial = re.search(r'([A-Z]{3,4})\s+([A-Z]{3,4})', text) + partial = re.search(r"([A-Z]{3,4})\s+([A-Z]{3,4})", text) if partial: - result['origin'] = partial.group(1) - result['destination'] = partial.group(2) + result["origin"] = partial.group(1) + result["destination"] = partial.group(2) - times = re.findall(r'\b(\d{4})\b', text) - labels = ['out', 'off', 'on', 'in'] + times = re.findall(r"\b(\d{4})\b", text) + labels = ["out", "off", "on", "in"] for i, t in enumerate(times[:4]): result[labels[i]] = t @@ -351,24 +368,24 @@ def translate_message(msg: dict) -> dict: Returns: Dict with 'label_description', 'message_type', 'parsed'. """ - label = msg.get('label') - text = msg.get('text') or msg.get('msg') or '' + label = msg.get("label") + text = msg.get("text") or msg.get("msg") or "" label_description = translate_label(label) message_type = classify_message_type(label, text) parsed: dict | None = None - if message_type == 'position' or (label == 'H1' and 'POS' in text.upper()): + if message_type == "position" or (label == "H1" and "POS" in text.upper()): parsed = parse_position_report(text) - elif message_type == 'engine_data': + elif message_type == "engine_data": parsed = parse_engine_data(text) - elif message_type == 'weather': + elif message_type == "weather": parsed = parse_weather_data(text) - elif message_type == 'oooi': + elif message_type == "oooi": parsed = parse_oooi(text) return { - 'label_description': label_description, - 'message_type': message_type, - 'parsed': parsed, + "label_description": label_description, + "message_type": message_type, + "parsed": parsed, } diff --git a/utils/adsb_history.py b/utils/adsb_history.py index c229496..03fc7a8 100644 --- a/utils/adsb_history.py +++ b/utils/adsb_history.py @@ -13,6 +13,7 @@ from datetime import datetime, timezone try: import psycopg2 from psycopg2.extras import Json, execute_values + PSYCOPG2_AVAILABLE = True except ImportError: psycopg2 = None # type: ignore @@ -34,58 +35,59 @@ from config import ( ADSB_HISTORY_QUEUE_SIZE, ) -logger = logging.getLogger('intercept.adsb_history') +logger = logging.getLogger("intercept.adsb_history") _MESSAGE_FIELDS = ( - 'received_at', - 'msg_time', - 'logged_time', - 'icao', - 'msg_type', - 'callsign', - 'altitude', - 'speed', - 'heading', - 'vertical_rate', - 'lat', - 'lon', - 'squawk', - 'session_id', - 'aircraft_id', - 'flight_id', - 'raw_line', - 'source_host', + "received_at", + "msg_time", + "logged_time", + "icao", + "msg_type", + "callsign", + "altitude", + "speed", + "heading", + "vertical_rate", + "lat", + "lon", + "squawk", + "session_id", + "aircraft_id", + "flight_id", + "raw_line", + "source_host", ) _MESSAGE_INSERT_SQL = f""" - INSERT INTO adsb_messages ({', '.join(_MESSAGE_FIELDS)}) + INSERT INTO adsb_messages ({", ".join(_MESSAGE_FIELDS)}) VALUES %s """ _SNAPSHOT_FIELDS = ( - 'captured_at', - 'icao', - 'callsign', - 'registration', - 'type_code', - 'type_desc', - 'altitude', - 'speed', - 'heading', - 'vertical_rate', - 'lat', - 'lon', - 'squawk', - 'source_host', - 'snapshot', + "captured_at", + "icao", + "callsign", + "registration", + "type_code", + "type_desc", + "altitude", + "speed", + "heading", + "vertical_rate", + "lat", + "lon", + "squawk", + "source_host", + "snapshot", ) _SNAPSHOT_INSERT_SQL = f""" - INSERT INTO adsb_snapshots ({', '.join(_SNAPSHOT_FIELDS)}) + INSERT INTO adsb_snapshots ({", ".join(_SNAPSHOT_FIELDS)}) VALUES %s """ + def _ensure_adsb_schema(conn: psycopg2.extensions.connection) -> None: with conn.cursor() as cur: cur.execute( @@ -200,8 +202,7 @@ def _ensure_adsb_schema(conn: psycopg2.extensions.connection) -> None: def _make_dsn() -> str: return ( - f"host={ADSB_DB_HOST} port={ADSB_DB_PORT} dbname={ADSB_DB_NAME} " - f"user={ADSB_DB_USER} password={ADSB_DB_PASSWORD}" + f"host={ADSB_DB_HOST} port={ADSB_DB_PORT} dbname={ADSB_DB_NAME} user={ADSB_DB_USER} password={ADSB_DB_PASSWORD}" ) @@ -221,7 +222,7 @@ class AdsbHistoryWriter: return if self._thread and self._thread.is_alive(): return - self._thread = threading.Thread(target=self._run, name='adsb-history-writer', daemon=True) + self._thread = threading.Thread(target=self._run, name="adsb-history-writer", daemon=True) self._thread.start() logger.info("ADS-B history writer started") @@ -231,8 +232,8 @@ class AdsbHistoryWriter: def enqueue(self, record: dict) -> None: if not self.enabled: return - if 'received_at' not in record or record['received_at'] is None: - record['received_at'] = datetime.now(timezone.utc) + if "received_at" not in record or record["received_at"] is None: + record["received_at"] = datetime.now(timezone.utc) try: self._queue.put_nowait(record) except queue.Full: @@ -317,7 +318,7 @@ class AdsbSnapshotWriter: return if self._thread and self._thread.is_alive(): return - self._thread = threading.Thread(target=self._run, name='adsb-snapshot-writer', daemon=True) + self._thread = threading.Thread(target=self._run, name="adsb-snapshot-writer", daemon=True) self._thread.start() logger.info("ADS-B snapshot writer started") @@ -327,8 +328,8 @@ class AdsbSnapshotWriter: def enqueue(self, record: dict) -> None: if not self.enabled: return - if 'captured_at' not in record or record['captured_at'] is None: - record['captured_at'] = datetime.now(timezone.utc) + if "captured_at" not in record or record["captured_at"] is None: + record["captured_at"] = datetime.now(timezone.utc) try: self._queue.put_nowait(record) except queue.Full: @@ -381,7 +382,7 @@ class AdsbSnapshotWriter: row = [] for field in _SNAPSHOT_FIELDS: value = record.get(field) - if field == 'snapshot' and value is not None: + if field == "snapshot" and value is not None: value = Json(value) row.append(value) values.append(tuple(row)) diff --git a/utils/aircraft_db.py b/utils/aircraft_db.py index fbba2d0..83ffd2e 100644 --- a/utils/aircraft_db.py +++ b/utils/aircraft_db.py @@ -12,18 +12,20 @@ from typing import Any from urllib.error import URLError from urllib.request import Request, urlopen -logger = logging.getLogger('intercept.aircraft_db') +logger = logging.getLogger("intercept.aircraft_db") # Database file location (persisted under data/adsb/ for Docker volume compatibility) -DB_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'data', 'adsb') +DB_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "adsb") os.makedirs(DB_DIR, exist_ok=True) -DB_FILE = os.path.join(DB_DIR, 'aircraft_db.json') -DB_META_FILE = os.path.join(DB_DIR, 'aircraft_db_meta.json') +DB_FILE = os.path.join(DB_DIR, "aircraft_db.json") +DB_META_FILE = os.path.join(DB_DIR, "aircraft_db_meta.json") # Mictronics database URLs (raw GitHub) -AIRCRAFT_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/aircrafts.json' -TYPES_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/types.json' -GITHUB_API_URL = 'https://api.github.com/repos/Mictronics/readsb-protobuf/commits?path=webapp/src/db/aircrafts.json&per_page=1' +AIRCRAFT_DB_URL = "https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/aircrafts.json" +TYPES_DB_URL = "https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/types.json" +GITHUB_API_URL = ( + "https://api.github.com/repos/Mictronics/readsb-protobuf/commits?path=webapp/src/db/aircrafts.json&per_page=1" +) # In-memory cache _aircraft_cache: dict[str, dict[str, str]] = {} @@ -41,12 +43,12 @@ def get_db_status() -> dict[str, Any]: meta = _load_meta() return { - 'installed': exists, - 'version': meta.get('version') if meta else None, - 'downloaded': meta.get('downloaded') if meta else None, - 'aircraft_count': len(_aircraft_cache) if _db_loaded else 0, - 'update_available': _update_available, - 'latest_version': _latest_version, + "installed": exists, + "version": meta.get("version") if meta else None, + "downloaded": meta.get("downloaded") if meta else None, + "aircraft_count": len(_aircraft_cache) if _db_loaded else 0, + "update_available": _update_available, + "latest_version": _latest_version, } @@ -69,10 +71,10 @@ def _save_meta(version: str) -> None: """Save database metadata.""" try: meta = { - 'version': version, - 'downloaded': datetime.utcnow().isoformat() + 'Z', + "version": version, + "downloaded": datetime.utcnow().isoformat() + "Z", } - with open(DB_META_FILE, 'w') as f: + with open(DB_META_FILE, "w") as f: json.dump(meta, f, indent=2) except Exception as e: logger.warning(f"Error saving aircraft db meta: {e}") @@ -91,12 +93,12 @@ def load_database() -> bool: with open(DB_FILE) as f: data = json.load(f) - _aircraft_cache = data.get('aircraft', {}) - _types_cache = data.get('types', {}) + _aircraft_cache = data.get("aircraft", {}) + _types_cache = data.get("types", {}) _db_loaded = True meta = _load_meta() - _db_version = meta.get('version') if meta else 'unknown' + _db_version = meta.get("version") if meta else "unknown" logger.info(f"Loaded aircraft database: {len(_aircraft_cache)} aircraft, {len(_types_cache)} types") return True @@ -125,22 +127,22 @@ def lookup(icao: str) -> dict[str, str] | None: # Database format is array: [registration, type_code, flags, ...] # Handle both list format (from Mictronics) and dict format (legacy) if isinstance(aircraft, list): - reg = aircraft[0] if len(aircraft) > 0 else '' - type_code = aircraft[1] if len(aircraft) > 1 else '' + reg = aircraft[0] if len(aircraft) > 0 else "" + type_code = aircraft[1] if len(aircraft) > 1 else "" else: # Dict format fallback - reg = aircraft.get('r', '') - type_code = aircraft.get('t', '') + reg = aircraft.get("r", "") + type_code = aircraft.get("t", "") # Look up type description - type_desc = '' + type_desc = "" if type_code and type_code in _types_cache: type_desc = _types_cache[type_code] return { - 'registration': reg, - 'type_code': type_code, - 'type_desc': type_desc, + "registration": reg, + "type_code": type_code, + "type_desc": type_desc, } @@ -152,34 +154,34 @@ def check_for_updates() -> dict[str, Any]: global _update_available, _latest_version try: - req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'}) + req = Request(GITHUB_API_URL, headers={"User-Agent": "Intercept-SIGINT"}) with urlopen(req, timeout=10) as response: - commits = json.loads(response.read().decode('utf-8')) + commits = json.loads(response.read().decode("utf-8")) if commits and len(commits) > 0: - latest_sha = commits[0]['sha'][:8] - latest_date = commits[0]['commit']['committer']['date'] + latest_sha = commits[0]["sha"][:8] + latest_date = commits[0]["commit"]["committer"]["date"] _latest_version = f"{latest_date[:10]}_{latest_sha}" meta = _load_meta() - current_version = meta.get('version') if meta else None + current_version = meta.get("version") if meta else None _update_available = current_version != _latest_version return { - 'success': True, - 'current_version': current_version, - 'latest_version': _latest_version, - 'update_available': _update_available, + "success": True, + "current_version": current_version, + "latest_version": _latest_version, + "update_available": _update_available, } except URLError as e: logger.warning(f"Failed to check for updates: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} except Exception as e: logger.warning(f"Error checking for updates: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} - return {'success': False, 'error': 'Unknown error'} + return {"success": False, "error": "Unknown error"} def download_database(progress_callback=None) -> dict[str, Any]: @@ -191,43 +193,43 @@ def download_database(progress_callback=None) -> dict[str, Any]: try: if progress_callback: - progress_callback('Downloading aircraft database...') + progress_callback("Downloading aircraft database...") # Download aircraft database - req = Request(AIRCRAFT_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'}) + req = Request(AIRCRAFT_DB_URL, headers={"User-Agent": "Intercept-SIGINT"}) with urlopen(req, timeout=60) as response: - aircraft_data = json.loads(response.read().decode('utf-8')) + aircraft_data = json.loads(response.read().decode("utf-8")) if progress_callback: - progress_callback('Downloading type codes...') + progress_callback("Downloading type codes...") # Download types database - req = Request(TYPES_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'}) + req = Request(TYPES_DB_URL, headers={"User-Agent": "Intercept-SIGINT"}) with urlopen(req, timeout=30) as response: - types_data = json.loads(response.read().decode('utf-8')) + types_data = json.loads(response.read().decode("utf-8")) if progress_callback: - progress_callback('Processing database...') + progress_callback("Processing database...") # Combine into single file combined = { - 'aircraft': aircraft_data, - 'types': types_data, + "aircraft": aircraft_data, + "types": types_data, } # Save to file - with open(DB_FILE, 'w') as f: - json.dump(combined, f, separators=(',', ':')) # Compact JSON + with open(DB_FILE, "w") as f: + json.dump(combined, f, separators=(",", ":")) # Compact JSON # Get version from GitHub - version = datetime.utcnow().strftime('%Y-%m-%d') + version = datetime.utcnow().strftime("%Y-%m-%d") try: - req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'}) + req = Request(GITHUB_API_URL, headers={"User-Agent": "Intercept-SIGINT"}) with urlopen(req, timeout=10) as response: - commits = json.loads(response.read().decode('utf-8')) + commits = json.loads(response.read().decode("utf-8")) if commits: - sha = commits[0]['sha'][:8] - date = commits[0]['commit']['committer']['date'][:10] + sha = commits[0]["sha"][:8] + date = commits[0]["commit"]["committer"]["date"][:10] version = f"{date}_{sha}" except Exception: pass @@ -239,17 +241,17 @@ def download_database(progress_callback=None) -> dict[str, Any]: load_database() return { - 'success': True, - 'message': f'Downloaded {len(aircraft_data)} aircraft, {len(types_data)} types', - 'version': version, + "success": True, + "message": f"Downloaded {len(aircraft_data)} aircraft, {len(types_data)} types", + "version": version, } except URLError as e: logger.error(f"Download failed: {e}") - return {'success': False, 'error': f'Download failed: {e}'} + return {"success": False, "error": f"Download failed: {e}"} except Exception as e: logger.error(f"Error downloading database: {e}") - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} def delete_database() -> dict[str, Any]: @@ -268,6 +270,6 @@ def delete_database() -> dict[str, Any]: if os.path.exists(DB_META_FILE): os.remove(DB_META_FILE) - return {'success': True, 'message': 'Database deleted'} + return {"success": True, "message": "Database deleted"} except Exception as e: - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} diff --git a/utils/airline_codes.py b/utils/airline_codes.py index d934b8d..9919b9c 100644 --- a/utils/airline_codes.py +++ b/utils/airline_codes.py @@ -119,7 +119,7 @@ IATA_TO_ICAO: dict[str, str] = { ICAO_TO_IATA: dict[str, str] = {v: k for k, v in IATA_TO_ICAO.items()} # Regex to split flight number into airline prefix and numeric part -_FLIGHT_RE = re.compile(r'^([A-Z]{2,3})(\d+[A-Z]?)$') +_FLIGHT_RE = re.compile(r"^([A-Z]{2,3})(\d+[A-Z]?)$") def translate_flight(flight: str) -> list[str]: diff --git a/utils/alerts.py b/utils/alerts.py index 251c95f..d75c26a 100644 --- a/utils/alerts.py +++ b/utils/alerts.py @@ -16,7 +16,7 @@ from typing import Any from config import ALERT_WEBHOOK_SECRET, ALERT_WEBHOOK_TIMEOUT, ALERT_WEBHOOK_URL from utils.database import get_db -logger = logging.getLogger('intercept.alerts') +logger = logging.getLogger("intercept.alerts") @dataclass @@ -49,35 +49,37 @@ class AlertManager: def _load_rules(self) -> None: with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at FROM alert_rules WHERE enabled = 1 ORDER BY id ASC - ''') + """) rules: list[AlertRule] = [] for row in cursor: match = {} notify = {} try: - match = json.loads(row['match']) if row['match'] else {} + match = json.loads(row["match"]) if row["match"] else {} except json.JSONDecodeError: match = {} try: - notify = json.loads(row['notify']) if row['notify'] else {} + notify = json.loads(row["notify"]) if row["notify"] else {} except json.JSONDecodeError: notify = {} - rules.append(AlertRule( - id=row['id'], - name=row['name'], - mode=row['mode'], - event_type=row['event_type'], - match=match, - severity=row['severity'] or 'medium', - enabled=bool(row['enabled']), - notify=notify, - created_at=row['created_at'], - )) + rules.append( + AlertRule( + id=row["id"], + name=row["name"], + mode=row["mode"], + event_type=row["event_type"], + match=match, + severity=row["severity"] or "medium", + enabled=bool(row["enabled"]), + notify=notify, + created_at=row["created_at"], + ) + ) with self._cache_lock: self._rules_cache = rules self._rules_loaded_at = time.time() @@ -93,48 +95,51 @@ class AlertManager: def list_rules(self, include_disabled: bool = False) -> list[dict]: with get_db() as conn: if include_disabled: - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at FROM alert_rules ORDER BY id DESC - ''') + """) else: - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT id, name, mode, event_type, match, severity, enabled, notify, created_at FROM alert_rules WHERE enabled = 1 ORDER BY id DESC - ''') + """) return [ { - 'id': row['id'], - 'name': row['name'], - 'mode': row['mode'], - 'event_type': row['event_type'], - 'match': json.loads(row['match']) if row['match'] else {}, - 'severity': row['severity'], - 'enabled': bool(row['enabled']), - 'notify': json.loads(row['notify']) if row['notify'] else {}, - 'created_at': row['created_at'], + "id": row["id"], + "name": row["name"], + "mode": row["mode"], + "event_type": row["event_type"], + "match": json.loads(row["match"]) if row["match"] else {}, + "severity": row["severity"], + "enabled": bool(row["enabled"]), + "notify": json.loads(row["notify"]) if row["notify"] else {}, + "created_at": row["created_at"], } for row in cursor ] def add_rule(self, rule: dict) -> int: with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO alert_rules (name, mode, event_type, match, severity, enabled, notify) VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ( - rule.get('name') or 'Alert Rule', - rule.get('mode'), - rule.get('event_type'), - json.dumps(rule.get('match') or {}), - rule.get('severity') or 'medium', - 1 if rule.get('enabled', True) else 0, - json.dumps(rule.get('notify') or {}), - )) + """, + ( + rule.get("name") or "Alert Rule", + rule.get("mode"), + rule.get("event_type"), + json.dumps(rule.get("match") or {}), + rule.get("severity") or "medium", + 1 if rule.get("enabled", True) else 0, + json.dumps(rule.get("notify") or {}), + ), + ) rule_id = cursor.lastrowid self.invalidate_cache() return int(rule_id) @@ -142,29 +147,26 @@ class AlertManager: def update_rule(self, rule_id: int, updates: dict) -> bool: fields = [] params = [] - for key in ('name', 'mode', 'event_type', 'severity'): + for key in ("name", "mode", "event_type", "severity"): if key in updates: fields.append(f"{key} = ?") params.append(updates[key]) - if 'enabled' in updates: - fields.append('enabled = ?') - params.append(1 if updates['enabled'] else 0) - if 'match' in updates: - fields.append('match = ?') - params.append(json.dumps(updates['match'] or {})) - if 'notify' in updates: - fields.append('notify = ?') - params.append(json.dumps(updates['notify'] or {})) + if "enabled" in updates: + fields.append("enabled = ?") + params.append(1 if updates["enabled"] else 0) + if "match" in updates: + fields.append("match = ?") + params.append(json.dumps(updates["match"] or {})) + if "notify" in updates: + fields.append("notify = ?") + params.append(json.dumps(updates["notify"] or {})) if not fields: return False params.append(rule_id) with get_db() as conn: - cursor = conn.execute( - f"UPDATE alert_rules SET {', '.join(fields)} WHERE id = ?", - params - ) + cursor = conn.execute(f"UPDATE alert_rules SET {', '.join(fields)} WHERE id = ?", params) updated = cursor.rowcount > 0 if updated: @@ -173,42 +175,44 @@ class AlertManager: def delete_rule(self, rule_id: int) -> bool: with get_db() as conn: - cursor = conn.execute('DELETE FROM alert_rules WHERE id = ?', (rule_id,)) + cursor = conn.execute("DELETE FROM alert_rules WHERE id = ?", (rule_id,)) deleted = cursor.rowcount > 0 if deleted: self.invalidate_cache() return deleted def list_events(self, limit: int = 100, mode: str | None = None, severity: str | None = None) -> list[dict]: - query = 'SELECT id, rule_id, mode, event_type, severity, title, message, payload, created_at FROM alert_events' + query = "SELECT id, rule_id, mode, event_type, severity, title, message, payload, created_at FROM alert_events" clauses = [] params: list[Any] = [] if mode: - clauses.append('mode = ?') + clauses.append("mode = ?") params.append(mode) if severity: - clauses.append('severity = ?') + clauses.append("severity = ?") params.append(severity) if clauses: - query += ' WHERE ' + ' AND '.join(clauses) - query += ' ORDER BY id DESC LIMIT ?' + query += " WHERE " + " AND ".join(clauses) + query += " ORDER BY id DESC LIMIT ?" params.append(limit) with get_db() as conn: cursor = conn.execute(query, params) events = [] for row in cursor: - events.append({ - 'id': row['id'], - 'rule_id': row['rule_id'], - 'mode': row['mode'], - 'event_type': row['event_type'], - 'severity': row['severity'], - 'title': row['title'], - 'message': row['message'], - 'payload': json.loads(row['payload']) if row['payload'] else {}, - 'created_at': row['created_at'], - }) + events.append( + { + "id": row["id"], + "rule_id": row["rule_id"], + "mode": row["mode"], + "event_type": row["event_type"], + "severity": row["severity"], + "title": row["title"], + "message": row["message"], + "payload": json.loads(row["payload"]) if row["payload"] else {}, + "created_at": row["created_at"], + } + ) return events # ------------------------------------------------------------------ @@ -219,7 +223,7 @@ class AlertManager: if not isinstance(event, dict): return - if event_type in ('keepalive', 'ping', 'status'): + if event_type in ("keepalive", "ping", "status"): return rules = self._get_rules() @@ -236,49 +240,49 @@ class AlertManager: if not self._match_rule(rule.match, event): continue - title = rule.name or 'Alert' + title = rule.name or "Alert" message = self._build_message(rule, event, event_type) payload = { - 'mode': mode, - 'event_type': event_type, - 'event': event, - 'rule': { - 'id': rule.id, - 'name': rule.name, + "mode": mode, + "event_type": event_type, + "event": event, + "rule": { + "id": rule.id, + "name": rule.name, }, } event_id = self._store_event(rule.id, mode, event_type, rule.severity, title, message, payload) alert_payload = { - 'id': event_id, - 'rule_id': rule.id, - 'mode': mode, - 'event_type': event_type, - 'severity': rule.severity, - 'title': title, - 'message': message, - 'payload': payload, - 'created_at': datetime.now(timezone.utc).isoformat(), + "id": event_id, + "rule_id": rule.id, + "mode": mode, + "event_type": event_type, + "severity": rule.severity, + "title": title, + "message": message, + "payload": payload, + "created_at": datetime.now(timezone.utc).isoformat(), } self._queue_event(alert_payload) self._maybe_send_webhook(alert_payload, rule.notify) def _build_message(self, rule: AlertRule, event: dict, event_type: str | None) -> str: - if isinstance(rule.notify, dict) and rule.notify.get('message'): - return str(rule.notify.get('message')) + if isinstance(rule.notify, dict) and rule.notify.get("message"): + return str(rule.notify.get("message")) summary_bits = [] if event_type: summary_bits.append(event_type) - if 'name' in event: - summary_bits.append(str(event.get('name'))) - if 'ssid' in event: - summary_bits.append(str(event.get('ssid'))) - if 'bssid' in event: - summary_bits.append(str(event.get('bssid'))) - if 'address' in event: - summary_bits.append(str(event.get('address'))) - if 'mac' in event: - summary_bits.append(str(event.get('mac'))) - summary = ' | '.join(summary_bits) if summary_bits else 'Alert triggered' + if "name" in event: + summary_bits.append(str(event.get("name"))) + if "ssid" in event: + summary_bits.append(str(event.get("ssid"))) + if "bssid" in event: + summary_bits.append(str(event.get("bssid"))) + if "address" in event: + summary_bits.append(str(event.get("address"))) + if "mac" in event: + summary_bits.append(str(event.get("mac"))) + summary = " | ".join(summary_bits) if summary_bits else "Alert triggered" return summary def _store_event( @@ -292,18 +296,21 @@ class AlertManager: payload: dict, ) -> int: with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO alert_events (rule_id, mode, event_type, severity, title, message, payload) VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ( - rule_id, - mode, - event_type, - severity, - title, - message, - json.dumps(payload), - )) + """, + ( + rule_id, + mode, + event_type, + severity, + title, + message, + json.dumps(payload), + ), + ) return int(cursor.lastrowid) def _queue_event(self, alert_payload: dict) -> None: @@ -319,20 +326,21 @@ class AlertManager: def _maybe_send_webhook(self, payload: dict, notify: dict) -> None: if not ALERT_WEBHOOK_URL: return - if isinstance(notify, dict) and notify.get('webhook') is False: + if isinstance(notify, dict) and notify.get("webhook") is False: return try: import urllib.request + req = urllib.request.Request( ALERT_WEBHOOK_URL, - data=json.dumps(payload).encode('utf-8'), + data=json.dumps(payload).encode("utf-8"), headers={ - 'Content-Type': 'application/json', - 'User-Agent': 'Intercept-Alert', - 'X-Alert-Token': ALERT_WEBHOOK_SECRET or '', + "Content-Type": "application/json", + "User-Agent": "Intercept-Alert", + "X-Alert-Token": ALERT_WEBHOOK_SECRET or "", }, - method='POST' + method="POST", ) with urllib.request.urlopen(req, timeout=ALERT_WEBHOOK_TIMEOUT) as _: pass @@ -354,10 +362,10 @@ class AlertManager: return True def _extract_value(self, event: dict, key: str) -> Any: - if '.' not in key: + if "." not in key: return event.get(key) current: Any = event - for part in key.split('.'): + for part in key.split("."): if isinstance(current, dict): current = current.get(part) else: @@ -365,9 +373,9 @@ class AlertManager: return current def _match_value(self, actual: Any, expected: Any) -> bool: - if isinstance(expected, dict) and 'op' in expected: - op = expected.get('op') - value = expected.get('value') + if isinstance(expected, dict) and "op" in expected: + op = expected.get("op") + value = expected.get("value") return self._apply_op(op, actual, value) if isinstance(expected, list): @@ -381,29 +389,29 @@ class AlertManager: return actual == expected def _apply_op(self, op: str, actual: Any, value: Any) -> bool: - if op == 'exists': + if op == "exists": return actual is not None - if op == 'eq': + if op == "eq": return actual == value - if op == 'neq': + if op == "neq": return actual != value - if op == 'gt': + if op == "gt": return _safe_number(actual) is not None and _safe_number(actual) > _safe_number(value) - if op == 'gte': + if op == "gte": return _safe_number(actual) is not None and _safe_number(actual) >= _safe_number(value) - if op == 'lt': + if op == "lt": return _safe_number(actual) is not None and _safe_number(actual) < _safe_number(value) - if op == 'lte': + if op == "lte": return _safe_number(actual) is not None and _safe_number(actual) <= _safe_number(value) - if op == 'in': + if op == "in": return actual in (value or []) - if op == 'contains': + if op == "contains": if actual is None: return False if isinstance(actual, list): return any(str(value).lower() in str(item).lower() for item in actual) return str(value).lower() in str(actual).lower() - if op == 'regex': + if op == "regex": if actual is None or value is None: return False try: @@ -422,7 +430,7 @@ class AlertManager: event = self._queue.get(timeout=timeout) yield event except queue.Empty: - yield {'type': 'keepalive'} + yield {"type": "keepalive"} _alert_manager: AlertManager | None = None diff --git a/utils/bluetooth/__init__.py b/utils/bluetooth/__init__.py index 5da5858..67335ec 100644 --- a/utils/bluetooth/__init__.py +++ b/utils/bluetooth/__init__.py @@ -48,74 +48,62 @@ from .tracker_signatures import ( __all__ = [ # Main scanner - 'BluetoothScanner', - 'get_bluetooth_scanner', - 'reset_bluetooth_scanner', - + "BluetoothScanner", + "get_bluetooth_scanner", + "reset_bluetooth_scanner", # Models - 'BTObservation', - 'BTDeviceAggregate', - 'ScanStatus', - 'SystemCapabilities', - + "BTObservation", + "BTDeviceAggregate", + "ScanStatus", + "SystemCapabilities", # Aggregator - 'DeviceAggregator', - + "DeviceAggregator", # Device key generation - 'generate_device_key', - 'is_randomized_mac', - 'extract_key_type', - + "generate_device_key", + "is_randomized_mac", + "extract_key_type", # Distance estimation - 'DistanceEstimator', - 'ProximityBand', - 'get_distance_estimator', - + "DistanceEstimator", + "ProximityBand", + "get_distance_estimator", # Ring buffer - 'RingBuffer', - 'get_ring_buffer', - 'reset_ring_buffer', - + "RingBuffer", + "get_ring_buffer", + "reset_ring_buffer", # Heuristics - 'HeuristicsEngine', - 'evaluate_device_heuristics', - 'evaluate_all_devices', - + "HeuristicsEngine", + "evaluate_device_heuristics", + "evaluate_all_devices", # Capability checks - 'check_capabilities', - 'quick_adapter_check', - + "check_capabilities", + "quick_adapter_check", # Constants - Range bands (legacy) - 'RANGE_VERY_CLOSE', - 'RANGE_CLOSE', - 'RANGE_NEARBY', - 'RANGE_FAR', - 'RANGE_UNKNOWN', - + "RANGE_VERY_CLOSE", + "RANGE_CLOSE", + "RANGE_NEARBY", + "RANGE_FAR", + "RANGE_UNKNOWN", # Constants - Proximity bands (new) - 'PROXIMITY_IMMEDIATE', - 'PROXIMITY_NEAR', - 'PROXIMITY_FAR', - 'PROXIMITY_UNKNOWN', - + "PROXIMITY_IMMEDIATE", + "PROXIMITY_NEAR", + "PROXIMITY_FAR", + "PROXIMITY_UNKNOWN", # Constants - Protocols - 'PROTOCOL_BLE', - 'PROTOCOL_CLASSIC', - 'PROTOCOL_AUTO', - + "PROTOCOL_BLE", + "PROTOCOL_CLASSIC", + "PROTOCOL_AUTO", # Constants - Address types - 'ADDRESS_TYPE_PUBLIC', - 'ADDRESS_TYPE_RANDOM', - 'ADDRESS_TYPE_RANDOM_STATIC', - 'ADDRESS_TYPE_RPA', - 'ADDRESS_TYPE_NRPA', - + "ADDRESS_TYPE_PUBLIC", + "ADDRESS_TYPE_RANDOM", + "ADDRESS_TYPE_RANDOM_STATIC", + "ADDRESS_TYPE_RPA", + "ADDRESS_TYPE_NRPA", # Tracker detection - 'TrackerSignatureEngine', - 'TrackerDetectionResult', - 'TrackerType', - 'TrackerConfidence', - 'DeviceFingerprint', - 'detect_tracker', - 'get_tracker_engine', + "TrackerSignatureEngine", + "TrackerDetectionResult", + "TrackerType", + "TrackerConfidence", + "DeviceFingerprint", + "detect_tracker", + "get_tracker_engine", ] diff --git a/utils/bluetooth/capability_check.py b/utils/bluetooth/capability_check.py index 3b548ba..ba219a8 100644 --- a/utils/bluetooth/capability_check.py +++ b/utils/bluetooth/capability_check.py @@ -21,6 +21,7 @@ from .models import SystemCapabilities # Import timeout from parent constants if available try: from ..constants import SUBPROCESS_TIMEOUT_SHORT as PARENT_TIMEOUT + SUBPROCESS_TIMEOUT_SHORT = PARENT_TIMEOUT except ImportError: SUBPROCESS_TIMEOUT_SHORT = 5 @@ -64,10 +65,11 @@ def _check_dbus(caps: SystemCapabilities) -> None: try: # Try to import dbus module import dbus + caps.has_dbus = True except ImportError: caps.has_dbus = False - caps.issues.append('Python dbus module not installed (pip install dbus-python)') + caps.issues.append("Python dbus module not installed (pip install dbus-python)") def _check_bluez(caps: SystemCapabilities) -> None: @@ -77,6 +79,7 @@ def _check_bluez(caps: SystemCapabilities) -> None: try: import dbus + bus = dbus.SystemBus() # Check if BlueZ service exists @@ -87,10 +90,7 @@ def _check_bluez(caps: SystemCapabilities) -> None: # Try to get BlueZ version from bluetoothd try: result = subprocess.run( - ['bluetoothd', '--version'], - capture_output=True, - text=True, - timeout=SUBPROCESS_TIMEOUT_SHORT + ["bluetoothd", "--version"], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT ) if result.returncode == 0: caps.bluez_version = result.stdout.strip() @@ -99,14 +99,14 @@ def _check_bluez(caps: SystemCapabilities) -> None: except dbus.exceptions.DBusException as e: caps.has_bluez = False - if 'org.freedesktop.DBus.Error.ServiceUnknown' in str(e): - caps.issues.append('BlueZ service not running (systemctl start bluetooth)') + if "org.freedesktop.DBus.Error.ServiceUnknown" in str(e): + caps.issues.append("BlueZ service not running (systemctl start bluetooth)") else: - caps.issues.append(f'BlueZ DBus error: {e}') + caps.issues.append(f"BlueZ DBus error: {e}") except Exception as e: caps.has_bluez = False - caps.issues.append(f'DBus connection error: {e}') + caps.issues.append(f"DBus connection error: {e}") def _check_adapters(caps: SystemCapabilities) -> None: @@ -118,24 +118,22 @@ def _check_adapters(caps: SystemCapabilities) -> None: try: import dbus + bus = dbus.SystemBus() - manager = dbus.Interface( - bus.get_object(BLUEZ_SERVICE, '/'), - 'org.freedesktop.DBus.ObjectManager' - ) + manager = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/"), "org.freedesktop.DBus.ObjectManager") objects = manager.GetManagedObjects() for path, interfaces in objects.items(): - if 'org.bluez.Adapter1' in interfaces: - adapter_props = interfaces['org.bluez.Adapter1'] + if "org.bluez.Adapter1" in interfaces: + adapter_props = interfaces["org.bluez.Adapter1"] adapter_info = { - 'id': str(path), # Alias for frontend - 'path': str(path), - 'name': str(adapter_props.get('Name', 'Unknown')), - 'address': str(adapter_props.get('Address', 'Unknown')), - 'powered': bool(adapter_props.get('Powered', False)), - 'discovering': bool(adapter_props.get('Discovering', False)), - 'alias': str(adapter_props.get('Alias', '')), + "id": str(path), # Alias for frontend + "path": str(path), + "name": str(adapter_props.get("Name", "Unknown")), + "address": str(adapter_props.get("Address", "Unknown")), + "powered": bool(adapter_props.get("Powered", False)), + "discovering": bool(adapter_props.get("Discovering", False)), + "alias": str(adapter_props.get("Alias", "")), } caps.adapters.append(adapter_info) @@ -144,10 +142,10 @@ def _check_adapters(caps: SystemCapabilities) -> None: caps.default_adapter = str(path) if not caps.adapters: - caps.issues.append('No Bluetooth adapters found') + caps.issues.append("No Bluetooth adapters found") except Exception as e: - caps.issues.append(f'Failed to enumerate adapters: {e}') + caps.issues.append(f"Failed to enumerate adapters: {e}") # Fall back to hciconfig _check_adapters_hciconfig(caps) @@ -155,42 +153,37 @@ def _check_adapters(caps: SystemCapabilities) -> None: def _check_adapters_hciconfig(caps: SystemCapabilities) -> None: """Check adapters using hciconfig (fallback).""" try: - result = subprocess.run( - ['hciconfig', '-a'], - capture_output=True, - text=True, - timeout=SUBPROCESS_TIMEOUT_SHORT - ) + result = subprocess.run(["hciconfig", "-a"], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT) if result.returncode == 0: # Parse hciconfig output current_adapter = None - for line in result.stdout.split('\n'): + for line in result.stdout.split("\n"): # Match adapter line (e.g., "hci0: Type: Primary Bus: USB") - adapter_match = re.match(r'^(hci\d+):', line) + adapter_match = re.match(r"^(hci\d+):", line) if adapter_match: adapter_name = adapter_match.group(1) current_adapter = { - 'id': adapter_name, # Alias for frontend - 'path': f'/org/bluez/{adapter_name}', - 'name': adapter_name, - 'address': 'Unknown', - 'powered': False, - 'discovering': False, + "id": adapter_name, # Alias for frontend + "path": f"/org/bluez/{adapter_name}", + "name": adapter_name, + "address": "Unknown", + "powered": False, + "discovering": False, } caps.adapters.append(current_adapter) if caps.default_adapter is None: - caps.default_adapter = current_adapter['path'] + caps.default_adapter = current_adapter["path"] elif current_adapter: # Parse BD Address - addr_match = re.search(r'BD Address: ([0-9A-F:]+)', line, re.I) + addr_match = re.search(r"BD Address: ([0-9A-F:]+)", line, re.I) if addr_match: - current_adapter['address'] = addr_match.group(1) + current_adapter["address"] = addr_match.group(1) # Check if UP - if 'UP RUNNING' in line: - current_adapter['powered'] = True + if "UP RUNNING" in line: + current_adapter["powered"] = True except (subprocess.TimeoutExpired, FileNotFoundError, OSError): pass @@ -200,20 +193,17 @@ def _check_rfkill(caps: SystemCapabilities) -> None: """Check rfkill status for Bluetooth.""" try: result = subprocess.run( - ['rfkill', 'list', 'bluetooth'], - capture_output=True, - text=True, - timeout=SUBPROCESS_TIMEOUT_SHORT + ["rfkill", "list", "bluetooth"], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT ) if result.returncode == 0: output = result.stdout.lower() - caps.is_soft_blocked = 'soft blocked: yes' in output - caps.is_hard_blocked = 'hard blocked: yes' in output + caps.is_soft_blocked = "soft blocked: yes" in output + caps.is_hard_blocked = "hard blocked: yes" in output if caps.is_soft_blocked: - caps.issues.append('Bluetooth is soft-blocked (rfkill unblock bluetooth)') + caps.issues.append("Bluetooth is soft-blocked (rfkill unblock bluetooth)") if caps.is_hard_blocked: - caps.issues.append('Bluetooth is hard-blocked (check hardware switch)') + caps.issues.append("Bluetooth is hard-blocked (check hardware switch)") except (subprocess.TimeoutExpired, FileNotFoundError, OSError): pass @@ -224,21 +214,22 @@ def _check_fallback_tools(caps: SystemCapabilities) -> None: # Check bleak (Python BLE library) try: import bleak + caps.has_bleak = True except ImportError: caps.has_bleak = False # Check hcitool - caps.has_hcitool = shutil.which('hcitool') is not None + caps.has_hcitool = shutil.which("hcitool") is not None # Check bluetoothctl - caps.has_bluetoothctl = shutil.which('bluetoothctl') is not None + caps.has_bluetoothctl = shutil.which("bluetoothctl") is not None # Check btmgmt - caps.has_btmgmt = shutil.which('btmgmt') is not None + caps.has_btmgmt = shutil.which("btmgmt") is not None # Check ubertooth tools (Ubertooth One hardware) - caps.has_ubertooth = shutil.which('ubertooth-btle') is not None + caps.has_ubertooth = shutil.which("ubertooth-btle") is not None # Check CAP_NET_ADMIN for non-root users if not caps.is_root: @@ -248,14 +239,9 @@ def _check_fallback_tools(caps: SystemCapabilities) -> None: def _check_capabilities_permission(caps: SystemCapabilities) -> None: """Check if process has CAP_NET_ADMIN capability.""" try: - result = subprocess.run( - ['capsh', '--print'], - capture_output=True, - text=True, - timeout=SUBPROCESS_TIMEOUT_SHORT - ) + result = subprocess.run(["capsh", "--print"], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT) if result.returncode == 0: - caps.has_bluetooth_permission = 'cap_net_admin' in result.stdout.lower() + caps.has_bluetooth_permission = "cap_net_admin" in result.stdout.lower() except (subprocess.TimeoutExpired, FileNotFoundError, OSError): # Assume no capabilities if capsh not available pass @@ -265,8 +251,9 @@ def _check_capabilities_permission(caps: SystemCapabilities) -> None: try: import grp import pwd + username = pwd.getpwuid(os.getuid()).pw_name - bluetooth_group = grp.getgrnam('bluetooth') + bluetooth_group = grp.getgrnam("bluetooth") if username in bluetooth_group.gr_mem: caps.has_bluetooth_permission = True except (KeyError, ImportError): @@ -280,28 +267,28 @@ def _determine_recommended_backend(caps: SystemCapabilities) -> None: # Prefer bleak (cross-platform, works in Flask) if caps.has_bleak: - caps.recommended_backend = 'bleak' + caps.recommended_backend = "bleak" return # Fallback to hcitool (requires root on Linux) if caps.has_hcitool and caps.is_root: - caps.recommended_backend = 'hcitool' + caps.recommended_backend = "hcitool" return # Fallback to bluetoothctl if caps.has_bluetoothctl: - caps.recommended_backend = 'bluetoothctl' + caps.recommended_backend = "bluetoothctl" return # DBus is last resort - won't work properly with Flask but keep as option # for potential future use with a separate scanning daemon if caps.has_dbus and caps.has_bluez and caps.adapters and not caps.is_soft_blocked and not caps.is_hard_blocked: - caps.recommended_backend = 'dbus' + caps.recommended_backend = "dbus" return - caps.recommended_backend = 'none' + caps.recommended_backend = "none" if not caps.issues: - caps.issues.append('No suitable Bluetooth scanning backend available') + caps.issues.append("No suitable Bluetooth scanning backend available") def quick_adapter_check() -> str | None: diff --git a/utils/bluetooth/constants.py b/utils/bluetooth/constants.py index ec289ff..123c31c 100644 --- a/utils/bluetooth/constants.py +++ b/utils/bluetooth/constants.py @@ -25,10 +25,10 @@ OBSERVATION_HISTORY_RETENTION = 3600 # 1 hour # ============================================================================= # RSSI ranges for distance estimation (dBm) -RSSI_VERY_CLOSE = -40 # >= -40 dBm -RSSI_CLOSE = -55 # -40 to -55 dBm -RSSI_NEARBY = -70 # -55 to -70 dBm -RSSI_FAR = -85 # -70 to -85 dBm +RSSI_VERY_CLOSE = -40 # >= -40 dBm +RSSI_CLOSE = -55 # -40 to -55 dBm +RSSI_NEARBY = -70 # -55 to -70 dBm +RSSI_FAR = -85 # -70 to -85 dBm # Minimum confidence levels for each range band CONFIDENCE_VERY_CLOSE = 0.7 @@ -59,17 +59,17 @@ NEW_DEVICE_WINDOW = 60 # ============================================================================= # BlueZ DBus service names -BLUEZ_SERVICE = 'org.bluez' -BLUEZ_ADAPTER_INTERFACE = 'org.bluez.Adapter1' -BLUEZ_DEVICE_INTERFACE = 'org.bluez.Device1' -DBUS_PROPERTIES_INTERFACE = 'org.freedesktop.DBus.Properties' -DBUS_OBJECT_MANAGER_INTERFACE = 'org.freedesktop.DBus.ObjectManager' +BLUEZ_SERVICE = "org.bluez" +BLUEZ_ADAPTER_INTERFACE = "org.bluez.Adapter1" +BLUEZ_DEVICE_INTERFACE = "org.bluez.Device1" +DBUS_PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties" +DBUS_OBJECT_MANAGER_INTERFACE = "org.freedesktop.DBus.ObjectManager" # DBus paths -BLUEZ_PATH = '/org/bluez' +BLUEZ_PATH = "/org/bluez" # Discovery filter settings -DISCOVERY_FILTER_TRANSPORT = 'auto' # 'bredr', 'le', or 'auto' +DISCOVERY_FILTER_TRANSPORT = "auto" # 'bredr', 'le', or 'auto' DISCOVERY_FILTER_RSSI = -100 # Minimum RSSI for discovery DISCOVERY_FILTER_DUPLICATE_DATA = True @@ -96,44 +96,44 @@ SUBPROCESS_TIMEOUT_SHORT = 5.0 # ADDRESS TYPE CLASSIFICATIONS # ============================================================================= -ADDRESS_TYPE_PUBLIC = 'public' -ADDRESS_TYPE_RANDOM = 'random' -ADDRESS_TYPE_RANDOM_STATIC = 'random_static' -ADDRESS_TYPE_RPA = 'rpa' # Resolvable Private Address -ADDRESS_TYPE_NRPA = 'nrpa' # Non-Resolvable Private Address -ADDRESS_TYPE_UUID = 'uuid' # CoreBluetooth platform UUID (macOS, no real MAC available) +ADDRESS_TYPE_PUBLIC = "public" +ADDRESS_TYPE_RANDOM = "random" +ADDRESS_TYPE_RANDOM_STATIC = "random_static" +ADDRESS_TYPE_RPA = "rpa" # Resolvable Private Address +ADDRESS_TYPE_NRPA = "nrpa" # Non-Resolvable Private Address +ADDRESS_TYPE_UUID = "uuid" # CoreBluetooth platform UUID (macOS, no real MAC available) # ============================================================================= # PROTOCOL TYPES # ============================================================================= -PROTOCOL_BLE = 'ble' -PROTOCOL_CLASSIC = 'classic' -PROTOCOL_AUTO = 'auto' +PROTOCOL_BLE = "ble" +PROTOCOL_CLASSIC = "classic" +PROTOCOL_AUTO = "auto" # ============================================================================= # RANGE BAND NAMES # ============================================================================= -RANGE_VERY_CLOSE = 'very_close' -RANGE_CLOSE = 'close' -RANGE_NEARBY = 'nearby' -RANGE_FAR = 'far' -RANGE_UNKNOWN = 'unknown' +RANGE_VERY_CLOSE = "very_close" +RANGE_CLOSE = "close" +RANGE_NEARBY = "nearby" +RANGE_FAR = "far" +RANGE_UNKNOWN = "unknown" # ============================================================================= # PROXIMITY BANDS (new visualization system) # ============================================================================= -PROXIMITY_IMMEDIATE = 'immediate' # < 1m -PROXIMITY_NEAR = 'near' # 1-3m -PROXIMITY_FAR = 'far' # 3-10m -PROXIMITY_UNKNOWN = 'unknown' +PROXIMITY_IMMEDIATE = "immediate" # < 1m +PROXIMITY_NEAR = "near" # 1-3m +PROXIMITY_FAR = "far" # 3-10m +PROXIMITY_UNKNOWN = "unknown" # RSSI thresholds for proximity band classification (dBm) PROXIMITY_RSSI_IMMEDIATE = -40 # >= -40 dBm -> immediate -PROXIMITY_RSSI_NEAR = -55 # >= -55 dBm -> near -PROXIMITY_RSSI_FAR = -75 # >= -75 dBm -> far +PROXIMITY_RSSI_NEAR = -55 # >= -55 dBm -> near +PROXIMITY_RSSI_FAR = -75 # >= -75 dBm -> far # ============================================================================= # DISTANCE ESTIMATION SETTINGS @@ -149,7 +149,7 @@ DISTANCE_RSSI_AT_1M = -59 DISTANCE_EMA_ALPHA = 0.3 # Variance thresholds for confidence scoring (dBm^2) -DISTANCE_LOW_VARIANCE = 25.0 # High confidence +DISTANCE_LOW_VARIANCE = 25.0 # High confidence DISTANCE_HIGH_VARIANCE = 100.0 # Low confidence # ============================================================================= @@ -183,22 +183,22 @@ HEATMAP_MAX_DEVICES = 50 # ============================================================================= MANUFACTURER_NAMES = { - 0x004C: 'Apple, Inc.', - 0x0006: 'Microsoft', - 0x000F: 'Broadcom', - 0x0075: 'Samsung Electronics', - 0x00E0: 'Google', - 0x0157: 'Xiaomi', - 0x0310: 'Bose Corporation', - 0x0059: 'Nordic Semiconductor', - 0x0046: 'Sony Corporation', - 0x0002: 'Intel Corporation', - 0x0087: 'Garmin International', - 0x00D2: 'Fitbit', - 0x0154: 'Huawei Technologies', - 0x038F: 'Tile, Inc.', - 0x0301: 'Jabra', - 0x01DA: 'Anker Innovations', + 0x004C: "Apple, Inc.", + 0x0006: "Microsoft", + 0x000F: "Broadcom", + 0x0075: "Samsung Electronics", + 0x00E0: "Google", + 0x0157: "Xiaomi", + 0x0310: "Bose Corporation", + 0x0059: "Nordic Semiconductor", + 0x0046: "Sony Corporation", + 0x0002: "Intel Corporation", + 0x0087: "Garmin International", + 0x00D2: "Fitbit", + 0x0154: "Huawei Technologies", + 0x038F: "Tile, Inc.", + 0x0301: "Jabra", + 0x01DA: "Anker Innovations", } # ============================================================================= @@ -207,77 +207,77 @@ MANUFACTURER_NAMES = { # Major device classes (bits 12-8 of CoD) MAJOR_DEVICE_CLASSES = { - 0x00: 'Miscellaneous', - 0x01: 'Computer', - 0x02: 'Phone', - 0x03: 'LAN/Network Access Point', - 0x04: 'Audio/Video', - 0x05: 'Peripheral', - 0x06: 'Imaging', - 0x07: 'Wearable', - 0x08: 'Toy', - 0x09: 'Health', - 0x1F: 'Uncategorized', + 0x00: "Miscellaneous", + 0x01: "Computer", + 0x02: "Phone", + 0x03: "LAN/Network Access Point", + 0x04: "Audio/Video", + 0x05: "Peripheral", + 0x06: "Imaging", + 0x07: "Wearable", + 0x08: "Toy", + 0x09: "Health", + 0x1F: "Uncategorized", } # Minor device classes for Audio/Video (0x04) MINOR_AUDIO_VIDEO = { - 0x00: 'Uncategorized', - 0x01: 'Wearable Headset', - 0x02: 'Hands-free Device', - 0x04: 'Microphone', - 0x05: 'Loudspeaker', - 0x06: 'Headphones', - 0x07: 'Portable Audio', - 0x08: 'Car Audio', - 0x09: 'Set-top Box', - 0x0A: 'HiFi Audio Device', - 0x0B: 'VCR', - 0x0C: 'Video Camera', - 0x0D: 'Camcorder', - 0x0E: 'Video Monitor', - 0x0F: 'Video Display and Loudspeaker', - 0x10: 'Video Conferencing', - 0x12: 'Gaming/Toy', + 0x00: "Uncategorized", + 0x01: "Wearable Headset", + 0x02: "Hands-free Device", + 0x04: "Microphone", + 0x05: "Loudspeaker", + 0x06: "Headphones", + 0x07: "Portable Audio", + 0x08: "Car Audio", + 0x09: "Set-top Box", + 0x0A: "HiFi Audio Device", + 0x0B: "VCR", + 0x0C: "Video Camera", + 0x0D: "Camcorder", + 0x0E: "Video Monitor", + 0x0F: "Video Display and Loudspeaker", + 0x10: "Video Conferencing", + 0x12: "Gaming/Toy", } # Minor device classes for Phone (0x02) MINOR_PHONE = { - 0x00: 'Uncategorized', - 0x01: 'Cellular', - 0x02: 'Cordless', - 0x03: 'Smartphone', - 0x04: 'Wired Modem', - 0x05: 'ISDN Access Point', + 0x00: "Uncategorized", + 0x01: "Cellular", + 0x02: "Cordless", + 0x03: "Smartphone", + 0x04: "Wired Modem", + 0x05: "ISDN Access Point", } # Minor device classes for Computer (0x01) MINOR_COMPUTER = { - 0x00: 'Uncategorized', - 0x01: 'Desktop Workstation', - 0x02: 'Server-class Computer', - 0x03: 'Laptop', - 0x04: 'Handheld PC/PDA', - 0x05: 'Palm-size PC/PDA', - 0x06: 'Wearable Computer', - 0x07: 'Tablet', + 0x00: "Uncategorized", + 0x01: "Desktop Workstation", + 0x02: "Server-class Computer", + 0x03: "Laptop", + 0x04: "Handheld PC/PDA", + 0x05: "Palm-size PC/PDA", + 0x06: "Wearable Computer", + 0x07: "Tablet", } # Minor device classes for Peripheral (0x05) MINOR_PERIPHERAL = { - 0x00: 'Not Keyboard/Pointing Device', - 0x01: 'Keyboard', - 0x02: 'Pointing Device', - 0x03: 'Combo Keyboard/Pointing Device', + 0x00: "Not Keyboard/Pointing Device", + 0x01: "Keyboard", + 0x02: "Pointing Device", + 0x03: "Combo Keyboard/Pointing Device", } # Minor device classes for Wearable (0x07) MINOR_WEARABLE = { - 0x01: 'Wristwatch', - 0x02: 'Pager', - 0x03: 'Jacket', - 0x04: 'Helmet', - 0x05: 'Glasses', + 0x01: "Wristwatch", + 0x02: "Pager", + 0x03: "Jacket", + 0x04: "Helmet", + 0x05: "Glasses", } # ============================================================================= @@ -285,48 +285,48 @@ MINOR_WEARABLE = { # ============================================================================= BLE_APPEARANCE_NAMES: dict[int, str] = { - 0: 'Unknown', - 64: 'Phone', - 128: 'Computer', - 192: 'Watch', - 193: 'Sports Watch', - 256: 'Clock', - 320: 'Display', - 384: 'Remote Control', - 448: 'Eye Glasses', - 512: 'Tag', - 576: 'Keyring', - 640: 'Media Player', - 704: 'Barcode Scanner', - 768: 'Thermometer', - 832: 'Heart Rate Sensor', - 896: 'Blood Pressure', - 960: 'HID', - 961: 'Keyboard', - 962: 'Mouse', - 963: 'Joystick', - 964: 'Gamepad', - 965: 'Digitizer Tablet', - 966: 'Card Reader', - 967: 'Digital Pen', - 968: 'Barcode Scanner (HID)', - 1024: 'Glucose Monitor', - 1088: 'Running Speed Sensor', - 1152: 'Cycling', - 1216: 'Control Device', - 1280: 'Network Device', - 1344: 'Sensor', - 1408: 'Light Fixture', - 1472: 'Fan', - 1536: 'HVAC', - 1600: 'Access Control', - 1664: 'Motorized Device', - 1728: 'Power Device', - 1792: 'Light Source', - 3136: 'Pulse Oximeter', - 3200: 'Weight Scale', - 3264: 'Personal Mobility', - 5184: 'Outdoor Sports Activity', + 0: "Unknown", + 64: "Phone", + 128: "Computer", + 192: "Watch", + 193: "Sports Watch", + 256: "Clock", + 320: "Display", + 384: "Remote Control", + 448: "Eye Glasses", + 512: "Tag", + 576: "Keyring", + 640: "Media Player", + 704: "Barcode Scanner", + 768: "Thermometer", + 832: "Heart Rate Sensor", + 896: "Blood Pressure", + 960: "HID", + 961: "Keyboard", + 962: "Mouse", + 963: "Joystick", + 964: "Gamepad", + 965: "Digitizer Tablet", + 966: "Card Reader", + 967: "Digital Pen", + 968: "Barcode Scanner (HID)", + 1024: "Glucose Monitor", + 1088: "Running Speed Sensor", + 1152: "Cycling", + 1216: "Control Device", + 1280: "Network Device", + 1344: "Sensor", + 1408: "Light Fixture", + 1472: "Fan", + 1536: "HVAC", + 1600: "Access Control", + 1664: "Motorized Device", + 1728: "Power Device", + 1792: "Light Source", + 3136: "Pulse Oximeter", + 3200: "Weight Scale", + 3264: "Personal Mobility", + 5184: "Outdoor Sports Activity", } diff --git a/utils/bluetooth/dbus_scanner.py b/utils/bluetooth/dbus_scanner.py index acd0304..9043b77 100644 --- a/utils/bluetooth/dbus_scanner.py +++ b/utils/bluetooth/dbus_scanner.py @@ -61,7 +61,7 @@ class DBusScanner: self._lock = threading.Lock() self._known_devices: set[str] = set() - def start(self, transport: str = 'auto', rssi_threshold: int = -100) -> bool: + def start(self, transport: str = "auto", rssi_threshold: int = -100) -> bool: """ Start DBus discovery. @@ -100,26 +100,26 @@ class DBusScanner: # Set up signal handlers self._bus.add_signal_receiver( self._on_interfaces_added, - signal_name='InterfacesAdded', + signal_name="InterfacesAdded", dbus_interface=DBUS_OBJECT_MANAGER_INTERFACE, bus_name=BLUEZ_SERVICE, ) self._bus.add_signal_receiver( self._on_properties_changed, - signal_name='PropertiesChanged', + signal_name="PropertiesChanged", dbus_interface=DBUS_PROPERTIES_INTERFACE, - path_keyword='path', + path_keyword="path", ) # Set discovery filter try: filter_dict = { - 'Transport': dbus.String(transport if transport != 'auto' else 'auto'), - 'DuplicateData': dbus.Boolean(DISCOVERY_FILTER_DUPLICATE_DATA), + "Transport": dbus.String(transport if transport != "auto" else "auto"), + "DuplicateData": dbus.Boolean(DISCOVERY_FILTER_DUPLICATE_DATA), } if rssi_threshold > -100: - filter_dict['RSSI'] = dbus.Int16(rssi_threshold) + filter_dict["RSSI"] = dbus.Int16(rssi_threshold) self._adapter.SetDiscoveryFilter(filter_dict) except dbus.exceptions.DBusException as e: @@ -129,7 +129,7 @@ class DBusScanner: try: self._adapter.StartDiscovery() except dbus.exceptions.DBusException as e: - if 'InProgress' not in str(e): + if "InProgress" not in str(e): logger.error(f"Failed to start discovery: {e}") return False @@ -138,10 +138,7 @@ class DBusScanner: # Start mainloop in background thread self._mainloop = GLib.MainLoop() - self._mainloop_thread = threading.Thread( - target=self._run_mainloop, - daemon=True - ) + self._mainloop_thread = threading.Thread(target=self._run_mainloop, daemon=True) self._mainloop_thread.start() self._is_scanning = True @@ -201,10 +198,8 @@ class DBusScanner: """Find the default Bluetooth adapter via DBus.""" try: import dbus - manager = dbus.Interface( - self._bus.get_object(BLUEZ_SERVICE, '/'), - DBUS_OBJECT_MANAGER_INTERFACE - ) + + manager = dbus.Interface(self._bus.get_object(BLUEZ_SERVICE, "/"), DBUS_OBJECT_MANAGER_INTERFACE) objects = manager.GetManagedObjects() for path, interfaces in objects.items(): @@ -219,10 +214,8 @@ class DBusScanner: """Process devices that already exist in BlueZ.""" try: import dbus - manager = dbus.Interface( - self._bus.get_object(BLUEZ_SERVICE, '/'), - DBUS_OBJECT_MANAGER_INTERFACE - ) + + manager = dbus.Interface(self._bus.get_object(BLUEZ_SERVICE, "/"), DBUS_OBJECT_MANAGER_INTERFACE) objects = manager.GetManagedObjects() for path, interfaces in objects.items(): @@ -239,20 +232,15 @@ class DBusScanner: props = interfaces[BLUEZ_DEVICE_INTERFACE] self._process_device_properties(str(path), props) - def _on_properties_changed( - self, - interface: str, - changed: dict, - invalidated: list, - path: str = None - ) -> None: + def _on_properties_changed(self, interface: str, changed: dict, invalidated: list, path: str = None) -> None: """Handle PropertiesChanged signal (device properties updated).""" if interface != BLUEZ_DEVICE_INTERFACE: return - if path and '/dev_' in path: + if path and "/dev_" in path: try: import dbus + device_obj = self._bus.get_object(BLUEZ_SERVICE, path) props_iface = dbus.Interface(device_obj, DBUS_PROPERTIES_INTERFACE) all_props = props_iface.GetAll(BLUEZ_DEVICE_INTERFACE) @@ -265,40 +253,40 @@ class DBusScanner: try: import dbus - address = str(props.get('Address', '')) + address = str(props.get("Address", "")) if not address: return # Determine address type address_type = ADDRESS_TYPE_PUBLIC - addr_type_raw = props.get('AddressType', 'public') + addr_type_raw = props.get("AddressType", "public") if addr_type_raw: addr_type_str = str(addr_type_raw).lower() - if 'random' in addr_type_str: + if "random" in addr_type_str: address_type = ADDRESS_TYPE_RANDOM # Extract name name = None - if 'Name' in props: - name = str(props['Name']) - elif 'Alias' in props and props['Alias'] != address: - name = str(props['Alias']) + if "Name" in props: + name = str(props["Name"]) + elif "Alias" in props and props["Alias"] != address: + name = str(props["Alias"]) # Extract RSSI rssi = None - if 'RSSI' in props: - rssi = int(props['RSSI']) + if "RSSI" in props: + rssi = int(props["RSSI"]) # Extract TX Power tx_power = None - if 'TxPower' in props: - tx_power = int(props['TxPower']) + if "TxPower" in props: + tx_power = int(props["TxPower"]) # Extract manufacturer data manufacturer_id = None manufacturer_data = None - if 'ManufacturerData' in props: - mfr_data = props['ManufacturerData'] + if "ManufacturerData" in props: + mfr_data = props["ManufacturerData"] if mfr_data: for mid, mdata in mfr_data.items(): manufacturer_id = int(mid) @@ -314,14 +302,14 @@ class DBusScanner: # Extract service UUIDs service_uuids = [] - if 'UUIDs' in props: - for uuid in props['UUIDs']: + if "UUIDs" in props: + for uuid in props["UUIDs"]: service_uuids.append(str(uuid)) # Extract service data service_data = {} - if 'ServiceData' in props: - for uuid, data in props['ServiceData'].items(): + if "ServiceData" in props: + for uuid, data in props["ServiceData"].items(): try: if isinstance(data, (bytes, bytearray, dbus.Array, list, tuple)): service_data[str(uuid)] = bytes(data) @@ -334,18 +322,18 @@ class DBusScanner: class_of_device = None major_class = None minor_class = None - if 'Class' in props: - class_of_device = int(props['Class']) + if "Class" in props: + class_of_device = int(props["Class"]) major_class, minor_class = self._decode_class_of_device(class_of_device) # Connection state - is_connected = bool(props.get('Connected', False)) - is_paired = bool(props.get('Paired', False)) + is_connected = bool(props.get("Connected", False)) + is_paired = bool(props.get("Paired", False)) # Appearance appearance = None - if 'Appearance' in props: - appearance = int(props['Appearance']) + if "Appearance" in props: + appearance = int(props["Appearance"]) # Create observation observation = BTObservation( diff --git a/utils/bluetooth/device_key.py b/utils/bluetooth/device_key.py index 17519a1..f6b267c 100644 --- a/utils/bluetooth/device_key.py +++ b/utils/bluetooth/device_key.py @@ -119,6 +119,6 @@ def extract_key_type(device_key: str) -> str: Returns: The key type ('id', 'mac', or 'fp'). """ - if ':' in device_key: - return device_key.split(':', 1)[0] - return 'unknown' + if ":" in device_key: + return device_key.split(":", 1)[0] + return "unknown" diff --git a/utils/bluetooth/distance.py b/utils/bluetooth/distance.py index 962dd8a..506d2f5 100644 --- a/utils/bluetooth/distance.py +++ b/utils/bluetooth/distance.py @@ -12,10 +12,11 @@ from enum import Enum class ProximityBand(str, Enum): """Proximity band classifications.""" - IMMEDIATE = 'immediate' # < 1m - NEAR = 'near' # 1-3m - FAR = 'far' # 3-10m - UNKNOWN = 'unknown' # Cannot determine + + IMMEDIATE = "immediate" # < 1m + NEAR = "near" # 1-3m + FAR = "far" # 3-10m + UNKNOWN = "unknown" # Cannot determine def __str__(self) -> str: return self.value @@ -26,8 +27,8 @@ DEFAULT_PATH_LOSS_EXPONENT = 2.5 # RSSI thresholds for band classification (dBm) RSSI_THRESHOLD_IMMEDIATE = -40 # >= -40 dBm -RSSI_THRESHOLD_NEAR = -55 # >= -55 dBm -RSSI_THRESHOLD_FAR = -75 # >= -75 dBm +RSSI_THRESHOLD_NEAR = -55 # >= -55 dBm +RSSI_THRESHOLD_FAR = -75 # >= -75 dBm # Default reference RSSI at 1 meter (typical BLE) DEFAULT_RSSI_AT_1M = -59 @@ -36,8 +37,8 @@ DEFAULT_RSSI_AT_1M = -59 DEFAULT_EMA_ALPHA = 0.3 # Variance thresholds for confidence scoring -LOW_VARIANCE_THRESHOLD = 25.0 # dBm^2 -HIGH_VARIANCE_THRESHOLD = 100.0 # dBm^2 +LOW_VARIANCE_THRESHOLD = 25.0 # dBm^2 +HIGH_VARIANCE_THRESHOLD = 100.0 # dBm^2 class DistanceEstimator: @@ -117,7 +118,7 @@ class DistanceEstimator: Estimated distance in meters. """ exponent = (tx_power - rssi) / (10 * self.path_loss_exponent) - distance = 10 ** exponent + distance = 10**exponent # Clamp to reasonable range return max(0.1, min(100.0, distance)) diff --git a/utils/bluetooth/fallback_scanner.py b/utils/bluetooth/fallback_scanner.py index c52c847..2eca2b1 100644 --- a/utils/bluetooth/fallback_scanner.py +++ b/utils/bluetooth/fallback_scanner.py @@ -26,7 +26,7 @@ from .constants import ( ) # CoreBluetooth UUID pattern: 8-4-4-4-12 hex digits -_CB_UUID_RE = re.compile(r'^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$') +_CB_UUID_RE = re.compile(r"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$") import contextlib from .models import BTObservation @@ -60,11 +60,7 @@ class BleakScanner: return True self._stop_event.clear() - self._scan_thread = threading.Thread( - target=self._scan_loop, - args=(duration,), - daemon=True - ) + self._scan_thread = threading.Thread(target=self._scan_loop, args=(duration,), daemon=True) self._scan_thread.start() self._is_scanning = True logger.info("Bleak scanner started") @@ -138,9 +134,9 @@ class BleakScanner: if device.address and _CB_UUID_RE.match(device.address): # macOS CoreBluetooth returns a platform UUID instead of a real MAC address_type = ADDRESS_TYPE_UUID - elif device.address and ':' in device.address: + elif device.address and ":" in device.address: # Check if first byte indicates random address - first_byte = int(device.address.split(':')[0], 16) + first_byte = int(device.address.split(":")[0], 16) if (first_byte & 0xC0) == 0xC0: # Random static address_type = ADDRESS_TYPE_RANDOM @@ -178,7 +174,7 @@ class BleakScanner: return BTObservation( timestamp=datetime.now(), - address=device.address.upper() if device.address else '', + address=device.address.upper() if device.address else "", address_type=address_type, rssi=adv_data.rssi, tx_power=adv_data.tx_power, @@ -187,7 +183,7 @@ class BleakScanner: manufacturer_data=manufacturer_data, service_uuids=list(adv_data.service_uuids) if adv_data.service_uuids else [], service_data=service_data, - is_connectable=getattr(adv_data, 'connectable', True) if adv_data else True, + is_connectable=getattr(adv_data, "connectable", True) if adv_data else True, ) @@ -200,7 +196,7 @@ class HcitoolScanner: def __init__( self, - adapter: str = 'hci0', + adapter: str = "hci0", on_observation: Callable[[BTObservation], None] | None = None, ): self._adapter = adapter @@ -218,17 +214,14 @@ class HcitoolScanner: # Start hcitool lescan with duplicate reporting self._process = subprocess.Popen( - ['hcitool', '-i', self._adapter, 'lescan', '--duplicates'], + ["hcitool", "-i", self._adapter, "lescan", "--duplicates"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True + text=True, ) self._stop_event.clear() - self._reader_thread = threading.Thread( - target=self._read_output, - daemon=True - ) + self._reader_thread = threading.Thread(target=self._read_output, daemon=True) self._reader_thread.start() self._is_scanning = True logger.info(f"hcitool scanner started on {self._adapter}") @@ -272,7 +265,7 @@ class HcitoolScanner: dump_process = None with contextlib.suppress(Exception): dump_process = subprocess.Popen( - ['hcidump', '-i', self._adapter, '--raw'], + ["hcidump", "-i", self._adapter, "--raw"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -283,7 +276,7 @@ class HcitoolScanner: break # Parse hcitool output: "AA:BB:CC:DD:EE:FF DeviceName" - match = re.match(r'^([0-9A-Fa-f:]{17})\s*(.*)$', line.strip()) + match = re.match(r"^([0-9A-Fa-f:]{17})\s*(.*)$", line.strip()) if match: address = match.group(1).upper() name = match.group(2).strip() or None @@ -292,7 +285,7 @@ class HcitoolScanner: timestamp=datetime.now(), address=address, address_type=ADDRESS_TYPE_PUBLIC, - name=name if name and name != '(unknown)' else None, + name=name if name and name != "(unknown)" else None, ) if self._on_observation: @@ -332,22 +325,15 @@ class BluetoothctlScanner: return True self._process = subprocess.Popen( - ['bluetoothctl'], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True + ["bluetoothctl"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) self._stop_event.clear() - self._reader_thread = threading.Thread( - target=self._read_output, - daemon=True - ) + self._reader_thread = threading.Thread(target=self._read_output, daemon=True) self._reader_thread.start() # Send scan on command - self._process.stdin.write('scan on\n') + self._process.stdin.write("scan on\n") self._process.stdin.flush() self._is_scanning = True @@ -367,8 +353,8 @@ class BluetoothctlScanner: if self._process: try: - self._process.stdin.write('scan off\n') - self._process.stdin.write('quit\n') + self._process.stdin.write("scan off\n") + self._process.stdin.write("quit\n") self._process.stdin.flush() self._process.wait(timeout=2.0) except Exception: @@ -401,18 +387,15 @@ class BluetoothctlScanner: # [CHG] Device AA:BB:CC:DD:EE:FF RSSI: -65 # [CHG] Device AA:BB:CC:DD:EE:FF Name: DeviceName - new_match = re.search( - r'\[NEW\]\s+Device\s+([0-9A-Fa-f:]{17})\s*(.*)', - line - ) + new_match = re.search(r"\[NEW\]\s+Device\s+([0-9A-Fa-f:]{17})\s*(.*)", line) if new_match: address = new_match.group(1).upper() name = new_match.group(2).strip() or None self._devices[address] = { - 'address': address, - 'name': name, - 'rssi': None, + "address": address, + "name": name, + "rssi": None, } observation = BTObservation( @@ -427,23 +410,20 @@ class BluetoothctlScanner: continue # RSSI change - rssi_match = re.search( - r'\[CHG\]\s+Device\s+([0-9A-Fa-f:]{17})\s+RSSI:\s*(-?\d+)', - line - ) + rssi_match = re.search(r"\[CHG\]\s+Device\s+([0-9A-Fa-f:]{17})\s+RSSI:\s*(-?\d+)", line) if rssi_match: address = rssi_match.group(1).upper() rssi = int(rssi_match.group(2)) - device_data = self._devices.get(address, {'address': address}) - device_data['rssi'] = rssi + device_data = self._devices.get(address, {"address": address}) + device_data["rssi"] = rssi self._devices[address] = device_data observation = BTObservation( timestamp=datetime.now(), address=address, address_type=ADDRESS_TYPE_PUBLIC, - name=device_data.get('name'), + name=device_data.get("name"), rssi=rssi, ) @@ -452,16 +432,13 @@ class BluetoothctlScanner: continue # Name change - name_match = re.search( - r'\[CHG\]\s+Device\s+([0-9A-Fa-f:]{17})\s+Name:\s*(.+)', - line - ) + name_match = re.search(r"\[CHG\]\s+Device\s+([0-9A-Fa-f:]{17})\s+Name:\s*(.+)", line) if name_match: address = name_match.group(1).upper() name = name_match.group(2).strip() - device_data = self._devices.get(address, {'address': address}) - device_data['name'] = name + device_data = self._devices.get(address, {"address": address}) + device_data["name"] = name self._devices[address] = device_data observation = BTObservation( @@ -469,7 +446,7 @@ class BluetoothctlScanner: address=address, address_type=ADDRESS_TYPE_PUBLIC, name=name, - rssi=device_data.get('rssi'), + rssi=device_data.get("rssi"), ) if self._on_observation: @@ -488,7 +465,7 @@ class FallbackScanner: def __init__( self, - adapter: str = 'hci0', + adapter: str = "hci0", on_observation: Callable[[BTObservation], None] | None = None, ): self._adapter = adapter @@ -501,21 +478,19 @@ class FallbackScanner: # Try bleak first (cross-platform) try: import bleak + self._active_scanner = BleakScanner(on_observation=self._on_observation) if self._active_scanner.start(): - self._backend = 'bleak' + self._backend = "bleak" return True except ImportError: pass # Try hcitool (requires root) try: - self._active_scanner = HcitoolScanner( - adapter=self._adapter, - on_observation=self._on_observation - ) + self._active_scanner = HcitoolScanner(adapter=self._adapter, on_observation=self._on_observation) if self._active_scanner.start(): - self._backend = 'hcitool' + self._backend = "hcitool" return True except Exception: pass @@ -524,7 +499,7 @@ class FallbackScanner: try: self._active_scanner = BluetoothctlScanner(on_observation=self._on_observation) if self._active_scanner.start(): - self._backend = 'bluetoothctl' + self._backend = "bluetoothctl" return True except Exception: pass @@ -532,9 +507,10 @@ class FallbackScanner: # Try ubertooth (raw packet capture with Ubertooth One hardware) try: from .ubertooth_scanner import UbertoothScanner + self._active_scanner = UbertoothScanner(on_observation=self._on_observation) if self._active_scanner.start(): - self._backend = 'ubertooth' + self._backend = "ubertooth" return True except Exception: pass diff --git a/utils/bluetooth/irk_extractor.py b/utils/bluetooth/irk_extractor.py index b712fa9..3d53f06 100644 --- a/utils/bluetooth/irk_extractor.py +++ b/utils/bluetooth/irk_extractor.py @@ -11,7 +11,7 @@ import platform import time from pathlib import Path -logger = logging.getLogger('intercept.bt.irk_extractor') +logger = logging.getLogger("intercept.bt.irk_extractor") # Cache paired IRKs for 30 seconds to avoid repeated disk reads _cache: list[dict] | None = None @@ -38,9 +38,9 @@ def get_paired_irks() -> list[dict]: system = platform.system() try: - if system == 'Darwin': + if system == "Darwin": results = _extract_macos() - elif system == 'Linux': + elif system == "Linux": results = _extract_linux() else: logger.debug(f"IRK extraction not supported on {system}") @@ -58,24 +58,24 @@ def _extract_macos() -> list[dict]: """Extract IRKs from macOS Bluetooth plist.""" import plistlib - plist_path = Path('/Library/Preferences/com.apple.Bluetooth.plist') + plist_path = Path("/Library/Preferences/com.apple.Bluetooth.plist") if not plist_path.exists(): logger.debug("macOS Bluetooth plist not found") return [] - with open(plist_path, 'rb') as f: + with open(plist_path, "rb") as f: plist = plistlib.load(f) devices = [] - cache_data = plist.get('CoreBluetoothCache', {}) + cache_data = plist.get("CoreBluetoothCache", {}) # CoreBluetoothCache contains BLE device info including IRKs for device_uuid, device_info in cache_data.items(): if not isinstance(device_info, dict): continue - irk = device_info.get('IRK') + irk = device_info.get("IRK") if irk is None: continue @@ -83,57 +83,61 @@ def _extract_macos() -> list[dict]: if isinstance(irk, bytes) and len(irk) == 16: irk_hex = irk.hex() elif isinstance(irk, str): - irk_hex = irk.replace('-', '').replace(' ', '') + irk_hex = irk.replace("-", "").replace(" ", "") if len(irk_hex) != 32: continue else: continue - name = device_info.get('Name') or device_info.get('DeviceName') - address = device_info.get('DeviceAddress', device_uuid) - addr_type = 'random' if device_info.get('AddressType', 1) == 1 else 'public' + name = device_info.get("Name") or device_info.get("DeviceName") + address = device_info.get("DeviceAddress", device_uuid) + addr_type = "random" if device_info.get("AddressType", 1) == 1 else "public" - devices.append({ - 'name': name, - 'address': str(address), - 'irk_hex': irk_hex, - 'address_type': addr_type, - }) + devices.append( + { + "name": name, + "address": str(address), + "irk_hex": irk_hex, + "address_type": addr_type, + } + ) # Also check LEPairedDevices / PairedDevices structures - for section_key in ('LEPairedDevices', 'PairedDevices'): + for section_key in ("LEPairedDevices", "PairedDevices"): section = plist.get(section_key, {}) if not isinstance(section, dict): continue for addr, dev_info in section.items(): if not isinstance(dev_info, dict): continue - irk = dev_info.get('IRK') or dev_info.get('IdentityResolvingKey') + irk = dev_info.get("IRK") or dev_info.get("IdentityResolvingKey") if irk is None: continue if isinstance(irk, bytes) and len(irk) == 16: irk_hex = irk.hex() elif isinstance(irk, str): - irk_hex = irk.replace('-', '').replace(' ', '') + irk_hex = irk.replace("-", "").replace(" ", "") if len(irk_hex) != 32: continue else: continue # Skip if we already have this IRK - if any(d['irk_hex'] == irk_hex for d in devices): + if any(d["irk_hex"] == irk_hex for d in devices): continue - name = dev_info.get('Name') or dev_info.get('DeviceName') - addr_type = 'random' if dev_info.get('AddressType', 1) == 1 else 'public' + name = dev_info.get("Name") or dev_info.get("DeviceName") + addr_type = "random" if dev_info.get("AddressType", 1) == 1 else "public" - devices.append({ - 'name': name, - 'address': str(addr), - 'irk_hex': irk_hex, - 'address_type': addr_type, - }) + devices.append( + { + "name": name, + "address": str(addr), + "irk_hex": irk_hex, + "address_type": addr_type, + } + ) logger.info(f"Extracted {len(devices)} IRK(s) from macOS paired devices") return devices @@ -147,7 +151,7 @@ def _extract_linux() -> list[dict]: """ import configparser - bt_root = Path('/var/lib/bluetooth') + bt_root = Path("/var/lib/bluetooth") if not bt_root.exists(): logger.debug("BlueZ bluetooth directory not found") return [] @@ -161,7 +165,7 @@ def _extract_linux() -> list[dict]: if not device_dir.is_dir(): continue - info_file = device_dir / 'info' + info_file = device_dir / "info" if not info_file.exists(): continue @@ -171,28 +175,30 @@ def _extract_linux() -> list[dict]: except (configparser.Error, OSError): continue - if not config.has_section('IdentityResolvingKey'): + if not config.has_section("IdentityResolvingKey"): continue - irk_hex = config.get('IdentityResolvingKey', 'Key', fallback=None) + irk_hex = config.get("IdentityResolvingKey", "Key", fallback=None) if not irk_hex: continue # BlueZ stores as hex string, may or may not have separators - irk_hex = irk_hex.replace(' ', '').replace('-', '') + irk_hex = irk_hex.replace(" ", "").replace("-", "") if len(irk_hex) != 32: continue - name = config.get('General', 'Name', fallback=None) + name = config.get("General", "Name", fallback=None) address = device_dir.name # Directory name is the MAC address - addr_type = config.get('General', 'AddressType', fallback=None) + addr_type = config.get("General", "AddressType", fallback=None) - devices.append({ - 'name': name, - 'address': address, - 'irk_hex': irk_hex, - 'address_type': addr_type, - }) + devices.append( + { + "name": name, + "address": address, + "irk_hex": irk_hex, + "address_type": addr_type, + } + ) logger.info(f"Extracted {len(devices)} IRK(s) from BlueZ paired devices") return devices diff --git a/utils/bluetooth/models.py b/utils/bluetooth/models.py index 3ee6277..939db8a 100644 --- a/utils/bluetooth/models.py +++ b/utils/bluetooth/models.py @@ -62,25 +62,25 @@ class BTObservation: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'timestamp': self.timestamp.isoformat(), - 'address': self.address, - 'address_type': self.address_type, - 'device_id': self.device_id, - 'rssi': self.rssi, - 'tx_power': self.tx_power, - 'name': self.name, - 'manufacturer_id': self.manufacturer_id, - 'manufacturer_name': self.manufacturer_name, - 'manufacturer_data': self.manufacturer_data.hex() if self.manufacturer_data else None, - 'service_uuids': self.service_uuids, - 'service_data': {k: v.hex() for k, v in self.service_data.items()}, - 'appearance': self.appearance, - 'is_connectable': self.is_connectable, - 'is_paired': self.is_paired, - 'is_connected': self.is_connected, - 'class_of_device': self.class_of_device, - 'major_class': self.major_class, - 'minor_class': self.minor_class, + "timestamp": self.timestamp.isoformat(), + "address": self.address, + "address_type": self.address_type, + "device_id": self.device_id, + "rssi": self.rssi, + "tx_power": self.tx_power, + "name": self.name, + "manufacturer_id": self.manufacturer_id, + "manufacturer_name": self.manufacturer_name, + "manufacturer_data": self.manufacturer_data.hex() if self.manufacturer_data else None, + "service_uuids": self.service_uuids, + "service_data": {k: v.hex() for k, v in self.service_data.items()}, + "appearance": self.appearance, + "is_connectable": self.is_connectable, + "is_paired": self.is_paired, + "is_connected": self.is_connected, + "class_of_device": self.class_of_device, + "major_class": self.major_class, + "minor_class": self.minor_class, } @@ -180,10 +180,7 @@ class BTDeviceAggregate: # Downsample if needed samples = self.rssi_samples[-max_points:] - return [ - {'timestamp': ts.isoformat(), 'rssi': rssi} - for ts, rssi in samples - ] + return [{"timestamp": ts.isoformat(), "rssi": rssi} for ts, rssi in samples] @property def age_seconds(self) -> float: @@ -200,172 +197,160 @@ class BTDeviceAggregate: """List of active heuristic flags.""" flags = [] if self.is_new: - flags.append('new') + flags.append("new") if self.is_persistent: - flags.append('persistent') + flags.append("persistent") if self.is_beacon_like: - flags.append('beacon_like') + flags.append("beacon_like") if self.is_strong_stable: - flags.append('strong_stable') + flags.append("strong_stable") if self.has_random_address: - flags.append('random_address') + flags.append("random_address") return flags def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'device_id': self.device_id, - 'address': self.address, - 'address_type': self.address_type, - 'protocol': self.protocol, - + "device_id": self.device_id, + "address": self.address, + "address_type": self.address_type, + "protocol": self.protocol, # Timestamps - 'first_seen': self.first_seen.isoformat(), - 'last_seen': self.last_seen.isoformat(), - 'age_seconds': self.age_seconds, - 'duration_seconds': self.duration_seconds, - 'seen_count': self.seen_count, - 'seen_rate': round(self.seen_rate, 2), - + "first_seen": self.first_seen.isoformat(), + "last_seen": self.last_seen.isoformat(), + "age_seconds": self.age_seconds, + "duration_seconds": self.duration_seconds, + "seen_count": self.seen_count, + "seen_rate": round(self.seen_rate, 2), # RSSI stats - 'rssi_current': self.rssi_current, - 'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None, - 'rssi_min': self.rssi_min, - 'rssi_max': self.rssi_max, - 'rssi_variance': round(self.rssi_variance, 2) if self.rssi_variance else None, - 'rssi_confidence': round(self.rssi_confidence, 2), - 'rssi_history': self.get_rssi_history(), - + "rssi_current": self.rssi_current, + "rssi_median": round(self.rssi_median, 1) if self.rssi_median else None, + "rssi_min": self.rssi_min, + "rssi_max": self.rssi_max, + "rssi_variance": round(self.rssi_variance, 2) if self.rssi_variance else None, + "rssi_confidence": round(self.rssi_confidence, 2), + "rssi_history": self.get_rssi_history(), # Range (legacy) - 'range_band': self.range_band, - 'range_confidence': round(self.range_confidence, 2), - + "range_band": self.range_band, + "range_confidence": round(self.range_confidence, 2), # Proximity (new system) - 'device_key': self.device_key, - 'proximity_band': self.proximity_band, - 'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None, - 'distance_confidence': round(self.distance_confidence, 2), - 'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None, - 'rssi_60s_min': self.rssi_60s_min, - 'rssi_60s_max': self.rssi_60s_max, - 'is_randomized_mac': self.is_randomized_mac, - 'threat_tags': self.threat_tags, - + "device_key": self.device_key, + "proximity_band": self.proximity_band, + "estimated_distance_m": round(self.estimated_distance_m, 2) if self.estimated_distance_m else None, + "distance_confidence": round(self.distance_confidence, 2), + "rssi_ema": round(self.rssi_ema, 1) if self.rssi_ema else None, + "rssi_60s_min": self.rssi_60s_min, + "rssi_60s_max": self.rssi_60s_max, + "is_randomized_mac": self.is_randomized_mac, + "threat_tags": self.threat_tags, # Device info - 'name': self.name, - 'manufacturer_id': self.manufacturer_id, - 'manufacturer_name': self.manufacturer_name, - 'manufacturer_bytes': self.manufacturer_bytes.hex() if self.manufacturer_bytes else None, - 'service_uuids': self.service_uuids, - 'tx_power': self.tx_power, - 'appearance': self.appearance, - 'class_of_device': self.class_of_device, - 'major_class': self.major_class, - 'minor_class': self.minor_class, - 'is_connectable': self.is_connectable, - 'is_paired': self.is_paired, - 'is_connected': self.is_connected, - + "name": self.name, + "manufacturer_id": self.manufacturer_id, + "manufacturer_name": self.manufacturer_name, + "manufacturer_bytes": self.manufacturer_bytes.hex() if self.manufacturer_bytes else None, + "service_uuids": self.service_uuids, + "tx_power": self.tx_power, + "appearance": self.appearance, + "class_of_device": self.class_of_device, + "major_class": self.major_class, + "minor_class": self.minor_class, + "is_connectable": self.is_connectable, + "is_paired": self.is_paired, + "is_connected": self.is_connected, # Heuristics - 'heuristics': { - 'is_new': self.is_new, - 'is_persistent': self.is_persistent, - 'is_beacon_like': self.is_beacon_like, - 'is_strong_stable': self.is_strong_stable, - 'has_random_address': self.has_random_address, + "heuristics": { + "is_new": self.is_new, + "is_persistent": self.is_persistent, + "is_beacon_like": self.is_beacon_like, + "is_strong_stable": self.is_strong_stable, + "has_random_address": self.has_random_address, }, - 'heuristic_flags': self.heuristic_flags, - + "heuristic_flags": self.heuristic_flags, # Baseline - 'in_baseline': self.in_baseline, - 'baseline_id': self.baseline_id, - 'seen_before': self.seen_before, - + "in_baseline": self.in_baseline, + "baseline_id": self.baseline_id, + "seen_before": self.seen_before, # Tracker detection - 'tracker': { - 'is_tracker': self.is_tracker, - 'type': self.tracker_type, - 'name': self.tracker_name, - 'confidence': self.tracker_confidence, - 'confidence_score': round(self.tracker_confidence_score, 2), - 'evidence': self.tracker_evidence, + "tracker": { + "is_tracker": self.is_tracker, + "type": self.tracker_type, + "name": self.tracker_name, + "confidence": self.tracker_confidence, + "confidence_score": round(self.tracker_confidence_score, 2), + "evidence": self.tracker_evidence, }, - # Suspicious presence analysis - 'risk_analysis': { - 'risk_score': round(self.risk_score, 2), - 'risk_factors': self.risk_factors, + "risk_analysis": { + "risk_score": round(self.risk_score, 2), + "risk_factors": self.risk_factors, }, - # IRK - 'has_irk': self.irk_hex is not None, - 'irk_hex': self.irk_hex, - 'irk_source_name': self.irk_source_name, - + "has_irk": self.irk_hex is not None, + "irk_hex": self.irk_hex, + "irk_source_name": self.irk_source_name, # Fingerprint - 'fingerprint': { - 'id': self.payload_fingerprint_id, - 'stability': round(self.payload_fingerprint_stability, 2), + "fingerprint": { + "id": self.payload_fingerprint_id, + "stability": round(self.payload_fingerprint_stability, 2), }, - # Raw service data for investigation - 'service_data': {k: v.hex() for k, v in self.service_data.items()}, + "service_data": {k: v.hex() for k, v in self.service_data.items()}, } def to_summary_dict(self) -> dict: """Compact dictionary for list views.""" return { - 'device_id': self.device_id, - 'device_key': self.device_key, - 'address': self.address, - 'address_type': self.address_type, - 'protocol': self.protocol, - 'name': self.name, - 'manufacturer_name': self.manufacturer_name, - 'rssi_current': self.rssi_current, - 'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None, - 'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None, - 'rssi_min': self.rssi_min, - 'rssi_max': self.rssi_max, - 'rssi_variance': round(self.rssi_variance, 2) if self.rssi_variance else None, - 'range_band': self.range_band, - 'proximity_band': self.proximity_band, - 'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None, - 'distance_confidence': round(self.distance_confidence, 2), - 'is_randomized_mac': self.is_randomized_mac, - 'last_seen': self.last_seen.isoformat(), - 'first_seen': self.first_seen.isoformat(), - 'age_seconds': self.age_seconds, - 'duration_seconds': self.duration_seconds, - 'seen_count': self.seen_count, - 'seen_rate': round(self.seen_rate, 2), - 'tx_power': self.tx_power, - 'manufacturer_id': self.manufacturer_id, - 'appearance': self.appearance, - 'appearance_name': get_appearance_name(self.appearance), - 'is_connectable': self.is_connectable, - 'service_uuids': self.service_uuids, - 'service_data': {k: v.hex() for k, v in self.service_data.items()}, - 'manufacturer_bytes': self.manufacturer_bytes.hex() if self.manufacturer_bytes else None, - 'heuristic_flags': self.heuristic_flags, - 'is_persistent': self.is_persistent, - 'is_beacon_like': self.is_beacon_like, - 'is_strong_stable': self.is_strong_stable, - 'in_baseline': self.in_baseline, - 'seen_before': self.seen_before, + "device_id": self.device_id, + "device_key": self.device_key, + "address": self.address, + "address_type": self.address_type, + "protocol": self.protocol, + "name": self.name, + "manufacturer_name": self.manufacturer_name, + "rssi_current": self.rssi_current, + "rssi_median": round(self.rssi_median, 1) if self.rssi_median else None, + "rssi_ema": round(self.rssi_ema, 1) if self.rssi_ema else None, + "rssi_min": self.rssi_min, + "rssi_max": self.rssi_max, + "rssi_variance": round(self.rssi_variance, 2) if self.rssi_variance else None, + "range_band": self.range_band, + "proximity_band": self.proximity_band, + "estimated_distance_m": round(self.estimated_distance_m, 2) if self.estimated_distance_m else None, + "distance_confidence": round(self.distance_confidence, 2), + "is_randomized_mac": self.is_randomized_mac, + "last_seen": self.last_seen.isoformat(), + "first_seen": self.first_seen.isoformat(), + "age_seconds": self.age_seconds, + "duration_seconds": self.duration_seconds, + "seen_count": self.seen_count, + "seen_rate": round(self.seen_rate, 2), + "tx_power": self.tx_power, + "manufacturer_id": self.manufacturer_id, + "appearance": self.appearance, + "appearance_name": get_appearance_name(self.appearance), + "is_connectable": self.is_connectable, + "service_uuids": self.service_uuids, + "service_data": {k: v.hex() for k, v in self.service_data.items()}, + "manufacturer_bytes": self.manufacturer_bytes.hex() if self.manufacturer_bytes else None, + "heuristic_flags": self.heuristic_flags, + "is_persistent": self.is_persistent, + "is_beacon_like": self.is_beacon_like, + "is_strong_stable": self.is_strong_stable, + "in_baseline": self.in_baseline, + "seen_before": self.seen_before, # Tracker info for list view - 'is_tracker': self.is_tracker, - 'tracker_type': self.tracker_type, - 'tracker_name': self.tracker_name, - 'tracker_confidence': self.tracker_confidence, - 'tracker_confidence_score': round(self.tracker_confidence_score, 2), - 'tracker_evidence': self.tracker_evidence, - 'risk_score': round(self.risk_score, 2), - 'risk_factors': self.risk_factors, - 'has_irk': self.irk_hex is not None, - 'irk_hex': self.irk_hex, - 'irk_source_name': self.irk_source_name, - 'fingerprint_id': self.payload_fingerprint_id, + "is_tracker": self.is_tracker, + "tracker_type": self.tracker_type, + "tracker_name": self.tracker_name, + "tracker_confidence": self.tracker_confidence, + "tracker_confidence_score": round(self.tracker_confidence_score, 2), + "tracker_evidence": self.tracker_evidence, + "risk_score": round(self.risk_score, 2), + "risk_factors": self.risk_factors, + "has_irk": self.irk_hex is not None, + "irk_hex": self.irk_hex, + "irk_source_name": self.irk_source_name, + "fingerprint_id": self.payload_fingerprint_id, } @@ -374,7 +359,7 @@ class ScanStatus: """Current scanning status.""" is_scanning: bool = False - mode: str = 'auto' # 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'auto' + mode: str = "auto" # 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'auto' backend: str | None = None # Active backend being used adapter_id: str | None = None started_at: datetime | None = None @@ -399,16 +384,16 @@ class ScanStatus: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'is_scanning': self.is_scanning, - 'mode': self.mode, - 'backend': self.backend, - 'adapter_id': self.adapter_id, - 'started_at': self.started_at.isoformat() if self.started_at else None, - 'duration_s': self.duration_s, - 'elapsed_seconds': round(self.elapsed_seconds, 1) if self.elapsed_seconds else None, - 'remaining_seconds': round(self.remaining_seconds, 1) if self.remaining_seconds else None, - 'devices_found': self.devices_found, - 'error': self.error, + "is_scanning": self.is_scanning, + "mode": self.mode, + "backend": self.backend, + "adapter_id": self.adapter_id, + "started_at": self.started_at.isoformat() if self.started_at else None, + "duration_s": self.duration_s, + "elapsed_seconds": round(self.elapsed_seconds, 1) if self.elapsed_seconds else None, + "remaining_seconds": round(self.remaining_seconds, 1) if self.remaining_seconds else None, + "devices_found": self.devices_found, + "error": self.error, } @@ -441,7 +426,7 @@ class SystemCapabilities: has_ubertooth: bool = False # Recommended backend - recommended_backend: str = 'none' + recommended_backend: str = "none" # Issues found issues: list[str] = field(default_factory=list) @@ -450,33 +435,33 @@ class SystemCapabilities: def can_scan(self) -> bool: """Whether scanning is possible with any backend.""" return ( - (self.has_dbus and self.has_bluez and len(self.adapters) > 0) or - self.has_bleak or - self.has_hcitool or - self.has_bluetoothctl or - self.has_ubertooth + (self.has_dbus and self.has_bluez and len(self.adapters) > 0) + or self.has_bleak + or self.has_hcitool + or self.has_bluetoothctl + or self.has_ubertooth ) def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'available': self.can_scan, # Alias for frontend compatibility - 'can_scan': self.can_scan, - 'has_dbus': self.has_dbus, - 'has_bluez': self.has_bluez, - 'bluez_version': self.bluez_version, - 'adapters': self.adapters, - 'default_adapter': self.default_adapter, - 'has_bluetooth_permission': self.has_bluetooth_permission, - 'is_root': self.is_root, - 'is_soft_blocked': self.is_soft_blocked, - 'is_hard_blocked': self.is_hard_blocked, - 'has_bleak': self.has_bleak, - 'has_hcitool': self.has_hcitool, - 'has_bluetoothctl': self.has_bluetoothctl, - 'has_btmgmt': self.has_btmgmt, - 'has_ubertooth': self.has_ubertooth, - 'preferred_backend': self.recommended_backend, # Alias for frontend - 'recommended_backend': self.recommended_backend, - 'issues': self.issues, + "available": self.can_scan, # Alias for frontend compatibility + "can_scan": self.can_scan, + "has_dbus": self.has_dbus, + "has_bluez": self.has_bluez, + "bluez_version": self.bluez_version, + "adapters": self.adapters, + "default_adapter": self.default_adapter, + "has_bluetooth_permission": self.has_bluetooth_permission, + "is_root": self.is_root, + "is_soft_blocked": self.is_soft_blocked, + "is_hard_blocked": self.is_hard_blocked, + "has_bleak": self.has_bleak, + "has_hcitool": self.has_hcitool, + "has_bluetoothctl": self.has_bluetoothctl, + "has_btmgmt": self.has_btmgmt, + "has_ubertooth": self.has_ubertooth, + "preferred_backend": self.recommended_backend, # Alias for frontend + "recommended_backend": self.recommended_backend, + "issues": self.issues, } diff --git a/utils/bluetooth/ring_buffer.py b/utils/bluetooth/ring_buffer.py index e868e58..5272b39 100644 --- a/utils/bluetooth/ring_buffer.py +++ b/utils/bluetooth/ring_buffer.py @@ -84,9 +84,7 @@ class RingBuffer: # Initialize deque for new device if device_key not in self._observations: - self._observations[device_key] = deque( - maxlen=self.max_observations_per_device - ) + self._observations[device_key] = deque(maxlen=self.max_observations_per_device) # Store observation self._observations[device_key].append((timestamp, rssi)) @@ -132,7 +130,7 @@ class RingBuffer: window_minutes: int | None = None, downsample_seconds: int = 10, top_n: int | None = None, - sort_by: str = 'recency', + sort_by: str = "recency", ) -> dict[str, list[dict]]: """ Get downsampled timeseries for all devices. @@ -164,9 +162,9 @@ class RingBuffer: device_info.append((device_key, last_seen, avg_rssi, len(recent))) # Sort based on criteria - if sort_by == 'strength': + if sort_by == "strength": device_info.sort(key=lambda x: x[2], reverse=True) # Higher RSSI first - elif sort_by == 'activity': + elif sort_by == "activity": device_info.sort(key=lambda x: x[3], reverse=True) # More observations first else: # recency device_info.sort(key=lambda x: x[1], reverse=True) # Most recent first @@ -221,10 +219,12 @@ class RingBuffer: for bucket_ts in sorted(buckets.keys()): rssi_values = buckets[bucket_ts] avg_rssi = sum(rssi_values) / len(rssi_values) - result.append({ - 'timestamp': bucket_ts.isoformat(), - 'rssi': round(avg_rssi, 1), - }) + result.append( + { + "timestamp": bucket_ts.isoformat(), + "rssi": round(avg_rssi, 1), + } + ) return result @@ -304,12 +304,12 @@ class RingBuffer: timestamps = [ts for ts, _ in obs] return { - 'observation_count': len(obs), - 'first_observation': min(timestamps).isoformat(), - 'last_observation': max(timestamps).isoformat(), - 'rssi_min': min(rssi_values), - 'rssi_max': max(rssi_values), - 'rssi_avg': sum(rssi_values) / len(rssi_values), + "observation_count": len(obs), + "first_observation": min(timestamps).isoformat(), + "last_observation": max(timestamps).isoformat(), + "rssi_min": min(rssi_values), + "rssi_max": max(rssi_values), + "rssi_avg": sum(rssi_values) / len(rssi_values), } diff --git a/utils/bluetooth/scanner.py b/utils/bluetooth/scanner.py index 1e36680..9105cbe 100644 --- a/utils/bluetooth/scanner.py +++ b/utils/bluetooth/scanner.py @@ -72,9 +72,9 @@ class BluetoothScanner: def start_scan( self, - mode: str = 'auto', + mode: str = "auto", duration_s: int | None = None, - transport: str = 'auto', + transport: str = "auto", rssi_threshold: int = -100, ) -> bool: """ @@ -98,7 +98,7 @@ class BluetoothScanner: # Determine adapter adapter = self._adapter_id or self._capabilities.default_adapter - if not adapter and mode == 'dbus': + if not adapter and mode == "dbus": self._status.error = "No Bluetooth adapter found" return False @@ -107,16 +107,16 @@ class BluetoothScanner: backend_used = None original_mode = mode - if mode == 'auto': - mode = self._capabilities.recommended_backend or 'bleak' + if mode == "auto": + mode = self._capabilities.recommended_backend or "bleak" - if mode == 'dbus': + if mode == "dbus": started, backend_used = self._start_dbus(adapter, transport, rssi_threshold) - elif mode == 'ubertooth': + elif mode == "ubertooth": started, backend_used = self._start_ubertooth() # Fallback: try non-DBus methods if DBus failed or wasn't requested - if not started and (original_mode == 'auto' or mode in ('bleak', 'hcitool', 'bluetoothctl')): + if not started and (original_mode == "auto" or mode in ("bleak", "hcitool", "bluetoothctl")): started, backend_used = self._start_fallback(adapter, original_mode) if not started: @@ -135,12 +135,14 @@ class BluetoothScanner: ) # Queue status event - self._queue_event({ - 'type': 'status', - 'status': 'started', - 'backend': backend_used, - 'mode': mode, - }) + self._queue_event( + { + "type": "status", + "status": "started", + "backend": backend_used, + "mode": mode, + } + ) # Set up timer for duration-based scanning if duration_s: @@ -151,12 +153,7 @@ class BluetoothScanner: logger.info(f"Bluetooth scan started: mode={mode}, backend={backend_used}") return True - def _start_dbus( - self, - adapter: str, - transport: str, - rssi_threshold: int - ) -> tuple[bool, str | None]: + def _start_dbus(self, adapter: str, transport: str, rssi_threshold: int) -> tuple[bool, str | None]: """Start DBus scanner.""" try: self._dbus_scanner = DBusScanner( @@ -164,7 +161,7 @@ class BluetoothScanner: on_observation=self._handle_observation, ) if self._dbus_scanner.start(transport=transport, rssi_threshold=rssi_threshold): - return True, 'dbus' + return True, "dbus" except Exception as e: logger.warning(f"DBus scanner failed: {e}") return False, None @@ -176,7 +173,7 @@ class BluetoothScanner: on_observation=self._handle_observation, ) if self._ubertooth_scanner.start(): - return True, 'ubertooth' + return True, "ubertooth" except Exception as e: logger.warning(f"Ubertooth scanner failed: {e}") return False, None @@ -185,7 +182,7 @@ class BluetoothScanner: """Start fallback scanner.""" try: # Extract adapter name from path if needed - adapter_name = adapter.split('/')[-1] if adapter else 'hci0' + adapter_name = adapter.split("/")[-1] if adapter else "hci0" self._fallback_scanner = FallbackScanner( adapter=adapter_name, @@ -226,10 +223,12 @@ class BluetoothScanner: self._active_backend = None # Queue status event - self._queue_event({ - 'type': 'status', - 'status': 'stopped', - }) + self._queue_event( + { + "type": "status", + "status": "stopped", + } + ) logger.info("Bluetooth scan stopped") @@ -239,11 +238,11 @@ class BluetoothScanner: return # Already matched address = device.address - if not address or len(address.replace(':', '').replace('-', '')) not in (12, 32): + if not address or len(address.replace(":", "").replace("-", "")) not in (12, 32): return # Only attempt RPA resolution on 6-byte addresses - addr_clean = address.replace(':', '').replace('-', '') + addr_clean = address.replace(":", "").replace("-", "") if len(addr_clean) != 12: return @@ -261,14 +260,14 @@ class BluetoothScanner: return for entry in paired: - irk_hex = entry.get('irk_hex', '') + irk_hex = entry.get("irk_hex", "") if not irk_hex or len(irk_hex) != 32: continue try: irk = bytes.fromhex(irk_hex) if resolve_rpa(irk, address): device.irk_hex = irk_hex - device.irk_source_name = entry.get('name') + device.irk_source_name = entry.get("name") logger.debug(f"IRK match for {address}: {entry.get('name', 'unnamed')}") return except Exception: @@ -293,18 +292,18 @@ class BluetoothScanner: # Build summary with MAC cluster count summary = device.to_summary_dict() if device.payload_fingerprint_id: - summary['mac_cluster_count'] = self._aggregator.get_fingerprint_mac_count( - device.payload_fingerprint_id - ) + summary["mac_cluster_count"] = self._aggregator.get_fingerprint_mac_count(device.payload_fingerprint_id) else: - summary['mac_cluster_count'] = 0 + summary["mac_cluster_count"] = 0 # Queue event - self._queue_event({ - 'type': 'device', - 'action': 'update', - 'device': summary, - }) + self._queue_event( + { + "type": "device", + "action": "update", + "device": summary, + } + ) # Callbacks for cb in self._on_device_updated_callbacks: @@ -336,7 +335,7 @@ class BluetoothScanner: def get_devices( self, - sort_by: str = 'last_seen', + sort_by: str = "last_seen", sort_desc: bool = True, min_rssi: int | None = None, protocol: str | None = None, @@ -367,11 +366,11 @@ class BluetoothScanner: # Sort sort_key = { - 'last_seen': lambda d: d.last_seen, - 'rssi_current': lambda d: d.rssi_current or -999, - 'name': lambda d: (d.name or '').lower(), - 'seen_count': lambda d: d.seen_count, - 'first_seen': lambda d: d.first_seen, + "last_seen": lambda d: d.last_seen, + "rssi_current": lambda d: d.rssi_current or -999, + "name": lambda d: (d.name or "").lower(), + "seen_count": lambda d: d.seen_count, + "first_seen": lambda d: d.first_seen, }.get(sort_by, lambda d: d.last_seen) devices.sort(key=sort_key, reverse=sort_desc) @@ -402,33 +401,39 @@ class BluetoothScanner: event = self._event_queue.get(timeout=timeout) yield event except queue.Empty: - yield {'type': 'ping'} + yield {"type": "ping"} def set_baseline(self) -> int: """Set current devices as baseline.""" count = self._aggregator.set_baseline() - self._queue_event({ - 'type': 'baseline', - 'action': 'set', - 'device_count': count, - }) + self._queue_event( + { + "type": "baseline", + "action": "set", + "device_count": count, + } + ) return count def clear_baseline(self) -> None: """Clear the baseline.""" self._aggregator.clear_baseline() - self._queue_event({ - 'type': 'baseline', - 'action': 'cleared', - }) + self._queue_event( + { + "type": "baseline", + "action": "cleared", + } + ) def clear_devices(self) -> None: """Clear all tracked devices.""" self._aggregator.clear() - self._queue_event({ - 'type': 'devices', - 'action': 'cleared', - }) + self._queue_event( + { + "type": "devices", + "action": "cleared", + } + ) def prune_stale(self, max_age_seconds: float = DEVICE_STALE_TIMEOUT) -> int: """Prune stale devices.""" diff --git a/utils/bluetooth/ubertooth_scanner.py b/utils/bluetooth/ubertooth_scanner.py index 3359df8..1d40b9c 100644 --- a/utils/bluetooth/ubertooth_scanner.py +++ b/utils/bluetooth/ubertooth_scanner.py @@ -58,7 +58,7 @@ class UbertoothScanner: @staticmethod def is_available() -> bool: """Check if ubertooth-btle is available on the system.""" - return shutil.which('ubertooth-btle') is not None + return shutil.which("ubertooth-btle") is not None def start(self) -> bool: """ @@ -81,9 +81,9 @@ class UbertoothScanner: # Build command: ubertooth-btle -n -U # -n = advertisements only (no follow mode) # -U = device index for multiple Ubertooths - cmd = ['ubertooth-btle', '-n'] + cmd = ["ubertooth-btle", "-n"] if self._device_index > 0: - cmd.extend(['-U', str(self._device_index)]) + cmd.extend(["-U", str(self._device_index)]) self._process = subprocess.Popen( cmd, @@ -94,11 +94,7 @@ class UbertoothScanner: ) self._stop_event.clear() - self._reader_thread = threading.Thread( - target=self._read_output, - daemon=True, - name='ubertooth-reader' - ) + self._reader_thread = threading.Thread(target=self._read_output, daemon=True, name="ubertooth-reader") self._reader_thread.start() self._is_scanning = True logger.info(f"Ubertooth scanner started (device index: {self._device_index})") @@ -162,7 +158,7 @@ class UbertoothScanner: continue # Skip non-packet lines (errors, status messages) - if not line.startswith('systime='): + if not line.startswith("systime="): # Log errors from stderr would go here if needed continue @@ -198,17 +194,14 @@ class UbertoothScanner: """ # Parse the structured prefix # Example: systime=1349412883 freq=2402 addr=8e89bed6 delta_t=38.441 ms 00 17 ab cd ef ... - match = re.match( - r'systime=(\d+)\s+freq=(\d+)\s+addr=([0-9a-fA-F]+)\s+delta_t=[\d.]+\s+ms\s+(.+)', - line - ) + match = re.match(r"systime=(\d+)\s+freq=(\d+)\s+addr=([0-9a-fA-F]+)\s+delta_t=[\d.]+\s+ms\s+(.+)", line) if not match: return None # Parse hex bytes hex_data = match.group(4).strip() try: - raw_bytes = bytes.fromhex(hex_data.replace(' ', '')) + raw_bytes = bytes.fromhex(hex_data.replace(" ", "")) except ValueError: return None @@ -228,14 +221,14 @@ class UbertoothScanner: # Extract advertiser address (bytes 2-7, reversed) # BLE addresses are transmitted LSB first addr_bytes = raw_bytes[2:8] - address = ':'.join(f'{b:02X}' for b in reversed(addr_bytes)) + address = ":".join(f"{b:02X}" for b in reversed(addr_bytes)) # Determine address type from PDU type and TxAdd flag tx_add = (raw_bytes[0] >> 6) & 0x01 address_type = ADDRESS_TYPE_RANDOM if tx_add else ADDRESS_TYPE_PUBLIC # Parse advertising data payload (after MAC address) - adv_data = raw_bytes[8:2 + length] if length > 6 else b'' + adv_data = raw_bytes[8 : 2 + length] if length > 6 else b"" # Parse advertising data structures name = None @@ -255,34 +248,36 @@ class UbertoothScanner: break ad_type = adv_data[i + 1] - ad_payload = adv_data[i + 2:i + 1 + ad_len] + ad_payload = adv_data[i + 2 : i + 1 + ad_len] # 0x01 = Flags # 0x02/0x03 = Incomplete/Complete list of 16-bit UUIDs if ad_type in (0x02, 0x03) and len(ad_payload) >= 2: for j in range(0, len(ad_payload), 2): if j + 2 <= len(ad_payload): - uuid16 = int.from_bytes(ad_payload[j:j + 2], 'little') - service_uuids.append(f'{uuid16:04X}') + uuid16 = int.from_bytes(ad_payload[j : j + 2], "little") + service_uuids.append(f"{uuid16:04X}") # 0x06/0x07 = Incomplete/Complete list of 128-bit UUIDs elif ad_type in (0x06, 0x07) and len(ad_payload) >= 16: for j in range(0, len(ad_payload), 16): if j + 16 <= len(ad_payload): - uuid_bytes = ad_payload[j:j + 16] - uuid128 = '-'.join([ - uuid_bytes[15:11:-1].hex(), - uuid_bytes[11:9:-1].hex(), - uuid_bytes[9:7:-1].hex(), - uuid_bytes[7:5:-1].hex(), - uuid_bytes[5::-1].hex(), - ]) + uuid_bytes = ad_payload[j : j + 16] + uuid128 = "-".join( + [ + uuid_bytes[15:11:-1].hex(), + uuid_bytes[11:9:-1].hex(), + uuid_bytes[9:7:-1].hex(), + uuid_bytes[7:5:-1].hex(), + uuid_bytes[5::-1].hex(), + ] + ) service_uuids.append(uuid128.upper()) # 0x08/0x09 = Shortened/Complete Local Name elif ad_type in (0x08, 0x09): with contextlib.suppress(Exception): - name = ad_payload.decode('utf-8', errors='replace') + name = ad_payload.decode("utf-8", errors="replace") # 0x0A = TX Power Level elif ad_type == 0x0A and len(ad_payload) >= 1: @@ -291,29 +286,31 @@ class UbertoothScanner: # 0xFF = Manufacturer Specific Data elif ad_type == 0xFF and len(ad_payload) >= 2: - manufacturer_id = int.from_bytes(ad_payload[0:2], 'little') + manufacturer_id = int.from_bytes(ad_payload[0:2], "little") manufacturer_data = bytes(ad_payload[2:]) # 0x16 = Service Data (16-bit UUID) elif ad_type == 0x16 and len(ad_payload) >= 2: - svc_uuid = f'{int.from_bytes(ad_payload[0:2], "little"):04X}' + svc_uuid = f"{int.from_bytes(ad_payload[0:2], 'little'):04X}" service_data[svc_uuid] = bytes(ad_payload[2:]) # 0x20 = Service Data (32-bit UUID) elif ad_type == 0x20 and len(ad_payload) >= 4: - svc_uuid = f'{int.from_bytes(ad_payload[0:4], "little"):08X}' + svc_uuid = f"{int.from_bytes(ad_payload[0:4], 'little'):08X}" service_data[svc_uuid] = bytes(ad_payload[4:]) # 0x21 = Service Data (128-bit UUID) elif ad_type == 0x21 and len(ad_payload) >= 16: uuid_bytes = ad_payload[0:16] - svc_uuid = '-'.join([ - uuid_bytes[15:11:-1].hex(), - uuid_bytes[11:9:-1].hex(), - uuid_bytes[9:7:-1].hex(), - uuid_bytes[7:5:-1].hex(), - uuid_bytes[5::-1].hex(), - ]).upper() + svc_uuid = "-".join( + [ + uuid_bytes[15:11:-1].hex(), + uuid_bytes[11:9:-1].hex(), + uuid_bytes[9:7:-1].hex(), + uuid_bytes[7:5:-1].hex(), + uuid_bytes[5::-1].hex(), + ] + ).upper() service_data[svc_uuid] = bytes(ad_payload[16:]) i += 1 + ad_len diff --git a/utils/bt_locate.py b/utils/bt_locate.py index 590c949..3bd703d 100644 --- a/utils/bt_locate.py +++ b/utils/bt_locate.py @@ -19,7 +19,7 @@ from utils.bluetooth.models import BTDeviceAggregate from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner from utils.gps import get_current_position -logger = logging.getLogger('intercept.bt_locate') +logger = logging.getLogger("intercept.bt_locate") # Maximum trail points to retain MAX_TRAIL_POINTS = 500 @@ -38,18 +38,18 @@ def _normalize_mac(address: str | None) -> str | None: if not address: return None - text = str(address).strip().upper().replace('-', ':') + text = str(address).strip().upper().replace("-", ":") if not text: return None # Handle raw 12-hex form: AABBCCDDEEFF - raw = ''.join(ch for ch in text if ch in '0123456789ABCDEF') - if ':' not in text and len(raw) == 12: - text = ':'.join(raw[i:i + 2] for i in range(0, 12, 2)) + raw = "".join(ch for ch in text if ch in "0123456789ABCDEF") + if ":" not in text and len(raw) == 12: + text = ":".join(raw[i : i + 2] for i in range(0, 12, 2)) - parts = text.split(':') - if len(parts) == 6 and all(len(p) == 2 and all(c in '0123456789ABCDEF' for c in p) for p in parts): - return ':'.join(parts) + parts = text.split(":") + if len(parts) == 6 and all(len(p) == 2 and all(c in "0123456789ABCDEF" for c in p) for p in parts): + return ":".join(parts) # Return cleaned original when not a strict MAC (caller may still use exact matching) return text @@ -65,7 +65,7 @@ def _address_looks_like_rpa(address: str | None) -> bool: if not normalized: return False try: - first_octet = int(normalized.split(':', 1)[0], 16) + first_octet = int(normalized.split(":", 1)[0], 16) except (ValueError, TypeError): return False return (first_octet >> 6) == 1 @@ -73,6 +73,7 @@ def _address_looks_like_rpa(address: str | None) -> bool: class Environment(Enum): """RF propagation environment presets.""" + FREE_SPACE = 2.0 OUTDOOR = 2.2 INDOOR = 3.0 @@ -99,7 +100,7 @@ def resolve_rpa(irk: bytes, address: str) -> bool: return False # Parse address bytes (remove colons, convert to bytes) - addr_bytes = bytes.fromhex(address.replace(':', '').replace('-', '')) + addr_bytes = bytes.fromhex(address.replace(":", "").replace("-", "")) if len(addr_bytes) != 6: return False @@ -113,7 +114,7 @@ def resolve_rpa(irk: bytes, address: str) -> bool: # ah(k, r) = e(k, r') mod 2^24 # r' is prand zero-padded to 16 bytes (MSB) - plaintext = b'\x00' * 13 + prand + plaintext = b"\x00" * 13 + prand cipher = Cipher(algorithms.AES(irk), modes.ECB()) encryptor = cipher.encryptor() @@ -128,6 +129,7 @@ def resolve_rpa(irk: bytes, address: str) -> bool: @dataclass class LocateTarget: """Target device specification for locate session.""" + mac_address: str | None = None name_pattern: str | None = None irk_hex: str | None = None @@ -161,7 +163,7 @@ class LocateTarget: def matches(self, device: BTDeviceAggregate, irk_bytes: bytes | None = None) -> bool: """Check if a device matches this target.""" # Match by stable device key (survives MAC randomization for many devices) - if self.device_key and getattr(device, 'device_key', None) == self.device_key: + if self.device_key and getattr(device, "device_key", None) == self.device_key: return True # Match by device_id (exact) @@ -169,9 +171,9 @@ class LocateTarget: return True # Match by device_id address portion (without :address_type suffix) - if self.device_id and ':' in self.device_id: - target_addr_part = self.device_id.rsplit(':', 1)[0].upper() - dev_addr = (device.address or '').upper() + if self.device_id and ":" in self.device_id: + target_addr_part = self.device_id.rsplit(":", 1)[0].upper() + dev_addr = (device.address or "").upper() if target_addr_part and dev_addr == target_addr_part: return True @@ -186,8 +188,8 @@ class LocateTarget: # For explicit hand-off sessions, allow exact fingerprint matches even if # stability is still warming up. if self.fingerprint_id: - dev_fp = getattr(device, 'payload_fingerprint_id', None) - dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0 + dev_fp = getattr(device, "payload_fingerprint_id", None) + dev_fp_stability = getattr(device, "payload_fingerprint_stability", 0.0) or 0.0 if dev_fp and dev_fp == self.fingerprint_id: if dev_fp_stability >= 0.35: return True @@ -208,26 +210,22 @@ class LocateTarget: if self.known_name and device.name: target_name = self.known_name.strip().lower() device_name = device.name.strip().lower() - if target_name and ( - target_name == device_name - or target_name in device_name - or device_name in target_name - ): + if target_name and (target_name == device_name or target_name in device_name or device_name in target_name): return True return False def to_dict(self) -> dict: return { - 'mac_address': self.mac_address, - 'name_pattern': self.name_pattern, - 'irk_hex': self.irk_hex, - 'device_id': self.device_id, - 'device_key': self.device_key, - 'fingerprint_id': self.fingerprint_id, - 'known_name': self.known_name, - 'known_manufacturer': self.known_manufacturer, - 'last_known_rssi': self.last_known_rssi, + "mac_address": self.mac_address, + "name_pattern": self.name_pattern, + "irk_hex": self.irk_hex, + "device_id": self.device_id, + "device_key": self.device_key, + "fingerprint_id": self.fingerprint_id, + "known_name": self.known_name, + "known_manufacturer": self.known_manufacturer, + "last_known_rssi": self.last_known_rssi, } @@ -251,16 +249,17 @@ class DistanceEstimator: def proximity_band(distance: float) -> str: """Classify distance into proximity band.""" if distance <= 1.0: - return 'IMMEDIATE' + return "IMMEDIATE" elif distance <= 5.0: - return 'NEAR' + return "NEAR" else: - return 'FAR' + return "FAR" @dataclass class DetectionPoint: """A single GPS-tagged BLE detection.""" + timestamp: str rssi: int rssi_ema: float @@ -273,15 +272,15 @@ class DetectionPoint: def to_dict(self) -> dict: return { - 'timestamp': self.timestamp, - 'rssi': self.rssi, - 'rssi_ema': round(self.rssi_ema, 1), - 'estimated_distance': round(self.estimated_distance, 2), - 'proximity_band': self.proximity_band, - 'lat': self.lat, - 'lon': self.lon, - 'gps_accuracy': self.gps_accuracy, - 'rpa_resolved': self.rpa_resolved, + "timestamp": self.timestamp, + "rssi": self.rssi, + "rssi_ema": round(self.rssi_ema, 1), + "estimated_distance": round(self.estimated_distance, 2), + "proximity_band": self.proximity_band, + "lat": self.lat, + "lon": self.lon, + "gps_accuracy": self.gps_accuracy, + "rpa_resolved": self.rpa_resolved, } @@ -350,7 +349,7 @@ class LocateSession: logger.info("BT scanner not running, starting scan for locate session") self._scanner_started_by_us = True self._last_scan_restart_attempt = time.monotonic() - if not self._scanner.start_scan(mode='auto'): + if not self._scanner.start_scan(mode="auto"): # Surface startup failure to caller and avoid leaving stale callbacks. status = self._scanner.get_status() reason = status.error or "unknown error" @@ -365,9 +364,7 @@ class LocateSession: self._stop_event.clear() # Start polling thread as reliable fallback - self._poll_thread = threading.Thread( - target=self._poll_loop, daemon=True, name='bt-locate-poll' - ) + self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True, name="bt-locate-poll") self._poll_thread.start() logger.info(f"Locate session started for target: {self.target.to_dict()}") @@ -379,7 +376,7 @@ class LocateSession: self._stop_event.set() if self._scanner: self._scanner.remove_device_callback(self._on_device) - if getattr(self, '_scanner_started_by_us', False) and self._scanner.is_scanning: + if getattr(self, "_scanner_started_by_us", False) and self._scanner.is_scanning: self._scanner.stop_scan() logger.info("Stopped BT scanner (was started by locate session)") if self._poll_thread: @@ -410,7 +407,7 @@ class LocateSession: if (now - self._last_scan_restart_attempt) >= SCAN_RESTART_BACKOFF_SECONDS: self._last_scan_restart_attempt = now logger.info("Scanner stopped, restarting for locate session") - self._scanner.start_scan(mode='auto') + self._scanner.start_scan(mode="auto") # Check devices seen within a recent window. Using a short window # (rather than the aggregator's full 120s) so that once a device @@ -518,10 +515,10 @@ class LocateSession: # Queue SSE event event = { - 'type': 'detection', - 'data': point.to_dict(), - 'device_name': device.name, - 'device_address': device.address, + "type": "detection", + "data": point.to_dict(), + "device_name": device.name, + "device_address": device.address, } try: self.event_queue.put_nowait(event) @@ -553,40 +550,35 @@ class LocateSession: debug_devices = self._debug_device_sample() if include_debug else [] scanner_running = self._scanner.is_scanning if self._scanner else False scanner_device_count = self._scanner.device_count if self._scanner else 0 - callback_registered = ( - self._on_device in self._scanner._on_device_updated_callbacks - if self._scanner else False - ) + callback_registered = self._on_device in self._scanner._on_device_updated_callbacks if self._scanner else False with self._lock: return { - 'active': self.active, - 'target': self.target.to_dict(), - 'environment': self.environment.name, - 'path_loss_exponent': self.estimator.n, - 'started_at': self.started_at.isoformat() if self.started_at else None, - 'detection_count': self.detection_count, - 'gps_trail_count': sum(1 for p in self.trail if p.lat is not None), - 'last_detection': self.last_detection.isoformat() if self.last_detection else None, - 'scanner_running': scanner_running, - 'scanner_device_count': scanner_device_count, - 'callback_registered': callback_registered, - 'event_queue_size': self.event_queue.qsize(), - 'callback_call_count': self.callback_call_count, - 'poll_count': self.poll_count, - 'poll_thread_alive': self._poll_thread.is_alive() if self._poll_thread else False, - 'last_seen_device': self._last_seen_device, - 'gps_available': gps_pos is not None, - 'gps_source': 'live' if gps_pos else ( - 'manual' if self.fallback_lat is not None else 'none' - ), - 'fallback_lat': self.fallback_lat, - 'fallback_lon': self.fallback_lon, - 'latest_rssi': self.trail[-1].rssi if self.trail else None, - 'latest_rssi_ema': round(self.trail[-1].rssi_ema, 1) if self.trail else None, - 'latest_distance': round(self.trail[-1].estimated_distance, 2) if self.trail else None, - 'latest_band': self.trail[-1].proximity_band if self.trail else None, - 'debug_devices': debug_devices, + "active": self.active, + "target": self.target.to_dict(), + "environment": self.environment.name, + "path_loss_exponent": self.estimator.n, + "started_at": self.started_at.isoformat() if self.started_at else None, + "detection_count": self.detection_count, + "gps_trail_count": sum(1 for p in self.trail if p.lat is not None), + "last_detection": self.last_detection.isoformat() if self.last_detection else None, + "scanner_running": scanner_running, + "scanner_device_count": scanner_device_count, + "callback_registered": callback_registered, + "event_queue_size": self.event_queue.qsize(), + "callback_call_count": self.callback_call_count, + "poll_count": self.poll_count, + "poll_thread_alive": self._poll_thread.is_alive() if self._poll_thread else False, + "last_seen_device": self._last_seen_device, + "gps_available": gps_pos is not None, + "gps_source": "live" if gps_pos else ("manual" if self.fallback_lat is not None else "none"), + "fallback_lat": self.fallback_lat, + "fallback_lon": self.fallback_lon, + "latest_rssi": self.trail[-1].rssi if self.trail else None, + "latest_rssi_ema": round(self.trail[-1].rssi_ema, 1) if self.trail else None, + "latest_distance": round(self.trail[-1].estimated_distance, 2) if self.trail else None, + "latest_band": self.trail[-1].proximity_band if self.trail else None, + "debug_devices": debug_devices, } def set_environment(self, environment: Environment, custom_exponent: float | None = None) -> None: @@ -604,11 +596,11 @@ class LocateSession: devices = self._scanner.get_devices(max_age_seconds=30) return [ { - 'id': d.device_id, - 'addr': d.address, - 'name': d.name, - 'rssi': d.rssi_current, - 'match': self.target.matches(d, irk_bytes=self._target_irk), + "id": d.device_id, + "addr": d.address, + "name": d.name, + "rssi": d.rssi_current, + "match": self.target.matches(d, irk_bytes=self._target_irk), } for d in devices[:8] ] @@ -648,9 +640,7 @@ def start_locate_session( if old_session: old_session.stop() - new_session = LocateSession( - target, environment, custom_exponent, fallback_lat, fallback_lon - ) + new_session = LocateSession(target, environment, custom_exponent, fallback_lat, fallback_lon) with _session_lock: _session = new_session diff --git a/utils/constants.py b/utils/constants.py index 85552bd..3efdbf6 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -159,12 +159,7 @@ DEFAULT_LATITUDE = 51.5074 DEFAULT_LONGITUDE = -0.1278 # Allowed TLE hosts for security -ALLOWED_TLE_HOSTS = [ - 'celestrak.org', - 'celestrak.com', - 'www.celestrak.org', - 'www.celestrak.com' -] +ALLOWED_TLE_HOSTS = ["celestrak.org", "celestrak.com", "www.celestrak.org", "www.celestrak.com"] # Earth radius (km) - WGS84 mean EARTH_RADIUS_KM = 6371 @@ -201,7 +196,7 @@ SBS_RECONNECT_DELAY = 2.0 # ============================================================================= # Default pager log file -DEFAULT_PAGER_LOG_FILE = 'pager_messages.log' +DEFAULT_PAGER_LOG_FILE = "pager_messages.log" # ============================================================================= @@ -230,13 +225,13 @@ MAX_VESSEL_AGE_SECONDS = 600 # 10 minutes AIS_TERMINATE_TIMEOUT = 5 # WiFi capture temp path prefix -WIFI_CAPTURE_PATH_PREFIX = '/tmp/intercept_wifi' +WIFI_CAPTURE_PATH_PREFIX = "/tmp/intercept_wifi" # Handshake capture path prefix -HANDSHAKE_CAPTURE_PATH_PREFIX = '/tmp/intercept_handshake_' +HANDSHAKE_CAPTURE_PATH_PREFIX = "/tmp/intercept_handshake_" # PMKID capture path prefix -PMKID_CAPTURE_PATH_PREFIX = '/tmp/intercept_pmkid_' +PMKID_CAPTURE_PATH_PREFIX = "/tmp/intercept_pmkid_" # ============================================================================= @@ -262,9 +257,9 @@ DSC_TERMINATE_TIMEOUT = 3 # Allowed ISM TX frequency bands (MHz) - transmit only within these ranges SUBGHZ_TX_ALLOWED_BANDS = [ - (300.0, 348.0), # 315 MHz ISM band - (387.0, 464.0), # 433 MHz ISM band - (779.0, 928.0), # 868/915 MHz ISM band + (300.0, 348.0), # 315 MHz ISM band + (387.0, 464.0), # 433 MHz ISM band + (779.0, 928.0), # 868/915 MHz ISM band ] # HackRF frequency limits (MHz) @@ -293,10 +288,10 @@ SUBGHZ_TERMINATE_TIMEOUT = 3 # Common SubGHz preset frequencies (MHz) SUBGHZ_PRESETS = { - '315 MHz': 315.0, - '433.92 MHz': 433.92, - '868 MHz': 868.0, - '915 MHz': 915.0, + "315 MHz": 315.0, + "433.92 MHz": 433.92, + "868 MHz": 868.0, + "915 MHz": 915.0, } @@ -332,4 +327,3 @@ MAX_DEAUTH_ALERTS_AGE_SECONDS = 300 # 5 minutes # Deauth detector sniff timeout (seconds) DEAUTH_SNIFF_TIMEOUT = 0.5 - diff --git a/utils/correlation.py b/utils/correlation.py index 2563475..b8e5942 100644 --- a/utils/correlation.py +++ b/utils/correlation.py @@ -15,12 +15,13 @@ from typing import Any from utils.database import add_correlation from utils.database import get_correlations as db_get_correlations -logger = logging.getLogger('intercept.correlation') +logger = logging.getLogger("intercept.correlation") @dataclass class DeviceObservation: """A single observation of a device.""" + mac: str first_seen: datetime last_seen: datetime @@ -39,12 +40,7 @@ class DeviceCorrelator: 3. They share the same OUI/manufacturer (bonus confidence) """ - def __init__( - self, - time_window_seconds: int = 30, - min_confidence: float = 0.5, - rssi_threshold: int = 20 - ): + def __init__(self, time_window_seconds: int = 30, min_confidence: float = 0.5, rssi_threshold: int = 20): """ Initialize correlator. @@ -57,11 +53,7 @@ class DeviceCorrelator: self.min_confidence = min_confidence self.rssi_threshold = rssi_threshold - def correlate( - self, - wifi_devices: dict[str, dict[str, Any]], - bt_devices: dict[str, dict[str, Any]] - ) -> list[dict]: + def correlate(self, wifi_devices: dict[str, dict[str, Any]], bt_devices: dict[str, dict[str, Any]]) -> list[dict]: """ Find correlations between WiFi and Bluetooth devices. @@ -75,26 +67,28 @@ class DeviceCorrelator: correlations = [] for wifi_mac, wifi_data in wifi_devices.items(): - wifi_obs = self._to_observation(wifi_mac, wifi_data, 'wifi') + wifi_obs = self._to_observation(wifi_mac, wifi_data, "wifi") if not wifi_obs: continue for bt_mac, bt_data in bt_devices.items(): - bt_obs = self._to_observation(bt_mac, bt_data, 'bluetooth') + bt_obs = self._to_observation(bt_mac, bt_data, "bluetooth") if not bt_obs: continue confidence = self._calculate_confidence(wifi_obs, bt_obs) if confidence >= self.min_confidence: - correlations.append({ - 'wifi_mac': wifi_mac, - 'wifi_name': wifi_obs.name, - 'bt_mac': bt_mac, - 'bt_name': bt_obs.name, - 'confidence': round(confidence, 2), - 'reason': self._get_correlation_reason(wifi_obs, bt_obs) - }) + correlations.append( + { + "wifi_mac": wifi_mac, + "wifi_name": wifi_obs.name, + "bt_mac": bt_mac, + "bt_name": bt_obs.name, + "confidence": round(confidence, 2), + "reason": self._get_correlation_reason(wifi_obs, bt_obs), + } + ) # Persist high-confidence correlations if confidence >= 0.7: @@ -103,73 +97,56 @@ class DeviceCorrelator: wifi_mac=wifi_mac, bt_mac=bt_mac, confidence=confidence, - metadata={ - 'wifi_name': wifi_obs.name, - 'bt_name': bt_obs.name - } + metadata={"wifi_name": wifi_obs.name, "bt_name": bt_obs.name}, ) except Exception as e: logger.debug(f"Failed to persist correlation: {e}") # Sort by confidence (highest first) - correlations.sort(key=lambda x: x['confidence'], reverse=True) + correlations.sort(key=lambda x: x["confidence"], reverse=True) return correlations - def _to_observation( - self, - mac: str, - data: dict[str, Any], - device_type: str - ) -> DeviceObservation | None: + def _to_observation(self, mac: str, data: dict[str, Any], device_type: str) -> DeviceObservation | None: """Convert device dict to observation.""" try: # Handle different timestamp formats - first_seen = data.get('first_seen') or data.get('firstSeen') - last_seen = data.get('last_seen') or data.get('lastSeen') + first_seen = data.get("first_seen") or data.get("firstSeen") + last_seen = data.get("last_seen") or data.get("lastSeen") if isinstance(first_seen, str): - first_seen = datetime.fromisoformat(first_seen.replace('Z', '+00:00')) + first_seen = datetime.fromisoformat(first_seen.replace("Z", "+00:00")) elif isinstance(first_seen, (int, float)): first_seen = datetime.fromtimestamp(first_seen / 1000) else: first_seen = datetime.now() if isinstance(last_seen, str): - last_seen = datetime.fromisoformat(last_seen.replace('Z', '+00:00')) + last_seen = datetime.fromisoformat(last_seen.replace("Z", "+00:00")) elif isinstance(last_seen, (int, float)): last_seen = datetime.fromtimestamp(last_seen / 1000) else: last_seen = datetime.now() # Get RSSI (different field names) - rssi = data.get('rssi') or data.get('power') or data.get('signal') + rssi = data.get("rssi") or data.get("power") or data.get("signal") if rssi is not None: rssi = int(rssi) # Get name - name = data.get('name') or data.get('essid') or data.get('ssid') + name = data.get("name") or data.get("essid") or data.get("ssid") # Get manufacturer - manufacturer = data.get('manufacturer') or data.get('vendor') + manufacturer = data.get("manufacturer") or data.get("vendor") return DeviceObservation( - mac=mac, - first_seen=first_seen, - last_seen=last_seen, - rssi=rssi, - name=name, - manufacturer=manufacturer + mac=mac, first_seen=first_seen, last_seen=last_seen, rssi=rssi, name=name, manufacturer=manufacturer ) except Exception as e: logger.debug(f"Failed to parse device {mac}: {e}") return None - def _calculate_confidence( - self, - wifi: DeviceObservation, - bt: DeviceObservation - ) -> float: + def _calculate_confidence(self, wifi: DeviceObservation, bt: DeviceObservation) -> float: """ Calculate correlation confidence score. @@ -227,11 +204,7 @@ class DeviceCorrelator: return min(confidence, 1.0) - def _get_correlation_reason( - self, - wifi: DeviceObservation, - bt: DeviceObservation - ) -> str: + def _get_correlation_reason(self, wifi: DeviceObservation, bt: DeviceObservation) -> str: """Generate human-readable reason for correlation.""" reasons = [] @@ -263,7 +236,7 @@ def get_correlations( wifi_devices: dict[str, dict] | None = None, bt_devices: dict[str, dict] | None = None, min_confidence: float = 0.5, - include_historical: bool = True + include_historical: bool = True, ) -> list[dict]: """ Get device correlations. @@ -291,23 +264,23 @@ def get_correlations( for h in historical: # Avoid duplicates existing = next( - (r for r in results - if r['wifi_mac'] == h['wifi_mac'] and r['bt_mac'] == h['bt_mac']), - None + (r for r in results if r["wifi_mac"] == h["wifi_mac"] and r["bt_mac"] == h["bt_mac"]), None ) if not existing: - results.append({ - 'wifi_mac': h['wifi_mac'], - 'bt_mac': h['bt_mac'], - 'confidence': h['confidence'], - 'reason': 'historical correlation', - 'first_seen': h['first_seen'], - 'last_seen': h['last_seen'] - }) + results.append( + { + "wifi_mac": h["wifi_mac"], + "bt_mac": h["bt_mac"], + "confidence": h["confidence"], + "reason": "historical correlation", + "first_seen": h["first_seen"], + "last_seen": h["last_seen"], + } + ) except Exception as e: logger.debug(f"Failed to get historical correlations: {e}") # Sort by confidence - results.sort(key=lambda x: x['confidence'], reverse=True) + results.sort(key=lambda x: x["confidence"], reverse=True) return results diff --git a/utils/dsc/__init__.py b/utils/dsc/__init__.py index 86de034..c56a2f5 100644 --- a/utils/dsc/__init__.py +++ b/utils/dsc/__init__.py @@ -21,13 +21,13 @@ from .parser import ( ) __all__ = [ - 'FORMAT_CODES', - 'DISTRESS_NATURE_CODES', - 'TELECOMMAND_CODES', - 'CATEGORY_PRIORITY', - 'MID_COUNTRY_MAP', - 'parse_dsc_message', - 'get_country_from_mmsi', - 'get_distress_nature_text', - 'get_format_text', + "FORMAT_CODES", + "DISTRESS_NATURE_CODES", + "TELECOMMAND_CODES", + "CATEGORY_PRIORITY", + "MID_COUNTRY_MAP", + "parse_dsc_message", + "get_country_from_mmsi", + "get_distress_nature_text", + "get_format_text", ] diff --git a/utils/dsc/constants.py b/utils/dsc/constants.py index 727f596..7149524 100644 --- a/utils/dsc/constants.py +++ b/utils/dsc/constants.py @@ -14,12 +14,12 @@ from __future__ import annotations # ============================================================================= FORMAT_CODES = { - 102: 'ALL_SHIPS', # All ships call - 112: 'INDIVIDUAL', # Individual call - 114: 'INDIVIDUAL_ACK', # Individual acknowledgement - 116: 'GROUP', # Group call (including geographic area) - 120: 'DISTRESS', # Distress alert - 123: 'ALL_SHIPS_URGENCY_SAFETY', # All ships urgency/safety + 102: "ALL_SHIPS", # All ships call + 112: "INDIVIDUAL", # Individual call + 114: "INDIVIDUAL_ACK", # Individual acknowledgement + 116: "GROUP", # Group call (including geographic area) + 120: "DISTRESS", # Distress alert + 123: "ALL_SHIPS_URGENCY_SAFETY", # All ships urgency/safety } # Valid ITU-R M.493 format specifiers @@ -30,12 +30,12 @@ VALID_EOS = {117, 122, 127} # Category priority (lower = higher priority) CATEGORY_PRIORITY = { - 'DISTRESS': 0, - 'ALL_SHIPS_URGENCY_SAFETY': 2, - 'ALL_SHIPS': 5, - 'GROUP': 5, - 'INDIVIDUAL': 5, - 'INDIVIDUAL_ACK': 5, + "DISTRESS": 0, + "ALL_SHIPS_URGENCY_SAFETY": 2, + "ALL_SHIPS": 5, + "GROUP": 5, + "INDIVIDUAL": 5, + "INDIVIDUAL_ACK": 5, } @@ -45,18 +45,18 @@ CATEGORY_PRIORITY = { # ============================================================================= DISTRESS_NATURE_CODES = { - 100: 'UNDESIGNATED', # Undesignated distress - 101: 'FIRE', # Fire, explosion - 102: 'FLOODING', # Flooding - 103: 'COLLISION', # Collision - 104: 'GROUNDING', # Grounding - 105: 'LISTING', # Listing, in danger of capsizing - 106: 'SINKING', # Sinking - 107: 'DISABLED', # Disabled and adrift - 108: 'ABANDONING', # Abandoning ship - 109: 'PIRACY', # Piracy/armed robbery attack - 110: 'MOB', # Man overboard - 112: 'EPIRB', # EPIRB emission + 100: "UNDESIGNATED", # Undesignated distress + 101: "FIRE", # Fire, explosion + 102: "FLOODING", # Flooding + 103: "COLLISION", # Collision + 104: "GROUNDING", # Grounding + 105: "LISTING", # Listing, in danger of capsizing + 106: "SINKING", # Sinking + 107: "DISABLED", # Disabled and adrift + 108: "ABANDONING", # Abandoning ship + 109: "PIRACY", # Piracy/armed robbery attack + 110: "MOB", # Man overboard + 112: "EPIRB", # EPIRB emission } @@ -67,26 +67,25 @@ DISTRESS_NATURE_CODES = { TELECOMMAND_CODES = { # First telecommand (type of subsequent communication) - 100: 'F3E_G3E_ALL', # F3E/G3E all modes (VHF telephony) - 101: 'F3E_G3E_DUPLEX', # F3E/G3E duplex - 102: 'POLLING', # Polling - 103: 'UNABLE_TO_COMPLY', # Unable to comply - 104: 'END_OF_CALL', # End of call - 105: 'DATA', # Data - 106: 'J3E_TELEPHONY', # J3E telephony (SSB) - 107: 'DISTRESS_ACK', # Distress acknowledgement - 108: 'DISTRESS_RELAY', # Distress relay - 109: 'F1B_J2B_FEC', # F1B/J2B FEC NBDP telegraphy - 110: 'F1B_J2B_ARQ', # F1B/J2B ARQ NBDP telegraphy - 111: 'TEST', # Test - 112: 'SHIP_POSITION', # Ship position request - 113: 'NO_INFO', # No information - 118: 'FREQ_ANNOUNCEMENT', # Frequency announcement - 126: 'NO_REASON', # No reason given - + 100: "F3E_G3E_ALL", # F3E/G3E all modes (VHF telephony) + 101: "F3E_G3E_DUPLEX", # F3E/G3E duplex + 102: "POLLING", # Polling + 103: "UNABLE_TO_COMPLY", # Unable to comply + 104: "END_OF_CALL", # End of call + 105: "DATA", # Data + 106: "J3E_TELEPHONY", # J3E telephony (SSB) + 107: "DISTRESS_ACK", # Distress acknowledgement + 108: "DISTRESS_RELAY", # Distress relay + 109: "F1B_J2B_FEC", # F1B/J2B FEC NBDP telegraphy + 110: "F1B_J2B_ARQ", # F1B/J2B ARQ NBDP telegraphy + 111: "TEST", # Test + 112: "SHIP_POSITION", # Ship position request + 113: "NO_INFO", # No information + 118: "FREQ_ANNOUNCEMENT", # Frequency announcement + 126: "NO_REASON", # No reason given # Second telecommand (additional info) - 200: 'F3E_G3E_SIMPLEX', # Simplex VHF telephony requested - 201: 'POLL_RESPONSE', # Poll response + 200: "F3E_G3E_SIMPLEX", # Simplex VHF telephony requested + 201: "POLL_RESPONSE", # Poll response } # Full 0-127 telecommand lookup (maps unknown codes to "UNKNOWN") @@ -106,14 +105,14 @@ MIN_SYMBOLS_FOR_FORMAT = 12 # Special symbols DSC_SYMBOLS = { - 120: 'DX', # Dot pattern (synchronization) - 121: 'RX', # Phasing sequence RX - 122: 'SX', # Phasing sequence SX - 123: 'S0', # Phasing sequence S0 - 124: 'S1', # Phasing sequence S1 - 125: 'S2', # Phasing sequence S2 - 126: 'S3', # Phasing sequence S3 - 127: 'EOS', # End of sequence + 120: "DX", # Dot pattern (synchronization) + 121: "RX", # Phasing sequence RX + 122: "SX", # Phasing sequence SX + 123: "S0", # Phasing sequence S0 + 124: "S1", # Phasing sequence S1 + 125: "S2", # Phasing sequence S2 + 126: "S3", # Phasing sequence S3 + 127: "EOS", # End of sequence } @@ -125,308 +124,303 @@ DSC_SYMBOLS = { MID_COUNTRY_MAP = { # Americas - '201': 'Albania', - '202': 'Andorra', - '203': 'Austria', - '204': 'Azores', - '205': 'Belgium', - '206': 'Belarus', - '207': 'Bulgaria', - '208': 'Vatican City', - '209': 'Cyprus', - '210': 'Cyprus', - '211': 'Germany', - '212': 'Cyprus', - '213': 'Georgia', - '214': 'Moldova', - '215': 'Malta', - '216': 'Armenia', - '218': 'Germany', - '219': 'Denmark', - '220': 'Denmark', - '224': 'Spain', - '225': 'Spain', - '226': 'France', - '227': 'France', - '228': 'France', - '229': 'Malta', - '230': 'Finland', - '231': 'Faroe Islands', - '232': 'United Kingdom', - '233': 'United Kingdom', - '234': 'United Kingdom', - '235': 'United Kingdom', - '236': 'Gibraltar', - '237': 'Greece', - '238': 'Croatia', - '239': 'Greece', - '240': 'Greece', - '241': 'Greece', - '242': 'Morocco', - '243': 'Hungary', - '244': 'Netherlands', - '245': 'Netherlands', - '246': 'Netherlands', - '247': 'Italy', - '248': 'Malta', - '249': 'Malta', - '250': 'Ireland', - '251': 'Iceland', - '252': 'Liechtenstein', - '253': 'Luxembourg', - '254': 'Monaco', - '255': 'Madeira', - '256': 'Malta', - '257': 'Norway', - '258': 'Norway', - '259': 'Norway', - '261': 'Poland', - '262': 'Montenegro', - '263': 'Portugal', - '264': 'Romania', - '265': 'Sweden', - '266': 'Sweden', - '267': 'Slovakia', - '268': 'San Marino', - '269': 'Switzerland', - '270': 'Czech Republic', - '271': 'Turkey', - '272': 'Ukraine', - '273': 'Russia', - '274': 'North Macedonia', - '275': 'Latvia', - '276': 'Estonia', - '277': 'Lithuania', - '278': 'Slovenia', - '279': 'Serbia', - + "201": "Albania", + "202": "Andorra", + "203": "Austria", + "204": "Azores", + "205": "Belgium", + "206": "Belarus", + "207": "Bulgaria", + "208": "Vatican City", + "209": "Cyprus", + "210": "Cyprus", + "211": "Germany", + "212": "Cyprus", + "213": "Georgia", + "214": "Moldova", + "215": "Malta", + "216": "Armenia", + "218": "Germany", + "219": "Denmark", + "220": "Denmark", + "224": "Spain", + "225": "Spain", + "226": "France", + "227": "France", + "228": "France", + "229": "Malta", + "230": "Finland", + "231": "Faroe Islands", + "232": "United Kingdom", + "233": "United Kingdom", + "234": "United Kingdom", + "235": "United Kingdom", + "236": "Gibraltar", + "237": "Greece", + "238": "Croatia", + "239": "Greece", + "240": "Greece", + "241": "Greece", + "242": "Morocco", + "243": "Hungary", + "244": "Netherlands", + "245": "Netherlands", + "246": "Netherlands", + "247": "Italy", + "248": "Malta", + "249": "Malta", + "250": "Ireland", + "251": "Iceland", + "252": "Liechtenstein", + "253": "Luxembourg", + "254": "Monaco", + "255": "Madeira", + "256": "Malta", + "257": "Norway", + "258": "Norway", + "259": "Norway", + "261": "Poland", + "262": "Montenegro", + "263": "Portugal", + "264": "Romania", + "265": "Sweden", + "266": "Sweden", + "267": "Slovakia", + "268": "San Marino", + "269": "Switzerland", + "270": "Czech Republic", + "271": "Turkey", + "272": "Ukraine", + "273": "Russia", + "274": "North Macedonia", + "275": "Latvia", + "276": "Estonia", + "277": "Lithuania", + "278": "Slovenia", + "279": "Serbia", # North America - '301': 'Anguilla', - '303': 'USA', - '304': 'Antigua and Barbuda', - '305': 'Antigua and Barbuda', - '306': 'Curacao', - '307': 'Aruba', - '308': 'Bahamas', - '309': 'Bahamas', - '310': 'Bermuda', - '311': 'Bahamas', - '312': 'Belize', - '314': 'Barbados', - '316': 'Canada', - '319': 'Cayman Islands', - '321': 'Costa Rica', - '323': 'Cuba', - '325': 'Dominica', - '327': 'Dominican Republic', - '329': 'Guadeloupe', - '330': 'Grenada', - '331': 'Greenland', - '332': 'Guatemala', - '334': 'Honduras', - '336': 'Haiti', - '338': 'USA', - '339': 'Jamaica', - '341': 'Saint Kitts and Nevis', - '343': 'Saint Lucia', - '345': 'Mexico', - '347': 'Martinique', - '348': 'Montserrat', - '350': 'Nicaragua', - '351': 'Panama', - '352': 'Panama', - '353': 'Panama', - '354': 'Panama', - '355': 'Panama', - '356': 'Panama', - '357': 'Panama', - '358': 'Puerto Rico', - '359': 'El Salvador', - '361': 'Saint Pierre and Miquelon', - '362': 'Trinidad and Tobago', - '364': 'Turks and Caicos', - '366': 'USA', - '367': 'USA', - '368': 'USA', - '369': 'USA', - '370': 'Panama', - '371': 'Panama', - '372': 'Panama', - '373': 'Panama', - '374': 'Panama', - '375': 'Saint Vincent and the Grenadines', - '376': 'Saint Vincent and the Grenadines', - '377': 'Saint Vincent and the Grenadines', - '378': 'British Virgin Islands', - '379': 'US Virgin Islands', - + "301": "Anguilla", + "303": "USA", + "304": "Antigua and Barbuda", + "305": "Antigua and Barbuda", + "306": "Curacao", + "307": "Aruba", + "308": "Bahamas", + "309": "Bahamas", + "310": "Bermuda", + "311": "Bahamas", + "312": "Belize", + "314": "Barbados", + "316": "Canada", + "319": "Cayman Islands", + "321": "Costa Rica", + "323": "Cuba", + "325": "Dominica", + "327": "Dominican Republic", + "329": "Guadeloupe", + "330": "Grenada", + "331": "Greenland", + "332": "Guatemala", + "334": "Honduras", + "336": "Haiti", + "338": "USA", + "339": "Jamaica", + "341": "Saint Kitts and Nevis", + "343": "Saint Lucia", + "345": "Mexico", + "347": "Martinique", + "348": "Montserrat", + "350": "Nicaragua", + "351": "Panama", + "352": "Panama", + "353": "Panama", + "354": "Panama", + "355": "Panama", + "356": "Panama", + "357": "Panama", + "358": "Puerto Rico", + "359": "El Salvador", + "361": "Saint Pierre and Miquelon", + "362": "Trinidad and Tobago", + "364": "Turks and Caicos", + "366": "USA", + "367": "USA", + "368": "USA", + "369": "USA", + "370": "Panama", + "371": "Panama", + "372": "Panama", + "373": "Panama", + "374": "Panama", + "375": "Saint Vincent and the Grenadines", + "376": "Saint Vincent and the Grenadines", + "377": "Saint Vincent and the Grenadines", + "378": "British Virgin Islands", + "379": "US Virgin Islands", # Asia - '401': 'Afghanistan', - '403': 'Saudi Arabia', - '405': 'Bangladesh', - '408': 'Bahrain', - '410': 'Bhutan', - '412': 'China', - '413': 'China', - '414': 'China', - '416': 'Taiwan', - '417': 'Sri Lanka', - '419': 'India', - '422': 'Iran', - '423': 'Azerbaijan', - '425': 'Iraq', - '428': 'Israel', - '431': 'Japan', - '432': 'Japan', - '434': 'Turkmenistan', - '436': 'Kazakhstan', - '437': 'Uzbekistan', - '438': 'Jordan', - '440': 'South Korea', - '441': 'South Korea', - '443': 'Palestine', - '445': 'North Korea', - '447': 'Kuwait', - '450': 'Lebanon', - '451': 'Kyrgyzstan', - '453': 'Macao', - '455': 'Maldives', - '457': 'Mongolia', - '459': 'Nepal', - '461': 'Oman', - '463': 'Pakistan', - '466': 'Qatar', - '468': 'Syria', - '470': 'UAE', - '471': 'UAE', - '472': 'Tajikistan', - '473': 'Yemen', - '475': 'Yemen', - '477': 'Hong Kong', - '478': 'Bosnia and Herzegovina', - + "401": "Afghanistan", + "403": "Saudi Arabia", + "405": "Bangladesh", + "408": "Bahrain", + "410": "Bhutan", + "412": "China", + "413": "China", + "414": "China", + "416": "Taiwan", + "417": "Sri Lanka", + "419": "India", + "422": "Iran", + "423": "Azerbaijan", + "425": "Iraq", + "428": "Israel", + "431": "Japan", + "432": "Japan", + "434": "Turkmenistan", + "436": "Kazakhstan", + "437": "Uzbekistan", + "438": "Jordan", + "440": "South Korea", + "441": "South Korea", + "443": "Palestine", + "445": "North Korea", + "447": "Kuwait", + "450": "Lebanon", + "451": "Kyrgyzstan", + "453": "Macao", + "455": "Maldives", + "457": "Mongolia", + "459": "Nepal", + "461": "Oman", + "463": "Pakistan", + "466": "Qatar", + "468": "Syria", + "470": "UAE", + "471": "UAE", + "472": "Tajikistan", + "473": "Yemen", + "475": "Yemen", + "477": "Hong Kong", + "478": "Bosnia and Herzegovina", # Oceania - '501': 'Adelie Land', - '503': 'Australia', - '506': 'Myanmar', - '508': 'Brunei', - '510': 'Micronesia', - '511': 'Palau', - '512': 'New Zealand', - '514': 'Cambodia', - '515': 'Cambodia', - '516': 'Christmas Island', - '518': 'Cook Islands', - '520': 'Fiji', - '523': 'Cocos Islands', - '525': 'Indonesia', - '529': 'Kiribati', - '531': 'Laos', - '533': 'Malaysia', - '536': 'Northern Mariana Islands', - '538': 'Marshall Islands', - '540': 'New Caledonia', - '542': 'Niue', - '544': 'Nauru', - '546': 'French Polynesia', - '548': 'Philippines', - '550': 'Timor-Leste', - '553': 'Papua New Guinea', - '555': 'Pitcairn Island', - '557': 'Solomon Islands', - '559': 'American Samoa', - '561': 'Samoa', - '563': 'Singapore', - '564': 'Singapore', - '565': 'Singapore', - '566': 'Singapore', - '567': 'Thailand', - '570': 'Tonga', - '572': 'Tuvalu', - '574': 'Vietnam', - '576': 'Vanuatu', - '577': 'Vanuatu', - '578': 'Wallis and Futuna', - + "501": "Adelie Land", + "503": "Australia", + "506": "Myanmar", + "508": "Brunei", + "510": "Micronesia", + "511": "Palau", + "512": "New Zealand", + "514": "Cambodia", + "515": "Cambodia", + "516": "Christmas Island", + "518": "Cook Islands", + "520": "Fiji", + "523": "Cocos Islands", + "525": "Indonesia", + "529": "Kiribati", + "531": "Laos", + "533": "Malaysia", + "536": "Northern Mariana Islands", + "538": "Marshall Islands", + "540": "New Caledonia", + "542": "Niue", + "544": "Nauru", + "546": "French Polynesia", + "548": "Philippines", + "550": "Timor-Leste", + "553": "Papua New Guinea", + "555": "Pitcairn Island", + "557": "Solomon Islands", + "559": "American Samoa", + "561": "Samoa", + "563": "Singapore", + "564": "Singapore", + "565": "Singapore", + "566": "Singapore", + "567": "Thailand", + "570": "Tonga", + "572": "Tuvalu", + "574": "Vietnam", + "576": "Vanuatu", + "577": "Vanuatu", + "578": "Wallis and Futuna", # Africa - '601': 'South Africa', - '603': 'Angola', - '605': 'Algeria', - '607': 'St. Paul and Amsterdam Islands', - '608': 'Ascension Island', - '609': 'Burundi', - '610': 'Benin', - '611': 'Botswana', - '612': 'Central African Republic', - '613': 'Cameroon', - '615': 'Congo', - '616': 'Comoros', - '617': 'Cabo Verde', - '618': 'Crozet Archipelago', - '619': 'Ivory Coast', - '620': 'Comoros', - '621': 'Djibouti', - '622': 'Egypt', - '624': 'Ethiopia', - '625': 'Eritrea', - '626': 'Gabon', - '627': 'Ghana', - '629': 'Gambia', - '630': 'Guinea-Bissau', - '631': 'Equatorial Guinea', - '632': 'Guinea', - '633': 'Burkina Faso', - '634': 'Kenya', - '635': 'Kerguelen Islands', - '636': 'Liberia', - '637': 'Liberia', - '638': 'South Sudan', - '642': 'Libya', - '644': 'Lesotho', - '645': 'Mauritius', - '647': 'Madagascar', - '649': 'Mali', - '650': 'Mozambique', - '654': 'Mauritania', - '655': 'Malawi', - '656': 'Niger', - '657': 'Nigeria', - '659': 'Namibia', - '660': 'Reunion', - '661': 'Rwanda', - '662': 'Sudan', - '663': 'Senegal', - '664': 'Seychelles', - '665': 'Saint Helena', - '666': 'Somalia', - '667': 'Sierra Leone', - '668': 'Sao Tome and Principe', - '669': 'Swaziland', - '670': 'Chad', - '671': 'Togo', - '672': 'Tunisia', - '674': 'Tanzania', - '675': 'Uganda', - '676': 'Democratic Republic of Congo', - '677': 'Tanzania', - '678': 'Zambia', - '679': 'Zimbabwe', - + "601": "South Africa", + "603": "Angola", + "605": "Algeria", + "607": "St. Paul and Amsterdam Islands", + "608": "Ascension Island", + "609": "Burundi", + "610": "Benin", + "611": "Botswana", + "612": "Central African Republic", + "613": "Cameroon", + "615": "Congo", + "616": "Comoros", + "617": "Cabo Verde", + "618": "Crozet Archipelago", + "619": "Ivory Coast", + "620": "Comoros", + "621": "Djibouti", + "622": "Egypt", + "624": "Ethiopia", + "625": "Eritrea", + "626": "Gabon", + "627": "Ghana", + "629": "Gambia", + "630": "Guinea-Bissau", + "631": "Equatorial Guinea", + "632": "Guinea", + "633": "Burkina Faso", + "634": "Kenya", + "635": "Kerguelen Islands", + "636": "Liberia", + "637": "Liberia", + "638": "South Sudan", + "642": "Libya", + "644": "Lesotho", + "645": "Mauritius", + "647": "Madagascar", + "649": "Mali", + "650": "Mozambique", + "654": "Mauritania", + "655": "Malawi", + "656": "Niger", + "657": "Nigeria", + "659": "Namibia", + "660": "Reunion", + "661": "Rwanda", + "662": "Sudan", + "663": "Senegal", + "664": "Seychelles", + "665": "Saint Helena", + "666": "Somalia", + "667": "Sierra Leone", + "668": "Sao Tome and Principe", + "669": "Swaziland", + "670": "Chad", + "671": "Togo", + "672": "Tunisia", + "674": "Tanzania", + "675": "Uganda", + "676": "Democratic Republic of Congo", + "677": "Tanzania", + "678": "Zambia", + "679": "Zimbabwe", # South America - '701': 'Argentina', - '710': 'Brazil', - '720': 'Bolivia', - '725': 'Chile', - '730': 'Colombia', - '735': 'Ecuador', - '740': 'Falkland Islands', - '745': 'Guiana', - '750': 'Guyana', - '755': 'Paraguay', - '760': 'Peru', - '765': 'Suriname', - '770': 'Uruguay', - '775': 'Venezuela', + "701": "Argentina", + "710": "Brazil", + "720": "Bolivia", + "725": "Chile", + "730": "Colombia", + "735": "Ecuador", + "740": "Falkland Islands", + "745": "Guiana", + "750": "Guyana", + "755": "Paraguay", + "760": "Peru", + "765": "Suriname", + "770": "Uruguay", + "775": "Venezuela", } @@ -435,9 +429,9 @@ MID_COUNTRY_MAP = { # ============================================================================= VHF_CHANNELS = { - 6: 156.300, # Intership safety - 8: 156.400, # Commercial working - 9: 156.450, # Calling + 6: 156.300, # Intership safety + 8: 156.400, # Commercial working + 9: 156.450, # Calling 10: 156.500, # Commercial working 12: 156.600, # Port operations 13: 156.650, # Bridge-to-bridge navigation safety @@ -469,5 +463,5 @@ DSC_AUDIO_SAMPLE_RATE = 48000 # Frame structure DSC_DOT_PATTERN_LENGTH = 200 # 200 bits of alternating pattern -DSC_PHASING_LENGTH = 7 # 7 symbols phasing sequence -DSC_MESSAGE_MAX_SYMBOLS = 180 # Maximum message length in symbols +DSC_PHASING_LENGTH = 7 # 7 symbols phasing sequence +DSC_MESSAGE_MAX_SYMBOLS = 180 # Maximum message length in symbols diff --git a/utils/dsc/decoder.py b/utils/dsc/decoder.py index 6a734d2..fd91970 100644 --- a/utils/dsc/decoder.py +++ b/utils/dsc/decoder.py @@ -47,12 +47,8 @@ from .constants import ( ) # Configure logging -logging.basicConfig( - level=logging.WARNING, - format='%(asctime)s [%(levelname)s] %(message)s', - stream=sys.stderr -) -logger = logging.getLogger('dsc.decoder') +logging.basicConfig(level=logging.WARNING, format="%(asctime)s [%(levelname)s] %(message)s", stream=sys.stderr) +logger = logging.getLogger("dsc.decoder") class DSCDecoder: @@ -75,7 +71,7 @@ class DSCDecoder: nyq = sample_rate / 2 low = 1100 / nyq high = 2300 / nyq - self.bp_b, self.bp_a = scipy_signal.butter(4, [low, high], btype='band') + self.bp_b, self.bp_a = scipy_signal.butter(4, [low, high], btype="band") # Build FSK correlators self._build_correlators() @@ -162,8 +158,8 @@ class DSCDecoder: break # Correlate with mark and space references - mark_corr = np.abs(np.correlate(segment, self.mark_ref, mode='valid')) - space_corr = np.abs(np.correlate(segment, self.space_ref, mode='valid')) + mark_corr = np.abs(np.correlate(segment, self.mark_ref, mode="valid")) + space_corr = np.abs(np.correlate(segment, self.space_ref, mode="valid")) # Decision: mark (1) if mark correlation > space correlation if np.max(mark_corr) > np.max(space_corr): @@ -304,7 +300,7 @@ class DSCDecoder: return None # Decode the message from symbols - return self._decode_symbols(symbols[:eos_index + 1]) + return self._decode_symbols(symbols[: eos_index + 1]) def _bits_to_symbol(self, bits: list[int]) -> int: """ @@ -322,7 +318,7 @@ class DSCDecoder: value = 0 for i in range(7): if bits[i]: - value |= (1 << i) + value |= 1 << i # Validate check bits: total number of 1s should be even ones = sum(bits) @@ -354,23 +350,23 @@ class DSCDecoder: try: # Format specifier (first non-phasing symbol) format_code = symbols[0] - format_text = FORMAT_CODES.get(format_code, f'UNKNOWN-{format_code}') + format_text = FORMAT_CODES.get(format_code, f"UNKNOWN-{format_code}") # Derive category from format specifier per ITU-R M.493 if format_code == 120: - category = 'DISTRESS' + category = "DISTRESS" elif format_code == 123: - category = 'ALL_SHIPS_URGENCY_SAFETY' + category = "ALL_SHIPS_URGENCY_SAFETY" elif format_code == 102: - category = 'ALL_SHIPS' + category = "ALL_SHIPS" elif format_code == 116: - category = 'GROUP' + category = "GROUP" elif format_code == 112: - category = 'INDIVIDUAL' + category = "INDIVIDUAL" elif format_code == 114: - category = 'INDIVIDUAL_ACK' + category = "INDIVIDUAL_ACK" else: - category = FORMAT_CODES.get(format_code, 'UNKNOWN') + category = FORMAT_CODES.get(format_code, "UNKNOWN") # Decode MMSI from symbols 1-5 (destination/address) dest_mmsi = self._decode_mmsi(symbols[1:6]) @@ -383,40 +379,38 @@ class DSCDecoder: return None message = { - 'type': 'dsc', - 'format': format_code, - 'format_text': format_text, - 'category': category, - 'source_mmsi': source_mmsi, - 'dest_mmsi': dest_mmsi, - 'timestamp': datetime.utcnow().isoformat() + 'Z', + "type": "dsc", + "format": format_code, + "format_text": format_text, + "category": category, + "source_mmsi": source_mmsi, + "dest_mmsi": dest_mmsi, + "timestamp": datetime.utcnow().isoformat() + "Z", } # Parse additional fields based on format remaining = symbols[11:-1] # Exclude EOS - if category in ('DISTRESS', 'DISTRESS_RELAY'): + if category in ("DISTRESS", "DISTRESS_RELAY"): # Distress messages have nature and position if len(remaining) >= 1: - message['nature'] = remaining[0] - message['nature_text'] = DISTRESS_NATURE_CODES.get( - remaining[0], f'UNKNOWN-{remaining[0]}' - ) + message["nature"] = remaining[0] + message["nature_text"] = DISTRESS_NATURE_CODES.get(remaining[0], f"UNKNOWN-{remaining[0]}") # Try to decode position if len(remaining) >= 11: position = self._decode_position(remaining[1:11]) if position: - message['position'] = position + message["position"] = position # Telecommand fields (last two before EOS) — only for formats # that carry telecommand fields per ITU-R M.493 if format_code in TELECOMMAND_FORMATS and len(remaining) >= 2: - message['telecommand1'] = remaining[-2] - message['telecommand2'] = remaining[-1] + message["telecommand1"] = remaining[-2] + message["telecommand2"] = remaining[-1] # Add raw data for debugging - message['raw'] = ''.join(f'{s:03d}' for s in symbols) + message["raw"] = "".join(f"{s:03d}" for s in symbols) logger.info(f"Decoded DSC: {category} from {source_mmsi}") return message @@ -441,9 +435,9 @@ class DSCDecoder: if sym < 0 or sym > 99: return None # Each symbol is 2 BCD digits - digits.append(f'{sym:02d}') + digits.append(f"{sym:02d}") - mmsi = ''.join(digits) + mmsi = "".join(digits) # MMSI is 9 digits - trim the leading digit from the 10-digit # BCD result since the first symbol's high digit is always 0 if len(mmsi) > 9: @@ -487,7 +481,7 @@ class DSCDecoder: lat = lat_sign * (lat_deg + lat_min / 60.0) lon = lon_sign * (lon_deg + lon_min / 60.0) - return {'lat': round(lat, 6), 'lon': round(lon, 6)} + return {"lat": round(lat, 6), "lon": round(lon, 6)} except Exception: return None @@ -517,20 +511,16 @@ def read_audio_stdin() -> Generator[bytes, None, None]: def main(): """Main entry point for DSC decoder.""" parser = argparse.ArgumentParser( - description='DSC (Digital Selective Calling) decoder', - epilog='Reads 48kHz 16-bit signed PCM audio from stdin' + description="DSC (Digital Selective Calling) decoder", epilog="Reads 48kHz 16-bit signed PCM audio from stdin" ) parser.add_argument( - '-r', '--sample-rate', + "-r", + "--sample-rate", type=int, default=DSC_AUDIO_SAMPLE_RATE, - help=f'Audio sample rate (default: {DSC_AUDIO_SAMPLE_RATE})' - ) - parser.add_argument( - '-v', '--verbose', - action='store_true', - help='Enable verbose logging' + help=f"Audio sample rate (default: {DSC_AUDIO_SAMPLE_RATE})", ) + parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging") args = parser.parse_args() if args.verbose: @@ -551,5 +541,5 @@ def main(): logger.info("DSC decoder stopped") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/utils/dsc/parser.py b/utils/dsc/parser.py index b7da06e..70571fe 100644 --- a/utils/dsc/parser.py +++ b/utils/dsc/parser.py @@ -23,7 +23,7 @@ from .constants import ( VALID_FORMAT_SPECIFIERS, ) -logger = logging.getLogger('intercept.dsc.parser') +logger = logging.getLogger("intercept.dsc.parser") def get_country_from_mmsi(mmsi: str) -> str | None: @@ -47,13 +47,13 @@ def get_country_from_mmsi(mmsi: str) -> str | None: return MID_COUNTRY_MAP[mid] # Coast station MMSI: starts with 00 + MID - if mmsi.startswith('00') and len(mmsi) >= 5: + if mmsi.startswith("00") and len(mmsi) >= 5: mid = mmsi[2:5] if mid in MID_COUNTRY_MAP: return MID_COUNTRY_MAP[mid] # Group ship station MMSI: starts with 0 + MID - if mmsi.startswith('0') and len(mmsi) >= 4: + if mmsi.startswith("0") and len(mmsi) >= 4: mid = mmsi[1:4] if mid in MID_COUNTRY_MAP: return MID_COUNTRY_MAP[mid] @@ -69,7 +69,7 @@ def get_distress_nature_text(code: int | str) -> str: except ValueError: return str(code) - return DISTRESS_NATURE_CODES.get(code, f'UNKNOWN ({code})') + return DISTRESS_NATURE_CODES.get(code, f"UNKNOWN ({code})") def get_format_text(code: int | str) -> str: @@ -80,7 +80,7 @@ def get_format_text(code: int | str) -> str: except ValueError: return str(code) - return FORMAT_CODES.get(code, f'UNKNOWN ({code})') + return FORMAT_CODES.get(code, f"UNKNOWN ({code})") def get_telecommand_text(code: int | str) -> str: @@ -91,7 +91,7 @@ def get_telecommand_text(code: int | str) -> str: except ValueError: return str(code) - return TELECOMMAND_CODES.get(code, f'UNKNOWN ({code})') + return TELECOMMAND_CODES.get(code, f"UNKNOWN ({code})") def get_category_priority(category: str) -> int: @@ -135,25 +135,25 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None: return None # Validate required fields - if data.get('type') != 'dsc': + if data.get("type") != "dsc": return None - if 'source_mmsi' not in data: + if "source_mmsi" not in data: return None # ITU-R M.493 validation: format specifier must be valid - format_code = data.get('format') + format_code = data.get("format") if format_code not in VALID_FORMAT_SPECIFIERS: logger.debug(f"Rejected DSC: invalid format specifier {format_code}") return None # Validate MMSIs - source_mmsi = str(data.get('source_mmsi', '')) + source_mmsi = str(data.get("source_mmsi", "")) if not validate_mmsi(source_mmsi): logger.debug(f"Rejected DSC: invalid source MMSI {source_mmsi}") return None - dest_mmsi_val = data.get('dest_mmsi') + dest_mmsi_val = data.get("dest_mmsi") if dest_mmsi_val is not None: dest_mmsi_str = str(dest_mmsi_val) if not validate_mmsi(dest_mmsi_str): @@ -161,10 +161,10 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None: return None # Validate raw field structure if present - raw = data.get('raw') + raw = data.get("raw") if raw is not None: raw_str = str(raw) - if not re.match(r'^\d+$', raw_str): + if not re.match(r"^\d+$", raw_str): logger.debug("Rejected DSC: raw field contains non-digits") return None if len(raw_str) % 3 != 0: @@ -178,7 +178,7 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None: return None # Validate telecommand values if present (must be 100-127) - for tc_field in ('telecommand1', 'telecommand2'): + for tc_field in ("telecommand1", "telecommand2"): tc_val = data.get(tc_field) if tc_val is not None: try: @@ -192,65 +192,69 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None: # Build parsed message msg = { - 'type': 'dsc_message', - 'source_mmsi': source_mmsi, - 'dest_mmsi': str(data.get('dest_mmsi', '')) if data.get('dest_mmsi') is not None else None, - 'format_code': format_code, - 'format_text': get_format_text(format_code), - 'category': data.get('category', 'UNKNOWN').upper(), - 'timestamp': data.get('timestamp') or datetime.utcnow().isoformat(), + "type": "dsc_message", + "source_mmsi": source_mmsi, + "dest_mmsi": str(data.get("dest_mmsi", "")) if data.get("dest_mmsi") is not None else None, + "format_code": format_code, + "format_text": get_format_text(format_code), + "category": data.get("category", "UNKNOWN").upper(), + "timestamp": data.get("timestamp") or datetime.utcnow().isoformat(), } # Add country from MMSI - country = get_country_from_mmsi(msg['source_mmsi']) + country = get_country_from_mmsi(msg["source_mmsi"]) if country: - msg['source_country'] = country + msg["source_country"] = country # Add distress nature if present - if data.get('nature') is not None: - msg['nature_code'] = data['nature'] - msg['nature_of_distress'] = get_distress_nature_text(data['nature']) + if data.get("nature") is not None: + msg["nature_code"] = data["nature"] + msg["nature_of_distress"] = get_distress_nature_text(data["nature"]) # Add position if present - position = data.get('position') + position = data.get("position") if position and isinstance(position, dict): - lat = position.get('lat') - lon = position.get('lon') + lat = position.get("lat") + lon = position.get("lon") if lat is not None and lon is not None: try: - msg['latitude'] = float(lat) - msg['longitude'] = float(lon) + msg["latitude"] = float(lat) + msg["longitude"] = float(lon) except (ValueError, TypeError): pass # Add telecommand info - if data.get('telecommand1') is not None: - msg['telecommand1'] = data['telecommand1'] - msg['telecommand1_text'] = get_telecommand_text(data['telecommand1']) + if data.get("telecommand1") is not None: + msg["telecommand1"] = data["telecommand1"] + msg["telecommand1_text"] = get_telecommand_text(data["telecommand1"]) - if data.get('telecommand2') is not None: - msg['telecommand2'] = data['telecommand2'] - msg['telecommand2_text'] = get_telecommand_text(data['telecommand2']) + if data.get("telecommand2") is not None: + msg["telecommand2"] = data["telecommand2"] + msg["telecommand2_text"] = get_telecommand_text(data["telecommand2"]) # Add channel if present - if data.get('channel') is not None: - msg['channel'] = data['channel'] + if data.get("channel") is not None: + msg["channel"] = data["channel"] # Add EOS (End of Sequence) info - if 'eos' in data: - msg['eos'] = data['eos'] + if "eos" in data: + msg["eos"] = data["eos"] # Add raw message for debugging - if 'raw' in data: - msg['raw_message'] = data['raw'] + if "raw" in data: + msg["raw_message"] = data["raw"] # Calculate priority - msg['priority'] = get_category_priority(msg['category']) + msg["priority"] = get_category_priority(msg["category"]) # Mark if this is a critical alert - msg['is_critical'] = msg['category'] in ( - 'DISTRESS', 'DISTRESS_ACK', 'DISTRESS_RELAY', - 'URGENCY', 'SAFETY', 'ALL_SHIPS_URGENCY_SAFETY', + msg["is_critical"] = msg["category"] in ( + "DISTRESS", + "DISTRESS_ACK", + "DISTRESS_RELAY", + "URGENCY", + "SAFETY", + "ALL_SHIPS_URGENCY_SAFETY", ) return msg @@ -269,9 +273,9 @@ def format_dsc_for_display(msg: dict) -> str: lines = [] # Header with category and MMSI - category = msg.get('category', 'UNKNOWN') - mmsi = msg.get('source_mmsi', 'UNKNOWN') - country = msg.get('source_country', '') + category = msg.get("category", "UNKNOWN") + mmsi = msg.get("source_mmsi", "UNKNOWN") + country = msg.get("source_country", "") header = f"[{category}] MMSI: {mmsi}" if country: @@ -279,34 +283,34 @@ def format_dsc_for_display(msg: dict) -> str: lines.append(header) # Destination if present - if msg.get('dest_mmsi'): + if msg.get("dest_mmsi"): lines.append(f" To: {msg['dest_mmsi']}") # Distress nature - if msg.get('nature_of_distress'): + if msg.get("nature_of_distress"): lines.append(f" Nature: {msg['nature_of_distress']}") # Position - if msg.get('latitude') is not None and msg.get('longitude') is not None: - lat = msg['latitude'] - lon = msg['longitude'] - lat_dir = 'N' if lat >= 0 else 'S' - lon_dir = 'E' if lon >= 0 else 'W' + if msg.get("latitude") is not None and msg.get("longitude") is not None: + lat = msg["latitude"] + lon = msg["longitude"] + lat_dir = "N" if lat >= 0 else "S" + lon_dir = "E" if lon >= 0 else "W" lines.append(f" Position: {abs(lat):.4f}{lat_dir} {abs(lon):.4f}{lon_dir}") # Telecommand - if msg.get('telecommand1_text'): + if msg.get("telecommand1_text"): lines.append(f" Request: {msg['telecommand1_text']}") # Channel - if msg.get('channel'): + if msg.get("channel"): lines.append(f" Channel: {msg['channel']}") # Timestamp - if msg.get('timestamp'): + if msg.get("timestamp"): lines.append(f" Time: {msg['timestamp']}") - return '\n'.join(lines) + return "\n".join(lines) def validate_mmsi(mmsi: str) -> bool: @@ -326,11 +330,11 @@ def validate_mmsi(mmsi: str) -> bool: return False # Must be 9 digits - if not re.match(r'^\d{9}$', mmsi): + if not re.match(r"^\d{9}$", mmsi): return False # All zeros is invalid - return mmsi != '000000000' + return mmsi != "000000000" def classify_mmsi(mmsi: str) -> str: @@ -344,30 +348,30 @@ def classify_mmsi(mmsi: str) -> str: Classification: 'ship', 'coast', 'group', 'sar', 'aton', or 'unknown' """ if not validate_mmsi(mmsi): - return 'unknown' + return "unknown" first_digit = mmsi[0] first_two = mmsi[:2] first_three = mmsi[:3] # Coast station: starts with 00 - if first_two == '00': - return 'coast' + if first_two == "00": + return "coast" # Group call: starts with 0 - if first_digit == '0': - return 'group' + if first_digit == "0": + return "group" # SAR aircraft: starts with 111 - if first_three == '111': - return 'sar' + if first_three == "111": + return "sar" # Aids to Navigation: starts with 99 - if first_two == '99': - return 'aton' + if first_two == "99": + return "aton" # Ship station: starts with MID (2-7) - if first_digit in '234567': - return 'ship' + if first_digit in "234567": + return "ship" - return 'unknown' + return "unknown" diff --git a/utils/event_pipeline.py b/utils/event_pipeline.py index 35fcf53..2bf9b62 100644 --- a/utils/event_pipeline.py +++ b/utils/event_pipeline.py @@ -8,23 +8,23 @@ from utils.alerts import get_alert_manager from utils.recording import get_recording_manager from utils.temporal_patterns import get_pattern_detector -IGNORE_TYPES = {'keepalive', 'ping'} +IGNORE_TYPES = {"keepalive", "ping"} DEVICE_ID_FIELDS = ( - 'device_id', - 'id', - 'mac', - 'mac_address', - 'address', - 'bssid', - 'station_mac', - 'client_mac', - 'icao', - 'callsign', - 'mmsi', - 'uuid', - 'hash', + "device_id", + "id", + "mac", + "mac_address", + "address", + "bssid", + "station_mac", + "client_mac", + "icao", + "callsign", + "mmsi", + "uuid", + "hash", ) @@ -54,6 +54,7 @@ def process_event(mode: str, event: dict | Any, event_type: str | None = None) - # Alert failures should never break streaming pass + def _extract_device_id(event: dict) -> str | None: for field in DEVICE_ID_FIELDS: value = event.get(field) @@ -63,7 +64,7 @@ def _extract_device_id(event: dict) -> str | None: if text: return text - nested_candidates = ('target', 'device', 'source', 'aircraft', 'vessel') + nested_candidates = ("target", "device", "source", "aircraft", "vessel") for key in nested_candidates: nested = event.get(key) if isinstance(nested, dict): diff --git a/utils/flight_correlator.py b/utils/flight_correlator.py index 4e37b84..e97261e 100644 --- a/utils/flight_correlator.py +++ b/utils/flight_correlator.py @@ -16,16 +16,20 @@ class FlightCorrelator: self._vdl2_messages: deque[dict] = deque(maxlen=max_messages) def add_acars_message(self, msg: dict) -> None: - self._acars_messages.append({ - **msg, - '_corr_time': time.time(), - }) + self._acars_messages.append( + { + **msg, + "_corr_time": time.time(), + } + ) def add_vdl2_message(self, msg: dict) -> None: - self._vdl2_messages.append({ - **msg, - '_corr_time': time.time(), - }) + self._vdl2_messages.append( + { + **msg, + "_corr_time": time.time(), + } + ) def get_messages_for_aircraft( self, @@ -35,7 +39,7 @@ class FlightCorrelator: ) -> dict[str, list[dict]]: """Match ACARS/VDL2 messages by callsign, flight, or registration fields.""" if not icao and not callsign: - return {'acars': [], 'vdl2': []} + return {"acars": [], "vdl2": []} search_terms: set[str] = set() if callsign: @@ -58,12 +62,12 @@ class FlightCorrelator: if self._msg_matches(msg, search_terms): vdl2.append(self._clean_msg(msg)) - return {'acars': acars, 'vdl2': vdl2} + return {"acars": acars, "vdl2": vdl2} @staticmethod def _msg_matches(msg: dict, terms: set[str]) -> bool: """Check if any identifying field in msg matches the search terms.""" - for field in ('flight', 'tail', 'reg', 'callsign', 'icao', 'addr'): + for field in ("flight", "tail", "reg", "callsign", "icao", "addr"): val = msg.get(field) if not val: continue @@ -79,11 +83,11 @@ class FlightCorrelator: @staticmethod def _clean_msg(msg: dict) -> dict: """Return message without internal correlation fields.""" - return {k: v for k, v in msg.items() if not k.startswith('_corr_')} + return {k: v for k, v in msg.items() if not k.startswith("_corr_")} - def get_recent_messages(self, msg_type: str = 'acars', limit: int = 50) -> list[dict]: + def get_recent_messages(self, msg_type: str = "acars", limit: int = 50) -> list[dict]: """Return the most recent messages (newest first).""" - source = self._acars_messages if msg_type == 'acars' else self._vdl2_messages + source = self._acars_messages if msg_type == "acars" else self._vdl2_messages msgs = [self._clean_msg(m) for m in source] msgs.reverse() return msgs[:limit] diff --git a/utils/geofence.py b/utils/geofence.py index 564e9d9..ccb30f9 100644 --- a/utils/geofence.py +++ b/utils/geofence.py @@ -23,7 +23,7 @@ def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> fl def _ensure_table() -> None: """Create geofence_zones table if it doesn't exist.""" with get_db() as conn: - conn.execute(''' + conn.execute(""" CREATE TABLE IF NOT EXISTS geofence_zones ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -33,7 +33,7 @@ def _ensure_table() -> None: alert_on TEXT DEFAULT 'enter_exit', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') + """) class GeofenceManager: @@ -46,30 +46,29 @@ class GeofenceManager: def list_zones(self) -> list[dict]: with get_db() as conn: cursor = conn.execute( - 'SELECT id, name, lat, lon, radius_m, alert_on, created_at FROM geofence_zones ORDER BY id' + "SELECT id, name, lat, lon, radius_m, alert_on, created_at FROM geofence_zones ORDER BY id" ) return [dict(row) for row in cursor] - def add_zone(self, name: str, lat: float, lon: float, radius_m: float, - alert_on: str = 'enter_exit') -> int: + def add_zone(self, name: str, lat: float, lon: float, radius_m: float, alert_on: str = "enter_exit") -> int: with get_db() as conn: cursor = conn.execute( - 'INSERT INTO geofence_zones (name, lat, lon, radius_m, alert_on) VALUES (?, ?, ?, ?, ?)', + "INSERT INTO geofence_zones (name, lat, lon, radius_m, alert_on) VALUES (?, ?, ?, ?, ?)", (name, lat, lon, radius_m, alert_on), ) return cursor.lastrowid def delete_zone(self, zone_id: int) -> bool: with get_db() as conn: - cursor = conn.execute('DELETE FROM geofence_zones WHERE id = ?', (zone_id,)) + cursor = conn.execute("DELETE FROM geofence_zones WHERE id = ?", (zone_id,)) # Clean up inside tracking for entity_zones in self._inside.values(): entity_zones.discard(zone_id) return cursor.rowcount > 0 - def check_position(self, entity_id: str, entity_type: str, - lat: float, lon: float, - metadata: dict[str, Any] | None = None) -> list[dict]: + def check_position( + self, entity_id: str, entity_type: str, lat: float, lon: float, metadata: dict[str, Any] | None = None + ) -> list[dict]: """Check entity position against all zones. Returns list of events.""" zones = self.list_zones() if not zones: @@ -80,36 +79,40 @@ class GeofenceManager: curr_inside: set[int] = set() for zone in zones: - dist = haversine_distance(lat, lon, zone['lat'], zone['lon']) - zid = zone['id'] - if dist <= zone['radius_m']: + dist = haversine_distance(lat, lon, zone["lat"], zone["lon"]) + zid = zone["id"] + if dist <= zone["radius_m"]: curr_inside.add(zid) - if zid not in prev_inside and zone['alert_on'] in ('enter', 'enter_exit'): - events.append({ - 'type': 'geofence_enter', - 'zone_id': zid, - 'zone_name': zone['name'], - 'entity_id': entity_id, - 'entity_type': entity_type, - 'distance_m': round(dist, 1), - 'lat': lat, - 'lon': lon, - **(metadata or {}), - }) + if zid not in prev_inside and zone["alert_on"] in ("enter", "enter_exit"): + events.append( + { + "type": "geofence_enter", + "zone_id": zid, + "zone_name": zone["name"], + "entity_id": entity_id, + "entity_type": entity_type, + "distance_m": round(dist, 1), + "lat": lat, + "lon": lon, + **(metadata or {}), + } + ) else: - if zid in prev_inside and zone['alert_on'] in ('exit', 'enter_exit'): - events.append({ - 'type': 'geofence_exit', - 'zone_id': zid, - 'zone_name': zone['name'], - 'entity_id': entity_id, - 'entity_type': entity_type, - 'distance_m': round(dist, 1), - 'lat': lat, - 'lon': lon, - **(metadata or {}), - }) + if zid in prev_inside and zone["alert_on"] in ("exit", "enter_exit"): + events.append( + { + "type": "geofence_exit", + "zone_id": zid, + "zone_name": zone["name"], + "entity_id": entity_id, + "entity_type": entity_type, + "distance_m": round(dist, 1), + "lat": lat, + "lon": lon, + **(metadata or {}), + } + ) self._inside[entity_id] = curr_inside return events diff --git a/utils/gps.py b/utils/gps.py index 3d8954c..c6073d6 100644 --- a/utils/gps.py +++ b/utils/gps.py @@ -14,33 +14,35 @@ from dataclasses import dataclass, field from datetime import datetime from typing import Callable -logger = logging.getLogger('intercept.gps') +logger = logging.getLogger("intercept.gps") @dataclass class GPSSatellite: """Individual satellite data from gpsd SKY message.""" + prn: int elevation: float | None = None # degrees azimuth: float | None = None # degrees snr: float | None = None # dB-Hz used: bool = False - constellation: str = 'GPS' # GPS, GLONASS, Galileo, BeiDou, SBAS, QZSS + constellation: str = "GPS" # GPS, GLONASS, Galileo, BeiDou, SBAS, QZSS def to_dict(self) -> dict: return { - 'prn': self.prn, - 'elevation': self.elevation, - 'azimuth': self.azimuth, - 'snr': self.snr, - 'used': self.used, - 'constellation': self.constellation, + "prn": self.prn, + "elevation": self.elevation, + "azimuth": self.azimuth, + "snr": self.snr, + "used": self.used, + "constellation": self.constellation, } @dataclass class GPSSkyData: """Sky view data from gpsd SKY message.""" + satellites: list[GPSSatellite] = field(default_factory=list) hdop: float | None = None vdop: float | None = None @@ -54,22 +56,23 @@ class GPSSkyData: def to_dict(self) -> dict: return { - 'satellites': [s.to_dict() for s in self.satellites], - 'hdop': self.hdop, - 'vdop': self.vdop, - 'pdop': self.pdop, - 'tdop': self.tdop, - 'gdop': self.gdop, - 'xdop': self.xdop, - 'ydop': self.ydop, - 'nsat': self.nsat, - 'usat': self.usat, + "satellites": [s.to_dict() for s in self.satellites], + "hdop": self.hdop, + "vdop": self.vdop, + "pdop": self.pdop, + "tdop": self.tdop, + "gdop": self.gdop, + "xdop": self.xdop, + "ydop": self.ydop, + "nsat": self.nsat, + "usat": self.usat, } @dataclass class GPSPosition: """GPS position data.""" + latitude: float longitude: float altitude: float | None = None @@ -90,21 +93,21 @@ class GPSPosition: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'latitude': self.latitude, - 'longitude': self.longitude, - 'altitude': self.altitude, - 'speed': self.speed, - 'heading': self.heading, - 'climb': self.climb, - 'satellites': self.satellites, - 'fix_quality': self.fix_quality, - 'timestamp': self.timestamp.isoformat() if self.timestamp else None, - 'device': self.device, - 'epx': self.epx, - 'epy': self.epy, - 'epv': self.epv, - 'eps': self.eps, - 'ept': self.ept, + "latitude": self.latitude, + "longitude": self.longitude, + "altitude": self.altitude, + "speed": self.speed, + "heading": self.heading, + "climb": self.climb, + "satellites": self.satellites, + "fix_quality": self.fix_quality, + "timestamp": self.timestamp.isoformat() if self.timestamp else None, + "device": self.device, + "epx": self.epx, + "epy": self.epy, + "epv": self.epv, + "eps": self.eps, + "ept": self.ept, } @@ -112,26 +115,32 @@ def _classify_constellation(prn: int, gnssid: int | None = None) -> str: """Classify satellite constellation from PRN or gnssid.""" if gnssid is not None: mapping = { - 0: 'GPS', 1: 'SBAS', 2: 'Galileo', 3: 'BeiDou', - 4: 'IMES', 5: 'QZSS', 6: 'GLONASS', 7: 'NavIC', + 0: "GPS", + 1: "SBAS", + 2: "Galileo", + 3: "BeiDou", + 4: "IMES", + 5: "QZSS", + 6: "GLONASS", + 7: "NavIC", } - return mapping.get(gnssid, 'GPS') + return mapping.get(gnssid, "GPS") # Fall back to PRN range heuristic if 1 <= prn <= 32: - return 'GPS' + return "GPS" elif 33 <= prn <= 64: - return 'SBAS' + return "SBAS" elif 65 <= prn <= 96: - return 'GLONASS' + return "GLONASS" elif 120 <= prn <= 158: - return 'SBAS' + return "SBAS" elif 201 <= prn <= 264: - return 'BeiDou' + return "BeiDou" elif 301 <= prn <= 336: - return 'Galileo' + return "Galileo" elif 193 <= prn <= 200: - return 'QZSS' - return 'GPS' + return "QZSS" + return "GPS" class GPSDClient: @@ -142,7 +151,7 @@ class GPSDClient: device management, making it ideal when gpsd is already running. """ - DEFAULT_HOST = 'localhost' + DEFAULT_HOST = "localhost" DEFAULT_PORT = 2947 def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT): @@ -194,20 +203,20 @@ class GPSDClient: """Return gpsd connection info.""" return f"gpsd://{self.host}:{self.port}" - def add_callback(self, callback: Callable[[GPSPosition], None]) -> None: - """Add a callback to be called on position updates.""" - if callback not in self._callbacks: - self._callbacks.append(callback) + def add_callback(self, callback: Callable[[GPSPosition], None]) -> None: + """Add a callback to be called on position updates.""" + if callback not in self._callbacks: + self._callbacks.append(callback) def remove_callback(self, callback: Callable[[GPSPosition], None]) -> None: """Remove a position update callback.""" if callback in self._callbacks: self._callbacks.remove(callback) - def add_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None: - """Add a callback to be called on sky data updates.""" - if callback not in self._sky_callbacks: - self._sky_callbacks.append(callback) + def add_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None: + """Add a callback to be called on sky data updates.""" + if callback not in self._sky_callbacks: + self._sky_callbacks.append(callback) def remove_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None: """Remove a sky data update callback.""" @@ -228,7 +237,7 @@ class GPSDClient: # Enable JSON watch mode watch_cmd = '?WATCH={"enable":true,"json":true}\n' - self._socket.send(watch_cmd.encode('ascii')) + self._socket.send(watch_cmd.encode("ascii")) self._running = True self._error = None @@ -289,11 +298,11 @@ class GPSDClient: self._error = "Connection closed by gpsd" break - buffer += data.decode('ascii', errors='ignore') + buffer += data.decode("ascii", errors="ignore") # Process complete JSON lines - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) line = line.strip() if not line: @@ -301,21 +310,21 @@ class GPSDClient: try: msg = json.loads(line) - msg_class = msg.get('class', '') + msg_class = msg.get("class", "") message_count += 1 if message_count <= 5 or message_count % 20 == 0: print(f"[GPS] gpsd msg [{message_count}]: {msg_class}", flush=True) - if msg_class == 'TPV': + if msg_class == "TPV": self._handle_tpv(msg) - elif msg_class == 'SKY': + elif msg_class == "SKY": self._handle_sky(msg) - elif msg_class == 'DEVICES': + elif msg_class == "DEVICES": # Track connected device - devices = msg.get('devices', []) + devices = msg.get("devices", []) if devices: - self._device = devices[0].get('path', 'unknown') + self._device = devices[0].get("path", "unknown") print(f"[GPS] gpsd device: {self._device}", flush=True) except json.JSONDecodeError: @@ -334,41 +343,41 @@ class GPSDClient: def _handle_tpv(self, msg: dict) -> None: """Handle TPV (Time-Position-Velocity) message from gpsd.""" # mode: 0=unknown, 1=no fix, 2=2D fix, 3=3D fix - mode = msg.get('mode', 0) + mode = msg.get("mode", 0) if mode < 2: # No fix yet return - lat = msg.get('lat') - lon = msg.get('lon') + lat = msg.get("lat") + lon = msg.get("lon") if lat is None or lon is None: return # Parse timestamp timestamp = None - time_str = msg.get('time') + time_str = msg.get("time") if time_str: with contextlib.suppress(ValueError, AttributeError): # gpsd uses ISO format: 2024-01-01T12:00:00.000Z - timestamp = datetime.fromisoformat(time_str.replace('Z', '+00:00')) + timestamp = datetime.fromisoformat(time_str.replace("Z", "+00:00")) position = GPSPosition( latitude=lat, longitude=lon, - altitude=msg.get('alt'), - speed=msg.get('speed'), # m/s in gpsd - heading=msg.get('track'), - climb=msg.get('climb'), + altitude=msg.get("alt"), + speed=msg.get("speed"), # m/s in gpsd + heading=msg.get("track"), + climb=msg.get("climb"), fix_quality=mode, timestamp=timestamp, device=self._device or f"gpsd://{self.host}:{self.port}", - epx=msg.get('epx'), - epy=msg.get('epy'), - epv=msg.get('epv'), - eps=msg.get('eps'), - ept=msg.get('ept'), + epx=msg.get("epx"), + epy=msg.get("epy"), + epv=msg.get("epv"), + eps=msg.get("eps"), + ept=msg.get("ept"), ) print(f"[GPS] gpsd FIX: {lat:.6f}, {lon:.6f} (mode: {mode})", flush=True) @@ -382,22 +391,24 @@ class GPSDClient: DOP-only SKY arrives, preserve the most recent satellite list instead of overwriting it with an empty one. """ - raw_sats = msg.get('satellites', []) + raw_sats = msg.get("satellites", []) has_satellites = len(raw_sats) > 0 if has_satellites: sats = [] for sat in raw_sats: - prn = sat.get('PRN', 0) - gnssid = sat.get('gnssid') - sats.append(GPSSatellite( - prn=prn, - elevation=sat.get('el'), - azimuth=sat.get('az'), - snr=sat.get('ss'), - used=sat.get('used', False), - constellation=_classify_constellation(prn, gnssid), - )) + prn = sat.get("PRN", 0) + gnssid = sat.get("gnssid") + sats.append( + GPSSatellite( + prn=prn, + elevation=sat.get("el"), + azimuth=sat.get("az"), + snr=sat.get("ss"), + used=sat.get("used", False), + constellation=_classify_constellation(prn, gnssid), + ) + ) else: # DOP-only SKY message — keep existing satellites with self._lock: @@ -405,13 +416,13 @@ class GPSDClient: sky_data = GPSSkyData( satellites=sats, - hdop=msg.get('hdop'), - vdop=msg.get('vdop'), - pdop=msg.get('pdop'), - tdop=msg.get('tdop'), - gdop=msg.get('gdop'), - xdop=msg.get('xdop'), - ydop=msg.get('ydop'), + hdop=msg.get("hdop"), + vdop=msg.get("vdop"), + pdop=msg.get("pdop"), + tdop=msg.get("tdop"), + gdop=msg.get("gdop"), + xdop=msg.get("xdop"), + ydop=msg.get("ydop"), nsat=len(sats), usat=sum(1 for s in sats if s.used), ) @@ -452,9 +463,12 @@ def get_gps_reader() -> GPSDClient | None: return _gps_client -def start_gpsd(host: str = 'localhost', port: int = 2947, - callback: Callable[[GPSPosition], None] | None = None, - sky_callback: Callable[[GPSSkyData], None] | None = None) -> bool: +def start_gpsd( + host: str = "localhost", + port: int = 2947, + callback: Callable[[GPSPosition], None] | None = None, + sky_callback: Callable[[GPSSkyData], None] | None = None, +) -> bool: """ Start the global GPS client connected to gpsd. @@ -524,42 +538,40 @@ def detect_gps_devices() -> list[dict]: devices: list[dict] = [] system = platform.system() - if system == 'Linux': + if system == "Linux": # Common USB GPS device paths - patterns = ['/dev/ttyUSB*', '/dev/ttyACM*'] + patterns = ["/dev/ttyUSB*", "/dev/ttyACM*"] for pattern in patterns: for path in sorted(glob.glob(pattern)): desc = _describe_device_linux(path) - devices.append({'path': path, 'description': desc}) + devices.append({"path": path, "description": desc}) # Also check /dev/serial/by-id for descriptive names - serial_dir = '/dev/serial/by-id' + serial_dir = "/dev/serial/by-id" if os.path.isdir(serial_dir): for name in sorted(os.listdir(serial_dir)): full = os.path.join(serial_dir, name) real = os.path.realpath(full) # Skip if we already found this device - if any(d['path'] == real for d in devices): + if any(d["path"] == real for d in devices): # Update description with the more descriptive name for d in devices: - if d['path'] == real: - d['description'] = name + if d["path"] == real: + d["description"] = name continue - devices.append({'path': real, 'description': name}) + devices.append({"path": real, "description": name}) - elif system == 'Darwin': + elif system == "Darwin": # macOS: USB serial devices (prefer cu. over tty. for outgoing) - patterns = ['/dev/cu.usbmodem*', '/dev/cu.usbserial*'] + patterns = ["/dev/cu.usbmodem*", "/dev/cu.usbserial*"] for pattern in patterns: for path in sorted(glob.glob(pattern)): desc = _describe_device_macos(path) - devices.append({'path': path, 'description': desc}) + devices.append({"path": path, "description": desc}) # Sort: devices with GPS-related descriptions first - gps_keywords = ('gps', 'gnss', 'u-blox', 'ublox', 'nmea', 'sirf', 'navigation') - devices.sort(key=lambda d: ( - 0 if any(k in d['description'].lower() for k in gps_keywords) else 1 - )) + gps_keywords = ("gps", "gnss", "u-blox", "ublox", "nmea", "sirf", "navigation") + devices.sort(key=lambda d: (0 if any(k in d["description"].lower() for k in gps_keywords) else 1)) return devices @@ -567,11 +579,12 @@ def detect_gps_devices() -> list[dict]: def _describe_device_linux(path: str) -> str: """Get a human-readable description of a Linux serial device.""" import os + basename = os.path.basename(path) # Try to read from sysfs try: # /sys/class/tty/ttyUSB0/device/../product - sysfs = f'/sys/class/tty/{basename}/device/../product' + sysfs = f"/sys/class/tty/{basename}/device/../product" if os.path.exists(sysfs): with open(sysfs) as f: return f.read().strip() @@ -583,12 +596,14 @@ def _describe_device_linux(path: str) -> str: def _describe_device_macos(path: str) -> str: """Get a description of a macOS serial device.""" import os + return os.path.basename(path) -def is_gpsd_running(host: str = 'localhost', port: int = 2947) -> bool: +def is_gpsd_running(host: str = "localhost", port: int = 2947) -> bool: """Check if gpsd is reachable.""" import socket + try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(1.0) @@ -599,8 +614,7 @@ def is_gpsd_running(host: str = 'localhost', port: int = 2947) -> bool: return False -def start_gpsd_daemon(device_path: str, host: str = 'localhost', - port: int = 2947) -> tuple[bool, str]: +def start_gpsd_daemon(device_path: str, host: str = "localhost", port: int = 2947) -> tuple[bool, str]: """ Start gpsd daemon pointing at the given device. @@ -614,21 +628,22 @@ def start_gpsd_daemon(device_path: str, host: str = 'localhost', with _gpsd_process_lock: # Already running? if is_gpsd_running(host, port): - return True, 'gpsd already running' + return True, "gpsd already running" - gpsd_bin = shutil.which('gpsd') + gpsd_bin = shutil.which("gpsd") if not gpsd_bin: - return False, 'gpsd not installed' + return False, "gpsd not installed" # Stop any existing managed process stop_gpsd_daemon() try: import os - if not os.path.exists(device_path): - return False, f'Device {device_path} not found' - cmd = [gpsd_bin, '-N', '-n', '-S', str(port), device_path] + if not os.path.exists(device_path): + return False, f"Device {device_path} not found" + + cmd = [gpsd_bin, "-N", "-n", "-S", str(port), device_path] logger.info(f"Starting gpsd: {' '.join(cmd)}") print(f"[GPS] Starting gpsd: {' '.join(cmd)}", flush=True) @@ -640,22 +655,23 @@ def start_gpsd_daemon(device_path: str, host: str = 'localhost', # Give gpsd a moment to start import time + time.sleep(1.5) if _gpsd_process.poll() is not None: - stderr = '' + stderr = "" if _gpsd_process.stderr: - stderr = _gpsd_process.stderr.read().decode('utf-8', errors='ignore').strip() - msg = f'gpsd exited with code {_gpsd_process.returncode}' + stderr = _gpsd_process.stderr.read().decode("utf-8", errors="ignore").strip() + msg = f"gpsd exited with code {_gpsd_process.returncode}" if stderr: - msg += f': {stderr}' + msg += f": {stderr}" return False, msg # Verify it's listening if is_gpsd_running(host, port): - return True, f'gpsd started on {device_path}' + return True, f"gpsd started on {device_path}" else: - return False, 'gpsd started but not accepting connections' + return False, "gpsd started but not accepting connections" except Exception as e: logger.error(f"Failed to start gpsd: {e}") diff --git a/utils/ground_station/consumers/fm_demod.py b/utils/ground_station/consumers/fm_demod.py index 46f3f52..841e28e 100644 --- a/utils/ground_station/consumers/fm_demod.py +++ b/utils/ground_station/consumers/fm_demod.py @@ -21,7 +21,7 @@ from utils.logging import get_logger from utils.process import register_process, safe_terminate, unregister_process from utils.waterfall_fft import cu8_to_complex -logger = get_logger('intercept.ground_station.fm_demod') +logger = get_logger("intercept.ground_station.fm_demod") AUDIO_RATE = 48_000 # Hz — standard rate for direwolf / multimon-ng @@ -33,7 +33,7 @@ class FMDemodConsumer: self, decoder_cmd: list[str], *, - modulation: str = 'fm', + modulation: str = "fm", on_decoded: Callable[[str], None] | None = None, ): """ @@ -104,10 +104,9 @@ class FMDemodConsumer: def _start_proc(self) -> None: import shutil + if not shutil.which(self._decoder_cmd[0]): - logger.warning( - f"FMDemodConsumer: decoder '{self._decoder_cmd[0]}' not found — disabled" - ) + logger.warning(f"FMDemodConsumer: decoder '{self._decoder_cmd[0]}' not found — disabled") return try: self._proc = subprocess.Popen( @@ -117,9 +116,7 @@ class FMDemodConsumer: stderr=subprocess.DEVNULL, ) register_process(self._proc) - self._stdout_thread = threading.Thread( - target=self._read_stdout, daemon=True, name='fm-demod-stdout' - ) + self._stdout_thread = threading.Thread(target=self._read_stdout, daemon=True, name="fm-demod-stdout") self._stdout_thread.start() except Exception as e: logger.error(f"FMDemodConsumer: failed to start decoder: {e}") @@ -130,7 +127,7 @@ class FMDemodConsumer: assert self._proc.stdout is not None try: for line in self._proc.stdout: - decoded = line.decode('utf-8', errors='replace').rstrip() + decoded = line.decode("utf-8", errors="replace").rstrip() if decoded and self._on_decoded: try: self._on_decoded(decoded) @@ -186,14 +183,14 @@ def _demodulate( if shifted.size < 16: return None, next_phase - if mod == 'fm': + if mod == "fm": audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32) - elif mod == 'am': + elif mod == "am": envelope = np.abs(shifted).astype(np.float32) audio = envelope - float(np.mean(envelope)) - elif mod == 'usb': + elif mod == "usb": audio = np.real(shifted).astype(np.float32) - elif mod == 'lsb': + elif mod == "lsb": audio = -np.real(shifted).astype(np.float32) else: audio = np.real(shifted).astype(np.float32) diff --git a/utils/ground_station/consumers/gr_satellites.py b/utils/ground_station/consumers/gr_satellites.py index e9222ba..e36c469 100644 --- a/utils/ground_station/consumers/gr_satellites.py +++ b/utils/ground_station/consumers/gr_satellites.py @@ -23,9 +23,9 @@ import numpy as np from utils.logging import get_logger from utils.process import register_process, safe_terminate, unregister_process -logger = get_logger('intercept.ground_station.gr_satellites') +logger = get_logger("intercept.ground_station.gr_satellites") -GR_SATELLITES_BIN = 'gr_satellites' +GR_SATELLITES_BIN = "gr_satellites" class GrSatConsumer: @@ -106,10 +106,11 @@ class GrSatConsumer: cmd = [ GR_SATELLITES_BIN, self._satellite_name, - '--samplerate', str(sample_rate), - '--iq', - '--json', - '-', + "--samplerate", + str(sample_rate), + "--iq", + "--json", + "-", ] try: self._proc = subprocess.Popen( @@ -124,7 +125,7 @@ class GrSatConsumer: target=self._read_stdout, args=(_json,), daemon=True, - name='gr-sat-stdout', + name="gr-sat-stdout", ) self._stdout_thread.start() logger.info(f"GrSatConsumer started for '{self._satellite_name}'") @@ -138,14 +139,14 @@ class GrSatConsumer: assert self._proc.stdout is not None try: for line in self._proc.stdout: - text = line.decode('utf-8', errors='replace').rstrip() + text = line.decode("utf-8", errors="replace").rstrip() if not text: continue if self._on_decoded: try: data = _json.loads(text) except _json.JSONDecodeError: - data = {'raw': text} + data = {"raw": text} try: self._on_decoded(data) except Exception as e: diff --git a/utils/ground_station/consumers/sigmf_writer.py b/utils/ground_station/consumers/sigmf_writer.py index d49e87d..4c68f14 100644 --- a/utils/ground_station/consumers/sigmf_writer.py +++ b/utils/ground_station/consumers/sigmf_writer.py @@ -5,7 +5,7 @@ from __future__ import annotations from utils.logging import get_logger from utils.sigmf import SigMFMetadata, SigMFWriter -logger = get_logger('intercept.ground_station.sigmf_consumer') +logger = get_logger("intercept.ground_station.sigmf_consumer") class SigMFConsumer: diff --git a/utils/ground_station/consumers/waterfall.py b/utils/ground_station/consumers/waterfall.py index 72c0c90..b0f33e5 100644 --- a/utils/ground_station/consumers/waterfall.py +++ b/utils/ground_station/consumers/waterfall.py @@ -20,7 +20,7 @@ from utils.waterfall_fft import ( quantize_to_uint8, ) -logger = get_logger('intercept.ground_station.waterfall_consumer') +logger = get_logger("intercept.ground_station.waterfall_consumer") FFT_SIZE = 1024 AVG_COUNT = 4 @@ -52,7 +52,7 @@ class WaterfallConsumer: self._start_freq = 0.0 self._end_freq = 0.0 self._sample_rate = 0 - self._buffer = b'' + self._buffer = b"" self._required_bytes = 0 self._frame_interval = 1.0 / max(1, fps) self._last_frame_time = 0.0 @@ -80,7 +80,7 @@ class WaterfallConsumer: ) self._required_bytes = required_samples * 2 # 1 byte I + 1 byte Q self._frame_interval = 1.0 / max(1, self._fps) - self._buffer = b'' + self._buffer = b"" self._last_frame_time = 0.0 def on_chunk(self, raw: bytes) -> None: @@ -91,15 +91,13 @@ class WaterfallConsumer: if len(self._buffer) < self._required_bytes: return - chunk = self._buffer[-self._required_bytes:] - self._buffer = b'' + chunk = self._buffer[-self._required_bytes :] + self._buffer = b"" self._last_frame_time = now try: samples = cu8_to_complex(chunk) - power_db = compute_power_spectrum( - samples, fft_size=self._fft_size, avg_count=self._avg_count - ) + power_db = compute_power_spectrum(samples, fft_size=self._fft_size, avg_count=self._avg_count) quantized = quantize_to_uint8(power_db, db_min=self._db_min, db_max=self._db_max) frame = build_binary_frame(self._start_freq, self._end_freq, quantized) except Exception as e: @@ -118,4 +116,4 @@ class WaterfallConsumer: pass def on_stop(self) -> None: - self._buffer = b'' + self._buffer = b"" diff --git a/utils/ground_station/iq_bus.py b/utils/ground_station/iq_bus.py index b5eaafc..139e8ad 100644 --- a/utils/ground_station/iq_bus.py +++ b/utils/ground_station/iq_bus.py @@ -21,7 +21,7 @@ from typing import Protocol, runtime_checkable from utils.logging import get_logger from utils.process import register_process, safe_terminate, unregister_process -logger = get_logger('intercept.ground_station.iq_bus') +logger = get_logger("intercept.ground_station.iq_bus") CHUNK_SIZE = 65_536 # bytes per read (~27 ms @ 2.4 Msps CU8) @@ -73,7 +73,7 @@ class IQBus: sample_rate: int = 2_400_000, gain: float | None = None, device_index: int = 0, - sdr_type: str = 'rtlsdr', + sdr_type: str = "rtlsdr", ppm: int | None = None, bias_t: bool = False, ): @@ -113,12 +113,12 @@ class IQBus: def start(self) -> tuple[bool, str]: """Start IQ capture. Returns (success, error_message).""" if self._running: - return True, '' + return True, "" try: cmd = self._build_command(self._center_mhz) except Exception as e: - return False, f'Failed to build IQ capture command: {e}' + return False, f"Failed to build IQ capture command: {e}" if not shutil.which(cmd[0]): return False, f'Required tool "{cmd[0]}" not found. Install SoapySDR (rx_sdr) or rtl-sdr.' @@ -132,21 +132,21 @@ class IQBus: ) register_process(self._proc) except Exception as e: - return False, f'Failed to spawn IQ capture: {e}' + return False, f"Failed to spawn IQ capture: {e}" # Brief check that the process actually started time.sleep(0.3) if self._proc.poll() is not None: - stderr_out = '' + stderr_out = "" if self._proc.stderr: try: - stderr_out = self._proc.stderr.read().decode('utf-8', errors='replace').strip() + stderr_out = self._proc.stderr.read().decode("utf-8", errors="replace").strip() except Exception: pass unregister_process(self._proc) self._proc = None - detail = f': {stderr_out}' if stderr_out else '' - return False, f'IQ capture process exited immediately{detail}' + detail = f": {stderr_out}" if stderr_out else "" + return False, f"IQ capture process exited immediately{detail}" self._stop_event.clear() self._running = True @@ -167,15 +167,13 @@ class IQBus: except Exception as e: logger.warning(f"Consumer on_start error: {e}") - self._producer_thread = threading.Thread( - target=self._producer_loop, daemon=True, name='iq-bus-producer' - ) + self._producer_thread = threading.Thread(target=self._producer_loop, daemon=True, name="iq-bus-producer") self._producer_thread.start() logger.info( f"IQBus started: {self._center_mhz} MHz, sr={self._sample_rate}, " f"device={self._sdr_type}:{self._device_index}" ) - return True, '' + return True, "" def stop(self) -> None: """Stop IQ capture and notify all consumers.""" @@ -201,7 +199,7 @@ class IQBus: """Retune by stopping and restarting the capture process.""" self._current_freq_mhz = new_freq_mhz if not self._running: - return False, 'Not running' + return False, "Not running" # Stop the current process self._stop_event.set() @@ -225,14 +223,12 @@ class IQBus: register_process(self._proc) except Exception as e: self._running = False - return False, f'Retune failed: {e}' + return False, f"Retune failed: {e}" - self._producer_thread = threading.Thread( - target=self._producer_loop, daemon=True, name='iq-bus-producer' - ) + self._producer_thread = threading.Thread(target=self._producer_loop, daemon=True, name="iq-bus-producer") self._producer_thread.start() logger.info(f"IQBus retuned to {new_freq_mhz:.6f} MHz") - return True, '' + return True, "" @property def running(self) -> bool: @@ -279,12 +275,12 @@ class IQBus: from utils.sdr.base import SDRDevice type_map = { - 'rtlsdr': SDRType.RTL_SDR, - 'rtl_sdr': SDRType.RTL_SDR, - 'hackrf': SDRType.HACKRF, - 'limesdr': SDRType.LIME_SDR, - 'airspy': SDRType.AIRSPY, - 'sdrplay': SDRType.SDRPLAY, + "rtlsdr": SDRType.RTL_SDR, + "rtl_sdr": SDRType.RTL_SDR, + "hackrf": SDRType.HACKRF, + "limesdr": SDRType.LIME_SDR, + "airspy": SDRType.AIRSPY, + "sdrplay": SDRType.SDRPLAY, } sdr_type = type_map.get(self._sdr_type.lower(), SDRType.RTL_SDR) builder = SDRFactory.get_builder(sdr_type) @@ -292,8 +288,8 @@ class IQBus: device = SDRDevice( sdr_type=sdr_type, index=self._device_index, - name=f'{sdr_type.value}-{self._device_index}', - serial='N/A', + name=f"{sdr_type.value}-{self._device_index}", + serial="N/A", driver=sdr_type.value, capabilities=caps, ) diff --git a/utils/ground_station/meteor_backend.py b/utils/ground_station/meteor_backend.py index 1d303ab..4b83802 100644 --- a/utils/ground_station/meteor_backend.py +++ b/utils/ground_station/meteor_backend.py @@ -11,14 +11,14 @@ from pathlib import Path from utils.logging import get_logger from utils.weather_sat import WeatherSatDecoder -logger = get_logger('intercept.ground_station.meteor_backend') +logger = get_logger("intercept.ground_station.meteor_backend") -OUTPUT_ROOT = Path('instance/ground_station/weather_outputs') +OUTPUT_ROOT = Path("instance/ground_station/weather_outputs") DECODE_TIMEOUT_SECONDS = 30 * 60 _NORAD_TO_SAT_KEY = { - 57166: 'METEOR-M2-3', - 59051: 'METEOR-M2-4', + 57166: "METEOR-M2-3", + 59051: "METEOR-M2-4", } @@ -26,11 +26,11 @@ def resolve_meteor_satellite_key(norad_id: int, satellite_name: str) -> str | No if norad_id in _NORAD_TO_SAT_KEY: return _NORAD_TO_SAT_KEY[norad_id] - upper = str(satellite_name or '').upper() - if 'M2-4' in upper: - return 'METEOR-M2-4' - if 'M2-3' in upper or 'METEOR' in upper: - return 'METEOR-M2-3' + upper = str(satellite_name or "").upper() + if "M2-4" in upper: + return "METEOR-M2-4" + if "M2-3" in upper or "METEOR" in upper: + return "METEOR-M2-3" return None @@ -48,31 +48,33 @@ def launch_meteor_decode( decode_job_id = _create_decode_job( observation_id=obs_db_id, norad_id=norad_id, - backend='meteor_lrpt', + backend="meteor_lrpt", input_path=data_path, ) - emit_event({ - 'type': 'weather_decode_queued', - 'decode_job_id': decode_job_id, - 'norad_id': norad_id, - 'satellite': satellite_name, - 'backend': 'meteor_lrpt', - 'input_path': str(data_path), - }) + emit_event( + { + "type": "weather_decode_queued", + "decode_job_id": decode_job_id, + "norad_id": norad_id, + "satellite": satellite_name, + "backend": "meteor_lrpt", + "input_path": str(data_path), + } + ) t = threading.Thread( target=_run_decode, kwargs={ - 'decode_job_id': decode_job_id, - 'obs_db_id': obs_db_id, - 'norad_id': norad_id, - 'satellite_name': satellite_name, - 'sample_rate': sample_rate, - 'data_path': data_path, - 'emit_event': emit_event, - 'register_output': register_output, + "decode_job_id": decode_job_id, + "obs_db_id": obs_db_id, + "norad_id": norad_id, + "satellite_name": satellite_name, + "sample_rate": sample_rate, + "data_path": data_path, + "emit_event": emit_event, + "register_output": register_output, }, daemon=True, - name=f'gs-meteor-decode-{norad_id}', + name=f"gs-meteor-decode-{norad_id}", ) t.start() @@ -89,84 +91,92 @@ def _run_decode( register_output, ) -> None: latest_status: dict[str, str | int | None] = { - 'message': None, - 'status': None, - 'phase': None, + "message": None, + "status": None, + "phase": None, } sat_key = resolve_meteor_satellite_key(norad_id, satellite_name) if not sat_key: _update_decode_job( decode_job_id, - status='failed', - error_message='No Meteor satellite mapping is available for this observation.', - details={'reason': 'unknown_satellite_mapping'}, + status="failed", + error_message="No Meteor satellite mapping is available for this observation.", + details={"reason": "unknown_satellite_mapping"}, completed=True, ) - emit_event({ - 'type': 'weather_decode_failed', - 'decode_job_id': decode_job_id, - 'norad_id': norad_id, - 'satellite': satellite_name, - 'backend': 'meteor_lrpt', - 'message': 'No Meteor satellite mapping is available for this observation.', - }) + emit_event( + { + "type": "weather_decode_failed", + "decode_job_id": decode_job_id, + "norad_id": norad_id, + "satellite": satellite_name, + "backend": "meteor_lrpt", + "message": "No Meteor satellite mapping is available for this observation.", + } + ) return - output_dir = OUTPUT_ROOT / f'{norad_id}_{int(time.time())}' + output_dir = OUTPUT_ROOT / f"{norad_id}_{int(time.time())}" decoder = WeatherSatDecoder(output_dir=output_dir) if decoder.decoder_available is None: _update_decode_job( decode_job_id, - status='failed', - error_message='SatDump backend is not available for Meteor LRPT decode.', - details={'reason': 'backend_unavailable', 'output_dir': str(output_dir)}, + status="failed", + error_message="SatDump backend is not available for Meteor LRPT decode.", + details={"reason": "backend_unavailable", "output_dir": str(output_dir)}, completed=True, ) - emit_event({ - 'type': 'weather_decode_failed', - 'decode_job_id': decode_job_id, - 'norad_id': norad_id, - 'satellite': satellite_name, - 'backend': 'meteor_lrpt', - 'message': 'SatDump backend is not available for Meteor LRPT decode.', - }) + emit_event( + { + "type": "weather_decode_failed", + "decode_job_id": decode_job_id, + "norad_id": norad_id, + "satellite": satellite_name, + "backend": "meteor_lrpt", + "message": "SatDump backend is not available for Meteor LRPT decode.", + } + ) return def _progress_cb(progress): - latest_status['message'] = progress.message or latest_status.get('message') - latest_status['status'] = progress.status - latest_status['phase'] = progress.capture_phase or latest_status.get('phase') + latest_status["message"] = progress.message or latest_status.get("message") + latest_status["status"] = progress.status + latest_status["phase"] = progress.capture_phase or latest_status.get("phase") progress_event = progress.to_dict() - progress_event.pop('type', None) - emit_event({ - 'type': 'weather_decode_progress', - 'decode_job_id': decode_job_id, - 'norad_id': norad_id, - 'satellite': satellite_name, - 'backend': 'meteor_lrpt', - **progress_event, - }) + progress_event.pop("type", None) + emit_event( + { + "type": "weather_decode_progress", + "decode_job_id": decode_job_id, + "norad_id": norad_id, + "satellite": satellite_name, + "backend": "meteor_lrpt", + **progress_event, + } + ) decoder.set_callback(_progress_cb) _update_decode_job( decode_job_id, - status='decoding', + status="decoding", output_dir=output_dir, details={ - 'sample_rate': sample_rate, - 'input_path': str(data_path), - 'satellite': satellite_name, + "sample_rate": sample_rate, + "input_path": str(data_path), + "satellite": satellite_name, }, started=True, ) - emit_event({ - 'type': 'weather_decode_started', - 'decode_job_id': decode_job_id, - 'norad_id': norad_id, - 'satellite': satellite_name, - 'backend': 'meteor_lrpt', - 'input_path': str(data_path), - }) + emit_event( + { + "type": "weather_decode_started", + "decode_job_id": decode_job_id, + "norad_id": norad_id, + "satellite": satellite_name, + "backend": "meteor_lrpt", + "input_path": str(data_path), + } + ) ok, error = decoder.start_from_file( satellite=sat_key, @@ -180,20 +190,22 @@ def _run_decode( decoder=decoder, latest_status=latest_status, ) - emit_event({ - 'type': 'weather_decode_failed', - 'decode_job_id': decode_job_id, - 'norad_id': norad_id, - 'satellite': satellite_name, - 'backend': 'meteor_lrpt', - 'message': error or details['message'], - 'failure_reason': details['reason'], - 'details': details, - }) + emit_event( + { + "type": "weather_decode_failed", + "decode_job_id": decode_job_id, + "norad_id": norad_id, + "satellite": satellite_name, + "backend": "meteor_lrpt", + "message": error or details["message"], + "failure_reason": details["reason"], + "details": details, + } + ) _update_decode_job( decode_job_id, - status='failed', - error_message=error or details['message'], + status="failed", + error_message=error or details["message"], details=details, completed=True, ) @@ -210,23 +222,25 @@ def _run_decode( sample_rate=sample_rate, decoder=decoder, latest_status=latest_status, - override_reason='timeout', - override_message='Meteor decode timed out.', + override_reason="timeout", + override_message="Meteor decode timed out.", + ) + emit_event( + { + "type": "weather_decode_failed", + "decode_job_id": decode_job_id, + "norad_id": norad_id, + "satellite": satellite_name, + "backend": "meteor_lrpt", + "message": details["message"], + "failure_reason": details["reason"], + "details": details, + } ) - emit_event({ - 'type': 'weather_decode_failed', - 'decode_job_id': decode_job_id, - 'norad_id': norad_id, - 'satellite': satellite_name, - 'backend': 'meteor_lrpt', - 'message': details['message'], - 'failure_reason': details['reason'], - 'details': details, - }) _update_decode_job( decode_job_id, - status='failed', - error_message=details['message'], + status="failed", + error_message=details["message"], details=details, completed=True, ) @@ -240,20 +254,22 @@ def _run_decode( decoder=decoder, latest_status=latest_status, ) - emit_event({ - 'type': 'weather_decode_failed', - 'decode_job_id': decode_job_id, - 'norad_id': norad_id, - 'satellite': satellite_name, - 'backend': 'meteor_lrpt', - 'message': details['message'], - 'failure_reason': details['reason'], - 'details': details, - }) + emit_event( + { + "type": "weather_decode_failed", + "decode_job_id": decode_job_id, + "norad_id": norad_id, + "satellite": satellite_name, + "backend": "meteor_lrpt", + "message": details["message"], + "failure_reason": details["reason"], + "details": details, + } + ) _update_decode_job( decode_job_id, - status='failed', - error_message=details['message'], + status="failed", + error_message=details["message"], details=details, completed=True, ) @@ -262,50 +278,54 @@ def _run_decode( outputs = [] for image in images: metadata = { - 'satellite': image.satellite, - 'mode': image.mode, - 'frequency': image.frequency, - 'product': image.product, - 'timestamp': image.timestamp.isoformat(), - 'size_bytes': image.size_bytes, + "satellite": image.satellite, + "mode": image.mode, + "frequency": image.frequency, + "product": image.product, + "timestamp": image.timestamp.isoformat(), + "size_bytes": image.size_bytes, } output_id = register_output( observation_id=obs_db_id, norad_id=norad_id, - output_type='image', - backend='meteor_lrpt', + output_type="image", + backend="meteor_lrpt", file_path=image.path, preview_path=image.path, metadata=metadata, ) - outputs.append({ - 'id': output_id, - 'file_path': str(image.path), - 'filename': image.filename, - 'product': image.product, - }) + outputs.append( + { + "id": output_id, + "file_path": str(image.path), + "filename": image.filename, + "product": image.product, + } + ) completion_details = { - 'sample_rate': sample_rate, - 'input_path': str(data_path), - 'output_dir': str(output_dir), - 'output_count': len(outputs), + "sample_rate": sample_rate, + "input_path": str(data_path), + "output_dir": str(output_dir), + "output_count": len(outputs), } _update_decode_job( decode_job_id, - status='complete', + status="complete", details=completion_details, completed=True, ) - emit_event({ - 'type': 'weather_decode_complete', - 'decode_job_id': decode_job_id, - 'norad_id': norad_id, - 'satellite': satellite_name, - 'backend': 'meteor_lrpt', - 'outputs': outputs, - }) + emit_event( + { + "type": "weather_decode_complete", + "decode_job_id": decode_job_id, + "norad_id": norad_id, + "satellite": satellite_name, + "backend": "meteor_lrpt", + "outputs": outputs, + } + ) def _build_failure_details( @@ -319,75 +339,75 @@ def _build_failure_details( ) -> dict[str, str | int | None]: file_size = data_path.stat().st_size if data_path.exists() else 0 status = decoder.get_status() - last_error = str(status.get('last_error') or latest_status.get('message') or '').strip() - return_code = status.get('last_returncode') + last_error = str(status.get("last_error") or latest_status.get("message") or "").strip() + return_code = status.get("last_returncode") if override_reason: reason = override_reason elif sample_rate < 200_000: - reason = 'sample_rate_too_low' + reason = "sample_rate_too_low" elif not data_path.exists(): - reason = 'missing_recording' + reason = "missing_recording" elif file_size < 5_000_000: - reason = 'recording_too_small' + reason = "recording_too_small" elif return_code not in (None, 0): - reason = 'satdump_failed' - elif 'samplerate' in last_error.lower() or 'sample rate' in last_error.lower(): - reason = 'invalid_sample_rate' - elif 'not found' in last_error.lower(): - reason = 'input_missing' - elif 'permission' in last_error.lower(): - reason = 'permission_error' + reason = "satdump_failed" + elif "samplerate" in last_error.lower() or "sample rate" in last_error.lower(): + reason = "invalid_sample_rate" + elif "not found" in last_error.lower(): + reason = "input_missing" + elif "permission" in last_error.lower(): + reason = "permission_error" else: - reason = 'no_imagery_produced' + reason = "no_imagery_produced" if override_message: message = override_message - elif reason == 'sample_rate_too_low': - message = f'Sample rate {sample_rate} Hz is too low for Meteor LRPT decoding.' - elif reason == 'missing_recording': - message = 'The recording file was not found when decode started.' - elif reason == 'recording_too_small': + elif reason == "sample_rate_too_low": + message = f"Sample rate {sample_rate} Hz is too low for Meteor LRPT decoding." + elif reason == "missing_recording": + message = "The recording file was not found when decode started." + elif reason == "recording_too_small": message = ( - f'Recording is very small ({_format_bytes(file_size)}); this usually means the pass ' - 'ended early or little usable IQ was captured.' + f"Recording is very small ({_format_bytes(file_size)}); this usually means the pass " + "ended early or little usable IQ was captured." ) - elif reason == 'satdump_failed': - message = last_error or f'SatDump exited with code {return_code}.' - elif reason == 'invalid_sample_rate': - message = last_error or 'SatDump rejected the recording sample rate.' - elif reason == 'input_missing': - message = last_error or 'Input recording was not accessible to the decoder.' - elif reason == 'permission_error': - message = last_error or 'Decoder could not access the recording or output path.' + elif reason == "satdump_failed": + message = last_error or f"SatDump exited with code {return_code}." + elif reason == "invalid_sample_rate": + message = last_error or "SatDump rejected the recording sample rate." + elif reason == "input_missing": + message = last_error or "Input recording was not accessible to the decoder." + elif reason == "permission_error": + message = last_error or "Decoder could not access the recording or output path." else: message = ( - last_error or - 'Decode completed without any image outputs. This usually indicates weak signal, ' - 'incorrect sample rate, or a SatDump pipeline mismatch.' + last_error + or "Decode completed without any image outputs. This usually indicates weak signal, " + "incorrect sample rate, or a SatDump pipeline mismatch." ) return { - 'reason': reason, - 'message': message, - 'sample_rate': sample_rate, - 'file_size_bytes': file_size, - 'file_size_human': _format_bytes(file_size), - 'last_error': last_error or None, - 'last_returncode': return_code, - 'capture_phase': status.get('capture_phase') or latest_status.get('phase'), - 'input_path': str(data_path), + "reason": reason, + "message": message, + "sample_rate": sample_rate, + "file_size_bytes": file_size, + "file_size_human": _format_bytes(file_size), + "last_error": last_error or None, + "last_returncode": return_code, + "capture_phase": status.get("capture_phase") or latest_status.get("phase"), + "input_path": str(data_path), } def _format_bytes(size_bytes: int) -> str: if size_bytes < 1024: - return f'{size_bytes} B' + return f"{size_bytes} B" if size_bytes < 1024 * 1024: - return f'{size_bytes / 1024:.1f} KB' + return f"{size_bytes / 1024:.1f} KB" if size_bytes < 1024 * 1024 * 1024: - return f'{size_bytes / (1024 * 1024):.1f} MB' - return f'{size_bytes / (1024 * 1024 * 1024):.2f} GB' + return f"{size_bytes / (1024 * 1024):.1f} MB" + return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" def _create_decode_job( @@ -402,16 +422,16 @@ def _create_decode_job( with get_db() as conn: cur = conn.execute( - ''' + """ INSERT INTO ground_station_decode_jobs (observation_id, norad_id, backend, status, input_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - ''', + """, ( observation_id, norad_id, backend, - 'queued', + "queued", str(input_path), _utcnow_iso(), _utcnow_iso(), @@ -438,33 +458,33 @@ def _update_decode_job( try: from utils.database import get_db - fields = ['status = ?', 'updated_at = ?'] + fields = ["status = ?", "updated_at = ?"] values: list[object] = [status, _utcnow_iso()] if output_dir is not None: - fields.append('output_dir = ?') + fields.append("output_dir = ?") values.append(str(output_dir)) if error_message is not None: - fields.append('error_message = ?') + fields.append("error_message = ?") values.append(error_message) if details is not None: - fields.append('details_json = ?') + fields.append("details_json = ?") values.append(json.dumps(details)) if started: - fields.append('started_at = ?') + fields.append("started_at = ?") values.append(_utcnow_iso()) if completed: - fields.append('completed_at = ?') + fields.append("completed_at = ?") values.append(_utcnow_iso()) values.append(decode_job_id) with get_db() as conn: conn.execute( - f''' + f""" UPDATE ground_station_decode_jobs SET {", ".join(fields)} WHERE id = ? - ''', + """, values, ) except Exception as e: diff --git a/utils/ground_station/observation_profile.py b/utils/ground_station/observation_profile.py index d32fe9a..82d676d 100644 --- a/utils/ground_station/observation_profile.py +++ b/utils/ground_station/observation_profile.py @@ -14,50 +14,50 @@ from typing import Any from utils.logging import get_logger -logger = get_logger('intercept.ground_station.profile') +logger = get_logger("intercept.ground_station.profile") VALID_TASK_TYPES = { - 'telemetry_ax25', - 'telemetry_gmsk', - 'telemetry_bpsk', - 'weather_meteor_lrpt', - 'record_iq', + "telemetry_ax25", + "telemetry_gmsk", + "telemetry_bpsk", + "weather_meteor_lrpt", + "record_iq", } def legacy_decoder_to_tasks(decoder_type: str | None, record_iq: bool = False) -> list[str]: - decoder = (decoder_type or 'fm').lower() + decoder = (decoder_type or "fm").lower() tasks: list[str] = [] - if decoder in ('fm', 'afsk'): - tasks.append('telemetry_ax25') - elif decoder == 'gmsk': - tasks.append('telemetry_gmsk') - elif decoder == 'bpsk': - tasks.append('telemetry_bpsk') - elif decoder == 'iq_only': - tasks.append('record_iq') + if decoder in ("fm", "afsk"): + tasks.append("telemetry_ax25") + elif decoder == "gmsk": + tasks.append("telemetry_gmsk") + elif decoder == "bpsk": + tasks.append("telemetry_bpsk") + elif decoder == "iq_only": + tasks.append("record_iq") - if record_iq and 'record_iq' not in tasks: - tasks.append('record_iq') + if record_iq and "record_iq" not in tasks: + tasks.append("record_iq") return tasks def tasks_to_legacy_decoder(tasks: list[str]) -> str: normalized = normalize_tasks(tasks) - if 'telemetry_bpsk' in normalized: - return 'bpsk' - if 'telemetry_gmsk' in normalized: - return 'gmsk' - if 'telemetry_ax25' in normalized: - return 'afsk' - return 'iq_only' + if "telemetry_bpsk" in normalized: + return "bpsk" + if "telemetry_gmsk" in normalized: + return "gmsk" + if "telemetry_ax25" in normalized: + return "afsk" + return "iq_only" def normalize_tasks(tasks: list[str] | None) -> list[str]: result: list[str] = [] for task in tasks or []: - value = str(task or '').strip().lower() + value = str(task or "").strip().lower() if value and value in VALID_TASK_TYPES and value not in result: result.append(value) return result @@ -68,9 +68,9 @@ class ObservationProfile: """Per-satellite capture configuration.""" norad_id: int - name: str # Human-readable label + name: str # Human-readable label frequency_mhz: float - decoder_type: str # 'fm', 'afsk', 'bpsk', 'gmsk', 'iq_only' + decoder_type: str # 'fm', 'afsk', 'bpsk', 'gmsk', 'iq_only' gain: float = 40.0 bandwidth_hz: int = 200_000 min_elevation: float = 10.0 @@ -79,62 +79,60 @@ class ObservationProfile: iq_sample_rate: int = 2_400_000 tasks: list[str] = field(default_factory=list) id: int | None = None - created_at: str = field( - default_factory=lambda: datetime.now(timezone.utc).isoformat() - ) + created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) def to_dict(self) -> dict[str, Any]: normalized_tasks = self.get_tasks() return { - 'id': self.id, - 'norad_id': self.norad_id, - 'name': self.name, - 'frequency_mhz': self.frequency_mhz, - 'decoder_type': self.decoder_type, - 'legacy_decoder_type': self.decoder_type, - 'gain': self.gain, - 'bandwidth_hz': self.bandwidth_hz, - 'min_elevation': self.min_elevation, - 'enabled': self.enabled, - 'record_iq': self.record_iq, - 'iq_sample_rate': self.iq_sample_rate, - 'tasks': normalized_tasks, - 'created_at': self.created_at, + "id": self.id, + "norad_id": self.norad_id, + "name": self.name, + "frequency_mhz": self.frequency_mhz, + "decoder_type": self.decoder_type, + "legacy_decoder_type": self.decoder_type, + "gain": self.gain, + "bandwidth_hz": self.bandwidth_hz, + "min_elevation": self.min_elevation, + "enabled": self.enabled, + "record_iq": self.record_iq, + "iq_sample_rate": self.iq_sample_rate, + "tasks": normalized_tasks, + "created_at": self.created_at, } def get_tasks(self) -> list[str]: tasks = normalize_tasks(self.tasks) if not tasks: tasks = legacy_decoder_to_tasks(self.decoder_type, self.record_iq) - if self.record_iq and 'record_iq' not in tasks: - tasks.append('record_iq') - if 'weather_meteor_lrpt' in tasks and 'record_iq' not in tasks: - tasks.append('record_iq') + if self.record_iq and "record_iq" not in tasks: + tasks.append("record_iq") + if "weather_meteor_lrpt" in tasks and "record_iq" not in tasks: + tasks.append("record_iq") return tasks @classmethod def from_row(cls, row) -> ObservationProfile: tasks = [] - raw_tasks = row.get('tasks_json', None) + raw_tasks = row.get("tasks_json", None) if raw_tasks: try: tasks = normalize_tasks(json.loads(raw_tasks)) except (TypeError, ValueError, json.JSONDecodeError): tasks = [] return cls( - id=row['id'], - norad_id=row['norad_id'], - name=row['name'], - frequency_mhz=row['frequency_mhz'], - decoder_type=row['decoder_type'], - gain=row['gain'], - bandwidth_hz=row['bandwidth_hz'], - min_elevation=row['min_elevation'], - enabled=bool(row['enabled']), - record_iq=bool(row['record_iq']), - iq_sample_rate=row['iq_sample_rate'], + id=row["id"], + norad_id=row["norad_id"], + name=row["name"], + frequency_mhz=row["frequency_mhz"], + decoder_type=row["decoder_type"], + gain=row["gain"], + bandwidth_hz=row["bandwidth_hz"], + min_elevation=row["min_elevation"], + enabled=bool(row["enabled"]), + record_iq=bool(row["record_iq"]), + iq_sample_rate=row["iq_sample_rate"], tasks=tasks, - created_at=row['created_at'], + created_at=row["created_at"], ) @@ -146,32 +144,32 @@ class ObservationProfile: def list_profiles() -> list[ObservationProfile]: """Return all observation profiles from the database.""" from utils.database import get_db + with get_db() as conn: - rows = conn.execute( - 'SELECT * FROM observation_profiles ORDER BY created_at DESC' - ).fetchall() + rows = conn.execute("SELECT * FROM observation_profiles ORDER BY created_at DESC").fetchall() return [ObservationProfile.from_row(r) for r in rows] def get_profile(norad_id: int) -> ObservationProfile | None: """Return the profile for a NORAD ID, or None if not found.""" from utils.database import get_db + with get_db() as conn: - row = conn.execute( - 'SELECT * FROM observation_profiles WHERE norad_id = ?', (norad_id,) - ).fetchone() + row = conn.execute("SELECT * FROM observation_profiles WHERE norad_id = ?", (norad_id,)).fetchone() return ObservationProfile.from_row(row) if row else None def save_profile(profile: ObservationProfile) -> ObservationProfile: """Insert or replace an observation profile. Returns the saved profile.""" from utils.database import get_db + normalized_tasks = profile.get_tasks() profile.tasks = normalized_tasks - profile.record_iq = 'record_iq' in normalized_tasks + profile.record_iq = "record_iq" in normalized_tasks profile.decoder_type = tasks_to_legacy_decoder(normalized_tasks) with get_db() as conn: - conn.execute(''' + conn.execute( + """ INSERT INTO observation_profiles (norad_id, name, frequency_mhz, decoder_type, tasks_json, gain, bandwidth_hz, min_elevation, enabled, record_iq, @@ -188,28 +186,29 @@ def save_profile(profile: ObservationProfile) -> ObservationProfile: enabled=excluded.enabled, record_iq=excluded.record_iq, iq_sample_rate=excluded.iq_sample_rate - ''', ( - profile.norad_id, - profile.name, - profile.frequency_mhz, - profile.decoder_type, - json.dumps(normalized_tasks), - profile.gain, - profile.bandwidth_hz, - profile.min_elevation, - int(profile.enabled), - int(profile.record_iq), - profile.iq_sample_rate, - profile.created_at, - )) + """, + ( + profile.norad_id, + profile.name, + profile.frequency_mhz, + profile.decoder_type, + json.dumps(normalized_tasks), + profile.gain, + profile.bandwidth_hz, + profile.min_elevation, + int(profile.enabled), + int(profile.record_iq), + profile.iq_sample_rate, + profile.created_at, + ), + ) return get_profile(profile.norad_id) or profile def delete_profile(norad_id: int) -> bool: """Delete a profile by NORAD ID. Returns True if a row was deleted.""" from utils.database import get_db + with get_db() as conn: - cur = conn.execute( - 'DELETE FROM observation_profiles WHERE norad_id = ?', (norad_id,) - ) + cur = conn.execute("DELETE FROM observation_profiles WHERE norad_id = ?", (norad_id,)) return cur.rowcount > 0 diff --git a/utils/kiwisdr.py b/utils/kiwisdr.py index 95954c3..6654807 100644 --- a/utils/kiwisdr.py +++ b/utils/kiwisdr.py @@ -13,6 +13,7 @@ from typing import Callable try: import websocket # websocket-client library + WEBSOCKET_CLIENT_AVAILABLE = True except ImportError: WEBSOCKET_CLIENT_AVAILABLE = False @@ -21,7 +22,7 @@ import contextlib from utils.logging import get_logger -logger = get_logger('intercept.kiwisdr') +logger = get_logger("intercept.kiwisdr") # Protocol constants KIWI_KEEPALIVE_INTERVAL = 5.0 @@ -29,14 +30,14 @@ KIWI_SAMPLE_RATE = 12000 # 12 kHz mono KIWI_SND_HEADER_SIZE = 10 # "SND"(3) + flags(1) + seq(4) + smeter(2) KIWI_DEFAULT_PORT = 8073 -VALID_MODES = ('am', 'usb', 'lsb', 'cw') +VALID_MODES = ("am", "usb", "lsb", "cw") # Default bandpass filters per mode (Hz) MODE_FILTERS = { - 'am': (-4500, 4500), - 'usb': (300, 3000), - 'lsb': (-3000, -300), - 'cw': (300, 800), + "am": (-4500, 4500), + "usb": (300, 3000), + "lsb": (-3000, -300), + "cw": (300, 800), } @@ -46,21 +47,21 @@ def parse_host_port(url: str) -> tuple[str, int]: Returns (host, port) tuple. Defaults to port 8073 if not specified. """ if not url: - return ('', KIWI_DEFAULT_PORT) + return ("", KIWI_DEFAULT_PORT) # Strip protocol cleaned = url - for prefix in ('http://', 'https://', 'ws://', 'wss://'): + for prefix in ("http://", "https://", "ws://", "wss://"): if cleaned.lower().startswith(prefix): - cleaned = cleaned[len(prefix):] + cleaned = cleaned[len(prefix) :] break # Strip path - cleaned = cleaned.split('/')[0] + cleaned = cleaned.split("/")[0] # Split host:port - if ':' in cleaned: - parts = cleaned.rsplit(':', 1) + if ":" in cleaned: + parts = cleaned.rsplit(":", 1) host = parts[0] try: port = int(parts[1]) @@ -83,7 +84,7 @@ class KiwiSDRClient: on_audio: Callable[[bytes, int], None] | None = None, on_error: Callable[[str], None] | None = None, on_disconnect: Callable[[], None] | None = None, - password: str = '', + password: str = "", ): self.host = host self.port = port @@ -100,14 +101,14 @@ class KiwiSDRClient: self._send_lock = threading.Lock() self.frequency_khz: float = 0 - self.mode: str = 'am' + self.mode: str = "am" self.last_smeter: int = 0 @property def connected(self) -> bool: return self._connected - def connect(self, frequency_khz: float, mode: str = 'am') -> bool: + def connect(self, frequency_khz: float, mode: str = "am") -> bool: """Connect to KiwiSDR and start receiving audio.""" if not WEBSOCKET_CLIENT_AVAILABLE: logger.error("websocket-client not installed") @@ -117,7 +118,7 @@ class KiwiSDRClient: self.disconnect() self.frequency_khz = frequency_khz - self.mode = mode if mode in VALID_MODES else 'am' + self.mode = mode if mode in VALID_MODES else "am" self._stopping = False ws_url = self._build_ws_url() @@ -129,33 +130,29 @@ class KiwiSDRClient: self._ws.connect(ws_url) # Auth - self._send('SET auth t=kiwi p=' + self.password) + self._send("SET auth t=kiwi p=" + self.password) time.sleep(0.2) # Request uncompressed PCM - self._send('SET compression=0') + self._send("SET compression=0") # Set AGC - self._send('SET agc=1 hang=0 thresh=-100 slope=6 decay=1000 manGain=50') + self._send("SET agc=1 hang=0 thresh=-100 slope=6 decay=1000 manGain=50") # Tune to frequency self._send_tune(frequency_khz, self.mode) # Request audio start - self._send('SET AR OK in=12000 out=44100') + self._send("SET AR OK in=12000 out=44100") self._connected = True # Start receive thread - self._receive_thread = threading.Thread( - target=self._receive_loop, daemon=True, name='kiwi-rx' - ) + self._receive_thread = threading.Thread(target=self._receive_loop, daemon=True, name="kiwi-rx") self._receive_thread.start() # Start keepalive thread - self._keepalive_thread = threading.Thread( - target=self._keepalive_loop, daemon=True, name='kiwi-ka' - ) + self._keepalive_thread = threading.Thread(target=self._keepalive_loop, daemon=True, name="kiwi-ka") self._keepalive_thread.start() logger.info(f"Connected to KiwiSDR {self.host}:{self.port} @ {frequency_khz} kHz {self.mode}") @@ -166,7 +163,7 @@ class KiwiSDRClient: self._cleanup() return False - def tune(self, frequency_khz: float, mode: str = 'am') -> bool: + def tune(self, frequency_khz: float, mode: str = "am") -> bool: """Retune without disconnecting.""" if not self._connected or not self._ws: return False @@ -192,7 +189,7 @@ class KiwiSDRClient: def _build_ws_url(self) -> str: ts = int(time.time() * 1000) - return f'ws://{self.host}:{self.port}/{ts}/SND' + return f"ws://{self.host}:{self.port}/{ts}/SND" def _send(self, msg: str) -> None: with self._send_lock: @@ -200,8 +197,8 @@ class KiwiSDRClient: self._ws.send(msg) def _send_tune(self, freq_khz: float, mode: str) -> None: - low_cut, high_cut = MODE_FILTERS.get(mode, MODE_FILTERS['am']) - self._send(f'SET mod={mode} low_cut={low_cut} high_cut={high_cut} freq={freq_khz}') + low_cut, high_cut = MODE_FILTERS.get(mode, MODE_FILTERS["am"]) + self._send(f"SET mod={mode} low_cut={low_cut} high_cut={high_cut} freq={freq_khz}") def _receive_loop(self) -> None: """Background thread: read frames from KiwiSDR WebSocket.""" @@ -241,14 +238,14 @@ class KiwiSDRClient: return # Check header magic - if data[:3] != b'SND': + if data[:3] != b"SND": return # flags = data[3] # seq = struct.unpack('>I', data[4:8])[0] # S-meter: big-endian int16 at offset 8 - smeter_raw = struct.unpack('>h', data[8:10])[0] + smeter_raw = struct.unpack(">h", data[8:10])[0] self.last_smeter = smeter_raw # PCM audio data starts at offset 10 @@ -264,7 +261,7 @@ class KiwiSDRClient: time.sleep(KIWI_KEEPALIVE_INTERVAL) if self._connected and not self._stopping: try: - self._send('SET keepalive') + self._send("SET keepalive") except Exception: break diff --git a/utils/logging.py b/utils/logging.py index 7b217b9..48d0bbd 100644 --- a/utils/logging.py +++ b/utils/logging.py @@ -21,10 +21,10 @@ def get_logger(name: str) -> logging.Logger: # Pre-configured loggers for each module -app_logger = get_logger('intercept') -pager_logger = get_logger('intercept.pager') -sensor_logger = get_logger('intercept.sensor') -wifi_logger = get_logger('intercept.wifi') -bluetooth_logger = get_logger('intercept.bluetooth') -adsb_logger = get_logger('intercept.adsb') -satellite_logger = get_logger('intercept.satellite') +app_logger = get_logger("intercept") +pager_logger = get_logger("intercept.pager") +sensor_logger = get_logger("intercept.sensor") +wifi_logger = get_logger("intercept.wifi") +bluetooth_logger = get_logger("intercept.bluetooth") +adsb_logger = get_logger("intercept.adsb") +satellite_logger = get_logger("intercept.satellite") diff --git a/utils/meshcore_client.py b/utils/meshcore_client.py index 67da561..c4da10f 100644 --- a/utils/meshcore_client.py +++ b/utils/meshcore_client.py @@ -107,8 +107,9 @@ class AsyncWorker: else: # Auto-detect: prefer ttyUSB0, then first available port candidates = list_serial_ports() - port = next((p for p in candidates if "ttyUSB0" in p), None) or \ - (candidates[0] if candidates else "/dev/ttyUSB0") + port = next((p for p in candidates if "ttyUSB0" in p), None) or ( + candidates[0] if candidates else "/dev/ttyUSB0" + ) self._mc = await MeshCore.create_serial(port=port, baudrate=cfg.baud, debug=False) transport, device = "serial", port elif isinstance(cfg, TCPConfig): diff --git a/utils/meshtastic.py b/utils/meshtastic.py index a58f586..f2a9ccf 100644 --- a/utils/meshtastic.py +++ b/utils/meshtastic.py @@ -26,7 +26,7 @@ from typing import Callable from utils.logging import get_logger -logger = get_logger('intercept.meshtastic') +logger = get_logger("intercept.meshtastic") # Meshtastic SDK import (optional dependency) try: @@ -35,6 +35,7 @@ try: import meshtastic.tcp_interface from meshtastic import BROADCAST_ADDR from pubsub import pub + HAS_MESHTASTIC = True except ImportError: HAS_MESHTASTIC = False @@ -45,6 +46,7 @@ except ImportError: @dataclass class MeshtasticMessage: """Decoded Meshtastic message.""" + from_id: str to_id: str message: str | None @@ -60,25 +62,26 @@ class MeshtasticMessage: def to_dict(self) -> dict: return { - 'type': 'meshtastic', - 'from': self.from_id, - 'from_name': self.from_name, - 'to': self.to_id, - 'to_name': self.to_name, - 'message': self.message, - 'text': self.message, # Alias for frontend compatibility - 'portnum': self.portnum, - 'channel': self.channel, - 'rssi': self.rssi, - 'snr': self.snr, - 'hop_limit': self.hop_limit, - 'timestamp': self.timestamp.timestamp(), # Unix seconds for frontend + "type": "meshtastic", + "from": self.from_id, + "from_name": self.from_name, + "to": self.to_id, + "to_name": self.to_name, + "message": self.message, + "text": self.message, # Alias for frontend compatibility + "portnum": self.portnum, + "channel": self.channel, + "rssi": self.rssi, + "snr": self.snr, + "hop_limit": self.hop_limit, + "timestamp": self.timestamp.timestamp(), # Unix seconds for frontend } @dataclass class ChannelConfig: """Meshtastic channel configuration.""" + index: int name: str psk: bytes @@ -86,35 +89,36 @@ class ChannelConfig: def to_dict(self) -> dict: """Convert to dict for API response (hides raw PSK).""" - role_names = ['DISABLED', 'PRIMARY', 'SECONDARY'] + role_names = ["DISABLED", "PRIMARY", "SECONDARY"] # Default key is 1 byte (0x01) or the well-known AQ== base64 - is_default = self.psk in (b'\x01', b'') + is_default = self.psk in (b"\x01", b"") return { - 'index': self.index, - 'name': self.name, - 'role': role_names[self.role] if self.role < len(role_names) else 'UNKNOWN', - 'encrypted': len(self.psk) > 1, - 'key_type': self._get_key_type(), - 'is_default_key': is_default, + "index": self.index, + "name": self.name, + "role": role_names[self.role] if self.role < len(role_names) else "UNKNOWN", + "encrypted": len(self.psk) > 1, + "key_type": self._get_key_type(), + "is_default_key": is_default, } def _get_key_type(self) -> str: """Determine encryption type from key length.""" if len(self.psk) == 0: - return 'none' + return "none" elif len(self.psk) == 1: - return 'default' + return "default" elif len(self.psk) == 16: - return 'AES-128' + return "AES-128" elif len(self.psk) == 32: - return 'AES-256' + return "AES-256" else: - return 'unknown' + return "unknown" @dataclass class MeshNode: """Tracked Meshtastic node with position and metadata.""" + num: int user_id: str long_name: str @@ -137,32 +141,33 @@ class MeshNode: def to_dict(self) -> dict: return { - 'num': self.num, - 'id': self.user_id or f"!{self.num:08x}", - 'long_name': self.long_name, - 'short_name': self.short_name, - 'hw_model': self.hw_model, - 'latitude': self.latitude, - 'longitude': self.longitude, - 'altitude': self.altitude, - 'battery_level': self.battery_level, - 'snr': self.snr, - 'last_heard': self.last_heard.isoformat() if self.last_heard else None, - 'has_position': self.latitude is not None and self.longitude is not None, + "num": self.num, + "id": self.user_id or f"!{self.num:08x}", + "long_name": self.long_name, + "short_name": self.short_name, + "hw_model": self.hw_model, + "latitude": self.latitude, + "longitude": self.longitude, + "altitude": self.altitude, + "battery_level": self.battery_level, + "snr": self.snr, + "last_heard": self.last_heard.isoformat() if self.last_heard else None, + "has_position": self.latitude is not None and self.longitude is not None, # Device telemetry - 'voltage': self.voltage, - 'channel_utilization': self.channel_utilization, - 'air_util_tx': self.air_util_tx, + "voltage": self.voltage, + "channel_utilization": self.channel_utilization, + "air_util_tx": self.air_util_tx, # Environment telemetry - 'temperature': self.temperature, - 'humidity': self.humidity, - 'barometric_pressure': self.barometric_pressure, + "temperature": self.temperature, + "humidity": self.humidity, + "barometric_pressure": self.barometric_pressure, } @dataclass class NodeInfo: """Meshtastic node information.""" + num: int user_id: str long_name: str @@ -174,45 +179,49 @@ class NodeInfo: def to_dict(self) -> dict: return { - 'num': self.num, - 'user_id': self.user_id, - 'long_name': self.long_name, - 'short_name': self.short_name, - 'hw_model': self.hw_model, - 'position': { - 'latitude': self.latitude, - 'longitude': self.longitude, - 'altitude': self.altitude, - } if self.latitude is not None else None, + "num": self.num, + "user_id": self.user_id, + "long_name": self.long_name, + "short_name": self.short_name, + "hw_model": self.hw_model, + "position": { + "latitude": self.latitude, + "longitude": self.longitude, + "altitude": self.altitude, + } + if self.latitude is not None + else None, } @dataclass class TracerouteResult: """Result of a traceroute to a mesh node.""" + destination_id: str - route: list[str] # Node IDs in forward path - route_back: list[str] # Return path - snr_towards: list[float] # SNR per hop (forward) - snr_back: list[float] # SNR per hop (return) + route: list[str] # Node IDs in forward path + route_back: list[str] # Return path + snr_towards: list[float] # SNR per hop (forward) + snr_back: list[float] # SNR per hop (return) timestamp: datetime success: bool def to_dict(self) -> dict: return { - 'destination_id': self.destination_id, - 'route': self.route, - 'route_back': self.route_back, - 'snr_towards': self.snr_towards, - 'snr_back': self.snr_back, - 'timestamp': self.timestamp.isoformat(), - 'success': self.success, + "destination_id": self.destination_id, + "route": self.route, + "route_back": self.route_back, + "snr_towards": self.snr_towards, + "snr_back": self.snr_back, + "timestamp": self.timestamp.isoformat(), + "success": self.success, } @dataclass class TelemetryPoint: """Single telemetry data point for graphing.""" + timestamp: datetime battery_level: int | None = None voltage: float | None = None @@ -224,41 +233,43 @@ class TelemetryPoint: def to_dict(self) -> dict: return { - 'timestamp': self.timestamp.isoformat(), - 'battery_level': self.battery_level, - 'voltage': self.voltage, - 'temperature': self.temperature, - 'humidity': self.humidity, - 'pressure': self.pressure, - 'channel_utilization': self.channel_utilization, - 'air_util_tx': self.air_util_tx, + "timestamp": self.timestamp.isoformat(), + "battery_level": self.battery_level, + "voltage": self.voltage, + "temperature": self.temperature, + "humidity": self.humidity, + "pressure": self.pressure, + "channel_utilization": self.channel_utilization, + "air_util_tx": self.air_util_tx, } @dataclass class PendingMessage: """Message waiting for ACK/NAK.""" + packet_id: int destination: int text: str channel: int timestamp: datetime - status: str = 'pending' # pending, acked, failed + status: str = "pending" # pending, acked, failed def to_dict(self) -> dict: return { - 'packet_id': self.packet_id, - 'destination': self.destination, - 'text': self.text, - 'channel': self.channel, - 'timestamp': self.timestamp.isoformat(), - 'status': self.status, + "packet_id": self.packet_id, + "destination": self.destination, + "text": self.text, + "channel": self.channel, + "timestamp": self.timestamp.isoformat(), + "status": self.status, } @dataclass class NeighborInfo: """Neighbor information from NEIGHBOR_INFO_APP.""" + neighbor_num: int neighbor_id: str snr: float @@ -266,10 +277,10 @@ class NeighborInfo: def to_dict(self) -> dict: return { - 'neighbor_num': self.neighbor_num, - 'neighbor_id': self.neighbor_id, - 'snr': self.snr, - 'timestamp': self.timestamp.isoformat(), + "neighbor_num": self.neighbor_num, + "neighbor_id": self.neighbor_id, + "snr": self.snr, + "timestamp": self.timestamp.isoformat(), } @@ -336,31 +347,30 @@ class MeshtasticClient: for node_id in (from_node, to_node): if node_id not in self._topology: self._topology[node_id] = { - 'neighbors': set(), - 'hop_count': hops, - 'msg_count': 0, - 'last_seen': now, + "neighbors": set(), + "hop_count": hops, + "msg_count": 0, + "last_seen": now, } entry = self._topology[node_id] - entry['msg_count'] += 1 - entry['last_seen'] = now - self._topology[from_node]['neighbors'].add(to_node) - self._topology[to_node]['neighbors'].add(from_node) + entry["msg_count"] += 1 + entry["last_seen"] = now + self._topology[from_node]["neighbors"].add(to_node) + self._topology[to_node]["neighbors"].add(from_node) def get_topology(self) -> dict: """Return topology dict with serializable sets.""" result = {} for node_id, data in self._topology.items(): result[node_id] = { - 'neighbors': list(data.get('neighbors', set())), - 'hop_count': data.get('hop_count'), - 'msg_count': data.get('msg_count', 0), - 'last_seen': data.get('last_seen'), + "neighbors": list(data.get("neighbors", set())), + "hop_count": data.get("hop_count"), + "msg_count": data.get("msg_count", 0), + "last_seen": data.get("last_seen"), } return result - def connect(self, device: str | None = None, connection_type: str = 'serial', - hostname: str | None = None) -> bool: + def connect(self, device: str | None = None, connection_type: str = "serial", hostname: str | None = None) -> bool: """ Connect to a Meshtastic device. @@ -392,14 +402,14 @@ class MeshtasticClient: pub.subscribe(self._on_connection, "meshtastic.connection.established") pub.subscribe(self._on_disconnect, "meshtastic.connection.lost") - if connection_type == 'tcp': + if connection_type == "tcp": if not hostname: self._error = "Hostname is required for TCP connections" self._cleanup_subscriptions() return False new_interface = meshtastic.tcp_interface.TCPInterface(hostname=hostname) new_device_path = hostname - new_connection_type = 'tcp' + new_connection_type = "tcp" logger.info(f"Connected to Meshtastic device via TCP: {hostname}") else: if device: @@ -408,7 +418,7 @@ class MeshtasticClient: else: new_interface = meshtastic.serial_interface.SerialInterface() new_device_path = "auto" - new_connection_type = 'serial' + new_connection_type = "serial" logger.info(f"Connected to Meshtastic device via serial: {new_device_path}") except Exception as e: self._error = str(e) @@ -482,13 +492,14 @@ class MeshtasticClient: try: # Try to set the device's time using the SDK import time + current_time = int(time.time()) - if hasattr(self._interface, 'localNode') and self._interface.localNode: + if hasattr(self._interface, "localNode") and self._interface.localNode: local_node = self._interface.localNode - if hasattr(local_node, 'setTime'): + if hasattr(local_node, "setTime"): local_node.setTime(current_time) logger.info(f"Set device time to {current_time}") - elif hasattr(self._interface, 'sendAdmin'): + elif hasattr(self._interface, "sendAdmin"): # Alternative: send admin message with time logger.debug("setTime not available, device time not synced") else: @@ -499,10 +510,10 @@ class MeshtasticClient: def _on_receive(self, packet: dict, interface) -> None: """Handle received packet from Meshtastic device.""" try: - decoded = packet.get('decoded', {}) - from_num = packet.get('from', 0) - to_num = packet.get('to', 0) - portnum = decoded.get('portnum', 'UNKNOWN') + decoded = packet.get("decoded", {}) + from_num = packet.get("from", 0) + to_num = packet.get("to", 0) + portnum = decoded.get("portnum", "UNKNOWN") # Track node from packet (always, even for filtered messages) self._track_node_from_packet(packet, decoded, portnum) @@ -512,19 +523,19 @@ class MeshtasticClient: self.record_message_route( self._format_node_id(from_num), self._format_node_id(to_num), - packet.get('hopLimit'), + packet.get("hopLimit"), ) # Parse traceroute responses - if portnum == 'TRACEROUTE_APP': + if portnum == "TRACEROUTE_APP": self._handle_traceroute_response(packet, decoded) # Handle ACK/NAK for message delivery tracking - if portnum == 'ROUTING_APP': + if portnum == "ROUTING_APP": self._handle_routing_packet(packet, decoded) # Handle neighbor info for mesh topology - if portnum == 'NEIGHBOR_INFO_APP': + if portnum == "NEIGHBOR_INFO_APP": self._handle_neighbor_info(packet, decoded) # Skip callback if none set @@ -533,19 +544,19 @@ class MeshtasticClient: # Filter out internal protocol messages that aren't useful to users ignored_portnums = { - 'ROUTING_APP', # Mesh routing/acknowledgments - handled above - 'ADMIN_APP', # Admin commands - 'REPLY_APP', # Internal replies - 'STORE_FORWARD_APP', # Store and forward protocol - 'RANGE_TEST_APP', # Range testing - 'PAXCOUNTER_APP', # People counter - 'REMOTE_HARDWARE_APP', # Remote hardware control - 'SIMULATOR_APP', # Simulator - 'MAP_REPORT_APP', # Map reporting - 'TELEMETRY_APP', # Device telemetry (battery, etc.) - too noisy - 'POSITION_APP', # Position updates - used for map, not messages - 'NODEINFO_APP', # Node info - used for tracking, not messages - 'NEIGHBOR_INFO_APP', # Neighbor info - handled above + "ROUTING_APP", # Mesh routing/acknowledgments - handled above + "ADMIN_APP", # Admin commands + "REPLY_APP", # Internal replies + "STORE_FORWARD_APP", # Store and forward protocol + "RANGE_TEST_APP", # Range testing + "PAXCOUNTER_APP", # People counter + "REMOTE_HARDWARE_APP", # Remote hardware control + "SIMULATOR_APP", # Simulator + "MAP_REPORT_APP", # Map reporting + "TELEMETRY_APP", # Device telemetry (battery, etc.) - too noisy + "POSITION_APP", # Position updates - used for map, not messages + "NODEINFO_APP", # Node info - used for tracking, not messages + "NEIGHBOR_INFO_APP", # Neighbor info - handled above } if portnum in ignored_portnums: logger.debug(f"Ignoring {portnum} message from {from_num}") @@ -553,12 +564,12 @@ class MeshtasticClient: # Extract text message if present message = None - if portnum == 'TEXT_MESSAGE_APP': - message = decoded.get('text') - elif portnum in ('WAYPOINT_APP', 'TRACEROUTE_APP'): + if portnum == "TEXT_MESSAGE_APP": + message = decoded.get("text") + elif portnum in ("WAYPOINT_APP", "TRACEROUTE_APP"): # Show these as informational messages message = f"[{portnum}]" - elif 'payload' in decoded: + elif "payload" in decoded: # For other message types, include payload info message = f"[{portnum}]" @@ -571,10 +582,10 @@ class MeshtasticClient: to_id=self._format_node_id(to_num), message=message, portnum=portnum, - channel=packet.get('channel', 0), - rssi=packet.get('rxRssi'), - snr=packet.get('rxSnr'), - hop_limit=packet.get('hopLimit'), + channel=packet.get("channel", 0), + rssi=packet.get("rxRssi"), + snr=packet.get("rxSnr"), + hop_limit=packet.get("hopLimit"), timestamp=datetime.now(timezone.utc), from_name=from_name, to_name=to_name, @@ -589,7 +600,7 @@ class MeshtasticClient: def _track_node_from_packet(self, packet: dict, decoded: dict, portnum: str) -> None: """Update node tracking from received packet.""" - from_num = packet.get('from', 0) + from_num = packet.get("from", 0) if from_num == 0 or from_num == 0xFFFFFFFF: return @@ -600,31 +611,31 @@ class MeshtasticClient: self._nodes[from_num] = MeshNode( num=from_num, user_id=f"!{from_num:08x}", - long_name='', - short_name='', - hw_model='UNKNOWN', + long_name="", + short_name="", + hw_model="UNKNOWN", ) node = self._nodes[from_num] node.last_heard = now - node.snr = packet.get('rxSnr', node.snr) + node.snr = packet.get("rxSnr", node.snr) # Parse NODEINFO_APP for user details - if portnum == 'NODEINFO_APP': - user = decoded.get('user', {}) + if portnum == "NODEINFO_APP": + user = decoded.get("user", {}) if user: - node.long_name = user.get('longName', node.long_name) - node.short_name = user.get('shortName', node.short_name) - node.hw_model = user.get('hwModel', node.hw_model) - if user.get('id'): - node.user_id = user.get('id') + node.long_name = user.get("longName", node.long_name) + node.short_name = user.get("shortName", node.short_name) + node.hw_model = user.get("hwModel", node.hw_model) + if user.get("id"): + node.user_id = user.get("id") # Parse POSITION_APP for location - elif portnum == 'POSITION_APP': - position = decoded.get('position', {}) + elif portnum == "POSITION_APP": + position = decoded.get("position", {}) if position: - lat = position.get('latitude') or position.get('latitudeI') - lon = position.get('longitude') or position.get('longitudeI') + lat = position.get("latitude") or position.get("latitudeI") + lon = position.get("longitude") or position.get("longitudeI") # Handle integer format (latitudeI/longitudeI are in 1e-7 degrees) if isinstance(lat, int) and abs(lat) > 1000: @@ -635,38 +646,38 @@ class MeshtasticClient: if lat is not None and lon is not None: node.latitude = lat node.longitude = lon - node.altitude = position.get('altitude', node.altitude) + node.altitude = position.get("altitude", node.altitude) # Parse TELEMETRY_APP for battery and other metrics - elif portnum == 'TELEMETRY_APP': - telemetry = decoded.get('telemetry', {}) + elif portnum == "TELEMETRY_APP": + telemetry = decoded.get("telemetry", {}) # Device metrics - device_metrics = telemetry.get('deviceMetrics', {}) + device_metrics = telemetry.get("deviceMetrics", {}) if device_metrics: - battery = device_metrics.get('batteryLevel') + battery = device_metrics.get("batteryLevel") if battery is not None: node.battery_level = battery - voltage = device_metrics.get('voltage') + voltage = device_metrics.get("voltage") if voltage is not None: node.voltage = voltage - channel_util = device_metrics.get('channelUtilization') + channel_util = device_metrics.get("channelUtilization") if channel_util is not None: node.channel_utilization = channel_util - air_util = device_metrics.get('airUtilTx') + air_util = device_metrics.get("airUtilTx") if air_util is not None: node.air_util_tx = air_util # Environment metrics - env_metrics = telemetry.get('environmentMetrics', {}) + env_metrics = telemetry.get("environmentMetrics", {}) if env_metrics: - temp = env_metrics.get('temperature') + temp = env_metrics.get("temperature") if temp is not None: node.temperature = temp - humidity = env_metrics.get('relativeHumidity') + humidity = env_metrics.get("relativeHumidity") if humidity is not None: node.humidity = humidity - pressure = env_metrics.get('barometricPressure') + pressure = env_metrics.get("barometricPressure") if pressure is not None: node.barometric_pressure = pressure @@ -681,13 +692,13 @@ class MeshtasticClient: point = TelemetryPoint( timestamp=datetime.now(timezone.utc), - battery_level=device_metrics.get('batteryLevel'), - voltage=device_metrics.get('voltage'), - temperature=env_metrics.get('temperature'), - humidity=env_metrics.get('relativeHumidity'), - pressure=env_metrics.get('barometricPressure'), - channel_utilization=device_metrics.get('channelUtilization'), - air_util_tx=device_metrics.get('airUtilTx'), + battery_level=device_metrics.get("batteryLevel"), + voltage=device_metrics.get("voltage"), + temperature=env_metrics.get("temperature"), + humidity=env_metrics.get("relativeHumidity"), + pressure=env_metrics.get("barometricPressure"), + channel_utilization=device_metrics.get("channelUtilization"), + air_util_tx=device_metrics.get("airUtilTx"), ) # Initialize deque for this node if needed @@ -709,23 +720,23 @@ class MeshtasticClient: return name # Try SDK's nodeDB with various key formats - if self._interface and hasattr(self._interface, 'nodes') and self._interface.nodes: + if self._interface and hasattr(self._interface, "nodes") and self._interface.nodes: nodes = self._interface.nodes # Try direct lookup with different key formats for key in [node_num, f"!{node_num:08x}", f"!{node_num:x}", str(node_num)]: if key in nodes: - user = nodes[key].get('user', {}) - name = user.get('shortName') or user.get('longName') + user = nodes[key].get("user", {}) + name = user.get("shortName") or user.get("longName") if name: logger.debug(f"Found name '{name}' for node {node_num} with key {key}") return name # Search through all nodes by num field for key, node_data in nodes.items(): - if node_data.get('num') == node_num: - user = node_data.get('user', {}) - name = user.get('shortName') or user.get('longName') + if node_data.get("num") == node_num: + user = node_data.get("user", {}) + name = user.get("shortName") or user.get("longName") if name: logger.debug(f"Found name '{name}' for node {node_num} by search") return name @@ -745,18 +756,18 @@ class MeshtasticClient: return None try: node = self._interface.getMyNodeInfo() - user = node.get('user', {}) - position = node.get('position', {}) + user = node.get("user", {}) + position = node.get("position", {}) return NodeInfo( - num=node.get('num', 0), - user_id=user.get('id', ''), - long_name=user.get('longName', ''), - short_name=user.get('shortName', ''), - hw_model=user.get('hwModel', 'UNKNOWN'), - latitude=position.get('latitude'), - longitude=position.get('longitude'), - altitude=position.get('altitude'), + num=node.get("num", 0), + user_id=user.get("id", ""), + long_name=user.get("longName", ""), + short_name=user.get("shortName", ""), + hw_model=user.get("hwModel", "UNKNOWN"), + latitude=position.get("latitude"), + longitude=position.get("longitude"), + altitude=position.get("altitude"), ) except Exception as e: logger.error(f"Error getting node info: {e}") @@ -782,50 +793,50 @@ class MeshtasticClient: # Skip if it's a string key like '!abcd1234' if isinstance(node_id, str): try: - num = int(node_id[1:], 16) if node_id.startswith('!') else int(node_id) + num = int(node_id[1:], 16) if node_id.startswith("!") else int(node_id) except ValueError: continue else: num = node_id - user = node_data.get('user', {}) - position = node_data.get('position', {}) + user = node_data.get("user", {}) + position = node_data.get("position", {}) # Get or create node if num not in self._nodes: self._nodes[num] = MeshNode( num=num, - user_id=user.get('id', f"!{num:08x}"), - long_name=user.get('longName', ''), - short_name=user.get('shortName', ''), - hw_model=user.get('hwModel', 'UNKNOWN'), + user_id=user.get("id", f"!{num:08x}"), + long_name=user.get("longName", ""), + short_name=user.get("shortName", ""), + hw_model=user.get("hwModel", "UNKNOWN"), ) node = self._nodes[num] # Update from SDK data if user: - node.long_name = user.get('longName', node.long_name) or node.long_name - node.short_name = user.get('shortName', node.short_name) or node.short_name - node.hw_model = user.get('hwModel', node.hw_model) or node.hw_model - if user.get('id'): - node.user_id = user.get('id') + node.long_name = user.get("longName", node.long_name) or node.long_name + node.short_name = user.get("shortName", node.short_name) or node.short_name + node.hw_model = user.get("hwModel", node.hw_model) or node.hw_model + if user.get("id"): + node.user_id = user.get("id") if position: - lat = position.get('latitude') - lon = position.get('longitude') + lat = position.get("latitude") + lon = position.get("longitude") if lat is not None and lon is not None: node.latitude = lat node.longitude = lon - node.altitude = position.get('altitude', node.altitude) + node.altitude = position.get("altitude", node.altitude) # Update last heard from SDK - last_heard = node_data.get('lastHeard') + last_heard = node_data.get("lastHeard") if last_heard: node.last_heard = datetime.fromtimestamp(last_heard, tz=timezone.utc) # Update SNR - node.snr = node_data.get('snr', node.snr) + node.snr = node_data.get("snr", node.snr) except Exception as e: logger.error(f"Error syncing nodes from interface: {e}") @@ -839,18 +850,19 @@ class MeshtasticClient: try: for i, ch in enumerate(self._interface.localNode.channels): if ch.role != 0: # 0 = DISABLED - channels.append(ChannelConfig( - index=i, - name=ch.settings.name or f"Channel {i}", - psk=bytes(ch.settings.psk) if ch.settings.psk else b'', - role=ch.role, - )) + channels.append( + ChannelConfig( + index=i, + name=ch.settings.name or f"Channel {i}", + psk=bytes(ch.settings.psk) if ch.settings.psk else b"", + role=ch.role, + ) + ) except Exception as e: logger.error(f"Error getting channels: {e}") return channels - def send_text(self, text: str, channel: int = 0, - destination: str | int | None = None) -> tuple[bool, str]: + def send_text(self, text: str, channel: int = 0, destination: str | int | None = None) -> tuple[bool, str]: """ Send a text message to the mesh network. @@ -878,7 +890,7 @@ class MeshtasticClient: dest_id = destination elif destination == "^all": dest_id = BROADCAST_ADDR - elif destination.startswith('!'): + elif destination.startswith("!"): dest_id = int(destination[1:], 16) else: # Try parsing as integer @@ -895,7 +907,7 @@ class MeshtasticClient: from meshtastic import portnums_pb2 self._interface.sendData( - text.encode('utf-8'), + text.encode("utf-8"), destinationId=dest_id, portNum=portnums_pb2.PortNum.TEXT_MESSAGE_APP, channelIndex=channel, @@ -910,8 +922,7 @@ class MeshtasticClient: logger.error(f"Error sending message: {e}") return False, str(e) - def set_channel(self, index: int, name: str | None = None, - psk: str | None = None) -> tuple[bool, str]: + def set_channel(self, index: int, name: str | None = None, psk: str | None = None) -> tuple[bool, str]: """ Configure a channel with encryption key. @@ -974,18 +985,18 @@ class MeshtasticClient: """ psk = psk.strip() - if psk.lower() == 'none': - return b'' + if psk.lower() == "none": + return b"" - if psk.lower() == 'default': + if psk.lower() == "default": # Default key (1 byte = use default) - return b'\x01' + return b"\x01" - if psk.lower() == 'random': + if psk.lower() == "random": # Generate random 32-byte key return secrets.token_bytes(32) - if psk.startswith('base64:'): + if psk.startswith("base64:"): try: decoded = base64.b64decode(psk[7:]) if len(decoded) not in (0, 1, 16, 32): @@ -994,7 +1005,7 @@ class MeshtasticClient: except Exception: return None - if psk.startswith('0x'): + if psk.startswith("0x"): try: decoded = bytes.fromhex(psk[2:]) if len(decoded) not in (0, 1, 16, 32): @@ -1003,9 +1014,9 @@ class MeshtasticClient: except Exception: return None - if psk.startswith('simple:'): + if psk.startswith("simple:"): # Hash passphrase to create 32-byte AES-256 key - passphrase = psk[7:].encode('utf-8') + passphrase = psk[7:].encode("utf-8") return hashlib.sha256(passphrase).digest() # Try as raw base64 (for compatibility) @@ -1042,7 +1053,7 @@ class MeshtasticClient: # Parse destination if isinstance(destination, int): dest_id = destination - elif destination.startswith('!'): + elif destination.startswith("!"): dest_id = int(destination[1:], 16) else: try: @@ -1066,14 +1077,14 @@ class MeshtasticClient: def _handle_traceroute_response(self, packet: dict, decoded: dict) -> None: """Handle incoming traceroute response.""" try: - from_num = packet.get('from', 0) - route_discovery = decoded.get('routeDiscovery', {}) + from_num = packet.get("from", 0) + route_discovery = decoded.get("routeDiscovery", {}) # Extract route information - route = route_discovery.get('route', []) - route_back = route_discovery.get('routeBack', []) - snr_towards = route_discovery.get('snrTowards', []) - snr_back = route_discovery.get('snrBack', []) + route = route_discovery.get("route", []) + route_back = route_discovery.get("routeBack", []) + snr_towards = route_discovery.get("snrTowards", []) + snr_back = route_discovery.get("snrBack", []) # Convert node numbers to IDs route_ids = [self._format_node_id(n) for n in route] @@ -1098,7 +1109,9 @@ class MeshtasticClient: if len(self._traceroute_results) > self._max_traceroute_results: self._traceroute_results.pop(0) - logger.info(f"Traceroute response from {result.destination_id}: route={route_ids}, route_back={route_back_ids}") + logger.info( + f"Traceroute response from {result.destination_id}: route={route_ids}, route_back={route_back_ids}" + ) except Exception as e: logger.error(f"Error handling traceroute response: {e}") @@ -1121,17 +1134,17 @@ class MeshtasticClient: def _handle_routing_packet(self, packet: dict, decoded: dict) -> None: """Handle ROUTING_APP packets for ACK/NAK tracking.""" try: - routing = decoded.get('routing', {}) - error_reason = routing.get('errorReason') - request_id = packet.get('requestId', 0) + routing = decoded.get("routing", {}) + error_reason = routing.get("errorReason") + request_id = packet.get("requestId", 0) if request_id and request_id in self._pending_messages: msg = self._pending_messages[request_id] - if error_reason and error_reason != 'NONE': - msg.status = 'failed' + if error_reason and error_reason != "NONE": + msg.status = "failed" logger.debug(f"Message {request_id} failed: {error_reason}") else: - msg.status = 'acked' + msg.status = "acked" logger.debug(f"Message {request_id} acknowledged") except Exception as e: logger.error(f"Error handling routing packet: {e}") @@ -1139,25 +1152,27 @@ class MeshtasticClient: def _handle_neighbor_info(self, packet: dict, decoded: dict) -> None: """Handle NEIGHBOR_INFO_APP packets for mesh topology.""" try: - from_num = packet.get('from', 0) + from_num = packet.get("from", 0) if from_num == 0: return - neighbor_info = decoded.get('neighborinfo', {}) - neighbors = neighbor_info.get('neighbors', []) + neighbor_info = decoded.get("neighborinfo", {}) + neighbors = neighbor_info.get("neighbors", []) now = datetime.now(timezone.utc) neighbor_list = [] for neighbor in neighbors: - neighbor_num = neighbor.get('nodeId', 0) + neighbor_num = neighbor.get("nodeId", 0) if neighbor_num: - neighbor_list.append(NeighborInfo( - neighbor_num=neighbor_num, - neighbor_id=self._format_node_id(neighbor_num), - snr=neighbor.get('snr', 0.0), - timestamp=now, - )) + neighbor_list.append( + NeighborInfo( + neighbor_num=neighbor_num, + neighbor_id=self._format_node_id(neighbor_num), + snr=neighbor.get("snr", 0.0), + timestamp=now, + ) + ) if neighbor_list: self._neighbors[from_num] = neighbor_list @@ -1195,10 +1210,7 @@ class MeshtasticClient: return [] cutoff = datetime.now(timezone.utc).timestamp() - (hours * 3600) - return [ - p for p in self._telemetry_history[node_num] - if p.timestamp.timestamp() > cutoff - ] + return [p for p in self._telemetry_history[node_num] if p.timestamp.timestamp() > cutoff] def get_pending_messages(self) -> dict[int, PendingMessage]: """Get all pending messages waiting for ACK.""" @@ -1224,7 +1236,7 @@ class MeshtasticClient: # Parse destination if isinstance(destination, int): dest_id = destination - elif destination.startswith('!'): + elif destination.startswith("!"): dest_id = int(destination[1:], 16) else: try: @@ -1242,7 +1254,7 @@ class MeshtasticClient: # Request position by sending an empty position request packet self._interface.sendData( - b'', # Empty payload triggers position response + b"", # Empty payload triggers position response destinationId=dest_id, portNum=portnums_pb2.PortNum.POSITION_APP, wantAck=True, @@ -1264,11 +1276,11 @@ class MeshtasticClient: Dict with current_version, latest_version, update_available, release_url """ result = { - 'current_version': None, - 'latest_version': None, - 'update_available': False, - 'release_url': None, - 'error': None, + "current_version": None, + "latest_version": None, + "update_available": False, + "release_url": None, + "error": None, } # Get current firmware version from device @@ -1276,60 +1288,58 @@ class MeshtasticClient: try: my_info = self._interface.getMyNodeInfo() if my_info: - my_info.get('deviceMetrics', {}) + my_info.get("deviceMetrics", {}) # Firmware version is in the user section or metadata - if 'firmware_version' in my_info: - self._firmware_version = my_info['firmware_version'] - elif hasattr(self._interface, 'myInfo') and self._interface.myInfo: - self._firmware_version = getattr(self._interface.myInfo, 'firmware_version', None) - result['current_version'] = self._firmware_version + if "firmware_version" in my_info: + self._firmware_version = my_info["firmware_version"] + elif hasattr(self._interface, "myInfo") and self._interface.myInfo: + self._firmware_version = getattr(self._interface.myInfo, "firmware_version", None) + result["current_version"] = self._firmware_version except Exception as e: logger.warning(f"Could not get device firmware version: {e}") # Check GitHub for latest release (cache for 15 minutes) now = datetime.now(timezone.utc) cache_valid = ( - self._firmware_check_time and - self._latest_firmware and - (now - self._firmware_check_time).total_seconds() < 900 + self._firmware_check_time + and self._latest_firmware + and (now - self._firmware_check_time).total_seconds() < 900 ) if not cache_valid: try: - url = 'https://api.github.com/repos/meshtastic/firmware/releases/latest' - req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT'}) + url = "https://api.github.com/repos/meshtastic/firmware/releases/latest" + req = urllib.request.Request(url, headers={"User-Agent": "INTERCEPT"}) with urllib.request.urlopen(req, timeout=10) as response: - data = json.loads(response.read().decode('utf-8')) + data = json.loads(response.read().decode("utf-8")) self._latest_firmware = { - 'version': data.get('tag_name', '').lstrip('v'), - 'url': data.get('html_url'), - 'name': data.get('name'), + "version": data.get("tag_name", "").lstrip("v"), + "url": data.get("html_url"), + "name": data.get("name"), } self._firmware_check_time = now except Exception as e: logger.warning(f"Could not check latest firmware: {e}") - result['error'] = str(e) + result["error"] = str(e) if self._latest_firmware: - result['latest_version'] = self._latest_firmware.get('version') - result['release_url'] = self._latest_firmware.get('url') + result["latest_version"] = self._latest_firmware.get("version") + result["release_url"] = self._latest_firmware.get("url") # Compare versions - if result['current_version'] and result['latest_version']: - result['update_available'] = self._compare_versions( - result['current_version'], - result['latest_version'] - ) + if result["current_version"] and result["latest_version"]: + result["update_available"] = self._compare_versions(result["current_version"], result["latest_version"]) return result def _compare_versions(self, current: str, latest: str) -> bool: """Compare semver versions, return True if update available.""" try: + def parse_version(v: str) -> tuple: # Strip any leading 'v' and split by dots - v = v.lstrip('v').split('-')[0] # Remove pre-release suffix - parts = v.split('.') + v = v.lstrip("v").split("-")[0] # Remove pre-release suffix + parts = v.split(".") return tuple(int(p) for p in parts[:3]) current_parts = parse_version(current) @@ -1378,15 +1388,13 @@ class MeshtasticClient: # For simplicity, we'll create a URL with the channel name and key info # The official format requires protobuf serialization channel_data = { - 'name': channel.name, - 'index': channel.index, - 'psk': base64.b64encode(channel.psk).decode('utf-8') if channel.psk else '', + "name": channel.name, + "index": channel.index, + "psk": base64.b64encode(channel.psk).decode("utf-8") if channel.psk else "", } # Encode as base64 JSON (simplified format) - encoded = base64.urlsafe_b64encode( - json.dumps(channel_data).encode('utf-8') - ).decode('utf-8') + encoded = base64.urlsafe_b64encode(json.dumps(channel_data).encode("utf-8")).decode("utf-8") url = f"https://meshtastic.org/e/#{encoded}" @@ -1404,7 +1412,7 @@ class MeshtasticClient: # Convert to PNG bytes buffer = BytesIO() - img.save(buffer, format='PNG') + img.save(buffer, format="PNG") return buffer.getvalue() except Exception as e: @@ -1442,19 +1450,20 @@ class MeshtasticClient: def send_packets(): import time + for i in range(count): if not self._range_test_running: break try: # Send range test packet with sequence number - payload = f"RangeTest #{i+1}".encode() + payload = f"RangeTest #{i + 1}".encode() self._interface.sendData( payload, destinationId=BROADCAST_ADDR, portNum=portnums_pb2.PortNum.RANGE_TEST_APP, ) - logger.info(f"Range test packet {i+1}/{count} sent") + logger.info(f"Range test packet {i + 1}/{count} sent") except Exception as e: logger.error(f"Error sending range test packet: {e}") @@ -1481,8 +1490,8 @@ class MeshtasticClient: def get_range_test_status(self) -> dict: """Get range test status.""" return { - 'running': self._range_test_running, - 'results': self._range_test_results, + "running": self._range_test_running, + "results": self._range_test_results, } def request_store_forward(self, window_minutes: int = 60) -> tuple[bool, str]: @@ -1509,9 +1518,9 @@ class MeshtasticClient: if self._interface.nodes: for node_id, node_data in self._interface.nodes.items(): # Check for router role - role = node_data.get('user', {}).get('role') - if role in ('ROUTER', 'ROUTER_CLIENT'): - if isinstance(node_id, str) and node_id.startswith('!'): + role = node_data.get("user", {}).get("role") + if role in ("ROUTER", "ROUTER_CLIENT"): + if isinstance(node_id, str) and node_id.startswith("!"): router_num = int(node_id[1:], 16) elif isinstance(node_id, int): router_num = node_id @@ -1548,23 +1557,23 @@ class MeshtasticClient: Dict with available status and router info """ result = { - 'available': False, - 'router_id': None, - 'router_name': None, + "available": False, + "router_id": None, + "router_name": None, } if not self._interface or not self._interface.nodes: return result for node_id, node_data in self._interface.nodes.items(): - role = node_data.get('user', {}).get('role') - if role in ('ROUTER', 'ROUTER_CLIENT'): - result['available'] = True + role = node_data.get("user", {}).get("role") + if role in ("ROUTER", "ROUTER_CLIENT"): + result["available"] = True if isinstance(node_id, str): - result['router_id'] = node_id + result["router_id"] = node_id else: - result['router_id'] = self._format_node_id(node_id) - result['router_name'] = node_data.get('user', {}).get('shortName') + result["router_id"] = self._format_node_id(node_id) + result["router_name"] = node_data.get("user", {}).get("shortName") break return result @@ -1579,10 +1588,12 @@ def get_meshtastic_client() -> MeshtasticClient | None: return _client -def start_meshtastic(device: str | None = None, - callback: Callable[[MeshtasticMessage], None] | None = None, - connection_type: str = 'serial', - hostname: str | None = None) -> bool: +def start_meshtastic( + device: str | None = None, + callback: Callable[[MeshtasticMessage], None] | None = None, + connection_type: str = "serial", + hostname: str | None = None, +) -> bool: """ Start the Meshtastic client. diff --git a/utils/meteor_detector.py b/utils/meteor_detector.py index 6ce45be..5d6b4b0 100644 --- a/utils/meteor_detector.py +++ b/utils/meteor_detector.py @@ -20,15 +20,17 @@ import numpy as np class PingState(enum.Enum): """Detection state machine states.""" - IDLE = 'idle' - DETECTING = 'detecting' - ACTIVE = 'active' - COOLDOWN = 'cooldown' + + IDLE = "idle" + DETECTING = "detecting" + ACTIVE = "active" + COOLDOWN = "cooldown" @dataclass class MeteorEvent: """A detected meteor scatter ping.""" + id: str start_ts: float end_ts: float @@ -164,8 +166,8 @@ class MeteorDetector: if self._noise_floor is not None and len(self._noise_floor) == len(window): mask = window < (self._noise_floor + self.snr_threshold_db * 0.5) alpha = self.noise_alpha - self._noise_floor[mask] = ( - (1 - alpha) * self._noise_floor[mask] + alpha * window[mask].astype(np.float64) + self._noise_floor[mask] = (1 - alpha) * self._noise_floor[mask] + alpha * window[mask].astype( + np.float64 ) else: self._noise_floor = window.copy().astype(np.float64) @@ -262,17 +264,17 @@ class MeteorDetector: # Tags tags: list[str] = [] if self._peak_snr >= 20: - tags.append('strong') + tags.append("strong") elif self._peak_snr >= 10: - tags.append('moderate') + tags.append("moderate") else: - tags.append('weak') + tags.append("weak") if duration_ms >= 5000: - tags.append('long-duration') + tags.append("long-duration") elif duration_ms >= 1000: - tags.append('medium') + tags.append("medium") else: - tags.append('short') + tags.append("short") event = MeteorEvent( id=str(uuid.uuid4())[:8], @@ -304,13 +306,13 @@ class MeteorDetector: pings_last_10min = sum(1 for e in self._events if e.start_ts >= cutoff) return { - 'type': 'stats', - 'state': self._state.value, - 'pings_total': self._pings_total, - 'pings_last_10min': pings_last_10min, - 'strongest_snr': round(self._strongest_snr, 1), - 'current_noise_floor': round(self._current_noise_floor_db, 1), - 'uptime_s': round(uptime_s, 1), + "type": "stats", + "state": self._state.value, + "pings_total": self._pings_total, + "pings_last_10min": pings_last_10min, + "strongest_snr": round(self._strongest_snr, 1), + "current_noise_floor": round(self._current_noise_floor_db, 1), + "uptime_s": round(uptime_s, 1), } def get_events(self, limit: int = 500) -> list[dict[str, Any]]: @@ -329,17 +331,37 @@ class MeteorDetector: """Export events as CSV string.""" output = io.StringIO() writer = csv.writer(output) - writer.writerow([ - 'id', 'start_ts', 'end_ts', 'duration_ms', 'peak_db', - 'snr_db', 'center_freq_hz', 'peak_freq_hz', 'freq_offset_hz', - 'confidence', 'tags', - ]) + writer.writerow( + [ + "id", + "start_ts", + "end_ts", + "duration_ms", + "peak_db", + "snr_db", + "center_freq_hz", + "peak_freq_hz", + "freq_offset_hz", + "confidence", + "tags", + ] + ) for e in self._events: - writer.writerow([ - e.id, e.start_ts, e.end_ts, e.duration_ms, e.peak_db, - e.snr_db, e.center_freq_hz, e.peak_freq_hz, e.freq_offset_hz, - e.confidence, ';'.join(e.tags), - ]) + writer.writerow( + [ + e.id, + e.start_ts, + e.end_ts, + e.duration_ms, + e.peak_db, + e.snr_db, + e.center_freq_hz, + e.peak_freq_hz, + e.freq_offset_hz, + e.confidence, + ";".join(e.tags), + ] + ) return output.getvalue() def export_events_json(self) -> str: diff --git a/utils/morse.py b/utils/morse.py index a0eae99..9d126ea 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -35,22 +35,64 @@ except Exception: # pragma: no cover - fallback path # International Morse Code table MORSE_TABLE: dict[str, str] = { - '.-': 'A', '-...': 'B', '-.-.': 'C', '-..': 'D', '.': 'E', - '..-.': 'F', '--.': 'G', '....': 'H', '..': 'I', '.---': 'J', - '-.-': 'K', '.-..': 'L', '--': 'M', '-.': 'N', '---': 'O', - '.--.': 'P', '--.-': 'Q', '.-.': 'R', '...': 'S', '-': 'T', - '..-': 'U', '...-': 'V', '.--': 'W', '-..-': 'X', '-.--': 'Y', - '--..': 'Z', - '-----': '0', '.----': '1', '..---': '2', '...--': '3', - '....-': '4', '.....': '5', '-....': '6', '--...': '7', - '---..': '8', '----.': '9', - '.-.-.-': '.', '--..--': ',', '..--..': '?', '.----.': "'", - '-.-.--': '!', '-..-.': '/', '-.--.': '(', '-.--.-': ')', - '.-...': '&', '---...': ':', '-.-.-.': ';', '-...-': '=', - '.-.-.': '+', '-....-': '-', '..--.-': '_', '.-..-.': '"', - '...-..-': '$', '.--.-.': '@', + ".-": "A", + "-...": "B", + "-.-.": "C", + "-..": "D", + ".": "E", + "..-.": "F", + "--.": "G", + "....": "H", + "..": "I", + ".---": "J", + "-.-": "K", + ".-..": "L", + "--": "M", + "-.": "N", + "---": "O", + ".--.": "P", + "--.-": "Q", + ".-.": "R", + "...": "S", + "-": "T", + "..-": "U", + "...-": "V", + ".--": "W", + "-..-": "X", + "-.--": "Y", + "--..": "Z", + "-----": "0", + ".----": "1", + "..---": "2", + "...--": "3", + "....-": "4", + ".....": "5", + "-....": "6", + "--...": "7", + "---..": "8", + "----.": "9", + ".-.-.-": ".", + "--..--": ",", + "..--..": "?", + ".----.": "'", + "-.-.--": "!", + "-..-.": "/", + "-.--.": "(", + "-.--.-": ")", + ".-...": "&", + "---...": ":", + "-.-.-.": ";", + "-...-": "=", + ".-.-.": "+", + "-....-": "-", + "..--.-": "_", + ".-..-.": '"', + "...-..-": "$", + ".--.-.": "@", # Prosigns (unique codes only; -...- and -.--.- already mapped above) - '-.-.-': '', '.-.-': '', '...-.-': '', + "-.-.-": "", + ".-.-": "", + "...-.-": "", } # Reverse lookup: character -> morse notation @@ -119,21 +161,21 @@ def _coerce_bool(value: Any, default: bool = False) -> bool: if value is None: return default text = str(value).strip().lower() - if text in {'1', 'true', 'yes', 'on'}: + if text in {"1", "true", "yes", "on"}: return True - if text in {'0', 'false', 'no', 'off'}: + if text in {"0", "false", "no", "off"}: return False return default def _normalize_threshold_mode(value: Any) -> str: - mode = str(value or 'auto').strip().lower() - return mode if mode in {'auto', 'manual'} else 'auto' + mode = str(value or "auto").strip().lower() + return mode if mode in {"auto", "manual"} else "auto" def _normalize_wpm_mode(value: Any) -> str: - mode = str(value or 'auto').strip().lower() - return mode if mode in {'auto', 'manual'} else 'auto' + mode = str(value or "auto").strip().lower() + return mode if mode in {"auto", "manual"} else "auto" def _clamp(value: float, lo: float, hi: float) -> float: @@ -151,19 +193,19 @@ class MorseDecoder: bandwidth_hz: int = 200, auto_tone_track: bool = True, tone_lock: bool = False, - threshold_mode: str = 'auto', + threshold_mode: str = "auto", manual_threshold: float = 0.0, threshold_multiplier: float = 2.8, threshold_offset: float = 0.0, - wpm_mode: str = 'auto', + wpm_mode: str = "auto", wpm_lock: bool = False, min_signal_gate: float = 0.0, - detect_mode: str = 'goertzel', + detect_mode: str = "goertzel", ): self.sample_rate = int(sample_rate) self.tone_freq = float(tone_freq) self.wpm = int(wpm) - self.detect_mode = detect_mode if detect_mode in ('goertzel', 'envelope') else 'goertzel' + self.detect_mode = detect_mode if detect_mode in ("goertzel", "envelope") else "goertzel" self.bandwidth_hz = int(_clamp(float(bandwidth_hz), 50, 400)) self.auto_tone_track = bool(auto_tone_track) @@ -186,7 +228,7 @@ class MorseDecoder: self._tone_scan_step_hz = 10.0 self._tone_scan_interval_blocks = 8 - if self.detect_mode == 'envelope': + if self.detect_mode == "envelope": self._detector = EnvelopeDetector(self._block_size) self._noise_detector_low = None self._noise_detector_high = None @@ -211,7 +253,7 @@ class MorseDecoder: # Envelope smoothing. # OOK has clean binary transitions; use symmetric fast alpha. # HF CW has gradual fading (QSB); use asymmetric slower release. - if self.detect_mode == 'envelope': + if self.detect_mode == "envelope": self._attack_alpha = 0.55 self._release_alpha = 0.55 else: @@ -228,7 +270,7 @@ class MorseDecoder: # Warm-up bootstrap. self._WARMUP_BLOCKS = 16 self._SETTLE_BLOCKS = 140 - self._mag_min = float('inf') + self._mag_min = float("inf") self._mag_max = 0.0 self._blocks_processed = 0 @@ -237,7 +279,7 @@ class MorseDecoder: dit_blocks = max(1.0, dit_sec / self._block_duration) self._dah_threshold = 2.2 * dit_blocks self._dit_min = 0.38 * dit_blocks - if self.detect_mode == 'envelope': + if self.detect_mode == "envelope": # Tighter gaps for OOK — clean binary transitions tolerate this. self._char_gap = 2.0 * dit_blocks self._word_gap = 5.0 * dit_blocks @@ -251,7 +293,7 @@ class MorseDecoder: self._tone_on = False self._tone_blocks = 0.0 self._silence_blocks = 0.0 - self._current_symbol = '' + self._current_symbol = "" self._pending_buffer: list[int] = [] # Dropout tolerance: bridge brief signal dropouts mid-element (~40ms). @@ -267,7 +309,7 @@ class MorseDecoder: self._noise_floor = 0.0 self._signal_peak = 0.0 self._threshold = 0.0 - self._mag_min = float('inf') + self._mag_min = float("inf") self._mag_max = 0.0 self._blocks_processed = 0 self._dit_observations.clear() @@ -275,38 +317,38 @@ class MorseDecoder: self._tone_on = False self._tone_blocks = 0.0 self._silence_blocks = 0.0 - self._current_symbol = '' + self._current_symbol = "" def get_metrics(self) -> dict[str, float | bool]: """Return latest decoder metrics for UI/status messages.""" metrics: dict[str, Any] = { - 'wpm': float(self._estimated_wpm), - 'tone_freq': float(self._active_tone_freq), - 'level': float(self._last_level), - 'noise_floor': float(self._noise_floor), - 'threshold': float(self._threshold), - 'tone_on': bool(self._tone_on), - 'dit_ms': float((self._effective_dit_blocks() * self._block_duration) * 1000.0), - 'detect_mode': self.detect_mode, + "wpm": float(self._estimated_wpm), + "tone_freq": float(self._active_tone_freq), + "level": float(self._last_level), + "noise_floor": float(self._noise_floor), + "threshold": float(self._threshold), + "tone_on": bool(self._tone_on), + "dit_ms": float((self._effective_dit_blocks() * self._block_duration) * 1000.0), + "detect_mode": self.detect_mode, } - if self.detect_mode == 'envelope': - metrics['snr'] = 0.0 - metrics['noise_ref'] = 0.0 - metrics['snr_on'] = 0.0 - metrics['snr_off'] = 0.0 + if self.detect_mode == "envelope": + metrics["snr"] = 0.0 + metrics["noise_ref"] = 0.0 + metrics["snr_on"] = 0.0 + metrics["snr_off"] = 0.0 else: snr_mult = max(1.15, self.threshold_multiplier * 0.5) snr_on = snr_mult * (1.0 + self._hysteresis) snr_off = snr_mult * (1.0 - self._hysteresis) - metrics['snr'] = float(self._last_level / max(self._noise_floor, 1e-6)) - metrics['noise_ref'] = float(self._noise_floor) - metrics['snr_on'] = float(snr_on) - metrics['snr_off'] = float(snr_off) + metrics["snr"] = float(self._last_level / max(self._noise_floor, 1e-6)) + metrics["noise_ref"] = float(self._noise_floor) + metrics["snr_on"] = float(snr_on) + metrics["snr_off"] = float(snr_off) return metrics def _rebuild_detectors(self) -> None: """Rebuild target/noise Goertzel filters after tone updates.""" - if self.detect_mode == 'envelope': + if self.detect_mode == "envelope": return # Envelope detector is frequency-agnostic self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size) ref_offset = max(150.0, self.bandwidth_hz) @@ -376,7 +418,7 @@ class MorseDecoder: def _effective_dit_blocks(self) -> float: """Return current dit estimate in block units.""" - if self.wpm_mode == 'manual' or self.wpm_lock: + if self.wpm_mode == "manual" or self.wpm_lock: wpm = max(5.0, min(50.0, float(self.wpm))) dit_blocks = max(1.0, (1.2 / wpm) / self._block_duration) self._estimated_wpm = wpm @@ -397,7 +439,7 @@ class MorseDecoder: """Feed a possible dit duration into the estimator.""" if blocks <= 0: return - if self.wpm_mode == 'manual' or self.wpm_lock: + if self.wpm_mode == "manual" or self.wpm_lock: return if blocks > 20: return @@ -408,10 +450,10 @@ class MorseDecoder: if char is None: return None return { - 'type': 'morse_char', - 'char': char, - 'morse': symbol, - 'timestamp': timestamp, + "type": "morse_char", + "char": char, + "morse": symbol, + "timestamp": timestamp, } def process_block(self, pcm_bytes: bytes) -> list[dict[str, Any]]: @@ -422,14 +464,14 @@ class MorseDecoder: if n_samples <= 0: return events - samples = struct.unpack(f'<{n_samples}h', pcm_bytes[:n_samples * 2]) + samples = struct.unpack(f"<{n_samples}h", pcm_bytes[: n_samples * 2]) self._pending_buffer.extend(samples) amplitudes: list[float] = [] while len(self._pending_buffer) >= self._block_size: - block = np.array(self._pending_buffer[:self._block_size], dtype=np.float64) - del self._pending_buffer[:self._block_size] + block = np.array(self._pending_buffer[: self._block_size], dtype=np.float64) + del self._pending_buffer[: self._block_size] normalized = block / 32768.0 @@ -445,7 +487,7 @@ class MorseDecoder: mag = self._detector.magnitude(normalized) - if self.detect_mode == 'envelope': + if self.detect_mode == "envelope": # Envelope mode: direct magnitude threshold, no noise detectors noise_ref = 0.0 level = float(mag) @@ -464,24 +506,23 @@ class MorseDecoder: self._signal_peak = max(self._noise_floor + 0.5, self._noise_floor * 2.5) else: self._signal_peak = max(self._mag_max, self._noise_floor * 1.8) - self._threshold = self._noise_floor + 0.22 * ( - self._signal_peak - self._noise_floor - ) + self._threshold = self._noise_floor + 0.22 * (self._signal_peak - self._noise_floor) tone_detected = False else: - settle_alpha = 0.30 if self._blocks_processed < (self._WARMUP_BLOCKS + self._SETTLE_BLOCKS) else 0.06 + settle_alpha = ( + 0.30 if self._blocks_processed < (self._WARMUP_BLOCKS + self._SETTLE_BLOCKS) else 0.06 + ) if level <= self._threshold: self._noise_floor += settle_alpha * (level - self._noise_floor) else: self._signal_peak += settle_alpha * (level - self._signal_peak) self._signal_peak = max(self._signal_peak, self._noise_floor * 1.05) - if self.threshold_mode == 'manual': + if self.threshold_mode == "manual": self._threshold = max(0.0, self.manual_threshold) else: self._threshold = ( - max(0.0, self._noise_floor * self.threshold_multiplier) - + self.threshold_offset + max(0.0, self._noise_floor * self.threshold_multiplier) + self.threshold_offset ) self._threshold = max(self._threshold, self._noise_floor + 0.35) @@ -529,12 +570,12 @@ class MorseDecoder: self._signal_peak = max(self._noise_floor + 0.5, self._noise_floor * 2.5) else: self._signal_peak = max(self._mag_max, self._noise_floor * 1.8) - self._threshold = self._noise_floor + 0.22 * ( - self._signal_peak - self._noise_floor - ) + self._threshold = self._noise_floor + 0.22 * (self._signal_peak - self._noise_floor) tone_detected = False else: - settle_alpha = 0.30 if self._blocks_processed < (self._WARMUP_BLOCKS + self._SETTLE_BLOCKS) else 0.06 + settle_alpha = ( + 0.30 if self._blocks_processed < (self._WARMUP_BLOCKS + self._SETTLE_BLOCKS) else 0.06 + ) detector_level = level @@ -548,12 +589,11 @@ class MorseDecoder: # Blend adjacent-band noise reference into noise floor. self._noise_floor += (settle_alpha * 0.25) * (noise_ref - self._noise_floor) - if self.threshold_mode == 'manual': + if self.threshold_mode == "manual": self._threshold = max(0.0, self.manual_threshold) else: self._threshold = ( - max(0.0, self._noise_floor * self.threshold_multiplier) - + self.threshold_offset + max(0.0, self._noise_floor * self.threshold_multiplier) + self.threshold_offset ) self._threshold = max(self._threshold, self._noise_floor + 0.35) @@ -575,7 +615,7 @@ class MorseDecoder: dit_blocks = self._effective_dit_blocks() self._dah_threshold = 2.2 * dit_blocks self._dit_min = max(1.0, 0.38 * dit_blocks) - if self.detect_mode == 'envelope': + if self.detect_mode == "envelope": self._char_gap = 2.0 * dit_blocks self._word_gap = 5.0 * dit_blocks else: @@ -591,29 +631,35 @@ class MorseDecoder: self._tone_blocks = 0.0 if self._current_symbol and silence_count >= self._char_gap: - timestamp = datetime.now().strftime('%H:%M:%S') + timestamp = datetime.now().strftime("%H:%M:%S") decoded = self._decode_symbol(self._current_symbol, timestamp) if decoded is not None: events.append(decoded) if silence_count >= self._word_gap: - events.append({ - 'type': 'morse_space', - 'timestamp': timestamp, - }) - events.append({ - 'type': 'morse_gap', - 'gap': 'word', - 'duration_ms': round(silence_count * self._block_duration * 1000.0, 1), - }) + events.append( + { + "type": "morse_space", + "timestamp": timestamp, + } + ) + events.append( + { + "type": "morse_gap", + "gap": "word", + "duration_ms": round(silence_count * self._block_duration * 1000.0, 1), + } + ) else: - events.append({ - 'type': 'morse_gap', - 'gap': 'char', - 'duration_ms': round(silence_count * self._block_duration * 1000.0, 1), - }) + events.append( + { + "type": "morse_gap", + "gap": "char", + "duration_ms": round(silence_count * self._block_duration * 1000.0, 1), + } + ) - self._current_symbol = '' + self._current_symbol = "" elif silence_count >= 1.0: # Intra-symbol gap candidate improves dit estimate for Farnsworth-style spacing. if silence_count <= (self._char_gap * 0.95): @@ -632,20 +678,22 @@ class MorseDecoder: self._tone_blocks = 0.0 self._dropout_blocks = 0.0 - element = '' + element = "" if tone_count >= self._dah_threshold: - element = '-' + element = "-" elif tone_count >= self._dit_min: - element = '.' + element = "." if element: self._current_symbol += element - events.append({ - 'type': 'morse_element', - 'element': element, - 'duration_ms': round(tone_count * self._block_duration * 1000.0, 1), - }) - if element == '.': + events.append( + { + "type": "morse_element", + "element": element, + "duration_ms": round(tone_count * self._block_duration * 1000.0, 1), + } + ) + if element == ".": self._record_dit_candidate(tone_count) elif tone_count <= (self._dah_threshold * 1.6): # Some operators send short-ish dahs; still useful for tracking. @@ -661,30 +709,30 @@ class MorseDecoder: if amplitudes: scope_event: dict[str, Any] = { - 'type': 'scope', - 'amplitudes': amplitudes, - 'threshold': self._threshold, - 'tone_on': self._tone_on, - 'tone_freq': round(self._active_tone_freq, 1), - 'level': self._last_level, - 'noise_floor': self._noise_floor, - 'wpm': round(self._estimated_wpm, 1), - 'dit_ms': round(self._effective_dit_blocks() * self._block_duration * 1000.0, 1), - 'detect_mode': self.detect_mode, + "type": "scope", + "amplitudes": amplitudes, + "threshold": self._threshold, + "tone_on": self._tone_on, + "tone_freq": round(self._active_tone_freq, 1), + "level": self._last_level, + "noise_floor": self._noise_floor, + "wpm": round(self._estimated_wpm, 1), + "dit_ms": round(self._effective_dit_blocks() * self._block_duration * 1000.0, 1), + "detect_mode": self.detect_mode, } - if self.detect_mode == 'envelope': - scope_event['snr'] = 0.0 - scope_event['noise_ref'] = 0.0 - scope_event['snr_on'] = 0.0 - scope_event['snr_off'] = 0.0 + if self.detect_mode == "envelope": + scope_event["snr"] = 0.0 + scope_event["noise_ref"] = 0.0 + scope_event["snr_on"] = 0.0 + scope_event["snr_off"] = 0.0 else: snr_mult = max(1.15, self.threshold_multiplier * 0.5) snr_on = snr_mult * (1.0 + self._hysteresis) snr_off = snr_mult * (1.0 - self._hysteresis) - scope_event['snr'] = round(self._last_level / max(self._last_noise_ref, 1e-6), 2) - scope_event['noise_ref'] = round(self._last_noise_ref, 4) - scope_event['snr_on'] = round(snr_on, 2) - scope_event['snr_off'] = round(snr_off, 2) + scope_event["snr"] = round(self._last_level / max(self._last_noise_ref, 1e-6), 2) + scope_event["noise_ref"] = round(self._last_noise_ref, 4) + scope_event["snr_on"] = round(snr_on, 2) + scope_event["snr_off"] = round(snr_off, 2) events.append(scope_event) return events @@ -695,19 +743,21 @@ class MorseDecoder: if self._tone_on and (self._tone_blocks + self._dropout_blocks) >= self._dit_min: tone_count = self._tone_blocks + self._dropout_blocks - element = '-' if tone_count >= self._dah_threshold else '.' + element = "-" if tone_count >= self._dah_threshold else "." self._current_symbol += element - events.append({ - 'type': 'morse_element', - 'element': element, - 'duration_ms': round(tone_count * self._block_duration * 1000.0, 1), - }) + events.append( + { + "type": "morse_element", + "element": element, + "duration_ms": round(tone_count * self._block_duration * 1000.0, 1), + } + ) if self._current_symbol: - decoded = self._decode_symbol(self._current_symbol, datetime.now().strftime('%H:%M:%S')) + decoded = self._decode_symbol(self._current_symbol, datetime.now().strftime("%H:%M:%S")) if decoded is not None: events.append(decoded) - self._current_symbol = '' + self._current_symbol = "" self._tone_on = False self._tone_blocks = 0.0 @@ -718,7 +768,7 @@ class MorseDecoder: def _wav_to_mono_float(path: Path) -> tuple[np.ndarray, int]: """Load WAV file and return mono float32 samples in [-1, 1].""" - with wave.open(str(path), 'rb') as wf: + with wave.open(str(path), "rb") as wf: n_channels = wf.getnchannels() sampwidth = wf.getsampwidth() sample_rate = wf.getframerate() @@ -733,7 +783,7 @@ def _wav_to_mono_float(path: Path) -> tuple[np.ndarray, int]: elif sampwidth == 4: pcm = np.frombuffer(raw, dtype=np.int32).astype(np.float64) / 2147483648.0 else: - raise ValueError(f'Unsupported WAV sample width: {sampwidth * 8} bits') + raise ValueError(f"Unsupported WAV sample width: {sampwidth * 8} bits") if n_channels > 1: pcm = pcm.reshape(-1, n_channels).mean(axis=1) @@ -762,18 +812,18 @@ def decode_morse_wav_file( bandwidth_hz: int = 200, auto_tone_track: bool = True, tone_lock: bool = False, - threshold_mode: str = 'auto', + threshold_mode: str = "auto", manual_threshold: float = 0.0, threshold_multiplier: float = 2.8, threshold_offset: float = 0.0, - wpm_mode: str = 'auto', + wpm_mode: str = "auto", wpm_lock: bool = False, min_signal_gate: float = 0.0, ) -> dict[str, Any]: """Decode Morse from a WAV file and return text/events/metrics.""" path = Path(wav_path) if not path.is_file(): - raise FileNotFoundError(f'WAV file not found: {path}') + raise FileNotFoundError(f"WAV file not found: {path}") audio, file_rate = _wav_to_mono_float(path) if file_rate != sample_rate: @@ -802,7 +852,7 @@ def decode_morse_wav_file( chunk_samples = 2048 idx = 0 while idx < len(pcm16): - chunk = pcm16[idx:idx + chunk_samples] + chunk = pcm16[idx : idx + chunk_samples] if len(chunk) == 0: break events.extend(decoder.process_block(chunk.tobytes())) @@ -813,28 +863,28 @@ def decode_morse_wav_file( text_parts: list[str] = [] raw_parts: list[str] = [] for event in events: - et = event.get('type') - if et == 'morse_char': - text_parts.append(str(event.get('char', ''))) - elif et == 'morse_space': - text_parts.append(' ') - elif et == 'morse_element': - raw_parts.append(str(event.get('element', ''))) - elif et == 'morse_gap': - gap = str(event.get('gap', '')) - if gap == 'char': - raw_parts.append(' / ') - elif gap == 'word': - raw_parts.append(' // ') + et = event.get("type") + if et == "morse_char": + text_parts.append(str(event.get("char", ""))) + elif et == "morse_space": + text_parts.append(" ") + elif et == "morse_element": + raw_parts.append(str(event.get("element", ""))) + elif et == "morse_gap": + gap = str(event.get("gap", "")) + if gap == "char": + raw_parts.append(" / ") + elif gap == "word": + raw_parts.append(" // ") - text = ''.join(text_parts) - raw = ''.join(raw_parts).strip() + text = "".join(text_parts) + raw = "".join(raw_parts).strip() return { - 'text': text, - 'raw': raw, - 'events': events, - 'metrics': decoder.get_metrics(), + "text": text, + "raw": raw, + "events": events, + "metrics": decoder.get_metrics(), } @@ -852,10 +902,10 @@ def _drain_control_queue(control_queue: queue.Queue | None, decoder: MorseDecode if not isinstance(cmd, dict): continue - action = str(cmd.get('cmd', '')).strip().lower() - if action == 'reset': + action = str(cmd.get("cmd", "")).strip().lower() + if action == "reset": decoder.reset_calibration() - elif action in {'shutdown', 'stop'}: + elif action in {"shutdown", "stop"}: keep_running = False return keep_running @@ -864,14 +914,16 @@ def _drain_control_queue(control_queue: queue.Queue | None, decoder: MorseDecode def _emit_waiting_scope(output_queue: queue.Queue, waiting_since: float) -> None: """Emit waiting heartbeat while no PCM arrives.""" with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'scope', - 'amplitudes': [], - 'threshold': 0, - 'tone_on': False, - 'waiting': True, - 'waiting_seconds': round(max(0.0, time.monotonic() - waiting_since), 1), - }) + output_queue.put_nowait( + { + "type": "scope", + "amplitudes": [], + "threshold": 0, + "tone_on": False, + "waiting": True, + "waiting_seconds": round(max(0.0, time.monotonic() - waiting_since), 1), + } + ) def _is_probably_rtl_log_text(data: bytes) -> bool: @@ -879,7 +931,7 @@ def _is_probably_rtl_log_text(data: bytes) -> bool: if not data: return False # PCM usually contains NULLs/non-printables; plain log lines do not. - if b'\x00' in data: + if b"\x00" in data: return False printable = sum(1 for b in data if (32 <= b <= 126) or b in (9, 10, 13)) ratio = printable / max(1, len(data)) @@ -887,17 +939,17 @@ def _is_probably_rtl_log_text(data: bytes) -> bool: return False lower = data.lower() keywords = ( - b'rtl_fm', - b'found ', - b'using device', - b'tuned to', - b'sampling at', - b'output at', - b'buffer size', - b'gain', - b'direct sampling', - b'oversampling', - b'exact sample rate', + b"rtl_fm", + b"found ", + b"using device", + b"tuned to", + b"sampling at", + b"output at", + b"buffer size", + b"gain", + b"direct sampling", + b"oversampling", + b"exact sample rate", ) return any(token in lower for token in keywords) @@ -917,7 +969,8 @@ def morse_decoder_thread( ) -> None: """Decode Morse from live PCM stream and push events to *output_queue*.""" import logging - logger = logging.getLogger('intercept.morse') + + logger = logging.getLogger("intercept.morse") CHUNK = 4096 SCOPE_INTERVAL = 0.10 @@ -926,20 +979,20 @@ def morse_decoder_thread( cfg = dict(decoder_config or {}) decoder = MorseDecoder( - sample_rate=int(cfg.get('sample_rate', sample_rate)), - tone_freq=float(cfg.get('tone_freq', tone_freq)), - wpm=int(cfg.get('wpm', wpm)), - bandwidth_hz=int(cfg.get('bandwidth_hz', 200)), - auto_tone_track=_coerce_bool(cfg.get('auto_tone_track', True), True), - tone_lock=_coerce_bool(cfg.get('tone_lock', False), False), - threshold_mode=_normalize_threshold_mode(cfg.get('threshold_mode', 'auto')), - manual_threshold=float(cfg.get('manual_threshold', 0.0) or 0.0), - threshold_multiplier=float(cfg.get('threshold_multiplier', 2.8) or 2.8), - threshold_offset=float(cfg.get('threshold_offset', 0.0) or 0.0), - wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')), - wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False), - min_signal_gate=float(cfg.get('min_signal_gate', 0.0) or 0.0), - detect_mode=str(cfg.get('detect_mode', 'goertzel')), + sample_rate=int(cfg.get("sample_rate", sample_rate)), + tone_freq=float(cfg.get("tone_freq", tone_freq)), + wpm=int(cfg.get("wpm", wpm)), + bandwidth_hz=int(cfg.get("bandwidth_hz", 200)), + auto_tone_track=_coerce_bool(cfg.get("auto_tone_track", True), True), + tone_lock=_coerce_bool(cfg.get("tone_lock", False), False), + threshold_mode=_normalize_threshold_mode(cfg.get("threshold_mode", "auto")), + manual_threshold=float(cfg.get("manual_threshold", 0.0) or 0.0), + threshold_multiplier=float(cfg.get("threshold_multiplier", 2.8) or 2.8), + threshold_offset=float(cfg.get("threshold_offset", 0.0) or 0.0), + wpm_mode=_normalize_wpm_mode(cfg.get("wpm_mode", "auto")), + wpm_lock=_coerce_bool(cfg.get("wpm_lock", False), False), + min_signal_gate=float(cfg.get("min_signal_gate", 0.0) or 0.0), + detect_mode=str(cfg.get("detect_mode", "goertzel")), ) last_scope = time.monotonic() @@ -956,6 +1009,7 @@ def morse_decoder_thread( raw_queue: queue.Queue[bytes] = queue.Queue(maxsize=96) try: + def _reader_loop() -> None: """Blocking PCM reader isolated from decode/control loop.""" nonlocal first_raw_logged @@ -970,16 +1024,18 @@ def morse_decoder_thread( if not ready: continue data = os.read(fd, CHUNK) - elif hasattr(rtl_stdout, 'read1'): + elif hasattr(rtl_stdout, "read1"): data = rtl_stdout.read1(CHUNK) else: data = rtl_stdout.read(CHUNK) except Exception as e: with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'info', - 'text': f'[pcm] reader error: {e}', - }) + output_queue.put_nowait( + { + "type": "info", + "text": f"[pcm] reader error: {e}", + } + ) break if data is None: @@ -993,26 +1049,30 @@ def morse_decoder_thread( if stream_ready_event is not None: stream_ready_event.set() with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'info', - 'text': f'[pcm] first raw chunk: {len(data)} bytes', - }) + output_queue.put_nowait( + { + "type": "info", + "text": f"[pcm] first raw chunk: {len(data)} bytes", + } + ) if strip_text_chunks and _is_probably_rtl_log_text(data): try: - text = data.decode('utf-8', errors='replace') + text = data.decode("utf-8", errors="replace") except Exception: - text = '' + text = "" if text: for line in text.splitlines(): clean = line.strip() if not clean: continue with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'info', - 'text': f'[rtl_fm] {clean}', - }) + output_queue.put_nowait( + { + "type": "info", + "text": f"[rtl_fm] {clean}", + } + ) continue try: @@ -1026,12 +1086,12 @@ def morse_decoder_thread( finally: reader_done.set() with contextlib.suppress(queue.Full): - raw_queue.put_nowait(b'') + raw_queue.put_nowait(b"") reader_thread = threading.Thread( target=_reader_loop, daemon=True, - name='morse-pcm-reader', + name="morse-pcm-reader", ) reader_thread.start() @@ -1060,10 +1120,12 @@ def morse_decoder_thread( if not data: if reader_done.is_set() and last_pcm_at is None: with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'info', - 'text': '[pcm] stream ended before samples were received', - }) + output_queue.put_nowait( + { + "type": "info", + "text": "[pcm] stream ended before samples were received", + } + ) break waiting_since = None @@ -1075,14 +1137,16 @@ def morse_decoder_thread( if pcm_ready_event is not None: pcm_ready_event.set() with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'info', - 'text': f'[pcm] first chunk: {len(data)} bytes', - }) + output_queue.put_nowait( + { + "type": "info", + "text": f"[pcm] first chunk: {len(data)} bytes", + } + ) events = decoder.process_block(data) for event in events: - if event.get('type') == 'scope': + if event.get("type") == "scope": now = time.monotonic() if now - last_scope >= SCOPE_INTERVAL: last_scope = now @@ -1096,20 +1160,24 @@ def morse_decoder_thread( if (now - pcm_report_at) >= 1.0: kbps = (pcm_bytes * 8.0) / max(1e-6, (now - pcm_report_at)) / 1000.0 with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'info', - 'text': f'[pcm] {pcm_bytes} B in {now - pcm_report_at:.1f}s ({kbps:.1f} kbps)', - }) + output_queue.put_nowait( + { + "type": "info", + "text": f"[pcm] {pcm_bytes} B in {now - pcm_report_at:.1f}s ({kbps:.1f} kbps)", + } + ) pcm_bytes = 0 pcm_report_at = now except Exception as e: # pragma: no cover - defensive runtime guard - logger.debug(f'Morse decoder thread error: {e}') + logger.debug(f"Morse decoder thread error: {e}") with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'info', - 'text': f'[pcm] decoder thread error: {e}', - }) + output_queue.put_nowait( + { + "type": "info", + "text": f"[pcm] decoder thread error: {e}", + } + ) finally: stop_event.set() if reader_thread is not None: @@ -1120,11 +1188,13 @@ def morse_decoder_thread( output_queue.put_nowait(event) with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'status', - 'status': 'stopped', - 'metrics': decoder.get_metrics(), - }) + output_queue.put_nowait( + { + "type": "status", + "status": "stopped", + "metrics": decoder.get_metrics(), + } + ) def _cu8_to_complex(raw: bytes) -> np.ndarray: @@ -1147,7 +1217,7 @@ def _iq_usb_to_pcm16( ) -> bytes: """Minimal USB demod from complex IQ to 16-bit PCM.""" if iq_samples.size < 16 or iq_sample_rate <= 0 or audio_sample_rate <= 0: - return b'' + return b"" audio = np.real(iq_samples).astype(np.float64) audio -= float(np.mean(audio)) @@ -1157,21 +1227,21 @@ def _iq_usb_to_pcm16( if decim > 1: usable = (audio.size // decim) * decim if usable < decim: - return b'' + return b"" audio = audio[:usable].reshape(-1, decim).mean(axis=1) fs1 = float(iq_sample_rate) / float(decim) if audio.size < 8: - return b'' + return b"" taps = int(max(1, min(31, fs1 / 12000.0))) if taps > 1: kernel = np.ones(taps, dtype=np.float64) / float(taps) - audio = np.convolve(audio, kernel, mode='same') + audio = np.convolve(audio, kernel, mode="same") if abs(fs1 - float(audio_sample_rate)) > 1.0: out_len = int(audio.size * float(audio_sample_rate) / fs1) if out_len < 8: - return b'' + return b"" x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float64) x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float64) audio = np.interp(x_new, x_old, audio) @@ -1199,7 +1269,8 @@ def morse_iq_decoder_thread( ) -> None: """Decode Morse from raw IQ (cu8) by in-process USB demodulation.""" import logging - logger = logging.getLogger('intercept.morse') + + logger = logging.getLogger("intercept.morse") CHUNK = 65536 SCOPE_INTERVAL = 0.10 @@ -1208,20 +1279,20 @@ def morse_iq_decoder_thread( cfg = dict(decoder_config or {}) decoder = MorseDecoder( - sample_rate=int(cfg.get('sample_rate', sample_rate)), - tone_freq=float(cfg.get('tone_freq', tone_freq)), - wpm=int(cfg.get('wpm', wpm)), - bandwidth_hz=int(cfg.get('bandwidth_hz', 200)), - auto_tone_track=_coerce_bool(cfg.get('auto_tone_track', True), True), - tone_lock=_coerce_bool(cfg.get('tone_lock', False), False), - threshold_mode=_normalize_threshold_mode(cfg.get('threshold_mode', 'auto')), - manual_threshold=float(cfg.get('manual_threshold', 0.0) or 0.0), - threshold_multiplier=float(cfg.get('threshold_multiplier', 2.8) or 2.8), - threshold_offset=float(cfg.get('threshold_offset', 0.0) or 0.0), - wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')), - wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False), - min_signal_gate=float(cfg.get('min_signal_gate', 0.0) or 0.0), - detect_mode=str(cfg.get('detect_mode', 'goertzel')), + sample_rate=int(cfg.get("sample_rate", sample_rate)), + tone_freq=float(cfg.get("tone_freq", tone_freq)), + wpm=int(cfg.get("wpm", wpm)), + bandwidth_hz=int(cfg.get("bandwidth_hz", 200)), + auto_tone_track=_coerce_bool(cfg.get("auto_tone_track", True), True), + tone_lock=_coerce_bool(cfg.get("tone_lock", False), False), + threshold_mode=_normalize_threshold_mode(cfg.get("threshold_mode", "auto")), + manual_threshold=float(cfg.get("manual_threshold", 0.0) or 0.0), + threshold_multiplier=float(cfg.get("threshold_multiplier", 2.8) or 2.8), + threshold_offset=float(cfg.get("threshold_offset", 0.0) or 0.0), + wpm_mode=_normalize_wpm_mode(cfg.get("wpm_mode", "auto")), + wpm_lock=_coerce_bool(cfg.get("wpm_lock", False), False), + min_signal_gate=float(cfg.get("min_signal_gate", 0.0) or 0.0), + detect_mode=str(cfg.get("detect_mode", "goertzel")), ) last_scope = time.monotonic() @@ -1238,6 +1309,7 @@ def morse_iq_decoder_thread( raw_queue: queue.Queue[bytes] = queue.Queue(maxsize=96) try: + def _reader_loop() -> None: nonlocal first_raw_logged try: @@ -1251,16 +1323,18 @@ def morse_iq_decoder_thread( if not ready: continue data = os.read(fd, CHUNK) - elif hasattr(iq_stdout, 'read1'): + elif hasattr(iq_stdout, "read1"): data = iq_stdout.read1(CHUNK) else: data = iq_stdout.read(CHUNK) except Exception as e: with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'info', - 'text': f'[iq] reader error: {e}', - }) + output_queue.put_nowait( + { + "type": "info", + "text": f"[iq] reader error: {e}", + } + ) break if data is None: @@ -1273,10 +1347,12 @@ def morse_iq_decoder_thread( if stream_ready_event is not None: stream_ready_event.set() with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'info', - 'text': f'[iq] first raw chunk: {len(data)} bytes', - }) + output_queue.put_nowait( + { + "type": "info", + "text": f"[iq] first raw chunk: {len(data)} bytes", + } + ) try: raw_queue.put(data, timeout=0.2) @@ -1288,12 +1364,12 @@ def morse_iq_decoder_thread( finally: reader_done.set() with contextlib.suppress(queue.Full): - raw_queue.put_nowait(b'') + raw_queue.put_nowait(b"") reader_thread = threading.Thread( target=_reader_loop, daemon=True, - name='morse-iq-reader', + name="morse-iq-reader", ) reader_thread.start() @@ -1322,10 +1398,12 @@ def morse_iq_decoder_thread( if not raw: if reader_done.is_set() and last_pcm_at is None: with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'info', - 'text': '[iq] stream ended before samples were received', - }) + output_queue.put_nowait( + { + "type": "info", + "text": "[iq] stream ended before samples were received", + } + ) break iq = _cu8_to_complex(raw) @@ -1346,14 +1424,16 @@ def morse_iq_decoder_thread( if pcm_ready_event is not None: pcm_ready_event.set() with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'info', - 'text': f'[pcm] first IQ demod chunk: {len(pcm)} bytes', - }) + output_queue.put_nowait( + { + "type": "info", + "text": f"[pcm] first IQ demod chunk: {len(pcm)} bytes", + } + ) events = decoder.process_block(pcm) for event in events: - if event.get('type') == 'scope': + if event.get("type") == "scope": now = time.monotonic() if now - last_scope >= SCOPE_INTERVAL: last_scope = now @@ -1367,20 +1447,24 @@ def morse_iq_decoder_thread( if (now - pcm_report_at) >= 1.0: kbps = (pcm_bytes * 8.0) / max(1e-6, (now - pcm_report_at)) / 1000.0 with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'info', - 'text': f'[pcm] {pcm_bytes} B in {now - pcm_report_at:.1f}s ({kbps:.1f} kbps)', - }) + output_queue.put_nowait( + { + "type": "info", + "text": f"[pcm] {pcm_bytes} B in {now - pcm_report_at:.1f}s ({kbps:.1f} kbps)", + } + ) pcm_bytes = 0 pcm_report_at = now except Exception as e: # pragma: no cover - runtime safety - logger.debug(f'Morse IQ decoder thread error: {e}') + logger.debug(f"Morse IQ decoder thread error: {e}") with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'info', - 'text': f'[iq] decoder thread error: {e}', - }) + output_queue.put_nowait( + { + "type": "info", + "text": f"[iq] decoder thread error: {e}", + } + ) finally: stop_event.set() if reader_thread is not None: @@ -1391,8 +1475,10 @@ def morse_iq_decoder_thread( output_queue.put_nowait(event) with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'status', - 'status': 'stopped', - 'metrics': decoder.get_metrics(), - }) + output_queue.put_nowait( + { + "type": "status", + "status": "stopped", + "metrics": decoder.get_metrics(), + } + ) diff --git a/utils/ook.py b/utils/ook.py index 4ff1592..2058739 100644 --- a/utils/ook.py +++ b/utils/ook.py @@ -26,7 +26,7 @@ import threading from datetime import datetime from typing import Any -logger = logging.getLogger('intercept.ook') +logger = logging.getLogger("intercept.ook") def decode_ook_frame(hex_data: str) -> dict[str, Any] | None: @@ -46,9 +46,9 @@ def decode_ook_frame(hex_data: str) -> dict[str, Any] | None: ``byte_count``, and ``bit_count``, or ``None`` on parse failure. """ try: - cleaned = hex_data.replace(' ', '') + cleaned = hex_data.replace(" ", "") # rtl_433 flex decoder prefixes hex with '0x' — strip it - if cleaned.startswith(('0x', '0X')): + if cleaned.startswith(("0x", "0X")): cleaned = cleaned[2:] raw = bytes.fromhex(cleaned) except ValueError: @@ -58,13 +58,13 @@ def decode_ook_frame(hex_data: str) -> dict[str, Any] | None: return None # Expand bytes to MSB-first bit string - bits = ''.join(f'{b:08b}' for b in raw) + bits = "".join(f"{b:08b}" for b in raw) return { - 'bits': bits, - 'hex': raw.hex(), - 'byte_count': len(raw), - 'bit_count': len(bits), + "bits": bits, + "hex": raw.hex(), + "byte_count": len(raw), + "bit_count": len(bits), } @@ -72,7 +72,7 @@ def ook_parser_thread( rtl_stdout, output_queue: queue.Queue, stop_event: threading.Event, - encoding: str = 'pwm', + encoding: str = "pwm", deduplicate: bool = False, ) -> None: """Thread function: reads rtl_433 JSON output and emits OOK frame events. @@ -99,39 +99,39 @@ def ook_parser_thread( last_hex: str | None = None try: - for line in iter(rtl_stdout.readline, b''): + for line in iter(rtl_stdout.readline, b""): if stop_event.is_set(): break - text = line.decode('utf-8', errors='replace').strip() + text = line.decode("utf-8", errors="replace").strip() if not text: continue try: data = json.loads(text) except json.JSONDecodeError: - logger.debug(f'[rtl_433/ook] {text}') + logger.debug(f"[rtl_433/ook] {text}") continue # rtl_433 flex decoder puts hex in 'codes' (list or string), # 'code' (singular), or 'data' depending on version. - codes = data.get('codes') + codes = data.get("codes") if codes is not None and isinstance(codes, str): codes = [codes] if codes else None if not codes: - code = data.get('code') + code = data.get("code") if code: codes = [str(code)] if not codes: - raw_data = data.get('data') + raw_data = data.get("data") if raw_data: codes = [str(raw_data)] # Extract signal level if rtl_433 was invoked with -M level rssi: float | None = None - for _rssi_key in ('snr', 'rssi', 'level', 'noise'): + for _rssi_key in ("snr", "rssi", "level", "noise"): _rssi_val = data.get(_rssi_key) if _rssi_val is not None: with contextlib.suppress(TypeError, ValueError): @@ -139,61 +139,61 @@ def ook_parser_thread( break if not codes: - logger.warning( - f'[rtl_433/ook] no code field — keys: {list(data.keys())}' - ) + logger.warning(f"[rtl_433/ook] no code field — keys: {list(data.keys())}") with contextlib.suppress(queue.Full): - output_queue.put_nowait({ - 'type': 'ook_raw', - 'data': data, - 'timestamp': datetime.now().strftime('%H:%M:%S'), - }) + output_queue.put_nowait( + { + "type": "ook_raw", + "data": data, + "timestamp": datetime.now().strftime("%H:%M:%S"), + } + ) continue for code_hex in codes: hex_str = str(code_hex).strip() # Strip leading {N} bit-count prefix if present - if hex_str.startswith('{'): - brace_end = hex_str.find('}') + if hex_str.startswith("{"): + brace_end = hex_str.find("}") if brace_end >= 0: - hex_str = hex_str[brace_end + 1:] + hex_str = hex_str[brace_end + 1 :] frame = decode_ook_frame(hex_str) if frame is None: continue - timestamp = datetime.now().strftime('%H:%M:%S') + timestamp = datetime.now().strftime("%H:%M:%S") # Deduplication: skip if identical to last frame - is_dup = deduplicate and frame['hex'] == last_hex - last_hex = frame['hex'] + is_dup = deduplicate and frame["hex"] == last_hex + last_hex = frame["hex"] if deduplicate and is_dup: continue try: event: dict[str, Any] = { - 'type': 'ook_frame', - 'hex': frame['hex'], - 'bits': frame['bits'], - 'byte_count': frame['byte_count'], - 'bit_count': frame['bit_count'], - 'inverted': False, - 'encoding': encoding, - 'timestamp': timestamp, + "type": "ook_frame", + "hex": frame["hex"], + "bits": frame["bits"], + "byte_count": frame["byte_count"], + "bit_count": frame["bit_count"], + "inverted": False, + "encoding": encoding, + "timestamp": timestamp, } if rssi is not None: - event['rssi'] = rssi + event["rssi"] = rssi output_queue.put_nowait(event) except queue.Full: pass except Exception as e: - logger.warning(f'OOK parser thread error: {e}') + logger.warning(f"OOK parser thread error: {e}") with contextlib.suppress(queue.Full): - output_queue.put_nowait({'type': 'error', 'text': str(e)}) + output_queue.put_nowait({"type": "error", "text": str(e)}) # Notify frontend that the parser has stopped (covers both normal exit # and unexpected rtl_433 crashes so the UI doesn't stay in "Listening"). with contextlib.suppress(queue.Full): - output_queue.put_nowait({'type': 'status', 'text': 'stopped'}) + output_queue.put_nowait({"type": "status", "text": "stopped"}) diff --git a/utils/process.py b/utils/process.py index a521658..d2a26ea 100644 --- a/utils/process.py +++ b/utils/process.py @@ -15,7 +15,7 @@ from typing import Any from .dependencies import check_tool -logger = logging.getLogger('intercept.process') +logger = logging.getLogger("intercept.process") # Track all spawned processes for cleanup _spawned_processes: list[subprocess.Popen] = [] @@ -62,6 +62,7 @@ def cleanup_all_processes() -> None: # Stop DataStore cleanup timers and run final cleanup try: from utils.cleanup import cleanup_manager + cleanup_manager.cleanup_now() cleanup_manager.stop() except Exception as e: @@ -110,6 +111,7 @@ def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bo # Register cleanup handlers atexit.register(cleanup_all_processes) + # Handle signals for graceful shutdown def _signal_handler(signum, frame): """Handle termination signals. @@ -119,6 +121,7 @@ def _signal_handler(signum, frame): Process cleanup is handled by the atexit handler registered above. """ import sys + if signum == signal.SIGINT: raise KeyboardInterrupt() sys.exit(0) @@ -131,6 +134,7 @@ def _is_under_gunicorn(): """Check if we're running inside a gunicorn worker.""" try: import gunicorn.arbiter # noqa: F401 + # If gunicorn is importable AND we were invoked via gunicorn, the # arbiter will have installed its own signal handlers already. # Check the current SIGTERM handler — if it's not the default, @@ -140,6 +144,7 @@ def _is_under_gunicorn(): except ImportError: return False + if not _is_under_gunicorn(): try: signal.signal(signal.SIGTERM, _signal_handler) @@ -152,13 +157,13 @@ if not _is_under_gunicorn(): def cleanup_stale_processes() -> None: """Kill any stale processes from previous runs (but not system services).""" # Note: dump1090 is NOT included here as users may run it as a system service - processes_to_kill = ['rtl_adsb', 'rtl_433', 'multimon-ng', 'rtl_fm'] + processes_to_kill = ["rtl_adsb", "rtl_433", "multimon-ng", "rtl_fm"] for proc_name in processes_to_kill: with contextlib.suppress(subprocess.SubprocessError, OSError): - subprocess.run(['pkill', '-9', proc_name], capture_output=True) + subprocess.run(["pkill", "-9", proc_name], capture_output=True) -_DUMP1090_PID_FILE = Path(__file__).resolve().parent.parent / 'instance' / 'dump1090.pid' +_DUMP1090_PID_FILE = Path(__file__).resolve().parent.parent / "instance" / "dump1090.pid" def write_dump1090_pid(pid: int) -> None: @@ -183,18 +188,15 @@ def clear_dump1090_pid() -> None: def _is_dump1090_process(pid: int) -> bool: """Check if the given PID is actually a dump1090/readsb process.""" try: - if platform.system() == 'Linux': - cmdline_path = Path(f'/proc/{pid}/cmdline') + if platform.system() == "Linux": + cmdline_path = Path(f"/proc/{pid}/cmdline") if cmdline_path.exists(): - cmdline = cmdline_path.read_bytes().replace(b'\x00', b' ').decode('utf-8', errors='ignore') - return 'dump1090' in cmdline or 'readsb' in cmdline + cmdline = cmdline_path.read_bytes().replace(b"\x00", b" ").decode("utf-8", errors="ignore") + return "dump1090" in cmdline or "readsb" in cmdline # macOS or fallback - result = subprocess.run( - ['ps', '-p', str(pid), '-o', 'comm='], - capture_output=True, text=True, timeout=5 - ) + result = subprocess.run(["ps", "-p", str(pid), "-o", "comm="], capture_output=True, text=True, timeout=5) comm = result.stdout.strip() - return 'dump1090' in comm or 'readsb' in comm + return "dump1090" in comm or "readsb" in comm except Exception: return False @@ -247,7 +249,7 @@ def is_valid_mac(mac: str | None) -> bool: """Validate MAC address format.""" if not mac: return False - return bool(re.match(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$', mac)) + return bool(re.match(r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", mac)) def is_valid_channel(channel: str | int | None) -> bool: @@ -263,41 +265,34 @@ def detect_devices() -> list[dict[str, Any]]: """Detect RTL-SDR devices.""" devices: list[dict[str, Any]] = [] - if not check_tool('rtl_test'): + if not check_tool("rtl_test"): return devices try: - result = subprocess.run( - ['rtl_test', '-t'], - capture_output=True, - text=True, - timeout=5 - ) + result = subprocess.run(["rtl_test", "-t"], capture_output=True, text=True, timeout=5) output = result.stderr + result.stdout # Parse device info - device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$' + device_pattern = r"(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$" - for line in output.split('\n'): + for line in output.split("\n"): line = line.strip() match = re.match(device_pattern, line) if match: - devices.append({ - 'index': int(match.group(1)), - 'name': match.group(2).strip().rstrip(','), - 'serial': match.group(3) or 'N/A' - }) + devices.append( + { + "index": int(match.group(1)), + "name": match.group(2).strip().rstrip(","), + "serial": match.group(3) or "N/A", + } + ) if not devices: - found_match = re.search(r'Found (\d+) device', output) + found_match = re.search(r"Found (\d+) device", output) if found_match: count = int(found_match.group(1)) for i in range(count): - devices.append({ - 'index': i, - 'name': f'RTL-SDR Device {i}', - 'serial': 'Unknown' - }) + devices.append({"index": i, "name": f"RTL-SDR Device {i}", "serial": "Unknown"}) except Exception: pass diff --git a/utils/process_monitor.py b/utils/process_monitor.py index 4f7cb3f..bc3df95 100644 --- a/utils/process_monitor.py +++ b/utils/process_monitor.py @@ -11,12 +11,13 @@ from dataclasses import dataclass, field from datetime import datetime from typing import Any, Callable -logger = logging.getLogger('intercept.process_monitor') +logger = logging.getLogger("intercept.process_monitor") @dataclass class ProcessInfo: """Information about a monitored process.""" + name: str process: Any # subprocess.Popen started_at: datetime = field(default_factory=datetime.now) @@ -51,7 +52,7 @@ class ProcessMonitor: process: Any, restart_callback: Callable | None = None, max_restarts: int = 3, - backoff_seconds: float = 5.0 + backoff_seconds: float = 5.0, ) -> None: """ Register a process for monitoring. @@ -69,7 +70,7 @@ class ProcessMonitor: process=process, restart_callback=restart_callback, max_restarts=max_restarts, - backoff_seconds=backoff_seconds + backoff_seconds=backoff_seconds, ) logger.info(f"Registered process for monitoring: {name}") @@ -125,9 +126,7 @@ class ProcessMonitor: # Check if process has terminated return_code = info.process.poll() if return_code is not None: - logger.warning( - f"Process '{name}' terminated with code {return_code}" - ) + logger.warning(f"Process '{name}' terminated with code {return_code}") crashed.append((name, info)) # Handle restarts outside lock (involves sleeps and callbacks) @@ -141,19 +140,15 @@ class ProcessMonitor: return if info.restart_count >= info.max_restarts: - logger.error( - f"Process '{name}' exceeded max restarts ({info.max_restarts}), " - "disabling auto-restart" - ) + logger.error(f"Process '{name}' exceeded max restarts ({info.max_restarts}), disabling auto-restart") with self._lock: info.enabled = False return # Calculate backoff with exponential increase - backoff = info.backoff_seconds * (2 ** info.restart_count) + backoff = info.backoff_seconds * (2**info.restart_count) logger.info( - f"Attempting to restart '{name}' in {backoff:.1f}s " - f"(attempt {info.restart_count + 1}/{info.max_restarts})" + f"Attempting to restart '{name}' in {backoff:.1f}s (attempt {info.restart_count + 1}/{info.max_restarts})" ) # Wait for backoff period outside lock @@ -181,17 +176,14 @@ class ProcessMonitor: with self._lock: status = {} for name, info in self.processes.items(): - is_running = ( - info.process is not None and - info.process.poll() is None - ) + is_running = info.process is not None and info.process.poll() is None status[name] = { - 'running': is_running, - 'started_at': info.started_at.isoformat() if info.started_at else None, - 'restart_count': info.restart_count, - 'last_restart': info.last_restart.isoformat() if info.last_restart else None, - 'auto_restart_enabled': info.enabled, - 'return_code': info.process.poll() if info.process else None + "running": is_running, + "started_at": info.started_at.isoformat() if info.started_at else None, + "restart_count": info.restart_count, + "last_restart": info.last_restart.isoformat() if info.last_restart else None, + "auto_restart_enabled": info.enabled, + "return_code": info.process.poll() if info.process else None, } return status diff --git a/utils/recording.py b/utils/recording.py index dc8ca79..23723d1 100644 --- a/utils/recording.py +++ b/utils/recording.py @@ -13,9 +13,9 @@ from typing import Any from utils.database import get_db -logger = logging.getLogger('intercept.recording') +logger = logging.getLogger("intercept.recording") -RECORDING_ROOT = Path(__file__).parent.parent / 'instance' / 'recordings' +RECORDING_ROOT = Path(__file__).parent.parent / "instance" / "recordings" @dataclass @@ -35,7 +35,7 @@ class RecordingSession: def open(self) -> None: self.file_path.parent.mkdir(parents=True, exist_ok=True) - self._file_handle = self.file_path.open('a', encoding='utf-8') + self._file_handle = self.file_path.open("a", encoding="utf-8") def close(self) -> None: if self._file_handle: @@ -46,12 +46,12 @@ class RecordingSession: def write_event(self, record: dict) -> None: if not self._file_handle: self.open() - line = json.dumps(record, ensure_ascii=True) + '\n' + line = json.dumps(record, ensure_ascii=True) + "\n" with self._lock: self._file_handle.write(line) self._file_handle.flush() self.event_count += 1 - self.size_bytes += len(line.encode('utf-8')) + self.size_bytes += len(line.encode("utf-8")) class RecordingManager: @@ -85,20 +85,23 @@ class RecordingManager: self._active_by_id[session_id] = session with get_db() as conn: - conn.execute(''' + conn.execute( + """ INSERT INTO recording_sessions (id, mode, label, started_at, file_path, event_count, size_bytes, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - session.id, - session.mode, - session.label, - session.started_at.isoformat(), - str(session.file_path), - session.event_count, - session.size_bytes, - json.dumps(session.metadata or {}), - )) + """, + ( + session.id, + session.mode, + session.label, + session.started_at.isoformat(), + str(session.file_path), + session.event_count, + session.size_bytes, + json.dumps(session.metadata or {}), + ), + ) return session @@ -120,30 +123,33 @@ class RecordingManager: self._active_by_id.pop(session.id, None) with get_db() as conn: - conn.execute(''' + conn.execute( + """ UPDATE recording_sessions SET stopped_at = ?, event_count = ?, size_bytes = ? WHERE id = ? - ''', ( - session.stopped_at.isoformat(), - session.event_count, - session.size_bytes, - session.id, - )) + """, + ( + session.stopped_at.isoformat(), + session.event_count, + session.size_bytes, + session.id, + ), + ) return session def record_event(self, mode: str, event: dict, event_type: str | None = None) -> None: - if event_type in ('keepalive', 'ping'): + if event_type in ("keepalive", "ping"): return session = self._active_by_mode.get(mode) if not session: return record = { - 'timestamp': datetime.now(timezone.utc).isoformat(), - 'mode': mode, - 'event_type': event_type, - 'event': event, + "timestamp": datetime.now(timezone.utc).isoformat(), + "mode": mode, + "event_type": event_type, + "event": event, } try: session.write_event(record) @@ -152,61 +158,71 @@ class RecordingManager: def list_recordings(self, limit: int = 50) -> list[dict]: with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT id, mode, label, started_at, stopped_at, file_path, event_count, size_bytes, metadata FROM recording_sessions ORDER BY started_at DESC LIMIT ? - ''', (limit,)) + """, + (limit,), + ) rows = [] for row in cursor: - rows.append({ - 'id': row['id'], - 'mode': row['mode'], - 'label': row['label'], - 'started_at': row['started_at'], - 'stopped_at': row['stopped_at'], - 'file_path': row['file_path'], - 'event_count': row['event_count'], - 'size_bytes': row['size_bytes'], - 'metadata': json.loads(row['metadata']) if row['metadata'] else {}, - }) + rows.append( + { + "id": row["id"], + "mode": row["mode"], + "label": row["label"], + "started_at": row["started_at"], + "stopped_at": row["stopped_at"], + "file_path": row["file_path"], + "event_count": row["event_count"], + "size_bytes": row["size_bytes"], + "metadata": json.loads(row["metadata"]) if row["metadata"] else {}, + } + ) return rows def get_recording(self, session_id: str) -> dict | None: with get_db() as conn: - cursor = conn.execute(''' + cursor = conn.execute( + """ SELECT id, mode, label, started_at, stopped_at, file_path, event_count, size_bytes, metadata FROM recording_sessions WHERE id = ? - ''', (session_id,)) + """, + (session_id,), + ) row = cursor.fetchone() if not row: return None return { - 'id': row['id'], - 'mode': row['mode'], - 'label': row['label'], - 'started_at': row['started_at'], - 'stopped_at': row['stopped_at'], - 'file_path': row['file_path'], - 'event_count': row['event_count'], - 'size_bytes': row['size_bytes'], - 'metadata': json.loads(row['metadata']) if row['metadata'] else {}, + "id": row["id"], + "mode": row["mode"], + "label": row["label"], + "started_at": row["started_at"], + "stopped_at": row["stopped_at"], + "file_path": row["file_path"], + "event_count": row["event_count"], + "size_bytes": row["size_bytes"], + "metadata": json.loads(row["metadata"]) if row["metadata"] else {}, } def get_active(self) -> list[dict]: with self._lock: sessions = [] for session in self._active_by_mode.values(): - sessions.append({ - 'id': session.id, - 'mode': session.mode, - 'label': session.label, - 'started_at': session.started_at.isoformat(), - 'event_count': session.event_count, - 'size_bytes': session.size_bytes, - }) + sessions.append( + { + "id": session.id, + "mode": session.mode, + "label": session.label, + "started_at": session.started_at.isoformat(), + "event_count": session.event_count, + "size_bytes": session.size_bytes, + } + ) return sessions diff --git a/utils/responses.py b/utils/responses.py index eb1e964..dc6e812 100644 --- a/utils/responses.py +++ b/utils/responses.py @@ -15,9 +15,9 @@ def api_success(data=None, message=None, status_code=200): message: Optional human-readable message. status_code: HTTP status code (default 200). """ - payload = {'status': 'success'} + payload = {"status": "success"} if message: - payload['message'] = message + payload["message"] = message if data: payload.update(data) return jsonify(payload), status_code @@ -31,7 +31,7 @@ def api_error(message, status_code=400, error_type=None): status_code: HTTP status code (default 400). error_type: Optional machine-readable error category (e.g. 'DEVICE_BUSY'). """ - payload = {'status': 'error', 'message': message} + payload = {"status": "error", "message": message} if error_type: - payload['error_type'] = error_type + payload["error_type"] = error_type return jsonify(payload), status_code diff --git a/utils/rotator.py b/utils/rotator.py index 4ed1a7d..26cdf9f 100644 --- a/utils/rotator.py +++ b/utils/rotator.py @@ -25,9 +25,9 @@ import threading from utils.logging import get_logger -logger = get_logger('intercept.rotator') +logger = get_logger("intercept.rotator") -DEFAULT_HOST = '127.0.0.1' +DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 4533 DEFAULT_TIMEOUT = 2.0 # seconds @@ -92,7 +92,7 @@ class RotatorController: az = max(0.0, min(360.0, float(az))) el = max(0.0, min(90.0, float(el))) - ok = self._send_command(f'P {az:.1f} {el:.1f}') + ok = self._send_command(f"P {az:.1f} {el:.1f}") if ok: self._current_az = az self._current_el = el @@ -108,9 +108,9 @@ class RotatorController: if not self._enabled or self._sock is None: return None try: - self._sock.sendall(b'p\n') + self._sock.sendall(b"p\n") resp = self._recv_line() - if resp and 'RPRT' not in resp: + if resp and "RPRT" not in resp: parts = resp.split() if len(parts) >= 2: return float(parts[0]), float(parts[1]) @@ -130,11 +130,11 @@ class RotatorController: def get_status(self) -> dict: return { - 'enabled': self._enabled, - 'host': self._host, - 'port': self._port, - 'current_az': self._current_az, - 'current_el': self._current_el, + "enabled": self._enabled, + "host": self._host, + "port": self._port, + "current_az": self._current_az, + "current_el": self._current_el, } # ------------------------------------------------------------------ @@ -146,9 +146,9 @@ class RotatorController: if not self._enabled or self._sock is None: return False try: - self._sock.sendall((cmd + '\n').encode()) + self._sock.sendall((cmd + "\n").encode()) resp = self._recv_line() - if resp and 'RPRT 0' in resp: + if resp and "RPRT 0" in resp: return True logger.warning(f"Rotator unexpected response to '{cmd}': {resp!r}") return False @@ -164,16 +164,16 @@ class RotatorController: def _recv_line(self, max_bytes: int = 256) -> str: """Read until newline (already holding _lock).""" - buf = b'' + buf = b"" assert self._sock is not None while len(buf) < max_bytes: c = self._sock.recv(1) if not c: break buf += c - if c == b'\n': + if c == b"\n": break - return buf.decode('ascii', errors='replace').strip() + return buf.decode("ascii", errors="replace").strip() # --------------------------------------------------------------------------- diff --git a/utils/satellite_predict.py b/utils/satellite_predict.py index ff4f8fd..f37a06c 100644 --- a/utils/satellite_predict.py +++ b/utils/satellite_predict.py @@ -10,15 +10,15 @@ from typing import Any from utils.logging import get_logger -logger = get_logger('intercept.satellite_predict') +logger = get_logger("intercept.satellite_predict") def predict_passes( tle_data: tuple, - observer, # skyfield wgs84.latlon object - ts, # skyfield timescale - t0, # skyfield Time start - t1, # skyfield Time end + observer, # skyfield wgs84.latlon object + ts, # skyfield timescale + t0, # skyfield Time start + t1, # skyfield Time end min_el: float = 10.0, include_trajectory: bool = True, include_ground_track: bool = True, @@ -56,9 +56,7 @@ def predict_passes( ndot_str = line1[33:43].strip() ndot = float(ndot_str) if abs(ndot) > 0.01: - logger.debug( - 'Skipping decaying satellite %s (ndot=%s)', tle_data[0], ndot - ) + logger.debug("Skipping decaying satellite %s (ndot=%s)", tle_data[0], ndot) return [] except (ValueError, IndexError): # Don't skip on parse error @@ -68,17 +66,15 @@ def predict_passes( try: satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts) except Exception as exc: - logger.debug('Failed to create EarthSatellite for %s: %s', tle_data[0], exc) + logger.debug("Failed to create EarthSatellite for %s: %s", tle_data[0], exc) return [] # Find events using Skyfield's native find_events() # Event types: 0=AOS, 1=TCA, 2=LOS try: - times, events = satellite.find_events( - observer, t0, t1, altitude_degrees=min_el - ) + times, events = satellite.find_events(observer, t0, t1, altitude_degrees=min_el) except Exception as exc: - logger.debug('find_events failed for %s: %s', tle_data[0], exc) + logger.debug("find_events failed for %s: %s", tle_data[0], exc) return [] # Group events into AOS->TCA->LOS triplets @@ -138,21 +134,21 @@ def predict_passes( duration = (los_dt - aos_dt).total_seconds() / 60.0 pass_dict: dict[str, Any] = { - 'aosTime': aos_dt.isoformat(), - 'aosAz': round(float(aos_az.degrees), 1), - 'aosEl': round(float(aos_alt.degrees), 1), - 'tcaTime': tca_dt.isoformat(), - 'tcaAz': round(float(tca_az.degrees), 1), - 'tcaEl': round(float(tca_alt.degrees), 1), - 'losTime': los_dt.isoformat(), - 'losAz': round(float(los_az.degrees), 1), - 'losEl': round(float(los_alt.degrees), 1), - 'duration': round(duration, 1), + "aosTime": aos_dt.isoformat(), + "aosAz": round(float(aos_az.degrees), 1), + "aosEl": round(float(aos_alt.degrees), 1), + "tcaTime": tca_dt.isoformat(), + "tcaAz": round(float(tca_az.degrees), 1), + "tcaEl": round(float(tca_alt.degrees), 1), + "losTime": los_dt.isoformat(), + "losAz": round(float(los_az.degrees), 1), + "losEl": round(float(los_alt.degrees), 1), + "duration": round(duration, 1), # Backwards-compatible fields - 'startTime': aos_dt.strftime('%Y-%m-%d %H:%M UTC'), - 'startTimeISO': aos_dt.isoformat(), - 'endTimeISO': los_dt.isoformat(), - 'maxEl': round(float(tca_alt.degrees), 1), + "startTime": aos_dt.strftime("%Y-%m-%d %H:%M UTC"), + "startTimeISO": aos_dt.isoformat(), + "endTimeISO": los_dt.isoformat(), + "maxEl": round(float(tca_alt.degrees), 1), } # Build 30-point az/el trajectory for polar plot @@ -160,49 +156,43 @@ def predict_passes( trajectory = [] for step in range(30): frac = step / 29.0 - t_pt = ts.tt_jd( - aos_time.tt + frac * (los_time.tt - aos_time.tt) - ) + t_pt = ts.tt_jd(aos_time.tt + frac * (los_time.tt - aos_time.tt)) try: pt_alt, pt_az, _ = (satellite - observer).at(t_pt).altaz() - trajectory.append({ - 'az': round(float(pt_az.degrees), 1), - 'el': round(float(max(0.0, pt_alt.degrees)), 1), - }) - except Exception as pt_exc: - logger.debug( - 'Trajectory point error for %s: %s', tle_data[0], pt_exc + trajectory.append( + { + "az": round(float(pt_az.degrees), 1), + "el": round(float(max(0.0, pt_alt.degrees)), 1), + } ) - pass_dict['trajectory'] = trajectory + except Exception as pt_exc: + logger.debug("Trajectory point error for %s: %s", tle_data[0], pt_exc) + pass_dict["trajectory"] = trajectory # Build 60-point lat/lon ground track for map if include_ground_track: ground_track = [] for step in range(60): frac = step / 59.0 - t_pt = ts.tt_jd( - aos_time.tt + frac * (los_time.tt - aos_time.tt) - ) + t_pt = ts.tt_jd(aos_time.tt + frac * (los_time.tt - aos_time.tt)) try: geocentric = satellite.at(t_pt) subpoint = wgs84.subpoint(geocentric) - ground_track.append({ - 'lat': round(float(subpoint.latitude.degrees), 4), - 'lon': round(float(subpoint.longitude.degrees), 4), - }) - except Exception as gt_exc: - logger.debug( - 'Ground track point error for %s: %s', tle_data[0], gt_exc + ground_track.append( + { + "lat": round(float(subpoint.latitude.degrees), 4), + "lon": round(float(subpoint.longitude.degrees), 4), + } ) - pass_dict['groundTrack'] = ground_track + except Exception as gt_exc: + logger.debug("Ground track point error for %s: %s", tle_data[0], gt_exc) + pass_dict["groundTrack"] = ground_track passes.append(pass_dict) except Exception as exc: - logger.debug( - 'Failed to compute pass details for %s: %s', tle_data[0], exc - ) + logger.debug("Failed to compute pass details for %s: %s", tle_data[0], exc) continue - passes.sort(key=lambda p: p['startTimeISO']) + passes.sort(key=lambda p: p["startTimeISO"]) return passes diff --git a/utils/satellite_telemetry.py b/utils/satellite_telemetry.py index 2f89ba3..ae4b80a 100644 --- a/utils/satellite_telemetry.py +++ b/utils/satellite_telemetry.py @@ -147,33 +147,33 @@ def parse_csp(data: bytes) -> dict | None: header: int = struct.unpack(">I", data[:4])[0] - priority = (header >> 27) & 0x1F - source = (header >> 22) & 0x1F + priority = (header >> 27) & 0x1F + source = (header >> 22) & 0x1F destination = (header >> 17) & 0x1F - dest_port = (header >> 12) & 0x1F - src_port = (header >> 6) & 0x3F - raw_flags = header & 0x3F + dest_port = (header >> 12) & 0x1F + src_port = (header >> 6) & 0x3F + raw_flags = header & 0x3F flags = { "frag": bool(raw_flags & 0x10), "hmac": bool(raw_flags & 0x08), "xtea": bool(raw_flags & 0x04), - "rdp": bool(raw_flags & 0x02), - "crc": bool(raw_flags & 0x01), + "rdp": bool(raw_flags & 0x02), + "crc": bool(raw_flags & 0x01), } payload = data[4:] return { - "protocol": "CSP", - "priority": priority, - "source": source, - "destination": destination, - "dest_port": dest_port, - "src_port": src_port, - "flags": flags, - "payload": payload, - "payload_hex": payload.hex(), + "protocol": "CSP", + "priority": priority, + "source": source, + "destination": destination, + "dest_port": dest_port, + "src_port": src_port, + "flags": flags, + "payload": payload, + "payload_hex": payload.hex(), "payload_length": len(payload), } @@ -210,30 +210,30 @@ def parse_ccsds(data: bytes) -> dict | None: word1: int = struct.unpack(">H", data[2:4])[0] word2: int = struct.unpack(">H", data[4:6])[0] - version = (word0 >> 13) & 0x07 - packet_type = (word0 >> 12) & 0x01 - secondary_header_flag = bool((word0 >> 11) & 0x01) - apid = word0 & 0x07FF + version = (word0 >> 13) & 0x07 + packet_type = (word0 >> 12) & 0x01 + secondary_header_flag = bool((word0 >> 11) & 0x01) + apid = word0 & 0x07FF - sequence_flags = (word1 >> 14) & 0x03 - sequence_count = word1 & 0x3FFF + sequence_flags = (word1 >> 14) & 0x03 + sequence_count = word1 & 0x3FFF data_length = word2 # raw field; actual user data bytes = data_length + 1 payload = data[6:] return { - "protocol": "CCSDS_TM", - "version": version, - "packet_type": packet_type, - "secondary_header": secondary_header_flag, - "apid": apid, - "sequence_flags": sequence_flags, - "sequence_count": sequence_count, - "data_length": data_length, - "payload": payload, - "payload_hex": payload.hex(), - "payload_length": len(payload), + "protocol": "CCSDS_TM", + "version": version, + "packet_type": packet_type, + "secondary_header": secondary_header_flag, + "apid": apid, + "sequence_flags": sequence_flags, + "sequence_count": sequence_count, + "data_length": data_length, + "payload": payload, + "payload_hex": payload.hex(), + "payload_length": len(payload), } except Exception: # noqa: BLE001 @@ -337,10 +337,10 @@ def analyze_payload(data: bytes) -> dict: strings = _extract_strings(data, min_len=3) interpretations = { - "float32": float32_values, - "uint16_le": uint16_values, - "uint32_le": uint32_values, - "strings": strings, + "float32": float32_values, + "uint16_le": uint16_values, + "uint32_le": uint32_values, + "strings": strings, } # --- heuristics --- @@ -370,19 +370,19 @@ def analyze_payload(data: bytes) -> dict: heuristics.append(f"Possible Unix timestamp: {ts} (index {idx})") return { - "hex_dump": hex_dump, - "length": length, + "hex_dump": hex_dump, + "length": length, "interpretations": interpretations, - "heuristics": heuristics, + "heuristics": heuristics, } except Exception: # noqa: BLE001 # Guarantee a safe return even on completely malformed input return { - "hex_dump": "", - "length": len(data) if isinstance(data, (bytes, bytearray)) else 0, + "hex_dump": "", + "length": len(data) if isinstance(data, (bytes, bytearray)) else 0, "interpretations": {"float32": [], "uint16_le": [], "uint32_le": [], "strings": []}, - "heuristics": [], + "heuristics": [], } @@ -430,6 +430,6 @@ def auto_parse(data: bytes) -> dict: # Nothing matched — return a raw analysis return { "protocol": "unknown", - "raw_hex": data.hex(), + "raw_hex": data.hex(), "analysis": analyze_payload(data), } diff --git a/utils/sdr/__init__.py b/utils/sdr/__init__.py index 1bc3244..30118c6 100644 --- a/utils/sdr/__init__.py +++ b/utils/sdr/__init__.py @@ -133,25 +133,20 @@ class SDRFactory: for sdr_type in cls._builders: caps = cls.get_capabilities(sdr_type) capabilities[sdr_type.value] = { - 'name': sdr_type.name.replace('_', ' '), - 'freq_min_mhz': caps.freq_min_mhz, - 'freq_max_mhz': caps.freq_max_mhz, - 'gain_min': caps.gain_min, - 'gain_max': caps.gain_max, - 'sample_rates': caps.sample_rates, - 'supports_bias_t': caps.supports_bias_t, - 'supports_ppm': caps.supports_ppm, - 'tx_capable': caps.tx_capable, + "name": sdr_type.name.replace("_", " "), + "freq_min_mhz": caps.freq_min_mhz, + "freq_max_mhz": caps.freq_max_mhz, + "gain_min": caps.gain_min, + "gain_max": caps.gain_max, + "sample_rates": caps.sample_rates, + "supports_bias_t": caps.supports_bias_t, + "supports_ppm": caps.supports_ppm, + "tx_capable": caps.tx_capable, } return capabilities @classmethod - def create_default_device( - cls, - sdr_type: SDRType, - index: int = 0, - serial: str = 'N/A' - ) -> SDRDevice: + def create_default_device(cls, sdr_type: SDRType, index: int = 0, serial: str = "N/A") -> SDRDevice: """ Create a default device object for a given SDR type. @@ -170,18 +165,14 @@ class SDRFactory: return SDRDevice( sdr_type=sdr_type, index=index, - name=f'{sdr_type.name.replace("_", " ")} Device {index}', + name=f"{sdr_type.name.replace('_', ' ')} Device {index}", serial=serial, driver=sdr_type.value, - capabilities=caps + capabilities=caps, ) @classmethod - def create_network_device( - cls, - host: str, - port: int = 1234 - ) -> SDRDevice: + def create_network_device(cls, host: str, port: int = 1234) -> SDRDevice: """ Create a network device for rtl_tcp connection. @@ -196,40 +187,40 @@ class SDRFactory: return SDRDevice( sdr_type=SDRType.RTL_SDR, index=0, - name=f'{host}:{port}', - serial='rtl_tcp', - driver='rtl_tcp', + name=f"{host}:{port}", + serial="rtl_tcp", + driver="rtl_tcp", capabilities=caps, rtl_tcp_host=host, - rtl_tcp_port=port + rtl_tcp_port=port, ) # Export commonly used items at package level __all__ = [ # Factory - 'SDRFactory', + "SDRFactory", # Types and classes - 'SDRType', - 'SDRDevice', - 'SDRCapabilities', - 'CommandBuilder', + "SDRType", + "SDRDevice", + "SDRCapabilities", + "CommandBuilder", # Builders - 'RTLSDRCommandBuilder', - 'LimeSDRCommandBuilder', - 'HackRFCommandBuilder', - 'AirspyCommandBuilder', - 'SDRPlayCommandBuilder', + "RTLSDRCommandBuilder", + "LimeSDRCommandBuilder", + "HackRFCommandBuilder", + "AirspyCommandBuilder", + "SDRPlayCommandBuilder", # Validation - 'SDRValidationError', - 'validate_frequency', - 'validate_gain', - 'validate_sample_rate', - 'validate_ppm', - 'validate_device_index', - 'validate_squelch', - 'get_capabilities_for_type', + "SDRValidationError", + "validate_frequency", + "validate_gain", + "validate_sample_rate", + "validate_ppm", + "validate_device_index", + "validate_squelch", + "get_capabilities_for_type", # Device probing - 'probe_rtlsdr_device', - 'invalidate_device_cache', + "probe_rtlsdr_device", + "invalidate_device_cache", ] diff --git a/utils/sdr/airspy.py b/utils/sdr/airspy.py index a146f60..760ce38 100644 --- a/utils/sdr/airspy.py +++ b/utils/sdr/airspy.py @@ -20,22 +20,22 @@ class AirspyCommandBuilder(CommandBuilder): # HF+ has different range but same interface CAPABILITIES = SDRCapabilities( sdr_type=SDRType.AIRSPY, - freq_min_mhz=24.0, # 24 MHz (HF+ goes lower) - freq_max_mhz=1800.0, # 1.8 GHz + freq_min_mhz=24.0, # 24 MHz (HF+ goes lower) + freq_max_mhz=1800.0, # 1.8 GHz gain_min=0.0, - gain_max=45.0, # LNA (0-15) + Mixer (0-15) + VGA (0-15) + gain_max=45.0, # LNA (0-15) + Mixer (0-15) + VGA (0-15) sample_rates=[2500000, 3000000, 6000000, 10000000], supports_bias_t=True, - supports_ppm=False, # Airspy has TCXO, no PPM needed - tx_capable=False + supports_ppm=False, # Airspy has TCXO, no PPM needed + tx_capable=False, ) def _build_device_string(self, device: SDRDevice) -> str: """Build SoapySDR device string for Airspy.""" - driver = device.driver if device.driver in ('airspy', 'airspyhf') else 'airspy' - if device.serial and device.serial != 'N/A': - return f'driver={driver},serial={device.serial}' - return f'driver={driver}' + driver = device.driver if device.driver in ("airspy", "airspyhf") else "airspy" + if device.serial and device.serial != "N/A": + return f"driver={driver},serial={device.serial}" + return f"driver={driver}" def _format_gain(self, gain: float) -> str: """ @@ -49,12 +49,12 @@ class AirspyCommandBuilder(CommandBuilder): This distributes the requested gain across stages. """ if gain <= 15: - return f'LNA={int(gain)},MIX=0,VGA=0' + return f"LNA={int(gain)},MIX=0,VGA=0" elif gain <= 30: - return f'LNA=15,MIX={int(gain - 15)},VGA=0' + return f"LNA=15,MIX={int(gain - 15)},VGA=0" else: vga = min(15, int(gain - 30)) - return f'LNA=15,MIX=15,VGA={vga}' + return f"LNA=15,MIX=15,VGA={vga}" def build_fm_demod_command( self, @@ -65,7 +65,7 @@ class AirspyCommandBuilder(CommandBuilder): ppm: int | None = None, modulation: str = "fm", squelch: int | None = None, - bias_t: bool = False + bias_t: bool = False, ) -> list[str]: """ Build SoapySDR rx_fm command for FM demodulation. @@ -74,35 +74,34 @@ class AirspyCommandBuilder(CommandBuilder): """ device_str = self._build_device_string(device) - rx_fm_path = get_tool_path('rx_fm') or 'rx_fm' + rx_fm_path = get_tool_path("rx_fm") or "rx_fm" cmd = [ rx_fm_path, - '-d', device_str, - '-f', f'{frequency_mhz}M', - '-M', modulation, - '-s', str(sample_rate), + "-d", + device_str, + "-f", + f"{frequency_mhz}M", + "-M", + modulation, + "-s", + str(sample_rate), ] if gain is not None and gain > 0: - cmd.extend(['-g', self._format_gain(gain)]) + cmd.extend(["-g", self._format_gain(gain)]) if squelch is not None and squelch > 0: - cmd.extend(['-l', str(squelch)]) + cmd.extend(["-l", str(squelch)]) if bias_t: - cmd.extend(['-T']) + cmd.extend(["-T"]) # Output to stdout - cmd.append('-') + cmd.append("-") return cmd - def build_adsb_command( - self, - device: SDRDevice, - gain: float | None = None, - bias_t: bool = False - ) -> list[str]: + def build_adsb_command(self, device: SDRDevice, gain: float | None = None, bias_t: bool = False) -> list[str]: """ Build dump1090/readsb command with SoapySDR support for ADS-B decoding. @@ -110,19 +109,13 @@ class AirspyCommandBuilder(CommandBuilder): """ device_str = self._build_device_string(device) - cmd = [ - 'readsb', - '--net', - '--device-type', 'soapysdr', - '--device', device_str, - '--quiet' - ] + cmd = ["readsb", "--net", "--device-type", "soapysdr", "--device", device_str, "--quiet"] if gain is not None: - cmd.extend(['--gain', str(int(gain))]) + cmd.extend(["--gain", str(int(gain))]) if bias_t: - cmd.extend(['--enable-bias-t']) + cmd.extend(["--enable-bias-t"]) return cmd @@ -132,7 +125,7 @@ class AirspyCommandBuilder(CommandBuilder): frequency_mhz: float = 433.92, gain: float | None = None, ppm: int | None = None, - bias_t: bool = False + bias_t: bool = False, ) -> list[str]: """ Build rtl_433 command with SoapySDR support for ISM band decoding. @@ -141,18 +134,13 @@ class AirspyCommandBuilder(CommandBuilder): """ device_str = self._build_device_string(device) - cmd = [ - 'rtl_433', - '-d', device_str, - '-f', f'{frequency_mhz}M', - '-F', 'json' - ] + cmd = ["rtl_433", "-d", device_str, "-f", f"{frequency_mhz}M", "-F", "json"] if gain is not None and gain > 0: - cmd.extend(['-g', str(int(gain))]) + cmd.extend(["-g", str(int(gain))]) if bias_t: - cmd.extend(['-T']) + cmd.extend(["-T"]) return cmd @@ -173,21 +161,24 @@ class AirspyCommandBuilder(CommandBuilder): device_str = self._build_device_string(device) cmd = [ - 'AIS-catcher', - '-d', f'soapysdr -d {device_str}', - '-S', str(tcp_port), - '-o', '5', - '-q', + "AIS-catcher", + "-d", + f"soapysdr -d {device_str}", + "-S", + str(tcp_port), + "-o", + "5", + "-q", ] if gain is not None and gain > 0: - cmd.extend(['-gr', 'tuner', str(int(gain))]) + cmd.extend(["-gr", "tuner", str(int(gain))]) if bias_t: - cmd.extend(['-gr', 'biastee', '1']) + cmd.extend(["-gr", "biastee", "1"]) if udp_host and udp_port: - cmd.extend(['-u', udp_host, str(udp_port)]) + cmd.extend(["-u", udp_host, str(udp_port)]) return cmd @@ -199,7 +190,7 @@ class AirspyCommandBuilder(CommandBuilder): gain: float | None = None, ppm: int | None = None, bias_t: bool = False, - output_format: str = 'cu8', + output_format: str = "cu8", ) -> list[str]: """ Build rx_sdr command for raw I/Q capture with Airspy. @@ -209,23 +200,27 @@ class AirspyCommandBuilder(CommandBuilder): device_str = self._build_device_string(device) freq_hz = int(frequency_mhz * 1e6) - rx_sdr_path = get_tool_path('rx_sdr') or 'rx_sdr' + rx_sdr_path = get_tool_path("rx_sdr") or "rx_sdr" cmd = [ rx_sdr_path, - '-d', device_str, - '-f', str(freq_hz), - '-s', str(sample_rate), - '-F', 'CU8', + "-d", + device_str, + "-f", + str(freq_hz), + "-s", + str(sample_rate), + "-F", + "CU8", ] if gain is not None and gain > 0: - cmd.extend(['-g', self._format_gain(gain)]) + cmd.extend(["-g", self._format_gain(gain)]) if bias_t: - cmd.append('-T') + cmd.append("-T") # Output to stdout - cmd.append('-') + cmd.append("-") return cmd diff --git a/utils/sdr/base.py b/utils/sdr/base.py index 0ecca03..69ec4d5 100644 --- a/utils/sdr/base.py +++ b/utils/sdr/base.py @@ -14,6 +14,7 @@ from enum import Enum class SDRType(Enum): """Supported SDR hardware types.""" + RTL_SDR = "rtlsdr" LIME_SDR = "limesdr" HACKRF = "hackrf" @@ -27,29 +28,31 @@ class SDRType(Enum): @dataclass class SDRCapabilities: """Hardware capabilities for an SDR device.""" + sdr_type: SDRType - freq_min_mhz: float # Minimum frequency in MHz - freq_max_mhz: float # Maximum frequency in MHz - gain_min: float # Minimum gain in dB - gain_max: float # Maximum gain in dB + freq_min_mhz: float # Minimum frequency in MHz + freq_max_mhz: float # Maximum frequency in MHz + gain_min: float # Minimum gain in dB + gain_max: float # Maximum gain in dB sample_rates: list[int] = field(default_factory=list) # Supported sample rates - supports_bias_t: bool = False # Bias-T support - supports_ppm: bool = True # PPM correction support - tx_capable: bool = False # Can transmit + supports_bias_t: bool = False # Bias-T support + supports_ppm: bool = True # PPM correction support + tx_capable: bool = False # Can transmit supports_iq_capture: bool = False # Raw I/Q sample capture @dataclass class SDRDevice: """Detected SDR device.""" + sdr_type: SDRType index: int name: str serial: str - driver: str # e.g., "rtlsdr", "lime", "hackrf" + driver: str # e.g., "rtlsdr", "lime", "hackrf" capabilities: SDRCapabilities - rtl_tcp_host: str | None = None # Remote rtl_tcp server host - rtl_tcp_port: int | None = None # Remote rtl_tcp server port + rtl_tcp_host: str | None = None # Remote rtl_tcp server host + rtl_tcp_port: int | None = None # Remote rtl_tcp server port @property def is_network(self) -> bool: @@ -59,26 +62,26 @@ class SDRDevice: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" result = { - 'index': self.index, - 'name': self.name, - 'serial': self.serial, - 'sdr_type': self.sdr_type.value, - 'driver': self.driver, - 'is_network': self.is_network, - 'capabilities': { - 'freq_min_mhz': self.capabilities.freq_min_mhz, - 'freq_max_mhz': self.capabilities.freq_max_mhz, - 'gain_min': self.capabilities.gain_min, - 'gain_max': self.capabilities.gain_max, - 'sample_rates': self.capabilities.sample_rates, - 'supports_bias_t': self.capabilities.supports_bias_t, - 'supports_ppm': self.capabilities.supports_ppm, - 'tx_capable': self.capabilities.tx_capable, - } + "index": self.index, + "name": self.name, + "serial": self.serial, + "sdr_type": self.sdr_type.value, + "driver": self.driver, + "is_network": self.is_network, + "capabilities": { + "freq_min_mhz": self.capabilities.freq_min_mhz, + "freq_max_mhz": self.capabilities.freq_max_mhz, + "gain_min": self.capabilities.gain_min, + "gain_max": self.capabilities.gain_max, + "sample_rates": self.capabilities.sample_rates, + "supports_bias_t": self.capabilities.supports_bias_t, + "supports_ppm": self.capabilities.supports_ppm, + "tx_capable": self.capabilities.tx_capable, + }, } if self.is_network: - result['rtl_tcp_host'] = self.rtl_tcp_host - result['rtl_tcp_port'] = self.rtl_tcp_port + result["rtl_tcp_host"] = self.rtl_tcp_host + result["rtl_tcp_port"] = self.rtl_tcp_port return result @@ -95,7 +98,7 @@ class CommandBuilder(ABC): ppm: int | None = None, modulation: str = "fm", squelch: int | None = None, - bias_t: bool = False + bias_t: bool = False, ) -> list[str]: """ Build FM demodulation command (for pager decoding). @@ -116,12 +119,7 @@ class CommandBuilder(ABC): pass @abstractmethod - def build_adsb_command( - self, - device: SDRDevice, - gain: float | None = None, - bias_t: bool = False - ) -> list[str]: + def build_adsb_command(self, device: SDRDevice, gain: float | None = None, bias_t: bool = False) -> list[str]: """ Build ADS-B decoder command. @@ -142,7 +140,7 @@ class CommandBuilder(ABC): frequency_mhz: float = 433.92, gain: float | None = None, ppm: int | None = None, - bias_t: bool = False + bias_t: bool = False, ) -> list[str]: """ Build ISM band decoder command (433MHz sensors). @@ -198,7 +196,7 @@ class CommandBuilder(ABC): gain: float | None = None, ppm: int | None = None, bias_t: bool = False, - output_format: str = 'cu8', + output_format: str = "cu8", ) -> list[str]: """ Build raw I/Q capture command for streaming samples to stdout. @@ -222,17 +220,15 @@ class CommandBuilder(ABC): NotImplementedError: If the SDR type does not support I/Q capture. """ if not device.capabilities.supports_iq_capture: - supported = ', '.join( - t.value for t in SDRType + supported = ", ".join( + t.value + for t in SDRType if t == SDRType.RTL_SDR # known IQ-capable types ) raise ValueError( - f"{device.sdr_type.value} does not support raw I/Q capture. " - f"Supported devices: {supported}" + f"{device.sdr_type.value} does not support raw I/Q capture. Supported devices: {supported}" ) - raise NotImplementedError( - f"{self.__class__.__name__} does not support raw I/Q capture" - ) + raise NotImplementedError(f"{self.__class__.__name__} does not support raw I/Q capture") @classmethod @abstractmethod diff --git a/utils/sdr/detection.py b/utils/sdr/detection.py index b63e4b2..9e1007b 100644 --- a/utils/sdr/detection.py +++ b/utils/sdr/detection.py @@ -39,7 +39,8 @@ def _hackrf_probe_blocked() -> bool: """Return True when probing HackRF would interfere with an active stream.""" try: from utils.subghz import get_subghz_manager - return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'} + + return get_subghz_manager().active_mode in {"rx", "decode", "tx", "sweep"} except Exception: return False @@ -75,20 +76,20 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities: sample_rates=[2048000], supports_bias_t=False, supports_ppm=False, - tx_capable=False + tx_capable=False, ) def _driver_to_sdr_type(driver: str) -> SDRType | None: """Map SoapySDR driver name to SDRType.""" mapping = { - 'rtlsdr': SDRType.RTL_SDR, - 'lime': SDRType.LIME_SDR, - 'limesdr': SDRType.LIME_SDR, - 'hackrf': SDRType.HACKRF, - 'airspy': SDRType.AIRSPY, - 'airspyhf': SDRType.AIRSPY, # Airspy HF+ uses same builder - 'sdrplay': SDRType.SDRPLAY, + "rtlsdr": SDRType.RTL_SDR, + "lime": SDRType.LIME_SDR, + "limesdr": SDRType.LIME_SDR, + "hackrf": SDRType.HACKRF, + "airspy": SDRType.AIRSPY, + "airspyhf": SDRType.AIRSPY, # Airspy HF+ uses same builder + "sdrplay": SDRType.SDRPLAY, # Future support # 'uhd': SDRType.USRP, # 'bladerf': SDRType.BLADE_RF, @@ -105,7 +106,7 @@ def detect_rtlsdr_devices() -> list[SDRDevice]: """ devices: list[SDRDevice] = [] - rtl_test_path = get_tool_path('rtl_test') + rtl_test_path = get_tool_path("rtl_test") if not rtl_test_path: logger.debug("rtl_test not found, skipping RTL-SDR detection") return devices @@ -113,21 +114,22 @@ def detect_rtlsdr_devices() -> list[SDRDevice]: try: import os import platform + env = os.environ.copy() - if platform.system() == 'Darwin': - lib_paths = ['/usr/local/lib', '/opt/homebrew/lib'] - current_ld = env.get('DYLD_LIBRARY_PATH', '') - env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths) + if platform.system() == "Darwin": + lib_paths = ["/usr/local/lib", "/opt/homebrew/lib"] + current_ld = env.get("DYLD_LIBRARY_PATH", "") + env["DYLD_LIBRARY_PATH"] = ":".join(lib_paths + [current_ld] if current_ld else lib_paths) try: result = subprocess.run( - [rtl_test_path, '-t'], + [rtl_test_path, "-t"], capture_output=True, text=True, - encoding='utf-8', - errors='replace', + encoding="utf-8", + errors="replace", timeout=5, - env=env + env=env, ) except subprocess.TimeoutExpired: logger.warning("rtl_test timed out after 5s") @@ -137,37 +139,41 @@ def detect_rtlsdr_devices() -> list[SDRDevice]: # Parse device info from rtl_test output # Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001" # Require a non-empty serial to avoid matching malformed lines like "SN:". - device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$' + device_pattern = r"(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$" from .rtlsdr import RTLSDRCommandBuilder - for line in output.split('\n'): + for line in output.split("\n"): line = line.strip() match = re.match(device_pattern, line) if match: - devices.append(SDRDevice( - sdr_type=SDRType.RTL_SDR, - index=int(match.group(1)), - name=match.group(2).strip().rstrip(','), - serial=match.group(3), - driver='rtlsdr', - capabilities=RTLSDRCommandBuilder.CAPABILITIES - )) + devices.append( + SDRDevice( + sdr_type=SDRType.RTL_SDR, + index=int(match.group(1)), + name=match.group(2).strip().rstrip(","), + serial=match.group(3), + driver="rtlsdr", + capabilities=RTLSDRCommandBuilder.CAPABILITIES, + ) + ) # Fallback: if we found devices but couldn't parse details if not devices: - found_match = re.search(r'Found (\d+) device', output) + found_match = re.search(r"Found (\d+) device", output) if found_match: count = int(found_match.group(1)) for i in range(count): - devices.append(SDRDevice( - sdr_type=SDRType.RTL_SDR, - index=i, - name=f'RTL-SDR Device {i}', - serial='Unknown', - driver='rtlsdr', - capabilities=RTLSDRCommandBuilder.CAPABILITIES - )) + devices.append( + SDRDevice( + sdr_type=SDRType.RTL_SDR, + index=i, + name=f"RTL-SDR Device {i}", + serial="Unknown", + driver="rtlsdr", + capabilities=RTLSDRCommandBuilder.CAPABILITIES, + ) + ) except subprocess.TimeoutExpired: logger.warning("rtl_test timed out") @@ -180,7 +186,7 @@ def detect_rtlsdr_devices() -> list[SDRDevice]: def _find_soapy_util() -> str | None: """Find SoapySDR utility command (name varies by distribution).""" # Try different command names used across distributions - for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']: + for cmd in ["SoapySDRUtil", "soapy_sdr_util", "soapysdr-util"]: tool_path = get_tool_path(cmd) if tool_path: return tool_path @@ -199,26 +205,27 @@ def _get_soapy_env() -> dict: """ import os import platform + env = os.environ.copy() - if platform.system() == 'Darwin': + if platform.system() == "Darwin": # Homebrew paths for Apple Silicon and Intel Macs - homebrew_paths = ['/opt/homebrew', '/usr/local'] + homebrew_paths = ["/opt/homebrew", "/usr/local"] lib_paths = [] for base in homebrew_paths: - lib_path = f'{base}/lib' + lib_path = f"{base}/lib" if os.path.isdir(lib_path): lib_paths.append(lib_path) if lib_paths: - current_dyld = env.get('DYLD_LIBRARY_PATH', '') - env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + ([current_dyld] if current_dyld else [])) + current_dyld = env.get("DYLD_LIBRARY_PATH", "") + env["DYLD_LIBRARY_PATH"] = ":".join(lib_paths + ([current_dyld] if current_dyld else [])) # Set SOAPY_SDR_ROOT if we found Homebrew installation for base in homebrew_paths: - if os.path.isdir(f'{base}/lib/SoapySDR'): - env['SOAPY_SDR_ROOT'] = base + if os.path.isdir(f"{base}/lib/SoapySDR"): + env["SOAPY_SDR_ROOT"] = base break return env @@ -244,13 +251,7 @@ def detect_soapy_devices(skip_types: set[SDRType] | None = None) -> list[SDRDevi try: # Use macOS-aware environment to find Homebrew-installed modules env = _get_soapy_env() - result = subprocess.run( - [soapy_cmd, '--find'], - capture_output=True, - text=True, - timeout=10, - env=env - ) + result = subprocess.run([soapy_cmd, "--find"], capture_output=True, text=True, timeout=10, env=env) # Parse SoapySDR output # Format varies but typically includes lines like: @@ -261,25 +262,25 @@ def detect_soapy_devices(skip_types: set[SDRType] | None = None) -> list[SDRDevi current_device: dict = {} device_counts: dict[SDRType, int] = {} - for line in result.stdout.split('\n'): + for line in result.stdout.split("\n"): line = line.strip() # Start of new device block - if line.startswith('Found device'): - if current_device.get('driver'): + if line.startswith("Found device"): + if current_device.get("driver"): _add_soapy_device(devices, current_device, device_counts, skip_types) current_device = {} continue # Parse key = value pairs - if ' = ' in line: - key, value = line.split(' = ', 1) + if " = " in line: + key, value = line.split(" = ", 1) key = key.strip() value = value.strip() current_device[key] = value # Don't forget the last device - if current_device.get('driver'): + if current_device.get("driver"): _add_soapy_device(devices, current_device, device_counts, skip_types) except subprocess.TimeoutExpired: @@ -291,13 +292,10 @@ def detect_soapy_devices(skip_types: set[SDRType] | None = None) -> list[SDRDevi def _add_soapy_device( - devices: list[SDRDevice], - device_info: dict, - device_counts: dict[SDRType, int], - skip_types: set[SDRType] + devices: list[SDRDevice], device_info: dict, device_counts: dict[SDRType, int], skip_types: set[SDRType] ) -> None: """Add a device from SoapySDR detection to the list.""" - driver = device_info.get('driver', '').lower() + driver = device_info.get("driver", "").lower() sdr_type = _driver_to_sdr_type(driver) if not sdr_type: @@ -316,14 +314,16 @@ def _add_soapy_device( index = device_counts[sdr_type] device_counts[sdr_type] += 1 - devices.append(SDRDevice( - sdr_type=sdr_type, - index=index, - name=device_info.get('label', device_info.get('driver', 'Unknown')), - serial=device_info.get('serial', 'N/A'), - driver=driver, - capabilities=_get_capabilities_for_type(sdr_type) - )) + devices.append( + SDRDevice( + sdr_type=sdr_type, + index=index, + name=device_info.get("label", device_info.get("driver", "Unknown")), + serial=device_info.get("serial", "N/A"), + driver=driver, + capabilities=_get_capabilities_for_type(sdr_type), + ) + ) def detect_hackrf_devices() -> list[SDRDevice]: @@ -345,19 +345,14 @@ def detect_hackrf_devices() -> list[SDRDevice]: devices: list[SDRDevice] = [] - hackrf_info_path = get_tool_path('hackrf_info') + hackrf_info_path = get_tool_path("hackrf_info") if not hackrf_info_path: _hackrf_cache = devices _hackrf_cache_ts = now return devices try: - result = subprocess.run( - [hackrf_info_path], - capture_output=True, - text=True, - timeout=5 - ) + result = subprocess.run([hackrf_info_path], capture_output=True, text=True, timeout=5) # Combine stdout + stderr: newer firmware may print to stderr, # and hackrf_info may exit non-zero when device is briefly busy @@ -369,46 +364,50 @@ def detect_hackrf_devices() -> list[SDRDevice]: from .hackrf import HackRFCommandBuilder serial_pattern = re.compile( - r'^\s*Serial\s+number:\s*(.+)$', + r"^\s*Serial\s+number:\s*(.+)$", re.IGNORECASE | re.MULTILINE, ) board_pattern = re.compile( - r'Board\s+ID\s+Number:\s*\d+\s*\(([^)]+)\)', + r"Board\s+ID\s+Number:\s*\d+\s*\(([^)]+)\)", re.IGNORECASE, ) serials_found = [] for raw in serial_pattern.findall(output): # Normalise legacy formats like "0x1234 5678" to plain hex. - serial = re.sub(r'0x', '', raw, flags=re.IGNORECASE) - serial = re.sub(r'[^0-9A-Fa-f]', '', serial) + serial = re.sub(r"0x", "", raw, flags=re.IGNORECASE) + serial = re.sub(r"[^0-9A-Fa-f]", "", serial) if serial: serials_found.append(serial) boards_found = board_pattern.findall(output) for i, serial in enumerate(serials_found): - board_name = boards_found[i] if i < len(boards_found) else 'HackRF' - devices.append(SDRDevice( - sdr_type=SDRType.HACKRF, - index=i, - name=board_name, - serial=serial, - driver='hackrf', - capabilities=HackRFCommandBuilder.CAPABILITIES - )) + board_name = boards_found[i] if i < len(boards_found) else "HackRF" + devices.append( + SDRDevice( + sdr_type=SDRType.HACKRF, + index=i, + name=board_name, + serial=serial, + driver="hackrf", + capabilities=HackRFCommandBuilder.CAPABILITIES, + ) + ) # Fallback: check if any HackRF found without serial - if not devices and re.search(r'Found\s+HackRF', output, re.IGNORECASE): + if not devices and re.search(r"Found\s+HackRF", output, re.IGNORECASE): board_match = board_pattern.search(output) - board_name = board_match.group(1) if board_match else 'HackRF' - devices.append(SDRDevice( - sdr_type=SDRType.HACKRF, - index=0, - name=board_name, - serial='Unknown', - driver='hackrf', - capabilities=HackRFCommandBuilder.CAPABILITIES - )) + board_name = board_match.group(1) if board_match else "HackRF" + devices.append( + SDRDevice( + sdr_type=SDRType.HACKRF, + index=0, + name=board_name, + serial="Unknown", + driver="hackrf", + capabilities=HackRFCommandBuilder.CAPABILITIES, + ) + ) except Exception as e: logger.debug(f"HackRF detection error: {e}") @@ -432,7 +431,7 @@ def probe_rtlsdr_device(device_index: int) -> str | None: An error message string if the device cannot be opened, or ``None`` if the device is available. """ - rtl_test_path = get_tool_path('rtl_test') + rtl_test_path = get_tool_path("rtl_test") if not rtl_test_path: # Can't probe without rtl_test — let the caller proceed and # surface errors from the actual decoder process instead. @@ -441,20 +440,19 @@ def probe_rtlsdr_device(device_index: int) -> str | None: try: import os import platform + env = os.environ.copy() - if platform.system() == 'Darwin': - lib_paths = ['/usr/local/lib', '/opt/homebrew/lib'] - current_ld = env.get('DYLD_LIBRARY_PATH', '') - env['DYLD_LIBRARY_PATH'] = ':'.join( - lib_paths + [current_ld] if current_ld else lib_paths - ) + if platform.system() == "Darwin": + lib_paths = ["/usr/local/lib", "/opt/homebrew/lib"] + current_ld = env.get("DYLD_LIBRARY_PATH", "") + env["DYLD_LIBRARY_PATH"] = ":".join(lib_paths + [current_ld] if current_ld else lib_paths) # Use Popen with early termination instead of run() with full timeout. # rtl_test prints device info to stderr quickly, then keeps running # its test loop. We kill it as soon as we see success or failure. proc = subprocess.Popen( - [rtl_test_path, '-d', str(device_index), '-t'], + [rtl_test_path, "-d", str(device_index), "-t"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -462,6 +460,7 @@ def probe_rtlsdr_device(device_index: int) -> str | None: ) import select + error_found = False device_found = False deadline = time.monotonic() + 3.0 @@ -472,22 +471,20 @@ def probe_rtlsdr_device(device_index: int) -> str | None: if remaining <= 0: break # Wait for stderr output with timeout - ready, _, _ = select.select( - [proc.stderr], [], [], min(remaining, 0.1) - ) + ready, _, _ = select.select([proc.stderr], [], [], min(remaining, 0.1)) if ready: line = proc.stderr.readline() if not line: break # EOF — process closed stderr # Check for no-device messages first (before success check, # since "No supported devices found" also contains "Found" + "device") - if 'no supported devices' in line.lower() or 'no matching devices' in line.lower(): + if "no supported devices" in line.lower() or "no matching devices" in line.lower(): error_found = True break - if 'usb_claim_interface' in line or 'Failed to open' in line: + if "usb_claim_interface" in line or "Failed to open" in line: error_found = True break - if 'Found' in line and 'device' in line.lower(): + if "Found" in line and "device" in line.lower(): # Device opened successfully — no need to wait longer device_found = True break @@ -506,13 +503,10 @@ def probe_rtlsdr_device(device_index: int) -> str | None: time.sleep(0.5) if error_found: - logger.warning( - f"RTL-SDR device {device_index} USB probe failed: " - f"device busy or unavailable" - ) + logger.warning(f"RTL-SDR device {device_index} USB probe failed: device busy or unavailable") return ( - f'SDR device {device_index} is not available — ' - f'check that the SDR device is connected and not in use by another process.' + f"SDR device {device_index} is not available — " + f"check that the SDR device is connected and not in use by another process." ) except Exception as e: @@ -589,4 +583,3 @@ def invalidate_device_cache() -> None: global _all_devices_cache, _all_devices_cache_ts _all_devices_cache = [] _all_devices_cache_ts = 0.0 - diff --git a/utils/sdr/hackrf.py b/utils/sdr/hackrf.py index 1274ada..e9c6a7d 100644 --- a/utils/sdr/hackrf.py +++ b/utils/sdr/hackrf.py @@ -17,21 +17,21 @@ class HackRFCommandBuilder(CommandBuilder): CAPABILITIES = SDRCapabilities( sdr_type=SDRType.HACKRF, - freq_min_mhz=1.0, # 1 MHz - freq_max_mhz=6000.0, # 6 GHz + freq_min_mhz=1.0, # 1 MHz + freq_max_mhz=6000.0, # 6 GHz gain_min=0.0, - gain_max=102.0, # LNA (0-40) + VGA (0-62) + gain_max=102.0, # LNA (0-40) + VGA (0-62) sample_rates=[2000000, 4000000, 8000000, 10000000, 20000000], supports_bias_t=True, supports_ppm=False, - tx_capable=True + tx_capable=True, ) def _build_device_string(self, device: SDRDevice) -> str: """Build SoapySDR device string for HackRF.""" - if device.serial and device.serial != 'N/A': - return f'driver=hackrf,serial={device.serial}' - return 'driver=hackrf' + if device.serial and device.serial != "N/A": + return f"driver=hackrf,serial={device.serial}" + return "driver=hackrf" def _split_gain(self, gain: float) -> tuple[int, int]: """ @@ -61,7 +61,7 @@ class HackRFCommandBuilder(CommandBuilder): ppm: int | None = None, modulation: str = "fm", squelch: int | None = None, - bias_t: bool = False + bias_t: bool = False, ) -> list[str]: """ Build SoapySDR rx_fm command for FM demodulation. @@ -70,36 +70,35 @@ class HackRFCommandBuilder(CommandBuilder): """ device_str = self._build_device_string(device) - rx_fm_path = get_tool_path('rx_fm') or 'rx_fm' + rx_fm_path = get_tool_path("rx_fm") or "rx_fm" cmd = [ rx_fm_path, - '-d', device_str, - '-f', f'{frequency_mhz}M', - '-M', modulation, - '-s', str(sample_rate), + "-d", + device_str, + "-f", + f"{frequency_mhz}M", + "-M", + modulation, + "-s", + str(sample_rate), ] if gain is not None and gain > 0: lna, vga = self._split_gain(gain) - cmd.extend(['-g', f'LNA={lna},VGA={vga}']) + cmd.extend(["-g", f"LNA={lna},VGA={vga}"]) if squelch is not None and squelch > 0: - cmd.extend(['-l', str(squelch)]) + cmd.extend(["-l", str(squelch)]) if bias_t: - cmd.extend(['-T']) + cmd.extend(["-T"]) # Output to stdout - cmd.append('-') + cmd.append("-") return cmd - def build_adsb_command( - self, - device: SDRDevice, - gain: float | None = None, - bias_t: bool = False - ) -> list[str]: + def build_adsb_command(self, device: SDRDevice, gain: float | None = None, bias_t: bool = False) -> list[str]: """ Build dump1090/readsb command with SoapySDR support for ADS-B decoding. @@ -107,19 +106,13 @@ class HackRFCommandBuilder(CommandBuilder): """ device_str = self._build_device_string(device) - cmd = [ - 'readsb', - '--net', - '--device-type', 'soapysdr', - '--device', device_str, - '--quiet' - ] + cmd = ["readsb", "--net", "--device-type", "soapysdr", "--device", device_str, "--quiet"] if gain is not None: - cmd.extend(['--gain', str(int(gain))]) + cmd.extend(["--gain", str(int(gain))]) if bias_t: - cmd.extend(['--enable-bias-t']) + cmd.extend(["--enable-bias-t"]) return cmd @@ -129,7 +122,7 @@ class HackRFCommandBuilder(CommandBuilder): frequency_mhz: float = 433.92, gain: float | None = None, ppm: int | None = None, - bias_t: bool = False + bias_t: bool = False, ) -> list[str]: """ Build rtl_433 command with SoapySDR support for ISM band decoding. @@ -142,17 +135,12 @@ class HackRFCommandBuilder(CommandBuilder): # Build device string with optional bias-t setting device_str = self._build_device_string(device) if bias_t: - device_str = f'{device_str},bias_t=1' + device_str = f"{device_str},bias_t=1" - cmd = [ - 'rtl_433', - '-d', device_str, - '-f', f'{frequency_mhz}M', - '-F', 'json' - ] + cmd = ["rtl_433", "-d", device_str, "-f", f"{frequency_mhz}M", "-F", "json"] if gain is not None and gain > 0: - cmd.extend(['-g', str(int(gain))]) + cmd.extend(["-g", str(int(gain))]) return cmd @@ -173,21 +161,24 @@ class HackRFCommandBuilder(CommandBuilder): device_str = self._build_device_string(device) cmd = [ - 'AIS-catcher', - '-d', f'soapysdr -d {device_str}', - '-S', str(tcp_port), - '-o', '5', - '-q', + "AIS-catcher", + "-d", + f"soapysdr -d {device_str}", + "-S", + str(tcp_port), + "-o", + "5", + "-q", ] if gain is not None and gain > 0: - cmd.extend(['-gr', 'tuner', str(int(gain))]) + cmd.extend(["-gr", "tuner", str(int(gain))]) if bias_t: - cmd.extend(['-gr', 'biastee', '1']) + cmd.extend(["-gr", "biastee", "1"]) if udp_host and udp_port: - cmd.extend(['-u', udp_host, str(udp_port)]) + cmd.extend(["-u", udp_host, str(udp_port)]) return cmd @@ -199,7 +190,7 @@ class HackRFCommandBuilder(CommandBuilder): gain: float | None = None, ppm: int | None = None, bias_t: bool = False, - output_format: str = 'cu8', + output_format: str = "cu8", ) -> list[str]: """ Build rx_sdr command for raw I/Q capture with HackRF. @@ -209,24 +200,28 @@ class HackRFCommandBuilder(CommandBuilder): device_str = self._build_device_string(device) freq_hz = int(frequency_mhz * 1e6) - rx_sdr_path = get_tool_path('rx_sdr') or 'rx_sdr' + rx_sdr_path = get_tool_path("rx_sdr") or "rx_sdr" cmd = [ rx_sdr_path, - '-d', device_str, - '-f', str(freq_hz), - '-s', str(sample_rate), - '-F', 'CU8', + "-d", + device_str, + "-f", + str(freq_hz), + "-s", + str(sample_rate), + "-F", + "CU8", ] if gain is not None and gain > 0: lna, vga = self._split_gain(gain) - cmd.extend(['-g', f'LNA={lna},VGA={vga}']) + cmd.extend(["-g", f"LNA={lna},VGA={vga}"]) if bias_t: - cmd.append('-T') + cmd.append("-T") # Output to stdout - cmd.append('-') + cmd.append("-") return cmd diff --git a/utils/sdr/limesdr.py b/utils/sdr/limesdr.py index 7b904a6..6a09e11 100644 --- a/utils/sdr/limesdr.py +++ b/utils/sdr/limesdr.py @@ -17,21 +17,21 @@ class LimeSDRCommandBuilder(CommandBuilder): CAPABILITIES = SDRCapabilities( sdr_type=SDRType.LIME_SDR, - freq_min_mhz=0.1, # 100 kHz - freq_max_mhz=3800.0, # 3.8 GHz + freq_min_mhz=0.1, # 100 kHz + freq_max_mhz=3800.0, # 3.8 GHz gain_min=0.0, - gain_max=73.0, # Combined LNA + TIA + PGA + gain_max=73.0, # Combined LNA + TIA + PGA sample_rates=[1000000, 2000000, 4000000, 8000000, 10000000, 20000000], supports_bias_t=False, - supports_ppm=False, # Uses TCXO, no PPM correction needed - tx_capable=True + supports_ppm=False, # Uses TCXO, no PPM correction needed + tx_capable=True, ) def _build_device_string(self, device: SDRDevice) -> str: """Build SoapySDR device string for LimeSDR.""" - if device.serial and device.serial != 'N/A': - return f'driver=lime,serial={device.serial}' - return 'driver=lime' + if device.serial and device.serial != "N/A": + return f"driver=lime,serial={device.serial}" + return "driver=lime" def build_fm_demod_command( self, @@ -42,7 +42,7 @@ class LimeSDRCommandBuilder(CommandBuilder): ppm: int | None = None, modulation: str = "fm", squelch: int | None = None, - bias_t: bool = False + bias_t: bool = False, ) -> list[str]: """ Build SoapySDR rx_fm command for FM demodulation. @@ -52,33 +52,32 @@ class LimeSDRCommandBuilder(CommandBuilder): """ device_str = self._build_device_string(device) - rx_fm_path = get_tool_path('rx_fm') or 'rx_fm' + rx_fm_path = get_tool_path("rx_fm") or "rx_fm" cmd = [ rx_fm_path, - '-d', device_str, - '-f', f'{frequency_mhz}M', - '-M', modulation, - '-s', str(sample_rate), + "-d", + device_str, + "-f", + f"{frequency_mhz}M", + "-M", + modulation, + "-s", + str(sample_rate), ] if gain is not None and gain > 0: # LimeSDR gain is applied to LNAH element - cmd.extend(['-g', f'LNAH={int(gain)}']) + cmd.extend(["-g", f"LNAH={int(gain)}"]) if squelch is not None and squelch > 0: - cmd.extend(['-l', str(squelch)]) + cmd.extend(["-l", str(squelch)]) # Output to stdout - cmd.append('-') + cmd.append("-") return cmd - def build_adsb_command( - self, - device: SDRDevice, - gain: float | None = None, - bias_t: bool = False - ) -> list[str]: + def build_adsb_command(self, device: SDRDevice, gain: float | None = None, bias_t: bool = False) -> list[str]: """ Build dump1090 command with SoapySDR support for ADS-B decoding. @@ -89,16 +88,10 @@ class LimeSDRCommandBuilder(CommandBuilder): device_str = self._build_device_string(device) # Try readsb first (better SoapySDR support), fallback to dump1090 - cmd = [ - 'readsb', - '--net', - '--device-type', 'soapysdr', - '--device', device_str, - '--quiet' - ] + cmd = ["readsb", "--net", "--device-type", "soapysdr", "--device", device_str, "--quiet"] if gain is not None: - cmd.extend(['--gain', str(int(gain))]) + cmd.extend(["--gain", str(int(gain))]) return cmd @@ -108,7 +101,7 @@ class LimeSDRCommandBuilder(CommandBuilder): frequency_mhz: float = 433.92, gain: float | None = None, ppm: int | None = None, - bias_t: bool = False + bias_t: bool = False, ) -> list[str]: """ Build rtl_433 command with SoapySDR support for ISM band decoding. @@ -118,20 +111,15 @@ class LimeSDRCommandBuilder(CommandBuilder): """ device_str = self._build_device_string(device) - cmd = [ - 'rtl_433', - '-d', device_str, - '-f', f'{frequency_mhz}M', - '-F', 'json' - ] + cmd = ["rtl_433", "-d", device_str, "-f", f"{frequency_mhz}M", "-F", "json"] if gain is not None and gain > 0: - cmd.extend(['-g', str(int(gain))]) + cmd.extend(["-g", str(int(gain))]) # PPM not typically needed for LimeSDR (TCXO) # but include if specified if ppm is not None and ppm != 0: - cmd.extend(['-p', str(ppm)]) + cmd.extend(["-p", str(ppm)]) return cmd @@ -153,18 +141,21 @@ class LimeSDRCommandBuilder(CommandBuilder): device_str = self._build_device_string(device) cmd = [ - 'AIS-catcher', - '-d', f'soapysdr -d {device_str}', - '-S', str(tcp_port), - '-o', '5', - '-q', + "AIS-catcher", + "-d", + f"soapysdr -d {device_str}", + "-S", + str(tcp_port), + "-o", + "5", + "-q", ] if gain is not None and gain > 0: - cmd.extend(['-gr', 'tuner', str(int(gain))]) + cmd.extend(["-gr", "tuner", str(int(gain))]) if udp_host and udp_port: - cmd.extend(['-u', udp_host, str(udp_port)]) + cmd.extend(["-u", udp_host, str(udp_port)]) return cmd @@ -176,7 +167,7 @@ class LimeSDRCommandBuilder(CommandBuilder): gain: float | None = None, ppm: int | None = None, bias_t: bool = False, - output_format: str = 'cu8', + output_format: str = "cu8", ) -> list[str]: """ Build rx_sdr command for raw I/Q capture with LimeSDR. @@ -187,20 +178,24 @@ class LimeSDRCommandBuilder(CommandBuilder): device_str = self._build_device_string(device) freq_hz = int(frequency_mhz * 1e6) - rx_sdr_path = get_tool_path('rx_sdr') or 'rx_sdr' + rx_sdr_path = get_tool_path("rx_sdr") or "rx_sdr" cmd = [ rx_sdr_path, - '-d', device_str, - '-f', str(freq_hz), - '-s', str(sample_rate), - '-F', 'CU8', + "-d", + device_str, + "-f", + str(freq_hz), + "-s", + str(sample_rate), + "-F", + "CU8", ] if gain is not None and gain > 0: - cmd.extend(['-g', f'LNAH={int(gain)}']) + cmd.extend(["-g", f"LNAH={int(gain)}"]) # Output to stdout - cmd.append('-') + cmd.append("-") return cmd diff --git a/utils/sdr/rtlsdr.py b/utils/sdr/rtlsdr.py index 51a9264..b1b8126 100644 --- a/utils/sdr/rtlsdr.py +++ b/utils/sdr/rtlsdr.py @@ -15,13 +15,13 @@ from utils.dependencies import get_tool_path from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType -logger = logging.getLogger('intercept.sdr.rtlsdr') +logger = logging.getLogger("intercept.sdr.rtlsdr") def _rtl_fm_demod_mode(modulation: str) -> str: """Map app/UI modulation names to rtl_fm demod tokens.""" - mod = str(modulation or '').lower().strip() - return 'wbfm' if mod == 'wfm' else mod + mod = str(modulation or "").lower().strip() + return "wbfm" if mod == "wfm" else mod def _rtl_tool_supports_bias_t(tool_path: str) -> bool: @@ -31,16 +31,11 @@ def _rtl_tool_supports_bias_t(tool_path: str) -> bool: rtl-sdr packages shipped by most distros. """ try: - result = subprocess.run( - [tool_path, '--help'], - capture_output=True, - text=True, - timeout=5 - ) + result = subprocess.run([tool_path, "--help"], capture_output=True, text=True, timeout=5) help_text = result.stdout + result.stderr # Match "-T" as a CLI flag (e.g. "[-T]" or "-T enable bias"), # not as part of "DVB-T" or similar text. - return bool(re.search(r'(? bool: Returns True if bias-t was enabled successfully. """ - rtl_biast_path = get_tool_path('rtl_biast') or 'rtl_biast' + rtl_biast_path = get_tool_path("rtl_biast") or "rtl_biast" try: result = subprocess.run( - [rtl_biast_path, '-b', '1', '-d', str(device_index)], - capture_output=True, - text=True, - timeout=5 + [rtl_biast_path, "-b", "1", "-d", str(device_index)], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: logger.info(f"Bias-t enabled via rtl_biast on device {device_index}") @@ -83,13 +75,10 @@ def disable_bias_t_via_rtl_biast(device_index: int = 0) -> bool: Returns True if bias-t was disabled successfully. """ - rtl_biast_path = get_tool_path('rtl_biast') or 'rtl_biast' + rtl_biast_path = get_tool_path("rtl_biast") or "rtl_biast" try: result = subprocess.run( - [rtl_biast_path, '-b', '0', '-d', str(device_index)], - capture_output=True, - text=True, - timeout=5 + [rtl_biast_path, "-b", "0", "-d", str(device_index)], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: logger.info(f"Bias-t disabled via rtl_biast on device {device_index}") @@ -114,17 +103,12 @@ def _get_dump1090_bias_t_flag(dump1090_path: str) -> str | None: Returns the correct flag string or None if bias-t is not supported. """ try: - result = subprocess.run( - [dump1090_path, '--help'], - capture_output=True, - text=True, - timeout=5 - ) + result = subprocess.run([dump1090_path, "--help"], capture_output=True, text=True, timeout=5) help_text = result.stdout + result.stderr # Check for dump1090-fa/readsb style flag (no hyphen) - if '--enable-biast' in help_text: - return '--enable-biast' + if "--enable-biast" in help_text: + return "--enable-biast" # No bias-t support found return None @@ -146,7 +130,7 @@ class RTLSDRCommandBuilder(CommandBuilder): supports_bias_t=True, supports_ppm=True, tx_capable=False, - supports_iq_capture=True + supports_iq_capture=True, ) def _get_device_arg(self, device: SDRDevice) -> str: @@ -180,50 +164,49 @@ class RTLSDRCommandBuilder(CommandBuilder): direct_sampling: Enable direct sampling mode (0=off, 1=I-branch, 2=Q-branch). Use 2 for HF reception below 24 MHz. """ - rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm' + rtl_fm_path = get_tool_path("rtl_fm") or "rtl_fm" demod_mode = _rtl_fm_demod_mode(modulation) cmd = [ rtl_fm_path, - '-d', self._get_device_arg(device), - '-f', f'{frequency_mhz}M', - '-M', demod_mode, - '-s', str(sample_rate), + "-d", + self._get_device_arg(device), + "-f", + f"{frequency_mhz}M", + "-M", + demod_mode, + "-s", + str(sample_rate), ] if gain is not None and gain > 0: - cmd.extend(['-g', str(gain)]) + cmd.extend(["-g", str(gain)]) if ppm is not None and ppm != 0: - cmd.extend(['-p', str(ppm)]) + cmd.extend(["-p", str(ppm)]) if squelch is not None and squelch > 0: - cmd.extend(['-l', str(squelch)]) + cmd.extend(["-l", str(squelch)]) if direct_sampling is not None: # Older rtl_fm builds (common in Docker/distro packages) don't # support -D; they use -E direct / -E direct2 instead. if direct_sampling == 1: - cmd.extend(['-E', 'direct']) + cmd.extend(["-E", "direct"]) elif direct_sampling == 2: - cmd.extend(['-E', 'direct2']) + cmd.extend(["-E", "direct2"]) if bias_t: if _rtl_tool_supports_bias_t(rtl_fm_path): - cmd.append('-T') + cmd.append("-T") else: logger.warning("Bias-t requested but rtl_fm does not support -T (RTL-SDR Blog drivers required).") # Output to stdout for piping - cmd.append('-') + cmd.append("-") return cmd - def build_adsb_command( - self, - device: SDRDevice, - gain: float | None = None, - bias_t: bool = False - ) -> list[str]: + def build_adsb_command(self, device: SDRDevice, gain: float | None = None, bias_t: bool = False) -> list[str]: """ Build dump1090 command for ADS-B decoding. @@ -239,16 +222,11 @@ class RTLSDRCommandBuilder(CommandBuilder): "connect to its SBS output (port 30003)." ) - dump1090_path = get_tool_path('dump1090') or 'dump1090' - cmd = [ - dump1090_path, - '--net', - '--device-index', str(device.index), - '--quiet' - ] + dump1090_path = get_tool_path("dump1090") or "dump1090" + cmd = [dump1090_path, "--net", "--device-index", str(device.index), "--quiet"] if gain is not None: - cmd.extend(['--gain', str(int(gain))]) + cmd.extend(["--gain", str(int(gain))]) if bias_t: bias_t_flag = _get_dump1090_bias_t_flag(dump1090_path) @@ -271,7 +249,7 @@ class RTLSDRCommandBuilder(CommandBuilder): frequency_mhz: float = 433.92, gain: float | None = None, ppm: int | None = None, - bias_t: bool = False + bias_t: bool = False, ) -> list[str]: """ Build rtl_433 command for ISM band sensor decoding. @@ -281,27 +259,22 @@ class RTLSDRCommandBuilder(CommandBuilder): Note: rtl_433's -T flag is for timeout, NOT bias-t. Bias-t is enabled via the device string suffix :biast=1 """ - rtl_433_path = get_tool_path('rtl_433') or 'rtl_433' + rtl_433_path = get_tool_path("rtl_433") or "rtl_433" # Build device argument with optional bias-t suffix # rtl_433 uses :biast=1 suffix on device string, not -T flag # (-T is timeout in rtl_433) device_arg = self._get_device_arg(device) if bias_t: - device_arg = f'{device_arg}:biast=1' + device_arg = f"{device_arg}:biast=1" - cmd = [ - rtl_433_path, - '-d', device_arg, - '-f', f'{frequency_mhz}M', - '-F', 'json' - ] + cmd = [rtl_433_path, "-d", device_arg, "-f", f"{frequency_mhz}M", "-F", "json"] if gain is not None and gain > 0: - cmd.extend(['-g', str(int(gain))]) + cmd.extend(["-g", str(int(gain))]) if ppm is not None and ppm != 0: - cmd.extend(['-p', str(ppm)]) + cmd.extend(["-p", str(ppm)]) return cmd @@ -322,25 +295,27 @@ class RTLSDRCommandBuilder(CommandBuilder): """ if device.is_network: raise ValueError( - "AIS-catcher does not support rtl_tcp. " - "For remote AIS, run AIS-catcher on the remote machine." + "AIS-catcher does not support rtl_tcp. For remote AIS, run AIS-catcher on the remote machine." ) cmd = [ - 'AIS-catcher', - f'-d:{device.index}', # Device index (colon format required) - '-S', str(tcp_port), 'JSON_FULL', 'on', # TCP server with full JSON output - '-q', # Quiet mode (less console output) + "AIS-catcher", + f"-d:{device.index}", # Device index (colon format required) + "-S", + str(tcp_port), + "JSON_FULL", + "on", # TCP server with full JSON output + "-q", # Quiet mode (less console output) ] if gain is not None and gain > 0: - cmd.extend(['-gr', 'TUNER', str(int(gain))]) + cmd.extend(["-gr", "TUNER", str(int(gain))]) if bias_t: - cmd.extend(['-gr', 'BIASTEE', 'on']) + cmd.extend(["-gr", "BIASTEE", "on"]) if udp_host and udp_port: - cmd.extend(['-u', udp_host, str(udp_port)]) + cmd.extend(["-u", udp_host, str(udp_port)]) return cmd @@ -352,37 +327,40 @@ class RTLSDRCommandBuilder(CommandBuilder): gain: float | None = None, ppm: int | None = None, bias_t: bool = False, - output_format: str = 'cu8', + output_format: str = "cu8", ) -> list[str]: """ Build rtl_sdr command for raw I/Q capture. Outputs unsigned 8-bit I/Q pairs to stdout for waterfall display. """ - rtl_sdr_path = get_tool_path('rtl_sdr') or 'rtl_sdr' + rtl_sdr_path = get_tool_path("rtl_sdr") or "rtl_sdr" freq_hz = int(frequency_mhz * 1e6) cmd = [ rtl_sdr_path, - '-d', self._get_device_arg(device), - '-f', str(freq_hz), - '-s', str(sample_rate), + "-d", + self._get_device_arg(device), + "-f", + str(freq_hz), + "-s", + str(sample_rate), ] if gain is not None and gain > 0: - cmd.extend(['-g', str(gain)]) + cmd.extend(["-g", str(gain)]) if ppm is not None and ppm != 0: - cmd.extend(['-p', str(ppm)]) + cmd.extend(["-p", str(ppm)]) if bias_t: if _rtl_tool_supports_bias_t(rtl_sdr_path): - cmd.append('-T') + cmd.append("-T") else: logger.warning("Bias-t requested but rtl_sdr does not support -T (RTL-SDR Blog drivers required).") # Output to stdout - cmd.append('-') + cmd.append("-") return cmd @@ -394,4 +372,3 @@ class RTLSDRCommandBuilder(CommandBuilder): def get_sdr_type(cls) -> SDRType: """Return SDR type.""" return SDRType.RTL_SDR - diff --git a/utils/sdr/sdrplay.py b/utils/sdr/sdrplay.py index 6b7293d..73c6788 100644 --- a/utils/sdr/sdrplay.py +++ b/utils/sdr/sdrplay.py @@ -18,21 +18,21 @@ class SDRPlayCommandBuilder(CommandBuilder): # SDRPlay RSP capabilities (RSPdx, RSP1A, RSPduo, etc.) CAPABILITIES = SDRCapabilities( sdr_type=SDRType.SDRPLAY, - freq_min_mhz=0.001, # 1 kHz - freq_max_mhz=2000.0, # 2 GHz + freq_min_mhz=0.001, # 1 kHz + freq_max_mhz=2000.0, # 2 GHz gain_min=0.0, - gain_max=59.0, # IFGR range + gain_max=59.0, # IFGR range sample_rates=[62500, 96000, 125000, 192000, 250000, 384000, 500000, 1000000, 2000000], supports_bias_t=True, - supports_ppm=False, # SDRPlay has TCXO, no PPM needed - tx_capable=False + supports_ppm=False, # SDRPlay has TCXO, no PPM needed + tx_capable=False, ) def _build_device_string(self, device: SDRDevice) -> str: """Build SoapySDR device string for SDRPlay.""" - if device.serial and device.serial != 'N/A': - return f'driver=sdrplay,serial={device.serial}' - return 'driver=sdrplay' + if device.serial and device.serial != "N/A": + return f"driver=sdrplay,serial={device.serial}" + return "driver=sdrplay" def build_fm_demod_command( self, @@ -43,7 +43,7 @@ class SDRPlayCommandBuilder(CommandBuilder): ppm: int | None = None, modulation: str = "fm", squelch: int | None = None, - bias_t: bool = False + bias_t: bool = False, ) -> list[str]: """ Build SoapySDR rx_fm command for FM demodulation. @@ -52,35 +52,34 @@ class SDRPlayCommandBuilder(CommandBuilder): """ device_str = self._build_device_string(device) - rx_fm_path = get_tool_path('rx_fm') or 'rx_fm' + rx_fm_path = get_tool_path("rx_fm") or "rx_fm" cmd = [ rx_fm_path, - '-d', device_str, - '-f', f'{frequency_mhz}M', - '-M', modulation, - '-s', str(sample_rate), + "-d", + device_str, + "-f", + f"{frequency_mhz}M", + "-M", + modulation, + "-s", + str(sample_rate), ] if gain is not None and gain > 0: - cmd.extend(['-g', f'IFGR={int(gain)}']) + cmd.extend(["-g", f"IFGR={int(gain)}"]) if squelch is not None and squelch > 0: - cmd.extend(['-l', str(squelch)]) + cmd.extend(["-l", str(squelch)]) if bias_t: - cmd.extend(['-T']) + cmd.extend(["-T"]) # Output to stdout - cmd.append('-') + cmd.append("-") return cmd - def build_adsb_command( - self, - device: SDRDevice, - gain: float | None = None, - bias_t: bool = False - ) -> list[str]: + def build_adsb_command(self, device: SDRDevice, gain: float | None = None, bias_t: bool = False) -> list[str]: """ Build dump1090/readsb command with SoapySDR support for ADS-B decoding. @@ -88,19 +87,13 @@ class SDRPlayCommandBuilder(CommandBuilder): """ device_str = self._build_device_string(device) - cmd = [ - 'readsb', - '--net', - '--device-type', 'soapysdr', - '--device', device_str, - '--quiet' - ] + cmd = ["readsb", "--net", "--device-type", "soapysdr", "--device", device_str, "--quiet"] if gain is not None: - cmd.extend(['--gain', str(int(gain))]) + cmd.extend(["--gain", str(int(gain))]) if bias_t: - cmd.extend(['--enable-bias-t']) + cmd.extend(["--enable-bias-t"]) return cmd @@ -110,7 +103,7 @@ class SDRPlayCommandBuilder(CommandBuilder): frequency_mhz: float = 433.92, gain: float | None = None, ppm: int | None = None, - bias_t: bool = False + bias_t: bool = False, ) -> list[str]: """ Build rtl_433 command with SoapySDR support for ISM band decoding. @@ -119,18 +112,13 @@ class SDRPlayCommandBuilder(CommandBuilder): """ device_str = self._build_device_string(device) - cmd = [ - 'rtl_433', - '-d', device_str, - '-f', f'{frequency_mhz}M', - '-F', 'json' - ] + cmd = ["rtl_433", "-d", device_str, "-f", f"{frequency_mhz}M", "-F", "json"] if gain is not None and gain > 0: - cmd.extend(['-g', str(int(gain))]) + cmd.extend(["-g", str(int(gain))]) if bias_t: - cmd.extend(['-T']) + cmd.extend(["-T"]) return cmd @@ -151,21 +139,24 @@ class SDRPlayCommandBuilder(CommandBuilder): device_str = self._build_device_string(device) cmd = [ - 'AIS-catcher', - '-d', f'soapysdr -d {device_str}', - '-S', str(tcp_port), - '-o', '5', - '-q', + "AIS-catcher", + "-d", + f"soapysdr -d {device_str}", + "-S", + str(tcp_port), + "-o", + "5", + "-q", ] if gain is not None and gain > 0: - cmd.extend(['-gr', 'tuner', str(int(gain))]) + cmd.extend(["-gr", "tuner", str(int(gain))]) if bias_t: - cmd.extend(['-gr', 'biastee', '1']) + cmd.extend(["-gr", "biastee", "1"]) if udp_host and udp_port: - cmd.extend(['-u', udp_host, str(udp_port)]) + cmd.extend(["-u", udp_host, str(udp_port)]) return cmd @@ -177,7 +168,7 @@ class SDRPlayCommandBuilder(CommandBuilder): gain: float | None = None, ppm: int | None = None, bias_t: bool = False, - output_format: str = 'cu8', + output_format: str = "cu8", ) -> list[str]: """ Build rx_sdr command for raw I/Q capture with SDRPlay. @@ -187,23 +178,27 @@ class SDRPlayCommandBuilder(CommandBuilder): device_str = self._build_device_string(device) freq_hz = int(frequency_mhz * 1e6) - rx_sdr_path = get_tool_path('rx_sdr') or 'rx_sdr' + rx_sdr_path = get_tool_path("rx_sdr") or "rx_sdr" cmd = [ rx_sdr_path, - '-d', device_str, - '-f', str(freq_hz), - '-s', str(sample_rate), - '-F', 'CU8', + "-d", + device_str, + "-f", + str(freq_hz), + "-s", + str(sample_rate), + "-F", + "CU8", ] if gain is not None and gain > 0: - cmd.extend(['-g', f'IFGR={int(gain)}']) + cmd.extend(["-g", f"IFGR={int(gain)}"]) if bias_t: - cmd.append('-T') + cmd.append("-T") # Output to stdout - cmd.append('-') + cmd.append("-") return cmd diff --git a/utils/sdr/validation.py b/utils/sdr/validation.py index 2099562..e2f1856 100644 --- a/utils/sdr/validation.py +++ b/utils/sdr/validation.py @@ -12,13 +12,12 @@ from .base import SDRCapabilities, SDRDevice, SDRType class SDRValidationError(ValueError): """Raised when SDR parameter validation fails.""" + pass def validate_frequency( - freq_mhz: float, - device: Optional[SDRDevice] = None, - capabilities: Optional[SDRCapabilities] = None + freq_mhz: float, device: Optional[SDRDevice] = None, capabilities: Optional[SDRCapabilities] = None ) -> float: """ Validate frequency against device capabilities. @@ -41,11 +40,7 @@ def validate_frequency( else: # Default RTL-SDR range for backwards compatibility caps = SDRCapabilities( - sdr_type=SDRType.RTL_SDR, - freq_min_mhz=24.0, - freq_max_mhz=1766.0, - gain_min=0.0, - gain_max=50.0 + sdr_type=SDRType.RTL_SDR, freq_min_mhz=24.0, freq_max_mhz=1766.0, gain_min=0.0, gain_max=50.0 ) if not caps.freq_min_mhz <= freq_mhz <= caps.freq_max_mhz: @@ -58,9 +53,7 @@ def validate_frequency( def validate_gain( - gain: float, - device: Optional[SDRDevice] = None, - capabilities: Optional[SDRCapabilities] = None + gain: float, device: Optional[SDRDevice] = None, capabilities: Optional[SDRCapabilities] = None ) -> float: """ Validate gain against device capabilities. @@ -83,11 +76,7 @@ def validate_gain( else: # Default range for backwards compatibility caps = SDRCapabilities( - sdr_type=SDRType.RTL_SDR, - freq_min_mhz=24.0, - freq_max_mhz=1766.0, - gain_min=0.0, - gain_max=50.0 + sdr_type=SDRType.RTL_SDR, freq_min_mhz=24.0, freq_max_mhz=1766.0, gain_min=0.0, gain_max=50.0 ) # Allow 0 for auto gain @@ -96,8 +85,7 @@ def validate_gain( if not caps.gain_min <= gain <= caps.gain_max: raise SDRValidationError( - f"Gain {gain} dB out of range for {caps.sdr_type.value}. " - f"Valid range: {caps.gain_min}-{caps.gain_max} dB" + f"Gain {gain} dB out of range for {caps.sdr_type.value}. Valid range: {caps.gain_min}-{caps.gain_max} dB" ) return gain @@ -107,7 +95,7 @@ def validate_sample_rate( rate: int, device: Optional[SDRDevice] = None, capabilities: Optional[SDRCapabilities] = None, - snap_to_nearest: bool = True + snap_to_nearest: bool = True, ) -> int: """ Validate sample rate against device capabilities. @@ -143,16 +131,11 @@ def validate_sample_rate( return closest raise SDRValidationError( - f"Sample rate {rate} Hz not supported by {caps.sdr_type.value}. " - f"Valid rates: {caps.sample_rates}" + f"Sample rate {rate} Hz not supported by {caps.sdr_type.value}. Valid rates: {caps.sample_rates}" ) -def validate_ppm( - ppm: int, - device: Optional[SDRDevice] = None, - capabilities: Optional[SDRCapabilities] = None -) -> int: +def validate_ppm(ppm: int, device: Optional[SDRDevice] = None, capabilities: Optional[SDRCapabilities] = None) -> int: """ Validate PPM frequency correction. @@ -183,9 +166,7 @@ def validate_ppm( # Standard PPM range if not -1000 <= ppm <= 1000: - raise SDRValidationError( - f"PPM correction {ppm} out of range. Valid range: -1000 to 1000" - ) + raise SDRValidationError(f"PPM correction {ppm} out of range. Valid range: -1000 to 1000") return ppm @@ -204,9 +185,7 @@ def validate_device_index(index: int) -> int: SDRValidationError: If index is out of range """ if not 0 <= index <= 255: - raise SDRValidationError( - f"Device index {index} out of range. Valid range: 0-255" - ) + raise SDRValidationError(f"Device index {index} out of range. Valid range: 0-255") return index @@ -224,9 +203,7 @@ def validate_squelch(squelch: int) -> int: SDRValidationError: If squelch is out of range """ if not 0 <= squelch <= 1000: - raise SDRValidationError( - f"Squelch {squelch} out of range. Valid range: 0-1000" - ) + raise SDRValidationError(f"Squelch {squelch} out of range. Valid range: 0-1000") return squelch diff --git a/utils/sigmf.py b/utils/sigmf.py index a8ad1dd..031a4ea 100644 --- a/utils/sigmf.py +++ b/utils/sigmf.py @@ -17,12 +17,12 @@ from typing import Any from utils.logging import get_logger -logger = get_logger('intercept.sigmf') +logger = get_logger("intercept.sigmf") # Abort recording if less than this many bytes are free on the disk DEFAULT_MIN_FREE_BYTES = 500 * 1024 * 1024 # 500 MB -OUTPUT_DIR = Path('instance/ground_station/recordings') +OUTPUT_DIR = Path("instance/ground_station/recordings") @dataclass @@ -36,48 +36,48 @@ class SigMFMetadata: sample_rate: int center_frequency_hz: float - datatype: str = 'cu8' # unsigned 8-bit I/Q (rtlsdr native) - description: str = '' - author: str = 'INTERCEPT ground station' - recorder: str = 'INTERCEPT' - hw: str = '' + datatype: str = "cu8" # unsigned 8-bit I/Q (rtlsdr native) + description: str = "" + author: str = "INTERCEPT ground station" + recorder: str = "INTERCEPT" + hw: str = "" norad_id: int = 0 - satellite_name: str = '' + satellite_name: str = "" latitude: float = 0.0 longitude: float = 0.0 annotations: list[dict[str, Any]] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: global_block: dict[str, Any] = { - 'core:datatype': self.datatype, - 'core:sample_rate': self.sample_rate, - 'core:version': '1.0.0', - 'core:recorder': self.recorder, + "core:datatype": self.datatype, + "core:sample_rate": self.sample_rate, + "core:version": "1.0.0", + "core:recorder": self.recorder, } if self.description: - global_block['core:description'] = self.description + global_block["core:description"] = self.description if self.author: - global_block['core:author'] = self.author + global_block["core:author"] = self.author if self.hw: - global_block['core:hw'] = self.hw + global_block["core:hw"] = self.hw if self.latitude or self.longitude: - global_block['core:geolocation'] = { - 'type': 'Point', - 'coordinates': [self.longitude, self.latitude], + global_block["core:geolocation"] = { + "type": "Point", + "coordinates": [self.longitude, self.latitude], } captures = [ { - 'core:sample_start': 0, - 'core:frequency': self.center_frequency_hz, - 'core:datetime': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + "core:sample_start": 0, + "core:frequency": self.center_frequency_hz, + "core:datetime": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), } ] return { - 'global': global_block, - 'captures': captures, - 'annotations': self.annotations, + "global": global_block, + "captures": captures, + "annotations": self.annotations, } @@ -109,9 +109,9 @@ class SigMFWriter: def open(self) -> None: """Create output directory and open the data file for writing.""" self._output_dir.mkdir(parents=True, exist_ok=True) - self._data_path = self._output_dir / f'{self._stem}.sigmf-data' - self._meta_path = self._output_dir / f'{self._stem}.sigmf-meta' - self._data_file = open(self._data_path, 'wb') + self._data_path = self._output_dir / f"{self._stem}.sigmf-data" + self._meta_path = self._output_dir / f"{self._stem}.sigmf-meta" + self._data_file = open(self._data_path, "wb") self._bytes_written = 0 self._aborted = False logger.info(f"SigMFWriter opened: {self._data_path}") @@ -168,15 +168,11 @@ class SigMFWriter: try: meta_dict = self._metadata.to_dict() - self._meta_path.write_text( - json.dumps(meta_dict, indent=2), encoding='utf-8' - ) + self._meta_path.write_text(json.dumps(meta_dict, indent=2), encoding="utf-8") except Exception as e: logger.error(f"Failed to write SigMF metadata: {e}") - logger.info( - f"SigMFWriter closed: {self._bytes_written} bytes → {self._data_path}" - ) + logger.info(f"SigMFWriter closed: {self._bytes_written} bytes → {self._data_path}") return self._meta_path, self._data_path @property @@ -202,7 +198,7 @@ class SigMFWriter: def _default_stem(meta: SigMFMetadata) -> str: - ts = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') - sat = (meta.satellite_name or 'unknown').replace(' ', '_').replace('/', '-') + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + sat = (meta.satellite_name or "unknown").replace(" ", "_").replace("/", "-") freq_khz = int(meta.center_frequency_hz / 1000) - return f'{ts}_{sat}_{freq_khz}kHz' + return f"{ts}_{sat}_{freq_khz}kHz" diff --git a/utils/signal_db.py b/utils/signal_db.py index 434f7ca..704b3a6 100644 --- a/utils/signal_db.py +++ b/utils/signal_db.py @@ -13,7 +13,7 @@ from typing import Any from utils.logging import get_logger -logger = get_logger('intercept.signal_db') +logger = get_logger("intercept.signal_db") _DB_PATH = Path(__file__).resolve().parent.parent / "data" / "signals.json" _cache: list[dict[str, Any]] | None = None @@ -109,8 +109,7 @@ def match_signals( elif bw_range["min_hz"] <= bandwidth_hz <= bw_range["max_hz"]: score += 30 reasons.append("bandwidth: within typical") - elif (bandwidth_hz <= bw_range["max_hz"] * 2 - and bandwidth_hz >= bw_range["min_hz"] // 2): + elif bandwidth_hz <= bw_range["max_hz"] * 2 and bandwidth_hz >= bw_range["min_hz"] // 2: score += 15 reasons.append("bandwidth: near typical") # else: 0 pts, no reason added diff --git a/utils/signal_guess.py b/utils/signal_guess.py index 419789c..a764be3 100644 --- a/utils/signal_guess.py +++ b/utils/signal_guess.py @@ -17,8 +17,10 @@ from enum import Enum # Confidence Levels # ============================================================================= + class Confidence(Enum): """Signal identification confidence level.""" + LOW = "LOW" MEDIUM = "MEDIUM" HIGH = "HIGH" @@ -28,9 +30,11 @@ class Confidence(Enum): # Signal Type Definitions # ============================================================================= + @dataclass class SignalTypeDefinition: """Definition of a known signal type with matching criteria.""" + label: str tags: list[str] description: str @@ -67,7 +71,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ base_score=15, regions=["UK/EU", "US", "GLOBAL"], ), - # Civil Aviation / Airband SignalTypeDefinition( label="Airband (Civil Aviation Voice)", @@ -81,7 +84,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ base_score=15, regions=["UK/EU", "US", "GLOBAL"], ), - # ISM 433 MHz (EU) SignalTypeDefinition( label="ISM Device (433 MHz)", @@ -96,7 +98,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ is_burst_type=True, regions=["UK/EU"], ), - # ISM 315 MHz (US) SignalTypeDefinition( label="ISM Device (315 MHz)", @@ -111,7 +112,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ is_burst_type=True, regions=["US"], ), - # ISM 868 MHz (EU) SignalTypeDefinition( label="ISM Device (868 MHz)", @@ -127,7 +127,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ is_burst_type=True, regions=["UK/EU"], ), - # ISM 915 MHz (US) SignalTypeDefinition( label="ISM Device (915 MHz)", @@ -142,7 +141,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ is_burst_type=True, regions=["US"], ), - # ISM 2.4 GHz (Global) SignalTypeDefinition( label="ISM Device (2.4 GHz)", @@ -156,7 +154,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ base_score=10, regions=["UK/EU", "US", "GLOBAL"], ), - # ISM 5.8 GHz (Global) SignalTypeDefinition( label="ISM Device (5.8 GHz)", @@ -170,7 +167,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ base_score=10, regions=["UK/EU", "US", "GLOBAL"], ), - # TPMS / Tire Pressure Monitoring SignalTypeDefinition( label="TPMS / Vehicle Telemetry", @@ -187,7 +183,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ is_burst_type=True, regions=["UK/EU", "US"], ), - # Cellular / LTE (broad category) SignalTypeDefinition( label="Cellular / Mobile Network", @@ -195,22 +190,21 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ description="Mobile network transmission (2G/3G/4G/5G)", frequency_ranges=[ # UK/EU common bands - (791_000_000, 862_000_000), # Band 20 (800 MHz) - (880_000_000, 960_000_000), # Band 8 (900 MHz GSM/UMTS) - (1_710_000_000, 1_880_000_000), # Band 3 (1800 MHz) - (1_920_000_000, 2_170_000_000), # Band 1 (2100 MHz UMTS) - (2_500_000_000, 2_690_000_000), # Band 7 (2600 MHz) + (791_000_000, 862_000_000), # Band 20 (800 MHz) + (880_000_000, 960_000_000), # Band 8 (900 MHz GSM/UMTS) + (1_710_000_000, 1_880_000_000), # Band 3 (1800 MHz) + (1_920_000_000, 2_170_000_000), # Band 1 (2100 MHz UMTS) + (2_500_000_000, 2_690_000_000), # Band 7 (2600 MHz) # US common bands - (698_000_000, 756_000_000), # Band 12/17 (700 MHz) - (824_000_000, 894_000_000), # Band 5 (850 MHz) - (1_850_000_000, 1_995_000_000), # Band 2/25 (1900 MHz PCS) + (698_000_000, 756_000_000), # Band 12/17 (700 MHz) + (824_000_000, 894_000_000), # Band 5 (850 MHz) + (1_850_000_000, 1_995_000_000), # Band 2/25 (1900 MHz PCS) ], modulation_hints=["OFDM", "QAM", "LTE", "4G", "5G", "GSM", "UMTS"], bandwidth_range=(200_000, 20_000_000), # 200 kHz (GSM) to 20 MHz (LTE) base_score=8, # Lower base due to broad category regions=["UK/EU", "US", "GLOBAL"], ), - # PMR446 (EU license-free) SignalTypeDefinition( label="PMR446 Radio", @@ -224,7 +218,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ base_score=14, regions=["UK/EU"], ), - # Marine VHF SignalTypeDefinition( label="Marine VHF Radio", @@ -238,7 +231,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ base_score=14, regions=["UK/EU", "US", "GLOBAL"], ), - # Amateur Radio 2m SignalTypeDefinition( label="Amateur Radio (2m)", @@ -252,7 +244,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ base_score=12, regions=["UK/EU", "US", "GLOBAL"], ), - # Amateur Radio 70cm SignalTypeDefinition( label="Amateur Radio (70cm)", @@ -266,7 +257,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ base_score=12, regions=["UK/EU", "US", "GLOBAL"], ), - # DECT Cordless Phones SignalTypeDefinition( label="DECT Cordless Phone", @@ -281,7 +271,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ base_score=12, regions=["UK/EU", "US"], ), - # DAB Digital Radio SignalTypeDefinition( label="DAB Digital Radio", @@ -295,7 +284,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ base_score=14, regions=["UK/EU"], ), - # Pager (POCSAG/FLEX) SignalTypeDefinition( label="Pager Network", @@ -311,7 +299,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ base_score=13, regions=["UK/EU", "US"], ), - # Weather Satellite (NOAA APT) SignalTypeDefinition( label="Weather Satellite (NOAA)", @@ -325,7 +312,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ base_score=14, regions=["GLOBAL"], ), - # ADS-B SignalTypeDefinition( label="ADS-B Aircraft Tracking", @@ -340,11 +326,10 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ is_burst_type=True, regions=["GLOBAL"], ), - - # Key Fob / Remote - SignalTypeDefinition( - label="Remote Control / Key Fob", - tags=["remote", "keyfob", "automotive", "burst", "ism"], + # Key Fob / Remote + SignalTypeDefinition( + label="Remote Control / Key Fob", + tags=["remote", "keyfob", "automotive", "burst", "ism"], description="Wireless remote control or vehicle key fob", frequency_ranges=[ (314_900_000, 315_100_000), # 315 MHz (US) @@ -364,9 +349,11 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [ # Signal Guess Result # ============================================================================= + @dataclass class SignalAlternative: """An alternative signal type guess.""" + label: str confidence: Confidence score: int @@ -375,6 +362,7 @@ class SignalAlternative: @dataclass class SignalGuessResult: """Complete signal guess result with hedged language.""" + primary_label: str confidence: Confidence alternatives: list[SignalAlternative] @@ -388,6 +376,7 @@ class SignalGuessResult: # Signal Guessing Engine # ============================================================================= + class SignalGuessingEngine: """ Heuristic-based signal identification engine. @@ -488,14 +477,14 @@ class SignalGuessingEngine: alt_score = scores[label] # Alternative confidence is always at most one level below primary # unless scores are very close - alt_confidence = self._calculate_alternative_confidence( - alt_score, primary_score, confidence + alt_confidence = self._calculate_alternative_confidence(alt_score, primary_score, confidence) + alternatives.append( + SignalAlternative( + label=label, + confidence=alt_confidence, + score=alt_score, + ) ) - alternatives.append(SignalAlternative( - label=label, - confidence=alt_confidence, - score=alt_score, - )) # Build explanation explanation = self._build_explanation( diff --git a/utils/sse.py b/utils/sse.py index 0a5a293..d7a465b 100644 --- a/utils/sse.py +++ b/utils/sse.py @@ -15,6 +15,7 @@ from typing import Any, Callable @dataclass class _QueueFanoutChannel: """Internal fanout state for a source queue.""" + source_queue: queue.Queue source_timeout: float subscribers: set[queue.Queue] = field(default_factory=set) @@ -154,7 +155,7 @@ def sse_stream_fanout( # Send an immediate keepalive so the browser receives response headers # right away (Werkzeug dev server buffers headers until first body byte). - yield format_sse({'type': 'keepalive'}) + yield format_sse({"type": "keepalive"}) try: while True: @@ -171,7 +172,7 @@ def sse_stream_fanout( except queue.Empty: now = time.time() if now - last_keepalive >= keepalive_interval: - yield format_sse({'type': 'keepalive'}) + yield format_sse({"type": "keepalive"}) last_keepalive = now finally: unsubscribe() @@ -228,7 +229,7 @@ def format_sse(data: dict[str, Any] | str, event: str | None = None) -> str: lines.append("") lines.append("") - return '\n'.join(lines) + return "\n".join(lines) def clear_queue(q: queue.Queue) -> int: diff --git a/utils/sstv/__init__.py b/utils/sstv/__init__.py index 092d9a8..d941d2e 100644 --- a/utils/sstv/__init__.py +++ b/utils/sstv/__init__.py @@ -20,14 +20,14 @@ from .sstv_decoder import ( ) __all__ = [ - 'DecodeProgress', - 'DopplerInfo', - 'DopplerTracker', - 'ISS_SSTV_FREQ', - 'SSTV_MODES', - 'SSTVDecoder', - 'SSTVImage', - 'get_general_sstv_decoder', - 'get_sstv_decoder', - 'is_sstv_available', + "DecodeProgress", + "DopplerInfo", + "DopplerTracker", + "ISS_SSTV_FREQ", + "SSTV_MODES", + "SSTVDecoder", + "SSTVImage", + "get_general_sstv_decoder", + "get_sstv_decoder", + "is_sstv_available", ] diff --git a/utils/sstv/constants.py b/utils/sstv/constants.py index f1a00d7..7dc9da6 100644 --- a/utils/sstv/constants.py +++ b/utils/sstv/constants.py @@ -20,17 +20,17 @@ STREAM_CHUNK_SAMPLES = 4800 # --------------------------------------------------------------------------- # SSTV tone frequencies (Hz) # --------------------------------------------------------------------------- -FREQ_VIS_BIT_1 = 1100 # VIS logic 1 -FREQ_SYNC = 1200 # Horizontal sync pulse -FREQ_VIS_BIT_0 = 1300 # VIS logic 0 -FREQ_BREAK = 1200 # Break tone in VIS header (same as sync) -FREQ_LEADER = 1900 # Leader / calibration tone -FREQ_BLACK = 1500 # Black level -FREQ_WHITE = 2300 # White level +FREQ_VIS_BIT_1 = 1100 # VIS logic 1 +FREQ_SYNC = 1200 # Horizontal sync pulse +FREQ_VIS_BIT_0 = 1300 # VIS logic 0 +FREQ_BREAK = 1200 # Break tone in VIS header (same as sync) +FREQ_LEADER = 1900 # Leader / calibration tone +FREQ_BLACK = 1500 # Black level +FREQ_WHITE = 2300 # White level # Pixel luminance mapping range -FREQ_PIXEL_LOW = 1500 # 0 luminance -FREQ_PIXEL_HIGH = 2300 # 255 luminance +FREQ_PIXEL_LOW = 1500 # 0 luminance +FREQ_PIXEL_HIGH = 2300 # 255 luminance # Frequency tolerance for tone detection (Hz) FREQ_TOLERANCE = 50 @@ -38,13 +38,13 @@ FREQ_TOLERANCE = 50 # --------------------------------------------------------------------------- # VIS header timing (seconds) # --------------------------------------------------------------------------- -VIS_LEADER_MIN = 0.200 # Minimum leader tone duration -VIS_LEADER_MAX = 0.500 # Maximum leader tone duration -VIS_LEADER_NOMINAL = 0.300 # Nominal leader tone duration -VIS_BREAK_DURATION = 0.010 # Break pulse duration (10 ms) -VIS_BIT_DURATION = 0.030 # Each VIS data bit (30 ms) +VIS_LEADER_MIN = 0.200 # Minimum leader tone duration +VIS_LEADER_MAX = 0.500 # Maximum leader tone duration +VIS_LEADER_NOMINAL = 0.300 # Nominal leader tone duration +VIS_BREAK_DURATION = 0.010 # Break pulse duration (10 ms) +VIS_BIT_DURATION = 0.030 # Each VIS data bit (30 ms) VIS_START_BIT_DURATION = 0.030 # Start bit (30 ms) -VIS_STOP_BIT_DURATION = 0.030 # Stop bit (30 ms) +VIS_STOP_BIT_DURATION = 0.030 # Stop bit (30 ms) # Timing tolerance for VIS detection VIS_TIMING_TOLERANCE = 0.5 # 50% tolerance on durations @@ -53,22 +53,22 @@ VIS_TIMING_TOLERANCE = 0.5 # 50% tolerance on durations # VIS code → mode name mapping # --------------------------------------------------------------------------- VIS_CODES: dict[int, str] = { - 8: 'Robot36', - 12: 'Robot72', - 44: 'Martin1', - 40: 'Martin2', - 60: 'Scottie1', - 56: 'Scottie2', - 95: 'PD120', - 97: 'PD180', + 8: "Robot36", + 12: "Robot72", + 44: "Martin1", + 40: "Martin2", + 60: "Scottie1", + 56: "Scottie2", + 95: "PD120", + 97: "PD180", # Less common but recognized - 4: 'Robot24', - 36: 'Martin3', - 52: 'Scottie3', - 76: 'ScottieDX', - 96: 'PD240', - 99: 'PD90', - 98: 'PD160', + 4: "Robot24", + 36: "Martin3", + 52: "Scottie3", + 76: "ScottieDX", + 96: "PD240", + 99: "PD90", + 98: "PD160", } # Reverse mapping: mode name → VIS code @@ -78,8 +78,14 @@ MODE_TO_VIS: dict[str, int] = {v: k for k, v in VIS_CODES.items()} # Common SSTV modes list (for UI / status) # --------------------------------------------------------------------------- SSTV_MODES = [ - 'PD120', 'PD180', 'Martin1', 'Martin2', - 'Scottie1', 'Scottie2', 'Robot36', 'Robot72', + "PD120", + "PD180", + "Martin1", + "Martin2", + "Scottie1", + "Scottie2", + "Robot36", + "Robot72", ] # ISS SSTV frequency diff --git a/utils/sstv/dsp.py b/utils/sstv/dsp.py index 3c1782a..e37dff1 100644 --- a/utils/sstv/dsp.py +++ b/utils/sstv/dsp.py @@ -18,8 +18,7 @@ from .constants import ( ) -def goertzel(samples: np.ndarray, target_freq: float, - sample_rate: int = SAMPLE_RATE) -> float: +def goertzel(samples: np.ndarray, target_freq: float, sample_rate: int = SAMPLE_RATE) -> float: """Compute Goertzel energy at a single target frequency. O(N) per frequency - more efficient than FFT when only a few @@ -56,8 +55,7 @@ def goertzel(samples: np.ndarray, target_freq: float, return s1 * s1 + s2 * s2 - coeff * s1 * s2 -def goertzel_mag(samples: np.ndarray, target_freq: float, - sample_rate: int = SAMPLE_RATE) -> float: +def goertzel_mag(samples: np.ndarray, target_freq: float, sample_rate: int = SAMPLE_RATE) -> float: """Compute Goertzel magnitude (square root of energy). Args: @@ -71,8 +69,9 @@ def goertzel_mag(samples: np.ndarray, target_freq: float, return math.sqrt(max(0.0, goertzel(samples, target_freq, sample_rate))) -def detect_tone(samples: np.ndarray, candidates: list[float], - sample_rate: int = SAMPLE_RATE) -> tuple[float | None, float]: +def detect_tone( + samples: np.ndarray, candidates: list[float], sample_rate: int = SAMPLE_RATE +) -> tuple[float | None, float]: """Detect which candidate frequency has the strongest energy. Args: @@ -98,16 +97,20 @@ def detect_tone(samples: np.ndarray, candidates: list[float], others = [e for f, e in energies.items() if f != max_freq] avg_others = sum(others) / len(others) if others else 0.0 - ratio = max_energy / avg_others if avg_others > 0 else float('inf') + ratio = max_energy / avg_others if avg_others > 0 else float("inf") if ratio >= MIN_ENERGY_RATIO: return max_freq, ratio return None, ratio -def estimate_frequency(samples: np.ndarray, freq_low: float = 1000.0, - freq_high: float = 2500.0, step: float = 25.0, - sample_rate: int = SAMPLE_RATE) -> float: +def estimate_frequency( + samples: np.ndarray, + freq_low: float = 1000.0, + freq_high: float = 2500.0, + step: float = 25.0, + sample_rate: int = SAMPLE_RATE, +) -> float: """Estimate the dominant frequency in a range using Goertzel sweep. Sweeps through frequencies in the given range and returns the one @@ -168,8 +171,7 @@ def freq_to_pixel(frequency: float) -> int: return max(0, min(255, int(normalized * 255 + 0.5))) -def samples_for_duration(duration_s: float, - sample_rate: int = SAMPLE_RATE) -> int: +def samples_for_duration(duration_s: float, sample_rate: int = SAMPLE_RATE) -> int: """Calculate number of samples for a given duration. Args: @@ -182,8 +184,7 @@ def samples_for_duration(duration_s: float, return int(duration_s * sample_rate + 0.5) -def goertzel_batch(audio_matrix: np.ndarray, frequencies: np.ndarray, - sample_rate: int = SAMPLE_RATE) -> np.ndarray: +def goertzel_batch(audio_matrix: np.ndarray, frequencies: np.ndarray, sample_rate: int = SAMPLE_RATE) -> np.ndarray: """Compute Goertzel energy for multiple audio segments at multiple frequencies. Vectorized implementation using numpy broadcasting. Processes all @@ -212,7 +213,7 @@ def goertzel_batch(audio_matrix: np.ndarray, frequencies: np.ndarray, s2 = np.zeros_like(s1) for n in range(N): - samples_n = audio_matrix[:, n:n + 1] # (M, 1) — broadcasts with (M, F) + samples_n = audio_matrix[:, n : n + 1] # (M, 1) — broadcasts with (M, F) s0 = samples_n + coeff * s1 - s2 s2 = s1 s1 = s0 diff --git a/utils/sstv/image_decoder.py b/utils/sstv/image_decoder.py index c90a6be..b8a1442 100644 --- a/utils/sstv/image_decoder.py +++ b/utils/sstv/image_decoder.py @@ -54,8 +54,7 @@ class SSTVImageDecoder: image = decoder.get_image() """ - def __init__(self, mode: SSTVMode, sample_rate: int = SAMPLE_RATE, - progress_cb: ProgressCallback | None = None): + def __init__(self, mode: SSTVMode, sample_rate: int = SAMPLE_RATE, progress_cb: ProgressCallback | None = None): self._mode = mode self._sample_rate = sample_rate self._progress_cb = progress_cb @@ -65,21 +64,16 @@ class SSTVImageDecoder: self._complete = False # Pre-calculate sample counts - self._sync_samples = samples_for_duration( - mode.sync_duration_ms / 1000.0, sample_rate) - self._porch_samples = samples_for_duration( - mode.sync_porch_ms / 1000.0, sample_rate) - self._line_samples = samples_for_duration( - mode.line_duration_ms / 1000.0, sample_rate) + self._sync_samples = samples_for_duration(mode.sync_duration_ms / 1000.0, sample_rate) + self._porch_samples = samples_for_duration(mode.sync_porch_ms / 1000.0, sample_rate) + self._line_samples = samples_for_duration(mode.line_duration_ms / 1000.0, sample_rate) self._separator_samples = ( samples_for_duration(mode.channel_separator_ms / 1000.0, sample_rate) - if mode.channel_separator_ms > 0 else 0 + if mode.channel_separator_ms > 0 + else 0 ) - self._channel_samples = [ - samples_for_duration(ch.duration_ms / 1000.0, sample_rate) - for ch in mode.channels - ] + self._channel_samples = [samples_for_duration(ch.duration_ms / 1000.0, sample_rate) for ch in mode.channels] # For PD modes, each "line" of audio produces 2 image lines if mode.color_model == ColorModel.YCRCB_DUAL: @@ -92,11 +86,9 @@ class SSTVImageDecoder: for _i, _ch_spec in enumerate(mode.channels): if mode.color_model == ColorModel.YCRCB_DUAL: # Y1, Cr, Cb, Y2 - all are width-wide - self._channel_data.append( - np.zeros((self._total_audio_lines, mode.width), dtype=np.uint8)) + self._channel_data.append(np.zeros((self._total_audio_lines, mode.width), dtype=np.uint8)) else: - self._channel_data.append( - np.zeros((mode.height, mode.width), dtype=np.uint8)) + self._channel_data.append(np.zeros((mode.height, mode.width), dtype=np.uint8)) # Track sync position for re-synchronization self._expected_line_start = 0 # Sample offset within buffer @@ -179,7 +171,7 @@ class SSTVImageDecoder: step = window_size // 2 for pos in range(0, len(search_region) - window_size, step): - chunk = search_region[pos:pos + window_size] + chunk = search_region[pos : pos + window_size] sync_energy = goertzel(chunk, FREQ_SYNC, self._sample_rate) # Check it's actually sync, not data at 1200 Hz area black_energy = goertzel(chunk, FREQ_BLACK, self._sample_rate) @@ -229,7 +221,7 @@ class SSTVImageDecoder: # Not enough data yet - put the data back and wait return - channel_audio = self._buffer[pos:pos + ch_samples] + channel_audio = self._buffer[pos : pos + ch_samples] pixels = self._decode_channel_pixels(channel_audio) self._channel_data[ch_idx][self._current_line, :] = pixels pos += ch_samples @@ -251,15 +243,11 @@ class SSTVImageDecoder: # a 2-sample/line drift; 800 samples covers ±200 ppm. bwd = min(800, pos) remaining = len(self._buffer) - pos - fwd = min(800, max( - 0, - remaining - self._sync_samples - - self._porch_samples - r_samples)) + fwd = min(800, max(0, remaining - self._sync_samples - self._porch_samples - r_samples)) deviation: float | None = None if bwd + fwd > 0: region_start = pos - bwd - sync_region = self._buffer[ - region_start: pos + self._sync_samples + fwd] + sync_region = self._buffer[region_start : pos + self._sync_samples + fwd] # Full-sync-length window gives best freq resolution # (~111 Hz at 48 kHz) to cleanly separate 1200 Hz # sync from 1500 Hz pixel data. @@ -269,29 +257,21 @@ class SSTVImageDecoder: # Step 5 samples (~0.1 ms) — enough resolution # for pixel-level drift, keeps batch size small. step = 5 - all_windows = ( - np.lib.stride_tricks.sliding_window_view( - sync_region, win)) + all_windows = np.lib.stride_tricks.sliding_window_view(sync_region, win) windows = all_windows[::step] - energies = goertzel_batch( - windows, - np.array([FREQ_SYNC, FREQ_BLACK]), - self._sample_rate) + energies = goertzel_batch(windows, np.array([FREQ_SYNC, FREQ_BLACK]), self._sample_rate) sync_e = energies[:, 0] black_e = energies[:, 1] valid_mask = sync_e > black_e * 2 if valid_mask.any(): - fine_idx = int( - np.argmax( - np.where(valid_mask, sync_e, 0.0))) + fine_idx = int(np.argmax(np.where(valid_mask, sync_e, 0.0))) deviation = float(fine_idx * step - bwd) self._sync_deviations.append(deviation) pos += self._sync_samples + self._porch_samples elif self._separator_samples > 0: # Robot: separator + porch between channels pos += self._separator_samples - elif (self._mode.sync_position == SyncPosition.FRONT - and self._mode.color_model == ColorModel.RGB): + elif self._mode.sync_position == SyncPosition.FRONT and self._mode.color_model == ColorModel.RGB: # Martin: porch between channels pos += self._porch_samples @@ -339,10 +319,10 @@ class SSTVImageDecoder: h = np.zeros(n) if n % 2 == 0: h[0] = h[n // 2] = 1 - h[1:n // 2] = 2 + h[1 : n // 2] = 2 else: h[0] = 1 - h[1:(n + 1) // 2] = 2 + h[1 : (n + 1) // 2] = 2 analytic = np.fft.ifft(spectrum * h) @@ -365,8 +345,7 @@ class SSTVImageDecoder: avg_freqs = sums / segment_lengths # Map to pixel values (1500 Hz → 0, 2300 Hz → 255) - normalized = (avg_freqs - FREQ_PIXEL_LOW) / ( - FREQ_PIXEL_HIGH - FREQ_PIXEL_LOW) + normalized = (avg_freqs - FREQ_PIXEL_LOW) / (FREQ_PIXEL_HIGH - FREQ_PIXEL_LOW) return np.clip(normalized * 255 + 0.5, 0, 255).astype(np.uint8) def get_image(self) -> Image.Image | None: @@ -401,8 +380,7 @@ class SSTVImageDecoder: measurements are averaged out; if fewer than 10 valid measurements exist the image is returned unchanged. """ - valid = [(i, d) for i, d in enumerate(self._sync_deviations) - if d is not None] + valid = [(i, d) for i, d in enumerate(self._sync_deviations) if d is not None] if len(valid) < 10: return img @@ -437,7 +415,7 @@ class SSTVImageDecoder: shift = -int(round(row * pixels_per_line)) corrected[row] = np.roll(arr[row], shift=shift, axis=0) - return Image.fromarray(corrected, 'RGB') + return Image.fromarray(corrected, "RGB") def _assemble_rgb(self) -> Image.Image: """Assemble RGB image from sequential R, G, B channel data. @@ -452,7 +430,7 @@ class SSTVImageDecoder: r_data = self._channel_data[2][:height] rgb = np.stack([r_data, g_data, b_data], axis=-1) - return Image.fromarray(rgb, 'RGB') + return Image.fromarray(rgb, "RGB") def _assemble_ycrcb(self) -> Image.Image: """Assemble image from YCrCb data (Robot modes). @@ -538,8 +516,7 @@ class SSTVImageDecoder: return self._ycrcb_to_rgb(y_full, cr_full, cb_full, height, width) @staticmethod - def _ycrcb_to_rgb(y: np.ndarray, cr: np.ndarray, cb: np.ndarray, - height: int, width: int) -> Image.Image: + def _ycrcb_to_rgb(y: np.ndarray, cr: np.ndarray, cb: np.ndarray, height: int, width: int) -> Image.Image: """Convert YCrCb pixel data to an RGB PIL Image. Uses the SSTV convention where pixel values 0-255 map to the @@ -562,4 +539,4 @@ class SSTVImageDecoder: b = np.clip(b, 0, 255).astype(np.uint8) rgb = np.stack([r, g, b], axis=-1) - return Image.fromarray(rgb, 'RGB') + return Image.fromarray(rgb, "RGB") diff --git a/utils/sstv/modes.py b/utils/sstv/modes.py index 4290c0a..35e2c3a 100644 --- a/utils/sstv/modes.py +++ b/utils/sstv/modes.py @@ -12,16 +12,18 @@ from dataclasses import dataclass, field class ColorModel(enum.Enum): """Color encoding models used by SSTV modes.""" - RGB = 'rgb' # Sequential R, G, B channels per line - YCRCB = 'ycrcb' # Luminance + chrominance (Robot modes) - YCRCB_DUAL = 'ycrcb_dual' # Dual-luminance YCrCb (PD modes) + + RGB = "rgb" # Sequential R, G, B channels per line + YCRCB = "ycrcb" # Luminance + chrominance (Robot modes) + YCRCB_DUAL = "ycrcb_dual" # Dual-luminance YCrCb (PD modes) class SyncPosition(enum.Enum): """Where the horizontal sync pulse appears in each line.""" - FRONT = 'front' # Sync at start of line (Robot, Martin) - MIDDLE = 'middle' # Sync between G and B channels (Scottie) - FRONT_PD = 'front_pd' # PD-style sync at start + + FRONT = "front" # Sync at start of line (Robot, Martin) + MIDDLE = "middle" # Sync between G and B channels (Scottie) + FRONT_PD = "front_pd" # PD-style sync at start @dataclass(frozen=True) @@ -31,6 +33,7 @@ class ChannelTiming: Attributes: duration_ms: Duration of this channel's pixel data in milliseconds. """ + duration_ms: float @@ -52,6 +55,7 @@ class SSTVMode: has_half_rate_chroma: Whether chroma is sent at half vertical rate (Robot modes: Cr and Cb alternate every other line). """ + name: str vis_code: int width: int @@ -71,7 +75,7 @@ class SSTVMode: # --------------------------------------------------------------------------- ROBOT_36 = SSTVMode( - name='Robot36', + name="Robot36", vis_code=8, width=320, height=240, @@ -80,8 +84,8 @@ ROBOT_36 = SSTVMode( sync_duration_ms=9.0, sync_porch_ms=3.0, channels=[ - ChannelTiming(duration_ms=88.0), # Y (luminance) - ChannelTiming(duration_ms=44.0), # Cr or Cb (alternating) + ChannelTiming(duration_ms=88.0), # Y (luminance) + ChannelTiming(duration_ms=44.0), # Cr or Cb (alternating) ], line_duration_ms=150.0, has_half_rate_chroma=True, @@ -89,7 +93,7 @@ ROBOT_36 = SSTVMode( ) ROBOT_72 = SSTVMode( - name='Robot72', + name="Robot72", vis_code=12, width=320, height=240, @@ -98,9 +102,9 @@ ROBOT_72 = SSTVMode( sync_duration_ms=9.0, sync_porch_ms=3.0, channels=[ - ChannelTiming(duration_ms=138.0), # Y (luminance) - ChannelTiming(duration_ms=69.0), # Cr - ChannelTiming(duration_ms=69.0), # Cb + ChannelTiming(duration_ms=138.0), # Y (luminance) + ChannelTiming(duration_ms=69.0), # Cr + ChannelTiming(duration_ms=69.0), # Cb ], line_duration_ms=300.0, has_half_rate_chroma=False, @@ -112,7 +116,7 @@ ROBOT_72 = SSTVMode( # --------------------------------------------------------------------------- MARTIN_1 = SSTVMode( - name='Martin1', + name="Martin1", vis_code=44, width=320, height=256, @@ -129,7 +133,7 @@ MARTIN_1 = SSTVMode( ) MARTIN_2 = SSTVMode( - name='Martin2', + name="Martin2", vis_code=40, width=320, height=256, @@ -138,9 +142,9 @@ MARTIN_2 = SSTVMode( sync_duration_ms=4.862, sync_porch_ms=0.572, channels=[ - ChannelTiming(duration_ms=73.216), # Green - ChannelTiming(duration_ms=73.216), # Blue - ChannelTiming(duration_ms=73.216), # Red + ChannelTiming(duration_ms=73.216), # Green + ChannelTiming(duration_ms=73.216), # Blue + ChannelTiming(duration_ms=73.216), # Red ], line_duration_ms=226.798, ) @@ -150,7 +154,7 @@ MARTIN_2 = SSTVMode( # --------------------------------------------------------------------------- SCOTTIE_1 = SSTVMode( - name='Scottie1', + name="Scottie1", vis_code=60, width=320, height=256, @@ -167,7 +171,7 @@ SCOTTIE_1 = SSTVMode( ) SCOTTIE_2 = SSTVMode( - name='Scottie2', + name="Scottie2", vis_code=56, width=320, height=256, @@ -176,9 +180,9 @@ SCOTTIE_2 = SSTVMode( sync_duration_ms=9.0, sync_porch_ms=1.5, channels=[ - ChannelTiming(duration_ms=88.064), # Green - ChannelTiming(duration_ms=88.064), # Blue - ChannelTiming(duration_ms=88.064), # Red + ChannelTiming(duration_ms=88.064), # Green + ChannelTiming(duration_ms=88.064), # Blue + ChannelTiming(duration_ms=88.064), # Red ], line_duration_ms=277.692, ) @@ -188,7 +192,7 @@ SCOTTIE_2 = SSTVMode( # --------------------------------------------------------------------------- PD_120 = SSTVMode( - name='PD120', + name="PD120", vis_code=95, width=640, height=496, @@ -206,7 +210,7 @@ PD_120 = SSTVMode( ) PD_180 = SSTVMode( - name='PD180', + name="PD180", vis_code=97, width=640, height=496, @@ -224,7 +228,7 @@ PD_180 = SSTVMode( ) PD_90 = SSTVMode( - name='PD90', + name="PD90", vis_code=99, width=640, height=496, @@ -233,16 +237,16 @@ PD_90 = SSTVMode( sync_duration_ms=20.0, sync_porch_ms=2.080, channels=[ - ChannelTiming(duration_ms=91.520), # Y1 - ChannelTiming(duration_ms=91.520), # Cr - ChannelTiming(duration_ms=91.520), # Cb - ChannelTiming(duration_ms=91.520), # Y2 + ChannelTiming(duration_ms=91.520), # Y1 + ChannelTiming(duration_ms=91.520), # Cr + ChannelTiming(duration_ms=91.520), # Cb + ChannelTiming(duration_ms=91.520), # Y2 ], line_duration_ms=388.160, ) PD_160 = SSTVMode( - name='PD160', + name="PD160", vis_code=98, width=640, height=496, @@ -260,7 +264,7 @@ PD_160 = SSTVMode( ) PD_240 = SSTVMode( - name='PD240', + name="PD240", vis_code=96, width=640, height=496, @@ -282,7 +286,7 @@ PD_240 = SSTVMode( # --------------------------------------------------------------------------- SCOTTIE_DX = SSTVMode( - name='ScottieDX', + name="ScottieDX", vis_code=76, width=320, height=256, @@ -304,11 +308,20 @@ SCOTTIE_DX = SSTVMode( # --------------------------------------------------------------------------- ALL_MODES: dict[int, SSTVMode] = { - m.vis_code: m for m in [ - ROBOT_36, ROBOT_72, - MARTIN_1, MARTIN_2, - SCOTTIE_1, SCOTTIE_2, SCOTTIE_DX, - PD_90, PD_120, PD_160, PD_180, PD_240, + m.vis_code: m + for m in [ + ROBOT_36, + ROBOT_72, + MARTIN_1, + MARTIN_2, + SCOTTIE_1, + SCOTTIE_2, + SCOTTIE_DX, + PD_90, + PD_120, + PD_160, + PD_180, + PD_240, ] } diff --git a/utils/sstv/sstv_decoder.py b/utils/sstv/sstv_decoder.py index 24497f7..0f46a89 100644 --- a/utils/sstv/sstv_decoder.py +++ b/utils/sstv/sstv_decoder.py @@ -35,7 +35,7 @@ from .image_decoder import SSTVImageDecoder from .modes import get_mode from .vis import VISDetector -logger = get_logger('intercept.sstv') +logger = get_logger("intercept.sstv") try: from PIL import Image as PILImage @@ -56,59 +56,61 @@ except ImportError: @dataclass class SSTVImage: """Decoded SSTV image.""" + filename: str path: Path mode: str timestamp: datetime frequency: float size_bytes: int = 0 - url_prefix: str = '/sstv' + url_prefix: str = "/sstv" def to_dict(self) -> dict: return { - 'filename': self.filename, - 'path': str(self.path), - 'mode': self.mode, - 'timestamp': self.timestamp.isoformat(), - 'frequency': self.frequency, - 'size_bytes': self.size_bytes, - 'url': f'{self.url_prefix}/images/{self.filename}' + "filename": self.filename, + "path": str(self.path), + "mode": self.mode, + "timestamp": self.timestamp.isoformat(), + "frequency": self.frequency, + "size_bytes": self.size_bytes, + "url": f"{self.url_prefix}/images/{self.filename}", } @dataclass class DecodeProgress: """SSTV decode progress update.""" + status: str # 'detecting', 'decoding', 'complete', 'error' mode: str | None = None progress_percent: int = 0 message: str | None = None image: SSTVImage | None = None signal_level: int | None = None # 0-100 RMS audio level, None = not measured - sstv_tone: str | None = None # 'leader', 'sync', 'noise', None - vis_state: str | None = None # VIS detector state name + sstv_tone: str | None = None # 'leader', 'sync', 'noise', None + vis_state: str | None = None # VIS detector state name partial_image: str | None = None # base64 data URL of partial decode def to_dict(self) -> dict: result: dict = { - 'type': 'sstv_progress', - 'status': self.status, - 'progress': self.progress_percent, + "type": "sstv_progress", + "status": self.status, + "progress": self.progress_percent, } if self.mode: - result['mode'] = self.mode + result["mode"] = self.mode if self.message: - result['message'] = self.message + result["message"] = self.message if self.image: - result['image'] = self.image.to_dict() + result["image"] = self.image.to_dict() if self.signal_level is not None: - result['signal_level'] = self.signal_level + result["signal_level"] = self.signal_level if self.sstv_tone: - result['sstv_tone'] = self.sstv_tone + result["sstv_tone"] = self.sstv_tone if self.vis_state: - result['vis_state'] = self.vis_state + result["vis_state"] = self.vis_state if self.partial_image: - result['partial_image'] = self.partial_image + result["partial_image"] = self.partial_image return result @@ -131,29 +133,30 @@ def _encode_scope_waveform(raw_samples: np.ndarray, window_size: int = 256) -> l # SSTVDecoder # --------------------------------------------------------------------------- + class SSTVDecoder: """SSTV decoder using pure-Python DSP with Doppler compensation.""" RETUNE_THRESHOLD_HZ = 500 DOPPLER_UPDATE_INTERVAL = 5 - def __init__(self, output_dir: str | Path | None = None, url_prefix: str = '/sstv'): + def __init__(self, output_dir: str | Path | None = None, url_prefix: str = "/sstv"): self._rtl_process = None self._running = False self._lock = threading.Lock() self._callback: Callable[[dict], None] | None = None - self._output_dir = Path(output_dir) if output_dir else Path('instance/sstv_images') + self._output_dir = Path(output_dir) if output_dir else Path("instance/sstv_images") self._url_prefix = url_prefix self._images: list[SSTVImage] = [] self._decode_thread = None self._doppler_thread = None self._frequency = ISS_SSTV_FREQ - self._modulation = 'fm' + self._modulation = "fm" self._current_tuned_freq_hz: int = 0 self._device_index = 0 # Doppler tracking - self._doppler_tracker = DopplerTracker('ISS') + self._doppler_tracker = DopplerTracker("ISS") self._doppler_enabled = False self._last_doppler_info: DopplerInfo | None = None @@ -167,7 +170,7 @@ class SSTVDecoder: @property def decoder_available(self) -> str: """Return name of available decoder. Always available with pure Python.""" - return 'python-sstv' + return "python-sstv" def set_callback(self, callback: Callable[[dict], None]) -> None: """Set callback for decode progress updates.""" @@ -179,7 +182,7 @@ class SSTVDecoder: device_index: int = 0, latitude: float | None = None, longitude: float | None = None, - modulation: str = 'fm', + modulation: str = "fm", ) -> bool: """Start SSTV decoder listening on specified frequency. @@ -220,30 +223,24 @@ class SSTVDecoder: # Start Doppler tracking thread if enabled if self._doppler_enabled: - self._doppler_thread = threading.Thread( - target=self._doppler_tracking_loop, daemon=True) + self._doppler_thread = threading.Thread(target=self._doppler_tracking_loop, daemon=True) self._doppler_thread.start() logger.info(f"SSTV decoder started on {frequency} MHz with Doppler tracking") - self._emit_progress(DecodeProgress( - status='detecting', - message=f'Listening on {frequency} MHz with Doppler tracking...' - )) + self._emit_progress( + DecodeProgress( + status="detecting", message=f"Listening on {frequency} MHz with Doppler tracking..." + ) + ) else: logger.info(f"SSTV decoder started on {frequency} MHz (no Doppler tracking)") - self._emit_progress(DecodeProgress( - status='detecting', - message=f'Listening on {frequency} MHz...' - )) + self._emit_progress(DecodeProgress(status="detecting", message=f"Listening on {frequency} MHz...")) return True except Exception as e: self._running = False logger.error(f"Failed to start SSTV decoder: {e}") - self._emit_progress(DecodeProgress( - status='error', - message=str(e) - )) + self._emit_progress(DecodeProgress(status="error", message=str(e))) return False def _get_doppler_corrected_freq_hz(self) -> int: @@ -267,27 +264,28 @@ class SSTVDecoder: def _start_pipeline(self, freq_hz: int) -> None: """Start the rtl_fm -> Python decode pipeline.""" rtl_cmd = [ - 'rtl_fm', - '-d', str(self._device_index), - '-f', str(freq_hz), - '-M', self._modulation, - '-s', str(SAMPLE_RATE), - '-r', str(SAMPLE_RATE), - '-l', '0', # No squelch - '-' + "rtl_fm", + "-d", + str(self._device_index), + "-f", + str(freq_hz), + "-M", + self._modulation, + "-s", + str(SAMPLE_RATE), + "-r", + str(SAMPLE_RATE), + "-l", + "0", # No squelch + "-", ] logger.info(f"Starting rtl_fm: {' '.join(rtl_cmd)}") - self._rtl_process = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + self._rtl_process = subprocess.Popen(rtl_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) # Start decode thread that reads from rtl_fm stdout - self._decode_thread = threading.Thread( - target=self._decode_audio_stream, daemon=True) + self._decode_thread = threading.Thread(target=self._decode_audio_stream, daemon=True) self._decode_thread.start() def _decode_audio_stream(self) -> None: @@ -304,7 +302,7 @@ class SSTVDecoder: last_partial_pct = -1 logger.info("Audio decode thread started") - rtl_fm_error: str = '' + rtl_fm_error: str = "" while self._running and self._rtl_process: try: @@ -312,16 +310,12 @@ class SSTVDecoder: if not raw_data: if self._running: # Read stderr to diagnose why rtl_fm exited - stderr_msg = '' + stderr_msg = "" if self._rtl_process and self._rtl_process.stderr: with contextlib.suppress(Exception): - stderr_msg = self._rtl_process.stderr.read().decode( - errors='replace').strip() + stderr_msg = self._rtl_process.stderr.read().decode(errors="replace").strip() rc = self._rtl_process.poll() if self._rtl_process else None - logger.warning( - f"rtl_fm stream ended unexpectedly " - f"(exit code: {rc})" - ) + logger.warning(f"rtl_fm stream ended unexpectedly (exit code: {rc})") if stderr_msg: logger.warning(f"rtl_fm stderr: {stderr_msg}") rtl_fm_error = stderr_msg @@ -331,7 +325,7 @@ class SSTVDecoder: n_samples = len(raw_data) // 2 if n_samples == 0: continue - raw_samples = np.frombuffer(raw_data[:n_samples * 2], dtype=np.int16) + raw_samples = np.frombuffer(raw_data[: n_samples * 2], dtype=np.int16) samples = normalize_audio(raw_samples) chunk_counter += 1 @@ -354,21 +348,23 @@ class SSTVDecoder: img = image_decoder.get_image() if img is not None: buf = io.BytesIO() - img.save(buf, format='JPEG', quality=40) - b64 = base64.b64encode(buf.getvalue()).decode('ascii') - partial_url = f'data:image/jpeg;base64,{b64}' + img.save(buf, format="JPEG", quality=40) + b64 = base64.b64encode(buf.getvalue()).decode("ascii") + partial_url = f"data:image/jpeg;base64,{b64}" except Exception: pass # Emit progress - self._emit_progress(DecodeProgress( - status='decoding', - mode=current_mode_name, - progress_percent=pct, - message=f'Decoding {current_mode_name}: {pct}%', - partial_image=partial_url, - )) - self._emit_scope(rms_val, peak_val, 'decoding', waveform) + self._emit_progress( + DecodeProgress( + status="decoding", + mode=current_mode_name, + progress_percent=pct, + message=f"Decoding {current_mode_name}: {pct}%", + partial_image=partial_url, + ) + ) + self._emit_scope(rms_val, peak_val, "decoding", waveform) if complete: # Save image @@ -386,8 +382,10 @@ class SSTVDecoder: # the image decoder before the next chunk arrives. remaining = vis_detector.remaining_buffer.copy() vis_detector.reset() - logger.info(f"VIS detected: code={vis_code}, mode={mode_name}, " - f"{len(remaining)} image-start samples retained") + logger.info( + f"VIS detected: code={vis_code}, mode={mode_name}, " + f"{len(remaining)} image-start samples retained" + ) mode_spec = get_mode(vis_code) if mode_spec: @@ -399,23 +397,27 @@ class SSTVDecoder: ) if len(remaining) > 0: image_decoder.feed(remaining) - self._emit_progress(DecodeProgress( - status='decoding', - mode=mode_name, - progress_percent=0, - message=f'Detected {mode_name} - decoding...' - )) + self._emit_progress( + DecodeProgress( + status="decoding", + mode=mode_name, + progress_percent=0, + message=f"Detected {mode_name} - decoding...", + ) + ) else: logger.warning(f"No mode spec for VIS code {vis_code}") - self._emit_progress(DecodeProgress( - status='detecting', - message=f'Detected unknown mode (VIS {vis_code}: {mode_name}) - unsupported', - )) + self._emit_progress( + DecodeProgress( + status="detecting", + message=f"Detected unknown mode (VIS {vis_code}: {mode_name}) - unsupported", + ) + ) # Emit signal level metrics every ~500ms (every 5th 100ms chunk) scope_tone: str | None = None if chunk_counter % 5 == 0 and image_decoder is None: - rms = float(np.sqrt(np.mean(samples ** 2))) + rms = float(np.sqrt(np.mean(samples**2))) signal_level = min(100, int(rms * 500)) leader_energy = goertzel_mag(samples, 1900.0, SAMPLE_RATE) @@ -425,26 +427,26 @@ class SSTVDecoder: # Require the tone to both exceed the noise floor AND # dominate the other tone by 2x to avoid false positives # from broadband noise. - if (leader_energy > noise_floor * 5 - and leader_energy > sync_energy * 2): - sstv_tone = 'leader' - elif (sync_energy > noise_floor * 5 - and sync_energy > leader_energy * 2): - sstv_tone = 'sync' + if leader_energy > noise_floor * 5 and leader_energy > sync_energy * 2: + sstv_tone = "leader" + elif sync_energy > noise_floor * 5 and sync_energy > leader_energy * 2: + sstv_tone = "sync" elif signal_level > 10: - sstv_tone = 'noise' + sstv_tone = "noise" else: sstv_tone = None scope_tone = sstv_tone - self._emit_progress(DecodeProgress( - status='detecting', - message='Listening...', - signal_level=signal_level, - sstv_tone=sstv_tone, - vis_state=vis_detector.state.value, - )) + self._emit_progress( + DecodeProgress( + status="detecting", + message="Listening...", + signal_level=signal_level, + sstv_tone=sstv_tone, + vis_state=vis_detector.state.value, + ) + ) self._emit_scope(rms_val, peak_val, scope_tone, waveform) @@ -473,37 +475,32 @@ class SSTVDecoder: if was_running: logger.warning("Audio decode thread stopped unexpectedly") - err_detail = rtl_fm_error.split('\n')[-1] if rtl_fm_error else '' - msg = f'rtl_fm failed: {err_detail}' if err_detail else 'Decode pipeline stopped unexpectedly' - self._emit_progress(DecodeProgress( - status='error', - message=msg - )) + err_detail = rtl_fm_error.split("\n")[-1] if rtl_fm_error else "" + msg = f"rtl_fm failed: {err_detail}" if err_detail else "Decode pipeline stopped unexpectedly" + self._emit_progress(DecodeProgress(status="error", message=msg)) else: logger.info("Audio decode thread stopped") - def _save_decoded_image(self, decoder: SSTVImageDecoder, - mode_name: str | None) -> None: + def _save_decoded_image(self, decoder: SSTVImageDecoder, mode_name: str | None) -> None: """Save a completed decoded image to disk.""" try: img = decoder.get_image() if img is None: logger.error("Failed to get image from decoder (Pillow not available?)") - self._emit_progress(DecodeProgress( - status='error', - message='Failed to create image - Pillow not installed' - )) + self._emit_progress( + DecodeProgress(status="error", message="Failed to create image - Pillow not installed") + ) return timestamp = datetime.now(timezone.utc) filename = f"sstv_{timestamp.strftime('%Y%m%d_%H%M%S')}_{mode_name or 'unknown'}.png" filepath = self._output_dir / filename - img.save(filepath, 'PNG') + img.save(filepath, "PNG") sstv_image = SSTVImage( filename=filename, path=filepath, - mode=mode_name or 'Unknown', + mode=mode_name or "Unknown", timestamp=timestamp, frequency=self._frequency, size_bytes=filepath.stat().st_size, @@ -512,20 +509,19 @@ class SSTVDecoder: self._images.append(sstv_image) logger.info(f"SSTV image saved: {filename} ({sstv_image.size_bytes} bytes)") - self._emit_progress(DecodeProgress( - status='complete', - mode=mode_name, - progress_percent=100, - message='Image decoded', - image=sstv_image, - )) + self._emit_progress( + DecodeProgress( + status="complete", + mode=mode_name, + progress_percent=100, + message="Image decoded", + image=sstv_image, + ) + ) except Exception as e: logger.error(f"Error saving decoded image: {e}") - self._emit_progress(DecodeProgress( - status='error', - message=f'Error saving image: {e}' - )) + self._emit_progress(DecodeProgress(status="error", message=f"Error saving image: {e}")) def _doppler_tracking_loop(self) -> None: """Background thread that monitors Doppler shift and retunes when needed.""" @@ -552,10 +548,12 @@ class SSTVDecoder: f"diff from tuned: {freq_diff} Hz" ) - self._emit_progress(DecodeProgress( - status='detecting', - message=f'Doppler: {doppler_info.shift_hz:+.0f} Hz, elevation: {doppler_info.elevation:.1f}\u00b0' - )) + self._emit_progress( + DecodeProgress( + status="detecting", + message=f"Doppler: {doppler_info.shift_hz:+.0f} Hz, elevation: {doppler_info.elevation:.1f}\u00b0", + ) + ) if freq_diff >= self.RETUNE_THRESHOLD_HZ: logger.info( @@ -589,23 +587,25 @@ class SSTVDecoder: # Build and start new process outside lock rtl_cmd = [ - 'rtl_fm', - '-d', str(self._device_index), - '-f', str(new_freq_hz), - '-M', self._modulation, - '-s', str(SAMPLE_RATE), - '-r', str(SAMPLE_RATE), - '-l', '0', - '-' + "rtl_fm", + "-d", + str(self._device_index), + "-f", + str(new_freq_hz), + "-M", + self._modulation, + "-s", + str(SAMPLE_RATE), + "-r", + str(SAMPLE_RATE), + "-l", + "0", + "-", ] logger.debug(f"Restarting rtl_fm: {' '.join(rtl_cmd)}") - new_proc = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + new_proc = subprocess.Popen(rtl_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) # Re-acquire lock to install new process with self._lock: @@ -665,7 +665,7 @@ class SSTVDecoder: def delete_all_images(self) -> int: """Delete all decoded images. Returns count deleted.""" count = 0 - for filepath in self._output_dir.glob('*.png'): + for filepath in self._output_dir.glob("*.png"): filepath.unlink() count += 1 self._images.clear() @@ -676,14 +676,14 @@ class SSTVDecoder: """Scan output directory for images.""" known_filenames = {img.filename for img in self._images} - for filepath in self._output_dir.glob('*.png'): + for filepath in self._output_dir.glob("*.png"): if filepath.name not in known_filenames: try: stat = filepath.stat() image = SSTVImage( filename=filepath.name, path=filepath, - mode='Unknown', + mode="Unknown", timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), frequency=self._frequency, size_bytes=stat.st_size, @@ -711,9 +711,9 @@ class SSTVDecoder: """Emit scope signal levels to callback.""" if self._callback: try: - payload = {'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone} + payload = {"type": "sstv_scope", "rms": rms, "peak": peak, "tone": tone} if waveform: - payload['waveform'] = waveform + payload["waveform"] = waveform self._callback(payload) except Exception: pass @@ -739,15 +739,14 @@ class SSTVDecoder: images: list[SSTVImage] = [] try: - with wave.open(str(audio_path), 'rb') as wf: + with wave.open(str(audio_path), "rb") as wf: n_channels = wf.getnchannels() sample_width = wf.getsampwidth() file_sample_rate = wf.getframerate() n_frames = wf.getnframes() logger.info( - f"Decoding WAV: {n_channels}ch, {sample_width*8}bit, " - f"{file_sample_rate}Hz, {n_frames} frames" + f"Decoding WAV: {n_channels}ch, {sample_width * 8}bit, {file_sample_rate}Hz, {n_frames} frames" ) # Read all audio data @@ -780,7 +779,7 @@ class SSTVDecoder: offset = 0 while offset < len(audio): - chunk = audio[offset:offset + chunk_size] + chunk = audio[offset : offset + chunk_size] offset += chunk_size if image_decoder is not None: @@ -789,14 +788,16 @@ class SSTVDecoder: img = image_decoder.get_image() if img is not None: timestamp = datetime.now(timezone.utc) - filename = f"sstv_{timestamp.strftime('%Y%m%d_%H%M%S')}_{current_mode_name or 'unknown'}.png" + filename = ( + f"sstv_{timestamp.strftime('%Y%m%d_%H%M%S')}_{current_mode_name or 'unknown'}.png" + ) filepath = self._output_dir / filename - img.save(filepath, 'PNG') + img.save(filepath, "PNG") sstv_image = SSTVImage( filename=filename, path=filepath, - mode=current_mode_name or 'Unknown', + mode=current_mode_name or "Unknown", timestamp=timestamp, frequency=0, size_bytes=filepath.stat().st_size, @@ -879,7 +880,7 @@ def get_general_sstv_decoder() -> SSTVDecoder: global _general_decoder if _general_decoder is None: _general_decoder = SSTVDecoder( - output_dir='instance/sstv_general_images', - url_prefix='/sstv-general', + output_dir="instance/sstv_general_images", + url_prefix="/sstv-general", ) return _general_decoder diff --git a/utils/sstv/vis.py b/utils/sstv/vis.py index 9694cfd..61804d1 100644 --- a/utils/sstv/vis.py +++ b/utils/sstv/vis.py @@ -40,15 +40,16 @@ VIS_WINDOW = 480 class VISState(enum.Enum): """States of the VIS detection state machine.""" - IDLE = 'idle' - LEADER_1 = 'leader_1' - BREAK = 'break' - LEADER_2 = 'leader_2' - START_BIT = 'start_bit' - DATA_BITS = 'data_bits' - PARITY = 'parity' - STOP_BIT = 'stop_bit' - DETECTED = 'detected' + + IDLE = "idle" + LEADER_1 = "leader_1" + BREAK = "break" + LEADER_2 = "leader_2" + START_BIT = "start_bit" + DATA_BITS = "data_bits" + PARITY = "parity" + STOP_BIT = "stop_bit" + DETECTED = "detected" # The four tone classes we need to distinguish in VIS detection. @@ -56,8 +57,7 @@ _VIS_FREQS = [FREQ_VIS_BIT_1, FREQ_SYNC, FREQ_VIS_BIT_0, FREQ_LEADER] # 1100, 1200, 1300, 1900 Hz -def _classify_tone(samples: np.ndarray, - sample_rate: int = SAMPLE_RATE) -> float | None: +def _classify_tone(samples: np.ndarray, sample_rate: int = SAMPLE_RATE) -> float | None: """Classify which VIS tone is present in the given samples. Computes Goertzel energy at each of the four VIS frequencies and returns @@ -78,8 +78,7 @@ def _classify_tone(samples: np.ndarray, # Require the best frequency to be at least 2x stronger than the # next-strongest tone. - others = sorted( - [e for f, e in energies.items() if f != best_freq], reverse=True) + others = sorted([e for f, e in energies.items() if f != best_freq], reverse=True) second_best = others[0] if others else 0.0 if second_best > 0 and best_energy / second_best < 2.0: @@ -170,8 +169,8 @@ class VISDetector: self._buffer = np.concatenate([self._buffer, samples]) while len(self._buffer) >= self._window: - result = self._process_window(self._buffer[:self._window]) - self._buffer = self._buffer[self._window:] + result = self._process_window(self._buffer[: self._window]) + self._buffer = self._buffer[self._window :] if result is not None: return result @@ -387,9 +386,7 @@ class VISDetector: for flip in range(8): corrected = vis_code ^ (1 << flip) # Flipping one data bit should fix parity too - corrected_parity_ok = ( - bin(corrected).count('1') + self._parity_bit - ) % 2 == 0 + corrected_parity_ok = (bin(corrected).count("1") + self._parity_bit) % 2 == 0 if corrected_parity_ok: mode_name = VIS_CODES.get(corrected) if mode_name is not None: diff --git a/utils/temporal_patterns.py b/utils/temporal_patterns.py index 1ee93df..657c284 100644 --- a/utils/temporal_patterns.py +++ b/utils/temporal_patterns.py @@ -35,8 +35,8 @@ class TemporalPatternDetector: for key in keys: result = self._analyze_intervals(self._timestamps.get(key, [])) if result: - result['device_id'] = device_id - result['mode'] = key.split(':')[0] + result["device_id"] = device_id + result["mode"] = key.split(":")[0] return result return None @@ -62,9 +62,9 @@ class TemporalPatternDetector: return None return { - 'period_seconds': round(median, 1), - 'confidence': round(confidence, 3), - 'occurrences': len(timestamps), + "period_seconds": round(median, 1), + "confidence": round(confidence, 3), + "occurrences": len(timestamps), } def get_all_patterns(self) -> list[dict]: @@ -72,7 +72,7 @@ class TemporalPatternDetector: results = [] seen = set() for key in self._timestamps: - mode, device_id = key.split(':', 1) + mode, device_id = key.split(":", 1) if device_id in seen: continue pattern = self.detect_patterns(device_id, mode) diff --git a/utils/trilateration.py b/utils/trilateration.py index c4ecb89..4335205 100644 --- a/utils/trilateration.py +++ b/utils/trilateration.py @@ -14,16 +14,18 @@ import math from dataclasses import dataclass, field from datetime import datetime, timezone -logger = logging.getLogger('intercept.trilateration') +logger = logging.getLogger("intercept.trilateration") # ============================================================================= # Data Classes # ============================================================================= + @dataclass class AgentObservation: """A single observation of a device by an agent.""" + agent_name: str agent_lat: float agent_lon: float @@ -35,6 +37,7 @@ class AgentObservation: @dataclass class LocationEstimate: """Estimated location of a device with confidence metrics.""" + latitude: float longitude: float accuracy_meters: float # Estimated accuracy radius @@ -47,14 +50,14 @@ class LocationEstimate: def to_dict(self) -> dict: """Convert to JSON-serializable dictionary.""" return { - 'latitude': self.latitude, - 'longitude': self.longitude, - 'accuracy_meters': self.accuracy_meters, - 'confidence': self.confidence, - 'num_observations': self.num_observations, - 'method': self.method, - 'timestamp': self.timestamp.isoformat(), - 'agents': [obs.agent_name for obs in self.observations] + "latitude": self.latitude, + "longitude": self.longitude, + "accuracy_meters": self.accuracy_meters, + "confidence": self.confidence, + "num_observations": self.num_observations, + "method": self.method, + "timestamp": self.timestamp.isoformat(), + "agents": [obs.agent_name for obs in self.observations], } @@ -62,6 +65,7 @@ class LocationEstimate: # Path Loss Models # ============================================================================= + class PathLossModel: """ Convert RSSI to estimated distance using path loss models. @@ -79,25 +83,22 @@ class PathLossModel: # Default parameters for different environments ENVIRONMENTS = { - 'free_space': {'n': 2.0, 'rssi_ref': -40}, - 'outdoor': {'n': 2.5, 'rssi_ref': -45}, - 'indoor': {'n': 3.0, 'rssi_ref': -50}, - 'indoor_obstructed': {'n': 4.0, 'rssi_ref': -55}, + "free_space": {"n": 2.0, "rssi_ref": -40}, + "outdoor": {"n": 2.5, "rssi_ref": -45}, + "indoor": {"n": 3.0, "rssi_ref": -50}, + "indoor_obstructed": {"n": 4.0, "rssi_ref": -55}, } # Frequency-specific reference RSSI adjustments (WiFi vs Bluetooth) FREQUENCY_ADJUSTMENTS = { - 2400: 0, # 2.4 GHz WiFi/Bluetooth - baseline - 5000: -3, # 5 GHz WiFi - weaker propagation - 900: +5, # 900 MHz ISM - better propagation - 433: +8, # 433 MHz sensors - even better + 2400: 0, # 2.4 GHz WiFi/Bluetooth - baseline + 5000: -3, # 5 GHz WiFi - weaker propagation + 900: +5, # 900 MHz ISM - better propagation + 433: +8, # 433 MHz sensors - even better } def __init__( - self, - environment: str = 'outdoor', - path_loss_exponent: float | None = None, - reference_rssi: float | None = None + self, environment: str = "outdoor", path_loss_exponent: float | None = None, reference_rssi: float | None = None ): """ Initialize path loss model. @@ -107,15 +108,11 @@ class PathLossModel: path_loss_exponent: Override the environment's default n value reference_rssi: Override the environment's default RSSI at 1m """ - env_params = self.ENVIRONMENTS.get(environment, self.ENVIRONMENTS['outdoor']) - self.n = path_loss_exponent if path_loss_exponent is not None else env_params['n'] - self.rssi_ref = reference_rssi if reference_rssi is not None else env_params['rssi_ref'] + env_params = self.ENVIRONMENTS.get(environment, self.ENVIRONMENTS["outdoor"]) + self.n = path_loss_exponent if path_loss_exponent is not None else env_params["n"] + self.rssi_ref = reference_rssi if reference_rssi is not None else env_params["rssi_ref"] - def rssi_to_distance( - self, - rssi: float, - frequency_mhz: float | None = None - ) -> float: + def rssi_to_distance(self, rssi: float, frequency_mhz: float | None = None) -> float: """ Convert RSSI to estimated distance in meters. @@ -146,11 +143,7 @@ class PathLossModel: except (ValueError, OverflowError): return 100.0 # Default fallback - def distance_to_rssi( - self, - distance: float, - frequency_mhz: float | None = None - ) -> float: + def distance_to_rssi(self, distance: float, frequency_mhz: float | None = None) -> float: """ Estimate RSSI at a given distance (inverse of rssi_to_distance). Useful for testing and validation. @@ -174,6 +167,7 @@ class PathLossModel: # Geographic Utilities # ============================================================================= + def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: """ Calculate the great-circle distance between two points in meters. @@ -187,8 +181,7 @@ def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> fl delta_phi = math.radians(lat2 - lat1) delta_lambda = math.radians(lon2 - lon1) - a = math.sin(delta_phi / 2) ** 2 + \ - math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2) ** 2 + a = math.sin(delta_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2) ** 2 c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) return R * c @@ -225,6 +218,7 @@ def offset_position(lat: float, lon: float, north_m: float, east_m: float) -> tu # Trilateration Algorithm # ============================================================================= + class Trilateration: """ Estimate device location using multilateration from multiple RSSI observations. @@ -240,7 +234,7 @@ class Trilateration: path_loss_model: PathLossModel | None = None, min_observations: int = 2, max_iterations: int = 100, - convergence_threshold: float = 0.1 # meters + convergence_threshold: float = 0.1, # meters ): """ Initialize trilateration engine. @@ -256,10 +250,7 @@ class Trilateration: self.max_iterations = max_iterations self.convergence_threshold = convergence_threshold - def estimate_location( - self, - observations: list[AgentObservation] - ) -> LocationEstimate | None: + def estimate_location(self, observations: list[AgentObservation]) -> LocationEstimate | None: """ Estimate device location from multiple agent observations. @@ -275,9 +266,12 @@ class Trilateration: # Filter out observations with invalid coordinates valid_obs = [ - obs for obs in observations - if obs.agent_lat is not None and obs.agent_lon is not None - and -90 <= obs.agent_lat <= 90 and -180 <= obs.agent_lon <= 180 + obs + for obs in observations + if obs.agent_lat is not None + and obs.agent_lon is not None + and -90 <= obs.agent_lat <= 90 + and -180 <= obs.agent_lon <= 180 ] if len(valid_obs) < self.min_observations: @@ -307,13 +301,10 @@ class Trilateration: total_error = 0.0 for obs, expected_dist in zip(valid_obs, distances): - actual_dist = haversine_distance( - current_lat, current_lon, - obs.agent_lat, obs.agent_lon - ) + actual_dist = haversine_distance(current_lat, current_lon, obs.agent_lat, obs.agent_lon) error = actual_dist - expected_dist - total_error += error ** 2 + total_error += error**2 if actual_dist > 0.1: # Avoid division by zero # Gradient components @@ -350,10 +341,7 @@ class Trilateration: # Calculate accuracy estimate (average distance error) total_error = 0.0 for obs, expected_dist in zip(valid_obs, distances): - actual_dist = haversine_distance( - current_lat, current_lon, - obs.agent_lat, obs.agent_lon - ) + actual_dist = haversine_distance(current_lat, current_lon, obs.agent_lat, obs.agent_lon) total_error += abs(actual_dist - expected_dist) avg_error = total_error / len(valid_obs) @@ -367,7 +355,7 @@ class Trilateration: error_factor = max(0.0, 1.0 - avg_error / 500.0) # Decreases as error increases rssi_factor = min(1.0, max(0.0, (max(obs.rssi for obs in valid_obs) + 90) / 50.0)) - confidence = (obs_factor * 0.3 + error_factor * 0.5 + rssi_factor * 0.2) + confidence = obs_factor * 0.3 + error_factor * 0.5 + rssi_factor * 0.2 return LocationEstimate( latitude=current_lat, @@ -376,7 +364,7 @@ class Trilateration: confidence=confidence, num_observations=len(valid_obs), observations=valid_obs, - method="multilateration" + method="multilateration", ) @@ -384,6 +372,7 @@ class Trilateration: # Device Location Tracker # ============================================================================= + class DeviceLocationTracker: """ Track device locations over time using observations from multiple agents. @@ -396,7 +385,7 @@ class DeviceLocationTracker: self, trilateration: Trilateration | None = None, observation_window_seconds: float = 60.0, - min_observations: int = 2 + min_observations: int = 2, ): """ Initialize device tracker. @@ -424,7 +413,7 @@ class DeviceLocationTracker: agent_lon: float, rssi: float, frequency_mhz: float | None = None, - timestamp: datetime | None = None + timestamp: datetime | None = None, ) -> LocationEstimate | None: """ Add an observation and potentially update location estimate. @@ -447,7 +436,7 @@ class DeviceLocationTracker: agent_lon=agent_lon, rssi=rssi, frequency_mhz=frequency_mhz, - timestamp=timestamp or datetime.now(timezone.utc) + timestamp=timestamp or datetime.now(timezone.utc), ) if device_id not in self.observations: @@ -467,8 +456,7 @@ class DeviceLocationTracker: cutoff = now.timestamp() - self.observation_window self.observations[device_id] = [ - obs for obs in self.observations[device_id] - if obs.timestamp.timestamp() > cutoff + obs for obs in self.observations[device_id] if obs.timestamp.timestamp() > cutoff ] def _update_location(self, device_id: str) -> LocationEstimate | None: @@ -501,12 +489,7 @@ class DeviceLocationTracker: """Get all current location estimates.""" return dict(self.locations) - def get_devices_near( - self, - lat: float, - lon: float, - radius_meters: float - ) -> list[tuple[str, LocationEstimate]]: + def get_devices_near(self, lat: float, lon: float, radius_meters: float) -> list[tuple[str, LocationEstimate]]: """Find all tracked devices within radius of a point.""" results = [] for device_id, estimate in self.locations.items(): @@ -525,10 +508,8 @@ class DeviceLocationTracker: # Convenience Functions # ============================================================================= -def estimate_location_from_observations( - observations: list[dict], - environment: str = 'outdoor' -) -> dict | None: + +def estimate_location_from_observations(observations: list[dict], environment: str = "outdoor") -> dict | None: """ Convenience function to estimate location from a list of observation dicts. @@ -555,17 +536,17 @@ def estimate_location_from_observations( """ obs_list = [] for obs in observations: - obs_list.append(AgentObservation( - agent_name=obs.get('agent_name', 'unknown'), - agent_lat=obs['agent_lat'], - agent_lon=obs['agent_lon'], - rssi=obs['rssi'], - frequency_mhz=obs.get('frequency_mhz') - )) + obs_list.append( + AgentObservation( + agent_name=obs.get("agent_name", "unknown"), + agent_lat=obs["agent_lat"], + agent_lon=obs["agent_lon"], + rssi=obs["rssi"], + frequency_mhz=obs.get("frequency_mhz"), + ) + ) - trilat = Trilateration( - path_loss_model=PathLossModel(environment=environment) - ) + trilat = Trilateration(path_loss_model=PathLossModel(environment=environment)) estimate = trilat.estimate_location(obs_list) return estimate.to_dict() if estimate else None diff --git a/utils/tscm/__init__.py b/utils/tscm/__init__.py index 062d447..d17c355 100644 --- a/utils/tscm/__init__.py +++ b/utils/tscm/__init__.py @@ -8,4 +8,4 @@ for counter-surveillance operations. from __future__ import annotations -__all__ = ['detector', 'baseline', 'correlation', 'ble_scanner', 'device_identity'] +__all__ = ["detector", "baseline", "correlation", "ble_scanner", "device_identity"] diff --git a/utils/tscm/advanced.py b/utils/tscm/advanced.py index 305448f..60c9453 100644 --- a/utils/tscm/advanced.py +++ b/utils/tscm/advanced.py @@ -26,33 +26,37 @@ from datetime import datetime, timedelta from enum import Enum from typing import Any -logger = logging.getLogger('intercept.tscm.advanced') +logger = logging.getLogger("intercept.tscm.advanced") # ============================================================================= # 1. Capability & Coverage Reality Panel # ============================================================================= + class WifiMode(Enum): """WiFi adapter operating modes.""" - MONITOR = 'monitor' - MANAGED = 'managed' - UNAVAILABLE = 'unavailable' + + MONITOR = "monitor" + MANAGED = "managed" + UNAVAILABLE = "unavailable" class BluetoothMode(Enum): """Bluetooth adapter capabilities.""" - BLE_CLASSIC = 'ble_classic' - BLE_ONLY = 'ble_only' - LIMITED = 'limited' - UNAVAILABLE = 'unavailable' + + BLE_CLASSIC = "ble_classic" + BLE_ONLY = "ble_only" + LIMITED = "limited" + UNAVAILABLE = "unavailable" @dataclass class RFCapability: """RF/SDR device capabilities.""" - device_type: str = 'none' - driver: str = '' + + device_type: str = "none" + driver: str = "" min_frequency_mhz: float = 0.0 max_frequency_mhz: float = 0.0 sample_rate_max: int = 0 @@ -68,22 +72,23 @@ class SweepCapabilities: Exposes what the current sweep CAN and CANNOT detect based on OS, privileges, adapters, and SDR hardware limits. """ + # System info - os_name: str = '' - os_version: str = '' + os_name: str = "" + os_version: str = "" is_root: bool = False # WiFi capabilities wifi_mode: WifiMode = WifiMode.UNAVAILABLE - wifi_interface: str = '' - wifi_driver: str = '' + wifi_interface: str = "" + wifi_driver: str = "" wifi_monitor_capable: bool = False wifi_limitations: list[str] = field(default_factory=list) # Bluetooth capabilities bt_mode: BluetoothMode = BluetoothMode.UNAVAILABLE - bt_adapter: str = '' - bt_version: str = '' + bt_adapter: str = "" + bt_version: str = "" bt_limitations: list[str] = field(default_factory=list) # RF/SDR capabilities @@ -98,38 +103,38 @@ class SweepCapabilities: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'system': { - 'os': self.os_name, - 'os_version': self.os_version, - 'is_root': self.is_root, + "system": { + "os": self.os_name, + "os_version": self.os_version, + "is_root": self.is_root, }, - 'wifi': { - 'mode': self.wifi_mode.value, - 'interface': self.wifi_interface, - 'driver': self.wifi_driver, - 'monitor_capable': self.wifi_monitor_capable, - 'limitations': self.wifi_limitations, + "wifi": { + "mode": self.wifi_mode.value, + "interface": self.wifi_interface, + "driver": self.wifi_driver, + "monitor_capable": self.wifi_monitor_capable, + "limitations": self.wifi_limitations, }, - 'bluetooth': { - 'mode': self.bt_mode.value, - 'adapter': self.bt_adapter, - 'version': self.bt_version, - 'limitations': self.bt_limitations, + "bluetooth": { + "mode": self.bt_mode.value, + "adapter": self.bt_adapter, + "version": self.bt_version, + "limitations": self.bt_limitations, }, - 'rf': { - 'device_type': self.rf_capability.device_type, - 'driver': self.rf_capability.driver, - 'frequency_range_mhz': { - 'min': self.rf_capability.min_frequency_mhz, - 'max': self.rf_capability.max_frequency_mhz, + "rf": { + "device_type": self.rf_capability.device_type, + "driver": self.rf_capability.driver, + "frequency_range_mhz": { + "min": self.rf_capability.min_frequency_mhz, + "max": self.rf_capability.max_frequency_mhz, }, - 'sample_rate_max': self.rf_capability.sample_rate_max, - 'available': self.rf_capability.available, - 'limitations': self.rf_capability.limitations, + "sample_rate_max": self.rf_capability.sample_rate_max, + "available": self.rf_capability.available, + "limitations": self.rf_capability.limitations, }, - 'all_limitations': self.all_limitations, - 'captured_at': self.captured_at.isoformat(), - 'disclaimer': ( + "all_limitations": self.all_limitations, + "captured_at": self.captured_at.isoformat(), + "disclaimer": ( "Capabilities are detected at sweep start time and may change. " "Limitations listed affect what this sweep can reliably detect." ), @@ -137,9 +142,7 @@ class SweepCapabilities: def detect_sweep_capabilities( - wifi_interface: str = '', - bt_adapter: str = '', - sdr_device: Any = None + wifi_interface: str = "", bt_adapter: str = "", sdr_device: Any = None ) -> SweepCapabilities: """ Detect current system capabilities for TSCM sweeping. @@ -157,7 +160,7 @@ def detect_sweep_capabilities( # System info caps.os_name = platform.system() caps.os_version = platform.release() - caps.is_root = os.geteuid() == 0 if hasattr(os, 'geteuid') else False + caps.is_root = os.geteuid() == 0 if hasattr(os, "geteuid") else False # Detect WiFi capabilities _detect_wifi_capabilities(caps, wifi_interface) @@ -169,17 +172,11 @@ def detect_sweep_capabilities( _detect_rf_capabilities(caps, sdr_device) # Compile all limitations - caps.all_limitations = ( - caps.wifi_limitations + - caps.bt_limitations + - caps.rf_capability.limitations - ) + caps.all_limitations = caps.wifi_limitations + caps.bt_limitations + caps.rf_capability.limitations # Add privilege-based limitations if not caps.is_root: - caps.all_limitations.append( - "Running without root privileges - some features may be limited" - ) + caps.all_limitations.append("Running without root privileges - some features may be limited") return caps @@ -188,12 +185,12 @@ def _detect_wifi_capabilities(caps: SweepCapabilities, interface: str) -> None: """Detect WiFi adapter capabilities.""" caps.wifi_interface = interface - if platform.system() == 'Darwin': + if platform.system() == "Darwin": # macOS: Check for WiFi capability using multiple methods wifi_available = False # Method 1: Check airport utility (older macOS) - airport_path = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport' + airport_path = "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport" if os.path.exists(airport_path): wifi_available = True @@ -201,12 +198,9 @@ def _detect_wifi_capabilities(caps: SweepCapabilities, interface: str) -> None: if not wifi_available: try: result = subprocess.run( - ['networksetup', '-listallhardwareports'], - capture_output=True, - text=True, - timeout=5 + ["networksetup", "-listallhardwareports"], capture_output=True, text=True, timeout=5 ) - if 'Wi-Fi' in result.stdout or 'AirPort' in result.stdout: + if "Wi-Fi" in result.stdout or "AirPort" in result.stdout: wifi_available = True except Exception: pass @@ -214,12 +208,7 @@ def _detect_wifi_capabilities(caps: SweepCapabilities, interface: str) -> None: # Method 3: Check if en0 exists (common WiFi interface on macOS) if not wifi_available: try: - result = subprocess.run( - ['ifconfig', 'en0'], - capture_output=True, - text=True, - timeout=5 - ) + result = subprocess.run(["ifconfig", "en0"], capture_output=True, text=True, timeout=5) if result.returncode == 0: wifi_available = True except Exception: @@ -227,7 +216,7 @@ def _detect_wifi_capabilities(caps: SweepCapabilities, interface: str) -> None: if wifi_available: caps.wifi_mode = WifiMode.MANAGED - caps.wifi_driver = 'apple80211' + caps.wifi_driver = "apple80211" caps.wifi_monitor_capable = False caps.wifi_limitations = [ "macOS WiFi operates in managed mode only.", @@ -242,31 +231,22 @@ def _detect_wifi_capabilities(caps: SweepCapabilities, interface: str) -> None: # Linux: Check for monitor mode capability try: # Check if interface supports monitor mode - result = subprocess.run( - ['iw', 'list'], - capture_output=True, - text=True, - timeout=5 - ) + result = subprocess.run(["iw", "list"], capture_output=True, text=True, timeout=5) - if 'monitor' in result.stdout.lower(): + if "monitor" in result.stdout.lower(): # Check current mode if interface: mode_result = subprocess.run( - ['iw', 'dev', interface, 'info'], - capture_output=True, - text=True, - timeout=5 + ["iw", "dev", interface, "info"], capture_output=True, text=True, timeout=5 ) - if 'type monitor' in mode_result.stdout.lower(): + if "type monitor" in mode_result.stdout.lower(): caps.wifi_mode = WifiMode.MONITOR caps.wifi_monitor_capable = True else: caps.wifi_mode = WifiMode.MANAGED caps.wifi_monitor_capable = True caps.wifi_limitations.append( - "WiFi interface in managed mode. " - "Probe requests and deauth detection require monitor mode." + "WiFi interface in managed mode. Probe requests and deauth detection require monitor mode." ) else: caps.wifi_mode = WifiMode.MANAGED @@ -283,7 +263,7 @@ def _detect_wifi_capabilities(caps: SweepCapabilities, interface: str) -> None: # Get driver info if interface: try: - driver_path = f'/sys/class/net/{interface}/device/driver' + driver_path = f"/sys/class/net/{interface}/device/driver" if os.path.exists(driver_path): caps.wifi_driver = os.path.basename(os.readlink(driver_path)) except Exception: @@ -298,18 +278,15 @@ def _detect_bluetooth_capabilities(caps: SweepCapabilities, adapter: str) -> Non """Detect Bluetooth adapter capabilities.""" caps.bt_adapter = adapter - if platform.system() == 'Darwin': + if platform.system() == "Darwin": # macOS: Use system_profiler try: result = subprocess.run( - ['system_profiler', 'SPBluetoothDataType', '-json'], - capture_output=True, - text=True, - timeout=10 + ["system_profiler", "SPBluetoothDataType", "-json"], capture_output=True, text=True, timeout=10 ) - if 'Bluetooth' in result.stdout: + if "Bluetooth" in result.stdout: caps.bt_mode = BluetoothMode.BLE_CLASSIC - caps.bt_version = 'macOS CoreBluetooth' + caps.bt_version = "macOS CoreBluetooth" caps.bt_limitations = [ "BLE scanning limited to advertising devices only.", "Classic Bluetooth discovery may be incomplete.", @@ -325,16 +302,11 @@ def _detect_bluetooth_capabilities(caps: SweepCapabilities, adapter: str) -> Non else: # Linux: Check bluetoothctl/hciconfig try: - result = subprocess.run( - ['hciconfig', '-a'], - capture_output=True, - text=True, - timeout=5 - ) + result = subprocess.run(["hciconfig", "-a"], capture_output=True, text=True, timeout=5) - if 'hci' in result.stdout.lower(): + if "hci" in result.stdout.lower(): # Check for BLE support - if 'le' in result.stdout.lower(): + if "le" in result.stdout.lower(): caps.bt_mode = BluetoothMode.BLE_CLASSIC caps.bt_limitations = [ "BLE scanning range depends on adapter sensitivity.", @@ -348,8 +320,8 @@ def _detect_bluetooth_capabilities(caps: SweepCapabilities, adapter: str) -> Non ] # Extract version - for line in result.stdout.split('\n'): - if 'hci version' in line.lower(): + for line in result.stdout.split("\n"): + if "hci version" in line.lower(): caps.bt_version = line.strip() break else: @@ -367,18 +339,19 @@ def _detect_rf_capabilities(caps: SweepCapabilities, sdr_device: Any) -> None: try: from utils.sdr import SDRFactory + devices = SDRFactory.detect_devices() if devices: device = devices[0] # Use first device rf_cap.available = True - rf_cap.device_type = getattr(device, 'sdr_type', 'unknown') - if hasattr(rf_cap.device_type, 'value'): + rf_cap.device_type = getattr(device, "sdr_type", "unknown") + if hasattr(rf_cap.device_type, "value"): rf_cap.device_type = rf_cap.device_type.value - rf_cap.driver = getattr(device, 'driver', '') + rf_cap.driver = getattr(device, "driver", "") # Set frequency ranges based on device type - if 'rtl' in rf_cap.device_type.lower(): + if "rtl" in rf_cap.device_type.lower(): rf_cap.min_frequency_mhz = 24.0 rf_cap.max_frequency_mhz = 1766.0 rf_cap.sample_rate_max = 3200000 @@ -388,7 +361,7 @@ def _detect_rf_capabilities(caps: SweepCapabilities, sdr_device: Any) -> None: "Cannot cover microwave bands (>1.8 GHz) without upconverter.", "Signal detection limited by SDR noise floor and dynamic range.", ] - elif 'hackrf' in rf_cap.device_type.lower(): + elif "hackrf" in rf_cap.device_type.lower(): rf_cap.min_frequency_mhz = 1.0 rf_cap.max_frequency_mhz = 6000.0 rf_cap.sample_rate_max = 20000000 @@ -403,7 +376,7 @@ def _detect_rf_capabilities(caps: SweepCapabilities, sdr_device: Any) -> None: ] else: rf_cap.available = False - rf_cap.device_type = 'none' + rf_cap.device_type = "none" rf_cap.limitations = [ "No SDR device detected.", "RF spectrum analysis is not available in this sweep.", @@ -427,16 +400,19 @@ def _detect_rf_capabilities(caps: SweepCapabilities, sdr_device: Any) -> None: # 2. Baseline Diff & Baseline Health # ============================================================================= + class BaselineHealth(Enum): """Baseline health status.""" - HEALTHY = 'healthy' - NOISY = 'noisy' - STALE = 'stale' + + HEALTHY = "healthy" + NOISY = "noisy" + STALE = "stale" @dataclass class DeviceChange: """Represents a change detected compared to baseline.""" + identifier: str protocol: str change_type: str # 'new', 'missing', 'rssi_drift', 'channel_change', 'security_change' @@ -453,6 +429,7 @@ class BaselineDiff: Shows what changed, whether baseline is reliable, and separates expected vs unexpected changes. """ + baseline_id: int sweep_id: int @@ -482,80 +459,80 @@ class BaselineDiff: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'baseline_id': self.baseline_id, - 'sweep_id': self.sweep_id, - 'health': { - 'status': self.health.value, - 'score': round(self.health_score, 2), - 'reasons': self.health_reasons, + "baseline_id": self.baseline_id, + "sweep_id": self.sweep_id, + "health": { + "status": self.health.value, + "score": round(self.health_score, 2), + "reasons": self.health_reasons, }, - 'age': { - 'hours': round(self.baseline_age_hours, 1), - 'is_stale': self.is_stale, + "age": { + "hours": round(self.baseline_age_hours, 1), + "is_stale": self.is_stale, }, - 'summary': { - 'new_devices': self.total_new, - 'missing_devices': self.total_missing, - 'changed_devices': self.total_changed, - 'expected_changes': len(self.expected_changes), - 'unexpected_changes': len(self.unexpected_changes), + "summary": { + "new_devices": self.total_new, + "missing_devices": self.total_missing, + "changed_devices": self.total_changed, + "expected_changes": len(self.expected_changes), + "unexpected_changes": len(self.unexpected_changes), }, - 'new_devices': [ - {'identifier': d.identifier, 'protocol': d.protocol, - 'description': d.description, 'details': d.details} + "new_devices": [ + {"identifier": d.identifier, "protocol": d.protocol, "description": d.description, "details": d.details} for d in self.new_devices ], - 'missing_devices': [ - {'identifier': d.identifier, 'protocol': d.protocol, - 'description': d.description, 'details': d.details} + "missing_devices": [ + {"identifier": d.identifier, "protocol": d.protocol, "description": d.description, "details": d.details} for d in self.missing_devices ], - 'changed_devices': [ - {'identifier': d.identifier, 'protocol': d.protocol, - 'change_type': d.change_type, 'description': d.description, - 'expected': d.expected, 'details': d.details} + "changed_devices": [ + { + "identifier": d.identifier, + "protocol": d.protocol, + "change_type": d.change_type, + "description": d.description, + "expected": d.expected, + "details": d.details, + } for d in self.changed_devices ], - 'disclaimer': ( + "disclaimer": ( "Baseline comparison shows differences, not confirmed threats. " "New devices may be legitimate. Missing devices may have been powered off." ), } -def calculate_baseline_diff( - baseline: dict, - current_wifi: list[dict], - current_wifi_clients: list[dict], - current_bt: list[dict], - current_rf: list[dict], - sweep_id: int -) -> BaselineDiff: +def calculate_baseline_diff( + baseline: dict, + current_wifi: list[dict], + current_wifi_clients: list[dict], + current_bt: list[dict], + current_rf: list[dict], + sweep_id: int, +) -> BaselineDiff: """ Calculate comprehensive diff between baseline and current scan. Args: baseline: Baseline dict from database current_wifi: Current WiFi devices - current_wifi_clients: Current WiFi clients - current_bt: Current Bluetooth devices + current_wifi_clients: Current WiFi clients + current_bt: Current Bluetooth devices current_rf: Current RF signals sweep_id: Current sweep ID Returns: BaselineDiff with complete comparison results """ - diff = BaselineDiff( - baseline_id=baseline.get('id', 0), - sweep_id=sweep_id - ) + diff = BaselineDiff(baseline_id=baseline.get("id", 0), sweep_id=sweep_id) # Calculate baseline age - created_at = baseline.get('created_at') + created_at = baseline.get("created_at") if created_at: if isinstance(created_at, str): try: - created = datetime.fromisoformat(created_at.replace('Z', '+00:00')) + created = datetime.fromisoformat(created_at.replace("Z", "+00:00")) diff.baseline_age_hours = (datetime.now() - created.replace(tzinfo=None)).total_seconds() / 3600 except ValueError: diff.baseline_age_hours = 0 @@ -566,32 +543,28 @@ def calculate_baseline_diff( diff.is_stale = diff.baseline_age_hours > 72 # Build baseline lookup dicts - baseline_wifi = { - d.get('bssid', d.get('mac', '')).upper(): d - for d in baseline.get('wifi_networks', []) - if d.get('bssid') or d.get('mac') - } - baseline_wifi_clients = { - d.get('mac', d.get('address', '')).upper(): d - for d in baseline.get('wifi_clients', []) - if d.get('mac') or d.get('address') - } + baseline_wifi = { + d.get("bssid", d.get("mac", "")).upper(): d + for d in baseline.get("wifi_networks", []) + if d.get("bssid") or d.get("mac") + } + baseline_wifi_clients = { + d.get("mac", d.get("address", "")).upper(): d + for d in baseline.get("wifi_clients", []) + if d.get("mac") or d.get("address") + } baseline_bt = { - d.get('mac', d.get('address', '')).upper(): d - for d in baseline.get('bt_devices', []) - if d.get('mac') or d.get('address') - } - baseline_rf = { - round(d.get('frequency', 0), 1): d - for d in baseline.get('rf_frequencies', []) - if d.get('frequency') + d.get("mac", d.get("address", "")).upper(): d + for d in baseline.get("bt_devices", []) + if d.get("mac") or d.get("address") } + baseline_rf = {round(d.get("frequency", 0), 1): d for d in baseline.get("rf_frequencies", []) if d.get("frequency")} - # Compare WiFi - _compare_wifi(diff, baseline_wifi, current_wifi) - - # Compare WiFi clients - _compare_wifi_clients(diff, baseline_wifi_clients, current_wifi_clients) + # Compare WiFi + _compare_wifi(diff, baseline_wifi, current_wifi) + + # Compare WiFi clients + _compare_wifi_clients(diff, baseline_wifi_clients, current_wifi_clients) # Compare Bluetooth _compare_bluetooth(diff, baseline_bt, current_bt) @@ -617,217 +590,221 @@ def calculate_baseline_diff( return diff -def _compare_wifi(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: +def _compare_wifi(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: """Compare WiFi devices between baseline and current.""" - current_macs = { - d.get('bssid', d.get('mac', '')).upper(): d - for d in current - if d.get('bssid') or d.get('mac') - } + current_macs = {d.get("bssid", d.get("mac", "")).upper(): d for d in current if d.get("bssid") or d.get("mac")} # Find new devices for mac, device in current_macs.items(): if mac not in baseline: - ssid = device.get('essid', device.get('ssid', 'Hidden')) - diff.new_devices.append(DeviceChange( - identifier=mac, - protocol='wifi', - change_type='new', - description=f'New WiFi AP: {ssid}', - expected=False, - details={ - 'ssid': ssid, - 'channel': device.get('channel'), - 'rssi': device.get('power', device.get('signal')), - } - )) - - -def _compare_wifi_clients(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: - """Compare WiFi clients between baseline and current.""" - current_macs = { - d.get('mac', d.get('address', '')).upper(): d - for d in current - if d.get('mac') or d.get('address') - } - - # Find new clients - for mac, device in current_macs.items(): - if mac not in baseline: - name = device.get('vendor', 'WiFi Client') - diff.new_devices.append(DeviceChange( - identifier=mac, - protocol='wifi_client', - change_type='new', - description=f'New WiFi client: {name}', - expected=False, - details={ - 'vendor': name, - 'rssi': device.get('rssi'), - 'associated_bssid': device.get('associated_bssid'), - } - )) - - # Find missing clients - for mac, device in baseline.items(): - if mac not in current_macs: - name = device.get('vendor', 'WiFi Client') - diff.missing_devices.append(DeviceChange( - identifier=mac, - protocol='wifi_client', - change_type='missing', - description=f'Missing WiFi client: {name}', - expected=True, - details={ - 'vendor': name, - } - )) + ssid = device.get("essid", device.get("ssid", "Hidden")) + diff.new_devices.append( + DeviceChange( + identifier=mac, + protocol="wifi", + change_type="new", + description=f"New WiFi AP: {ssid}", + expected=False, + details={ + "ssid": ssid, + "channel": device.get("channel"), + "rssi": device.get("power", device.get("signal")), + }, + ) + ) + + +def _compare_wifi_clients(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: + """Compare WiFi clients between baseline and current.""" + current_macs = {d.get("mac", d.get("address", "")).upper(): d for d in current if d.get("mac") or d.get("address")} + + # Find new clients + for mac, device in current_macs.items(): + if mac not in baseline: + name = device.get("vendor", "WiFi Client") + diff.new_devices.append( + DeviceChange( + identifier=mac, + protocol="wifi_client", + change_type="new", + description=f"New WiFi client: {name}", + expected=False, + details={ + "vendor": name, + "rssi": device.get("rssi"), + "associated_bssid": device.get("associated_bssid"), + }, + ) + ) + + # Find missing clients + for mac, device in baseline.items(): + if mac not in current_macs: + name = device.get("vendor", "WiFi Client") + diff.missing_devices.append( + DeviceChange( + identifier=mac, + protocol="wifi_client", + change_type="missing", + description=f"Missing WiFi client: {name}", + expected=True, + details={ + "vendor": name, + }, + ) + ) else: # Check for changes baseline_dev = baseline[mac] changes = [] # RSSI drift - curr_rssi = device.get('power', device.get('signal')) - base_rssi = baseline_dev.get('power', baseline_dev.get('signal')) + curr_rssi = device.get("power", device.get("signal")) + base_rssi = baseline_dev.get("power", baseline_dev.get("signal")) if curr_rssi and base_rssi: rssi_diff = abs(int(curr_rssi) - int(base_rssi)) if rssi_diff > 15: - changes.append(('rssi_drift', f'RSSI changed by {rssi_diff} dBm')) + changes.append(("rssi_drift", f"RSSI changed by {rssi_diff} dBm")) # Channel change - curr_chan = device.get('channel') - base_chan = baseline_dev.get('channel') + curr_chan = device.get("channel") + base_chan = baseline_dev.get("channel") if curr_chan and base_chan and curr_chan != base_chan: - changes.append(('channel_change', f'Channel changed from {base_chan} to {curr_chan}')) + changes.append(("channel_change", f"Channel changed from {base_chan} to {curr_chan}")) # Security change - curr_sec = device.get('encryption', device.get('privacy', '')) - base_sec = baseline_dev.get('encryption', baseline_dev.get('privacy', '')) + curr_sec = device.get("encryption", device.get("privacy", "")) + base_sec = baseline_dev.get("encryption", baseline_dev.get("privacy", "")) if curr_sec and base_sec and curr_sec != base_sec: - changes.append(('security_change', f'Security changed from {base_sec} to {curr_sec}')) + changes.append(("security_change", f"Security changed from {base_sec} to {curr_sec}")) for change_type, desc in changes: - diff.changed_devices.append(DeviceChange( - identifier=mac, - protocol='wifi', - change_type=change_type, - description=desc, - expected=change_type == 'rssi_drift', # RSSI drift is often expected - details={ - 'ssid': device.get('essid', device.get('ssid')), - 'baseline': baseline_dev, - 'current': device, - } - )) + diff.changed_devices.append( + DeviceChange( + identifier=mac, + protocol="wifi", + change_type=change_type, + description=desc, + expected=change_type == "rssi_drift", # RSSI drift is often expected + details={ + "ssid": device.get("essid", device.get("ssid")), + "baseline": baseline_dev, + "current": device, + }, + ) + ) # Find missing devices for mac, device in baseline.items(): if mac not in current_macs: - ssid = device.get('essid', device.get('ssid', 'Hidden')) - diff.missing_devices.append(DeviceChange( - identifier=mac, - protocol='wifi', - change_type='missing', - description=f'Missing WiFi AP: {ssid}', - expected=False, # Could be powered off - details={ - 'ssid': ssid, - 'last_channel': device.get('channel'), - } - )) + ssid = device.get("essid", device.get("ssid", "Hidden")) + diff.missing_devices.append( + DeviceChange( + identifier=mac, + protocol="wifi", + change_type="missing", + description=f"Missing WiFi AP: {ssid}", + expected=False, # Could be powered off + details={ + "ssid": ssid, + "last_channel": device.get("channel"), + }, + ) + ) def _compare_bluetooth(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: """Compare Bluetooth devices between baseline and current.""" - current_macs = { - d.get('mac', d.get('address', '')).upper(): d - for d in current - if d.get('mac') or d.get('address') - } + current_macs = {d.get("mac", d.get("address", "")).upper(): d for d in current if d.get("mac") or d.get("address")} # Find new devices for mac, device in current_macs.items(): if mac not in baseline: - name = device.get('name', 'Unknown') - diff.new_devices.append(DeviceChange( - identifier=mac, - protocol='bluetooth', - change_type='new', - description=f'New BLE device: {name}', - expected=False, - details={ - 'name': name, - 'rssi': device.get('rssi'), - 'manufacturer': device.get('manufacturer'), - } - )) + name = device.get("name", "Unknown") + diff.new_devices.append( + DeviceChange( + identifier=mac, + protocol="bluetooth", + change_type="new", + description=f"New BLE device: {name}", + expected=False, + details={ + "name": name, + "rssi": device.get("rssi"), + "manufacturer": device.get("manufacturer"), + }, + ) + ) else: # Check for changes baseline_dev = baseline[mac] # Name change (device renamed) - curr_name = device.get('name', '') - base_name = baseline_dev.get('name', '') + curr_name = device.get("name", "") + base_name = baseline_dev.get("name", "") if curr_name and base_name and curr_name != base_name: - diff.changed_devices.append(DeviceChange( - identifier=mac, - protocol='bluetooth', - change_type='name_change', - description=f'Device renamed: {base_name} -> {curr_name}', - expected=True, - details={'old_name': base_name, 'new_name': curr_name} - )) + diff.changed_devices.append( + DeviceChange( + identifier=mac, + protocol="bluetooth", + change_type="name_change", + description=f"Device renamed: {base_name} -> {curr_name}", + expected=True, + details={"old_name": base_name, "new_name": curr_name}, + ) + ) # Find missing devices for mac, device in baseline.items(): if mac not in current_macs: - name = device.get('name', 'Unknown') - diff.missing_devices.append(DeviceChange( - identifier=mac, - protocol='bluetooth', - change_type='missing', - description=f'Missing BLE device: {name}', - expected=True, # BLE devices often go to sleep - details={'name': name} - )) + name = device.get("name", "Unknown") + diff.missing_devices.append( + DeviceChange( + identifier=mac, + protocol="bluetooth", + change_type="missing", + description=f"Missing BLE device: {name}", + expected=True, # BLE devices often go to sleep + details={"name": name}, + ) + ) def _compare_rf(diff: BaselineDiff, baseline: dict, current: list[dict]) -> None: """Compare RF signals between baseline and current.""" - current_freqs = { - round(s.get('frequency', 0), 1): s - for s in current - if s.get('frequency') - } + current_freqs = {round(s.get("frequency", 0), 1): s for s in current if s.get("frequency")} # Find new signals for freq, signal in current_freqs.items(): if freq not in baseline: - diff.new_devices.append(DeviceChange( - identifier=f'{freq:.1f} MHz', - protocol='rf', - change_type='new', - description=f'New RF signal at {freq:.3f} MHz', - expected=False, - details={ - 'frequency': freq, - 'power': signal.get('power', signal.get('level')), - 'modulation': signal.get('modulation'), - } - )) + diff.new_devices.append( + DeviceChange( + identifier=f"{freq:.1f} MHz", + protocol="rf", + change_type="new", + description=f"New RF signal at {freq:.3f} MHz", + expected=False, + details={ + "frequency": freq, + "power": signal.get("power", signal.get("level")), + "modulation": signal.get("modulation"), + }, + ) + ) # Find missing signals for freq, signal in baseline.items(): if freq not in current_freqs: - diff.missing_devices.append(DeviceChange( - identifier=f'{freq:.1f} MHz', - protocol='rf', - change_type='missing', - description=f'Missing RF signal at {freq:.1f} MHz', - expected=True, # RF signals can be intermittent - details={'frequency': freq} - )) + diff.missing_devices.append( + DeviceChange( + identifier=f"{freq:.1f} MHz", + protocol="rf", + change_type="missing", + description=f"Missing RF signal at {freq:.1f} MHz", + expected=True, # RF signals can be intermittent + details={"frequency": freq}, + ) + ) def _calculate_baseline_health(diff: BaselineDiff, baseline: dict) -> None: @@ -847,12 +824,12 @@ def _calculate_baseline_health(diff: BaselineDiff, baseline: dict) -> None: reasons.append(f"Baseline is {diff.baseline_age_hours:.0f} hours old") # Device churn penalty - total_baseline = ( - len(baseline.get('wifi_networks', [])) + - len(baseline.get('wifi_clients', [])) + - len(baseline.get('bt_devices', [])) + - len(baseline.get('rf_frequencies', [])) - ) + total_baseline = ( + len(baseline.get("wifi_networks", [])) + + len(baseline.get("wifi_clients", [])) + + len(baseline.get("bt_devices", [])) + + len(baseline.get("rf_frequencies", [])) + ) if total_baseline > 0: churn_rate = (diff.total_new + diff.total_missing) / total_baseline @@ -889,9 +866,11 @@ def _calculate_baseline_health(diff: BaselineDiff, baseline: dict) -> None: # 3. Per-Device Timelines # ============================================================================= + @dataclass class DeviceObservation: """A single observation of a device.""" + timestamp: datetime rssi: int | None = None present: bool = True @@ -908,6 +887,7 @@ class DeviceTimeline: Used to assess signal stability, movement patterns, and meeting window correlation. """ + identifier: str protocol: str name: str | None = None @@ -929,7 +909,7 @@ class DeviceTimeline: # Movement assessment appears_stationary: bool = True - movement_pattern: str = 'unknown' # 'stationary', 'mobile', 'intermittent' + movement_pattern: str = "unknown" # 'stationary', 'mobile', 'intermittent' # Meeting correlation meeting_correlated: bool = False @@ -938,38 +918,38 @@ class DeviceTimeline: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'identifier': self.identifier, - 'protocol': self.protocol, - 'name': self.name, - 'observations': [ + "identifier": self.identifier, + "protocol": self.protocol, + "name": self.name, + "observations": [ { - 'timestamp': obs.timestamp.isoformat(), - 'rssi': obs.rssi, - 'present': obs.present, - 'channel': obs.channel, - 'frequency': obs.frequency, + "timestamp": obs.timestamp.isoformat(), + "rssi": obs.rssi, + "present": obs.present, + "channel": obs.channel, + "frequency": obs.frequency, } for obs in self.observations[-50:] # Limit to last 50 ], - 'metrics': { - 'first_seen': self.first_seen.isoformat() if self.first_seen else None, - 'last_seen': self.last_seen.isoformat() if self.last_seen else None, - 'total_observations': self.total_observations, - 'presence_ratio': round(self.presence_ratio, 2), + "metrics": { + "first_seen": self.first_seen.isoformat() if self.first_seen else None, + "last_seen": self.last_seen.isoformat() if self.last_seen else None, + "total_observations": self.total_observations, + "presence_ratio": round(self.presence_ratio, 2), }, - 'signal': { - 'rssi_min': self.rssi_min, - 'rssi_max': self.rssi_max, - 'rssi_mean': round(self.rssi_mean, 1) if self.rssi_mean else None, - 'stability': round(self.rssi_stability, 2), + "signal": { + "rssi_min": self.rssi_min, + "rssi_max": self.rssi_max, + "rssi_mean": round(self.rssi_mean, 1) if self.rssi_mean else None, + "stability": round(self.rssi_stability, 2), }, - 'movement': { - 'appears_stationary': self.appears_stationary, - 'pattern': self.movement_pattern, + "movement": { + "appears_stationary": self.appears_stationary, + "pattern": self.movement_pattern, }, - 'meeting_correlation': { - 'correlated': self.meeting_correlated, - 'observations_during_meeting': self.meeting_observations, + "meeting_correlation": { + "correlated": self.meeting_correlated, + "observations_during_meeting": self.meeting_observations, }, } @@ -1001,7 +981,7 @@ class TimelineManager: channel: int | None = None, frequency: float | None = None, name: str | None = None, - attributes: dict | None = None + attributes: dict | None = None, ) -> None: """Add an observation for a device.""" key = f"{protocol}:{identifier.upper()}" @@ -1049,7 +1029,7 @@ class TimelineManager: # Enforce max observations if len(timeline.observations) > self.max_observations: - timeline.observations = timeline.observations[-self.max_observations:] + timeline.observations = timeline.observations[-self.max_observations :] # Update metrics timeline.last_seen = now @@ -1107,13 +1087,13 @@ class TimelineManager: rssi_range = timeline.rssi_max - timeline.rssi_min if rssi_range < 10: timeline.appears_stationary = True - timeline.movement_pattern = 'stationary' + timeline.movement_pattern = "stationary" elif rssi_range < 25: timeline.appears_stationary = False - timeline.movement_pattern = 'mobile' + timeline.movement_pattern = "mobile" else: timeline.appears_stationary = False - timeline.movement_pattern = 'intermittent' + timeline.movement_pattern = "intermittent" # Presence ratio if timeline.first_seen and timeline.last_seen: @@ -1132,7 +1112,7 @@ class TimelineManager: def get_all_timelines(self) -> list[DeviceTimeline]: """Get all device timelines with computed metrics.""" for key in self.timelines: - protocol, identifier = key.split(':', 1) + protocol, identifier = key.split(":", 1) self.compute_metrics(identifier, protocol) return list(self.timelines.values()) @@ -1141,6 +1121,7 @@ class TimelineManager: # 5. Meeting-Window Summary Enhancements # ============================================================================= + @dataclass class MeetingWindowSummary: """ @@ -1149,6 +1130,7 @@ class MeetingWindowSummary: Tracks devices first seen during meeting, behavior changes, and applies meeting-window scoring modifiers. """ + meeting_id: int name: str | None = None start_time: datetime | None = None @@ -1173,20 +1155,20 @@ class MeetingWindowSummary: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'meeting_id': self.meeting_id, - 'name': self.name, - 'start_time': self.start_time.isoformat() if self.start_time else None, - 'end_time': self.end_time.isoformat() if self.end_time else None, - 'duration_minutes': round(self.duration_minutes, 1), - 'summary': { - 'total_devices_active': self.total_devices_active, - 'new_devices': self.new_devices_count, - 'behavior_changes': self.behavior_changes_count, - 'high_interest': self.high_interest_count, + "meeting_id": self.meeting_id, + "name": self.name, + "start_time": self.start_time.isoformat() if self.start_time else None, + "end_time": self.end_time.isoformat() if self.end_time else None, + "duration_minutes": round(self.duration_minutes, 1), + "summary": { + "total_devices_active": self.total_devices_active, + "new_devices": self.new_devices_count, + "behavior_changes": self.behavior_changes_count, + "high_interest": self.high_interest_count, }, - 'devices_first_seen': self.devices_first_seen, - 'devices_behavior_change': self.devices_behavior_change, - 'disclaimer': ( + "devices_first_seen": self.devices_first_seen, + "devices_behavior_change": self.devices_behavior_change, + "disclaimer": ( "Meeting-correlated activity indicates temporal correlation only, " "not confirmed surveillance. Devices may have legitimate reasons " "for appearing during meetings." @@ -1195,9 +1177,7 @@ class MeetingWindowSummary: def generate_meeting_summary( - meeting_window: dict, - device_timelines: list[DeviceTimeline], - device_profiles: list[dict] + meeting_window: dict, device_timelines: list[DeviceTimeline], device_profiles: list[dict] ) -> MeetingWindowSummary: """ Generate summary of device activity during a meeting window. @@ -1211,23 +1191,23 @@ def generate_meeting_summary( MeetingWindowSummary with analysis """ summary = MeetingWindowSummary( - meeting_id=meeting_window.get('id', 0), - name=meeting_window.get('name'), + meeting_id=meeting_window.get("id", 0), + name=meeting_window.get("name"), ) # Parse times - start_str = meeting_window.get('start_time') - end_str = meeting_window.get('end_time') + start_str = meeting_window.get("start_time") + end_str = meeting_window.get("end_time") if start_str: if isinstance(start_str, str): - summary.start_time = datetime.fromisoformat(start_str.replace('Z', '+00:00')).replace(tzinfo=None) + summary.start_time = datetime.fromisoformat(start_str.replace("Z", "+00:00")).replace(tzinfo=None) else: summary.start_time = start_str if end_str: if isinstance(end_str, str): - summary.end_time = datetime.fromisoformat(end_str.replace('Z', '+00:00')).replace(tzinfo=None) + summary.end_time = datetime.fromisoformat(end_str.replace("Z", "+00:00")).replace(tzinfo=None) else: summary.end_time = end_str @@ -1263,20 +1243,22 @@ def generate_meeting_summary( if was_active: device_info = { - 'identifier': timeline.identifier, - 'protocol': timeline.protocol, - 'name': timeline.name, - 'meeting_correlated': True, + "identifier": timeline.identifier, + "protocol": timeline.protocol, + "name": timeline.name, + "meeting_correlated": True, } summary.active_devices.append(device_info) if first_seen_during: - device_info['first_seen_during_meeting'] = True - summary.devices_first_seen.append({ - **device_info, - 'description': 'Device first seen during meeting window', - 'risk_modifier': '+2 (meeting-correlated activity)', - }) + device_info["first_seen_during_meeting"] = True + summary.devices_first_seen.append( + { + **device_info, + "description": "Device first seen during meeting window", + "risk_modifier": "+2 (meeting-correlated activity)", + } + ) # Update counts summary.total_devices_active = len(summary.active_devices) @@ -1285,9 +1267,9 @@ def generate_meeting_summary( # Count high interest from profiles for profile in device_profiles: - if profile.get('risk_level') == 'high_interest': - indicators = profile.get('indicators', []) - if any(i.get('type') == 'meeting_correlated' for i in indicators): + if profile.get("risk_level") == "high_interest": + indicators = profile.get("indicators", []) + if any(i.get("type") == "meeting_correlated" for i in indicators): summary.high_interest_count += 1 return summary @@ -1297,9 +1279,11 @@ def generate_meeting_summary( # 7. WiFi Advanced Indicators (LIMITED SCOPE) # ============================================================================= + @dataclass class WiFiAdvancedIndicator: """An advanced WiFi indicator detection.""" + indicator_type: str # 'evil_twin', 'probe_request', 'deauth_burst' severity: str # 'high', 'medium', 'low' description: str @@ -1309,13 +1293,13 @@ class WiFiAdvancedIndicator: def to_dict(self) -> dict: return { - 'type': self.indicator_type, - 'severity': self.severity, - 'description': self.description, - 'details': self.details, - 'timestamp': self.timestamp.isoformat(), - 'requires_monitor_mode': self.requires_monitor_mode, - 'disclaimer': ( + "type": self.indicator_type, + "severity": self.severity, + "description": self.description, + "details": self.details, + "timestamp": self.timestamp.isoformat(), + "requires_monitor_mode": self.requires_monitor_mode, + "disclaimer": ( "Pattern detected - this is an indicator, not confirmation of an attack. " "Further investigation required." ), @@ -1344,13 +1328,13 @@ class WiFiAdvancedDetector: def set_known_networks(self, networks: list[dict]) -> None: """Set known/expected networks from baseline.""" for net in networks: - ssid = net.get('essid', net.get('ssid', '')) + ssid = net.get("essid", net.get("ssid", "")) if ssid: self.known_networks[ssid] = { - 'bssid': net.get('bssid', net.get('mac', '')).upper(), - 'security': net.get('encryption', net.get('privacy', '')), - 'channel': net.get('channel'), - 'rssi': net.get('power', net.get('signal')), + "bssid": net.get("bssid", net.get("mac", "")).upper(), + "security": net.get("encryption", net.get("privacy", "")), + "channel": net.get("channel"), + "rssi": net.get("power", net.get("signal")), } def analyze_network(self, network: dict) -> list[WiFiAdvancedIndicator]: @@ -1360,73 +1344,79 @@ class WiFiAdvancedDetector: Detects: Same SSID with different BSSID, security, or abnormal signal. """ indicators = [] - ssid = network.get('essid', network.get('ssid', '')) - bssid = network.get('bssid', network.get('mac', '')).upper() - security = network.get('encryption', network.get('privacy', '')) - rssi = network.get('power', network.get('signal')) + ssid = network.get("essid", network.get("ssid", "")) + bssid = network.get("bssid", network.get("mac", "")).upper() + security = network.get("encryption", network.get("privacy", "")) + rssi = network.get("power", network.get("signal")) - if not ssid or ssid in ['', 'Hidden', '[Hidden]']: + if not ssid or ssid in ["", "Hidden", "[Hidden]"]: return indicators if ssid in self.known_networks: known = self.known_networks[ssid] # Different BSSID for same SSID - if known['bssid'] and known['bssid'] != bssid: + if known["bssid"] and known["bssid"] != bssid: # Check security mismatch - security_mismatch = known['security'] and security and known['security'] != security + security_mismatch = known["security"] and security and known["security"] != security # Check signal anomaly (significantly stronger than expected) signal_anomaly = False - if rssi and known.get('rssi'): + if rssi and known.get("rssi"): try: - rssi_diff = int(rssi) - int(known['rssi']) + rssi_diff = int(rssi) - int(known["rssi"]) signal_anomaly = rssi_diff > 20 # Much stronger than expected except (ValueError, TypeError): pass if security_mismatch: - indicators.append(WiFiAdvancedIndicator( - indicator_type='evil_twin', - severity='high', - description=f'Evil twin pattern detected for SSID "{ssid}"', - details={ - 'ssid': ssid, - 'detected_bssid': bssid, - 'expected_bssid': known['bssid'], - 'detected_security': security, - 'expected_security': known['security'], - 'pattern': 'Different BSSID with security downgrade', - }, - requires_monitor_mode=False, - )) + indicators.append( + WiFiAdvancedIndicator( + indicator_type="evil_twin", + severity="high", + description=f'Evil twin pattern detected for SSID "{ssid}"', + details={ + "ssid": ssid, + "detected_bssid": bssid, + "expected_bssid": known["bssid"], + "detected_security": security, + "expected_security": known["security"], + "pattern": "Different BSSID with security downgrade", + }, + requires_monitor_mode=False, + ) + ) elif signal_anomaly: - indicators.append(WiFiAdvancedIndicator( - indicator_type='evil_twin', - severity='medium', - description=f'Possible evil twin pattern for SSID "{ssid}"', - details={ - 'ssid': ssid, - 'detected_bssid': bssid, - 'expected_bssid': known['bssid'], - 'signal_difference': f'+{rssi_diff} dBm stronger than expected', - 'pattern': 'Different BSSID with abnormally strong signal', - }, - requires_monitor_mode=False, - )) + indicators.append( + WiFiAdvancedIndicator( + indicator_type="evil_twin", + severity="medium", + description=f'Possible evil twin pattern for SSID "{ssid}"', + details={ + "ssid": ssid, + "detected_bssid": bssid, + "expected_bssid": known["bssid"], + "signal_difference": f"+{rssi_diff} dBm stronger than expected", + "pattern": "Different BSSID with abnormally strong signal", + }, + requires_monitor_mode=False, + ) + ) else: - indicators.append(WiFiAdvancedIndicator( - indicator_type='evil_twin', - severity='low', - description=f'Duplicate SSID detected: "{ssid}"', - details={ - 'ssid': ssid, - 'detected_bssid': bssid, - 'expected_bssid': known['bssid'], - 'pattern': 'Multiple APs with same SSID (may be legitimate)', - }, - requires_monitor_mode=False, - )) + indicators.append( + WiFiAdvancedIndicator( + indicator_type="evil_twin", + severity="low", + description=f'Duplicate SSID detected: "{ssid}"', + details={ + "ssid": ssid, + "detected_bssid": bssid, + "expected_bssid": known["bssid"], + "pattern": "Multiple APs with same SSID (may be legitimate)", + }, + requires_monitor_mode=False, + ) + ) self.indicators.extend(indicators) return indicators @@ -1440,21 +1430,30 @@ class WiFiAdvancedDetector: if not self.monitor_mode: return None - self.probe_requests.append({ - 'timestamp': datetime.now(), - 'src_mac': frame.get('src_mac', '').upper(), - 'probed_ssid': frame.get('ssid', ''), - }) + self.probe_requests.append( + { + "timestamp": datetime.now(), + "src_mac": frame.get("src_mac", "").upper(), + "probed_ssid": frame.get("ssid", ""), + } + ) # Keep last 1000 probe requests if len(self.probe_requests) > 1000: self.probe_requests = self.probe_requests[-1000:] # Check for sensitive SSID probing - ssid = frame.get('ssid', '') + ssid = frame.get("ssid", "") sensitive_patterns = [ - 'corp', 'internal', 'private', 'secure', 'vpn', - 'admin', 'management', 'executive', 'board', + "corp", + "internal", + "private", + "secure", + "vpn", + "admin", + "management", + "executive", + "board", ] is_sensitive = any(p in ssid.lower() for p in sensitive_patterns) if ssid else False @@ -1463,20 +1462,19 @@ class WiFiAdvancedDetector: # Count recent probes for this SSID recent_cutoff = datetime.now() - timedelta(minutes=5) recent_probes = [ - p for p in self.probe_requests - if p['probed_ssid'] == ssid and p['timestamp'] > recent_cutoff + p for p in self.probe_requests if p["probed_ssid"] == ssid and p["timestamp"] > recent_cutoff ] if len(recent_probes) >= 3: indicator = WiFiAdvancedIndicator( - indicator_type='probe_request', - severity='medium', + indicator_type="probe_request", + severity="medium", description=f'Repeated probing for sensitive SSID "{ssid}"', details={ - 'ssid': ssid, - 'probe_count': len(recent_probes), - 'source_macs': list({p['src_mac'] for p in recent_probes}), - 'pattern': 'Multiple probe requests for potentially sensitive network', + "ssid": ssid, + "probe_count": len(recent_probes), + "source_macs": list({p["src_mac"] for p in recent_probes}), + "pattern": "Multiple probe requests for potentially sensitive network", }, requires_monitor_mode=True, ) @@ -1494,13 +1492,15 @@ class WiFiAdvancedDetector: if not self.monitor_mode: return None - self.deauth_frames.append({ - 'timestamp': datetime.now(), - 'src_mac': frame.get('src_mac', '').upper(), - 'dst_mac': frame.get('dst_mac', '').upper(), - 'bssid': frame.get('bssid', '').upper(), - 'reason': frame.get('reason_code'), - }) + self.deauth_frames.append( + { + "timestamp": datetime.now(), + "src_mac": frame.get("src_mac", "").upper(), + "dst_mac": frame.get("dst_mac", "").upper(), + "bssid": frame.get("bssid", "").upper(), + "reason": frame.get("reason_code"), + } + ) # Keep last 500 deauth frames if len(self.deauth_frames) > 500: @@ -1508,30 +1508,30 @@ class WiFiAdvancedDetector: # Check for deauth burst (>10 deauths in 10 seconds) recent_cutoff = datetime.now() - timedelta(seconds=10) - recent_deauths = [d for d in self.deauth_frames if d['timestamp'] > recent_cutoff] + recent_deauths = [d for d in self.deauth_frames if d["timestamp"] > recent_cutoff] if len(recent_deauths) >= 10: # Check if targeting specific BSSID - bssid = frame.get('bssid', '').upper() - targeting_bssid = len([d for d in recent_deauths if d['bssid'] == bssid]) >= 5 + bssid = frame.get("bssid", "").upper() + targeting_bssid = len([d for d in recent_deauths if d["bssid"] == bssid]) >= 5 indicator = WiFiAdvancedIndicator( - indicator_type='deauth_burst', - severity='high' if targeting_bssid else 'medium', - description='Deauthentication burst pattern detected', + indicator_type="deauth_burst", + severity="high" if targeting_bssid else "medium", + description="Deauthentication burst pattern detected", details={ - 'deauth_count': len(recent_deauths), - 'time_window_seconds': 10, - 'targeted_bssid': bssid if targeting_bssid else None, - 'unique_sources': len({d['src_mac'] for d in recent_deauths}), - 'pattern': 'Abnormal deauthentication frame volume', + "deauth_count": len(recent_deauths), + "time_window_seconds": 10, + "targeted_bssid": bssid if targeting_bssid else None, + "unique_sources": len({d["src_mac"] for d in recent_deauths}), + "pattern": "Abnormal deauthentication frame volume", }, requires_monitor_mode=True, ) self.indicators.append(indicator) # Clear recent to avoid repeated alerts - self.deauth_frames = [d for d in self.deauth_frames if d['timestamp'] <= recent_cutoff] + self.deauth_frames = [d for d in self.deauth_frames if d["timestamp"] <= recent_cutoff] return indicator @@ -1556,13 +1556,15 @@ class WiFiAdvancedDetector: # 8. Bluetooth Risk Explainability & Proximity Heuristics # ============================================================================= + class BLEProximity(Enum): """RSSI-based proximity estimation.""" - VERY_CLOSE = 'very_close' # Within ~1m - CLOSE = 'close' # Within ~3m - MODERATE = 'moderate' # Within ~10m - FAR = 'far' # Beyond ~10m - UNKNOWN = 'unknown' + + VERY_CLOSE = "very_close" # Within ~1m + CLOSE = "close" # Within ~3m + MODERATE = "moderate" # Within ~10m + FAR = "far" # Beyond ~10m + UNKNOWN = "unknown" @dataclass @@ -1573,64 +1575,65 @@ class BLERiskExplanation: Provides human-readable explanations, proximity estimates, and recommended actions. """ + identifier: str name: str | None = None # Risk assessment - risk_level: str = 'informational' + risk_level: str = "informational" risk_score: int = 0 - risk_explanation: str = '' + risk_explanation: str = "" # Proximity proximity: BLEProximity = BLEProximity.UNKNOWN - proximity_explanation: str = '' - estimated_distance: str = '' + proximity_explanation: str = "" + estimated_distance: str = "" # Tracker detection is_tracker: bool = False tracker_type: str | None = None - tracker_explanation: str = '' + tracker_explanation: str = "" # Meeting correlation meeting_correlated: bool = False - meeting_explanation: str = '' + meeting_explanation: str = "" # Recommended action - recommended_action: str = '' - action_rationale: str = '' + recommended_action: str = "" + action_rationale: str = "" # All indicators with explanations indicators: list[dict] = field(default_factory=list) def to_dict(self) -> dict: return { - 'identifier': self.identifier, - 'name': self.name, - 'risk': { - 'level': self.risk_level, - 'score': self.risk_score, - 'explanation': self.risk_explanation, + "identifier": self.identifier, + "name": self.name, + "risk": { + "level": self.risk_level, + "score": self.risk_score, + "explanation": self.risk_explanation, }, - 'proximity': { - 'estimate': self.proximity.value, - 'explanation': self.proximity_explanation, - 'estimated_distance': self.estimated_distance, + "proximity": { + "estimate": self.proximity.value, + "explanation": self.proximity_explanation, + "estimated_distance": self.estimated_distance, }, - 'tracker': { - 'is_tracker': self.is_tracker, - 'type': self.tracker_type, - 'explanation': self.tracker_explanation, + "tracker": { + "is_tracker": self.is_tracker, + "type": self.tracker_type, + "explanation": self.tracker_explanation, }, - 'meeting_correlation': { - 'correlated': self.meeting_correlated, - 'explanation': self.meeting_explanation, + "meeting_correlation": { + "correlated": self.meeting_correlated, + "explanation": self.meeting_explanation, }, - 'recommended_action': { - 'action': self.recommended_action, - 'rationale': self.action_rationale, + "recommended_action": { + "action": self.recommended_action, + "rationale": self.action_rationale, }, - 'indicators': self.indicators, - 'disclaimer': ( + "indicators": self.indicators, + "disclaimer": ( "Risk assessment is based on observable indicators and heuristics. " "Proximity estimates are approximate based on RSSI and may vary with environment. " "Tracker detection indicates brand presence, not confirmed threat." @@ -1651,43 +1654,29 @@ def estimate_ble_proximity(rssi: int) -> tuple[BLEProximity, str, str]: Tuple of (proximity enum, explanation, estimated distance string) """ if rssi is None: - return ( - BLEProximity.UNKNOWN, - "RSSI not available - cannot estimate proximity", - "Unknown" - ) + return (BLEProximity.UNKNOWN, "RSSI not available - cannot estimate proximity", "Unknown") # These thresholds are heuristic approximations if rssi >= -50: return ( BLEProximity.VERY_CLOSE, f"Very strong signal ({rssi} dBm) suggests device is very close", - "< 1 meter (approximate)" + "< 1 meter (approximate)", ) elif rssi >= -65: - return ( - BLEProximity.CLOSE, - f"Strong signal ({rssi} dBm) suggests device is nearby", - "1-3 meters (approximate)" - ) + return (BLEProximity.CLOSE, f"Strong signal ({rssi} dBm) suggests device is nearby", "1-3 meters (approximate)") elif rssi >= -80: return ( BLEProximity.MODERATE, f"Moderate signal ({rssi} dBm) suggests device is in the area", - "3-10 meters (approximate)" + "3-10 meters (approximate)", ) else: - return ( - BLEProximity.FAR, - f"Weak signal ({rssi} dBm) suggests device is distant", - "> 10 meters (approximate)" - ) + return (BLEProximity.FAR, f"Weak signal ({rssi} dBm) suggests device is distant", "> 10 meters (approximate)") def generate_ble_risk_explanation( - device: dict, - profile: dict | None = None, - is_during_meeting: bool = False + device: dict, profile: dict | None = None, is_during_meeting: bool = False ) -> BLERiskExplanation: """ Generate human-readable risk explanation for a BLE device. @@ -1700,9 +1689,9 @@ def generate_ble_risk_explanation( Returns: BLERiskExplanation with complete assessment """ - mac = device.get('mac', device.get('address', '')).upper() - name = device.get('name', '') - rssi = device.get('rssi', device.get('signal')) + mac = device.get("mac", device.get("address", "")).upper() + name = device.get("name", "") + rssi = device.get("rssi", device.get("signal")) explanation = BLERiskExplanation( identifier=mac, @@ -1722,31 +1711,31 @@ def generate_ble_risk_explanation( explanation.proximity_explanation = "Could not parse RSSI value" # Tracker detection with explanation - device.get('tracker_type') or device.get('is_tracker') - if device.get('is_airtag'): + device.get("tracker_type") or device.get("is_tracker") + if device.get("is_airtag"): explanation.is_tracker = True - explanation.tracker_type = 'Apple AirTag' + explanation.tracker_type = "Apple AirTag" explanation.tracker_explanation = ( "Apple AirTag detected via manufacturer data. AirTags are legitimate " "tracking devices but may indicate unwanted tracking if not recognized. " "Apple's Find My network will alert iPhone users to unknown AirTags." ) - elif device.get('is_tile'): + elif device.get("is_tile"): explanation.is_tracker = True - explanation.tracker_type = 'Tile' + explanation.tracker_type = "Tile" explanation.tracker_explanation = ( "Tile tracker detected. Tile trackers are common consumer devices " "for finding lost items. Presence does not indicate surveillance." ) - elif device.get('is_smarttag'): + elif device.get("is_smarttag"): explanation.is_tracker = True - explanation.tracker_type = 'Samsung SmartTag' + explanation.tracker_type = "Samsung SmartTag" explanation.tracker_explanation = ( "Samsung SmartTag detected. SmartTags are consumer tracking devices " "similar to AirTags. Samsung phones can detect unknown SmartTags." ) - elif device.get('is_espressif'): - explanation.tracker_type = 'ESP32/ESP8266' + elif device.get("is_espressif"): + explanation.tracker_type = "ESP32/ESP8266" explanation.tracker_explanation = ( "Espressif chipset (ESP32/ESP8266) detected. These are programmable " "development boards commonly used in IoT projects. They can be configured " @@ -1754,7 +1743,7 @@ def generate_ble_risk_explanation( ) # Meeting correlation explanation - if is_during_meeting or device.get('meeting_correlated'): + if is_during_meeting or device.get("meeting_correlated"): explanation.meeting_correlated = True explanation.meeting_explanation = ( "Device detected during a marked meeting window. This temporal correlation " @@ -1764,28 +1753,30 @@ def generate_ble_risk_explanation( # Build risk explanation from profile if profile: - explanation.risk_level = profile.get('risk_level', 'informational') - explanation.risk_score = profile.get('total_score', 0) + explanation.risk_level = profile.get("risk_level", "informational") + explanation.risk_score = profile.get("total_score", 0) # Convert indicators to explanations - for ind in profile.get('indicators', []): - ind_type = ind.get('type', '') - ind_desc = ind.get('description', '') + for ind in profile.get("indicators", []): + ind_type = ind.get("type", "") + ind_desc = ind.get("description", "") - explanation.indicators.append({ - 'type': ind_type, - 'description': ind_desc, - 'explanation': _get_indicator_explanation(ind_type), - }) + explanation.indicators.append( + { + "type": ind_type, + "description": ind_desc, + "explanation": _get_indicator_explanation(ind_type), + } + ) # Build overall risk explanation - if explanation.risk_level == 'high_interest': + if explanation.risk_level == "high_interest": explanation.risk_explanation = ( f"This device has accumulated {explanation.risk_score} risk points " "across multiple indicators, warranting closer investigation. " "High interest does not confirm surveillance - manual verification required." ) - elif explanation.risk_level == 'review': + elif explanation.risk_level == "review": explanation.risk_explanation = ( f"This device shows {explanation.risk_score} risk points indicating " "it should be reviewed but is not immediately concerning." @@ -1807,48 +1798,42 @@ def generate_ble_risk_explanation( def _get_indicator_explanation(indicator_type: str) -> str: """Get human-readable explanation for an indicator type.""" explanations = { - 'unknown_device': ( + "unknown_device": ( "Device manufacturer is unknown or uses a generic chipset. " "This is common in DIY/hobbyist devices and some surveillance equipment." ), - 'audio_capable': ( + "audio_capable": ( "Device advertises audio services (headphones, speakers, etc.). " "Audio-capable devices could theoretically transmit captured audio." ), - 'persistent': ( + "persistent": ( "Device has been detected repeatedly across multiple scans. " "Persistence suggests a fixed or regularly present device." ), - 'meeting_correlated': ( + "meeting_correlated": ( "Device activity correlates with marked meeting windows. " "This is a temporal pattern that warrants attention." ), - 'hidden_identity': ( + "hidden_identity": ( "Device does not broadcast a name or uses minimal advertising. " "Some legitimate devices minimize advertising for battery life." ), - 'stable_rssi': ( + "stable_rssi": ( "Signal strength is very stable, suggesting a stationary device. " "Fixed placement could indicate a planted device." ), - 'mac_rotation': ( + "mac_rotation": ( "Device appears to use MAC address randomization. " "This is a privacy feature in modern devices, also used to evade detection." ), - 'known_tracker': ( + "known_tracker": ( "Device matches known tracking device signatures. " "May be a legitimate item tracker or unwanted surveillance." ), - 'airtag_detected': ( - "Apple AirTag identified. Check if this belongs to someone present." - ), - 'tile_detected': ( - "Tile tracker identified. Common consumer tracking device." - ), - 'smarttag_detected': ( - "Samsung SmartTag identified. Consumer tracking device." - ), - 'esp32_device': ( + "airtag_detected": ("Apple AirTag identified. Check if this belongs to someone present."), + "tile_detected": ("Tile tracker identified. Common consumer tracking device."), + "smarttag_detected": ("Samsung SmartTag identified. Consumer tracking device."), + "esp32_device": ( "Espressif development board detected. Highly programmable, " "could be configured for custom surveillance applications." ), @@ -1858,37 +1843,36 @@ def _get_indicator_explanation(indicator_type: str) -> str: def _set_recommended_action(explanation: BLERiskExplanation) -> None: """Set recommended action based on risk assessment.""" - if explanation.risk_level == 'high_interest': + if explanation.risk_level == "high_interest": if explanation.is_tracker and explanation.proximity == BLEProximity.VERY_CLOSE: - explanation.recommended_action = 'Investigate immediately' + explanation.recommended_action = "Investigate immediately" explanation.action_rationale = ( "Unknown tracker in very close proximity warrants immediate " "physical search of the area and personal belongings." ) elif explanation.is_tracker: - explanation.recommended_action = 'Investigate location' + explanation.recommended_action = "Investigate location" explanation.action_rationale = ( "Tracker detected - recommend searching the area to locate " "the physical device and determine if it belongs to someone present." ) else: - explanation.recommended_action = 'Review and document' + explanation.recommended_action = "Review and document" explanation.action_rationale = ( "Multiple risk indicators present. Document the finding, " "attempt to identify the device, and consider physical search " "if other indicators suggest surveillance." ) - elif explanation.risk_level == 'review': - explanation.recommended_action = 'Monitor and document' + elif explanation.risk_level == "review": + explanation.recommended_action = "Monitor and document" explanation.action_rationale = ( "Device shows some indicators worth noting. Add to monitoring list " "and compare against future sweeps to identify patterns." ) else: - explanation.recommended_action = 'Continue monitoring' + explanation.recommended_action = "Continue monitoring" explanation.action_rationale = ( - "No immediate action required. Device will be tracked in subsequent " - "sweeps for pattern analysis." + "No immediate action required. Device will be tracked in subsequent sweeps for pattern analysis." ) @@ -1896,9 +1880,11 @@ def _set_recommended_action(explanation: BLERiskExplanation) -> None: # 9. Operator Playbooks ("What To Do Next") # ============================================================================= + @dataclass class PlaybookStep: """A single step in an operator playbook.""" + step_number: int action: str details: str @@ -1913,32 +1899,33 @@ class OperatorPlaybook: Playbooks are procedural (what to do), not prescriptive (how to decide). All guidance is legally safe and professional. """ + playbook_id: str title: str risk_level: str description: str steps: list[PlaybookStep] = field(default_factory=list) - when_to_escalate: str = '' + when_to_escalate: str = "" documentation_required: list[str] = field(default_factory=list) def to_dict(self) -> dict: return { - 'playbook_id': self.playbook_id, - 'title': self.title, - 'risk_level': self.risk_level, - 'description': self.description, - 'steps': [ + "playbook_id": self.playbook_id, + "title": self.title, + "risk_level": self.risk_level, + "description": self.description, + "steps": [ { - 'step': s.step_number, - 'action': s.action, - 'details': s.details, - 'safety_note': s.safety_note, + "step": s.step_number, + "action": s.action, + "details": s.details, + "safety_note": s.safety_note, } for s in self.steps ], - 'when_to_escalate': self.when_to_escalate, - 'documentation_required': self.documentation_required, - 'disclaimer': ( + "when_to_escalate": self.when_to_escalate, + "documentation_required": self.documentation_required, + "disclaimer": ( "This playbook provides procedural guidance only. Actions should be " "adapted to local laws, organizational policies, and professional judgment. " "Do not disassemble, interfere with, or remove suspected devices without " @@ -1949,204 +1936,198 @@ class OperatorPlaybook: # Predefined playbooks by risk level PLAYBOOKS = { - 'high_interest_tracker': OperatorPlaybook( - playbook_id='PB-001', - title='High Interest: Unknown Tracker Detection', - risk_level='high_interest', - description='Guidance for responding to unknown tracking device detection', + "high_interest_tracker": OperatorPlaybook( + playbook_id="PB-001", + title="High Interest: Unknown Tracker Detection", + risk_level="high_interest", + description="Guidance for responding to unknown tracking device detection", steps=[ PlaybookStep( step_number=1, - action='Document the finding', - details='Record device identifier, signal strength, location, and timestamp. Take screenshots of the detection.', + action="Document the finding", + details="Record device identifier, signal strength, location, and timestamp. Take screenshots of the detection.", ), PlaybookStep( step_number=2, - action='Estimate device location', - details='Use signal strength variations while moving to triangulate approximate device position. Note areas of strongest signal.', - safety_note='Do not touch or disturb any physical device found.', + action="Estimate device location", + details="Use signal strength variations while moving to triangulate approximate device position. Note areas of strongest signal.", + safety_note="Do not touch or disturb any physical device found.", ), PlaybookStep( step_number=3, - action='Physical search (if authorized)', - details='Systematically search the high-signal area. Check common hiding spots: under furniture, in plants, behind fixtures, in bags/belongings.', - safety_note='Only conduct physical searches with proper authorization.', + action="Physical search (if authorized)", + details="Systematically search the high-signal area. Check common hiding spots: under furniture, in plants, behind fixtures, in bags/belongings.", + safety_note="Only conduct physical searches with proper authorization.", ), PlaybookStep( step_number=4, - action='Identify device owner', - details='If device is located, determine if it belongs to someone legitimately present. Apple/Samsung/Tile devices can be scanned by their respective apps.', + action="Identify device owner", + details="If device is located, determine if it belongs to someone legitimately present. Apple/Samsung/Tile devices can be scanned by their respective apps.", ), PlaybookStep( step_number=5, - action='Escalate if unidentified', - details='If device owner cannot be determined and device is in sensitive location, escalate to security management.', + action="Escalate if unidentified", + details="If device owner cannot be determined and device is in sensitive location, escalate to security management.", ), ], - when_to_escalate='Escalate immediately if: device is concealed in sensitive area, owner cannot be identified, or multiple unknown trackers are found.', + when_to_escalate="Escalate immediately if: device is concealed in sensitive area, owner cannot be identified, or multiple unknown trackers are found.", documentation_required=[ - 'Device identifier (MAC address)', - 'Signal strength readings at multiple locations', - 'Physical location description', - 'Photos of any located devices', - 'Names of individuals present during search', + "Device identifier (MAC address)", + "Signal strength readings at multiple locations", + "Physical location description", + "Photos of any located devices", + "Names of individuals present during search", ], ), - - 'high_interest_generic': OperatorPlaybook( - playbook_id='PB-002', - title='High Interest: Suspicious Device Pattern', - risk_level='high_interest', - description='Guidance for devices with multiple high-risk indicators', + "high_interest_generic": OperatorPlaybook( + playbook_id="PB-002", + title="High Interest: Suspicious Device Pattern", + risk_level="high_interest", + description="Guidance for devices with multiple high-risk indicators", steps=[ PlaybookStep( step_number=1, - action='Review all indicators', - details='Examine each risk indicator in the device profile. Understand why the device scored high interest.', + action="Review all indicators", + details="Examine each risk indicator in the device profile. Understand why the device scored high interest.", ), PlaybookStep( step_number=2, - action='Cross-reference with baseline', - details='Check if device appears in baseline. New devices warrant more scrutiny than known devices.', + action="Cross-reference with baseline", + details="Check if device appears in baseline. New devices warrant more scrutiny than known devices.", ), PlaybookStep( step_number=3, - action='Monitor for pattern', - details='Continue sweep and note if device persists, moves, or correlates with sensitive activities.', + action="Monitor for pattern", + details="Continue sweep and note if device persists, moves, or correlates with sensitive activities.", ), PlaybookStep( step_number=4, - action='Attempt identification', - details='Research manufacturer OUI, check for matching devices in the environment, ask occupants about devices.', + action="Attempt identification", + details="Research manufacturer OUI, check for matching devices in the environment, ask occupants about devices.", ), PlaybookStep( step_number=5, - action='Document and report', - details='Add finding to sweep report with full details. Include in meeting/client debrief.', + action="Document and report", + details="Add finding to sweep report with full details. Include in meeting/client debrief.", ), ], - when_to_escalate='Escalate if: device cannot be identified, shows surveillance-consistent behavior, or correlates strongly with sensitive activities.', + when_to_escalate="Escalate if: device cannot be identified, shows surveillance-consistent behavior, or correlates strongly with sensitive activities.", documentation_required=[ - 'Complete device profile', - 'All risk indicators with scores', - 'Timeline of observations', - 'Correlation with meeting windows', - 'Any identification attempts and results', + "Complete device profile", + "All risk indicators with scores", + "Timeline of observations", + "Correlation with meeting windows", + "Any identification attempts and results", ], ), - - 'needs_review': OperatorPlaybook( - playbook_id='PB-003', - title='Needs Review: Unknown Device', - risk_level='needs_review', - description='Guidance for devices requiring investigation but not immediately concerning', + "needs_review": OperatorPlaybook( + playbook_id="PB-003", + title="Needs Review: Unknown Device", + risk_level="needs_review", + description="Guidance for devices requiring investigation but not immediately concerning", steps=[ PlaybookStep( step_number=1, - action='Note the device', - details='Add device to monitoring list. Record basic details: identifier, type, signal strength.', + action="Note the device", + details="Add device to monitoring list. Record basic details: identifier, type, signal strength.", ), PlaybookStep( step_number=2, - action='Check against known devices', - details='Verify device is not a known infrastructure device or personal device of authorized personnel.', + action="Check against known devices", + details="Verify device is not a known infrastructure device or personal device of authorized personnel.", ), PlaybookStep( step_number=3, - action='Continue sweep', - details='Complete the sweep. Review device in context of all findings.', + action="Continue sweep", + details="Complete the sweep. Review device in context of all findings.", ), PlaybookStep( step_number=4, - action='Assess in final review', - details='During sweep wrap-up, decide if device warrants further investigation or can be added to baseline.', + action="Assess in final review", + details="During sweep wrap-up, decide if device warrants further investigation or can be added to baseline.", ), ], when_to_escalate='Escalate if: multiple "needs review" devices appear together, or device shows high-interest indicators in subsequent sweeps.', documentation_required=[ - 'Device identifier and type', - 'Brief description of why flagged', - 'Decision made (investigate further / add to baseline / monitor)', + "Device identifier and type", + "Brief description of why flagged", + "Decision made (investigate further / add to baseline / monitor)", ], ), - - 'informational': OperatorPlaybook( - playbook_id='PB-004', - title='Informational: Known/Expected Device', - risk_level='informational', - description='Guidance for devices that appear normal and expected', + "informational": OperatorPlaybook( + playbook_id="PB-004", + title="Informational: Known/Expected Device", + risk_level="informational", + description="Guidance for devices that appear normal and expected", steps=[ PlaybookStep( step_number=1, - action='Verify against baseline', - details='Confirm device matches baseline entry. Note any changes (signal strength, channel, etc.).', + action="Verify against baseline", + details="Confirm device matches baseline entry. Note any changes (signal strength, channel, etc.).", ), PlaybookStep( step_number=2, - action='Log observation', - details='Record observation for timeline tracking. Even known devices should be logged.', + action="Log observation", + details="Record observation for timeline tracking. Even known devices should be logged.", ), PlaybookStep( step_number=3, - action='Continue sweep', - details='No further action required. Proceed with sweep.', + action="Continue sweep", + details="No further action required. Proceed with sweep.", ), ], - when_to_escalate='Only escalate if device shows unexpected behavior changes or additional risk indicators.', + when_to_escalate="Only escalate if device shows unexpected behavior changes or additional risk indicators.", documentation_required=[ - 'Device identifier (for timeline)', - 'Observation timestamp', + "Device identifier (for timeline)", + "Observation timestamp", ], ), - - 'wifi_evil_twin': OperatorPlaybook( - playbook_id='PB-005', - title='High Interest: Evil Twin Pattern Detected', - risk_level='high_interest', - description='Guidance when duplicate SSID with security mismatch is detected', + "wifi_evil_twin": OperatorPlaybook( + playbook_id="PB-005", + title="High Interest: Evil Twin Pattern Detected", + risk_level="high_interest", + description="Guidance when duplicate SSID with security mismatch is detected", steps=[ PlaybookStep( step_number=1, - action='Document both access points', - details='Record details of legitimate AP and suspected rogue: BSSID, security, signal strength, channel.', + action="Document both access points", + details="Record details of legitimate AP and suspected rogue: BSSID, security, signal strength, channel.", ), PlaybookStep( step_number=2, - action='Verify legitimate AP', - details='Confirm which AP is the authorized infrastructure. Check with IT/facilities if needed.', + action="Verify legitimate AP", + details="Confirm which AP is the authorized infrastructure. Check with IT/facilities if needed.", ), PlaybookStep( step_number=3, - action='Locate rogue AP', - details='Use signal strength to estimate rogue AP location. Walk the area noting signal variations.', - safety_note='Do not connect to or interact with the suspected rogue AP.', + action="Locate rogue AP", + details="Use signal strength to estimate rogue AP location. Walk the area noting signal variations.", + safety_note="Do not connect to or interact with the suspected rogue AP.", ), PlaybookStep( step_number=4, - action='Physical search', - details='Search suspected area for unauthorized access point. Check for hidden devices, suspicious equipment.', + action="Physical search", + details="Search suspected area for unauthorized access point. Check for hidden devices, suspicious equipment.", ), PlaybookStep( step_number=5, - action='Report to IT Security', - details='Even if device not found, report the finding to IT Security for network monitoring.', + action="Report to IT Security", + details="Even if device not found, report the finding to IT Security for network monitoring.", ), ], - when_to_escalate='Escalate immediately. Evil twin attacks can capture credentials and traffic.', + when_to_escalate="Escalate immediately. Evil twin attacks can capture credentials and traffic.", documentation_required=[ - 'Both AP details (BSSID, SSID, security, channel, signal)', - 'Location where detected', - 'Signal strength map if created', - 'Physical search results', + "Both AP details (BSSID, SSID, security, channel, signal)", + "Location where detected", + "Signal strength map if created", + "Physical search results", ], ), } def get_playbook_for_finding( - risk_level: str, - finding_type: str | None = None, - indicators: list[dict] | None = None + risk_level: str, finding_type: str | None = None, indicators: list[dict] | None = None ) -> OperatorPlaybook: """ Get appropriate playbook for a finding. @@ -2160,22 +2141,22 @@ def get_playbook_for_finding( Appropriate OperatorPlaybook """ # Check for specific finding types - if finding_type == 'evil_twin': - return PLAYBOOKS['wifi_evil_twin'] + if finding_type == "evil_twin": + return PLAYBOOKS["wifi_evil_twin"] # Check indicators for tracker if indicators: - tracker_types = ['airtag_detected', 'tile_detected', 'smarttag_detected', 'known_tracker'] - if any(i.get('type') in tracker_types for i in indicators) and risk_level == 'high_interest': - return PLAYBOOKS['high_interest_tracker'] + tracker_types = ["airtag_detected", "tile_detected", "smarttag_detected", "known_tracker"] + if any(i.get("type") in tracker_types for i in indicators) and risk_level == "high_interest": + return PLAYBOOKS["high_interest_tracker"] # Return based on risk level - if risk_level == 'high_interest': - return PLAYBOOKS['high_interest_generic'] - elif risk_level in ['review', 'needs_review']: - return PLAYBOOKS['needs_review'] + if risk_level == "high_interest": + return PLAYBOOKS["high_interest_generic"] + elif risk_level in ["review", "needs_review"]: + return PLAYBOOKS["needs_review"] else: - return PLAYBOOKS['informational'] + return PLAYBOOKS["informational"] def attach_playbook_to_finding(finding: dict) -> dict: @@ -2188,13 +2169,13 @@ def attach_playbook_to_finding(finding: dict) -> dict: Returns: Finding dict with playbook attached """ - risk_level = finding.get('risk_level', 'informational') - finding_type = finding.get('finding_type') - indicators = finding.get('indicators', []) + risk_level = finding.get("risk_level", "informational") + finding_type = finding.get("finding_type") + indicators = finding.get("indicators", []) playbook = get_playbook_for_finding(risk_level, finding_type, indicators) - finding['suggested_playbook'] = playbook.to_dict() - finding['suggested_next_steps'] = [ + finding["suggested_playbook"] = playbook.to_dict() + finding["suggested_next_steps"] = [ f"Step {s.step_number}: {s.action}" for s in playbook.steps[:3] # First 3 steps as quick reference ] diff --git a/utils/tscm/baseline.py b/utils/tscm/baseline.py index d437e39..c9cf2f5 100644 --- a/utils/tscm/baseline.py +++ b/utils/tscm/baseline.py @@ -16,7 +16,7 @@ from utils.database import ( update_tscm_baseline, ) -logger = logging.getLogger('intercept.tscm.baseline') +logger = logging.getLogger("intercept.tscm.baseline") class BaselineRecorder: @@ -24,20 +24,15 @@ class BaselineRecorder: Records and manages TSCM environment baselines. """ - def __init__(self): - self.recording = False - self.current_baseline_id: int | None = None - self.wifi_networks: dict[str, dict] = {} # BSSID -> network info - self.wifi_clients: dict[str, dict] = {} # MAC -> client info - self.bt_devices: dict[str, dict] = {} # MAC -> device info - self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info + def __init__(self): + self.recording = False + self.current_baseline_id: int | None = None + self.wifi_networks: dict[str, dict] = {} # BSSID -> network info + self.wifi_clients: dict[str, dict] = {} # MAC -> client info + self.bt_devices: dict[str, dict] = {} # MAC -> device info + self.rf_frequencies: dict[float, dict] = {} # Frequency -> signal info - def start_recording( - self, - name: str, - location: str | None = None, - description: str | None = None - ) -> int: + def start_recording(self, name: str, location: str | None = None, description: str | None = None) -> int: """ Start recording a new baseline. @@ -49,18 +44,14 @@ class BaselineRecorder: Returns: Baseline ID """ - self.recording = True - self.wifi_networks = {} - self.wifi_clients = {} - self.bt_devices = {} - self.rf_frequencies = {} + self.recording = True + self.wifi_networks = {} + self.wifi_clients = {} + self.bt_devices = {} + self.rf_frequencies = {} # Create baseline in database - self.current_baseline_id = create_tscm_baseline( - name=name, - location=location, - description=description - ) + self.current_baseline_id = create_tscm_baseline(name=name, location=location, description=description) logger.info(f"Started baseline recording: {name} (ID: {self.current_baseline_id})") return self.current_baseline_id @@ -73,32 +64,32 @@ class BaselineRecorder: Final baseline summary """ if not self.recording or not self.current_baseline_id: - return {'error': 'Not recording'} + return {"error": "Not recording"} self.recording = False # Convert to lists for storage - wifi_list = list(self.wifi_networks.values()) - wifi_client_list = list(self.wifi_clients.values()) - bt_list = list(self.bt_devices.values()) - rf_list = list(self.rf_frequencies.values()) + wifi_list = list(self.wifi_networks.values()) + wifi_client_list = list(self.wifi_clients.values()) + bt_list = list(self.bt_devices.values()) + rf_list = list(self.rf_frequencies.values()) # Update database - update_tscm_baseline( - self.current_baseline_id, - wifi_networks=wifi_list, - wifi_clients=wifi_client_list, - bt_devices=bt_list, - rf_frequencies=rf_list - ) + update_tscm_baseline( + self.current_baseline_id, + wifi_networks=wifi_list, + wifi_clients=wifi_client_list, + bt_devices=bt_list, + rf_frequencies=rf_list, + ) - summary = { - 'baseline_id': self.current_baseline_id, - 'wifi_count': len(wifi_list), - 'wifi_client_count': len(wifi_client_list), - 'bt_count': len(bt_list), - 'rf_count': len(rf_list), - } + summary = { + "baseline_id": self.current_baseline_id, + "wifi_count": len(wifi_list), + "wifi_client_count": len(wifi_client_list), + "bt_count": len(bt_list), + "rf_count": len(rf_list), + } logger.info( f"Baseline recording complete: {summary['wifi_count']} WiFi, " @@ -114,87 +105,93 @@ class BaselineRecorder: if not self.recording: return - mac = device.get('bssid', device.get('mac', '')).upper() + mac = device.get("bssid", device.get("mac", "")).upper() if not mac: return # Update or add device if mac in self.wifi_networks: # Update with latest info - self.wifi_networks[mac].update({ - 'last_seen': datetime.now().isoformat(), - 'power': device.get('power', self.wifi_networks[mac].get('power')), - }) + self.wifi_networks[mac].update( + { + "last_seen": datetime.now().isoformat(), + "power": device.get("power", self.wifi_networks[mac].get("power")), + } + ) else: self.wifi_networks[mac] = { - 'bssid': mac, - 'essid': device.get('essid', device.get('ssid', '')), - 'channel': device.get('channel'), - 'power': device.get('power', device.get('signal')), - 'vendor': device.get('vendor', ''), - 'encryption': device.get('privacy', device.get('encryption', '')), - 'first_seen': datetime.now().isoformat(), - 'last_seen': datetime.now().isoformat(), + "bssid": mac, + "essid": device.get("essid", device.get("ssid", "")), + "channel": device.get("channel"), + "power": device.get("power", device.get("signal")), + "vendor": device.get("vendor", ""), + "encryption": device.get("privacy", device.get("encryption", "")), + "first_seen": datetime.now().isoformat(), + "last_seen": datetime.now().isoformat(), } - def add_bt_device(self, device: dict) -> None: - """Add a Bluetooth device to the current baseline.""" + def add_bt_device(self, device: dict) -> None: + """Add a Bluetooth device to the current baseline.""" if not self.recording: return - mac = device.get('mac', device.get('address', '')).upper() + mac = device.get("mac", device.get("address", "")).upper() if not mac: return if mac in self.bt_devices: - self.bt_devices[mac].update({ - 'last_seen': datetime.now().isoformat(), - 'rssi': device.get('rssi', self.bt_devices[mac].get('rssi')), - }) + self.bt_devices[mac].update( + { + "last_seen": datetime.now().isoformat(), + "rssi": device.get("rssi", self.bt_devices[mac].get("rssi")), + } + ) else: - self.bt_devices[mac] = { - 'mac': mac, - 'name': device.get('name', ''), - 'rssi': device.get('rssi', device.get('signal')), - 'manufacturer': device.get('manufacturer', ''), - 'type': device.get('type', ''), - 'first_seen': datetime.now().isoformat(), - 'last_seen': datetime.now().isoformat(), - } - - def add_wifi_client(self, client: dict) -> None: - """Add a WiFi client to the current baseline.""" - if not self.recording: - return - - mac = client.get('mac', client.get('address', '')).upper() - if not mac: - return - - if mac in self.wifi_clients: - self.wifi_clients[mac].update({ - 'last_seen': datetime.now().isoformat(), - 'rssi': client.get('rssi', self.wifi_clients[mac].get('rssi')), - 'associated_bssid': client.get('associated_bssid', self.wifi_clients[mac].get('associated_bssid')), - }) - else: - self.wifi_clients[mac] = { - 'mac': mac, - 'vendor': client.get('vendor', ''), - 'rssi': client.get('rssi'), - 'associated_bssid': client.get('associated_bssid'), - 'probed_ssids': client.get('probed_ssids', []), - 'probe_count': client.get('probe_count', len(client.get('probed_ssids', []))), - 'first_seen': datetime.now().isoformat(), - 'last_seen': datetime.now().isoformat(), - } - - def add_rf_signal(self, signal: dict) -> None: - """Add an RF signal to the current baseline.""" + self.bt_devices[mac] = { + "mac": mac, + "name": device.get("name", ""), + "rssi": device.get("rssi", device.get("signal")), + "manufacturer": device.get("manufacturer", ""), + "type": device.get("type", ""), + "first_seen": datetime.now().isoformat(), + "last_seen": datetime.now().isoformat(), + } + + def add_wifi_client(self, client: dict) -> None: + """Add a WiFi client to the current baseline.""" if not self.recording: return - frequency = signal.get('frequency') + mac = client.get("mac", client.get("address", "")).upper() + if not mac: + return + + if mac in self.wifi_clients: + self.wifi_clients[mac].update( + { + "last_seen": datetime.now().isoformat(), + "rssi": client.get("rssi", self.wifi_clients[mac].get("rssi")), + "associated_bssid": client.get("associated_bssid", self.wifi_clients[mac].get("associated_bssid")), + } + ) + else: + self.wifi_clients[mac] = { + "mac": mac, + "vendor": client.get("vendor", ""), + "rssi": client.get("rssi"), + "associated_bssid": client.get("associated_bssid"), + "probed_ssids": client.get("probed_ssids", []), + "probe_count": client.get("probe_count", len(client.get("probed_ssids", []))), + "first_seen": datetime.now().isoformat(), + "last_seen": datetime.now().isoformat(), + } + + def add_rf_signal(self, signal: dict) -> None: + """Add an RF signal to the current baseline.""" + if not self.recording: + return + + frequency = signal.get("frequency") if not frequency: return @@ -203,33 +200,33 @@ class BaselineRecorder: if freq_key in self.rf_frequencies: existing = self.rf_frequencies[freq_key] - existing['last_seen'] = datetime.now().isoformat() - existing['hit_count'] = existing.get('hit_count', 1) + 1 + existing["last_seen"] = datetime.now().isoformat() + existing["hit_count"] = existing.get("hit_count", 1) + 1 # Update max signal level - new_level = signal.get('level', signal.get('power', -100)) - if new_level > existing.get('max_level', -100): - existing['max_level'] = new_level + new_level = signal.get("level", signal.get("power", -100)) + if new_level > existing.get("max_level", -100): + existing["max_level"] = new_level else: self.rf_frequencies[freq_key] = { - 'frequency': freq_key, - 'level': signal.get('level', signal.get('power')), - 'max_level': signal.get('level', signal.get('power', -100)), - 'modulation': signal.get('modulation', ''), - 'first_seen': datetime.now().isoformat(), - 'last_seen': datetime.now().isoformat(), - 'hit_count': 1, + "frequency": freq_key, + "level": signal.get("level", signal.get("power")), + "max_level": signal.get("level", signal.get("power", -100)), + "modulation": signal.get("modulation", ""), + "first_seen": datetime.now().isoformat(), + "last_seen": datetime.now().isoformat(), + "hit_count": 1, } - def get_recording_status(self) -> dict: - """Get current recording status and counts.""" - return { - 'recording': self.recording, - 'baseline_id': self.current_baseline_id, - 'wifi_count': len(self.wifi_networks), - 'wifi_client_count': len(self.wifi_clients), - 'bt_count': len(self.bt_devices), - 'rf_count': len(self.rf_frequencies), - } + def get_recording_status(self) -> dict: + """Get current recording status and counts.""" + return { + "recording": self.recording, + "baseline_id": self.current_baseline_id, + "wifi_count": len(self.wifi_networks), + "wifi_client_count": len(self.wifi_clients), + "bt_count": len(self.bt_devices), + "rf_count": len(self.rf_frequencies), + } class BaselineComparator: @@ -246,24 +243,22 @@ class BaselineComparator: """ self.baseline = baseline self.baseline_wifi = { - d.get('bssid', d.get('mac', '')).upper(): d - for d in baseline.get('wifi_networks', []) - if d.get('bssid') or d.get('mac') + d.get("bssid", d.get("mac", "")).upper(): d + for d in baseline.get("wifi_networks", []) + if d.get("bssid") or d.get("mac") + } + self.baseline_bt = { + d.get("mac", d.get("address", "")).upper(): d + for d in baseline.get("bt_devices", []) + if d.get("mac") or d.get("address") + } + self.baseline_wifi_clients = { + d.get("mac", d.get("address", "")).upper(): d + for d in baseline.get("wifi_clients", []) + if d.get("mac") or d.get("address") } - self.baseline_bt = { - d.get('mac', d.get('address', '')).upper(): d - for d in baseline.get('bt_devices', []) - if d.get('mac') or d.get('address') - } - self.baseline_wifi_clients = { - d.get('mac', d.get('address', '')).upper(): d - for d in baseline.get('wifi_clients', []) - if d.get('mac') or d.get('address') - } self.baseline_rf = { - round(d.get('frequency', 0), 1): d - for d in baseline.get('rf_frequencies', []) - if d.get('frequency') + round(d.get("frequency", 0), 1): d for d in baseline.get("rf_frequencies", []) if d.get("frequency") } def compare_wifi(self, current_devices: list[dict]) -> dict: @@ -274,9 +269,7 @@ class BaselineComparator: Dict with new, missing, and matching devices """ current_macs = { - d.get('bssid', d.get('mac', '')).upper(): d - for d in current_devices - if d.get('bssid') or d.get('mac') + d.get("bssid", d.get("mac", "")).upper(): d for d in current_devices if d.get("bssid") or d.get("mac") } new_devices = [] @@ -296,20 +289,18 @@ class BaselineComparator: missing_devices.append(device) return { - 'new': new_devices, - 'missing': missing_devices, - 'matching': matching_devices, - 'new_count': len(new_devices), - 'missing_count': len(missing_devices), - 'matching_count': len(matching_devices), + "new": new_devices, + "missing": missing_devices, + "matching": matching_devices, + "new_count": len(new_devices), + "missing_count": len(missing_devices), + "matching_count": len(matching_devices), } - def compare_bluetooth(self, current_devices: list[dict]) -> dict: - """Compare current Bluetooth devices against baseline.""" + def compare_bluetooth(self, current_devices: list[dict]) -> dict: + """Compare current Bluetooth devices against baseline.""" current_macs = { - d.get('mac', d.get('address', '')).upper(): d - for d in current_devices - if d.get('mac') or d.get('address') + d.get("mac", d.get("address", "")).upper(): d for d in current_devices if d.get("mac") or d.get("address") } new_devices = [] @@ -326,53 +317,47 @@ class BaselineComparator: if mac not in current_macs: missing_devices.append(device) - return { - 'new': new_devices, - 'missing': missing_devices, - 'matching': matching_devices, - 'new_count': len(new_devices), - 'missing_count': len(missing_devices), - 'matching_count': len(matching_devices), - } - - def compare_wifi_clients(self, current_devices: list[dict]) -> dict: - """Compare current WiFi clients against baseline.""" - current_macs = { - d.get('mac', d.get('address', '')).upper(): d - for d in current_devices - if d.get('mac') or d.get('address') - } - - new_devices = [] - missing_devices = [] - matching_devices = [] - - for mac, device in current_macs.items(): - if mac not in self.baseline_wifi_clients: - new_devices.append(device) - else: - matching_devices.append(device) - - for mac, device in self.baseline_wifi_clients.items(): - if mac not in current_macs: - missing_devices.append(device) - - return { - 'new': new_devices, - 'missing': missing_devices, - 'matching': matching_devices, - 'new_count': len(new_devices), - 'missing_count': len(missing_devices), - 'matching_count': len(matching_devices), - } + return { + "new": new_devices, + "missing": missing_devices, + "matching": matching_devices, + "new_count": len(new_devices), + "missing_count": len(missing_devices), + "matching_count": len(matching_devices), + } + + def compare_wifi_clients(self, current_devices: list[dict]) -> dict: + """Compare current WiFi clients against baseline.""" + current_macs = { + d.get("mac", d.get("address", "")).upper(): d for d in current_devices if d.get("mac") or d.get("address") + } + + new_devices = [] + missing_devices = [] + matching_devices = [] + + for mac, device in current_macs.items(): + if mac not in self.baseline_wifi_clients: + new_devices.append(device) + else: + matching_devices.append(device) + + for mac, device in self.baseline_wifi_clients.items(): + if mac not in current_macs: + missing_devices.append(device) + + return { + "new": new_devices, + "missing": missing_devices, + "matching": matching_devices, + "new_count": len(new_devices), + "missing_count": len(missing_devices), + "matching_count": len(matching_devices), + } def compare_rf(self, current_signals: list[dict]) -> dict: """Compare current RF signals against baseline.""" - current_freqs = { - round(s.get('frequency', 0), 1): s - for s in current_signals - if s.get('frequency') - } + current_freqs = {round(s.get("frequency", 0), 1): s for s in current_signals if s.get("frequency")} new_signals = [] missing_signals = [] @@ -389,65 +374,65 @@ class BaselineComparator: missing_signals.append(signal) return { - 'new': new_signals, - 'missing': missing_signals, - 'matching': matching_signals, - 'new_count': len(new_signals), - 'missing_count': len(missing_signals), - 'matching_count': len(matching_signals), + "new": new_signals, + "missing": missing_signals, + "matching": matching_signals, + "new_count": len(new_signals), + "missing_count": len(missing_signals), + "matching_count": len(matching_signals), } - def compare_all( - self, - wifi_devices: list[dict] | None = None, - wifi_clients: list[dict] | None = None, - bt_devices: list[dict] | None = None, - rf_signals: list[dict] | None = None - ) -> dict: + def compare_all( + self, + wifi_devices: list[dict] | None = None, + wifi_clients: list[dict] | None = None, + bt_devices: list[dict] | None = None, + rf_signals: list[dict] | None = None, + ) -> dict: """ Compare all current data against baseline. Returns: Dict with comparison results for each category """ - results = { - 'wifi': None, - 'wifi_clients': None, - 'bluetooth': None, - 'rf': None, - 'total_new': 0, - 'total_missing': 0, - } + results = { + "wifi": None, + "wifi_clients": None, + "bluetooth": None, + "rf": None, + "total_new": 0, + "total_missing": 0, + } - if wifi_devices is not None: - results['wifi'] = self.compare_wifi(wifi_devices) - results['total_new'] += results['wifi']['new_count'] - results['total_missing'] += results['wifi']['missing_count'] - - if wifi_clients is not None: - results['wifi_clients'] = self.compare_wifi_clients(wifi_clients) - results['total_new'] += results['wifi_clients']['new_count'] - results['total_missing'] += results['wifi_clients']['missing_count'] - - if bt_devices is not None: - results['bluetooth'] = self.compare_bluetooth(bt_devices) - results['total_new'] += results['bluetooth']['new_count'] - results['total_missing'] += results['bluetooth']['missing_count'] + if wifi_devices is not None: + results["wifi"] = self.compare_wifi(wifi_devices) + results["total_new"] += results["wifi"]["new_count"] + results["total_missing"] += results["wifi"]["missing_count"] + + if wifi_clients is not None: + results["wifi_clients"] = self.compare_wifi_clients(wifi_clients) + results["total_new"] += results["wifi_clients"]["new_count"] + results["total_missing"] += results["wifi_clients"]["missing_count"] + + if bt_devices is not None: + results["bluetooth"] = self.compare_bluetooth(bt_devices) + results["total_new"] += results["bluetooth"]["new_count"] + results["total_missing"] += results["bluetooth"]["missing_count"] if rf_signals is not None: - results['rf'] = self.compare_rf(rf_signals) - results['total_new'] += results['rf']['new_count'] - results['total_missing'] += results['rf']['missing_count'] + results["rf"] = self.compare_rf(rf_signals) + results["total_new"] += results["rf"]["new_count"] + results["total_missing"] += results["rf"]["missing_count"] return results -def get_comparison_for_active_baseline( - wifi_devices: list[dict] | None = None, - wifi_clients: list[dict] | None = None, - bt_devices: list[dict] | None = None, - rf_signals: list[dict] | None = None -) -> dict | None: +def get_comparison_for_active_baseline( + wifi_devices: list[dict] | None = None, + wifi_clients: list[dict] | None = None, + bt_devices: list[dict] | None = None, + rf_signals: list[dict] | None = None, +) -> dict | None: """ Convenience function to compare against the active baseline. @@ -459,4 +444,4 @@ def get_comparison_for_active_baseline( return None comparator = BaselineComparator(baseline) - return comparator.compare_all(wifi_devices, wifi_clients, bt_devices, rf_signals) + return comparator.compare_all(wifi_devices, wifi_clients, bt_devices, rf_signals) diff --git a/utils/tscm/ble_scanner.py b/utils/tscm/ble_scanner.py index 0e17830..4070dee 100644 --- a/utils/tscm/ble_scanner.py +++ b/utils/tscm/ble_scanner.py @@ -21,45 +21,45 @@ from dataclasses import dataclass, field from datetime import datetime from typing import Optional -logger = logging.getLogger('intercept.tscm.ble') +logger = logging.getLogger("intercept.tscm.ble") # Manufacturer company IDs (Bluetooth SIG assigned) COMPANY_IDS = { - 0x004C: 'Apple', - 0x02E5: 'Espressif', - 0x0059: 'Nordic Semiconductor', - 0x000D: 'Texas Instruments', - 0x0075: 'Samsung', - 0x00E0: 'Google', - 0x0006: 'Microsoft', - 0x01DA: 'Tile', + 0x004C: "Apple", + 0x02E5: "Espressif", + 0x0059: "Nordic Semiconductor", + 0x000D: "Texas Instruments", + 0x0075: "Samsung", + 0x00E0: "Google", + 0x0006: "Microsoft", + 0x01DA: "Tile", } # Known tracker signatures TRACKER_SIGNATURES = { # Apple AirTag detection patterns - 'airtag': { - 'company_id': 0x004C, - 'data_patterns': [ - b'\x12\x19', # AirTag/Find My advertisement prefix - b'\x07\x19', # Offline Finding + "airtag": { + "company_id": 0x004C, + "data_patterns": [ + b"\x12\x19", # AirTag/Find My advertisement prefix + b"\x07\x19", # Offline Finding ], - 'name_patterns': ['airtag', 'findmy', 'find my'], + "name_patterns": ["airtag", "findmy", "find my"], }, # Tile tracker - 'tile': { - 'company_id': 0x01DA, - 'name_patterns': ['tile'], + "tile": { + "company_id": 0x01DA, + "name_patterns": ["tile"], }, # Samsung SmartTag - 'smarttag': { - 'company_id': 0x0075, - 'name_patterns': ['smarttag', 'smart tag', 'galaxy smart'], + "smarttag": { + "company_id": 0x0075, + "name_patterns": ["smarttag", "smart tag", "galaxy smart"], }, # ESP32/ESP8266 - 'espressif': { - 'company_id': 0x02E5, - 'name_patterns': ['esp32', 'esp8266', 'espressif'], + "espressif": { + "company_id": 0x02E5, + "name_patterns": ["esp32", "esp8266", "espressif"], }, } @@ -67,6 +67,7 @@ TRACKER_SIGNATURES = { @dataclass class BLEDevice: """Represents a detected BLE device with full advertisement data.""" + mac: str name: Optional[str] = None rssi: Optional[int] = None @@ -92,22 +93,22 @@ class BLEDevice: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'mac': self.mac, - 'name': self.name or 'Unknown', - 'rssi': self.rssi, - 'manufacturer_id': self.manufacturer_id, - 'manufacturer_name': self.manufacturer_name, - 'service_uuids': self.service_uuids, - 'tx_power': self.tx_power, - 'is_connectable': self.is_connectable, - 'is_airtag': self.is_airtag, - 'is_tile': self.is_tile, - 'is_smarttag': self.is_smarttag, - 'is_espressif': self.is_espressif, - 'is_tracker': self.is_tracker, - 'tracker_type': self.tracker_type, - 'detection_count': self.detection_count, - 'type': 'ble', + "mac": self.mac, + "name": self.name or "Unknown", + "rssi": self.rssi, + "manufacturer_id": self.manufacturer_id, + "manufacturer_name": self.manufacturer_name, + "service_uuids": self.service_uuids, + "tx_power": self.tx_power, + "is_connectable": self.is_connectable, + "is_airtag": self.is_airtag, + "is_tile": self.is_tile, + "is_smarttag": self.is_smarttag, + "is_espressif": self.is_espressif, + "is_tracker": self.is_tracker, + "tracker_type": self.tracker_type, + "detection_count": self.detection_count, + "type": "ble", } @@ -128,6 +129,7 @@ class BLEScanner: """Check if bleak library is available.""" try: import bleak + return True except ImportError: logger.warning("bleak library not available - using fallback scanning") @@ -177,7 +179,7 @@ class BLEScanner: if adv_data.manufacturer_data: for company_id, data in adv_data.manufacturer_data.items(): ble_device.manufacturer_id = company_id - ble_device.manufacturer_name = COMPANY_IDS.get(company_id, f'Unknown ({hex(company_id)})') + ble_device.manufacturer_name = COMPANY_IDS.get(company_id, f"Unknown ({hex(company_id)})") # Handle various data types safely try: if isinstance(data, (bytes, bytearray, list, tuple)): @@ -259,19 +261,19 @@ class BLEScanner: if data[0] == 0x12 and data[1] == 0x19: device.is_airtag = True device.is_tracker = True - device.tracker_type = 'AirTag' + device.tracker_type = "AirTag" logger.info(f"AirTag detected: {device.mac}") elif data[0] == 0x07: # Offline Finding device.is_airtag = True device.is_tracker = True - device.tracker_type = 'AirTag (Offline)' + device.tracker_type = "AirTag (Offline)" logger.info(f"AirTag (offline mode) detected: {device.mac}") # Tile tracker elif company_id == 0x01DA: # Tile device.is_tile = True device.is_tracker = True - device.tracker_type = 'Tile' + device.tracker_type = "Tile" logger.info(f"Tile tracker detected: {device.mac}") # Samsung SmartTag @@ -279,13 +281,13 @@ class BLEScanner: # Check if it's specifically a SmartTag device.is_smarttag = True device.is_tracker = True - device.tracker_type = 'SmartTag' + device.tracker_type = "SmartTag" logger.info(f"Samsung SmartTag detected: {device.mac}") # Espressif (ESP32/ESP8266) elif company_id == 0x02E5: # Espressif device.is_espressif = True - device.tracker_type = 'ESP32/ESP8266' + device.tracker_type = "ESP32/ESP8266" logger.info(f"ESP32/ESP8266 device detected: {device.mac}") def _check_name_patterns(self, device: BLEDevice): @@ -297,24 +299,24 @@ class BLEScanner: # Check each tracker type for tracker_type, sig in TRACKER_SIGNATURES.items(): - patterns = sig.get('name_patterns', []) + patterns = sig.get("name_patterns", []) for pattern in patterns: if pattern in name_lower: - if tracker_type == 'airtag': + if tracker_type == "airtag": device.is_airtag = True device.is_tracker = True - device.tracker_type = 'AirTag' - elif tracker_type == 'tile': + device.tracker_type = "AirTag" + elif tracker_type == "tile": device.is_tile = True device.is_tracker = True - device.tracker_type = 'Tile' - elif tracker_type == 'smarttag': + device.tracker_type = "Tile" + elif tracker_type == "smarttag": device.is_smarttag = True device.is_tracker = True - device.tracker_type = 'SmartTag' - elif tracker_type == 'espressif': + device.tracker_type = "SmartTag" + elif tracker_type == "espressif": device.is_espressif = True - device.tracker_type = 'ESP32/ESP8266' + device.tracker_type = "ESP32/ESP8266" logger.info(f"Tracker identified by name: {device.name} -> {tracker_type}") return @@ -326,7 +328,7 @@ class BLEScanner: """ system = platform.system() - if system == 'Darwin': + if system == "Darwin": return self._scan_macos(duration) else: return self._scan_linux(duration) @@ -337,20 +339,20 @@ class BLEScanner: try: import json + result = subprocess.run( - ['system_profiler', 'SPBluetoothDataType', '-json'], - capture_output=True, text=True, timeout=15 + ["system_profiler", "SPBluetoothDataType", "-json"], capture_output=True, text=True, timeout=15 ) data = json.loads(result.stdout) - bt_data = data.get('SPBluetoothDataType', [{}])[0] + bt_data = data.get("SPBluetoothDataType", [{}])[0] # Get connected/paired devices - for section in ['device_connected', 'device_title']: + for section in ["device_connected", "device_title"]: section_data = bt_data.get(section, {}) if isinstance(section_data, dict): for name, info in section_data.items(): if isinstance(info, dict): - mac = info.get('device_address', '').upper() + mac = info.get("device_address", "").upper() if mac: device = BLEDevice( mac=mac, @@ -374,26 +376,23 @@ class BLEScanner: seen_macs = set() # Method 1: Try btmgmt for BLE devices - if shutil.which('btmgmt'): + if shutil.which("btmgmt"): try: logger.info("Trying btmgmt find...") - result = subprocess.run( - ['btmgmt', 'find'], - capture_output=True, text=True, timeout=duration + 5 - ) + result = subprocess.run(["btmgmt", "find"], capture_output=True, text=True, timeout=duration + 5) - for line in result.stdout.split('\n'): - if 'dev_found' in line.lower() or ('type' in line.lower() and ':' in line): + for line in result.stdout.split("\n"): + if "dev_found" in line.lower() or ("type" in line.lower() and ":" in line): mac_match = re.search( - r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:' - r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})', - line + r"([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:" + r"[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})", + line, ) if mac_match: mac = mac_match.group(1).upper() if mac not in seen_macs: seen_macs.add(mac) - name_match = re.search(r'name\s+(.+?)(?:\s|$)', line, re.I) + name_match = re.search(r"name\s+(.+?)(?:\s|$)", line, re.I) name = name_match.group(1) if name_match else None device = BLEDevice(mac=mac, name=name) @@ -405,28 +404,26 @@ class BLEScanner: logger.warning(f"btmgmt failed: {e}") # Method 2: Try hcitool lescan - if not devices and shutil.which('hcitool'): + if not devices and shutil.which("hcitool"): try: logger.info("Trying hcitool lescan...") # Start lescan in background process = subprocess.Popen( - ['hcitool', 'lescan', '--duplicates'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True + ["hcitool", "lescan", "--duplicates"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) import time + time.sleep(duration) process.terminate() stdout, _ = process.communicate(timeout=2) - for line in stdout.split('\n'): + for line in stdout.split("\n"): mac_match = re.search( - r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:' - r'[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})', - line + r"([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:" + r"[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})", + line, ) if mac_match: mac = mac_match.group(1).upper() @@ -434,9 +431,9 @@ class BLEScanner: seen_macs.add(mac) # Extract name (comes after MAC) parts = line.strip().split() - name = ' '.join(parts[1:]) if len(parts) > 1 else None + name = " ".join(parts[1:]) if len(parts) > 1 else None - device = BLEDevice(mac=mac, name=name if name != '(unknown)' else None) + device = BLEDevice(mac=mac, name=name if name != "(unknown)" else None) self._check_name_patterns(device) devices.append(device) diff --git a/utils/tscm/correlation.py b/utils/tscm/correlation.py index e23d71d..0a7fdf6 100644 --- a/utils/tscm/correlation.py +++ b/utils/tscm/correlation.py @@ -16,38 +16,40 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta from enum import Enum -logger = logging.getLogger('intercept.tscm.correlation') +logger = logging.getLogger("intercept.tscm.correlation") class RiskLevel(Enum): """Risk classification levels.""" - INFORMATIONAL = 'informational' # Score 0-2 - NEEDS_REVIEW = 'needs_review' # Score 3-5 - HIGH_INTEREST = 'high_interest' # Score 6+ + + INFORMATIONAL = "informational" # Score 0-2 + NEEDS_REVIEW = "needs_review" # Score 3-5 + HIGH_INTEREST = "high_interest" # Score 6+ class IndicatorType(Enum): """Types of risk indicators.""" - UNKNOWN_DEVICE = 'unknown_device' - AUDIO_CAPABLE = 'audio_capable' - PERSISTENT = 'persistent' - MEETING_CORRELATED = 'meeting_correlated' - CROSS_PROTOCOL = 'cross_protocol' - HIDDEN_IDENTITY = 'hidden_identity' - ROGUE_AP = 'rogue_ap' - BURST_TRANSMISSION = 'burst_transmission' - STABLE_RSSI = 'stable_rssi' - HIGH_FREQ_ADVERTISING = 'high_freq_advertising' - MAC_ROTATION = 'mac_rotation' - NARROWBAND_SIGNAL = 'narrowband_signal' - ALWAYS_ON_CARRIER = 'always_on_carrier' + + UNKNOWN_DEVICE = "unknown_device" + AUDIO_CAPABLE = "audio_capable" + PERSISTENT = "persistent" + MEETING_CORRELATED = "meeting_correlated" + CROSS_PROTOCOL = "cross_protocol" + HIDDEN_IDENTITY = "hidden_identity" + ROGUE_AP = "rogue_ap" + BURST_TRANSMISSION = "burst_transmission" + STABLE_RSSI = "stable_rssi" + HIGH_FREQ_ADVERTISING = "high_freq_advertising" + MAC_ROTATION = "mac_rotation" + NARROWBAND_SIGNAL = "narrowband_signal" + ALWAYS_ON_CARRIER = "always_on_carrier" # Tracker-specific indicators - KNOWN_TRACKER = 'known_tracker' - AIRTAG_DETECTED = 'airtag_detected' - TILE_DETECTED = 'tile_detected' - SMARTTAG_DETECTED = 'smarttag_detected' - ESP32_DEVICE = 'esp32_device' - GENERIC_CHIPSET = 'generic_chipset' + KNOWN_TRACKER = "known_tracker" + AIRTAG_DETECTED = "airtag_detected" + TILE_DETECTED = "tile_detected" + SMARTTAG_DETECTED = "smarttag_detected" + ESP32_DEVICE = "esp32_device" + GENERIC_CHIPSET = "generic_chipset" # Scoring weights for each indicator @@ -78,25 +80,54 @@ INDICATOR_SCORES = { # Known tracker device signatures TRACKER_SIGNATURES = { # Apple AirTag - OUI prefixes - 'airtag_oui': ['4C:E6:76', '7C:04:D0', 'DC:A4:CA', 'F0:B3:EC'], + "airtag_oui": ["4C:E6:76", "7C:04:D0", "DC:A4:CA", "F0:B3:EC"], # Tile trackers - 'tile_oui': ['D0:03:DF', 'EC:2E:4E'], + "tile_oui": ["D0:03:DF", "EC:2E:4E"], # Samsung SmartTag - 'smarttag_oui': ['8C:71:F8', 'CC:2D:83', 'F0:5C:D5'], + "smarttag_oui": ["8C:71:F8", "CC:2D:83", "F0:5C:D5"], # ESP32/ESP8266 Espressif chipsets - 'espressif_oui': ['24:0A:C4', '24:6F:28', '24:62:AB', '30:AE:A4', - '3C:61:05', '3C:71:BF', '40:F5:20', '48:3F:DA', - '4C:11:AE', '54:43:B2', '58:BF:25', '5C:CF:7F', - '60:01:94', '68:C6:3A', '7C:9E:BD', '84:0D:8E', - '84:CC:A8', '84:F3:EB', '8C:AA:B5', '90:38:0C', - '94:B5:55', '98:CD:AC', 'A4:7B:9D', 'A4:CF:12', - 'AC:67:B2', 'B4:E6:2D', 'BC:DD:C2', 'C4:4F:33', - 'C8:2B:96', 'CC:50:E3', 'D8:A0:1D', 'DC:4F:22', - 'E0:98:06', 'E8:68:E7', 'EC:FA:BC', 'F4:CF:A2'], + "espressif_oui": [ + "24:0A:C4", + "24:6F:28", + "24:62:AB", + "30:AE:A4", + "3C:61:05", + "3C:71:BF", + "40:F5:20", + "48:3F:DA", + "4C:11:AE", + "54:43:B2", + "58:BF:25", + "5C:CF:7F", + "60:01:94", + "68:C6:3A", + "7C:9E:BD", + "84:0D:8E", + "84:CC:A8", + "84:F3:EB", + "8C:AA:B5", + "90:38:0C", + "94:B5:55", + "98:CD:AC", + "A4:7B:9D", + "A4:CF:12", + "AC:67:B2", + "B4:E6:2D", + "BC:DD:C2", + "C4:4F:33", + "C8:2B:96", + "CC:50:E3", + "D8:A0:1D", + "DC:4F:22", + "E0:98:06", + "E8:68:E7", + "EC:FA:BC", + "F4:CF:A2", + ], # Generic/suspicious chipset vendors (potential covert devices) - 'generic_chipset_oui': [ - '00:1A:7D', # cyber-blue(HK) - '00:25:00', # Apple (but generic BLE) + "generic_chipset_oui": [ + "00:1A:7D", # cyber-blue(HK) + "00:25:00", # Apple (but generic BLE) ], } @@ -104,6 +135,7 @@ TRACKER_SIGNATURES = { @dataclass class Indicator: """A single risk indicator.""" + type: IndicatorType description: str score: int @@ -114,19 +146,20 @@ class Indicator: @dataclass class DeviceProfile: """Complete profile for a detected device.""" + # Identity identifier: str # MAC, BSSID, or frequency - protocol: str # 'bluetooth', 'wifi', 'rf' + protocol: str # 'bluetooth', 'wifi', 'rf' - # Device info - name: str | None = None - manufacturer: str | None = None - device_type: str | None = None - tracker_type: str | None = None - tracker_name: str | None = None - tracker_confidence: str | None = None - tracker_confidence_score: float | None = None - tracker_evidence: list[str] = field(default_factory=list) + # Device info + name: str | None = None + manufacturer: str | None = None + device_type: str | None = None + tracker_type: str | None = None + tracker_name: str | None = None + tracker_confidence: str | None = None + tracker_confidence_score: float | None = None + tracker_evidence: list[str] = field(default_factory=list) # Bluetooth-specific services: list[str] = field(default_factory=list) @@ -161,7 +194,7 @@ class DeviceProfile: # Output confidence: float = 0.0 - recommended_action: str = 'monitor' + recommended_action: str = "monitor" known_device: bool = False known_device_name: str | None = None score_modifier: int = 0 @@ -186,16 +219,12 @@ class DeviceProfile: # Variance of ~0 = 1.0, variance of 100+ = ~0 return max(0, 1 - (variance / 100)) - def add_indicator(self, indicator_type: IndicatorType, description: str, - details: dict = None) -> None: + def add_indicator(self, indicator_type: IndicatorType, description: str, details: dict = None) -> None: """Add a risk indicator and update score.""" score = INDICATOR_SCORES.get(indicator_type, 1) - self.indicators.append(Indicator( - type=indicator_type, - description=description, - score=score, - details=details or {} - )) + self.indicators.append( + Indicator(type=indicator_type, description=description, score=score, details=details or {}) + ) self._recalculate_score() def _recalculate_score(self) -> None: @@ -204,13 +233,13 @@ class DeviceProfile: if self.total_score >= 6: self.risk_level = RiskLevel.HIGH_INTEREST - self.recommended_action = 'investigate' + self.recommended_action = "investigate" elif self.total_score >= 3: self.risk_level = RiskLevel.NEEDS_REVIEW - self.recommended_action = 'review' + self.recommended_action = "review" else: self.risk_level = RiskLevel.INFORMATIONAL - self.recommended_action = 'monitor' + self.recommended_action = "monitor" # Calculate confidence based on number and quality of indicators indicator_count = len(self.indicators) @@ -225,107 +254,107 @@ class DeviceProfile: if self.total_score >= 6: self.risk_level = RiskLevel.HIGH_INTEREST - self.recommended_action = 'investigate' + self.recommended_action = "investigate" elif self.total_score >= 3: self.risk_level = RiskLevel.NEEDS_REVIEW - self.recommended_action = 'review' + self.recommended_action = "review" else: self.risk_level = RiskLevel.INFORMATIONAL - self.recommended_action = 'monitor' + self.recommended_action = "monitor" indicator_count = len(self.indicators) self.confidence = min(1.0, (indicator_count * 0.15) + (self.total_score * 0.05)) - def to_dict(self) -> dict: - """Convert to dictionary for JSON serialization.""" - return { - 'identifier': self.identifier, - 'protocol': self.protocol, - 'name': self.name, - 'manufacturer': self.manufacturer, - 'device_type': self.device_type, - 'tracker_type': self.tracker_type, - 'tracker_name': self.tracker_name, - 'tracker_confidence': self.tracker_confidence, - 'tracker_confidence_score': self.tracker_confidence_score, - 'tracker_evidence': self.tracker_evidence, - 'ssid': self.ssid, - 'frequency': self.frequency, - 'first_seen': self.first_seen.isoformat() if self.first_seen else None, - 'last_seen': self.last_seen.isoformat() if self.last_seen else None, - 'detection_count': self.detection_count, - 'rssi_current': self.rssi_samples[-1][1] if self.rssi_samples else None, - 'rssi_stability': self.get_rssi_stability(), - 'indicators': [ + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + return { + "identifier": self.identifier, + "protocol": self.protocol, + "name": self.name, + "manufacturer": self.manufacturer, + "device_type": self.device_type, + "tracker_type": self.tracker_type, + "tracker_name": self.tracker_name, + "tracker_confidence": self.tracker_confidence, + "tracker_confidence_score": self.tracker_confidence_score, + "tracker_evidence": self.tracker_evidence, + "ssid": self.ssid, + "frequency": self.frequency, + "first_seen": self.first_seen.isoformat() if self.first_seen else None, + "last_seen": self.last_seen.isoformat() if self.last_seen else None, + "detection_count": self.detection_count, + "rssi_current": self.rssi_samples[-1][1] if self.rssi_samples else None, + "rssi_stability": self.get_rssi_stability(), + "indicators": [ { - 'type': i.type.value, - 'description': i.description, - 'score': i.score, + "type": i.type.value, + "description": i.description, + "score": i.score, } for i in self.indicators ], - 'total_score': self.total_score, - 'score_modifier': self.score_modifier, - 'risk_level': self.risk_level.value, - 'confidence': round(self.confidence, 2), - 'recommended_action': self.recommended_action, - 'correlated_devices': self.correlated_devices, - 'known_device': self.known_device, - 'known_device_name': self.known_device_name, + "total_score": self.total_score, + "score_modifier": self.score_modifier, + "risk_level": self.risk_level.value, + "confidence": round(self.confidence, 2), + "recommended_action": self.recommended_action, + "correlated_devices": self.correlated_devices, + "known_device": self.known_device, + "known_device_name": self.known_device_name, } # Known audio-capable BLE service UUIDs -AUDIO_SERVICE_UUIDS = [ - '0000110b-0000-1000-8000-00805f9b34fb', # A2DP Sink - '0000110a-0000-1000-8000-00805f9b34fb', # A2DP Source - '0000111e-0000-1000-8000-00805f9b34fb', # Handsfree - '0000111f-0000-1000-8000-00805f9b34fb', # Handsfree Audio Gateway - '00001108-0000-1000-8000-00805f9b34fb', # Headset - '00001203-0000-1000-8000-00805f9b34fb', # Generic Audio -] - -_BT_BASE_UUID_SUFFIX = '-0000-1000-8000-00805f9b34fb' - - -def _normalize_bt_uuid(value: str) -> str: - """Normalize BLE UUIDs to 16-bit where possible.""" - if not value: - return '' - uuid = str(value).lower().strip() - if uuid.startswith('0x'): - uuid = uuid[2:] - if uuid.endswith(_BT_BASE_UUID_SUFFIX) and len(uuid) >= 8: - return uuid[4:8] - if len(uuid) == 4: - return uuid - return uuid - - -AUDIO_SERVICE_UUIDS_16 = {_normalize_bt_uuid(u) for u in AUDIO_SERVICE_UUIDS} +AUDIO_SERVICE_UUIDS = [ + "0000110b-0000-1000-8000-00805f9b34fb", # A2DP Sink + "0000110a-0000-1000-8000-00805f9b34fb", # A2DP Source + "0000111e-0000-1000-8000-00805f9b34fb", # Handsfree + "0000111f-0000-1000-8000-00805f9b34fb", # Handsfree Audio Gateway + "00001108-0000-1000-8000-00805f9b34fb", # Headset + "00001203-0000-1000-8000-00805f9b34fb", # Generic Audio +] + +_BT_BASE_UUID_SUFFIX = "-0000-1000-8000-00805f9b34fb" + + +def _normalize_bt_uuid(value: str) -> str: + """Normalize BLE UUIDs to 16-bit where possible.""" + if not value: + return "" + uuid = str(value).lower().strip() + if uuid.startswith("0x"): + uuid = uuid[2:] + if uuid.endswith(_BT_BASE_UUID_SUFFIX) and len(uuid) >= 8: + return uuid[4:8] + if len(uuid) == 4: + return uuid + return uuid + + +AUDIO_SERVICE_UUIDS_16 = {_normalize_bt_uuid(u) for u in AUDIO_SERVICE_UUIDS} # Generic chipset vendors (often used in covert devices) GENERIC_CHIPSET_VENDORS = [ - 'espressif', - 'nordic', - 'texas instruments', - 'silicon labs', - 'realtek', - 'mediatek', - 'qualcomm', - 'broadcom', - 'cypress', - 'dialog', + "espressif", + "nordic", + "texas instruments", + "silicon labs", + "realtek", + "mediatek", + "qualcomm", + "broadcom", + "cypress", + "dialog", ] # Suspicious frequency ranges for RF SUSPICIOUS_RF_BANDS = [ - {'start': 136, 'end': 174, 'name': 'VHF', 'risk': 'high'}, - {'start': 400, 'end': 470, 'name': 'UHF', 'risk': 'high'}, - {'start': 315, 'end': 316, 'name': '315 MHz ISM', 'risk': 'medium'}, - {'start': 433, 'end': 435, 'name': '433 MHz ISM', 'risk': 'medium'}, - {'start': 868, 'end': 870, 'name': '868 MHz ISM', 'risk': 'medium'}, - {'start': 902, 'end': 928, 'name': '915 MHz ISM', 'risk': 'medium'}, + {"start": 136, "end": 174, "name": "VHF", "risk": "high"}, + {"start": 400, "end": 470, "name": "UHF", "risk": "high"}, + {"start": 315, "end": 316, "name": "315 MHz ISM", "risk": "medium"}, + {"start": 433, "end": 435, "name": "433 MHz ISM", "risk": "medium"}, + {"start": 868, "end": 870, "name": "868 MHz ISM", "risk": "medium"}, + {"start": 902, "end": 928, "name": "915 MHz ISM", "risk": "medium"}, ] @@ -379,7 +408,7 @@ class CorrelationEngine: if identifier: candidates.append(str(identifier)) - if protocol == 'rf': + if protocol == "rf": try: freq_val = float(identifier) candidates.append(f"{freq_val:.3f}") @@ -405,8 +434,8 @@ class CorrelationEngine: known = self._lookup_known_device(identifier, protocol) if known: profile.known_device = True - profile.known_device_name = known.get('name') if isinstance(known, dict) else None - modifier = known.get('score_modifier', 0) if isinstance(known, dict) else 0 + profile.known_device_name = known.get("name") if isinstance(known, dict) else None + modifier = known.get("score_modifier", 0) if isinstance(known, dict) else 0 else: profile.known_device = False profile.known_device_name = None @@ -419,9 +448,7 @@ class CorrelationEngine: key = f"{protocol}:{identifier}" if key not in self.device_profiles: self.device_profiles[key] = DeviceProfile( - identifier=identifier, - protocol=protocol, - first_seen=datetime.now() + identifier=identifier, protocol=protocol, first_seen=datetime.now() ) profile = self.device_profiles[key] profile.last_seen = datetime.now() @@ -438,33 +465,33 @@ class CorrelationEngine: Returns: DeviceProfile with risk assessment """ - mac = device.get('mac', device.get('address', '')).upper() - profile = self.get_or_create_profile(mac, 'bluetooth') + mac = device.get("mac", device.get("address", "")).upper() + profile = self.get_or_create_profile(mac, "bluetooth") # Update profile data - profile.name = device.get('name') or profile.name - profile.manufacturer = device.get('manufacturer') or profile.manufacturer - profile.device_type = device.get('type') or profile.device_type - services = device.get('services') - if not services: - services = device.get('service_uuids') - profile.services = services or profile.services - profile.company_id = device.get('company_id') or profile.company_id - profile.advertising_interval = device.get('advertising_interval') or profile.advertising_interval - tracker_data = device.get('tracker') or {} - if tracker_data: - profile.tracker_type = tracker_data.get('type') or profile.tracker_type - profile.tracker_name = tracker_data.get('name') or profile.tracker_name - profile.tracker_confidence = tracker_data.get('confidence') or profile.tracker_confidence - profile.tracker_confidence_score = tracker_data.get('confidence_score') or profile.tracker_confidence_score - evidence = tracker_data.get('evidence') - if isinstance(evidence, list): - profile.tracker_evidence = evidence - elif evidence: - profile.tracker_evidence = [str(evidence)] + profile.name = device.get("name") or profile.name + profile.manufacturer = device.get("manufacturer") or profile.manufacturer + profile.device_type = device.get("type") or profile.device_type + services = device.get("services") + if not services: + services = device.get("service_uuids") + profile.services = services or profile.services + profile.company_id = device.get("company_id") or profile.company_id + profile.advertising_interval = device.get("advertising_interval") or profile.advertising_interval + tracker_data = device.get("tracker") or {} + if tracker_data: + profile.tracker_type = tracker_data.get("type") or profile.tracker_type + profile.tracker_name = tracker_data.get("name") or profile.tracker_name + profile.tracker_confidence = tracker_data.get("confidence") or profile.tracker_confidence + profile.tracker_confidence_score = tracker_data.get("confidence_score") or profile.tracker_confidence_score + evidence = tracker_data.get("evidence") + if isinstance(evidence, list): + profile.tracker_evidence = evidence + elif evidence: + profile.tracker_evidence = [str(evidence)] # Add RSSI sample - rssi = device.get('rssi', device.get('signal')) + rssi = device.get("rssi", device.get("signal")) if rssi: with contextlib.suppress(ValueError, TypeError): profile.add_rssi_sample(int(rssi)) @@ -472,79 +499,78 @@ class CorrelationEngine: # Clear previous indicators for fresh analysis profile.indicators = [] - # === Detection Logic === - - # 1. Unknown manufacturer or generic chipset - if not profile.manufacturer and mac and not device.get('is_randomized_mac'): - try: - first_octet = int(mac.split(':')[0], 16) - except (ValueError, IndexError): - first_octet = None - if first_octet is None or not (first_octet & 0x02): - try: - from data.oui import get_manufacturer - vendor = get_manufacturer(mac) - if vendor and vendor != 'Unknown': - profile.manufacturer = vendor - except Exception: - pass - if not profile.manufacturer: - profile.add_indicator( - IndicatorType.UNKNOWN_DEVICE, - 'Unknown manufacturer', - {'manufacturer': None} - ) + # === Detection Logic === + + # 1. Unknown manufacturer or generic chipset + if not profile.manufacturer and mac and not device.get("is_randomized_mac"): + try: + first_octet = int(mac.split(":")[0], 16) + except (ValueError, IndexError): + first_octet = None + if first_octet is None or not (first_octet & 0x02): + try: + from data.oui import get_manufacturer + + vendor = get_manufacturer(mac) + if vendor and vendor != "Unknown": + profile.manufacturer = vendor + except Exception: + pass + if not profile.manufacturer: + profile.add_indicator(IndicatorType.UNKNOWN_DEVICE, "Unknown manufacturer", {"manufacturer": None}) elif any(v in profile.manufacturer.lower() for v in GENERIC_CHIPSET_VENDORS): profile.add_indicator( IndicatorType.UNKNOWN_DEVICE, - f'Generic chipset vendor: {profile.manufacturer}', - {'manufacturer': profile.manufacturer} + f"Generic chipset vendor: {profile.manufacturer}", + {"manufacturer": profile.manufacturer}, ) # 2. No human-readable name - if not profile.name or profile.name in ['Unknown', '', 'N/A']: - profile.add_indicator( - IndicatorType.HIDDEN_IDENTITY, - 'No device name advertised', - {'name': profile.name} - ) + if not profile.name or profile.name in ["Unknown", "", "N/A"]: + profile.add_indicator(IndicatorType.HIDDEN_IDENTITY, "No device name advertised", {"name": profile.name}) - # 3. Audio-capable services - if profile.services: - normalized_services = {_normalize_bt_uuid(s) for s in profile.services if s} - audio_services = [s for s in normalized_services if s in AUDIO_SERVICE_UUIDS_16] - if audio_services: - profile.add_indicator( - IndicatorType.AUDIO_CAPABLE, - 'Audio-capable BLE services detected', - {'services': audio_services} - ) + # 3. Audio-capable services + if profile.services: + normalized_services = {_normalize_bt_uuid(s) for s in profile.services if s} + audio_services = [s for s in normalized_services if s in AUDIO_SERVICE_UUIDS_16] + if audio_services: + profile.add_indicator( + IndicatorType.AUDIO_CAPABLE, "Audio-capable BLE services detected", {"services": audio_services} + ) # Check name for audio keywords if profile.name: - audio_keywords = ['headphone', 'headset', 'earphone', 'speaker', - 'mic', 'audio', 'airpod', 'buds', 'jabra', 'bose'] + audio_keywords = [ + "headphone", + "headset", + "earphone", + "speaker", + "mic", + "audio", + "airpod", + "buds", + "jabra", + "bose", + ] if any(k in profile.name.lower() for k in audio_keywords): profile.add_indicator( - IndicatorType.AUDIO_CAPABLE, - f'Audio device name: {profile.name}', - {'name': profile.name} + IndicatorType.AUDIO_CAPABLE, f"Audio device name: {profile.name}", {"name": profile.name} ) # 4. High-frequency advertising (< 100ms interval is suspicious) if profile.advertising_interval and profile.advertising_interval < 100: profile.add_indicator( IndicatorType.HIGH_FREQ_ADVERTISING, - f'High advertising frequency: {profile.advertising_interval}ms', - {'interval': profile.advertising_interval} + f"High advertising frequency: {profile.advertising_interval}ms", + {"interval": profile.advertising_interval}, ) # 5. Persistent presence if profile.detection_count >= 3: profile.add_indicator( IndicatorType.PERSISTENT, - f'Persistent device ({profile.detection_count} detections)', - {'count': profile.detection_count} + f"Persistent device ({profile.detection_count} detections)", + {"count": profile.detection_count}, ) # 6. Stable RSSI (suggests fixed placement) @@ -552,240 +578,222 @@ class CorrelationEngine: if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5: profile.add_indicator( IndicatorType.STABLE_RSSI, - f'Stable signal strength (stability: {rssi_stability:.0%})', - {'stability': rssi_stability} + f"Stable signal strength (stability: {rssi_stability:.0%})", + {"stability": rssi_stability}, ) # 7. Meeting correlation if self.is_during_meeting(): profile.add_indicator( - IndicatorType.MEETING_CORRELATED, - 'Detected during sensitive period', - {'during_meeting': True} + IndicatorType.MEETING_CORRELATED, "Detected during sensitive period", {"during_meeting": True} ) # 8. MAC rotation pattern (random MAC prefix) - if mac and mac[1] in ['2', '6', 'A', 'E', 'a', 'e']: - profile.add_indicator( - IndicatorType.MAC_ROTATION, - 'Random/rotating MAC address detected', - {'mac': mac} - ) + if mac and mac[1] in ["2", "6", "A", "E", "a", "e"]: + profile.add_indicator(IndicatorType.MAC_ROTATION, "Random/rotating MAC address detected", {"mac": mac}) - # 9. Known tracker detection (AirTag, Tile, SmartTag, ESP32) - mac_prefix = mac[:8] if len(mac) >= 8 else '' - tracker_detected = False - tracker_data = device.get('tracker') or {} - - if tracker_data.get('is_tracker'): - tracker_detected = True - tracker_label = tracker_data.get('name') or tracker_data.get('type') - if tracker_label: - label_lower = str(tracker_label).lower() - if 'airtag' in label_lower or 'find my' in label_lower: - profile.add_indicator( - IndicatorType.AIRTAG_DETECTED, - f'Tracker detected: {tracker_label}', - {'mac': mac, 'tracker_type': tracker_label} - ) - profile.device_type = 'AirTag' - elif 'tile' in label_lower: - profile.add_indicator( - IndicatorType.TILE_DETECTED, - f'Tracker detected: {tracker_label}', - {'mac': mac, 'tracker_type': tracker_label} - ) - profile.device_type = 'Tile Tracker' - elif 'smarttag' in label_lower or 'samsung' in label_lower: - profile.add_indicator( - IndicatorType.SMARTTAG_DETECTED, - f'Tracker detected: {tracker_label}', - {'mac': mac, 'tracker_type': tracker_label} - ) - profile.device_type = 'Samsung SmartTag' - else: - profile.device_type = tracker_label - elif not profile.device_type: - profile.device_type = 'Tracker' - - # Check for tracker flags from BLE scanner (manufacturer ID detection) - if device.get('is_airtag'): - profile.add_indicator( - IndicatorType.AIRTAG_DETECTED, - 'Apple AirTag detected via manufacturer data', - {'mac': mac, 'tracker_type': 'AirTag'} + # 9. Known tracker detection (AirTag, Tile, SmartTag, ESP32) + mac_prefix = mac[:8] if len(mac) >= 8 else "" + tracker_detected = False + tracker_data = device.get("tracker") or {} + + if tracker_data.get("is_tracker"): + tracker_detected = True + tracker_label = tracker_data.get("name") or tracker_data.get("type") + if tracker_label: + label_lower = str(tracker_label).lower() + if "airtag" in label_lower or "find my" in label_lower: + profile.add_indicator( + IndicatorType.AIRTAG_DETECTED, + f"Tracker detected: {tracker_label}", + {"mac": mac, "tracker_type": tracker_label}, + ) + profile.device_type = "AirTag" + elif "tile" in label_lower: + profile.add_indicator( + IndicatorType.TILE_DETECTED, + f"Tracker detected: {tracker_label}", + {"mac": mac, "tracker_type": tracker_label}, + ) + profile.device_type = "Tile Tracker" + elif "smarttag" in label_lower or "samsung" in label_lower: + profile.add_indicator( + IndicatorType.SMARTTAG_DETECTED, + f"Tracker detected: {tracker_label}", + {"mac": mac, "tracker_type": tracker_label}, + ) + profile.device_type = "Samsung SmartTag" + else: + profile.device_type = tracker_label + elif not profile.device_type: + profile.device_type = "Tracker" + + # Check for tracker flags from BLE scanner (manufacturer ID detection) + if device.get("is_airtag"): + profile.add_indicator( + IndicatorType.AIRTAG_DETECTED, + "Apple AirTag detected via manufacturer data", + {"mac": mac, "tracker_type": "AirTag"}, ) - profile.device_type = device.get('tracker_type', 'AirTag') + profile.device_type = device.get("tracker_type", "AirTag") tracker_detected = True - if device.get('is_tile'): + if device.get("is_tile"): profile.add_indicator( IndicatorType.TILE_DETECTED, - 'Tile tracker detected via manufacturer data', - {'mac': mac, 'tracker_type': 'Tile'} + "Tile tracker detected via manufacturer data", + {"mac": mac, "tracker_type": "Tile"}, ) - profile.device_type = 'Tile Tracker' + profile.device_type = "Tile Tracker" tracker_detected = True - if device.get('is_smarttag'): + if device.get("is_smarttag"): profile.add_indicator( IndicatorType.SMARTTAG_DETECTED, - 'Samsung SmartTag detected via manufacturer data', - {'mac': mac, 'tracker_type': 'SmartTag'} + "Samsung SmartTag detected via manufacturer data", + {"mac": mac, "tracker_type": "SmartTag"}, ) - profile.device_type = 'Samsung SmartTag' + profile.device_type = "Samsung SmartTag" tracker_detected = True - if device.get('is_espressif'): + if device.get("is_espressif"): profile.add_indicator( IndicatorType.ESP32_DEVICE, - 'ESP32/ESP8266 detected via Espressif manufacturer ID', - {'mac': mac, 'chipset': 'Espressif'} + "ESP32/ESP8266 detected via Espressif manufacturer ID", + {"mac": mac, "chipset": "Espressif"}, ) - profile.manufacturer = 'Espressif' - profile.device_type = device.get('tracker_type', 'ESP32/ESP8266') + profile.manufacturer = "Espressif" + profile.device_type = device.get("tracker_type", "ESP32/ESP8266") tracker_detected = True # Check manufacturer_id directly - mfg_id = device.get('manufacturer_id') + mfg_id = device.get("manufacturer_id") if mfg_id: - if mfg_id == 0x004C and not device.get('is_airtag'): + if mfg_id == 0x004C and not device.get("is_airtag"): # Apple device - could be AirTag - profile.manufacturer = 'Apple' - elif mfg_id == 0x02E5 and not device.get('is_espressif'): + profile.manufacturer = "Apple" + elif mfg_id == 0x02E5 and not device.get("is_espressif"): # Espressif device profile.add_indicator( IndicatorType.ESP32_DEVICE, - 'ESP32/ESP8266 detected via manufacturer ID', - {'mac': mac, 'manufacturer_id': mfg_id} + "ESP32/ESP8266 detected via manufacturer ID", + {"mac": mac, "manufacturer_id": mfg_id}, ) - profile.manufacturer = 'Espressif' + profile.manufacturer = "Espressif" tracker_detected = True # Fallback: Check for Apple AirTag by OUI - if not tracker_detected and mac_prefix in TRACKER_SIGNATURES.get('airtag_oui', []): + if not tracker_detected and mac_prefix in TRACKER_SIGNATURES.get("airtag_oui", []): profile.add_indicator( IndicatorType.AIRTAG_DETECTED, - 'Apple AirTag detected - potential tracking device', - {'mac': mac, 'tracker_type': 'AirTag'} + "Apple AirTag detected - potential tracking device", + {"mac": mac, "tracker_type": "AirTag"}, ) - profile.device_type = 'AirTag' + profile.device_type = "AirTag" tracker_detected = True # Check for Tile tracker - if mac_prefix in TRACKER_SIGNATURES.get('tile_oui', []): + if mac_prefix in TRACKER_SIGNATURES.get("tile_oui", []): profile.add_indicator( - IndicatorType.TILE_DETECTED, - 'Tile tracker detected', - {'mac': mac, 'tracker_type': 'Tile'} + IndicatorType.TILE_DETECTED, "Tile tracker detected", {"mac": mac, "tracker_type": "Tile"} ) - profile.device_type = 'Tile Tracker' + profile.device_type = "Tile Tracker" tracker_detected = True # Check for Samsung SmartTag - if mac_prefix in TRACKER_SIGNATURES.get('smarttag_oui', []): + if mac_prefix in TRACKER_SIGNATURES.get("smarttag_oui", []): profile.add_indicator( - IndicatorType.SMARTTAG_DETECTED, - 'Samsung SmartTag detected', - {'mac': mac, 'tracker_type': 'SmartTag'} + IndicatorType.SMARTTAG_DETECTED, "Samsung SmartTag detected", {"mac": mac, "tracker_type": "SmartTag"} ) - profile.device_type = 'Samsung SmartTag' + profile.device_type = "Samsung SmartTag" tracker_detected = True # Check for ESP32/ESP8266 devices - if mac_prefix in TRACKER_SIGNATURES.get('espressif_oui', []): + if mac_prefix in TRACKER_SIGNATURES.get("espressif_oui", []): profile.add_indicator( IndicatorType.ESP32_DEVICE, - 'ESP32/ESP8266 device detected - programmable hardware', - {'mac': mac, 'chipset': 'Espressif'} + "ESP32/ESP8266 device detected - programmable hardware", + {"mac": mac, "chipset": "Espressif"}, ) - profile.manufacturer = 'Espressif' + profile.manufacturer = "Espressif" tracker_detected = True # Check for generic/suspicious chipsets - if mac_prefix in TRACKER_SIGNATURES.get('generic_chipset_oui', []): + if mac_prefix in TRACKER_SIGNATURES.get("generic_chipset_oui", []): profile.add_indicator( - IndicatorType.GENERIC_CHIPSET, - 'Generic chipset vendor - often used in covert devices', - {'mac': mac} + IndicatorType.GENERIC_CHIPSET, "Generic chipset vendor - often used in covert devices", {"mac": mac} ) tracker_detected = True # If any tracker detected, add general tracker indicator if tracker_detected: - profile.add_indicator( - IndicatorType.KNOWN_TRACKER, - 'Known tracking device signature detected', - {'mac': mac} - ) + profile.add_indicator(IndicatorType.KNOWN_TRACKER, "Known tracking device signature detected", {"mac": mac}) # Also check name for tracker keywords if profile.name: name_lower = profile.name.lower() - if 'airtag' in name_lower or 'findmy' in name_lower: + if "airtag" in name_lower or "findmy" in name_lower: profile.add_indicator( - IndicatorType.AIRTAG_DETECTED, - f'AirTag identified by name: {profile.name}', - {'name': profile.name} + IndicatorType.AIRTAG_DETECTED, f"AirTag identified by name: {profile.name}", {"name": profile.name} ) - profile.device_type = 'AirTag' - elif 'tile' in name_lower: + profile.device_type = "AirTag" + elif "tile" in name_lower: profile.add_indicator( IndicatorType.TILE_DETECTED, - f'Tile tracker identified by name: {profile.name}', - {'name': profile.name} + f"Tile tracker identified by name: {profile.name}", + {"name": profile.name}, ) - profile.device_type = 'Tile Tracker' - elif 'smarttag' in name_lower: + profile.device_type = "Tile Tracker" + elif "smarttag" in name_lower: profile.add_indicator( IndicatorType.SMARTTAG_DETECTED, - f'SmartTag identified by name: {profile.name}', - {'name': profile.name} + f"SmartTag identified by name: {profile.name}", + {"name": profile.name}, ) - profile.device_type = 'Samsung SmartTag' + profile.device_type = "Samsung SmartTag" - self._apply_known_device_modifier(profile, mac, 'bluetooth') + self._apply_known_device_modifier(profile, mac, "bluetooth") return profile - def analyze_wifi_device(self, device: dict) -> DeviceProfile: - """ - Analyze a Wi-Fi device/AP for suspicious indicators. + def analyze_wifi_device(self, device: dict) -> DeviceProfile: + """ + Analyze a Wi-Fi device/AP for suspicious indicators. Args: device: Dict with bssid, ssid, channel, rssi, encryption, etc. - Returns: - DeviceProfile with risk assessment - """ - bssid = device.get('bssid', device.get('mac', '')).upper() - profile = self.get_or_create_profile(bssid, 'wifi') - is_client = bool(device.get('is_client') or device.get('role') == 'client') - - # Update profile data - ssid = device.get('ssid', device.get('essid', '')) - if is_client: - profile.name = device.get('name') or device.get('vendor') or profile.name or f'Client ({bssid[-8:]})' - profile.device_type = 'client' - profile.ssid = profile.ssid # Clients are not SSIDs - profile.channel = device.get('channel') or profile.channel - profile.encryption = profile.encryption - profile.beacon_interval = profile.beacon_interval - profile.is_hidden = False - else: - profile.ssid = ssid if ssid else profile.ssid - profile.name = ssid or f'Hidden Network ({bssid[-8:]})' - profile.channel = device.get('channel') or profile.channel - profile.encryption = device.get('encryption', device.get('privacy')) or profile.encryption - profile.beacon_interval = device.get('beacon_interval') or profile.beacon_interval - profile.is_hidden = not ssid or ssid in ['', 'Hidden', '[Hidden]'] - - # Extract manufacturer from OUI - if bssid and len(bssid) >= 8: - profile.manufacturer = device.get('vendor') or profile.manufacturer + Returns: + DeviceProfile with risk assessment + """ + bssid = device.get("bssid", device.get("mac", "")).upper() + profile = self.get_or_create_profile(bssid, "wifi") + is_client = bool(device.get("is_client") or device.get("role") == "client") + + # Update profile data + ssid = device.get("ssid", device.get("essid", "")) + if is_client: + profile.name = device.get("name") or device.get("vendor") or profile.name or f"Client ({bssid[-8:]})" + profile.device_type = "client" + profile.ssid = profile.ssid # Clients are not SSIDs + profile.channel = device.get("channel") or profile.channel + profile.encryption = profile.encryption + profile.beacon_interval = profile.beacon_interval + profile.is_hidden = False + else: + profile.ssid = ssid if ssid else profile.ssid + profile.name = ssid or f"Hidden Network ({bssid[-8:]})" + profile.channel = device.get("channel") or profile.channel + profile.encryption = device.get("encryption", device.get("privacy")) or profile.encryption + profile.beacon_interval = device.get("beacon_interval") or profile.beacon_interval + profile.is_hidden = not ssid or ssid in ["", "Hidden", "[Hidden]"] + + # Extract manufacturer from OUI + if bssid and len(bssid) >= 8: + profile.manufacturer = device.get("vendor") or profile.manufacturer # Add RSSI sample - rssi = device.get('rssi', device.get('power', device.get('signal'))) + rssi = device.get("rssi", device.get("power", device.get("signal"))) if rssi: with contextlib.suppress(ValueError, TypeError): profile.add_rssi_sample(int(rssi)) @@ -793,120 +801,112 @@ class CorrelationEngine: # Clear previous indicators profile.indicators = [] - # === Detection Logic === - if is_client: - if not profile.manufacturer: - profile.add_indicator( - IndicatorType.UNKNOWN_DEVICE, - 'Unknown client manufacturer', - {'mac': bssid} - ) - - if profile.detection_count >= 3: - profile.add_indicator( - IndicatorType.PERSISTENT, - f'Persistent client ({profile.detection_count} detections)', - {'count': profile.detection_count} - ) - - rssi_stability = profile.get_rssi_stability() - if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5: - profile.add_indicator( - IndicatorType.STABLE_RSSI, - f'Stable client signal (stability: {rssi_stability:.0%})', - {'stability': rssi_stability} - ) - - if self.is_during_meeting(): - profile.add_indicator( - IndicatorType.MEETING_CORRELATED, - 'Detected during sensitive period', - {'during_meeting': True} - ) - - try: - first_octet = int(bssid.split(':')[0], 16) - if first_octet & 0x02: - profile.add_indicator( - IndicatorType.MAC_ROTATION, - 'Random/locally administered MAC detected', - {'mac': bssid} - ) - except (ValueError, IndexError): - pass - else: - # 1. Hidden or unnamed SSID - if profile.is_hidden: - profile.add_indicator( - IndicatorType.HIDDEN_IDENTITY, - 'Hidden or empty SSID', - {'ssid': ssid} - ) - - # 2. BSSID not in authorized list (would need baseline) - # For now, mark as unknown if no manufacturer - if not profile.manufacturer: - profile.add_indicator( - IndicatorType.UNKNOWN_DEVICE, - 'Unknown AP manufacturer', - {'bssid': bssid} - ) - - # 3. Consumer device OUI in restricted environment - consumer_ouis = ['tp-link', 'netgear', 'd-link', 'linksys', 'asus'] - if profile.manufacturer and any(c in profile.manufacturer.lower() for c in consumer_ouis): - profile.add_indicator( - IndicatorType.ROGUE_AP, - f'Consumer-grade AP detected: {profile.manufacturer}', - {'manufacturer': profile.manufacturer} - ) - - # 4. Camera device patterns - camera_keywords = ['cam', 'camera', 'ipcam', 'dvr', 'nvr', 'wyze', - 'ring', 'arlo', 'nest', 'blink', 'eufy', 'yi'] - if ssid and any(k in ssid.lower() for k in camera_keywords): - profile.add_indicator( - IndicatorType.AUDIO_CAPABLE, # Cameras often have mics - f'Potential camera device: {ssid}', - {'ssid': ssid} - ) - - # 5. Persistent presence - if profile.detection_count >= 3: - profile.add_indicator( - IndicatorType.PERSISTENT, - f'Persistent AP ({profile.detection_count} detections)', - {'count': profile.detection_count} - ) - - # 6. Stable RSSI (fixed placement) - rssi_stability = profile.get_rssi_stability() - if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5: - profile.add_indicator( - IndicatorType.STABLE_RSSI, - f'Stable signal (stability: {rssi_stability:.0%})', - {'stability': rssi_stability} - ) - - # 7. Meeting correlation - if self.is_during_meeting(): - profile.add_indicator( - IndicatorType.MEETING_CORRELATED, - 'Detected during sensitive period', - {'during_meeting': True} - ) - - # 8. Strong hidden AP (very suspicious) - if profile.is_hidden and profile.rssi_samples: - latest_rssi = profile.rssi_samples[-1][1] - if latest_rssi > -50: - profile.add_indicator( - IndicatorType.ROGUE_AP, - f'Strong hidden AP (RSSI: {latest_rssi} dBm)', - {'rssi': latest_rssi} - ) + # === Detection Logic === + if is_client: + if not profile.manufacturer: + profile.add_indicator(IndicatorType.UNKNOWN_DEVICE, "Unknown client manufacturer", {"mac": bssid}) - self._apply_known_device_modifier(profile, bssid, 'wifi') + if profile.detection_count >= 3: + profile.add_indicator( + IndicatorType.PERSISTENT, + f"Persistent client ({profile.detection_count} detections)", + {"count": profile.detection_count}, + ) + + rssi_stability = profile.get_rssi_stability() + if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5: + profile.add_indicator( + IndicatorType.STABLE_RSSI, + f"Stable client signal (stability: {rssi_stability:.0%})", + {"stability": rssi_stability}, + ) + + if self.is_during_meeting(): + profile.add_indicator( + IndicatorType.MEETING_CORRELATED, "Detected during sensitive period", {"during_meeting": True} + ) + + try: + first_octet = int(bssid.split(":")[0], 16) + if first_octet & 0x02: + profile.add_indicator( + IndicatorType.MAC_ROTATION, "Random/locally administered MAC detected", {"mac": bssid} + ) + except (ValueError, IndexError): + pass + else: + # 1. Hidden or unnamed SSID + if profile.is_hidden: + profile.add_indicator(IndicatorType.HIDDEN_IDENTITY, "Hidden or empty SSID", {"ssid": ssid}) + + # 2. BSSID not in authorized list (would need baseline) + # For now, mark as unknown if no manufacturer + if not profile.manufacturer: + profile.add_indicator(IndicatorType.UNKNOWN_DEVICE, "Unknown AP manufacturer", {"bssid": bssid}) + + # 3. Consumer device OUI in restricted environment + consumer_ouis = ["tp-link", "netgear", "d-link", "linksys", "asus"] + if profile.manufacturer and any(c in profile.manufacturer.lower() for c in consumer_ouis): + profile.add_indicator( + IndicatorType.ROGUE_AP, + f"Consumer-grade AP detected: {profile.manufacturer}", + {"manufacturer": profile.manufacturer}, + ) + + # 4. Camera device patterns + camera_keywords = [ + "cam", + "camera", + "ipcam", + "dvr", + "nvr", + "wyze", + "ring", + "arlo", + "nest", + "blink", + "eufy", + "yi", + ] + if ssid and any(k in ssid.lower() for k in camera_keywords): + profile.add_indicator( + IndicatorType.AUDIO_CAPABLE, # Cameras often have mics + f"Potential camera device: {ssid}", + {"ssid": ssid}, + ) + + # 5. Persistent presence + if profile.detection_count >= 3: + profile.add_indicator( + IndicatorType.PERSISTENT, + f"Persistent AP ({profile.detection_count} detections)", + {"count": profile.detection_count}, + ) + + # 6. Stable RSSI (fixed placement) + rssi_stability = profile.get_rssi_stability() + if rssi_stability > 0.7 and len(profile.rssi_samples) >= 5: + profile.add_indicator( + IndicatorType.STABLE_RSSI, + f"Stable signal (stability: {rssi_stability:.0%})", + {"stability": rssi_stability}, + ) + + # 7. Meeting correlation + if self.is_during_meeting(): + profile.add_indicator( + IndicatorType.MEETING_CORRELATED, "Detected during sensitive period", {"during_meeting": True} + ) + + # 8. Strong hidden AP (very suspicious) + if profile.is_hidden and profile.rssi_samples: + latest_rssi = profile.rssi_samples[-1][1] + if latest_rssi > -50: + profile.add_indicator( + IndicatorType.ROGUE_AP, f"Strong hidden AP (RSSI: {latest_rssi} dBm)", {"rssi": latest_rssi} + ) + + self._apply_known_device_modifier(profile, bssid, "wifi") return profile @@ -920,18 +920,18 @@ class CorrelationEngine: Returns: DeviceProfile with risk assessment """ - frequency = signal.get('frequency', 0) + frequency = signal.get("frequency", 0) freq_key = f"{frequency:.3f}" - profile = self.get_or_create_profile(freq_key, 'rf') + profile = self.get_or_create_profile(freq_key, "rf") # Update profile data profile.frequency = frequency - profile.name = f'{frequency:.3f} MHz' - profile.bandwidth = signal.get('bandwidth') or profile.bandwidth - profile.modulation = signal.get('modulation') or profile.modulation + profile.name = f"{frequency:.3f} MHz" + profile.bandwidth = signal.get("bandwidth") or profile.bandwidth + profile.modulation = signal.get("modulation") or profile.modulation # Add power sample - power = signal.get('power', signal.get('level')) + power = signal.get("power", signal.get("level")) if power: with contextlib.suppress(ValueError, TypeError): profile.add_rssi_sample(int(float(power))) @@ -944,38 +944,38 @@ class CorrelationEngine: # 1. Determine frequency band risk band_info = None for band in SUSPICIOUS_RF_BANDS: - if band['start'] <= frequency <= band['end']: + if band["start"] <= frequency <= band["end"]: band_info = band break if band_info: - if band_info['risk'] == 'high': + if band_info["risk"] == "high": profile.add_indicator( IndicatorType.NARROWBAND_SIGNAL, f"Signal in high-risk band: {band_info['name']}", - {'band': band_info['name'], 'frequency': frequency} + {"band": band_info["name"], "frequency": frequency}, ) else: profile.add_indicator( IndicatorType.UNKNOWN_DEVICE, f"Signal in ISM band: {band_info['name']}", - {'band': band_info['name'], 'frequency': frequency} + {"band": band_info["name"], "frequency": frequency}, ) # 2. Narrowband FM/AM (potential bug) - if profile.modulation and profile.modulation.lower() in ['fm', 'nfm', 'am']: + if profile.modulation and profile.modulation.lower() in ["fm", "nfm", "am"]: profile.add_indicator( IndicatorType.NARROWBAND_SIGNAL, - f'Narrowband {profile.modulation.upper()} signal', - {'modulation': profile.modulation} + f"Narrowband {profile.modulation.upper()} signal", + {"modulation": profile.modulation}, ) # 3. Persistent/always-on carrier if profile.detection_count >= 2: profile.add_indicator( IndicatorType.ALWAYS_ON_CARRIER, - f'Persistent carrier ({profile.detection_count} detections)', - {'count': profile.detection_count} + f"Persistent carrier ({profile.detection_count} detections)", + {"count": profile.detection_count}, ) # 4. Strong signal (close proximity) @@ -984,19 +984,17 @@ class CorrelationEngine: if latest_power > -40: profile.add_indicator( IndicatorType.STABLE_RSSI, - f'Strong signal suggesting close proximity ({latest_power} dBm)', - {'power': latest_power} + f"Strong signal suggesting close proximity ({latest_power} dBm)", + {"power": latest_power}, ) # 5. Meeting correlation if self.is_during_meeting(): profile.add_indicator( - IndicatorType.MEETING_CORRELATED, - 'Signal detected during sensitive period', - {'during_meeting': True} + IndicatorType.MEETING_CORRELATED, "Signal detected during sensitive period", {"during_meeting": True} ) - self._apply_known_device_modifier(profile, freq_key, 'rf') + self._apply_known_device_modifier(profile, freq_key, "rf") return profile @@ -1013,76 +1011,81 @@ class CorrelationEngine: now = datetime.now() # Get recent devices by protocol - bt_devices = [p for p in self.device_profiles.values() - if p.protocol == 'bluetooth' and - p.last_seen and (now - p.last_seen) < self.correlation_window] - wifi_devices = [p for p in self.device_profiles.values() - if p.protocol == 'wifi' and - p.last_seen and (now - p.last_seen) < self.correlation_window] - rf_signals = [p for p in self.device_profiles.values() - if p.protocol == 'rf' and - p.last_seen and (now - p.last_seen) < self.correlation_window] + bt_devices = [ + p + for p in self.device_profiles.values() + if p.protocol == "bluetooth" and p.last_seen and (now - p.last_seen) < self.correlation_window + ] + wifi_devices = [ + p + for p in self.device_profiles.values() + if p.protocol == "wifi" and p.last_seen and (now - p.last_seen) < self.correlation_window + ] + rf_signals = [ + p + for p in self.device_profiles.values() + if p.protocol == "rf" and p.last_seen and (now - p.last_seen) < self.correlation_window + ] # Correlation 1: BLE audio device + RF narrowband signal - audio_bt = [p for p in bt_devices - if any(i.type == IndicatorType.AUDIO_CAPABLE for i in p.indicators)] - narrowband_rf = [p for p in rf_signals - if any(i.type == IndicatorType.NARROWBAND_SIGNAL for i in p.indicators)] + audio_bt = [p for p in bt_devices if any(i.type == IndicatorType.AUDIO_CAPABLE for i in p.indicators)] + narrowband_rf = [p for p in rf_signals if any(i.type == IndicatorType.NARROWBAND_SIGNAL for i in p.indicators)] for bt in audio_bt: for rf in narrowband_rf: correlation = { - 'type': 'bt_audio_rf_narrowband', - 'description': 'Audio-capable BLE device detected alongside narrowband RF signal', - 'devices': [bt.identifier, rf.identifier], - 'protocols': ['bluetooth', 'rf'], - 'score_boost': 3, - 'significance': 'high', + "type": "bt_audio_rf_narrowband", + "description": "Audio-capable BLE device detected alongside narrowband RF signal", + "devices": [bt.identifier, rf.identifier], + "protocols": ["bluetooth", "rf"], + "score_boost": 3, + "significance": "high", } correlations.append(correlation) # Add cross-protocol indicator to both bt.add_indicator( IndicatorType.CROSS_PROTOCOL, - f'Correlated with RF signal at {rf.frequency:.3f} MHz', - {'correlated_device': rf.identifier} + f"Correlated with RF signal at {rf.frequency:.3f} MHz", + {"correlated_device": rf.identifier}, ) rf.add_indicator( IndicatorType.CROSS_PROTOCOL, - f'Correlated with BLE device {bt.identifier}', - {'correlated_device': bt.identifier} + f"Correlated with BLE device {bt.identifier}", + {"correlated_device": bt.identifier}, ) bt.correlated_devices.append(rf.identifier) rf.correlated_devices.append(bt.identifier) # Correlation 2: Rogue WiFi AP + RF burst activity - rogue_aps = [p for p in wifi_devices - if any(i.type == IndicatorType.ROGUE_AP for i in p.indicators)] - rf_bursts = [p for p in rf_signals - if any(i.type in [IndicatorType.BURST_TRANSMISSION, - IndicatorType.ALWAYS_ON_CARRIER] for i in p.indicators)] + rogue_aps = [p for p in wifi_devices if any(i.type == IndicatorType.ROGUE_AP for i in p.indicators)] + rf_bursts = [ + p + for p in rf_signals + if any(i.type in [IndicatorType.BURST_TRANSMISSION, IndicatorType.ALWAYS_ON_CARRIER] for i in p.indicators) + ] for ap in rogue_aps: for rf in rf_bursts: correlation = { - 'type': 'rogue_ap_rf_burst', - 'description': 'Rogue AP detected alongside RF transmission', - 'devices': [ap.identifier, rf.identifier], - 'protocols': ['wifi', 'rf'], - 'score_boost': 3, - 'significance': 'high', + "type": "rogue_ap_rf_burst", + "description": "Rogue AP detected alongside RF transmission", + "devices": [ap.identifier, rf.identifier], + "protocols": ["wifi", "rf"], + "score_boost": 3, + "significance": "high", } correlations.append(correlation) ap.add_indicator( IndicatorType.CROSS_PROTOCOL, - f'Correlated with RF at {rf.frequency:.3f} MHz', - {'correlated_device': rf.identifier} + f"Correlated with RF at {rf.frequency:.3f} MHz", + {"correlated_device": rf.identifier}, ) rf.add_indicator( IndicatorType.CROSS_PROTOCOL, - f'Correlated with AP {ap.ssid or ap.identifier}', - {'correlated_device': ap.identifier} + f"Correlated with AP {ap.ssid or ap.identifier}", + {"correlated_device": ap.identifier}, ) # Correlation 3: Same vendor BLE + WiFi @@ -1091,12 +1094,12 @@ class CorrelationEngine: for wifi in wifi_devices: if wifi.manufacturer and bt.manufacturer.lower() in wifi.manufacturer.lower(): correlation = { - 'type': 'same_vendor_bt_wifi', - 'description': f'Same vendor ({bt.manufacturer}) on BLE and WiFi', - 'devices': [bt.identifier, wifi.identifier], - 'protocols': ['bluetooth', 'wifi'], - 'score_boost': 2, - 'significance': 'medium', + "type": "same_vendor_bt_wifi", + "description": f"Same vendor ({bt.manufacturer}) on BLE and WiFi", + "devices": [bt.identifier, wifi.identifier], + "protocols": ["bluetooth", "wifi"], + "score_boost": 2, + "significance": "medium", } correlations.append(correlation) @@ -1108,8 +1111,7 @@ class CorrelationEngine: def get_high_interest_devices(self) -> list[DeviceProfile]: """Get all devices classified as high interest.""" - return [p for p in self.device_profiles.values() - if p.risk_level == RiskLevel.HIGH_INTEREST] + return [p for p in self.device_profiles.values() if p.risk_level == RiskLevel.HIGH_INTEREST] def get_all_findings(self) -> dict: """ @@ -1121,26 +1123,26 @@ class CorrelationEngine: correlations = self.correlate_devices() devices_by_risk = { - 'high_interest': [], - 'needs_review': [], - 'informational': [], + "high_interest": [], + "needs_review": [], + "informational": [], } for profile in self.device_profiles.values(): devices_by_risk[profile.risk_level.value].append(profile.to_dict()) return { - 'timestamp': datetime.now().isoformat(), - 'summary': { - 'total_devices': len(self.device_profiles), - 'high_interest': len(devices_by_risk['high_interest']), - 'needs_review': len(devices_by_risk['needs_review']), - 'informational': len(devices_by_risk['informational']), - 'correlations_found': len(correlations), + "timestamp": datetime.now().isoformat(), + "summary": { + "total_devices": len(self.device_profiles), + "high_interest": len(devices_by_risk["high_interest"]), + "needs_review": len(devices_by_risk["needs_review"]), + "informational": len(devices_by_risk["informational"]), + "correlations_found": len(correlations), }, - 'devices': devices_by_risk, - 'correlations': correlations, - 'disclaimer': ( + "devices": devices_by_risk, + "correlations": correlations, + "disclaimer": ( "This system performs wireless and RF surveillance screening. " "Findings indicate anomalies and indicators, not confirmed surveillance devices." ), @@ -1149,10 +1151,7 @@ class CorrelationEngine: def clear_old_profiles(self, max_age_hours: int = 24) -> int: """Remove profiles older than specified age.""" cutoff = datetime.now() - timedelta(hours=max_age_hours) - old_keys = [ - k for k, v in self.device_profiles.items() - if v.last_seen and v.last_seen < cutoff - ] + old_keys = [k for k, v in self.device_profiles.items() if v.last_seen and v.last_seen < cutoff] for key in old_keys: del self.device_profiles[key] return len(old_keys) diff --git a/utils/tscm/detector.py b/utils/tscm/detector.py index a1cb2ea..95dd43e 100644 --- a/utils/tscm/detector.py +++ b/utils/tscm/detector.py @@ -20,33 +20,48 @@ from utils.tscm.signal_classification import ( get_signal_strength_info, ) -logger = logging.getLogger('intercept.tscm.detector') +logger = logging.getLogger("intercept.tscm.detector") # Classification levels for TSCM devices CLASSIFICATION_LEVELS = { - 'informational': { - 'color': '#00cc00', # Green - 'label': 'Informational', - 'description': 'Known device, expected infrastructure, or background noise', + "informational": { + "color": "#00cc00", # Green + "label": "Informational", + "description": "Known device, expected infrastructure, or background noise", }, - 'review': { - 'color': '#ffcc00', # Yellow - 'label': 'Needs Review', - 'description': 'Unknown device requiring investigation', + "review": { + "color": "#ffcc00", # Yellow + "label": "Needs Review", + "description": "Unknown device requiring investigation", }, - 'high_interest': { - 'color': '#ff3333', # Red - 'label': 'High Interest', - 'description': 'Suspicious device requiring immediate attention', + "high_interest": { + "color": "#ff3333", # Red + "label": "High Interest", + "description": "Suspicious device requiring immediate attention", }, } # BLE device types that can transmit audio (potential bugs) AUDIO_CAPABLE_BLE_NAMES = [ - 'headphone', 'headset', 'earphone', 'earbud', 'speaker', - 'audio', 'mic', 'microphone', 'airpod', 'buds', - 'jabra', 'bose', 'sony wf', 'sony wh', 'beats', - 'jbl', 'soundcore', 'anker', 'skullcandy', + "headphone", + "headset", + "earphone", + "earbud", + "speaker", + "audio", + "mic", + "microphone", + "airpod", + "buds", + "jabra", + "bose", + "sony wf", + "sony wh", + "beats", + "jbl", + "soundcore", + "anker", + "skullcandy", ] # Device history for tracking repeat detections across scans @@ -62,10 +77,7 @@ def _record_device_seen(identifier: str) -> int: # Clean old entries cutoff = now.timestamp() - (_history_window_hours * 3600) - _device_history[identifier] = [ - dt for dt in _device_history[identifier] - if dt.timestamp() > cutoff - ] + _device_history[identifier] = [dt for dt in _device_history[identifier] if dt.timestamp() > cutoff] _device_history[identifier].append(now) return len(_device_history[identifier]) @@ -80,7 +92,7 @@ def _is_audio_capable_ble(name: str | None, device_type: str | None = None) -> b return True if device_type: type_lower = device_type.lower() - if any(t in type_lower for t in ['audio', 'headset', 'headphone', 'speaker']): + if any(t in type_lower for t in ["audio", "headset", "headphone", "speaker"]): return True return False @@ -107,28 +119,28 @@ class ThreatDetector: def _load_baseline(self, baseline: dict) -> None: """Load baseline device identifiers for comparison.""" - # WiFi networks and clients - for network in baseline.get('wifi_networks', []): - if 'bssid' in network: - self.baseline_wifi_macs.add(network['bssid'].upper()) - if 'clients' in network: - for client in network['clients']: - if 'mac' in client: - self.baseline_wifi_macs.add(client['mac'].upper()) - - for client in baseline.get('wifi_clients', []): - if 'mac' in client: - self.baseline_wifi_macs.add(client['mac'].upper()) + # WiFi networks and clients + for network in baseline.get("wifi_networks", []): + if "bssid" in network: + self.baseline_wifi_macs.add(network["bssid"].upper()) + if "clients" in network: + for client in network["clients"]: + if "mac" in client: + self.baseline_wifi_macs.add(client["mac"].upper()) + + for client in baseline.get("wifi_clients", []): + if "mac" in client: + self.baseline_wifi_macs.add(client["mac"].upper()) # Bluetooth devices - for device in baseline.get('bt_devices', []): - if 'mac' in device: - self.baseline_bt_macs.add(device['mac'].upper()) + for device in baseline.get("bt_devices", []): + if "mac" in device: + self.baseline_bt_macs.add(device["mac"].upper()) # RF frequencies (rounded to nearest 0.1 MHz) - for freq in baseline.get('rf_frequencies', []): + for freq in baseline.get("rf_frequencies", []): if isinstance(freq, dict): - self.baseline_rf_freqs.add(round(freq.get('frequency', 0), 1)) + self.baseline_rf_freqs.add(round(freq.get("frequency", 0), 1)) else: self.baseline_rf_freqs.add(round(freq, 1)) @@ -144,31 +156,31 @@ class ThreatDetector: Returns: Dict with 'classification', 'reasons', and metadata """ - mac = device.get('bssid', device.get('mac', '')).upper() - ssid = device.get('essid', device.get('ssid', '')) - signal = device.get('power', device.get('signal', -100)) + mac = device.get("bssid", device.get("mac", "")).upper() + ssid = device.get("essid", device.get("ssid", "")) + signal = device.get("power", device.get("signal", -100)) reasons = [] - classification = 'informational' + classification = "informational" # Track repeat detections - times_seen = _record_device_seen(f'wifi:{mac}') if mac else 1 + times_seen = _record_device_seen(f"wifi:{mac}") if mac else 1 # Check if in baseline (known device) in_baseline = mac in self.baseline_wifi_macs if self.baseline else False if in_baseline: - reasons.append('Known device in baseline') - classification = 'informational' + reasons.append("Known device in baseline") + classification = "informational" else: # New/unknown device - reasons.append('New WiFi access point') - classification = 'review' + reasons.append("New WiFi access point") + classification = "review" # Check for suspicious patterns -> high interest if is_potential_camera(ssid=ssid, mac=mac): - reasons.append('Matches camera device patterns') - classification = 'high_interest' + reasons.append("Matches camera device patterns") + classification = "high_interest" try: signal_val = int(signal) if signal else -100 @@ -177,27 +189,27 @@ class ThreatDetector: # Use standardized signal classification signal_info = get_signal_strength_info(signal_val) - if not ssid and signal_info['strength'] in ('strong', 'very_strong'): + if not ssid and signal_info["strength"] in ("strong", "very_strong"): reasons.append(f"Hidden SSID with {signal_info['label'].lower()} signal") - classification = 'high_interest' + classification = "high_interest" # Repeat detections across scans if times_seen >= 3: - reasons.append(f'Repeat detection ({times_seen} times)') - if classification != 'high_interest': - classification = 'high_interest' + reasons.append(f"Repeat detection ({times_seen} times)") + if classification != "high_interest": + classification = "high_interest" # Include standardized signal classification signal_info = get_signal_strength_info(signal_val) return { - 'classification': classification, - 'reasons': reasons, - 'in_baseline': in_baseline, - 'times_seen': times_seen, - 'signal_strength': signal_info['strength'], - 'signal_label': signal_info['label'], - 'signal_confidence': signal_info['confidence'], + "classification": classification, + "reasons": reasons, + "in_baseline": in_baseline, + "times_seen": times_seen, + "signal_strength": signal_info["strength"], + "signal_label": signal_info["label"], + "signal_confidence": signal_info["confidence"], } def classify_bt_device(self, device: dict) -> dict: @@ -209,33 +221,33 @@ class ThreatDetector: Returns: Dict with 'classification', 'reasons', and metadata """ - mac = device.get('mac', device.get('address', '')).upper() - name = device.get('name', '') - rssi = device.get('rssi', device.get('signal', -100)) - device_type = device.get('type', '') - manufacturer_data = device.get('manufacturer_data') + mac = device.get("mac", device.get("address", "")).upper() + name = device.get("name", "") + rssi = device.get("rssi", device.get("signal", -100)) + device_type = device.get("type", "") + manufacturer_data = device.get("manufacturer_data") reasons = [] - classification = 'informational' + classification = "informational" # Track repeat detections - times_seen = _record_device_seen(f'bt:{mac}') if mac else 1 + times_seen = _record_device_seen(f"bt:{mac}") if mac else 1 # Check if in baseline (known device) in_baseline = mac in self.baseline_bt_macs if self.baseline else False # Use v2 tracker detection data if available (from get_tscm_bluetooth_snapshot) - tracker_data = device.get('tracker', {}) - is_tracker_v2 = tracker_data.get('is_tracker', False) - tracker_type_v2 = tracker_data.get('type') - tracker_name_v2 = tracker_data.get('name') - tracker_confidence_v2 = tracker_data.get('confidence') - tracker_evidence_v2 = tracker_data.get('evidence', []) + tracker_data = device.get("tracker", {}) + is_tracker_v2 = tracker_data.get("is_tracker", False) + tracker_type_v2 = tracker_data.get("type") + tracker_name_v2 = tracker_data.get("name") + tracker_confidence_v2 = tracker_data.get("confidence") + tracker_evidence_v2 = tracker_data.get("evidence", []) # Use v2 risk analysis if available - risk_data = device.get('risk_analysis', {}) - risk_score = risk_data.get('risk_score', 0) - risk_factors = risk_data.get('risk_factors', []) + risk_data = device.get("risk_analysis", {}) + risk_score = risk_data.get("risk_score", 0) + risk_factors = risk_data.get("risk_factors", []) # Fall back to legacy detection if v2 not available tracker_info_legacy = None @@ -245,23 +257,23 @@ class ThreatDetector: is_tracker = is_tracker_v2 or (tracker_info_legacy is not None) if in_baseline: - reasons.append('Known device in baseline') - classification = 'informational' + reasons.append("Known device in baseline") + classification = "informational" else: # New/unknown BLE device - if not name or name == 'Unknown': - reasons.append('Unknown BLE device') - classification = 'review' + if not name or name == "Unknown": + reasons.append("Unknown BLE device") + classification = "review" else: - reasons.append('New Bluetooth device') - classification = 'review' + reasons.append("New Bluetooth device") + classification = "review" # Check for trackers -> high interest if is_tracker_v2: - tracker_label = tracker_name_v2 or tracker_type_v2 or 'Unknown tracker' - conf_label = f' ({tracker_confidence_v2})' if tracker_confidence_v2 else '' + tracker_label = tracker_name_v2 or tracker_type_v2 or "Unknown tracker" + conf_label = f" ({tracker_confidence_v2})" if tracker_confidence_v2 else "" reasons.append(f"Tracker detected: {tracker_label}{conf_label}") - classification = 'high_interest' + classification = "high_interest" # Add evidence from v2 detection for evidence_item in tracker_evidence_v2[:2]: # First 2 items @@ -275,12 +287,12 @@ class ThreatDetector: elif tracker_info_legacy: reasons.append(f"Known tracker: {tracker_info_legacy.get('name', 'Unknown')}") - classification = 'high_interest' + classification = "high_interest" # Check for audio-capable devices -> high interest if _is_audio_capable_ble(name, device_type): - reasons.append('Audio-capable BLE device') - classification = 'high_interest' + reasons.append("Audio-capable BLE device") + classification = "high_interest" # Strong signal from unknown device - use standardized classification try: @@ -289,15 +301,15 @@ class ThreatDetector: rssi_val = -100 signal_info = get_signal_strength_info(rssi_val) - if signal_info['strength'] in ('strong', 'very_strong') and not name: + if signal_info["strength"] in ("strong", "very_strong") and not name: reasons.append(f"{signal_info['label']} signal from unnamed device") - classification = 'high_interest' + classification = "high_interest" # Repeat detections across scans if times_seen >= 3: - reasons.append(f'Repeat detection ({times_seen} times)') - if classification != 'high_interest': - classification = 'high_interest' + reasons.append(f"Repeat detection ({times_seen} times)") + if classification != "high_interest": + classification = "high_interest" # Include standardized signal classification try: @@ -307,19 +319,19 @@ class ThreatDetector: signal_info = get_signal_strength_info(rssi_val) return { - 'classification': classification, - 'reasons': reasons, - 'in_baseline': in_baseline, - 'times_seen': times_seen, - 'is_tracker': is_tracker, - 'tracker_type': tracker_type_v2, - 'tracker_name': tracker_name_v2, - 'tracker_confidence': tracker_confidence_v2, - 'risk_score': risk_score, - 'is_audio_capable': _is_audio_capable_ble(name, device_type), - 'signal_strength': signal_info['strength'], - 'signal_label': signal_info['label'], - 'signal_confidence': signal_info['confidence'], + "classification": classification, + "reasons": reasons, + "in_baseline": in_baseline, + "times_seen": times_seen, + "is_tracker": is_tracker, + "tracker_type": tracker_type_v2, + "tracker_name": tracker_name_v2, + "tracker_confidence": tracker_confidence_v2, + "risk_score": risk_score, + "is_audio_capable": _is_audio_capable_ble(name, device_type), + "signal_strength": signal_info["strength"], + "signal_label": signal_info["label"], + "signal_confidence": signal_info["confidence"], } def classify_rf_signal(self, signal: dict) -> dict: @@ -329,16 +341,16 @@ class ThreatDetector: Returns: Dict with 'classification', 'reasons', and metadata """ - frequency = signal.get('frequency', 0) - power = signal.get('power', signal.get('level', -100)) - signal.get('band', '') + frequency = signal.get("frequency", 0) + power = signal.get("power", signal.get("level", -100)) + signal.get("band", "") reasons = [] - classification = 'informational' + classification = "informational" freq_rounded = round(frequency, 1) # Track repeat detections - times_seen = _record_device_seen(f'rf:{freq_rounded}') + times_seen = _record_device_seen(f"rf:{freq_rounded}") # Check if in baseline (known frequency) in_baseline = freq_rounded in self.baseline_rf_freqs if self.baseline else False @@ -347,33 +359,33 @@ class ThreatDetector: risk, band_name = get_frequency_risk(frequency) if in_baseline: - reasons.append('Known frequency in baseline') - classification = 'informational' + reasons.append("Known frequency in baseline") + classification = "informational" else: # New/unidentified RF carrier - reasons.append(f'Unidentified RF carrier in {band_name}') + reasons.append(f"Unidentified RF carrier in {band_name}") - if risk == 'low': - reasons.append('Background RF noise band') - classification = 'review' - elif risk == 'medium': - reasons.append('ISM band signal') - classification = 'review' - elif risk in ['high', 'critical']: - reasons.append(f'High-risk surveillance band: {band_name}') - classification = 'high_interest' + if risk == "low": + reasons.append("Background RF noise band") + classification = "review" + elif risk == "medium": + reasons.append("ISM band signal") + classification = "review" + elif risk in ["high", "critical"]: + reasons.append(f"High-risk surveillance band: {band_name}") + classification = "high_interest" # Strong persistent signal - use standardized classification if power: power_info = get_signal_strength_info(float(power)) - if power_info['strength'] in ('strong', 'very_strong'): + if power_info["strength"] in ("strong", "very_strong"): reasons.append(f"{power_info['label']} persistent transmitter") - classification = 'high_interest' + classification = "high_interest" # Repeat detections (persistent transmitter) if times_seen >= 2: - reasons.append(f'Persistent transmitter ({times_seen} detections)') - classification = 'high_interest' + reasons.append(f"Persistent transmitter ({times_seen} detections)") + classification = "high_interest" # Include standardized signal classification try: @@ -383,15 +395,15 @@ class ThreatDetector: signal_info = get_signal_strength_info(power_val) return { - 'classification': classification, - 'reasons': reasons, - 'in_baseline': in_baseline, - 'times_seen': times_seen, - 'risk_level': risk, - 'band_name': band_name, - 'signal_strength': signal_info['strength'], - 'signal_label': signal_info['label'], - 'signal_confidence': signal_info['confidence'], + "classification": classification, + "reasons": reasons, + "in_baseline": in_baseline, + "times_seen": times_seen, + "risk_level": risk, + "band_name": band_name, + "signal_strength": signal_info["strength"], + "signal_label": signal_info["label"], + "signal_confidence": signal_info["confidence"], } def analyze_wifi_device(self, device: dict) -> dict | None: @@ -404,28 +416,32 @@ class ThreatDetector: Returns: Threat dict if threat detected, None otherwise """ - mac = device.get('bssid', device.get('mac', '')).upper() - ssid = device.get('essid', device.get('ssid', '')) - vendor = device.get('vendor', '') - signal = device.get('power', device.get('signal', -100)) + mac = device.get("bssid", device.get("mac", "")).upper() + ssid = device.get("essid", device.get("ssid", "")) + vendor = device.get("vendor", "") + signal = device.get("power", device.get("signal", -100)) threats = [] # Check if new device (not in baseline) if self.baseline and mac and mac not in self.baseline_wifi_macs: - threats.append({ - 'type': 'new_device', - 'severity': get_threat_severity('new_device', {'signal_strength': signal}), - 'reason': 'Device not present in baseline', - }) + threats.append( + { + "type": "new_device", + "severity": get_threat_severity("new_device", {"signal_strength": signal}), + "reason": "Device not present in baseline", + } + ) # Check for hidden camera patterns if is_potential_camera(ssid=ssid, mac=mac, vendor=vendor): - threats.append({ - 'type': 'hidden_camera', - 'severity': get_threat_severity('hidden_camera', {'signal_strength': signal}), - 'reason': 'Device matches WiFi camera patterns', - }) + threats.append( + { + "type": "hidden_camera", + "severity": get_threat_severity("hidden_camera", {"signal_strength": signal}), + "reason": "Device matches WiFi camera patterns", + } + ) # Check for hidden SSID with strong signal - use standardized classification try: @@ -434,31 +450,33 @@ class ThreatDetector: signal_int = -100 signal_info = get_signal_strength_info(signal_int) - if not ssid and signal_info['strength'] in ('strong', 'very_strong'): - threats.append({ - 'type': 'anomaly', - 'severity': 'medium', - 'reason': f"Hidden SSID with {signal_info['label'].lower()} signal", - }) + if not ssid and signal_info["strength"] in ("strong", "very_strong"): + threats.append( + { + "type": "anomaly", + "severity": "medium", + "reason": f"Hidden SSID with {signal_info['label'].lower()} signal", + } + ) if not threats: return None # Return highest severity threat - threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True) + threats.sort(key=lambda t: ["low", "medium", "high", "critical"].index(t["severity"]), reverse=True) return { - 'threat_type': threats[0]['type'], - 'severity': threats[0]['severity'], - 'source': 'wifi', - 'identifier': mac, - 'name': ssid or 'Hidden Network', - 'signal_strength': signal, - 'details': { - 'all_threats': threats, - 'vendor': vendor, - 'ssid': ssid, - } + "threat_type": threats[0]["type"], + "severity": threats[0]["severity"], + "source": "wifi", + "identifier": mac, + "name": ssid or "Hidden Network", + "signal_strength": signal, + "details": { + "all_threats": threats, + "vendor": vendor, + "ssid": ssid, + }, } def analyze_bt_device(self, device: dict) -> dict | None: @@ -471,46 +489,52 @@ class ThreatDetector: Returns: Threat dict if threat detected, None otherwise """ - mac = device.get('mac', device.get('address', '')).upper() - name = device.get('name', '') - rssi = device.get('rssi', device.get('signal', -100)) - manufacturer = device.get('manufacturer', '') - device_type = device.get('type', '') - manufacturer_data = device.get('manufacturer_data') - tracker_data = device.get('tracker', {}) or {} - - threats = [] + mac = device.get("mac", device.get("address", "")).upper() + name = device.get("name", "") + rssi = device.get("rssi", device.get("signal", -100)) + manufacturer = device.get("manufacturer", "") + device_type = device.get("type", "") + manufacturer_data = device.get("manufacturer_data") + tracker_data = device.get("tracker", {}) or {} + + threats = [] # Check if new device (not in baseline) if self.baseline and mac and mac not in self.baseline_bt_macs: - threats.append({ - 'type': 'new_device', - 'severity': get_threat_severity('new_device', {'signal_strength': rssi}), - 'reason': 'Device not present in baseline', - }) + threats.append( + { + "type": "new_device", + "severity": get_threat_severity("new_device", {"signal_strength": rssi}), + "reason": "Device not present in baseline", + } + ) - # Check for known trackers (v2 tracker data if available) - if tracker_data.get('is_tracker'): - tracker_label = tracker_data.get('name') or tracker_data.get('type') or 'Tracker' - confidence = str(tracker_data.get('confidence') or '').lower() - severity = 'high' if confidence in ('high', 'medium') else 'medium' - threats.append({ - 'type': 'tracker', - 'severity': severity, - 'reason': f"Tracker detected: {tracker_label}", - 'tracker_type': tracker_label, - 'details': tracker_data.get('evidence', []), - }) - - # Check for known trackers (legacy patterns) - tracker_info = is_known_tracker(name, manufacturer_data) - if tracker_info: - threats.append({ - 'type': 'tracker', - 'severity': tracker_info.get('risk', 'high'), - 'reason': f"Known tracker detected: {tracker_info.get('name', 'Unknown')}", - 'tracker_type': tracker_info.get('name'), - }) + # Check for known trackers (v2 tracker data if available) + if tracker_data.get("is_tracker"): + tracker_label = tracker_data.get("name") or tracker_data.get("type") or "Tracker" + confidence = str(tracker_data.get("confidence") or "").lower() + severity = "high" if confidence in ("high", "medium") else "medium" + threats.append( + { + "type": "tracker", + "severity": severity, + "reason": f"Tracker detected: {tracker_label}", + "tracker_type": tracker_label, + "details": tracker_data.get("evidence", []), + } + ) + + # Check for known trackers (legacy patterns) + tracker_info = is_known_tracker(name, manufacturer_data) + if tracker_info: + threats.append( + { + "type": "tracker", + "severity": tracker_info.get("risk", "high"), + "reason": f"Known tracker detected: {tracker_info.get('name', 'Unknown')}", + "tracker_type": tracker_info.get("name"), + } + ) # Check for suspicious BLE beacons (unnamed, persistent) - use standardized classification try: @@ -519,31 +543,33 @@ class ThreatDetector: rssi_int = -100 signal_info = get_signal_strength_info(rssi_int) - if not name and signal_info['strength'] in ('moderate', 'strong', 'very_strong'): - threats.append({ - 'type': 'anomaly', - 'severity': 'medium', - 'reason': f"Unnamed BLE device with {signal_info['label'].lower()} signal", - }) + if not name and signal_info["strength"] in ("moderate", "strong", "very_strong"): + threats.append( + { + "type": "anomaly", + "severity": "medium", + "reason": f"Unnamed BLE device with {signal_info['label'].lower()} signal", + } + ) if not threats: return None # Return highest severity threat - threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True) + threats.sort(key=lambda t: ["low", "medium", "high", "critical"].index(t["severity"]), reverse=True) return { - 'threat_type': threats[0]['type'], - 'severity': threats[0]['severity'], - 'source': 'bluetooth', - 'identifier': mac, - 'name': name or 'Unknown BLE Device', - 'signal_strength': rssi, - 'details': { - 'all_threats': threats, - 'manufacturer': manufacturer, - 'device_type': device_type, - } + "threat_type": threats[0]["type"], + "severity": threats[0]["severity"], + "source": "bluetooth", + "identifier": mac, + "name": name or "Unknown BLE Device", + "signal_strength": rssi, + "details": { + "all_threats": threats, + "manufacturer": manufacturer, + "device_type": device_type, + }, } def analyze_rf_signal(self, signal: dict) -> dict | None: @@ -556,9 +582,9 @@ class ThreatDetector: Returns: Threat dict if threat detected, None otherwise """ - frequency = signal.get('frequency', 0) - level = signal.get('level', signal.get('power', -100)) - modulation = signal.get('modulation', '') + frequency = signal.get("frequency", 0) + level = signal.get("level", signal.get("power", -100)) + modulation = signal.get("modulation", "") if not frequency: return None @@ -569,47 +595,51 @@ class ThreatDetector: # Check if new frequency (not in baseline) if self.baseline and freq_rounded not in self.baseline_rf_freqs: risk, band_name = get_frequency_risk(frequency) - threats.append({ - 'type': 'unknown_signal', - 'severity': risk, - 'reason': f'New signal in {band_name}', - }) + threats.append( + { + "type": "unknown_signal", + "severity": risk, + "reason": f"New signal in {band_name}", + } + ) # Check frequency risk even without baseline risk, band_name = get_frequency_risk(frequency) - if risk in ['high', 'critical']: - threats.append({ - 'type': 'unknown_signal', - 'severity': risk, - 'reason': f'Signal in high-risk band: {band_name}', - }) + if risk in ["high", "critical"]: + threats.append( + { + "type": "unknown_signal", + "severity": risk, + "reason": f"Signal in high-risk band: {band_name}", + } + ) if not threats: return None # Return highest severity threat - threats.sort(key=lambda t: ['low', 'medium', 'high', 'critical'].index(t['severity']), reverse=True) + threats.sort(key=lambda t: ["low", "medium", "high", "critical"].index(t["severity"]), reverse=True) return { - 'threat_type': threats[0]['type'], - 'severity': threats[0]['severity'], - 'source': 'rf', - 'identifier': f'{frequency:.3f} MHz', - 'name': f'RF Signal @ {frequency:.3f} MHz', - 'signal_strength': level, - 'frequency': frequency, - 'details': { - 'all_threats': threats, - 'modulation': modulation, - 'band_name': band_name, - } + "threat_type": threats[0]["type"], + "severity": threats[0]["severity"], + "source": "rf", + "identifier": f"{frequency:.3f} MHz", + "name": f"RF Signal @ {frequency:.3f} MHz", + "signal_strength": level, + "frequency": frequency, + "details": { + "all_threats": threats, + "modulation": modulation, + "band_name": band_name, + }, } def analyze_all( self, wifi_devices: list[dict] | None = None, bt_devices: list[dict] | None = None, - rf_signals: list[dict] | None = None + rf_signals: list[dict] | None = None, ) -> list[dict]: """ Analyze all provided devices and signals for threats. @@ -638,17 +668,13 @@ class ThreatDetector: threats.append(threat) # Sort by severity (critical first) - severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3} - threats.sort(key=lambda t: severity_order.get(t.get('severity', 'low'), 3)) + severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3} + threats.sort(key=lambda t: severity_order.get(t.get("severity", "low"), 3)) return threats -def classify_device_threat( - source: str, - device: dict, - baseline: dict | None = None -) -> dict | None: +def classify_device_threat(source: str, device: dict, baseline: dict | None = None) -> dict | None: """ Convenience function to classify a single device. @@ -662,11 +688,11 @@ def classify_device_threat( """ detector = ThreatDetector(baseline) - if source == 'wifi': + if source == "wifi": return detector.analyze_wifi_device(device) - elif source == 'bluetooth': + elif source == "bluetooth": return detector.analyze_bt_device(device) - elif source == 'rf': + elif source == "rf": return detector.analyze_rf_signal(device) return None diff --git a/utils/tscm/device_identity.py b/utils/tscm/device_identity.py index 02ad8d7..42e42a5 100644 --- a/utils/tscm/device_identity.py +++ b/utils/tscm/device_identity.py @@ -32,7 +32,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta from enum import Enum -logger = logging.getLogger('intercept.tscm.device_identity') +logger = logging.getLogger("intercept.tscm.device_identity") # ============================================================================= @@ -40,8 +40,8 @@ logger = logging.getLogger('intercept.tscm.device_identity') # ============================================================================= # Session gap thresholds (seconds) -BLE_SESSION_GAP = 60 # New session if no observations for 60s -WIFI_SESSION_GAP = 120 # WiFi clients may probe less frequently +BLE_SESSION_GAP = 60 # New session if no observations for 60s +WIFI_SESSION_GAP = 120 # WiFi clients may probe less frequently # Clustering thresholds MIN_CLUSTER_CONFIDENCE = 0.3 # Minimum confidence to consider clustering @@ -56,64 +56,70 @@ TEMPORAL_CORRELATION_WINDOW = timedelta(seconds=5) # Fingerprint weights (sum to 1.0 for normalization) FINGERPRINT_WEIGHTS = { - 'manufacturer_data': 0.25, - 'service_uuids': 0.20, - 'capabilities': 0.15, - 'payload_structure': 0.15, - 'timing_pattern': 0.10, - 'rssi_trajectory': 0.10, - 'name_similarity': 0.05, + "manufacturer_data": 0.25, + "service_uuids": 0.20, + "capabilities": 0.15, + "payload_structure": 0.15, + "timing_pattern": 0.10, + "rssi_trajectory": 0.10, + "name_similarity": 0.05, } class AddressType(Enum): """BLE address types per Bluetooth spec.""" - PUBLIC = 'public' - RANDOM_STATIC = 'random_static' - RPA = 'rpa' # Resolvable Private Address - NRPA = 'nrpa' # Non-Resolvable Private Address - UNKNOWN = 'unknown' + + PUBLIC = "public" + RANDOM_STATIC = "random_static" + RPA = "rpa" # Resolvable Private Address + NRPA = "nrpa" # Non-Resolvable Private Address + UNKNOWN = "unknown" class AdvType(Enum): """BLE advertisement types.""" - ADV_IND = 'ADV_IND' - ADV_DIRECT_IND = 'ADV_DIRECT_IND' - ADV_NONCONN_IND = 'ADV_NONCONN_IND' - ADV_SCAN_IND = 'ADV_SCAN_IND' - SCAN_RSP = 'SCAN_RSP' - UNKNOWN = 'unknown' + + ADV_IND = "ADV_IND" + ADV_DIRECT_IND = "ADV_DIRECT_IND" + ADV_NONCONN_IND = "ADV_NONCONN_IND" + ADV_SCAN_IND = "ADV_SCAN_IND" + SCAN_RSP = "SCAN_RSP" + UNKNOWN = "unknown" class WifiFrameType(Enum): """WiFi frame types of interest.""" - BEACON = 'beacon' - PROBE_REQUEST = 'probe_request' - PROBE_RESPONSE = 'probe_response' - AUTH = 'auth' - ASSOC_REQUEST = 'assoc_request' - ASSOC_RESPONSE = 'assoc_response' - DEAUTH = 'deauth' - DISASSOC = 'disassoc' - DATA = 'data' - UNKNOWN = 'unknown' + + BEACON = "beacon" + PROBE_REQUEST = "probe_request" + PROBE_RESPONSE = "probe_response" + AUTH = "auth" + ASSOC_REQUEST = "assoc_request" + ASSOC_RESPONSE = "assoc_response" + DEAUTH = "deauth" + DISASSOC = "disassoc" + DATA = "data" + UNKNOWN = "unknown" class RiskLevel(Enum): """TSCM risk levels for device clusters.""" - INFORMATIONAL = 'informational' - LOW = 'low' - MEDIUM = 'medium' - HIGH = 'high' + + INFORMATIONAL = "informational" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" # ============================================================================= # Observation Data Classes # ============================================================================= + @dataclass class BLEObservation: """Single BLE advertisement observation.""" + timestamp: datetime addr: str # MAC-like address addr_type: AddressType = AddressType.UNKNOWN @@ -189,7 +195,7 @@ class BLEObservation: # Check MAC address format for random bit # Bit 1 of first octet set = locally administered (random) try: - first_octet = int(self.addr.split(':')[0], 16) + first_octet = int(self.addr.split(":")[0], 16) return bool(first_octet & 0x02) except (ValueError, IndexError): return False @@ -198,6 +204,7 @@ class BLEObservation: @dataclass class WifiObservation: """Single WiFi frame observation.""" + timestamp: datetime src_mac: str dst_mac: str | None = None @@ -245,11 +252,11 @@ class WifiObservation: # Capability fingerprint caps = [] if self.ht_capable: - caps.append('HT') + caps.append("HT") if self.vht_capable: - caps.append('VHT') + caps.append("VHT") if self.he_capable: - caps.append('HE') + caps.append("HE") if caps: components.append(f"caps:{'+'.join(caps)}") @@ -276,7 +283,7 @@ class WifiObservation: def is_randomized_address(self) -> bool: """Check if source MAC appears to be randomized.""" try: - first_octet = int(self.src_mac.split(':')[0], 16) + first_octet = int(self.src_mac.split(":")[0], 16) return bool(first_octet & 0x02) except (ValueError, IndexError): return False @@ -286,6 +293,7 @@ class WifiObservation: # Session and Cluster Data Classes # ============================================================================= + @dataclass class DeviceSession: """ @@ -294,6 +302,7 @@ class DeviceSession: Multiple observations from the same MAC (or clustered identity) within the session gap threshold belong to the same session. """ + session_id: str protocol: str # 'ble' or 'wifi' first_seen: datetime @@ -312,11 +321,11 @@ class DeviceSession: self.observations.append(obs) self.last_seen = obs.timestamp - if hasattr(obs, 'addr'): + if hasattr(obs, "addr"): self.observed_macs.add(obs.addr) if self.primary_mac is None: self.primary_mac = obs.addr - elif hasattr(obs, 'src_mac'): + elif hasattr(obs, "src_mac"): self.observed_macs.add(obs.src_mac) if self.primary_mac is None: self.primary_mac = obs.src_mac @@ -369,24 +378,25 @@ class DeviceSession: def to_dict(self) -> dict: """Convert to dictionary for serialization.""" return { - 'session_id': self.session_id, - 'protocol': self.protocol, - 'first_seen': self.first_seen.isoformat(), - 'last_seen': self.last_seen.isoformat(), - 'duration_seconds': self.get_duration().total_seconds(), - 'observation_count': len(self.observations), - 'primary_mac': self.primary_mac, - 'observed_macs': list(self.observed_macs), - 'fingerprint_hashes': list(self.fingerprint_hashes), - 'mean_rssi': self.get_mean_rssi(), - 'rssi_stability': self.get_rssi_stability(), - 'mean_interval': self.get_mean_interval(), + "session_id": self.session_id, + "protocol": self.protocol, + "first_seen": self.first_seen.isoformat(), + "last_seen": self.last_seen.isoformat(), + "duration_seconds": self.get_duration().total_seconds(), + "observation_count": len(self.observations), + "primary_mac": self.primary_mac, + "observed_macs": list(self.observed_macs), + "fingerprint_hashes": list(self.fingerprint_hashes), + "mean_rssi": self.get_mean_rssi(), + "rssi_stability": self.get_rssi_stability(), + "mean_interval": self.get_mean_interval(), } @dataclass class RiskIndicator: """A TSCM risk indicator for a device cluster.""" + indicator_type: str description: str score: int # 0-10 @@ -395,11 +405,11 @@ class RiskIndicator: def to_dict(self) -> dict: return { - 'type': self.indicator_type, - 'description': self.description, - 'score': self.score, - 'evidence': self.evidence, - 'timestamp': self.timestamp.isoformat(), + "type": self.indicator_type, + "description": self.description, + "score": self.score, + "evidence": self.evidence, + "timestamp": self.timestamp.isoformat(), } @@ -411,6 +421,7 @@ class DeviceCluster: Multiple sessions and MACs may be linked to the same cluster based on fingerprint similarity, temporal correlation, and RSSI patterns. """ + cluster_id: str protocol: str created_at: datetime = field(default_factory=datetime.now) @@ -441,8 +452,7 @@ class DeviceCluster: last_seen: datetime | None = None presence_ratio: float = 0.0 # % of monitoring period device was present - def add_session(self, session: DeviceSession, link_reason: str, - link_confidence: float) -> None: + def add_session(self, session: DeviceSession, link_reason: str, link_confidence: float) -> None: """Add a session to this cluster with linking evidence.""" self.sessions.append(session) self.linked_macs.update(session.observed_macs) @@ -455,18 +465,18 @@ class DeviceCluster: if self.last_seen is None or session.last_seen > self.last_seen: self.last_seen = session.last_seen - self.link_evidence.append({ - 'session_id': session.session_id, - 'reason': link_reason, - 'confidence': link_confidence, - 'timestamp': datetime.now().isoformat(), - }) + self.link_evidence.append( + { + "session_id": session.session_id, + "reason": link_reason, + "confidence": link_confidence, + "timestamp": datetime.now().isoformat(), + } + ) # Update overall confidence (weighted average) if self.link_evidence: - self.confidence = statistics.mean( - e['confidence'] for e in self.link_evidence - ) + self.confidence = statistics.mean(e["confidence"] for e in self.link_evidence) def add_risk_indicator(self, indicator: RiskIndicator) -> None: """Add a risk indicator and update risk assessment.""" @@ -493,27 +503,27 @@ class DeviceCluster: def to_dict(self) -> dict: """Convert to dictionary for serialization.""" return { - 'cluster_id': self.cluster_id, - 'protocol': self.protocol, - 'created_at': self.created_at.isoformat(), - 'updated_at': self.updated_at.isoformat(), - 'confidence': round(self.confidence, 3), - 'session_count': len(self.sessions), - 'linked_macs': list(self.linked_macs), - 'fingerprint_hashes': list(self.fingerprint_hashes), - 'best_name': self.best_name, - 'manufacturer_id': self.manufacturer_id, - 'manufacturer_name': self.manufacturer_name, - 'device_type': self.device_type, - 'risk_level': self.risk_level.value, - 'risk_score': self.risk_score, - 'risk_indicators': [i.to_dict() for i in self.risk_indicators], - 'total_observations': self.total_observations, - 'first_seen': self.first_seen.isoformat() if self.first_seen else None, - 'last_seen': self.last_seen.isoformat() if self.last_seen else None, - 'presence_ratio': round(self.presence_ratio, 3), - 'link_evidence': self.link_evidence, - 'sessions': [s.to_dict() for s in self.sessions], + "cluster_id": self.cluster_id, + "protocol": self.protocol, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + "confidence": round(self.confidence, 3), + "session_count": len(self.sessions), + "linked_macs": list(self.linked_macs), + "fingerprint_hashes": list(self.fingerprint_hashes), + "best_name": self.best_name, + "manufacturer_id": self.manufacturer_id, + "manufacturer_name": self.manufacturer_name, + "device_type": self.device_type, + "risk_level": self.risk_level.value, + "risk_score": self.risk_score, + "risk_indicators": [i.to_dict() for i in self.risk_indicators], + "total_observations": self.total_observations, + "first_seen": self.first_seen.isoformat() if self.first_seen else None, + "last_seen": self.last_seen.isoformat() if self.last_seen else None, + "presence_ratio": round(self.presence_ratio, 3), + "link_evidence": self.link_evidence, + "sessions": [s.to_dict() for s in self.sessions], } @@ -521,6 +531,7 @@ class DeviceCluster: # Fingerprint Similarity Functions # ============================================================================= + def jaccard_similarity(set1: set, set2: set) -> float: """Calculate Jaccard similarity between two sets.""" if not set1 and not set2: @@ -530,8 +541,7 @@ def jaccard_similarity(set1: set, set2: set) -> float: return intersection / union if union > 0 else 0.0 -def manufacturer_data_similarity(data1: bytes | None, - data2: bytes | None) -> float: +def manufacturer_data_similarity(data1: bytes | None, data2: bytes | None) -> float: """ Calculate similarity between manufacturer data blobs. @@ -546,9 +556,7 @@ def manufacturer_data_similarity(data1: bytes | None, # Compare common prefix (often contains device type info) prefix_len = min(8, len(data1), len(data2)) - prefix_match = sum( - 1 for i in range(prefix_len) if data1[i] == data2[i] - ) / prefix_len if prefix_len > 0 else 0.0 + prefix_match = sum(1 for i in range(prefix_len) if data1[i] == data2[i]) / prefix_len if prefix_len > 0 else 0.0 # Compare full content via byte-level similarity min_len = min(len(data1), len(data2)) @@ -559,9 +567,7 @@ def manufacturer_data_similarity(data1: bytes | None, return 0.5 * prefix_match + 0.3 * content_sim + 0.2 * len_sim -def rssi_trajectory_similarity(samples1: list[int], - samples2: list[int], - time_window: float = 5.0) -> float: +def rssi_trajectory_similarity(samples1: list[int], samples2: list[int], time_window: float = 5.0) -> float: """ Calculate RSSI trajectory similarity. @@ -594,8 +600,7 @@ def rssi_trajectory_similarity(samples1: list[int], return 0.6 * mean_sim + 0.4 * var_sim -def timing_pattern_similarity(intervals1: list[float], - intervals2: list[float]) -> float: +def timing_pattern_similarity(intervals1: list[float], intervals2: list[float]) -> float: """ Calculate advertising/probing interval similarity. @@ -650,6 +655,7 @@ def name_similarity(name1: str | None, name2: str | None) -> float: # Device Identity Engine # ============================================================================= + class DeviceIdentityEngine: """ Main engine for MAC-randomization resistant device detection. @@ -720,8 +726,8 @@ class DeviceIdentityEngine: def _create_ble_session(self, obs: BLEObservation) -> DeviceSession: """Create a new BLE session from initial observation.""" session = DeviceSession( - session_id=self._generate_session_id('ble'), - protocol='ble', + session_id=self._generate_session_id("ble"), + protocol="ble", first_seen=obs.timestamp, last_seen=obs.timestamp, ) @@ -762,8 +768,8 @@ class DeviceIdentityEngine: def _create_wifi_session(self, obs: WifiObservation) -> DeviceSession: """Create a new WiFi session from initial observation.""" session = DeviceSession( - session_id=self._generate_session_id('wifi'), - protocol='wifi', + session_id=self._generate_session_id("wifi"), + protocol="wifi", first_seen=obs.timestamp, last_seen=obs.timestamp, ) @@ -778,11 +784,7 @@ class DeviceIdentityEngine: if cluster: # Add to existing cluster similarity = self._calculate_cluster_similarity(cluster, session) - cluster.add_session( - session, - link_reason="Fingerprint/behavioral match", - link_confidence=similarity - ) + cluster.add_session(session, link_reason="Fingerprint/behavioral match", link_confidence=similarity) else: # Create new cluster cluster = self._create_cluster_from_session(session) @@ -811,8 +813,7 @@ class DeviceIdentityEngine: return best_match - def _calculate_cluster_similarity(self, cluster: DeviceCluster, - session: DeviceSession) -> float: + def _calculate_cluster_similarity(self, cluster: DeviceCluster, session: DeviceSession) -> float: """ Calculate similarity between a cluster and a session. @@ -823,48 +824,35 @@ class DeviceIdentityEngine: # 1. Fingerprint hash matching (strongest signal) fp_overlap = cluster.fingerprint_hashes & session.fingerprint_hashes if fp_overlap: - fp_score = len(fp_overlap) / max( - len(cluster.fingerprint_hashes), - len(session.fingerprint_hashes) - ) - scores['fingerprint'] = min(1.0, fp_score * 1.5) # Boost for exact match + fp_score = len(fp_overlap) / max(len(cluster.fingerprint_hashes), len(session.fingerprint_hashes)) + scores["fingerprint"] = min(1.0, fp_score * 1.5) # Boost for exact match # 2. Manufacturer data similarity cluster_mfg_data = self._get_cluster_manufacturer_data(cluster) session_mfg_data = self._get_session_manufacturer_data(session) if cluster_mfg_data and session_mfg_data: - scores['manufacturer_data'] = manufacturer_data_similarity( - cluster_mfg_data, session_mfg_data - ) + scores["manufacturer_data"] = manufacturer_data_similarity(cluster_mfg_data, session_mfg_data) # 3. Service UUID overlap cluster_uuids = self._get_cluster_service_uuids(cluster) session_uuids = self._get_session_service_uuids(session) if cluster_uuids or session_uuids: - scores['service_uuids'] = jaccard_similarity( - cluster_uuids, session_uuids - ) + scores["service_uuids"] = jaccard_similarity(cluster_uuids, session_uuids) # 4. RSSI trajectory similarity cluster_rssi = cluster.get_all_rssi_samples() if cluster_rssi and session.rssi_samples: - scores['rssi_trajectory'] = rssi_trajectory_similarity( - cluster_rssi, session.rssi_samples - ) + scores["rssi_trajectory"] = rssi_trajectory_similarity(cluster_rssi, session.rssi_samples) # 5. Timing pattern similarity cluster_intervals = self._get_cluster_intervals(cluster) if cluster_intervals and session.observation_intervals: - scores['timing_pattern'] = timing_pattern_similarity( - cluster_intervals, session.observation_intervals - ) + scores["timing_pattern"] = timing_pattern_similarity(cluster_intervals, session.observation_intervals) # 6. Name similarity session_name = self._get_session_name(session) if cluster.best_name and session_name: - scores['name_similarity'] = name_similarity( - cluster.best_name, session_name - ) + scores["name_similarity"] = name_similarity(cluster.best_name, session_name) if not scores: return 0.0 @@ -884,14 +872,14 @@ class DeviceIdentityEngine: """Get representative manufacturer data from cluster.""" for session in cluster.sessions: for obs in session.observations: - if hasattr(obs, 'manufacturer_data') and obs.manufacturer_data: + if hasattr(obs, "manufacturer_data") and obs.manufacturer_data: return obs.manufacturer_data return None def _get_session_manufacturer_data(self, session: DeviceSession) -> bytes | None: """Get manufacturer data from session.""" for obs in session.observations: - if hasattr(obs, 'manufacturer_data') and obs.manufacturer_data: + if hasattr(obs, "manufacturer_data") and obs.manufacturer_data: return obs.manufacturer_data return None @@ -900,7 +888,7 @@ class DeviceIdentityEngine: uuids = set() for session in cluster.sessions: for obs in session.observations: - if hasattr(obs, 'service_uuids') and obs.service_uuids: + if hasattr(obs, "service_uuids") and obs.service_uuids: uuids.update(obs.service_uuids) return uuids @@ -908,7 +896,7 @@ class DeviceIdentityEngine: """Get service UUIDs from session.""" uuids = set() for obs in session.observations: - if hasattr(obs, 'service_uuids') and obs.service_uuids: + if hasattr(obs, "service_uuids") and obs.service_uuids: uuids.update(obs.service_uuids) return uuids @@ -922,7 +910,7 @@ class DeviceIdentityEngine: def _get_session_name(self, session: DeviceSession) -> str | None: """Get device name from session.""" for obs in session.observations: - if hasattr(obs, 'local_name') and obs.local_name: + if hasattr(obs, "local_name") and obs.local_name: return obs.local_name return None @@ -933,17 +921,13 @@ class DeviceIdentityEngine: protocol=session.protocol, ) - cluster.add_session( - session, - link_reason="Initial session", - link_confidence=1.0 - ) + cluster.add_session(session, link_reason="Initial session", link_confidence=1.0) # Extract identifying information for obs in session.observations: - if hasattr(obs, 'local_name') and obs.local_name: + if hasattr(obs, "local_name") and obs.local_name: cluster.best_name = obs.local_name - if hasattr(obs, 'manufacturer_id') and obs.manufacturer_id: + if hasattr(obs, "manufacturer_id") and obs.manufacturer_id: cluster.manufacturer_id = obs.manufacturer_id return cluster @@ -969,12 +953,14 @@ class DeviceIdentityEngine: # Risk: High presence ratio (device always present) if cluster.presence_ratio > 0.8: - cluster.add_risk_indicator(RiskIndicator( - indicator_type='high_presence', - description='Device present for >80% of monitoring period', - score=2, - evidence={'presence_ratio': round(cluster.presence_ratio, 2)} - )) + cluster.add_risk_indicator( + RiskIndicator( + indicator_type="high_presence", + description="Device present for >80% of monitoring period", + score=2, + evidence={"presence_ratio": round(cluster.presence_ratio, 2)}, + ) + ) # Risk: Very stable RSSI (stationary device) rssi_samples = cluster.get_all_rssi_samples() @@ -982,65 +968,69 @@ class DeviceIdentityEngine: try: stdev = statistics.stdev(rssi_samples) if stdev < 3: - cluster.add_risk_indicator(RiskIndicator( - indicator_type='stable_rssi', - description='Very stable signal suggests fixed placement', - score=2, - evidence={ - 'rssi_stdev': round(stdev, 2), - 'sample_count': len(rssi_samples) - } - )) + cluster.add_risk_indicator( + RiskIndicator( + indicator_type="stable_rssi", + description="Very stable signal suggests fixed placement", + score=2, + evidence={"rssi_stdev": round(stdev, 2), "sample_count": len(rssi_samples)}, + ) + ) except statistics.StatisticsError: pass # Risk: Multiple MAC addresses observed (MAC rotation) if len(cluster.linked_macs) > 1: - cluster.add_risk_indicator(RiskIndicator( - indicator_type='mac_rotation', - description=f'Multiple MACs ({len(cluster.linked_macs)}) linked to same device', - score=1, - evidence={'mac_count': len(cluster.linked_macs)} - )) + cluster.add_risk_indicator( + RiskIndicator( + indicator_type="mac_rotation", + description=f"Multiple MACs ({len(cluster.linked_macs)}) linked to same device", + score=1, + evidence={"mac_count": len(cluster.linked_macs)}, + ) + ) # Risk: Check for suspicious manufacturer IDs if cluster.manufacturer_id: suspicious_mfg = { - 0x02E5: ('Espressif', 3, 'Programmable ESP32/ESP8266 device'), + 0x02E5: ("Espressif", 3, "Programmable ESP32/ESP8266 device"), } if cluster.manufacturer_id in suspicious_mfg: name, score, desc = suspicious_mfg[cluster.manufacturer_id] - cluster.add_risk_indicator(RiskIndicator( - indicator_type='suspicious_chipset', - description=desc, - score=score, - evidence={'manufacturer': name, 'id': hex(cluster.manufacturer_id)} - )) + cluster.add_risk_indicator( + RiskIndicator( + indicator_type="suspicious_chipset", + description=desc, + score=score, + evidence={"manufacturer": name, "id": hex(cluster.manufacturer_id)}, + ) + ) # Risk: Check for audio-capable services (BLE) - audio_service_prefixes = ['0000110', '00001108', '00001203'] # A2DP, Headset, Audio + audio_service_prefixes = ["0000110", "00001108", "00001203"] # A2DP, Headset, Audio cluster_uuids = set() for session in cluster.sessions: cluster_uuids.update(self._get_session_service_uuids(session)) for uuid in cluster_uuids: if any(uuid.lower().startswith(prefix) for prefix in audio_service_prefixes): - cluster.add_risk_indicator(RiskIndicator( - indicator_type='audio_capable', - description='Audio-capable BLE services detected', - score=2, - evidence={'service_uuid': uuid} - )) + cluster.add_risk_indicator( + RiskIndicator( + indicator_type="audio_capable", + description="Audio-capable BLE services detected", + score=2, + evidence={"service_uuid": uuid}, + ) + ) break # Risk: No name advertised (hidden identity) if not cluster.best_name: - cluster.add_risk_indicator(RiskIndicator( - indicator_type='no_name', - description='Device does not advertise a name', - score=1, - evidence={} - )) + cluster.add_risk_indicator( + RiskIndicator( + indicator_type="no_name", description="Device does not advertise a name", score=1, evidence={} + ) + ) # Risk: High observation count relative to duration (aggressive advertising) if cluster.first_seen and cluster.last_seen: @@ -1048,16 +1038,18 @@ class DeviceIdentityEngine: if duration > 60 and cluster.total_observations > 0: obs_rate = cluster.total_observations / duration if obs_rate > 2.0: # More than 2 observations per second - cluster.add_risk_indicator(RiskIndicator( - indicator_type='high_ad_rate', - description='Unusually high advertising rate', - score=2, - evidence={ - 'rate': round(obs_rate, 2), - 'observations': cluster.total_observations, - 'duration': round(duration, 1) - } - )) + cluster.add_risk_indicator( + RiskIndicator( + indicator_type="high_ad_rate", + description="Unusually high advertising rate", + score=2, + evidence={ + "rate": round(obs_rate, 2), + "observations": cluster.total_observations, + "duration": round(duration, 1), + }, + ) + ) def finalize_all_sessions(self) -> None: """Finalize all active sessions (call at end of monitoring).""" @@ -1068,50 +1060,40 @@ class DeviceIdentityEngine: def get_clusters(self, min_confidence: float = 0.0) -> list[DeviceCluster]: """Get all clusters above minimum confidence.""" - return [ - c for c in self.clusters.values() - if c.confidence >= min_confidence - ] + return [c for c in self.clusters.values() if c.confidence >= min_confidence] def get_high_risk_clusters(self) -> list[DeviceCluster]: """Get clusters with HIGH risk level.""" - return [ - c for c in self.clusters.values() - if c.risk_level == RiskLevel.HIGH - ] + return [c for c in self.clusters.values() if c.risk_level == RiskLevel.HIGH] def get_summary(self) -> dict: """Get summary of all clusters and sessions.""" - clusters_by_risk = { - 'high': [], - 'medium': [], - 'low': [], - 'informational': [] - } + clusters_by_risk = {"high": [], "medium": [], "low": [], "informational": []} for cluster in self.clusters.values(): clusters_by_risk[cluster.risk_level.value].append(cluster.to_dict()) return { - 'monitoring_period': { - 'start': self.monitoring_start.isoformat() if self.monitoring_start else None, - 'end': self.monitoring_end.isoformat() if self.monitoring_end else None, - 'duration_seconds': ( + "monitoring_period": { + "start": self.monitoring_start.isoformat() if self.monitoring_start else None, + "end": self.monitoring_end.isoformat() if self.monitoring_end else None, + "duration_seconds": ( (self.monitoring_end - self.monitoring_start).total_seconds() - if self.monitoring_start and self.monitoring_end else 0 - ) + if self.monitoring_start and self.monitoring_end + else 0 + ), }, - 'statistics': { - 'total_clusters': len(self.clusters), - 'ble_sessions': len(self.ble_sessions), - 'wifi_sessions': len(self.wifi_sessions), - 'high_risk_count': len(clusters_by_risk['high']), - 'medium_risk_count': len(clusters_by_risk['medium']), - 'low_risk_count': len(clusters_by_risk['low']), - 'unique_fingerprints': len(self._fingerprint_to_sessions), + "statistics": { + "total_clusters": len(self.clusters), + "ble_sessions": len(self.ble_sessions), + "wifi_sessions": len(self.wifi_sessions), + "high_risk_count": len(clusters_by_risk["high"]), + "medium_risk_count": len(clusters_by_risk["medium"]), + "low_risk_count": len(clusters_by_risk["low"]), + "unique_fingerprints": len(self._fingerprint_to_sessions), }, - 'clusters_by_risk': clusters_by_risk, - 'disclaimer': ( + "clusters_by_risk": clusters_by_risk, + "disclaimer": ( "Device clustering uses passive fingerprinting and statistical correlation. " "Results indicate probable device identities, NOT confirmed matches. " "Confidence scores reflect similarity measures, not certainty. " @@ -1167,7 +1149,7 @@ def _convert_to_bytes(value) -> bytes | None: return bytes.fromhex(value) except ValueError: # Not a valid hex string, encode as UTF-8 - return value.encode('utf-8') + return value.encode("utf-8") if isinstance(value, (list, tuple)): # Array of integers (like dbus.Array) try: @@ -1184,22 +1166,23 @@ def ingest_ble_dict(data: dict) -> DeviceSession: Convenience function for API integration. """ obs = BLEObservation( - timestamp=datetime.fromisoformat(data['timestamp']) if isinstance(data.get('timestamp'), str) - else data.get('timestamp', datetime.now()), - addr=data.get('addr', data.get('mac', '')).upper(), - addr_type=data.get('addr_type', 'unknown'), - rssi=data.get('rssi'), - tx_power=data.get('tx_power'), - adv_type=data.get('adv_type', 'unknown'), - adv_flags=data.get('adv_flags'), - manufacturer_id=data.get('manufacturer_id'), - manufacturer_data=_convert_to_bytes(data.get('manufacturer_data')), - service_uuids=data.get('service_uuids', []), - service_data=_convert_to_bytes(data.get('service_data')), - local_name=data.get('local_name', data.get('name')), - appearance=data.get('appearance'), - packet_length=data.get('packet_length'), - phy=data.get('phy'), + timestamp=datetime.fromisoformat(data["timestamp"]) + if isinstance(data.get("timestamp"), str) + else data.get("timestamp", datetime.now()), + addr=data.get("addr", data.get("mac", "")).upper(), + addr_type=data.get("addr_type", "unknown"), + rssi=data.get("rssi"), + tx_power=data.get("tx_power"), + adv_type=data.get("adv_type", "unknown"), + adv_flags=data.get("adv_flags"), + manufacturer_id=data.get("manufacturer_id"), + manufacturer_data=_convert_to_bytes(data.get("manufacturer_data")), + service_uuids=data.get("service_uuids", []), + service_data=_convert_to_bytes(data.get("service_data")), + local_name=data.get("local_name", data.get("name")), + appearance=data.get("appearance"), + packet_length=data.get("packet_length"), + phy=data.get("phy"), ) return get_identity_engine().ingest_ble_observation(obs) @@ -1211,29 +1194,30 @@ def ingest_wifi_dict(data: dict) -> DeviceSession: Convenience function for API integration. """ obs = WifiObservation( - timestamp=datetime.fromisoformat(data['timestamp']) if isinstance(data.get('timestamp'), str) - else data.get('timestamp', datetime.now()), - src_mac=data.get('src_mac', data.get('mac', '')).upper(), - dst_mac=data.get('dst_mac'), - bssid=data.get('bssid'), - ssid=data.get('ssid'), - frame_type=data.get('frame_type', 'unknown'), - rssi=data.get('rssi'), - channel=data.get('channel'), - bandwidth=data.get('bandwidth'), - encryption=data.get('encryption'), - beacon_interval=data.get('beacon_interval'), - capabilities=data.get('capabilities'), - supported_rates=data.get('supported_rates', []), - extended_rates=data.get('extended_rates', []), - ht_capable=data.get('ht_capable', False), - vht_capable=data.get('vht_capable', False), - he_capable=data.get('he_capable', False), - ht_capabilities=data.get('ht_capabilities'), - vht_capabilities=data.get('vht_capabilities'), - vendor_ies=data.get('vendor_ies', []), - wps_present=data.get('wps_present', False), - sequence_number=data.get('sequence_number'), - probed_ssids=data.get('probed_ssids', []), + timestamp=datetime.fromisoformat(data["timestamp"]) + if isinstance(data.get("timestamp"), str) + else data.get("timestamp", datetime.now()), + src_mac=data.get("src_mac", data.get("mac", "")).upper(), + dst_mac=data.get("dst_mac"), + bssid=data.get("bssid"), + ssid=data.get("ssid"), + frame_type=data.get("frame_type", "unknown"), + rssi=data.get("rssi"), + channel=data.get("channel"), + bandwidth=data.get("bandwidth"), + encryption=data.get("encryption"), + beacon_interval=data.get("beacon_interval"), + capabilities=data.get("capabilities"), + supported_rates=data.get("supported_rates", []), + extended_rates=data.get("extended_rates", []), + ht_capable=data.get("ht_capable", False), + vht_capable=data.get("vht_capable", False), + he_capable=data.get("he_capable", False), + ht_capabilities=data.get("ht_capabilities"), + vht_capabilities=data.get("vht_capabilities"), + vendor_ies=data.get("vendor_ies", []), + wps_present=data.get("wps_present", False), + sequence_number=data.get("sequence_number"), + probed_ssids=data.get("probed_ssids", []), ) return get_identity_engine().ingest_wifi_observation(obs) diff --git a/utils/tscm/reports.py b/utils/tscm/reports.py index 5cfafca..87d2e51 100644 --- a/utils/tscm/reports.py +++ b/utils/tscm/reports.py @@ -23,15 +23,17 @@ from utils.tscm.signal_classification import ( generate_hedged_statement, ) -logger = logging.getLogger('intercept.tscm.reports') +logger = logging.getLogger("intercept.tscm.reports") # ============================================================================= # Report Data Structures # ============================================================================= + @dataclass class ReportFinding: """A single finding for the report.""" + identifier: str protocol: str name: str | None @@ -39,8 +41,8 @@ class ReportFinding: risk_score: int description: str indicators: list[dict] = field(default_factory=list) - recommended_action: str = '' - playbook_reference: str = '' + recommended_action: str = "" + playbook_reference: str = "" # Signal classification data signal_strength: str | None = None # minimal, weak, moderate, strong, very_strong signal_confidence: str | None = None # low, medium, high @@ -51,6 +53,7 @@ class ReportFinding: @dataclass class ReportMeetingSummary: """Meeting window summary for report.""" + name: str | None start_time: str end_time: str | None @@ -67,6 +70,7 @@ class TSCMReport: Contains all data needed for both client-safe PDF and technical annex. """ + # Report metadata report_id: str generated_at: datetime @@ -75,13 +79,13 @@ class TSCMReport: # Location and context location: str | None = None - examiner_name: str = '' + examiner_name: str = "" baseline_id: int | None = None baseline_name: str | None = None # Executive summary - executive_summary: str = '' - overall_risk_assessment: str = 'low' # low, moderate, elevated, high + executive_summary: str = "" + overall_risk_assessment: str = "low" # low, moderate, elevated, high key_findings_count: int = 0 # Capabilities used @@ -173,6 +177,7 @@ policies and applicable privacy regulations. # Report Generation Functions # ============================================================================= + def generate_executive_summary(report: TSCMReport) -> str: """Generate executive summary text.""" lines = [] @@ -185,13 +190,13 @@ def generate_executive_summary(report: TSCMReport) -> str: # Overall assessment assessment_text = { - 'low': 'No significant indicators of surveillance activity were detected.', - 'moderate': 'Some devices require review but no confirmed surveillance indicators.', - 'elevated': 'Multiple indicators warrant further investigation.', - 'high': 'Significant indicators detected requiring immediate attention.', + "low": "No significant indicators of surveillance activity were detected.", + "moderate": "Some devices require review but no confirmed surveillance indicators.", + "elevated": "Multiple indicators warrant further investigation.", + "high": "Significant indicators detected requiring immediate attention.", } lines.append(f"OVERALL ASSESSMENT: {report.overall_risk_assessment.upper()}") - lines.append(assessment_text.get(report.overall_risk_assessment, '')) + lines.append(assessment_text.get(report.overall_risk_assessment, "")) lines.append("") # Key statistics @@ -221,9 +226,11 @@ def generate_executive_summary(report: TSCMReport) -> str: if report.meeting_summaries: lines.append("MEETING WINDOW ACTIVITY:") for meeting in report.meeting_summaries: - lines.append(f" - {meeting.name or 'Unnamed meeting'}: " - f"{meeting.devices_first_seen} new devices, " - f"{meeting.high_interest_devices} high interest") + lines.append( + f" - {meeting.name or 'Unnamed meeting'}: " + f"{meeting.devices_first_seen} new devices, " + f"{meeting.high_interest_devices} high interest" + ) lines.append("") # Limitations @@ -251,8 +258,8 @@ def generate_findings_section(findings: list[ReportFinding], title: str) -> str: # Signal classification with confidence if finding.signal_strength: - confidence_label = (finding.signal_confidence or 'low').capitalize() - strength_label = finding.signal_strength.replace('_', ' ').title() + confidence_label = (finding.signal_confidence or "low").capitalize() + strength_label = finding.signal_strength.replace("_", " ").title() lines.append(f" Signal: {strength_label} (Confidence: {confidence_label})") lines.append(f" Assessment: {finding.description}") @@ -272,7 +279,7 @@ def generate_findings_section(findings: list[ReportFinding], title: str) -> str: lines.append(f" Reference: {finding.playbook_reference}") # Include relevant caveats for high-interest findings - if finding.signal_caveats and finding.risk_level == 'high_interest': + if finding.signal_caveats and finding.risk_level == "high_interest": lines.append(" Note: " + finding.signal_caveats[0]) lines.append("") @@ -339,18 +346,12 @@ def generate_pdf_content(report: TSCMReport) -> str: # High Interest Findings if report.high_interest_findings: sections.append("-" * 70) - sections.append(generate_findings_section( - report.high_interest_findings, - "HIGH INTEREST FINDINGS" - )) + sections.append(generate_findings_section(report.high_interest_findings, "HIGH INTEREST FINDINGS")) # Needs Review Findings if report.needs_review_findings: sections.append("-" * 70) - sections.append(generate_findings_section( - report.needs_review_findings, - "FINDINGS REQUIRING REVIEW" - )) + sections.append(generate_findings_section(report.needs_review_findings, "FINDINGS REQUIRING REVIEW")) # Meeting Window Summary if report.meeting_summaries: @@ -366,11 +367,11 @@ def generate_pdf_content(report: TSCMReport) -> str: if report.capabilities: caps = report.capabilities sections.append("Equipment Used:") - if caps.get('wifi', {}).get('mode') != 'unavailable': + if caps.get("wifi", {}).get("mode") != "unavailable": sections.append(f" - WiFi: {caps.get('wifi', {}).get('mode', 'unknown')} mode") - if caps.get('bluetooth', {}).get('mode') != 'unavailable': + if caps.get("bluetooth", {}).get("mode") != "unavailable": sections.append(f" - Bluetooth: {caps.get('bluetooth', {}).get('mode', 'unknown')}") - if caps.get('rf', {}).get('available'): + if caps.get("rf", {}).get("available"): sections.append(f" - RF/SDR: {caps.get('rf', {}).get('device_type', 'unknown')}") sections.append("") @@ -408,93 +409,87 @@ def generate_technical_annex_json(report: TSCMReport) -> dict: for audit and further analysis. """ return { - 'annex_type': 'tscm_technical_annex', - 'report_id': report.report_id, - 'generated_at': report.generated_at.isoformat(), - 'sweep_id': report.sweep_id, - 'disclaimer': ANNEX_DISCLAIMER.strip(), - - 'sweep_details': { - 'type': report.sweep_type, - 'location': report.location, - 'start_time': report.sweep_start.isoformat() if report.sweep_start else None, - 'end_time': report.sweep_end.isoformat() if report.sweep_end else None, - 'duration_minutes': report.duration_minutes, - 'baseline_id': report.baseline_id, - 'baseline_name': report.baseline_name, + "annex_type": "tscm_technical_annex", + "report_id": report.report_id, + "generated_at": report.generated_at.isoformat(), + "sweep_id": report.sweep_id, + "disclaimer": ANNEX_DISCLAIMER.strip(), + "sweep_details": { + "type": report.sweep_type, + "location": report.location, + "start_time": report.sweep_start.isoformat() if report.sweep_start else None, + "end_time": report.sweep_end.isoformat() if report.sweep_end else None, + "duration_minutes": report.duration_minutes, + "baseline_id": report.baseline_id, + "baseline_name": report.baseline_name, }, - - 'capabilities': report.capabilities, - 'limitations': report.limitations, - - 'statistics': { - 'total_devices': report.total_devices_scanned, - 'wifi_devices': report.wifi_devices, - 'wifi_clients': report.wifi_clients, - 'bluetooth_devices': report.bluetooth_devices, - 'rf_signals': report.rf_signals, - 'new_devices': report.new_devices, - 'missing_devices': report.missing_devices, - 'high_interest_count': len(report.high_interest_findings), - 'needs_review_count': len(report.needs_review_findings), - 'informational_count': len(report.informational_findings), + "capabilities": report.capabilities, + "limitations": report.limitations, + "statistics": { + "total_devices": report.total_devices_scanned, + "wifi_devices": report.wifi_devices, + "wifi_clients": report.wifi_clients, + "bluetooth_devices": report.bluetooth_devices, + "rf_signals": report.rf_signals, + "new_devices": report.new_devices, + "missing_devices": report.missing_devices, + "high_interest_count": len(report.high_interest_findings), + "needs_review_count": len(report.needs_review_findings), + "informational_count": len(report.informational_findings), }, - - 'findings': { - 'high_interest': [ + "findings": { + "high_interest": [ { - 'identifier': f.identifier, - 'protocol': f.protocol, - 'name': f.name, - 'risk_score': f.risk_score, - 'description': f.description, - 'indicators': f.indicators, - 'recommended_action': f.recommended_action, - 'signal_classification': { - 'strength': f.signal_strength, - 'confidence': f.signal_confidence, - 'interpretation': f.signal_interpretation, - 'caveats': f.signal_caveats, + "identifier": f.identifier, + "protocol": f.protocol, + "name": f.name, + "risk_score": f.risk_score, + "description": f.description, + "indicators": f.indicators, + "recommended_action": f.recommended_action, + "signal_classification": { + "strength": f.signal_strength, + "confidence": f.signal_confidence, + "interpretation": f.signal_interpretation, + "caveats": f.signal_caveats, }, } for f in report.high_interest_findings ], - 'needs_review': [ + "needs_review": [ { - 'identifier': f.identifier, - 'protocol': f.protocol, - 'name': f.name, - 'risk_score': f.risk_score, - 'description': f.description, - 'indicators': f.indicators, - 'signal_classification': { - 'strength': f.signal_strength, - 'confidence': f.signal_confidence, - 'interpretation': f.signal_interpretation, - 'caveats': f.signal_caveats, + "identifier": f.identifier, + "protocol": f.protocol, + "name": f.name, + "risk_score": f.risk_score, + "description": f.description, + "indicators": f.indicators, + "signal_classification": { + "strength": f.signal_strength, + "confidence": f.signal_confidence, + "interpretation": f.signal_interpretation, + "caveats": f.signal_caveats, }, } for f in report.needs_review_findings ], }, - - 'meeting_windows': [ + "meeting_windows": [ { - 'name': m.name, - 'start_time': m.start_time, - 'end_time': m.end_time, - 'duration_minutes': m.duration_minutes, - 'devices_first_seen': m.devices_first_seen, - 'behavior_changes': m.behavior_changes, - 'high_interest_devices': m.high_interest_devices, + "name": m.name, + "start_time": m.start_time, + "end_time": m.end_time, + "duration_minutes": m.duration_minutes, + "devices_first_seen": m.devices_first_seen, + "behavior_changes": m.behavior_changes, + "high_interest_devices": m.high_interest_devices, } for m in report.meeting_summaries ], - - 'device_timelines': report.device_timelines, - 'all_indicators': report.all_indicators, - 'baseline_diff': report.baseline_diff, - 'correlations': report.correlation_data, + "device_timelines": report.device_timelines, + "all_indicators": report.all_indicators, + "baseline_diff": report.baseline_diff, + "correlations": report.correlation_data, } @@ -508,80 +503,88 @@ def generate_technical_annex_csv(report: TSCMReport) -> str: writer = csv.writer(output) # Header - writer.writerow([ - 'identifier', - 'protocol', - 'name', - 'risk_level', - 'risk_score', - 'first_seen', - 'last_seen', - 'observation_count', - 'rssi_min', - 'rssi_max', - 'rssi_mean', - 'rssi_stability', - 'movement_pattern', - 'meeting_correlated', - 'indicators', - ]) + writer.writerow( + [ + "identifier", + "protocol", + "name", + "risk_level", + "risk_score", + "first_seen", + "last_seen", + "observation_count", + "rssi_min", + "rssi_max", + "rssi_mean", + "rssi_stability", + "movement_pattern", + "meeting_correlated", + "indicators", + ] + ) # Device data from timelines for timeline in report.device_timelines: - indicators_str = '; '.join( - f"{i.get('type', '')}({i.get('score', 0)})" - for i in timeline.get('indicators', []) + indicators_str = "; ".join(f"{i.get('type', '')}({i.get('score', 0)})" for i in timeline.get("indicators", [])) + + signal = timeline.get("signal", {}) + metrics = timeline.get("metrics", {}) + movement = timeline.get("movement", {}) + meeting = timeline.get("meeting_correlation", {}) + + writer.writerow( + [ + timeline.get("identifier", ""), + timeline.get("protocol", ""), + timeline.get("name", ""), + timeline.get("risk_level", "informational"), + timeline.get("risk_score", 0), + metrics.get("first_seen", ""), + metrics.get("last_seen", ""), + metrics.get("total_observations", 0), + signal.get("rssi_min", ""), + signal.get("rssi_max", ""), + signal.get("rssi_mean", ""), + signal.get("stability", ""), + movement.get("pattern", ""), + meeting.get("correlated", False), + indicators_str, + ] ) - signal = timeline.get('signal', {}) - metrics = timeline.get('metrics', {}) - movement = timeline.get('movement', {}) - meeting = timeline.get('meeting_correlation', {}) - - writer.writerow([ - timeline.get('identifier', ''), - timeline.get('protocol', ''), - timeline.get('name', ''), - timeline.get('risk_level', 'informational'), - timeline.get('risk_score', 0), - metrics.get('first_seen', ''), - metrics.get('last_seen', ''), - metrics.get('total_observations', 0), - signal.get('rssi_min', ''), - signal.get('rssi_max', ''), - signal.get('rssi_mean', ''), - signal.get('stability', ''), - movement.get('pattern', ''), - meeting.get('correlated', False), - indicators_str, - ]) - # Also add findings summary writer.writerow([]) - writer.writerow(['--- FINDINGS SUMMARY ---']) - writer.writerow([ - 'identifier', 'protocol', 'risk_level', 'risk_score', - 'signal_strength', 'signal_confidence', - 'description', 'interpretation', 'recommended_action' - ]) - - all_findings = ( - report.high_interest_findings + - report.needs_review_findings + writer.writerow(["--- FINDINGS SUMMARY ---"]) + writer.writerow( + [ + "identifier", + "protocol", + "risk_level", + "risk_score", + "signal_strength", + "signal_confidence", + "description", + "interpretation", + "recommended_action", + ] ) + all_findings = report.high_interest_findings + report.needs_review_findings + for finding in all_findings: - writer.writerow([ - finding.identifier, - finding.protocol, - finding.risk_level, - finding.risk_score, - finding.signal_strength or '', - finding.signal_confidence or '', - finding.description, - finding.signal_interpretation or '', - finding.recommended_action, - ]) + writer.writerow( + [ + finding.identifier, + finding.protocol, + finding.risk_level, + finding.risk_score, + finding.signal_strength or "", + finding.signal_confidence or "", + finding.description, + finding.signal_interpretation or "", + finding.recommended_action, + ] + ) return output.getvalue() @@ -590,6 +593,7 @@ def generate_technical_annex_csv(report: TSCMReport) -> str: # Report Builder # ============================================================================= + class TSCMReportBuilder: """ Builder for constructing TSCM reports from sweep data. @@ -608,7 +612,7 @@ class TSCMReportBuilder: report_id=f"TSCM-{sweep_id}-{datetime.now().strftime('%Y%m%d%H%M%S')}", generated_at=datetime.now(), sweep_id=sweep_id, - sweep_type='standard', + sweep_type="standard", ) def set_sweep_type(self, sweep_type: str) -> TSCMReportBuilder: @@ -628,27 +632,21 @@ class TSCMReportBuilder: self.report.baseline_name = baseline_name return self - def set_sweep_times( - self, - start: datetime, - end: datetime | None = None - ) -> TSCMReportBuilder: + def set_sweep_times(self, start: datetime, end: datetime | None = None) -> TSCMReportBuilder: self.report.sweep_start = start self.report.sweep_end = end or datetime.now() - self.report.duration_minutes = ( - (self.report.sweep_end - self.report.sweep_start).total_seconds() / 60 - ) + self.report.duration_minutes = (self.report.sweep_end - self.report.sweep_start).total_seconds() / 60 return self def add_capabilities(self, capabilities: dict) -> TSCMReportBuilder: self.report.capabilities = capabilities - self.report.limitations = capabilities.get('all_limitations', []) + self.report.limitations = capabilities.get("all_limitations", []) return self def add_finding(self, finding: ReportFinding) -> TSCMReportBuilder: - if finding.risk_level == 'high_interest': + if finding.risk_level == "high_interest": self.report.high_interest_findings.append(finding) - elif finding.risk_level in ['review', 'needs_review']: + elif finding.risk_level in ["review", "needs_review"]: self.report.needs_review_findings.append(finding) else: self.report.informational_findings.append(finding) @@ -661,19 +659,19 @@ class TSCMReportBuilder: signal_data = self._classify_finding_signal(profile) finding = ReportFinding( - identifier=profile.get('identifier', ''), - protocol=profile.get('protocol', ''), - name=profile.get('name'), - risk_level=profile.get('risk_level', 'informational'), - risk_score=profile.get('total_score', 0), + identifier=profile.get("identifier", ""), + protocol=profile.get("protocol", ""), + name=profile.get("name"), + risk_level=profile.get("risk_level", "informational"), + risk_score=profile.get("total_score", 0), description=self._generate_finding_description(profile), - indicators=profile.get('indicators', []), - recommended_action=profile.get('recommended_action', 'monitor'), + indicators=profile.get("indicators", []), + recommended_action=profile.get("recommended_action", "monitor"), playbook_reference=self._get_playbook_reference(profile), - signal_strength=signal_data['signal_strength'], - signal_confidence=signal_data['signal_confidence'], - signal_interpretation=signal_data['signal_interpretation'], - signal_caveats=signal_data['signal_caveats'], + signal_strength=signal_data["signal_strength"], + signal_confidence=signal_data["signal_confidence"], + signal_interpretation=signal_data["signal_interpretation"], + signal_caveats=signal_data["signal_caveats"], ) self.add_finding(finding) @@ -681,13 +679,13 @@ class TSCMReportBuilder: def _generate_finding_description(self, profile: dict) -> str: """Generate description from profile indicators using hedged language.""" - indicators = profile.get('indicators', []) - protocol = profile.get('protocol', 'Unknown').upper() + indicators = profile.get("indicators", []) + protocol = profile.get("protocol", "Unknown").upper() # Get signal data for context - rssi = profile.get('rssi_mean') or profile.get('rssi') - duration = profile.get('observation_duration_seconds') - observation_count = profile.get('observation_count', 1) + rssi = profile.get("rssi_mean") or profile.get("rssi") + duration = profile.get("observation_duration_seconds") + observation_count = profile.get("observation_count", 1) # Assess signal to determine confidence assessment = assess_signal(rssi, duration, observation_count) @@ -695,44 +693,24 @@ class TSCMReportBuilder: if not indicators: # Use hedged language based on confidence - return generate_hedged_statement( - f"Observed {protocol} signal", - 'device_presence', - confidence - ) + return generate_hedged_statement(f"Observed {protocol} signal", "device_presence", confidence) # Build description with hedged language primary = indicators[0] - indicator_type = primary.get('type', 'pattern') + indicator_type = primary.get("type", "pattern") # Map indicator types to hedged descriptions - if indicator_type in ('airtag_detected', 'tile_detected', 'smarttag_detected', 'known_tracker'): - desc = generate_hedged_statement( - f"{protocol} signal characteristics", - 'device_presence', - confidence - ) + if indicator_type in ("airtag_detected", "tile_detected", "smarttag_detected", "known_tracker"): + desc = generate_hedged_statement(f"{protocol} signal characteristics", "device_presence", confidence) desc += f" - pattern consistent with {indicator_type.replace('_', ' ')}" - elif indicator_type == 'audio_capable': - desc = generate_hedged_statement( - "Device characteristics", - 'surveillance_indicator', - confidence - ) + elif indicator_type == "audio_capable": + desc = generate_hedged_statement("Device characteristics", "surveillance_indicator", confidence) desc += " - audio-capable device type identified" - elif indicator_type in ('hidden_identity', 'hidden_ssid'): - desc = generate_hedged_statement( - "Network configuration", - 'surveillance_indicator', - confidence - ) + elif indicator_type in ("hidden_identity", "hidden_ssid"): + desc = generate_hedged_statement("Network configuration", "surveillance_indicator", confidence) desc += " - concealed identity pattern observed" else: - desc = generate_hedged_statement( - f"{protocol} signal pattern", - 'device_presence', - confidence - ) + desc = generate_hedged_statement(f"{protocol} signal pattern", "device_presence", confidence) if len(indicators) > 1: desc += f" (+{len(indicators) - 1} additional indicators)" @@ -741,58 +719,52 @@ class TSCMReportBuilder: def _classify_finding_signal(self, profile: dict) -> dict: """Extract signal classification data for a finding.""" - rssi = profile.get('rssi_mean') or profile.get('rssi') - duration = profile.get('observation_duration_seconds') - observation_count = profile.get('observation_count', 1) + rssi = profile.get("rssi_mean") or profile.get("rssi") + duration = profile.get("observation_duration_seconds") + observation_count = profile.get("observation_count", 1) assessment = assess_signal(rssi, duration, observation_count) return { - 'signal_strength': assessment.signal_strength.value, - 'signal_confidence': assessment.confidence.value, - 'signal_interpretation': assessment.interpretation, - 'signal_caveats': assessment.caveats, + "signal_strength": assessment.signal_strength.value, + "signal_confidence": assessment.confidence.value, + "signal_interpretation": assessment.interpretation, + "signal_caveats": assessment.caveats, } def _get_playbook_reference(self, profile: dict) -> str: """Get playbook reference based on profile.""" - risk_level = profile.get('risk_level', 'informational') - indicators = profile.get('indicators', []) + risk_level = profile.get("risk_level", "informational") + indicators = profile.get("indicators", []) # Check for tracker - tracker_types = ['airtag_detected', 'tile_detected', 'smarttag_detected', 'known_tracker'] - if any(i.get('type') in tracker_types for i in indicators) and risk_level == 'high_interest': - return 'PB-001 (Tracker Detection)' + tracker_types = ["airtag_detected", "tile_detected", "smarttag_detected", "known_tracker"] + if any(i.get("type") in tracker_types for i in indicators) and risk_level == "high_interest": + return "PB-001 (Tracker Detection)" - if risk_level == 'high_interest': - return 'PB-002 (Suspicious Device)' - elif risk_level in ['review', 'needs_review']: - return 'PB-003 (Unknown Device)' + if risk_level == "high_interest": + return "PB-002 (Suspicious Device)" + elif risk_level in ["review", "needs_review"]: + return "PB-003 (Unknown Device)" - return '' + return "" def add_meeting_summary(self, summary: dict) -> TSCMReportBuilder: """Add meeting window summary.""" meeting = ReportMeetingSummary( - name=summary.get('name'), - start_time=summary.get('start_time', ''), - end_time=summary.get('end_time'), - duration_minutes=summary.get('duration_minutes', 0), - devices_first_seen=summary.get('devices_first_seen', 0), - behavior_changes=summary.get('behavior_changes', 0), - high_interest_devices=summary.get('high_interest_devices', 0), + name=summary.get("name"), + start_time=summary.get("start_time", ""), + end_time=summary.get("end_time"), + duration_minutes=summary.get("duration_minutes", 0), + devices_first_seen=summary.get("devices_first_seen", 0), + behavior_changes=summary.get("behavior_changes", 0), + high_interest_devices=summary.get("high_interest_devices", 0), ) self.report.meeting_summaries.append(meeting) return self def add_statistics( - self, - wifi: int = 0, - wifi_clients: int = 0, - bluetooth: int = 0, - rf: int = 0, - new: int = 0, - missing: int = 0 + self, wifi: int = 0, wifi_clients: int = 0, bluetooth: int = 0, rf: int = 0, new: int = 0, missing: int = 0 ) -> TSCMReportBuilder: self.report.wifi_devices = wifi self.report.wifi_clients = wifi_clients @@ -824,17 +796,16 @@ class TSCMReportBuilder: # Calculate overall risk assessment if self.report.high_interest_findings: if len(self.report.high_interest_findings) >= 3: - self.report.overall_risk_assessment = 'high' + self.report.overall_risk_assessment = "high" else: - self.report.overall_risk_assessment = 'elevated' + self.report.overall_risk_assessment = "elevated" elif self.report.needs_review_findings: - self.report.overall_risk_assessment = 'moderate' + self.report.overall_risk_assessment = "moderate" else: - self.report.overall_risk_assessment = 'low' + self.report.overall_risk_assessment = "low" - self.report.key_findings_count = ( - len(self.report.high_interest_findings) + - len(self.report.needs_review_findings) + self.report.key_findings_count = len(self.report.high_interest_findings) + len( + self.report.needs_review_findings ) # Generate executive summary @@ -847,6 +818,7 @@ class TSCMReportBuilder: # Report Generation API Functions # ============================================================================= + def generate_report( sweep_id: int, sweep_data: dict, @@ -857,8 +829,8 @@ def generate_report( meeting_summaries: list[dict] | None = None, correlations: list[dict] | None = None, categories: list[str] | None = None, - site_name: str = '', - examiner_name: str = '', + site_name: str = "", + examiner_name: str = "", ) -> TSCMReport: """ Generate a complete TSCM report from sweep data. @@ -879,20 +851,20 @@ def generate_report( builder = TSCMReportBuilder(sweep_id) # Basic info - builder.set_sweep_type(sweep_data.get('sweep_type', 'standard')) + builder.set_sweep_type(sweep_data.get("sweep_type", "standard")) if site_name: builder.set_location(site_name) if examiner_name: builder.set_examiner(examiner_name) # Parse times - started_at = sweep_data.get('started_at') - completed_at = sweep_data.get('completed_at') + started_at = sweep_data.get("started_at") + completed_at = sweep_data.get("completed_at") if started_at: if isinstance(started_at, str): - started_at = datetime.fromisoformat(started_at.replace('Z', '+00:00')).replace(tzinfo=None) + started_at = datetime.fromisoformat(started_at.replace("Z", "+00:00")).replace(tzinfo=None) if completed_at and isinstance(completed_at, str): - completed_at = datetime.fromisoformat(completed_at.replace('Z', '+00:00')).replace(tzinfo=None) + completed_at = datetime.fromisoformat(completed_at.replace("Z", "+00:00")).replace(tzinfo=None) builder.set_sweep_times(started_at, completed_at) # Capabilities @@ -900,43 +872,40 @@ def generate_report( # Apply category filter before building findings if categories: - _cat_map = {'needs_review': {'review', 'needs_review'}} + _cat_map = {"needs_review": {"review", "needs_review"}} allowed = set() for c in categories: allowed |= _cat_map.get(c, {c}) - device_profiles = [ - p for p in device_profiles - if p.get('risk_level', 'informational') in allowed - ] + device_profiles = [p for p in device_profiles if p.get("risk_level", "informational") in allowed] # Add findings from profiles builder.add_findings_from_profiles(device_profiles) # Statistics - results = sweep_data.get('results', {}) - wifi_count = results.get('wifi_count') + results = sweep_data.get("results", {}) + wifi_count = results.get("wifi_count") if wifi_count is None: - wifi_count = len(results.get('wifi_devices', results.get('wifi', []))) + wifi_count = len(results.get("wifi_devices", results.get("wifi", []))) - wifi_client_count = results.get('wifi_client_count') + wifi_client_count = results.get("wifi_client_count") if wifi_client_count is None: - wifi_client_count = len(results.get('wifi_clients', [])) + wifi_client_count = len(results.get("wifi_clients", [])) - bt_count = results.get('bt_count') + bt_count = results.get("bt_count") if bt_count is None: - bt_count = len(results.get('bt_devices', results.get('bluetooth', []))) + bt_count = len(results.get("bt_devices", results.get("bluetooth", []))) - rf_count = results.get('rf_count') + rf_count = results.get("rf_count") if rf_count is None: - rf_count = len(results.get('rf_signals', results.get('rf', []))) + rf_count = len(results.get("rf_signals", results.get("rf", []))) builder.add_statistics( wifi=wifi_count, wifi_clients=wifi_client_count, bluetooth=bt_count, rf=rf_count, - new=baseline_diff.get('summary', {}).get('new_devices', 0) if baseline_diff else 0, - missing=baseline_diff.get('summary', {}).get('missing_devices', 0) if baseline_diff else 0, + new=baseline_diff.get("summary", {}).get("new_devices", 0) if baseline_diff else 0, + missing=baseline_diff.get("summary", {}).get("missing_devices", 0) if baseline_diff else 0, ) # Technical data @@ -955,12 +924,8 @@ def generate_report( # Extract all indicators all_indicators = [] for profile in device_profiles: - for ind in profile.get('indicators', []): - all_indicators.append({ - 'device': profile.get('identifier'), - 'protocol': profile.get('protocol'), - **ind - }) + for ind in profile.get("indicators", []): + all_indicators.append({"device": profile.get("identifier"), "protocol": profile.get("protocol"), **ind}) builder.add_all_indicators(all_indicators) return builder.build() diff --git a/utils/tscm/signal_classification.py b/utils/tscm/signal_classification.py index aba9491..2caeb81 100644 --- a/utils/tscm/signal_classification.py +++ b/utils/tscm/signal_classification.py @@ -16,8 +16,10 @@ from enum import Enum # Signal Strength Classification # ============================================================================= + class SignalStrength(Enum): """Qualitative signal strength labels.""" + MINIMAL = "minimal" WEAK = "weak" MODERATE = "moderate" @@ -27,43 +29,43 @@ class SignalStrength(Enum): # RSSI thresholds (dBm) - upper bounds for each category RSSI_THRESHOLDS = { - SignalStrength.MINIMAL: -85, # -100 to -85 - SignalStrength.WEAK: -70, # -84 to -70 - SignalStrength.MODERATE: -55, # -69 to -55 - SignalStrength.STRONG: -40, # -54 to -40 - SignalStrength.VERY_STRONG: 0, # > -40 + SignalStrength.MINIMAL: -85, # -100 to -85 + SignalStrength.WEAK: -70, # -84 to -70 + SignalStrength.MODERATE: -55, # -69 to -55 + SignalStrength.STRONG: -40, # -54 to -40 + SignalStrength.VERY_STRONG: 0, # > -40 } SIGNAL_STRENGTH_DESCRIPTIONS = { SignalStrength.MINIMAL: { - 'label': 'Minimal', - 'description': 'At detection threshold', - 'interpretation': 'may be ambient noise or distant source', - 'confidence': 'low', + "label": "Minimal", + "description": "At detection threshold", + "interpretation": "may be ambient noise or distant source", + "confidence": "low", }, SignalStrength.WEAK: { - 'label': 'Weak', - 'description': 'Detectable signal', - 'interpretation': 'potentially distant or obstructed', - 'confidence': 'low', + "label": "Weak", + "description": "Detectable signal", + "interpretation": "potentially distant or obstructed", + "confidence": "low", }, SignalStrength.MODERATE: { - 'label': 'Moderate', - 'description': 'Consistent presence', - 'interpretation': 'likely in proximity', - 'confidence': 'medium', + "label": "Moderate", + "description": "Consistent presence", + "interpretation": "likely in proximity", + "confidence": "medium", }, SignalStrength.STRONG: { - 'label': 'Strong', - 'description': 'Clear signal', - 'interpretation': 'probable close proximity', - 'confidence': 'medium', + "label": "Strong", + "description": "Clear signal", + "interpretation": "probable close proximity", + "confidence": "medium", }, SignalStrength.VERY_STRONG: { - 'label': 'Very Strong', - 'description': 'High signal level', - 'interpretation': 'indicates likely nearby source', - 'confidence': 'high', + "label": "Very Strong", + "description": "High signal level", + "interpretation": "indicates likely nearby source", + "confidence": "high", }, } @@ -106,8 +108,8 @@ def get_signal_strength_info(rssi: float | int | None) -> dict: """ strength = classify_signal_strength(rssi) info = SIGNAL_STRENGTH_DESCRIPTIONS[strength].copy() - info['strength'] = strength.value - info['rssi'] = rssi + info["strength"] = strength.value + info["rssi"] = rssi return info @@ -115,8 +117,10 @@ def get_signal_strength_info(rssi: float | int | None) -> dict: # Detection Duration / Confidence Modifiers # ============================================================================= + class DetectionDuration(Enum): """Qualitative duration labels.""" + TRANSIENT = "transient" SHORT = "short" SUSTAINED = "sustained" @@ -125,32 +129,32 @@ class DetectionDuration(Enum): # Duration thresholds (seconds) DURATION_THRESHOLDS = { - DetectionDuration.TRANSIENT: 5, # < 5 seconds - DetectionDuration.SHORT: 30, # 5-30 seconds - DetectionDuration.SUSTAINED: 120, # 30s - 2 min - DetectionDuration.PERSISTENT: float('inf'), # > 2 min + DetectionDuration.TRANSIENT: 5, # < 5 seconds + DetectionDuration.SHORT: 30, # 5-30 seconds + DetectionDuration.SUSTAINED: 120, # 30s - 2 min + DetectionDuration.PERSISTENT: float("inf"), # > 2 min } DURATION_DESCRIPTIONS = { DetectionDuration.TRANSIENT: { - 'label': 'Transient', - 'modifier': 'briefly observed', - 'confidence_impact': 'reduces confidence', + "label": "Transient", + "modifier": "briefly observed", + "confidence_impact": "reduces confidence", }, DetectionDuration.SHORT: { - 'label': 'Short-duration', - 'modifier': 'observed for a short period', - 'confidence_impact': 'limited confidence', + "label": "Short-duration", + "modifier": "observed for a short period", + "confidence_impact": "limited confidence", }, DetectionDuration.SUSTAINED: { - 'label': 'Sustained', - 'modifier': 'observed over sustained period', - 'confidence_impact': 'supports confidence', + "label": "Sustained", + "modifier": "observed over sustained period", + "confidence_impact": "supports confidence", }, DetectionDuration.PERSISTENT: { - 'label': 'Persistent', - 'modifier': 'continuously observed', - 'confidence_impact': 'increases confidence', + "label": "Persistent", + "modifier": "continuously observed", + "confidence_impact": "increases confidence", }, } @@ -187,8 +191,8 @@ def get_duration_info(seconds: float | int | None) -> dict: """Get full duration classification with metadata.""" duration = classify_duration(seconds) info = DURATION_DESCRIPTIONS[duration].copy() - info['duration'] = duration.value - info['seconds'] = seconds + info["duration"] = duration.value + info["seconds"] = seconds return info @@ -196,8 +200,10 @@ def get_duration_info(seconds: float | int | None) -> dict: # Combined Confidence Assessment # ============================================================================= + class ConfidenceLevel(Enum): """Overall detection confidence.""" + LOW = "low" MEDIUM = "medium" HIGH = "high" @@ -206,6 +212,7 @@ class ConfidenceLevel(Enum): @dataclass class SignalAssessment: """Complete signal assessment with confidence-safe language.""" + rssi: float | None duration_seconds: float | None observation_count: int @@ -244,9 +251,7 @@ def assess_signal( duration = classify_duration(duration_seconds) # Calculate confidence based on multiple factors - confidence = _calculate_confidence( - strength, duration, observation_count, has_corroborating_data - ) + confidence = _calculate_confidence(strength, duration, observation_count, has_corroborating_data) strength_info = SIGNAL_STRENGTH_DESCRIPTIONS[strength] duration_info = DURATION_DESCRIPTIONS[duration] @@ -263,8 +268,8 @@ def assess_signal( signal_strength=strength, detection_duration=duration, confidence=confidence, - strength_label=strength_info['label'], - duration_label=duration_info['label'], + strength_label=strength_info["label"], + duration_label=duration_info["label"], summary=summary, interpretation=interpretation, caveats=caveats, @@ -326,10 +331,7 @@ def _build_summary( f"with characteristics that suggest device presence in proximity" ) elif confidence == ConfidenceLevel.MEDIUM: - return ( - f"{strength_info['label']}, {duration_info['label'].lower()} signal " - f"that may indicate device activity" - ) + return f"{strength_info['label']}, {duration_info['label'].lower()} signal that may indicate device activity" else: return ( f"{duration_info['modifier'].capitalize()} {strength_info['label'].lower()} signal " @@ -345,7 +347,7 @@ def _build_interpretation( """Build interpretation text with appropriate hedging.""" strength_info = SIGNAL_STRENGTH_DESCRIPTIONS[strength] - base = strength_info['interpretation'] + base = strength_info["interpretation"] if confidence == ConfidenceLevel.HIGH: return f"Observed signal characteristics suggest {base}" @@ -365,29 +367,22 @@ def _build_caveats( # Always include general caveat caveats.append( - "Signal strength is affected by environmental factors including walls, " - "interference, and device orientation" + "Signal strength is affected by environmental factors including walls, interference, and device orientation" ) # Strength-specific caveats if strength in (SignalStrength.MINIMAL, SignalStrength.WEAK): - caveats.append( - "Weak signals may represent background noise, distant devices, " - "or heavily obstructed sources" - ) + caveats.append("Weak signals may represent background noise, distant devices, or heavily obstructed sources") # Duration-specific caveats if duration == DetectionDuration.TRANSIENT: caveats.append( - "Brief detection may indicate passing device, intermittent transmission, " - "or momentary interference" + "Brief detection may indicate passing device, intermittent transmission, or momentary interference" ) # Confidence-specific caveats if confidence == ConfidenceLevel.LOW: - caveats.append( - "Insufficient data for reliable assessment; additional observation recommended" - ) + caveats.append("Insufficient data for reliable assessment; additional observation recommended") return caveats @@ -396,6 +391,7 @@ def _build_caveats( # Client-Facing Language Generators # ============================================================================= + def describe_signal_for_report( rssi: float | int | None, duration_seconds: float | int | None = None, @@ -418,24 +414,24 @@ def describe_signal_for_report( range_estimate = _estimate_range(rssi) return { - 'headline': f"{assessment.strength_label} {protocol} signal, {assessment.duration_label.lower()}", - 'description': assessment.summary, - 'interpretation': assessment.interpretation, - 'technical': { - 'rssi_dbm': rssi, - 'strength_category': assessment.signal_strength.value, - 'duration_seconds': duration_seconds, - 'duration_category': assessment.detection_duration.value, - 'observations': observation_count, + "headline": f"{assessment.strength_label} {protocol} signal, {assessment.duration_label.lower()}", + "description": assessment.summary, + "interpretation": assessment.interpretation, + "technical": { + "rssi_dbm": rssi, + "strength_category": assessment.signal_strength.value, + "duration_seconds": duration_seconds, + "duration_category": assessment.detection_duration.value, + "observations": observation_count, }, - 'range_estimate': range_estimate, - 'confidence': assessment.confidence.value, - 'confidence_factors': { - 'signal_strength': assessment.strength_label, - 'detection_duration': assessment.duration_label, - 'observation_count': observation_count, + "range_estimate": range_estimate, + "confidence": assessment.confidence.value, + "confidence_factors": { + "signal_strength": assessment.strength_label, + "detection_duration": assessment.duration_label, + "observation_count": observation_count, }, - 'caveats': assessment.caveats, + "caveats": assessment.caveats, } @@ -447,16 +443,16 @@ def _estimate_range(rssi: float | int | None) -> dict: """ if rssi is None: return { - 'estimate': 'Unknown', - 'disclaimer': 'Insufficient signal data for range estimation', + "estimate": "Unknown", + "disclaimer": "Insufficient signal data for range estimation", } try: rssi_val = float(rssi) except (ValueError, TypeError): return { - 'estimate': 'Unknown', - 'disclaimer': 'Invalid signal data', + "estimate": "Unknown", + "disclaimer": "Invalid signal data", } # Very rough estimates based on free-space path loss @@ -478,10 +474,10 @@ def _estimate_range(rssi: float | int | None) -> dict: range_min, range_max = 30, None return { - 'estimate': estimate, - 'range_min_meters': range_min, - 'range_max_meters': range_max, - 'disclaimer': ( + "estimate": estimate, + "range_min_meters": range_min, + "range_max_meters": range_max, + "disclaimer": ( "Range estimates are approximate and significantly affected by " "walls, interference, antenna characteristics, and transmit power" ), @@ -505,19 +501,19 @@ def format_signal_for_dashboard( duration = classify_duration(duration_seconds) colors = { - SignalStrength.MINIMAL: '#888888', # Gray - SignalStrength.WEAK: '#6baed6', # Light blue - SignalStrength.MODERATE: '#3182bd', # Blue - SignalStrength.STRONG: '#fd8d3c', # Orange - SignalStrength.VERY_STRONG: '#e6550d', # Red-orange + SignalStrength.MINIMAL: "#888888", # Gray + SignalStrength.WEAK: "#6baed6", # Light blue + SignalStrength.MODERATE: "#3182bd", # Blue + SignalStrength.STRONG: "#fd8d3c", # Orange + SignalStrength.VERY_STRONG: "#e6550d", # Red-orange } icons = { - SignalStrength.MINIMAL: 'signal-0', - SignalStrength.WEAK: 'signal-1', - SignalStrength.MODERATE: 'signal-2', - SignalStrength.STRONG: 'signal-3', - SignalStrength.VERY_STRONG: 'signal-4', + SignalStrength.MINIMAL: "signal-0", + SignalStrength.WEAK: "signal-1", + SignalStrength.MODERATE: "signal-2", + SignalStrength.STRONG: "signal-3", + SignalStrength.VERY_STRONG: "signal-4", } strength_info = SIGNAL_STRENGTH_DESCRIPTIONS[strength] @@ -528,12 +524,12 @@ def format_signal_for_dashboard( tooltip += f", {duration_info['modifier']}" return { - 'label': strength_info['label'], - 'color': colors[strength], - 'icon': icons[strength], - 'tooltip': tooltip, - 'strength': strength.value, - 'duration': duration.value, + "label": strength_info["label"], + "color": colors[strength], + "icon": icons[strength], + "tooltip": tooltip, + "strength": strength.value, + "duration": duration.value, } @@ -543,38 +539,38 @@ def format_signal_for_dashboard( # Vocabulary for generating hedged statements HEDGED_VERBS = { - 'high_confidence': [ - 'suggests', - 'indicates', - 'is consistent with', + "high_confidence": [ + "suggests", + "indicates", + "is consistent with", ], - 'medium_confidence': [ - 'may indicate', - 'could suggest', - 'is potentially consistent with', + "medium_confidence": [ + "may indicate", + "could suggest", + "is potentially consistent with", ], - 'low_confidence': [ - 'might represent', - 'could possibly indicate', - 'may or may not suggest', + "low_confidence": [ + "might represent", + "could possibly indicate", + "may or may not suggest", ], } HEDGED_CONCLUSIONS = { - 'device_presence': { - 'high': 'likely device presence in proximity', - 'medium': 'possible device activity', - 'low': 'potential device presence, though environmental factors cannot be ruled out', + "device_presence": { + "high": "likely device presence in proximity", + "medium": "possible device activity", + "low": "potential device presence, though environmental factors cannot be ruled out", }, - 'surveillance_indicator': { - 'high': 'characteristics warranting further investigation', - 'medium': 'pattern that may warrant review', - 'low': 'inconclusive pattern requiring additional data', + "surveillance_indicator": { + "high": "characteristics warranting further investigation", + "medium": "pattern that may warrant review", + "low": "inconclusive pattern requiring additional data", }, - 'location': { - 'high': 'probable location within estimated range', - 'medium': 'possible location in general vicinity', - 'low': 'uncertain location; signal could originate from various distances', + "location": { + "high": "probable location within estimated range", + "medium": "possible location in general vicinity", + "low": "uncertain location; signal could originate from various distances", }, } @@ -600,9 +596,9 @@ def generate_hedged_statement( else: conf_key = str(confidence).lower() - verbs = HEDGED_VERBS.get(f'{conf_key}_confidence', HEDGED_VERBS['low_confidence']) + verbs = HEDGED_VERBS.get(f"{conf_key}_confidence", HEDGED_VERBS["low_confidence"]) conclusions = HEDGED_CONCLUSIONS.get(conclusion_type, {}) - conclusion = conclusions.get(conf_key, conclusions.get('low', 'an inconclusive pattern')) + conclusion = conclusions.get(conf_key, conclusions.get("low", "an inconclusive pattern")) verb = verbs[0] # Use first verb for consistency diff --git a/utils/updater.py b/utils/updater.py index ab3cebb..f7d620f 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -20,14 +20,14 @@ from urllib.request import Request, urlopen import config from utils.database import get_setting, set_setting -logger = logging.getLogger('intercept.updater') +logger = logging.getLogger("intercept.updater") # Cache keys for settings -CACHE_KEY_LAST_CHECK = 'update.last_check' -CACHE_KEY_LATEST_VERSION = 'update.latest_version' -CACHE_KEY_RELEASE_URL = 'update.release_url' -CACHE_KEY_RELEASE_NOTES = 'update.release_notes' -CACHE_KEY_DISMISSED_VERSION = 'update.dismissed_version' +CACHE_KEY_LAST_CHECK = "update.last_check" +CACHE_KEY_LATEST_VERSION = "update.latest_version" +CACHE_KEY_RELEASE_URL = "update.release_url" +CACHE_KEY_RELEASE_NOTES = "update.release_notes" +CACHE_KEY_DISMISSED_VERSION = "update.dismissed_version" # Default check interval (6 hours in seconds) DEFAULT_CHECK_INTERVAL = 6 * 60 * 60 @@ -35,18 +35,18 @@ DEFAULT_CHECK_INTERVAL = 6 * 60 * 60 def _get_github_repo() -> str: """Get the configured GitHub repository.""" - return getattr(config, 'GITHUB_REPO', 'smittix/intercept') + return getattr(config, "GITHUB_REPO", "smittix/intercept") def _get_check_interval() -> int: """Get the configured check interval in seconds.""" - hours = getattr(config, 'UPDATE_CHECK_INTERVAL_HOURS', 6) + hours = getattr(config, "UPDATE_CHECK_INTERVAL_HOURS", 6) return hours * 60 * 60 def _is_update_check_enabled() -> bool: """Check if update checking is enabled.""" - return getattr(config, 'UPDATE_CHECK_ENABLED', True) + return getattr(config, "UPDATE_CHECK_ENABLED", True) def _compare_versions(current: str, latest: str) -> int: @@ -58,14 +58,15 @@ def _compare_versions(current: str, latest: str) -> int: 0 if current == latest 1 if current > latest """ + def parse_version(v: str) -> tuple: # Strip 'v' prefix if present - v = v.lstrip('v') + v = v.lstrip("v") # Split by dots and convert to integers parts = [] - for part in v.split('.'): + for part in v.split("."): # Handle pre-release suffixes like 2.11.0-beta - match = re.match(r'^(\d+)', part) + match = re.match(r"^(\d+)", part) if match: parts.append(int(match.group(1))) else: @@ -97,27 +98,24 @@ def _fetch_github_release() -> dict[str, Any] | None: Dict with release info or None on error """ repo = _get_github_repo() - url = f'https://api.github.com/repos/{repo}/releases/latest' + url = f"https://api.github.com/repos/{repo}/releases/latest" try: - req = Request(url, headers={ - 'User-Agent': 'Intercept-SIGINT', - 'Accept': 'application/vnd.github.v3+json' - }) + req = Request(url, headers={"User-Agent": "Intercept-SIGINT", "Accept": "application/vnd.github.v3+json"}) with urlopen(req, timeout=10) as response: # Check rate limit headers - remaining = response.headers.get('X-RateLimit-Remaining') + remaining = response.headers.get("X-RateLimit-Remaining") if remaining and int(remaining) < 10: logger.warning(f"GitHub API rate limit low: {remaining} remaining") - data = json.loads(response.read().decode('utf-8')) + data = json.loads(response.read().decode("utf-8")) return { - 'tag_name': data.get('tag_name', ''), - 'html_url': data.get('html_url', ''), - 'body': data.get('body', ''), - 'published_at': data.get('published_at', ''), - 'name': data.get('name', '') + "tag_name": data.get("tag_name", ""), + "html_url": data.get("html_url", ""), + "body": data.get("body", ""), + "published_at": data.get("published_at", ""), + "name": data.get("name", ""), } except HTTPError as e: if e.code == 404: @@ -148,12 +146,7 @@ def check_for_updates(force: bool = False) -> dict[str, Any]: Dict with update status information """ if not _is_update_check_enabled(): - return { - 'success': True, - 'update_available': False, - 'disabled': True, - 'message': 'Update checking is disabled' - } + return {"success": True, "update_available": False, "disabled": True, "message": "Update checking is disabled"} current_version = config.VERSION @@ -175,16 +168,16 @@ def check_for_updates(force: bool = False) -> dict[str, Any]: show_notification = update_available and dismissed != cached_version return { - 'success': True, - 'checked': True, - 'update_available': update_available, - 'show_notification': show_notification, - 'current_version': current_version, - 'latest_version': cached_version, - 'release_url': get_setting(CACHE_KEY_RELEASE_URL) or '', - 'release_notes': get_setting(CACHE_KEY_RELEASE_NOTES) or '', - 'cached': True, - 'last_check': datetime.fromtimestamp(last_check_time).isoformat() + "success": True, + "checked": True, + "update_available": update_available, + "show_notification": show_notification, + "current_version": current_version, + "latest_version": cached_version, + "release_url": get_setting(CACHE_KEY_RELEASE_URL) or "", + "release_notes": get_setting(CACHE_KEY_RELEASE_NOTES) or "", + "cached": True, + "last_check": datetime.fromtimestamp(last_check_time).isoformat(), } except (ValueError, TypeError): pass @@ -198,46 +191,43 @@ def check_for_updates(force: bool = False) -> dict[str, Any]: if cached_version: update_available = _compare_versions(current_version, cached_version) < 0 return { - 'success': True, - 'checked': True, - 'update_available': update_available, - 'current_version': current_version, - 'latest_version': cached_version, - 'release_url': get_setting(CACHE_KEY_RELEASE_URL) or '', - 'release_notes': get_setting(CACHE_KEY_RELEASE_NOTES) or '', - 'cached': True, - 'network_error': True + "success": True, + "checked": True, + "update_available": update_available, + "current_version": current_version, + "latest_version": cached_version, + "release_url": get_setting(CACHE_KEY_RELEASE_URL) or "", + "release_notes": get_setting(CACHE_KEY_RELEASE_NOTES) or "", + "cached": True, + "network_error": True, } - return { - 'success': False, - 'error': 'Failed to check for updates' - } + return {"success": False, "error": "Failed to check for updates"} - latest_version = release['tag_name'].lstrip('v') + latest_version = release["tag_name"].lstrip("v") # Update cache set_setting(CACHE_KEY_LAST_CHECK, str(time.time())) set_setting(CACHE_KEY_LATEST_VERSION, latest_version) - set_setting(CACHE_KEY_RELEASE_URL, release['html_url']) - set_setting(CACHE_KEY_RELEASE_NOTES, release['body'][:2000] if release['body'] else '') + set_setting(CACHE_KEY_RELEASE_URL, release["html_url"]) + set_setting(CACHE_KEY_RELEASE_NOTES, release["body"][:2000] if release["body"] else "") update_available = _compare_versions(current_version, latest_version) < 0 dismissed = get_setting(CACHE_KEY_DISMISSED_VERSION) show_notification = update_available and dismissed != latest_version return { - 'success': True, - 'checked': True, - 'update_available': update_available, - 'show_notification': show_notification, - 'current_version': current_version, - 'latest_version': latest_version, - 'release_url': release['html_url'], - 'release_notes': release['body'] or '', - 'release_name': release['name'] or f'v{latest_version}', - 'published_at': release['published_at'], - 'cached': False, - 'last_check': datetime.now().isoformat() + "success": True, + "checked": True, + "update_available": update_available, + "show_notification": show_notification, + "current_version": current_version, + "latest_version": latest_version, + "release_url": release["html_url"], + "release_notes": release["body"] or "", + "release_name": release["name"] or f"v{latest_version}", + "published_at": release["published_at"], + "cached": False, + "last_check": datetime.now().isoformat(), } @@ -254,11 +244,7 @@ def get_update_status() -> dict[str, Any]: dismissed = get_setting(CACHE_KEY_DISMISSED_VERSION) if not cached_version: - return { - 'success': True, - 'checked': False, - 'current_version': current_version - } + return {"success": True, "checked": False, "current_version": current_version} update_available = _compare_versions(current_version, cached_version) < 0 show_notification = update_available and dismissed != cached_version @@ -269,16 +255,16 @@ def get_update_status() -> dict[str, Any]: last_check_time = datetime.fromtimestamp(float(last_check)).isoformat() return { - 'success': True, - 'checked': True, - 'update_available': update_available, - 'show_notification': show_notification, - 'current_version': current_version, - 'latest_version': cached_version, - 'release_url': get_setting(CACHE_KEY_RELEASE_URL) or '', - 'release_notes': get_setting(CACHE_KEY_RELEASE_NOTES) or '', - 'dismissed_version': dismissed, - 'last_check': last_check_time + "success": True, + "checked": True, + "update_available": update_available, + "show_notification": show_notification, + "current_version": current_version, + "latest_version": cached_version, + "release_url": get_setting(CACHE_KEY_RELEASE_URL) or "", + "release_notes": get_setting(CACHE_KEY_RELEASE_NOTES) or "", + "dismissed_version": dismissed, + "last_check": last_check_time, } @@ -293,23 +279,20 @@ def dismiss_update(version: str) -> dict[str, Any]: Status dict """ set_setting(CACHE_KEY_DISMISSED_VERSION, version) - return { - 'success': True, - 'dismissed_version': version - } + return {"success": True, "dismissed_version": version} def _is_git_repo() -> bool: """Check if the current directory is a git repository.""" try: result = subprocess.run( - ['git', 'rev-parse', '--is-inside-work-tree'], + ["git", "rev-parse", "--is-inside-work-tree"], capture_output=True, text=True, timeout=5, - cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ) - return result.returncode == 0 and result.stdout.strip() == 'true' + return result.returncode == 0 and result.stdout.strip() == "true" except Exception: return False @@ -321,39 +304,26 @@ def _get_git_status() -> dict[str, Any]: try: # Check for uncommitted changes result = subprocess.run( - ['git', 'status', '--porcelain'], - capture_output=True, - text=True, - timeout=10, - cwd=repo_root + ["git", "status", "--porcelain"], capture_output=True, text=True, timeout=10, cwd=repo_root ) has_changes = bool(result.stdout.strip()) - changed_files = result.stdout.strip().split('\n') if has_changes else [] + changed_files = result.stdout.strip().split("\n") if has_changes else [] # Get current branch branch_result = subprocess.run( - ['git', 'branch', '--show-current'], - capture_output=True, - text=True, - timeout=5, - cwd=repo_root + ["git", "branch", "--show-current"], capture_output=True, text=True, timeout=5, cwd=repo_root ) - current_branch = branch_result.stdout.strip() or 'main' + current_branch = branch_result.stdout.strip() or "main" return { - 'has_changes': has_changes, - 'changed_files': [f for f in changed_files if f], - 'current_branch': current_branch + "has_changes": has_changes, + "changed_files": [f for f in changed_files if f], + "current_branch": current_branch, } except Exception as e: logger.warning(f"Error getting git status: {e}") - return { - 'has_changes': False, - 'changed_files': [], - 'current_branch': 'unknown', - 'error': str(e) - } + return {"has_changes": False, "changed_files": [], "current_branch": "unknown", "error": str(e)} def perform_update(stash_changes: bool = False) -> dict[str, Any]: @@ -371,115 +341,92 @@ def perform_update(stash_changes: bool = False) -> dict[str, Any]: # Check if this is a git repo if not _is_git_repo(): return { - 'success': False, - 'error': 'Not a git repository', - 'manual_update': True, - 'message': 'This installation is not using git. Please update manually by downloading the latest release from GitHub.' + "success": False, + "error": "Not a git repository", + "manual_update": True, + "message": "This installation is not using git. Please update manually by downloading the latest release from GitHub.", } git_status = _get_git_status() # Check for local changes - if git_status['has_changes'] and not stash_changes: + if git_status["has_changes"] and not stash_changes: return { - 'success': False, - 'error': 'local_changes', - 'message': 'You have uncommitted local changes. Either commit them, discard them, or enable "stash changes" to temporarily save them.', - 'changed_files': git_status['changed_files'] + "success": False, + "error": "local_changes", + "message": 'You have uncommitted local changes. Either commit them, discard them, or enable "stash changes" to temporarily save them.', + "changed_files": git_status["changed_files"], } try: # Stash changes if requested stashed = False - if stash_changes and git_status['has_changes']: + if stash_changes and git_status["has_changes"]: stash_result = subprocess.run( - ['git', 'stash', 'push', '-m', 'INTERCEPT auto-stash before update'], + ["git", "stash", "push", "-m", "INTERCEPT auto-stash before update"], capture_output=True, text=True, timeout=30, - cwd=repo_root + cwd=repo_root, ) if stash_result.returncode == 0: stashed = True logger.info("Stashed local changes before update") else: - return { - 'success': False, - 'error': 'Failed to stash changes', - 'details': stash_result.stderr - } + return {"success": False, "error": "Failed to stash changes", "details": stash_result.stderr} # Get current requirements.txt hash to detect changes - req_path = os.path.join(repo_root, 'requirements.txt') + req_path = os.path.join(repo_root, "requirements.txt") req_hash_before = None if os.path.exists(req_path): - with open(req_path, 'rb') as f: + with open(req_path, "rb") as f: import hashlib + req_hash_before = hashlib.md5(f.read()).hexdigest() # Fetch latest changes fetch_result = subprocess.run( - ['git', 'fetch', 'origin'], - capture_output=True, - text=True, - timeout=60, - cwd=repo_root + ["git", "fetch", "origin"], capture_output=True, text=True, timeout=60, cwd=repo_root ) if fetch_result.returncode != 0: # Restore stash if we stashed if stashed: - subprocess.run(['git', 'stash', 'pop'], cwd=repo_root, timeout=30) - return { - 'success': False, - 'error': 'Failed to fetch updates', - 'details': fetch_result.stderr - } + subprocess.run(["git", "stash", "pop"], cwd=repo_root, timeout=30) + return {"success": False, "error": "Failed to fetch updates", "details": fetch_result.stderr} # Get the main branch name - branch = git_status.get('current_branch', 'main') + branch = git_status.get("current_branch", "main") # Pull changes pull_result = subprocess.run( - ['git', 'pull', 'origin', branch], - capture_output=True, - text=True, - timeout=120, - cwd=repo_root + ["git", "pull", "origin", branch], capture_output=True, text=True, timeout=120, cwd=repo_root ) if pull_result.returncode != 0: # Check for merge conflict - if 'CONFLICT' in pull_result.stdout or 'CONFLICT' in pull_result.stderr: + if "CONFLICT" in pull_result.stdout or "CONFLICT" in pull_result.stderr: # Abort merge - subprocess.run(['git', 'merge', '--abort'], cwd=repo_root, timeout=30) + subprocess.run(["git", "merge", "--abort"], cwd=repo_root, timeout=30) # Restore stash if we stashed if stashed: - subprocess.run(['git', 'stash', 'pop'], cwd=repo_root, timeout=30) + subprocess.run(["git", "stash", "pop"], cwd=repo_root, timeout=30) return { - 'success': False, - 'error': 'merge_conflict', - 'message': 'Merge conflict detected. The update was aborted. Please resolve conflicts manually or reset to a clean state.', - 'details': pull_result.stdout + pull_result.stderr + "success": False, + "error": "merge_conflict", + "message": "Merge conflict detected. The update was aborted. Please resolve conflicts manually or reset to a clean state.", + "details": pull_result.stdout + pull_result.stderr, } # Restore stash if we stashed if stashed: - subprocess.run(['git', 'stash', 'pop'], cwd=repo_root, timeout=30) - return { - 'success': False, - 'error': 'Failed to pull updates', - 'details': pull_result.stderr - } + subprocess.run(["git", "stash", "pop"], cwd=repo_root, timeout=30) + return {"success": False, "error": "Failed to pull updates", "details": pull_result.stderr} # Restore stashed changes if stashed: stash_pop_result = subprocess.run( - ['git', 'stash', 'pop'], - capture_output=True, - text=True, - timeout=30, - cwd=repo_root + ["git", "stash", "pop"], capture_output=True, text=True, timeout=30, cwd=repo_root ) if stash_pop_result.returncode != 0: logger.warning(f"Failed to restore stashed changes: {stash_pop_result.stderr}") @@ -487,47 +434,40 @@ def perform_update(stash_changes: bool = False) -> dict[str, Any]: # Check if requirements changed requirements_changed = False if req_hash_before and os.path.exists(req_path): - with open(req_path, 'rb') as f: + with open(req_path, "rb") as f: import hashlib + req_hash_after = hashlib.md5(f.read()).hexdigest() requirements_changed = req_hash_before != req_hash_after # Determine if update actually happened - if 'Already up to date' in pull_result.stdout: - return { - 'success': True, - 'updated': False, - 'message': 'Already up to date', - 'stashed': stashed - } + if "Already up to date" in pull_result.stdout: + return {"success": True, "updated": False, "message": "Already up to date", "stashed": stashed} # Clear update cache to reflect new version - set_setting(CACHE_KEY_LAST_CHECK, '') - set_setting(CACHE_KEY_LATEST_VERSION, '') + set_setting(CACHE_KEY_LAST_CHECK, "") + set_setting(CACHE_KEY_LATEST_VERSION, "") return { - 'success': True, - 'updated': True, - 'message': 'Update successful! Please restart the application.', - 'restart_required': True, - 'requirements_changed': requirements_changed, - 'stashed': stashed, - 'stash_restored': stashed, - 'output': pull_result.stdout + "success": True, + "updated": True, + "message": "Update successful! Please restart the application.", + "restart_required": True, + "requirements_changed": requirements_changed, + "stashed": stashed, + "stash_restored": stashed, + "output": pull_result.stdout, } except subprocess.TimeoutExpired: return { - 'success': False, - 'error': 'Operation timed out', - 'message': 'The update operation timed out. Please check your network connection and try again.' + "success": False, + "error": "Operation timed out", + "message": "The update operation timed out. Please check your network connection and try again.", } except Exception as e: logger.error(f"Update error: {e}") - return { - 'success': False, - 'error': str(e) - } + return {"success": False, "error": str(e)} def restart_application() -> dict[str, Any]: @@ -599,12 +539,8 @@ def restart_application() -> dict[str, Any]: os.execv(python_executable, args) # This code is never reached - return {'success': True, 'message': 'Restarting...'} + return {"success": True, "message": "Restarting..."} except Exception as e: logger.error(f"Restart failed: {e}") - return { - 'success': False, - 'error': str(e), - 'message': 'Failed to restart application. Please restart manually.' - } + return {"success": False, "error": str(e), "message": "Failed to restart application. Please restart manually."} diff --git a/utils/validation.py b/utils/validation.py index ece0b53..10459cb 100644 --- a/utils/validation.py +++ b/utils/validation.py @@ -9,17 +9,17 @@ from typing import Any def escape_html(text: str | None) -> str: """Escape HTML special characters to prevent XSS attacks.""" if text is None: - return '' + return "" if not isinstance(text, str): text = str(text) html_escape_table = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", } - return ''.join(html_escape_table.get(c, c) for c in text) + return "".join(html_escape_table.get(c, c) for c in text) def validate_latitude(lat: Any) -> float: @@ -74,7 +74,7 @@ def validate_rtl_tcp_host(host: Any) -> str: if not host: raise ValueError("rtl_tcp host cannot be empty") # Allow alphanumeric, dots, hyphens (valid for hostnames and IPs) - if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9.\-]*$', host): + if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9.\-]*$", host): raise ValueError(f"Invalid rtl_tcp host: {host}") if len(host) > 253: raise ValueError("rtl_tcp host too long") @@ -160,12 +160,12 @@ def validate_mac_address(mac: Any) -> str: if not mac or not isinstance(mac, str): raise ValueError("MAC address is required") mac = mac.upper().strip() - if not re.match(r'^([0-9A-F]{2}:){5}[0-9A-F]{2}$', mac): + if not re.match(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$", mac): raise ValueError(f"Invalid MAC address format: {mac}") return mac -def validate_positive_int(value: Any, name: str = 'value', max_val: int | None = None) -> int: +def validate_positive_int(value: Any, name: str = "value", max_val: int | None = None) -> int: """Validate and return a positive integer.""" try: val_int = int(value) @@ -181,15 +181,15 @@ def validate_positive_int(value: Any, name: str = 'value', max_val: int | None = def sanitize_callsign(callsign: str | None) -> str: """Sanitize aircraft callsign for display.""" if not callsign: - return '' + return "" # Only allow alphanumeric, dash, and space - return re.sub(r'[^A-Za-z0-9\- ]', '', str(callsign))[:10] + return re.sub(r"[^A-Za-z0-9\- ]", "", str(callsign))[:10] def sanitize_ssid(ssid: str | None) -> str: """Sanitize WiFi SSID for display.""" if not ssid: - return '' + return "" # Escape HTML and limit length return escape_html(str(ssid)[:64]) @@ -197,7 +197,7 @@ def sanitize_ssid(ssid: str | None) -> str: def sanitize_device_name(name: str | None) -> str: """Sanitize Bluetooth device name for display.""" if not name: - return '' + return "" # Escape HTML and limit length return escape_html(str(name)[:64]) @@ -232,7 +232,7 @@ def validate_network_interface(name: Any) -> str: raise ValueError(f"Interface name too long (max 15 chars): {name}") # Must start with letter, contain only alphanumeric/underscore/hyphen - if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', name): + if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name): raise ValueError(f"Invalid interface name: {name}") return name @@ -257,7 +257,7 @@ def validate_bluetooth_interface(name: Any) -> str: name = name.strip() # Must be hciX format where X is a number 0-255 - if not re.match(r'^hci([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$', name): + if not re.match(r"^hci([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$", name): raise ValueError(f"Invalid Bluetooth interface name (expected hciX): {name}") return name diff --git a/utils/waterfall_fft.py b/utils/waterfall_fft.py index d8db37b..6c4e41a 100644 --- a/utils/waterfall_fft.py +++ b/utils/waterfall_fft.py @@ -132,5 +132,5 @@ def build_binary_frame( Binary frame bytes. """ bin_count = len(quantized_bins) - header = struct.pack(' dict[str, Any]: return { - 'id': self.id, - 'satellite': self.satellite, - 'name': self.name, - 'frequency': self.frequency, - 'mode': self.mode, - 'startTimeISO': self.start_time, - 'endTimeISO': self.end_time, - 'maxEl': self.max_el, - 'duration': self.duration, - 'quality': self.quality, - 'status': self.status, - 'skipped': self.skipped, + "id": self.id, + "satellite": self.satellite, + "name": self.name, + "frequency": self.frequency, + "mode": self.mode, + "startTimeISO": self.start_time, + "endTimeISO": self.end_time, + "maxEl": self.max_el, + "duration": self.duration, + "quality": self.quality, + "status": self.status, + "skipped": self.skipped, } @@ -160,24 +160,26 @@ class WeatherSatScheduler: self._passes.clear() logger.info("Weather satellite auto-scheduler disabled") - return {'status': 'disabled'} + return {"status": "disabled"} def skip_pass(self, pass_id: str) -> bool: """Manually skip a scheduled pass.""" with self._lock: for p in self._passes: - if p.id == pass_id and p.status == 'scheduled': + if p.id == pass_id and p.status == "scheduled": p.skipped = True - p.status = 'skipped' + p.status = "skipped" if p._timer: p._timer.cancel() p._timer = None logger.info(f"Skipped pass: {p.satellite} at {p.start_time}") - self._emit_event({ - 'type': 'schedule_capture_skipped', - 'pass': p.to_dict(), - 'reason': 'manual', - }) + self._emit_event( + { + "type": "schedule_capture_skipped", + "pass": p.to_dict(), + "reason": "manual", + } + ) return True return False @@ -185,16 +187,14 @@ class WeatherSatScheduler: """Get current scheduler status.""" with self._lock: return { - 'enabled': self._enabled, - 'observer': {'latitude': self._lat, 'longitude': self._lon}, - 'device': self._device, - 'gain': self._gain, - 'bias_t': self._bias_t, - 'min_elevation': self._min_elevation, - 'scheduled_count': sum( - 1 for p in self._passes if p.status == 'scheduled' - ), - 'total_passes': len(self._passes), + "enabled": self._enabled, + "observer": {"latitude": self._lat, "longitude": self._lon}, + "device": self._device, + "gain": self._gain, + "bias_t": self._bias_t, + "min_elevation": self._min_elevation, + "scheduled_count": sum(1 for p in self._passes if p.status == "scheduled"), + "total_passes": len(self._passes), } def get_passes(self) -> list[dict[str, Any]]: @@ -229,7 +229,7 @@ class WeatherSatScheduler: p._stop_timer.cancel() # Keep completed/skipped for history, replace scheduled - history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')] + history = [p for p in self._passes if p.status in ("complete", "skipped", "capturing")] self._passes = history now = datetime.now(timezone.utc) @@ -264,8 +264,7 @@ class WeatherSatScheduler: self._passes.append(sp) logger.info( - f"Scheduler refreshed: {sum(1 for p in self._passes if p.status == 'scheduled')} " - f"passes scheduled" + f"Scheduler refreshed: {sum(1 for p in self._passes if p.status == 'scheduled')} passes scheduled" ) # Schedule next refresh @@ -287,33 +286,38 @@ class WeatherSatScheduler: if decoder.is_running: logger.info(f"SDR busy, skipping scheduled pass: {sp.satellite}") - sp.status = 'skipped' + sp.status = "skipped" sp.skipped = True - self._emit_event({ - 'type': 'schedule_capture_skipped', - 'pass': sp.to_dict(), - 'reason': 'sdr_busy', - }) + self._emit_event( + { + "type": "schedule_capture_skipped", + "pass": sp.to_dict(), + "reason": "sdr_busy", + } + ) return # Claim SDR device try: import app as app_module - error = app_module.claim_sdr_device(self._device, 'weather_sat') + + error = app_module.claim_sdr_device(self._device, "weather_sat") if error: logger.info(f"SDR device busy, skipping: {sp.satellite} - {error}") - sp.status = 'skipped' + sp.status = "skipped" sp.skipped = True - self._emit_event({ - 'type': 'schedule_capture_skipped', - 'pass': sp.to_dict(), - 'reason': 'device_busy', - }) + self._emit_event( + { + "type": "schedule_capture_skipped", + "pass": sp.to_dict(), + "reason": "device_busy", + } + ) return except ImportError: pass - sp.status = 'capturing' + sp.status = "capturing" # Set up callbacks if self._progress_callback: @@ -322,14 +326,15 @@ class WeatherSatScheduler: def _release_device(): try: import app as app_module + owner = None - get_status = getattr(app_module, 'get_sdr_device_status', None) + get_status = getattr(app_module, "get_sdr_device_status", None) if callable(get_status): try: owner = get_status().get(self._device) except Exception: owner = None - if owner and owner != 'weather_sat': + if owner and owner != "weather_sat": logger.debug( "Skipping SDR release for device %s owned by %s", self._device, @@ -352,10 +357,12 @@ class WeatherSatScheduler: if success: logger.info(f"Auto-scheduler started capture: {sp.satellite}") - self._emit_event({ - 'type': 'schedule_capture_start', - 'pass': sp.to_dict(), - }) + self._emit_event( + { + "type": "schedule_capture_start", + "pass": sp.to_dict(), + } + ) # Schedule stop timer at pass end + buffer now = datetime.now(timezone.utc) @@ -365,13 +372,15 @@ class WeatherSatScheduler: sp._stop_timer.daemon = True sp._stop_timer.start() else: - sp.status = 'skipped' + sp.status = "skipped" _release_device() - self._emit_event({ - 'type': 'schedule_capture_skipped', - 'pass': sp.to_dict(), - 'reason': 'start_failed', - }) + self._emit_event( + { + "type": "schedule_capture_skipped", + "pass": sp.to_dict(), + "reason": "start_failed", + } + ) def _stop_capture(self, sp: ScheduledPass) -> None: """Stop capture at pass end.""" @@ -382,12 +391,14 @@ class WeatherSatScheduler: def _on_capture_complete(self, sp: ScheduledPass, release_fn: Callable) -> None: """Handle capture completion.""" - sp.status = 'complete' + sp.status = "complete" release_fn() - self._emit_event({ - 'type': 'schedule_capture_complete', - 'pass': sp.to_dict(), - }) + self._emit_event( + { + "type": "schedule_capture_complete", + "pass": sp.to_dict(), + } + ) def _emit_event(self, event: dict[str, Any]) -> None: """Emit scheduler event to callback.""" @@ -405,10 +416,10 @@ def _parse_utc_iso(value: str) -> datetime: text = str(value).strip() # Backward compatibility for malformed legacy strings. - text = text.replace('+00:00Z', 'Z') + text = text.replace("+00:00Z", "Z") # Python <3.11 does not accept trailing 'Z' in fromisoformat. - if text.endswith('Z'): - text = text[:-1] + '+00:00' + if text.endswith("Z"): + text = text[:-1] + "+00:00" dt = datetime.fromisoformat(text) if dt.tzinfo is None: diff --git a/utils/wefax.py b/utils/wefax.py index cfc1465..d9bbd0d 100644 --- a/utils/wefax.py +++ b/utils/wefax.py @@ -33,7 +33,7 @@ from utils.dependencies import get_tool_path from utils.logging import get_logger from utils.sdr import SDRFactory, SDRType -logger = get_logger('intercept.wefax') +logger = get_logger("intercept.wefax") try: from PIL import Image as PILImage @@ -43,16 +43,16 @@ except ImportError: # --------------------------------------------------------------------------- # WeFax protocol constants # --------------------------------------------------------------------------- -CARRIER_FREQ = 1900.0 # Hz - center/carrier -BLACK_FREQ = 1500.0 # Hz - black level -WHITE_FREQ = 2300.0 # Hz - white level -START_TONE_FREQ = 300.0 # Hz - start tone -STOP_TONE_FREQ = 450.0 # Hz - stop tone -PHASING_FREQ = WHITE_FREQ # White pulse during phasing +CARRIER_FREQ = 1900.0 # Hz - center/carrier +BLACK_FREQ = 1500.0 # Hz - black level +WHITE_FREQ = 2300.0 # Hz - white level +START_TONE_FREQ = 300.0 # Hz - start tone +STOP_TONE_FREQ = 450.0 # Hz - stop tone +PHASING_FREQ = WHITE_FREQ # White pulse during phasing -START_TONE_DURATION = 3.0 # Minimum seconds of start tone to detect -STOP_TONE_DURATION = 3.0 # Minimum seconds of stop tone to detect -PHASING_MIN_LINES = 5 # Minimum phasing lines before image +START_TONE_DURATION = 3.0 # Minimum seconds of start tone to detect +STOP_TONE_DURATION = 3.0 # Minimum seconds of stop tone to detect +PHASING_MIN_LINES = 5 # Minimum phasing lines before image DEFAULT_SAMPLE_RATE = 22050 DEFAULT_IOC = 576 @@ -61,20 +61,23 @@ DEFAULT_LPM = 120 class DecoderState(Enum): """WeFax decoder state machine states.""" - SCANNING = 'scanning' - START_DETECTED = 'start_detected' - PHASING = 'phasing' - RECEIVING = 'receiving' - COMPLETE = 'complete' + + SCANNING = "scanning" + START_DETECTED = "start_detected" + PHASING = "phasing" + RECEIVING = "receiving" + COMPLETE = "complete" # --------------------------------------------------------------------------- # Dataclasses # --------------------------------------------------------------------------- + @dataclass class WeFaxImage: """Decoded WeFax image metadata.""" + filename: str path: Path station: str @@ -86,24 +89,25 @@ class WeFaxImage: def to_dict(self) -> dict: return { - 'filename': self.filename, - 'path': str(self.path), - 'station': self.station, - 'frequency_khz': self.frequency_khz, - 'timestamp': self.timestamp.isoformat(), - 'ioc': self.ioc, - 'lpm': self.lpm, - 'size_bytes': self.size_bytes, - 'url': f'/wefax/images/{self.filename}', + "filename": self.filename, + "path": str(self.path), + "station": self.station, + "frequency_khz": self.frequency_khz, + "timestamp": self.timestamp.isoformat(), + "ioc": self.ioc, + "lpm": self.lpm, + "size_bytes": self.size_bytes, + "url": f"/wefax/images/{self.filename}", } @dataclass class WeFaxProgress: """WeFax decode progress update for SSE streaming.""" + status: str # 'scanning', 'phasing', 'receiving', 'complete', 'error', 'stopped' - station: str = '' - message: str = '' + station: str = "" + message: str = "" progress_percent: int = 0 line_count: int = 0 image: WeFaxImage | None = None @@ -111,20 +115,20 @@ class WeFaxProgress: def to_dict(self) -> dict: result: dict = { - 'type': 'wefax_progress', - 'status': self.status, - 'progress': self.progress_percent, + "type": "wefax_progress", + "status": self.status, + "progress": self.progress_percent, } if self.station: - result['station'] = self.station + result["station"] = self.station if self.message: - result['message'] = self.message + result["message"] = self.message if self.line_count: - result['line_count'] = self.line_count + result["line_count"] = self.line_count if self.image: - result['image'] = self.image.to_dict() + result["image"] = self.image.to_dict() if self.partial_image: - result['partial_image'] = self.partial_image + result["partial_image"] = self.partial_image return result @@ -132,8 +136,8 @@ class WeFaxProgress: # DSP helpers (reuse Goertzel from SSTV where sensible) # --------------------------------------------------------------------------- -def _goertzel_mag(samples: np.ndarray, target_freq: float, - sample_rate: int) -> float: + +def _goertzel_mag(samples: np.ndarray, target_freq: float, sample_rate: int) -> float: """Compute Goertzel magnitude at a single frequency.""" n = len(samples) if n == 0: @@ -159,9 +163,9 @@ def _freq_to_pixel(frequency: float) -> int: return max(0, min(255, int(normalized * 255 + 0.5))) -def _estimate_frequency(samples: np.ndarray, sample_rate: int, - freq_low: float = 1200.0, - freq_high: float = 2500.0) -> float: +def _estimate_frequency( + samples: np.ndarray, sample_rate: int, freq_low: float = 1200.0, freq_high: float = 2500.0 +) -> float: """Estimate dominant frequency using coarse+fine Goertzel sweep.""" if len(samples) == 0: return 0.0 @@ -192,8 +196,7 @@ def _estimate_frequency(samples: np.ndarray, sample_rate: int, return best_freq -def _detect_tone(samples: np.ndarray, target_freq: float, - sample_rate: int, threshold: float = 3.0) -> bool: +def _detect_tone(samples: np.ndarray, target_freq: float, sample_rate: int, threshold: float = 3.0) -> bool: """Detect if a specific tone dominates the signal.""" target_mag = _goertzel_mag(samples, target_freq, sample_rate) # Check against a few reference frequencies @@ -211,6 +214,7 @@ def _detect_tone(samples: np.ndarray, target_freq: float, # WeFaxDecoder # --------------------------------------------------------------------------- + class WeFaxDecoder: """WeFax decoder singleton. @@ -225,12 +229,12 @@ class WeFaxDecoder: self._lock = threading.Lock() self._callback: Callable[[dict], None] | None = None self._last_scope_time: float = 0.0 - self._output_dir = Path('instance/wefax_images') + self._output_dir = Path("instance/wefax_images") self._images: list[WeFaxImage] = [] self._decode_thread: threading.Thread | None = None # Current session parameters - self._station = '' + self._station = "" self._frequency_khz = 0.0 self._ioc = DEFAULT_IOC self._lpm = DEFAULT_LPM @@ -240,8 +244,8 @@ class WeFaxDecoder: self._direct_sampling = True self._output_dir.mkdir(parents=True, exist_ok=True) - self._sdr_tool_name: str = 'rtl_fm' - self._last_error: str = '' + self._sdr_tool_name: str = "rtl_fm" + self._last_error: str = "" @property def is_running(self) -> bool: @@ -259,13 +263,13 @@ class WeFaxDecoder: def start( self, frequency_khz: float, - station: str = '', + station: str = "", device_index: int = 0, gain: float = 40.0, ioc: int = DEFAULT_IOC, lpm: int = DEFAULT_LPM, direct_sampling: bool = True, - sdr_type: str = 'rtlsdr', + sdr_type: str = "rtlsdr", ) -> bool: """Start WeFax decoder. @@ -298,35 +302,36 @@ class WeFaxDecoder: try: self._running = True - self._last_error = '' + self._last_error = "" self._start_pipeline_spawn() except Exception as e: self._running = False self._last_error = str(e) logger.error(f"Failed to start WeFax decoder: {e}") - self._emit_progress(WeFaxProgress( - status='error', - message=str(e), - )) + self._emit_progress( + WeFaxProgress( + status="error", + message=str(e), + ) + ) return False # Health check sleep outside lock try: self._start_pipeline_health_check() - logger.info( - f"WeFax decoder started: {frequency_khz} kHz, " - f"station={station}, IOC={ioc}, LPM={lpm}" - ) + logger.info(f"WeFax decoder started: {frequency_khz} kHz, station={station}, IOC={ioc}, LPM={lpm}") return True except Exception as e: with self._lock: self._running = False self._last_error = str(e) logger.error(f"Failed to start WeFax decoder: {e}") - self._emit_progress(WeFaxProgress( - status='error', - message=str(e), - )) + self._emit_progress( + WeFaxProgress( + status="error", + message=str(e), + ) + ) return False def _start_pipeline(self) -> None: @@ -343,33 +348,32 @@ class WeFaxDecoder: # Validate that the required tool is available if sdr_type_enum == SDRType.RTL_SDR: - if not get_tool_path('rtl_fm'): - raise RuntimeError('rtl_fm not found') + if not get_tool_path("rtl_fm"): + raise RuntimeError("rtl_fm not found") else: - if not get_tool_path('rx_fm'): - raise RuntimeError('rx_fm not found (required for non-RTL-SDR devices)') + if not get_tool_path("rx_fm"): + raise RuntimeError("rx_fm not found (required for non-RTL-SDR devices)") - sdr_device = SDRFactory.create_default_device( - sdr_type_enum, index=self._device_index) + sdr_device = SDRFactory.create_default_device(sdr_type_enum, index=self._device_index) builder = SDRFactory.get_builder(sdr_type_enum) rtl_cmd = builder.build_fm_demod_command( device=sdr_device, frequency_mhz=self._frequency_khz / 1000.0, sample_rate=self._sample_rate, gain=self._gain, - modulation='usb', + modulation="usb", ) # RTL-SDR: append direct sampling flag for HF reception if sdr_type_enum == SDRType.RTL_SDR and self._direct_sampling: # Insert before trailing '-' stdout marker - if rtl_cmd and rtl_cmd[-1] == '-': - rtl_cmd.insert(-1, '-E') - rtl_cmd.insert(-1, 'direct2') + if rtl_cmd and rtl_cmd[-1] == "-": + rtl_cmd.insert(-1, "-E") + rtl_cmd.insert(-1, "direct2") else: - rtl_cmd.extend(['-E', 'direct2', '-']) + rtl_cmd.extend(["-E", "direct2", "-"]) - self._sdr_tool_name = rtl_cmd[0] if rtl_cmd else 'sdr' + self._sdr_tool_name = rtl_cmd[0] if rtl_cmd else "sdr" logger.info(f"Starting {self._sdr_tool_name}: {' '.join(rtl_cmd)}") self._sdr_process = subprocess.Popen( @@ -384,17 +388,15 @@ class WeFaxDecoder: with self._lock: if self._sdr_process and self._sdr_process.poll() is not None: - stderr_detail = '' + stderr_detail = "" if self._sdr_process.stderr: - stderr_detail = self._sdr_process.stderr.read().decode( - errors='replace').strip() + stderr_detail = self._sdr_process.stderr.read().decode(errors="replace").strip() rc = self._sdr_process.returncode self._sdr_process = None - detail = stderr_detail.split('\n')[-1] if stderr_detail else f'exit code {rc}' - raise RuntimeError(f'{self._sdr_tool_name} failed: {detail}') + detail = stderr_detail.split("\n")[-1] if stderr_detail else f"exit code {rc}" + raise RuntimeError(f"{self._sdr_tool_name} failed: {detail}") - self._decode_thread = threading.Thread( - target=self._decode_audio_stream, daemon=True) + self._decode_thread = threading.Thread(target=self._decode_audio_stream, daemon=True) self._decode_thread.start() def _decode_audio_stream(self) -> None: @@ -422,7 +424,7 @@ class WeFaxDecoder: line_buffer = np.zeros(0, dtype=np.float64) max_lines = 2000 # Safety limit - sdr_error = '' + sdr_error = "" last_partial_line = -1 logger.info( @@ -434,11 +436,13 @@ class WeFaxDecoder: # Emit initial scanning progress here (not in start()) so the # frontend SSE connection is established before this event fires. time.sleep(0.1) - self._emit_progress(WeFaxProgress( - status='scanning', - station=self._station, - message=f'Scanning {self._frequency_khz} kHz for WeFax start tone...', - )) + self._emit_progress( + WeFaxProgress( + status="scanning", + station=self._station, + message=f"Scanning {self._frequency_khz} kHz for WeFax start tone...", + ) + ) while self._running and self._sdr_process: try: @@ -456,11 +460,10 @@ class WeFaxDecoder: raw_data = os.read(fd, chunk_bytes) if not raw_data: if self._running: - stderr_msg = '' + stderr_msg = "" if self._sdr_process and self._sdr_process.stderr: with contextlib.suppress(Exception): - stderr_msg = self._sdr_process.stderr.read().decode( - errors='replace').strip() + stderr_msg = self._sdr_process.stderr.read().decode(errors="replace").strip() rc = self._sdr_process.poll() if self._sdr_process else None logger.warning(f"{self._sdr_tool_name} stream ended (exit code: {rc})") if stderr_msg: @@ -472,7 +475,7 @@ class WeFaxDecoder: if n_samples == 0: continue - raw_int16 = np.frombuffer(raw_data[:n_samples * 2], dtype=np.int16) + raw_int16 = np.frombuffer(raw_data[: n_samples * 2], dtype=np.int16) samples = raw_int16.astype(np.float64) / 32768.0 # Emit scope waveform for frontend visualisation @@ -488,11 +491,13 @@ class WeFaxDecoder: state = DecoderState.PHASING phasing_line_count = 0 logger.info("WeFax start tone detected, entering phasing") - self._emit_progress(WeFaxProgress( - status='phasing', - station=self._station, - message='Start tone detected, synchronising...', - )) + self._emit_progress( + WeFaxProgress( + status="phasing", + station=self._station, + message="Start tone detected, synchronising...", + ) + ) else: start_tone_count = max(0, start_tone_count - 1) @@ -506,11 +511,13 @@ class WeFaxDecoder: line_buffer = np.zeros(0, dtype=np.float64) last_partial_line = -1 logger.info("Phasing complete, receiving image") - self._emit_progress(WeFaxProgress( - status='receiving', - station=self._station, - message='Receiving image...', - )) + self._emit_progress( + WeFaxProgress( + status="receiving", + station=self._station, + message="Receiving image...", + ) + ) elif state == DecoderState.RECEIVING: # Check for stop tone @@ -520,15 +527,11 @@ class WeFaxDecoder: if stop_tone_count >= needed_stop: # Process any remaining line buffer if len(line_buffer) >= samples_per_line * 0.5: - line_pixels = self._decode_line( - line_buffer, pixels_per_line, sr) + line_pixels = self._decode_line(line_buffer, pixels_per_line, sr) image_lines.append(line_pixels) state = DecoderState.COMPLETE - logger.info( - f"Stop tone detected, image complete: " - f"{len(image_lines)} lines" - ) + logger.info(f"Stop tone detected, image complete: {len(image_lines)} lines") break else: stop_tone_count = max(0, stop_tone_count - 1) @@ -541,8 +544,7 @@ class WeFaxDecoder: line_samples = line_buffer[:samples_per_line] line_buffer = line_buffer[samples_per_line:] - line_pixels = self._decode_line( - line_samples, pixels_per_line, sr) + line_pixels = self._decode_line(line_samples, pixels_per_line, sr) image_lines.append(line_pixels) # Safety limit @@ -557,16 +559,17 @@ class WeFaxDecoder: last_partial_line = current_lines # Rough progress estimate (typical chart ~800 lines) pct = min(95, int(current_lines / 8)) - partial_url = self._encode_partial( - image_lines, pixels_per_line) - self._emit_progress(WeFaxProgress( - status='receiving', - station=self._station, - message=f'Receiving: {current_lines} lines', - progress_percent=pct, - line_count=current_lines, - partial_image=partial_url, - )) + partial_url = self._encode_partial(image_lines, pixels_per_line) + self._emit_progress( + WeFaxProgress( + status="receiving", + station=self._station, + message=f"Receiving: {current_lines} lines", + progress_percent=pct, + line_count=current_lines, + partial_image=partial_url, + ) + ) except Exception as e: logger.error(f"Error in WeFax decode thread: {e}") @@ -593,19 +596,16 @@ class WeFaxDecoder: self._sdr_process = None if was_running: - err_detail = sdr_error.split('\n')[-1] if sdr_error else '' + err_detail = sdr_error.split("\n")[-1] if sdr_error else "" if state != DecoderState.COMPLETE: - msg = f'{self._sdr_tool_name} failed: {err_detail}' if err_detail else 'Decode stopped unexpectedly' - self._emit_progress(WeFaxProgress( - status='error', message=msg)) + msg = f"{self._sdr_tool_name} failed: {err_detail}" if err_detail else "Decode stopped unexpectedly" + self._emit_progress(WeFaxProgress(status="error", message=msg)) else: - self._emit_progress(WeFaxProgress( - status='stopped', message='Decoder stopped')) + self._emit_progress(WeFaxProgress(status="stopped", message="Decoder stopped")) logger.info("WeFax decode thread ended") - def _decode_line(self, line_samples: np.ndarray, - pixels_per_line: int, sample_rate: int) -> np.ndarray: + def _decode_line(self, line_samples: np.ndarray, pixels_per_line: int, sample_rate: int) -> np.ndarray: """Decode one scan line from audio samples to pixel values. Uses instantaneous frequency estimation via the analytic signal @@ -621,8 +621,7 @@ class WeFaxDecoder: # Use Hilbert transform for instantaneous frequency try: - analytic = np.fft.ifft( - np.fft.fft(line_samples) * 2 * (np.arange(n) < n // 2)) + analytic = np.fft.ifft(np.fft.fft(line_samples) * 2 * (np.arange(n) < n // 2)) inst_phase = np.unwrap(np.angle(analytic)) inst_freq = np.diff(inst_phase) / (2.0 * math.pi) * sample_rate inst_freq = np.clip(inst_freq, BLACK_FREQ - 200, WHITE_FREQ + 200) @@ -645,14 +644,12 @@ class WeFaxDecoder: if start_idx >= len(line_samples) or start_idx >= end_idx: break window = line_samples[start_idx:end_idx] - freq = _estimate_frequency(window, sample_rate, - BLACK_FREQ - 200, WHITE_FREQ + 200) + freq = _estimate_frequency(window, sample_rate, BLACK_FREQ - 200, WHITE_FREQ + 200) pixels[px] = _freq_to_pixel(freq) return pixels - def _encode_partial(self, image_lines: list[np.ndarray], - width: int) -> str | None: + def _encode_partial(self, image_lines: list[np.ndarray], width: int) -> str | None: """Encode current image lines as a JPEG data URL for live preview.""" if PILImage is None or not image_lines: return None @@ -660,38 +657,39 @@ class WeFaxDecoder: height = len(image_lines) img_array = np.zeros((height, width), dtype=np.uint8) for i, line in enumerate(image_lines): - img_array[i, :len(line)] = line[:width] - img = PILImage.fromarray(img_array, mode='L') + img_array[i, : len(line)] = line[:width] + img = PILImage.fromarray(img_array, mode="L") buf = io.BytesIO() - img.save(buf, format='JPEG', quality=40) - b64 = base64.b64encode(buf.getvalue()).decode('ascii') - return f'data:image/jpeg;base64,{b64}' + img.save(buf, format="JPEG", quality=40) + b64 = base64.b64encode(buf.getvalue()).decode("ascii") + return f"data:image/jpeg;base64,{b64}" except Exception: return None - def _save_image(self, image_lines: list[np.ndarray], - width: int) -> None: + def _save_image(self, image_lines: list[np.ndarray], width: int) -> None: """Save completed image to disk.""" if PILImage is None: logger.error("Cannot save image: Pillow not installed") - self._emit_progress(WeFaxProgress( - status='error', - message='Cannot save image - Pillow not installed', - )) + self._emit_progress( + WeFaxProgress( + status="error", + message="Cannot save image - Pillow not installed", + ) + ) return try: height = len(image_lines) img_array = np.zeros((height, width), dtype=np.uint8) for i, line in enumerate(image_lines): - img_array[i, :len(line)] = line[:width] + img_array[i, : len(line)] = line[:width] - img = PILImage.fromarray(img_array, mode='L') + img = PILImage.fromarray(img_array, mode="L") timestamp = datetime.now(timezone.utc) - station_tag = self._station or 'unknown' + station_tag = self._station or "unknown" filename = f"wefax_{timestamp.strftime('%Y%m%d_%H%M%S')}_{station_tag}.png" filepath = self._output_dir / filename - img.save(filepath, 'PNG') + img.save(filepath, "PNG") wefax_image = WeFaxImage( filename=filename, @@ -706,21 +704,25 @@ class WeFaxDecoder: self._images.append(wefax_image) logger.info(f"WeFax image saved: {filename} ({wefax_image.size_bytes} bytes)") - self._emit_progress(WeFaxProgress( - status='complete', - station=self._station, - message=f'Image decoded: {height} lines', - progress_percent=100, - line_count=height, - image=wefax_image, - )) + self._emit_progress( + WeFaxProgress( + status="complete", + station=self._station, + message=f"Image decoded: {height} lines", + progress_percent=100, + line_count=height, + image=wefax_image, + ) + ) except Exception as e: logger.error(f"Error saving WeFax image: {e}") - self._emit_progress(WeFaxProgress( - status='error', - message=f'Error saving image: {e}', - )) + self._emit_progress( + WeFaxProgress( + status="error", + message=f"Error saving image: {e}", + ) + ) def stop(self) -> None: """Stop WeFax decoder. @@ -765,7 +767,7 @@ class WeFaxDecoder: def delete_all_images(self) -> int: """Delete all decoded images. Returns count deleted.""" count = 0 - for filepath in self._output_dir.glob('*.png'): + for filepath in self._output_dir.glob("*.png"): filepath.unlink() count += 1 self._images.clear() @@ -775,14 +777,14 @@ class WeFaxDecoder: def _scan_images(self) -> None: """Scan output directory for images not yet tracked.""" known = {img.filename for img in self._images} - for filepath in self._output_dir.glob('*.png'): + for filepath in self._output_dir.glob("*.png"): if filepath.name not in known: try: stat = filepath.stat() image = WeFaxImage( filename=filepath.name, path=filepath, - station='', + station="", frequency_khz=0, timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), ioc=self._ioc, @@ -819,12 +821,14 @@ class WeFaxDecoder: window = raw_int16[-256:] if len(raw_int16) > 256 else raw_int16 waveform = np.clip(window // 256, -127, 127).astype(np.int8).tolist() - self._callback({ - 'type': 'scope', - 'rms': rms, - 'peak': peak, - 'waveform': waveform, - }) + self._callback( + { + "type": "scope", + "rms": rms, + "peak": peak, + "waveform": waveform, + } + ) except Exception: pass diff --git a/utils/wefax_scheduler.py b/utils/wefax_scheduler.py index 91c5ffc..7d717c3 100644 --- a/utils/wefax_scheduler.py +++ b/utils/wefax_scheduler.py @@ -18,7 +18,7 @@ from utils.logging import get_logger from utils.wefax import get_wefax_decoder from utils.wefax_stations import get_station -logger = get_logger('intercept.wefax_scheduler') +logger = get_logger("intercept.wefax_scheduler") # Import config defaults try: @@ -31,46 +31,46 @@ except ImportError: WEFAX_CAPTURE_BUFFER_SECONDS = 30 -class ScheduledBroadcast: +class ScheduledBroadcast: """A broadcast scheduled for automatic capture.""" - def __init__( - self, - station: str, - callsign: str, - frequency_khz: float, - utc_time: str, - duration_min: int, - content: str, - occurrence_date: str = '', - ): - self.id: str = str(uuid.uuid4())[:8] - self.station = station - self.callsign = callsign - self.frequency_khz = frequency_khz - self.utc_time = utc_time - self.duration_min = duration_min - self.content = content - self.occurrence_date = occurrence_date - self.status: str = 'scheduled' # scheduled, capturing, complete, skipped - self._timer: threading.Timer | None = None - self._stop_timer: threading.Timer | None = None + def __init__( + self, + station: str, + callsign: str, + frequency_khz: float, + utc_time: str, + duration_min: int, + content: str, + occurrence_date: str = "", + ): + self.id: str = str(uuid.uuid4())[:8] + self.station = station + self.callsign = callsign + self.frequency_khz = frequency_khz + self.utc_time = utc_time + self.duration_min = duration_min + self.content = content + self.occurrence_date = occurrence_date + self.status: str = "scheduled" # scheduled, capturing, complete, skipped + self._timer: threading.Timer | None = None + self._stop_timer: threading.Timer | None = None def to_dict(self) -> dict[str, Any]: return { - 'id': self.id, - 'station': self.station, - 'callsign': self.callsign, - 'frequency_khz': self.frequency_khz, - 'utc_time': self.utc_time, - 'duration_min': self.duration_min, - 'content': self.content, - 'occurrence_date': self.occurrence_date, - 'status': self.status, - } + "id": self.id, + "station": self.station, + "callsign": self.callsign, + "frequency_khz": self.frequency_khz, + "utc_time": self.utc_time, + "duration_min": self.duration_min, + "content": self.content, + "occurrence_date": self.occurrence_date, + "status": self.status, + } -class WeFaxScheduler: +class WeFaxScheduler: """Auto-scheduler for WeFax broadcast captures.""" def __init__(self): @@ -78,8 +78,8 @@ class WeFaxScheduler: self._lock = threading.Lock() self._broadcasts: list[ScheduledBroadcast] = [] self._refresh_timer: threading.Timer | None = None - self._station: str = '' - self._callsign: str = '' + self._station: str = "" + self._callsign: str = "" self._frequency_khz: float = 0.0 self._device: int = 0 self._gain: float = 40.0 @@ -128,10 +128,10 @@ class WeFaxScheduler: """ station_data = get_station(station) if not station_data: - return {'status': 'error', 'message': f'Station {station} not found'} + return {"status": "error", "message": f"Station {station} not found"} with self._lock: - self._station = station_data.get('name', station) + self._station = station_data.get("name", station) self._callsign = station self._frequency_khz = frequency_khz self._device = device @@ -167,25 +167,25 @@ class WeFaxScheduler: self._broadcasts.clear() logger.info("WeFax auto-scheduler disabled") - return {'status': 'disabled'} + return {"status": "disabled"} def skip_broadcast(self, broadcast_id: str) -> bool: """Manually skip a scheduled broadcast.""" with self._lock: for b in self._broadcasts: - if b.id == broadcast_id and b.status == 'scheduled': - b.status = 'skipped' + if b.id == broadcast_id and b.status == "scheduled": + b.status = "skipped" if b._timer: b._timer.cancel() b._timer = None - logger.info( - "Skipped broadcast: %s at %s", b.content, b.utc_time + logger.info("Skipped broadcast: %s at %s", b.content, b.utc_time) + self._emit_event( + { + "type": "schedule_capture_skipped", + "broadcast": b.to_dict(), + "reason": "manual", + } ) - self._emit_event({ - 'type': 'schedule_capture_skipped', - 'broadcast': b.to_dict(), - 'reason': 'manual', - }) return True return False @@ -193,29 +193,27 @@ class WeFaxScheduler: """Get current scheduler status.""" with self._lock: return { - 'enabled': self._enabled, - 'station': self._station, - 'callsign': self._callsign, - 'frequency_khz': self._frequency_khz, - 'device': self._device, - 'gain': self._gain, - 'ioc': self._ioc, - 'lpm': self._lpm, - 'scheduled_count': sum( - 1 for b in self._broadcasts if b.status == 'scheduled' - ), - 'total_broadcasts': len(self._broadcasts), + "enabled": self._enabled, + "station": self._station, + "callsign": self._callsign, + "frequency_khz": self._frequency_khz, + "device": self._device, + "gain": self._gain, + "ioc": self._ioc, + "lpm": self._lpm, + "scheduled_count": sum(1 for b in self._broadcasts if b.status == "scheduled"), + "total_broadcasts": len(self._broadcasts), } - def get_broadcasts(self) -> list[dict[str, Any]]: - """Get list of scheduled broadcasts.""" - with self._lock: - return [b.to_dict() for b in self._broadcasts] - - @staticmethod - def _history_key(callsign: str, utc_time: str, occurrence_date: str) -> str: - """Build a stable key for one station UTC slot on one calendar day.""" - return f'{callsign}_{utc_time}_{occurrence_date}' + def get_broadcasts(self) -> list[dict[str, Any]]: + """Get list of scheduled broadcasts.""" + with self._lock: + return [b.to_dict() for b in self._broadcasts] + + @staticmethod + def _history_key(callsign: str, utc_time: str, occurrence_date: str) -> str: + """Build a stable key for one station UTC slot on one calendar day.""" + return f"{callsign}_{utc_time}_{occurrence_date}" def _refresh_schedule(self) -> None: """Recompute broadcast schedule and set timers.""" @@ -227,7 +225,7 @@ class WeFaxScheduler: logger.error("Station %s not found during refresh", self._callsign) return - schedule = station_data.get('schedule', []) + schedule = station_data.get("schedule", []) with self._lock: # Cancel existing timers @@ -238,21 +236,18 @@ class WeFaxScheduler: b._stop_timer.cancel() # Keep completed/skipped for history, replace scheduled - history = [ - b for b in self._broadcasts - if b.status in ('complete', 'skipped', 'capturing') - ] + history = [b for b in self._broadcasts if b.status in ("complete", "skipped", "capturing")] self._broadcasts = history now = datetime.now(timezone.utc) buffer = WEFAX_CAPTURE_BUFFER_SECONDS for entry in schedule: - utc_time = entry.get('utc', '') - duration_min = entry.get('duration_min', 20) - content = entry.get('content', '') + utc_time = entry.get("utc", "") + duration_min = entry.get("duration_min", 20) + content = entry.get("content", "") - parts = utc_time.split(':') + parts = utc_time.split(":") if len(parts) != 2: continue @@ -263,67 +258,62 @@ class WeFaxScheduler: continue # Compute next occurrence (today or tomorrow) - broadcast_dt = now.replace( - hour=hour, minute=minute, second=0, microsecond=0 - ) - capture_end = broadcast_dt + timedelta( - minutes=duration_min, seconds=buffer - ) + broadcast_dt = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + capture_end = broadcast_dt + timedelta(minutes=duration_min, seconds=buffer) # If the broadcast end is already past, schedule for tomorrow if capture_end <= now: broadcast_dt += timedelta(days=1) - capture_end = broadcast_dt + timedelta( - minutes=duration_min, seconds=buffer - ) + capture_end = broadcast_dt + timedelta(minutes=duration_min, seconds=buffer) - capture_start = broadcast_dt - timedelta(seconds=buffer) - occurrence_date = broadcast_dt.date().isoformat() - - # Check if this specific day/slot was already processed. - history_key = self._history_key( - self._callsign, - utc_time, - occurrence_date, - ) - if any( - self._history_key( - h.callsign, - h.utc_time, - getattr(h, 'occurrence_date', ''), - ) == history_key - for h in history - ): - continue - - sb = ScheduledBroadcast( + capture_start = broadcast_dt - timedelta(seconds=buffer) + occurrence_date = broadcast_dt.date().isoformat() + + # Check if this specific day/slot was already processed. + history_key = self._history_key( + self._callsign, + utc_time, + occurrence_date, + ) + if any( + self._history_key( + h.callsign, + h.utc_time, + getattr(h, "occurrence_date", ""), + ) + == history_key + for h in history + ): + continue + + sb = ScheduledBroadcast( station=self._station, callsign=self._callsign, - frequency_khz=self._frequency_khz, - utc_time=utc_time, - duration_min=duration_min, - content=content, - occurrence_date=occurrence_date, - ) + frequency_khz=self._frequency_khz, + utc_time=utc_time, + duration_min=duration_min, + content=content, + occurrence_date=occurrence_date, + ) # Schedule capture timer delay = max(0.0, (capture_start - now).total_seconds()) - sb._timer = threading.Timer( - delay, self._execute_capture, args=[sb] - ) + sb._timer = threading.Timer(delay, self._execute_capture, args=[sb]) sb._timer.daemon = True sb._timer.start() logger.info( "Scheduled capture: %s at %s UTC (fires in %.0fs)", - content, utc_time, delay, + content, + utc_time, + delay, ) self._broadcasts.append(sb) logger.info( "WeFax scheduler refreshed: %d broadcasts scheduled", - sum(1 for b in self._broadcasts if b.status == 'scheduled'), + sum(1 for b in self._broadcasts if b.status == "scheduled"), ) # Schedule next refresh @@ -344,102 +334,113 @@ class WeFaxScheduler: except Exception: logger.exception( "Unhandled exception in scheduled capture: %s at %s", - sb.content, sb.utc_time, + sb.content, + sb.utc_time, + ) + sb.status = "skipped" + self._emit_event( + { + "type": "schedule_capture_skipped", + "broadcast": sb.to_dict(), + "reason": "error", + "detail": "internal error — see server logs", + } ) - sb.status = 'skipped' - self._emit_event({ - 'type': 'schedule_capture_skipped', - 'broadcast': sb.to_dict(), - 'reason': 'error', - 'detail': 'internal error — see server logs', - }) def _execute_capture_inner(self, sb: ScheduledBroadcast) -> None: """Execute capture for a scheduled broadcast.""" - if not self._enabled or sb.status != 'scheduled': + if not self._enabled or sb.status != "scheduled": return decoder = get_wefax_decoder() if decoder.is_running: logger.info("Decoder busy, skipping scheduled broadcast: %s", sb.content) - sb.status = 'skipped' - self._emit_event({ - 'type': 'schedule_capture_skipped', - 'broadcast': sb.to_dict(), - 'reason': 'decoder_busy', - }) + sb.status = "skipped" + self._emit_event( + { + "type": "schedule_capture_skipped", + "broadcast": sb.to_dict(), + "reason": "decoder_busy", + } + ) return # Claim SDR device try: import app as app_module - error = app_module.claim_sdr_device(self._device, 'wefax') + + error = app_module.claim_sdr_device(self._device, "wefax") if error: - logger.info( - "SDR device busy, skipping: %s - %s", sb.content, error + logger.info("SDR device busy, skipping: %s - %s", sb.content, error) + sb.status = "skipped" + self._emit_event( + { + "type": "schedule_capture_skipped", + "broadcast": sb.to_dict(), + "reason": "device_busy", + } ) - sb.status = 'skipped' - self._emit_event({ - 'type': 'schedule_capture_skipped', - 'broadcast': sb.to_dict(), - 'reason': 'device_busy', - }) return except ImportError: pass - sb.status = 'capturing' - - def _release_device(): - try: - import app as app_module - app_module.release_sdr_device(self._device) - except ImportError: - pass - - released = False - release_lock = threading.Lock() - - def _release_device_once() -> None: - nonlocal released - with release_lock: - if released: - return - released = True - _release_device() - - def _scheduler_progress_callback(progress: dict) -> None: - """Forward progress updates and release scheduler resources on terminal states.""" - if self._progress_callback: - self._progress_callback(progress) - - if not isinstance(progress, dict) or progress.get('type') != 'wefax_progress': - return - - status = progress.get('status') - if status not in ('complete', 'error', 'stopped'): - return - - if sb.status == 'capturing': - if status == 'complete': - sb.status = 'complete' - self._emit_event({ - 'type': 'schedule_capture_complete', - 'broadcast': sb.to_dict(), - }) - else: - sb.status = 'skipped' - self._emit_event({ - 'type': 'schedule_capture_skipped', - 'broadcast': sb.to_dict(), - 'reason': 'decoder_error', - 'detail': progress.get('message', ''), - }) - - _release_device_once() - - decoder.set_callback(_scheduler_progress_callback) + sb.status = "capturing" + + def _release_device(): + try: + import app as app_module + + app_module.release_sdr_device(self._device) + except ImportError: + pass + + released = False + release_lock = threading.Lock() + + def _release_device_once() -> None: + nonlocal released + with release_lock: + if released: + return + released = True + _release_device() + + def _scheduler_progress_callback(progress: dict) -> None: + """Forward progress updates and release scheduler resources on terminal states.""" + if self._progress_callback: + self._progress_callback(progress) + + if not isinstance(progress, dict) or progress.get("type") != "wefax_progress": + return + + status = progress.get("status") + if status not in ("complete", "error", "stopped"): + return + + if sb.status == "capturing": + if status == "complete": + sb.status = "complete" + self._emit_event( + { + "type": "schedule_capture_complete", + "broadcast": sb.to_dict(), + } + ) + else: + sb.status = "skipped" + self._emit_event( + { + "type": "schedule_capture_skipped", + "broadcast": sb.to_dict(), + "reason": "decoder_error", + "detail": progress.get("message", ""), + } + ) + + _release_device_once() + + decoder.set_callback(_scheduler_progress_callback) success = decoder.start( frequency_khz=self._frequency_khz, @@ -453,17 +454,21 @@ class WeFaxScheduler: if success: logger.info("Auto-scheduler started capture: %s", sb.content) - self._emit_event({ - 'type': 'schedule_capture_start', - 'broadcast': sb.to_dict(), - }) + self._emit_event( + { + "type": "schedule_capture_start", + "broadcast": sb.to_dict(), + } + ) # Schedule stop timer at broadcast end + buffer now = datetime.now(timezone.utc) - parts = sb.utc_time.split(':') + parts = sb.utc_time.split(":") broadcast_dt = now.replace( - hour=int(parts[0]), minute=int(parts[1]), - second=0, microsecond=0, + hour=int(parts[0]), + minute=int(parts[1]), + second=0, + microsecond=0, ) if broadcast_dt < now - timedelta(hours=1): broadcast_dt += timedelta(days=1) @@ -471,53 +476,53 @@ class WeFaxScheduler: minutes=sb.duration_min, seconds=WEFAX_CAPTURE_BUFFER_SECONDS, ) - stop_delay = max(0.0, (stop_dt - now).total_seconds()) - - if stop_delay > 0: - sb._stop_timer = threading.Timer( - stop_delay, self._stop_capture, args=[sb, _release_device_once] - ) - sb._stop_timer.daemon = True - sb._stop_timer.start() - else: - # If execution was delayed beyond end-of-window, close out - # immediately so SDR allocation is never stranded. - logger.warning( - "Capture window already elapsed for %s at %s UTC; stopping immediately", - sb.content, - sb.utc_time, - ) - self._stop_capture(sb, _release_device_once) - else: - sb.status = 'skipped' - _release_device_once() - self._emit_event({ - 'type': 'schedule_capture_skipped', - 'broadcast': sb.to_dict(), - 'reason': 'start_failed', - 'detail': decoder.last_error or 'unknown error', - }) + stop_delay = max(0.0, (stop_dt - now).total_seconds()) - def _stop_capture( - self, sb: ScheduledBroadcast, release_fn: Callable - ) -> None: - """Stop capture at broadcast end.""" - if sb.status != 'capturing': - release_fn() - return - - sb.status = 'complete' - - decoder = get_wefax_decoder() - if decoder.is_running: - decoder.stop() - logger.info("Auto-scheduler stopped capture: %s", sb.content) - - release_fn() - self._emit_event({ - 'type': 'schedule_capture_complete', - 'broadcast': sb.to_dict(), - }) + if stop_delay > 0: + sb._stop_timer = threading.Timer(stop_delay, self._stop_capture, args=[sb, _release_device_once]) + sb._stop_timer.daemon = True + sb._stop_timer.start() + else: + # If execution was delayed beyond end-of-window, close out + # immediately so SDR allocation is never stranded. + logger.warning( + "Capture window already elapsed for %s at %s UTC; stopping immediately", + sb.content, + sb.utc_time, + ) + self._stop_capture(sb, _release_device_once) + else: + sb.status = "skipped" + _release_device_once() + self._emit_event( + { + "type": "schedule_capture_skipped", + "broadcast": sb.to_dict(), + "reason": "start_failed", + "detail": decoder.last_error or "unknown error", + } + ) + + def _stop_capture(self, sb: ScheduledBroadcast, release_fn: Callable) -> None: + """Stop capture at broadcast end.""" + if sb.status != "capturing": + release_fn() + return + + sb.status = "complete" + + decoder = get_wefax_decoder() + if decoder.is_running: + decoder.stop() + logger.info("Auto-scheduler stopped capture: %s", sb.content) + + release_fn() + self._emit_event( + { + "type": "schedule_capture_complete", + "broadcast": sb.to_dict(), + } + ) def _emit_event(self, event: dict[str, Any]) -> None: """Emit scheduler event to callback.""" diff --git a/utils/wifi/__init__.py b/utils/wifi/__init__.py index 38cd5db..f813b36 100644 --- a/utils/wifi/__init__.py +++ b/utils/wifi/__init__.py @@ -93,91 +93,79 @@ from .scanner import ( __all__ = [ # Main scanner - 'UnifiedWiFiScanner', - 'get_wifi_scanner', - 'reset_wifi_scanner', - + "UnifiedWiFiScanner", + "get_wifi_scanner", + "reset_wifi_scanner", # Models - 'WiFiObservation', - 'WiFiAccessPoint', - 'WiFiClient', - 'WiFiProbeRequest', - 'WiFiScanResult', - 'WiFiScanStatus', - 'WiFiCapabilities', - 'ChannelStats', - 'ChannelRecommendation', - + "WiFiObservation", + "WiFiAccessPoint", + "WiFiClient", + "WiFiProbeRequest", + "WiFiScanResult", + "WiFiScanStatus", + "WiFiCapabilities", + "ChannelStats", + "ChannelRecommendation", # Channel analysis - 'ChannelAnalyzer', - 'analyze_channels', - + "ChannelAnalyzer", + "analyze_channels", # Hidden SSID correlation - 'HiddenSSIDCorrelator', - 'get_hidden_correlator', - + "HiddenSSIDCorrelator", + "get_hidden_correlator", # Constants - Bands - 'BAND_2_4_GHZ', - 'BAND_5_GHZ', - 'BAND_6_GHZ', - 'BAND_UNKNOWN', - + "BAND_2_4_GHZ", + "BAND_5_GHZ", + "BAND_6_GHZ", + "BAND_UNKNOWN", # Constants - Channels - 'CHANNELS_2_4_GHZ', - 'CHANNELS_5_GHZ', - 'CHANNELS_6_GHZ', - 'NON_OVERLAPPING_2_4_GHZ', - 'NON_OVERLAPPING_5_GHZ', - + "CHANNELS_2_4_GHZ", + "CHANNELS_5_GHZ", + "CHANNELS_6_GHZ", + "NON_OVERLAPPING_2_4_GHZ", + "NON_OVERLAPPING_5_GHZ", # Constants - Security - 'SECURITY_OPEN', - 'SECURITY_WEP', - 'SECURITY_WPA', - 'SECURITY_WPA2', - 'SECURITY_WPA3', - 'SECURITY_WPA_WPA2', - 'SECURITY_WPA2_WPA3', - 'SECURITY_ENTERPRISE', - 'SECURITY_UNKNOWN', - + "SECURITY_OPEN", + "SECURITY_WEP", + "SECURITY_WPA", + "SECURITY_WPA2", + "SECURITY_WPA3", + "SECURITY_WPA_WPA2", + "SECURITY_WPA2_WPA3", + "SECURITY_ENTERPRISE", + "SECURITY_UNKNOWN", # Constants - Cipher - 'CIPHER_NONE', - 'CIPHER_WEP', - 'CIPHER_TKIP', - 'CIPHER_CCMP', - 'CIPHER_GCMP', - 'CIPHER_UNKNOWN', - + "CIPHER_NONE", + "CIPHER_WEP", + "CIPHER_TKIP", + "CIPHER_CCMP", + "CIPHER_GCMP", + "CIPHER_UNKNOWN", # Constants - Auth - 'AUTH_OPEN', - 'AUTH_PSK', - 'AUTH_SAE', - 'AUTH_EAP', - 'AUTH_OWE', - 'AUTH_UNKNOWN', - + "AUTH_OPEN", + "AUTH_PSK", + "AUTH_SAE", + "AUTH_EAP", + "AUTH_OWE", + "AUTH_UNKNOWN", # Constants - Signal bands - 'SIGNAL_STRONG', - 'SIGNAL_MEDIUM', - 'SIGNAL_WEAK', - 'SIGNAL_VERY_WEAK', - 'SIGNAL_UNKNOWN', - + "SIGNAL_STRONG", + "SIGNAL_MEDIUM", + "SIGNAL_WEAK", + "SIGNAL_VERY_WEAK", + "SIGNAL_UNKNOWN", # Constants - Proximity bands - 'PROXIMITY_IMMEDIATE', - 'PROXIMITY_NEAR', - 'PROXIMITY_FAR', - 'PROXIMITY_UNKNOWN', - + "PROXIMITY_IMMEDIATE", + "PROXIMITY_NEAR", + "PROXIMITY_FAR", + "PROXIMITY_UNKNOWN", # Constants - Scan modes - 'SCAN_MODE_QUICK', - 'SCAN_MODE_DEEP', - + "SCAN_MODE_QUICK", + "SCAN_MODE_DEEP", # Helper functions - 'get_band_from_channel', - 'get_band_from_frequency', - 'get_channel_from_frequency', - 'get_signal_band', - 'get_proximity_band', - 'get_vendor_from_mac', + "get_band_from_channel", + "get_band_from_frequency", + "get_channel_from_frequency", + "get_signal_band", + "get_proximity_band", + "get_vendor_from_mac", ] diff --git a/utils/wifi/channel_analyzer.py b/utils/wifi/channel_analyzer.py index c858276..0aed971 100644 --- a/utils/wifi/channel_analyzer.py +++ b/utils/wifi/channel_analyzer.py @@ -36,6 +36,7 @@ DFS_CHANNELS_5_GHZ = list(range(52, 65)) + list(range(100, 145)) @dataclass class ChannelScore: """Internal scoring for a channel.""" + channel: int band: str ap_count: int = 0 @@ -118,9 +119,7 @@ class ChannelAnalyzer: channel_stats = self._build_channel_stats(scores) # Generate recommendations - recommendations = self._generate_recommendations( - scores, access_points, include_dfs - ) + recommendations = self._generate_recommendations(scores, access_points, include_dfs) return channel_stats, recommendations @@ -256,13 +255,15 @@ class ChannelAnalyzer: if is_dfs: reason += " (DFS - radar detection required)" - recommendations.append(ChannelRecommendation( - channel=channel, - band=band, - score=utilization, - reason=reason, - is_dfs=is_dfs, - )) + recommendations.append( + ChannelRecommendation( + channel=channel, + band=band, + score=utilization, + reason=reason, + is_dfs=is_dfs, + ) + ) # Sort by score (lower is better), then prefer non-DFS recommendations.sort(key=lambda r: (r.score, r.is_dfs, r.channel)) diff --git a/utils/wifi/constants.py b/utils/wifi/constants.py index ccb4d11..84c8298 100644 --- a/utils/wifi/constants.py +++ b/utils/wifi/constants.py @@ -30,10 +30,10 @@ PROBE_REQUEST_RETENTION = 600 # 10 minutes # WIFI BANDS # ============================================================================= -BAND_2_4_GHZ = '2.4GHz' -BAND_5_GHZ = '5GHz' -BAND_6_GHZ = '6GHz' -BAND_UNKNOWN = 'unknown' +BAND_2_4_GHZ = "2.4GHz" +BAND_5_GHZ = "5GHz" +BAND_6_GHZ = "6GHz" +BAND_UNKNOWN = "unknown" # ============================================================================= # WIFI BAND CHANNEL MAPPINGS @@ -45,21 +45,97 @@ CHANNELS_2_4_GHZ = list(range(1, 15)) # 5 GHz channels (UNII-1, UNII-2A, UNII-2C, UNII-3) CHANNELS_5_GHZ = [ # UNII-1 (5150-5250 MHz) - 36, 40, 44, 48, + 36, + 40, + 44, + 48, # UNII-2A (5250-5350 MHz) - DFS - 52, 56, 60, 64, + 52, + 56, + 60, + 64, # UNII-2C (5470-5725 MHz) - DFS - 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, + 100, + 104, + 108, + 112, + 116, + 120, + 124, + 128, + 132, + 136, + 140, + 144, # UNII-3 (5725-5850 MHz) - 149, 153, 157, 161, 165, + 149, + 153, + 157, + 161, + 165, ] # 6 GHz channels (Wi-Fi 6E) CHANNELS_6_GHZ = [ - 1, 5, 9, 13, 17, 21, 25, 29, 33, 37, 41, 45, 49, 53, 57, 61, 65, 69, 73, - 77, 81, 85, 89, 93, 97, 101, 105, 109, 113, 117, 121, 125, 129, 133, 137, - 141, 145, 149, 153, 157, 161, 165, 169, 173, 177, 181, 185, 189, 193, 197, - 201, 205, 209, 213, 217, 221, 225, 229, 233 + 1, + 5, + 9, + 13, + 17, + 21, + 25, + 29, + 33, + 37, + 41, + 45, + 49, + 53, + 57, + 61, + 65, + 69, + 73, + 77, + 81, + 85, + 89, + 93, + 97, + 101, + 105, + 109, + 113, + 117, + 121, + 125, + 129, + 133, + 137, + 141, + 145, + 149, + 153, + 157, + 161, + 165, + 169, + 173, + 177, + 181, + 185, + 189, + 193, + 197, + 201, + 205, + 209, + 213, + 217, + 221, + 225, + 229, + 233, ] # Non-overlapping channels for recommendations @@ -69,14 +145,46 @@ NON_OVERLAPPING_5_GHZ = [36, 40, 44, 48, 149, 153, 157, 161, 165] # Non-DFS # Channel to frequency mappings (MHz) CHANNEL_FREQUENCIES = { # 2.4 GHz - 1: 2412, 2: 2417, 3: 2422, 4: 2427, 5: 2432, 6: 2437, 7: 2442, - 8: 2447, 9: 2452, 10: 2457, 11: 2462, 12: 2467, 13: 2472, 14: 2484, + 1: 2412, + 2: 2417, + 3: 2422, + 4: 2427, + 5: 2432, + 6: 2437, + 7: 2442, + 8: 2447, + 9: 2452, + 10: 2457, + 11: 2462, + 12: 2467, + 13: 2472, + 14: 2484, # 5 GHz - 36: 5180, 40: 5200, 44: 5220, 48: 5240, - 52: 5260, 56: 5280, 60: 5300, 64: 5320, - 100: 5500, 104: 5520, 108: 5540, 112: 5560, 116: 5580, - 120: 5600, 124: 5620, 128: 5640, 132: 5660, 136: 5680, 140: 5700, 144: 5720, - 149: 5745, 153: 5765, 157: 5785, 161: 5805, 165: 5825, + 36: 5180, + 40: 5200, + 44: 5220, + 48: 5240, + 52: 5260, + 56: 5280, + 60: 5300, + 64: 5320, + 100: 5500, + 104: 5520, + 108: 5540, + 112: 5560, + 116: 5580, + 120: 5600, + 124: 5620, + 128: 5640, + 132: 5660, + 136: 5680, + 140: 5700, + 144: 5720, + 149: 5745, + 153: 5765, + 157: 5785, + 161: 5805, + 165: 5825, } # Frequency to channel reverse mapping @@ -114,15 +222,15 @@ def get_channel_from_frequency(frequency_mhz: int) -> int | None: # SECURITY TYPES # ============================================================================= -SECURITY_OPEN = 'Open' -SECURITY_WEP = 'WEP' -SECURITY_WPA = 'WPA' -SECURITY_WPA2 = 'WPA2' -SECURITY_WPA3 = 'WPA3' -SECURITY_WPA_WPA2 = 'WPA/WPA2' -SECURITY_WPA2_WPA3 = 'WPA2/WPA3' -SECURITY_ENTERPRISE = 'Enterprise' -SECURITY_UNKNOWN = 'Unknown' +SECURITY_OPEN = "Open" +SECURITY_WEP = "WEP" +SECURITY_WPA = "WPA" +SECURITY_WPA2 = "WPA2" +SECURITY_WPA3 = "WPA3" +SECURITY_WPA_WPA2 = "WPA/WPA2" +SECURITY_WPA2_WPA3 = "WPA2/WPA3" +SECURITY_ENTERPRISE = "Enterprise" +SECURITY_UNKNOWN = "Unknown" # Security type priority (higher = more secure) SECURITY_PRIORITY = { @@ -141,44 +249,44 @@ SECURITY_PRIORITY = { # CIPHER TYPES # ============================================================================= -CIPHER_NONE = 'None' -CIPHER_WEP = 'WEP' -CIPHER_TKIP = 'TKIP' -CIPHER_CCMP = 'CCMP' -CIPHER_GCMP = 'GCMP' -CIPHER_UNKNOWN = 'Unknown' +CIPHER_NONE = "None" +CIPHER_WEP = "WEP" +CIPHER_TKIP = "TKIP" +CIPHER_CCMP = "CCMP" +CIPHER_GCMP = "GCMP" +CIPHER_UNKNOWN = "Unknown" # ============================================================================= # AUTHENTICATION TYPES # ============================================================================= -AUTH_OPEN = 'Open' -AUTH_PSK = 'PSK' -AUTH_SAE = 'SAE' -AUTH_EAP = 'EAP' -AUTH_OWE = 'OWE' -AUTH_UNKNOWN = 'Unknown' +AUTH_OPEN = "Open" +AUTH_PSK = "PSK" +AUTH_SAE = "SAE" +AUTH_EAP = "EAP" +AUTH_OWE = "OWE" +AUTH_UNKNOWN = "Unknown" # ============================================================================= # CHANNEL WIDTH # ============================================================================= -WIDTH_20_MHZ = '20MHz' -WIDTH_40_MHZ = '40MHz' -WIDTH_80_MHZ = '80MHz' -WIDTH_160_MHZ = '160MHz' -WIDTH_320_MHZ = '320MHz' -WIDTH_UNKNOWN = 'Unknown' +WIDTH_20_MHZ = "20MHz" +WIDTH_40_MHZ = "40MHz" +WIDTH_80_MHZ = "80MHz" +WIDTH_160_MHZ = "160MHz" +WIDTH_320_MHZ = "320MHz" +WIDTH_UNKNOWN = "Unknown" # ============================================================================= # SIGNAL STRENGTH BANDS (for proximity radar) # ============================================================================= -SIGNAL_STRONG = 'strong' # >= -50 dBm -SIGNAL_MEDIUM = 'medium' # -50 to -70 dBm -SIGNAL_WEAK = 'weak' # -70 to -85 dBm -SIGNAL_VERY_WEAK = 'very_weak' # < -85 dBm -SIGNAL_UNKNOWN = 'unknown' +SIGNAL_STRONG = "strong" # >= -50 dBm +SIGNAL_MEDIUM = "medium" # -50 to -70 dBm +SIGNAL_WEAK = "weak" # -70 to -85 dBm +SIGNAL_VERY_WEAK = "very_weak" # < -85 dBm +SIGNAL_UNKNOWN = "unknown" # RSSI thresholds for signal bands RSSI_STRONG = -50 @@ -203,14 +311,14 @@ def get_signal_band(rssi: int | None) -> str: # PROXIMITY BANDS (consistent with Bluetooth) # ============================================================================= -PROXIMITY_IMMEDIATE = 'immediate' # < 3m -PROXIMITY_NEAR = 'near' # 3-10m -PROXIMITY_FAR = 'far' # > 10m -PROXIMITY_UNKNOWN = 'unknown' +PROXIMITY_IMMEDIATE = "immediate" # < 3m +PROXIMITY_NEAR = "near" # 3-10m +PROXIMITY_FAR = "far" # > 10m +PROXIMITY_UNKNOWN = "unknown" # RSSI thresholds for proximity band classification PROXIMITY_RSSI_IMMEDIATE = -55 # >= -55 dBm -> immediate -PROXIMITY_RSSI_NEAR = -70 # >= -70 dBm -> near +PROXIMITY_RSSI_NEAR = -70 # >= -70 dBm -> near def get_proximity_band(rssi: int | None) -> str: @@ -241,22 +349,22 @@ WIFI_EMA_ALPHA = 0.3 # SCAN MODES # ============================================================================= -SCAN_MODE_QUICK = 'quick' # Uses system tools (no monitor mode) -SCAN_MODE_DEEP = 'deep' # Uses airodump-ng (monitor mode required) +SCAN_MODE_QUICK = "quick" # Uses system tools (no monitor mode) +SCAN_MODE_DEEP = "deep" # Uses airodump-ng (monitor mode required) # ============================================================================= # TOOL DETECTION # ============================================================================= # Quick scan tools (by platform priority) -QUICK_SCAN_TOOLS_LINUX = ['nmcli', 'iw', 'iwlist'] -QUICK_SCAN_TOOLS_DARWIN = ['airport'] +QUICK_SCAN_TOOLS_LINUX = ["nmcli", "iw", "iwlist"] +QUICK_SCAN_TOOLS_DARWIN = ["airport"] # Deep scan tools -DEEP_SCAN_TOOLS = ['airodump-ng'] +DEEP_SCAN_TOOLS = ["airodump-ng"] # Monitor mode tools -MONITOR_MODE_TOOLS = ['airmon-ng', 'iw'] +MONITOR_MODE_TOOLS = ["airmon-ng", "iw"] # Tool command timeouts (seconds) TOOL_TIMEOUT_QUICK = 30.0 @@ -266,22 +374,22 @@ TOOL_TIMEOUT_DETECT = 5.0 # AIRODUMP-NG SETTINGS # ============================================================================= -AIRODUMP_OUTPUT_PREFIX = 'airodump_wifi' +AIRODUMP_OUTPUT_PREFIX = "airodump_wifi" AIRODUMP_POLL_INTERVAL = 1.0 # seconds between CSV reads # ============================================================================= # HEURISTIC FLAGS # ============================================================================= -HEURISTIC_HIDDEN = 'hidden' -HEURISTIC_ROGUE_AP = 'rogue_ap' -HEURISTIC_EVIL_TWIN = 'evil_twin' -HEURISTIC_BEACON_FLOOD = 'beacon_flood' -HEURISTIC_WEAK_SECURITY = 'weak_security' -HEURISTIC_DEAUTH_DETECTED = 'deauth_detected' -HEURISTIC_NEW = 'new' -HEURISTIC_PERSISTENT = 'persistent' -HEURISTIC_STRONG_STABLE = 'strong_stable' +HEURISTIC_HIDDEN = "hidden" +HEURISTIC_ROGUE_AP = "rogue_ap" +HEURISTIC_EVIL_TWIN = "evil_twin" +HEURISTIC_BEACON_FLOOD = "beacon_flood" +HEURISTIC_WEAK_SECURITY = "weak_security" +HEURISTIC_DEAUTH_DETECTED = "deauth_detected" +HEURISTIC_NEW = "new" +HEURISTIC_PERSISTENT = "persistent" +HEURISTIC_STRONG_STABLE = "strong_stable" # Thresholds BEACON_FLOOD_THRESHOLD = 50 # Same BSSID seen > 50 times/minute @@ -295,146 +403,147 @@ STABLE_VARIANCE_THRESHOLD = 5.0 # ============================================================================= VENDOR_OUIS = { - '00:00:5E': 'IANA', - '00:03:93': 'Apple', - '00:0A:95': 'Apple', - '00:0D:93': 'Apple', - '00:11:24': 'Apple', - '00:14:51': 'Apple', - '00:16:CB': 'Apple', - '00:17:F2': 'Apple', - '00:19:E3': 'Apple', - '00:1B:63': 'Apple', - '00:1C:B3': 'Apple', - '00:1D:4F': 'Apple', - '00:1E:52': 'Apple', - '00:1E:C2': 'Apple', - '00:1F:5B': 'Apple', - '00:1F:F3': 'Apple', - '00:21:E9': 'Apple', - '00:22:41': 'Apple', - '00:23:12': 'Apple', - '00:23:32': 'Apple', - '00:23:6C': 'Apple', - '00:23:DF': 'Apple', - '00:24:36': 'Apple', - '00:25:00': 'Apple', - '00:25:4B': 'Apple', - '00:25:BC': 'Apple', - '00:26:08': 'Apple', - '00:26:4A': 'Apple', - '00:26:B0': 'Apple', - '00:26:BB': 'Apple', - '00:50:F2': 'Microsoft', - '00:15:5D': 'Microsoft', - '00:17:FA': 'Microsoft', - '00:1D:D8': 'Microsoft', - '00:50:56': 'VMware', - '00:0C:29': 'VMware', - '00:05:69': 'VMware', - '08:00:27': 'VirtualBox', - '00:1C:42': 'Parallels', - '00:16:3E': 'Xen', - 'DC:A6:32': 'Raspberry Pi', - 'B8:27:EB': 'Raspberry Pi', - 'E4:5F:01': 'Raspberry Pi', - '28:CD:C1': 'Raspberry Pi', - '00:1A:11': 'Google', - '00:1A:22': 'Google', - '3C:5A:B4': 'Google', - '54:60:09': 'Google', - '94:EB:2C': 'Google', - 'F4:F5:D8': 'Google', - '00:17:C4': 'Netgear', - '00:1B:2F': 'Netgear', - '00:1E:2A': 'Netgear', - '00:22:3F': 'Netgear', - '00:24:B2': 'Netgear', - '00:26:F2': 'Netgear', - '00:18:F8': 'Cisco', - '00:1A:A1': 'Cisco', - '00:1B:0C': 'Cisco', - '00:1B:D4': 'Cisco', - '00:1C:0E': 'Cisco', - '00:1C:57': 'Cisco', - '00:40:96': 'Cisco', - '00:50:54': 'Cisco', - '00:60:5C': 'Cisco', - 'E8:65:D4': 'Ubiquiti', - 'FC:EC:DA': 'Ubiquiti', - '00:27:22': 'Ubiquiti', - '04:18:D6': 'Ubiquiti', - '18:E8:29': 'Ubiquiti', - '24:A4:3C': 'Ubiquiti', - '44:D9:E7': 'Ubiquiti', - '68:72:51': 'Ubiquiti', - '74:83:C2': 'Ubiquiti', - '78:8A:20': 'Ubiquiti', - 'B4:FB:E4': 'Ubiquiti', - 'F0:9F:C2': 'Ubiquiti', - '00:0C:F1': 'Intel', - '00:13:02': 'Intel', - '00:13:20': 'Intel', - '00:13:CE': 'Intel', - '00:13:E8': 'Intel', - '00:15:00': 'Intel', - '00:15:17': 'Intel', - '00:16:6F': 'Intel', - '00:16:76': 'Intel', - '00:16:EA': 'Intel', - '00:16:EB': 'Intel', - '00:18:DE': 'Intel', - '00:19:D1': 'Intel', - '00:19:D2': 'Intel', - '00:1B:21': 'Intel', - '00:1B:77': 'Intel', - '00:1C:BF': 'Intel', - '00:1D:E0': 'Intel', - '00:1D:E1': 'Intel', - '00:1E:64': 'Intel', - '00:1E:65': 'Intel', - '00:1E:67': 'Intel', - '00:1F:3B': 'Intel', - '00:1F:3C': 'Intel', - '00:20:E0': 'TP-Link', - '00:23:CD': 'TP-Link', - '00:25:86': 'TP-Link', - '00:27:19': 'TP-Link', - '14:CC:20': 'TP-Link', - '14:CF:92': 'TP-Link', - '18:A6:F7': 'TP-Link', - '1C:3B:F3': 'TP-Link', - '30:B5:C2': 'TP-Link', - '50:C7:BF': 'TP-Link', - '54:C8:0F': 'TP-Link', - '60:E3:27': 'TP-Link', - '64:56:01': 'TP-Link', - '64:66:B3': 'TP-Link', - '64:70:02': 'TP-Link', + "00:00:5E": "IANA", + "00:03:93": "Apple", + "00:0A:95": "Apple", + "00:0D:93": "Apple", + "00:11:24": "Apple", + "00:14:51": "Apple", + "00:16:CB": "Apple", + "00:17:F2": "Apple", + "00:19:E3": "Apple", + "00:1B:63": "Apple", + "00:1C:B3": "Apple", + "00:1D:4F": "Apple", + "00:1E:52": "Apple", + "00:1E:C2": "Apple", + "00:1F:5B": "Apple", + "00:1F:F3": "Apple", + "00:21:E9": "Apple", + "00:22:41": "Apple", + "00:23:12": "Apple", + "00:23:32": "Apple", + "00:23:6C": "Apple", + "00:23:DF": "Apple", + "00:24:36": "Apple", + "00:25:00": "Apple", + "00:25:4B": "Apple", + "00:25:BC": "Apple", + "00:26:08": "Apple", + "00:26:4A": "Apple", + "00:26:B0": "Apple", + "00:26:BB": "Apple", + "00:50:F2": "Microsoft", + "00:15:5D": "Microsoft", + "00:17:FA": "Microsoft", + "00:1D:D8": "Microsoft", + "00:50:56": "VMware", + "00:0C:29": "VMware", + "00:05:69": "VMware", + "08:00:27": "VirtualBox", + "00:1C:42": "Parallels", + "00:16:3E": "Xen", + "DC:A6:32": "Raspberry Pi", + "B8:27:EB": "Raspberry Pi", + "E4:5F:01": "Raspberry Pi", + "28:CD:C1": "Raspberry Pi", + "00:1A:11": "Google", + "00:1A:22": "Google", + "3C:5A:B4": "Google", + "54:60:09": "Google", + "94:EB:2C": "Google", + "F4:F5:D8": "Google", + "00:17:C4": "Netgear", + "00:1B:2F": "Netgear", + "00:1E:2A": "Netgear", + "00:22:3F": "Netgear", + "00:24:B2": "Netgear", + "00:26:F2": "Netgear", + "00:18:F8": "Cisco", + "00:1A:A1": "Cisco", + "00:1B:0C": "Cisco", + "00:1B:D4": "Cisco", + "00:1C:0E": "Cisco", + "00:1C:57": "Cisco", + "00:40:96": "Cisco", + "00:50:54": "Cisco", + "00:60:5C": "Cisco", + "E8:65:D4": "Ubiquiti", + "FC:EC:DA": "Ubiquiti", + "00:27:22": "Ubiquiti", + "04:18:D6": "Ubiquiti", + "18:E8:29": "Ubiquiti", + "24:A4:3C": "Ubiquiti", + "44:D9:E7": "Ubiquiti", + "68:72:51": "Ubiquiti", + "74:83:C2": "Ubiquiti", + "78:8A:20": "Ubiquiti", + "B4:FB:E4": "Ubiquiti", + "F0:9F:C2": "Ubiquiti", + "00:0C:F1": "Intel", + "00:13:02": "Intel", + "00:13:20": "Intel", + "00:13:CE": "Intel", + "00:13:E8": "Intel", + "00:15:00": "Intel", + "00:15:17": "Intel", + "00:16:6F": "Intel", + "00:16:76": "Intel", + "00:16:EA": "Intel", + "00:16:EB": "Intel", + "00:18:DE": "Intel", + "00:19:D1": "Intel", + "00:19:D2": "Intel", + "00:1B:21": "Intel", + "00:1B:77": "Intel", + "00:1C:BF": "Intel", + "00:1D:E0": "Intel", + "00:1D:E1": "Intel", + "00:1E:64": "Intel", + "00:1E:65": "Intel", + "00:1E:67": "Intel", + "00:1F:3B": "Intel", + "00:1F:3C": "Intel", + "00:20:E0": "TP-Link", + "00:23:CD": "TP-Link", + "00:25:86": "TP-Link", + "00:27:19": "TP-Link", + "14:CC:20": "TP-Link", + "14:CF:92": "TP-Link", + "18:A6:F7": "TP-Link", + "1C:3B:F3": "TP-Link", + "30:B5:C2": "TP-Link", + "50:C7:BF": "TP-Link", + "54:C8:0F": "TP-Link", + "60:E3:27": "TP-Link", + "64:56:01": "TP-Link", + "64:66:B3": "TP-Link", + "64:70:02": "TP-Link", } -def get_vendor_from_mac(mac: str) -> str | None: - """Get vendor name from MAC address OUI.""" - if not mac: - return None - # Normalize MAC format - mac_upper = mac.upper().replace('-', ':') - oui = mac_upper[:8] - vendor = VENDOR_OUIS.get(oui) - if vendor: - return vendor - - # Fallback to expanded OUI database if available - try: - from data.oui import get_manufacturer - manufacturer = get_manufacturer(mac_upper) - if manufacturer and manufacturer != 'Unknown': - return manufacturer - except Exception: - return None - - return None +def get_vendor_from_mac(mac: str) -> str | None: + """Get vendor name from MAC address OUI.""" + if not mac: + return None + # Normalize MAC format + mac_upper = mac.upper().replace("-", ":") + oui = mac_upper[:8] + vendor = VENDOR_OUIS.get(oui) + if vendor: + return vendor + + # Fallback to expanded OUI database if available + try: + from data.oui import get_manufacturer + + manufacturer = get_manufacturer(mac_upper) + if manufacturer and manufacturer != "Unknown": + return manufacturer + except Exception: + return None + + return None # ============================================================================= diff --git a/utils/wifi/deauth_detector.py b/utils/wifi/deauth_detector.py index 724627a..b0847a0 100644 --- a/utils/wifi/deauth_detector.py +++ b/utils/wifi/deauth_detector.py @@ -57,6 +57,7 @@ DEAUTH_REASON_CODES = { @dataclass class DeauthPacketInfo: """Information about a captured deauth/disassoc packet.""" + timestamp: float frame_type: str # 'deauth' or 'disassoc' src_mac: str @@ -69,6 +70,7 @@ class DeauthPacketInfo: @dataclass class DeauthTracker: """Tracks deauth packets for a specific source/dest/bssid combination.""" + packets: list[DeauthPacketInfo] = field(default_factory=list) first_seen: float = 0.0 last_seen: float = 0.0 @@ -100,6 +102,7 @@ class DeauthTracker: @dataclass class DeauthAlert: """A deauthentication attack alert.""" + id: str timestamp: float severity: str # 'low', 'medium', 'high' @@ -136,38 +139,38 @@ class DeauthAlert: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'id': self.id, - 'type': 'deauth_alert', - 'timestamp': self.timestamp, - 'severity': self.severity, - 'attacker': { - 'mac': self.attacker_mac, - 'vendor': self.attacker_vendor, - 'signal_dbm': self.attacker_signal_dbm, - 'is_spoofed_ap': self.is_spoofed_ap, + "id": self.id, + "type": "deauth_alert", + "timestamp": self.timestamp, + "severity": self.severity, + "attacker": { + "mac": self.attacker_mac, + "vendor": self.attacker_vendor, + "signal_dbm": self.attacker_signal_dbm, + "is_spoofed_ap": self.is_spoofed_ap, }, - 'target': { - 'mac': self.target_mac, - 'vendor': self.target_vendor, - 'type': self.target_type, - 'known_from_scan': self.target_known_from_scan, + "target": { + "mac": self.target_mac, + "vendor": self.target_vendor, + "type": self.target_type, + "known_from_scan": self.target_known_from_scan, }, - 'access_point': { - 'bssid': self.ap_bssid, - 'essid': self.ap_essid, - 'channel': self.ap_channel, + "access_point": { + "bssid": self.ap_bssid, + "essid": self.ap_essid, + "channel": self.ap_channel, }, - 'attack_info': { - 'frame_type': self.frame_type, - 'reason_code': self.reason_code, - 'reason_text': self.reason_text, - 'packet_count': self.packet_count, - 'window_seconds': self.window_seconds, - 'packets_per_second': self.packets_per_second, + "attack_info": { + "frame_type": self.frame_type, + "reason_code": self.reason_code, + "reason_text": self.reason_text, + "packet_count": self.packet_count, + "window_seconds": self.window_seconds, + "packets_per_second": self.packets_per_second, }, - 'analysis': { - 'attack_type': self.attack_type, - 'description': self.description, + "analysis": { + "attack_type": self.attack_type, + "description": self.description, }, } @@ -226,12 +229,12 @@ class DeauthDetector: def stats(self) -> dict: """Get detector statistics.""" return { - 'is_running': self.is_running, - 'interface': self.interface, - 'started_at': self._started_at, - 'packets_captured': self._packets_captured, - 'alerts_generated': self._alerts_generated, - 'active_trackers': len(self._trackers), + "is_running": self.is_running, + "interface": self.interface, + "started_at": self._started_at, + "packets_captured": self._packets_captured, + "alerts_generated": self._alerts_generated, + "active_trackers": len(self._trackers), } def start(self) -> bool: @@ -299,10 +302,12 @@ class DeauthDetector: from scapy.all import Dot11, Dot11Deauth, Dot11Disas, sniff except ImportError: logger.error("scapy not installed. Install with: pip install scapy") - self.event_callback({ - 'type': 'deauth_error', - 'error': 'scapy not installed', - }) + self.event_callback( + { + "type": "deauth_error", + "error": "scapy not installed", + } + ) return logger.info(f"Starting deauth sniff on {self.interface}") @@ -341,22 +346,28 @@ class DeauthDetector: except OSError as e: if "No such device" in str(e): logger.error(f"Interface {self.interface} not found") - self.event_callback({ - 'type': 'deauth_error', - 'error': f'Interface {self.interface} not found', - }) + self.event_callback( + { + "type": "deauth_error", + "error": f"Interface {self.interface} not found", + } + ) else: logger.exception(f"Sniff error: {e}") - self.event_callback({ - 'type': 'deauth_error', - 'error': str(e), - }) + self.event_callback( + { + "type": "deauth_error", + "error": str(e), + } + ) except Exception as e: logger.exception(f"Sniff error: {e}") - self.event_callback({ - 'type': 'deauth_error', - 'error': str(e), - }) + self.event_callback( + { + "type": "deauth_error", + "error": str(e), + } + ) def _process_deauth_packet(self, pkt): """Process a deauth/disassoc packet and emit alert if threshold exceeded.""" @@ -367,19 +378,19 @@ class DeauthDetector: # Determine frame type if pkt.haslayer(Dot11Deauth): - frame_type = 'deauth' + frame_type = "deauth" reason_code = pkt[Dot11Deauth].reason elif pkt.haslayer(Dot11Disas): - frame_type = 'disassoc' + frame_type = "disassoc" reason_code = pkt[Dot11Disas].reason else: return # Extract addresses from Dot11 layer dot11 = pkt[Dot11] - dst_mac = (dot11.addr1 or '').upper() - src_mac = (dot11.addr2 or '').upper() - bssid = (dot11.addr3 or '').upper() + dst_mac = (dot11.addr1 or "").upper() + src_mac = (dot11.addr2 or "").upper() + bssid = (dot11.addr3 or "").upper() # Skip if addresses are missing if not src_mac or not dst_mac: @@ -448,11 +459,11 @@ class DeauthDetector: # Determine severity if packet_count >= DEAUTH_CRITICAL_THRESHOLD: - severity = 'high' + severity = "high" elif packet_count >= DEAUTH_ALERT_THRESHOLD * 2.5: - severity = 'medium' + severity = "medium" else: - severity = 'low' + severity = "low" # Lookup AP info ap_info = self._lookup_ap(bssid) @@ -461,12 +472,12 @@ class DeauthDetector: target_info = self._lookup_device(dst_mac) # Determine target type - if dst_mac == 'FF:FF:FF:FF:FF:FF': - target_type = 'broadcast' + if dst_mac == "FF:FF:FF:FF:FF:FF": + target_type = "broadcast" elif dst_mac in self._get_known_aps(): - target_type = 'ap' + target_type = "ap" else: - target_type = 'client' + target_type = "client" # Check if source is spoofed (matches known AP) is_spoofed = self._check_spoofed_source(src_mac) @@ -482,15 +493,17 @@ class DeauthDetector: pps = 0.0 # Determine attack type and description - if dst_mac == 'FF:FF:FF:FF:FF:FF': - attack_type = 'broadcast' + if dst_mac == "FF:FF:FF:FF:FF:FF": + attack_type = "broadcast" description = "Broadcast deauth flood targeting all clients on the network" - elif target_type == 'ap': - attack_type = 'ap_flood' + elif target_type == "ap": + attack_type = "ap_flood" description = "Deauth flood targeting access point" else: - attack_type = 'targeted' - description = f"Targeted deauth flood against {'known' if target_info.get('known_from_scan') else 'unknown'} client" + attack_type = "targeted" + description = ( + f"Targeted deauth flood against {'known' if target_info.get('known_from_scan') else 'unknown'} client" + ) # Get reason code info reason_code = latest_pkt.reason_code if latest_pkt else 0 @@ -516,13 +529,13 @@ class DeauthDetector: attacker_signal_dbm=signal_dbm, is_spoofed_ap=is_spoofed, target_mac=dst_mac, - target_vendor=target_info.get('vendor'), + target_vendor=target_info.get("vendor"), target_type=target_type, - target_known_from_scan=target_info.get('known_from_scan', False), + target_known_from_scan=target_info.get("known_from_scan", False), ap_bssid=bssid, - ap_essid=ap_info.get('essid'), - ap_channel=ap_info.get('channel'), - frame_type=latest_pkt.frame_type if latest_pkt else 'deauth', + ap_essid=ap_info.get("essid"), + ap_channel=ap_info.get("channel"), + frame_type=latest_pkt.frame_type if latest_pkt else "deauth", reason_code=reason_code, reason_text=reason_text, packet_count=packet_count, @@ -535,21 +548,21 @@ class DeauthDetector: def _lookup_ap(self, bssid: str) -> dict: """Get AP info from current scan data.""" if not self.get_networks: - return {'bssid': bssid, 'essid': None, 'channel': None} + return {"bssid": bssid, "essid": None, "channel": None} try: networks = self.get_networks() ap = networks.get(bssid.upper()) if ap: return { - 'bssid': bssid, - 'essid': ap.get('essid') or ap.get('ssid'), - 'channel': ap.get('channel'), + "bssid": bssid, + "essid": ap.get("essid") or ap.get("ssid"), + "channel": ap.get("channel"), } except Exception as e: logger.debug(f"Error looking up AP {bssid}: {e}") - return {'bssid': bssid, 'essid': None, 'channel': None} + return {"bssid": bssid, "essid": None, "channel": None} def _lookup_device(self, mac: str) -> dict: """Get device info and vendor from MAC.""" @@ -565,9 +578,9 @@ class DeauthDetector: pass return { - 'mac': mac, - 'vendor': vendor, - 'known_from_scan': known_from_scan, + "mac": mac, + "vendor": vendor, + "known_from_scan": known_from_scan, } def _get_known_aps(self) -> set[str]: @@ -589,14 +602,16 @@ class DeauthDetector: """Get vendor from MAC OUI.""" try: from data.oui import get_manufacturer + vendor = get_manufacturer(mac) - return vendor if vendor != 'Unknown' else None + return vendor if vendor != "Unknown" else None except Exception: pass # Fallback to wifi constants try: from utils.wifi.constants import get_vendor_from_mac + return get_vendor_from_mac(mac) except Exception: return None diff --git a/utils/wifi/hidden_ssid.py b/utils/wifi/hidden_ssid.py index 30eca24..e1c4a58 100644 --- a/utils/wifi/hidden_ssid.py +++ b/utils/wifi/hidden_ssid.py @@ -36,6 +36,7 @@ _correlator_lock = threading.Lock() @dataclass class ProbeRecord: """Record of a probe request.""" + timestamp: datetime client_mac: str probed_ssid: str @@ -44,6 +45,7 @@ class ProbeRecord: @dataclass class AssociationRecord: """Record of a client association.""" + timestamp: datetime client_mac: str bssid: str @@ -52,6 +54,7 @@ class AssociationRecord: @dataclass class CorrelationResult: """Result of an SSID correlation.""" + bssid: str revealed_ssid: str client_mac: str @@ -110,11 +113,13 @@ class HiddenSSIDCorrelator: client_mac = client_mac.upper() with self._lock: - self._probe_records.append(ProbeRecord( - timestamp=timestamp, - client_mac=client_mac, - probed_ssid=probed_ssid, - )) + self._probe_records.append( + ProbeRecord( + timestamp=timestamp, + client_mac=client_mac, + probed_ssid=probed_ssid, + ) + ) # Prune old records self._prune_records() @@ -139,11 +144,13 @@ class HiddenSSIDCorrelator: bssid = bssid.upper() with self._lock: - self._association_records.append(AssociationRecord( - timestamp=timestamp, - client_mac=client_mac, - bssid=bssid, - )) + self._association_records.append( + AssociationRecord( + timestamp=timestamp, + client_mac=client_mac, + bssid=bssid, + ) + ) # Prune old records self._prune_records() @@ -206,10 +213,7 @@ class HiddenSSIDCorrelator: Dict of BSSID -> revealed SSID. """ with self._lock: - return { - bssid: result.revealed_ssid - for bssid, result in self._revealed.items() - } + return {bssid: result.revealed_ssid for bssid, result in self._revealed.items()} def set_callback(self, callback: Callable[[CorrelationResult], None]): """Set callback for when an SSID is revealed.""" @@ -219,15 +223,9 @@ class HiddenSSIDCorrelator: """Remove records older than the correlation window.""" cutoff = datetime.now() - timedelta(seconds=self.correlation_window * 2) - self._probe_records = [ - r for r in self._probe_records - if r.timestamp > cutoff - ] + self._probe_records = [r for r in self._probe_records if r.timestamp > cutoff] - self._association_records = [ - r for r in self._association_records - if r.timestamp > cutoff - ] + self._association_records = [r for r in self._association_records if r.timestamp > cutoff] def _check_correlations(self): """Check for new SSID correlations.""" @@ -241,8 +239,7 @@ class HiddenSSIDCorrelator: # Find associations with this hidden AP relevant_associations = [ - a for a in self._association_records - if a.bssid == bssid and (now - a.timestamp) <= window + a for a in self._association_records if a.bssid == bssid and (now - a.timestamp) <= window ] if not relevant_associations: @@ -251,7 +248,8 @@ class HiddenSSIDCorrelator: # For each associated client, look for recent probes for assoc in relevant_associations: client_probes = [ - p for p in self._probe_records + p + for p in self._probe_records if p.client_mac == assoc.client_mac and abs((p.timestamp - assoc.timestamp).total_seconds()) <= self.correlation_window ] @@ -274,14 +272,13 @@ class HiddenSSIDCorrelator: client_mac=assoc.client_mac, confidence=confidence, correlation_time=now, - method='probe_association', + method="probe_association", ) self._revealed[bssid] = result logger.info( - f"Hidden SSID revealed: {bssid} -> '{latest_probe.probed_ssid}' " - f"(confidence: {confidence:.2f})" + f"Hidden SSID revealed: {bssid} -> '{latest_probe.probed_ssid}' (confidence: {confidence:.2f})" ) # Callback diff --git a/utils/wifi/models.py b/utils/wifi/models.py index f31a0e0..ffb1835 100644 --- a/utils/wifi/models.py +++ b/utils/wifi/models.py @@ -45,7 +45,7 @@ class WiFiObservation: @property def is_hidden(self) -> bool: """Check if this is a hidden network.""" - return not self.essid or self.essid.strip() == '' + return not self.essid or self.essid.strip() == "" @property def band(self) -> str: @@ -62,21 +62,21 @@ class WiFiObservation: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'timestamp': self.timestamp.isoformat(), - 'bssid': self.bssid, - 'essid': self.essid, - 'is_hidden': self.is_hidden, - 'channel': self.channel, - 'frequency_mhz': self.frequency_mhz, - 'band': self.band, - 'rssi': self.rssi, - 'security': self.security, - 'cipher': self.cipher, - 'auth': self.auth, - 'width': self.width, - 'beacon_count': self.beacon_count, - 'data_count': self.data_count, - 'vendor': self.vendor, + "timestamp": self.timestamp.isoformat(), + "bssid": self.bssid, + "essid": self.essid, + "is_hidden": self.is_hidden, + "channel": self.channel, + "frequency_mhz": self.frequency_mhz, + "band": self.band, + "rssi": self.rssi, + "security": self.security, + "cipher": self.cipher, + "auth": self.auth, + "width": self.width, + "beacon_count": self.beacon_count, + "data_count": self.data_count, + "vendor": self.vendor, } @@ -164,111 +164,99 @@ class WiFiAccessPoint: if not self.rssi_samples: return [] samples = self.rssi_samples[-max_points:] - return [ - {'timestamp': ts.isoformat(), 'rssi': rssi} - for ts, rssi in samples - ] + return [{"timestamp": ts.isoformat(), "rssi": rssi} for ts, rssi in samples] def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { # Identity - 'bssid': self.bssid, - 'essid': self.essid, - 'display_name': self.display_name, - 'is_hidden': self.is_hidden, - 'revealed_essid': self.revealed_essid, - + "bssid": self.bssid, + "essid": self.essid, + "display_name": self.display_name, + "is_hidden": self.is_hidden, + "revealed_essid": self.revealed_essid, # Radio - 'channel': self.channel, - 'frequency_mhz': self.frequency_mhz, - 'band': self.band, - 'width': self.width, - + "channel": self.channel, + "frequency_mhz": self.frequency_mhz, + "band": self.band, + "width": self.width, # Signal - 'rssi_current': self.rssi_current, - 'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None, - 'rssi_min': self.rssi_min, - 'rssi_max': self.rssi_max, - 'rssi_variance': round(self.rssi_variance, 2) if self.rssi_variance else None, - 'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None, - 'rssi_history': self.get_rssi_history(), - + "rssi_current": self.rssi_current, + "rssi_median": round(self.rssi_median, 1) if self.rssi_median else None, + "rssi_min": self.rssi_min, + "rssi_max": self.rssi_max, + "rssi_variance": round(self.rssi_variance, 2) if self.rssi_variance else None, + "rssi_ema": round(self.rssi_ema, 1) if self.rssi_ema else None, + "rssi_history": self.get_rssi_history(), # Proximity - 'signal_band': self.signal_band, - 'proximity_band': self.proximity_band, - 'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None, - 'distance_confidence': round(self.distance_confidence, 2), - + "signal_band": self.signal_band, + "proximity_band": self.proximity_band, + "estimated_distance_m": round(self.estimated_distance_m, 2) if self.estimated_distance_m else None, + "distance_confidence": round(self.distance_confidence, 2), # Security - 'security': self.security, - 'cipher': self.cipher, - 'auth': self.auth, - + "security": self.security, + "cipher": self.cipher, + "auth": self.auth, # Timestamps - 'first_seen': self.first_seen.isoformat(), - 'last_seen': self.last_seen.isoformat(), - 'age_seconds': round(self.age_seconds, 1), - 'duration_seconds': round(self.duration_seconds, 1), - 'seen_count': self.seen_count, - 'seen_rate': round(self.seen_rate, 2), - + "first_seen": self.first_seen.isoformat(), + "last_seen": self.last_seen.isoformat(), + "age_seconds": round(self.age_seconds, 1), + "duration_seconds": round(self.duration_seconds, 1), + "seen_count": self.seen_count, + "seen_rate": round(self.seen_rate, 2), # Traffic - 'beacon_count': self.beacon_count, - 'data_count': self.data_count, - 'client_count': self.client_count, - + "beacon_count": self.beacon_count, + "data_count": self.data_count, + "client_count": self.client_count, # Metadata - 'vendor': self.vendor, - + "vendor": self.vendor, # Heuristics - 'heuristic_flags': self.heuristic_flags, - 'heuristics': { - 'is_new': self.is_new, - 'is_persistent': self.is_persistent, - 'is_strong_stable': self.is_strong_stable, + "heuristic_flags": self.heuristic_flags, + "heuristics": { + "is_new": self.is_new, + "is_persistent": self.is_persistent, + "is_strong_stable": self.is_strong_stable, }, - # Baseline - 'in_baseline': self.in_baseline, - 'baseline_id': self.baseline_id, + "in_baseline": self.in_baseline, + "baseline_id": self.baseline_id, } def to_summary_dict(self) -> dict: """Compact dictionary for list views.""" return { - 'bssid': self.bssid, - 'essid': self.essid, - 'display_name': self.display_name, - 'is_hidden': self.is_hidden, - 'channel': self.channel, - 'band': self.band, - 'rssi_current': self.rssi_current, - 'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None, - 'signal_band': self.signal_band, - 'proximity_band': self.proximity_band, - 'security': self.security, - 'vendor': self.vendor, - 'client_count': self.client_count, - 'last_seen': self.last_seen.isoformat(), - 'age_seconds': round(self.age_seconds, 1), - 'heuristic_flags': self.heuristic_flags, - 'in_baseline': self.in_baseline, + "bssid": self.bssid, + "essid": self.essid, + "display_name": self.display_name, + "is_hidden": self.is_hidden, + "channel": self.channel, + "band": self.band, + "rssi_current": self.rssi_current, + "rssi_median": round(self.rssi_median, 1) if self.rssi_median else None, + "signal_band": self.signal_band, + "proximity_band": self.proximity_band, + "security": self.security, + "vendor": self.vendor, + "client_count": self.client_count, + "last_seen": self.last_seen.isoformat(), + "age_seconds": round(self.age_seconds, 1), + "heuristic_flags": self.heuristic_flags, + "in_baseline": self.in_baseline, } - def to_legacy_dict(self) -> dict: - """Convert to legacy format for TSCM compatibility.""" - return { - 'bssid': self.bssid, - 'essid': self.essid or '', - 'vendor': self.vendor, - 'power': str(self.rssi_current) if self.rssi_current else '-100', - 'channel': str(self.channel) if self.channel else '', - 'privacy': self.security, - 'first_seen': self.first_seen.isoformat() if self.first_seen else '', - 'last_seen': self.last_seen.isoformat() if self.last_seen else '', - 'beacon_count': str(self.beacon_count), - 'lan_ip': '', # Not tracked in new system + def to_legacy_dict(self) -> dict: + """Convert to legacy format for TSCM compatibility.""" + return { + "bssid": self.bssid, + "essid": self.essid or "", + "vendor": self.vendor, + "power": str(self.rssi_current) if self.rssi_current else "-100", + "channel": str(self.channel) if self.channel else "", + "privacy": self.security, + "first_seen": self.first_seen.isoformat() if self.first_seen else "", + "last_seen": self.last_seen.isoformat() if self.last_seen else "", + "beacon_count": str(self.beacon_count), + "lan_ip": "", # Not tracked in new system } @@ -323,50 +311,40 @@ class WiFiClient: if not self.rssi_samples: return [] samples = self.rssi_samples[-max_points:] - return [ - {'timestamp': ts.isoformat(), 'rssi': rssi} - for ts, rssi in samples - ] + return [{"timestamp": ts.isoformat(), "rssi": rssi} for ts, rssi in samples] def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'mac': self.mac, - 'vendor': self.vendor, - + "mac": self.mac, + "vendor": self.vendor, # Signal - 'rssi_current': self.rssi_current, - 'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None, - 'rssi_min': self.rssi_min, - 'rssi_max': self.rssi_max, - 'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None, - 'rssi_history': self.get_rssi_history(), - + "rssi_current": self.rssi_current, + "rssi_median": round(self.rssi_median, 1) if self.rssi_median else None, + "rssi_min": self.rssi_min, + "rssi_max": self.rssi_max, + "rssi_ema": round(self.rssi_ema, 1) if self.rssi_ema else None, + "rssi_history": self.get_rssi_history(), # Proximity - 'signal_band': self.signal_band, - 'proximity_band': self.proximity_band, - 'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None, - + "signal_band": self.signal_band, + "proximity_band": self.proximity_band, + "estimated_distance_m": round(self.estimated_distance_m, 2) if self.estimated_distance_m else None, # Association - 'associated_bssid': self.associated_bssid, - 'is_associated': self.is_associated, - + "associated_bssid": self.associated_bssid, + "is_associated": self.is_associated, # Probes - 'probed_ssids': self.probed_ssids, - 'probe_count': len(self.probed_ssids), - + "probed_ssids": self.probed_ssids, + "probe_count": len(self.probed_ssids), # Timestamps - 'first_seen': self.first_seen.isoformat(), - 'last_seen': self.last_seen.isoformat(), - 'age_seconds': round(self.age_seconds, 1), - 'seen_count': self.seen_count, - + "first_seen": self.first_seen.isoformat(), + "last_seen": self.last_seen.isoformat(), + "age_seconds": round(self.age_seconds, 1), + "seen_count": self.seen_count, # Traffic - 'packets_sent': self.packets_sent, - 'packets_received': self.packets_received, - + "packets_sent": self.packets_sent, + "packets_received": self.packets_received, # Heuristics - 'heuristic_flags': self.heuristic_flags, + "heuristic_flags": self.heuristic_flags, } @@ -383,11 +361,11 @@ class WiFiProbeRequest: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'timestamp': self.timestamp.isoformat(), - 'client_mac': self.client_mac, - 'probed_ssid': self.probed_ssid, - 'rssi': self.rssi, - 'client_vendor': self.client_vendor, + "timestamp": self.timestamp.isoformat(), + "client_mac": self.client_mac, + "probed_ssid": self.probed_ssid, + "rssi": self.rssi, + "client_vendor": self.client_vendor, } @@ -417,16 +395,16 @@ class ChannelStats: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'channel': self.channel, - 'band': self.band, - 'frequency_mhz': self.frequency_mhz, - 'ap_count': self.ap_count, - 'client_count': self.client_count, - 'rssi_avg': round(self.rssi_avg, 1) if self.rssi_avg else None, - 'rssi_min': self.rssi_min, - 'rssi_max': self.rssi_max, - 'utilization_score': round(self.utilization_score, 3), - 'recommendation_rank': self.recommendation_rank, + "channel": self.channel, + "band": self.band, + "frequency_mhz": self.frequency_mhz, + "ap_count": self.ap_count, + "client_count": self.client_count, + "rssi_avg": round(self.rssi_avg, 1) if self.rssi_avg else None, + "rssi_min": self.rssi_min, + "rssi_max": self.rssi_max, + "utilization_score": round(self.utilization_score, 3), + "recommendation_rank": self.recommendation_rank, } @@ -444,12 +422,12 @@ class ChannelRecommendation: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'channel': self.channel, - 'band': self.band, - 'score': round(self.score, 3), - 'reason': self.reason, - 'is_dfs': self.is_dfs, - 'rank': self.recommendation_rank, + "channel": self.channel, + "band": self.band, + "score": round(self.score, 3), + "reason": self.reason, + "is_dfs": self.is_dfs, + "rank": self.recommendation_rank, } @@ -497,42 +475,38 @@ class WiFiScanResult: """Convert to dictionary for JSON serialization.""" return { # Entities - 'access_points': [ap.to_dict() for ap in self.access_points], - 'clients': [c.to_dict() for c in self.clients], - 'probe_requests': [p.to_dict() for p in self.probe_requests], - + "access_points": [ap.to_dict() for ap in self.access_points], + "clients": [c.to_dict() for c in self.clients], + "probe_requests": [p.to_dict() for p in self.probe_requests], # Channel analysis - 'channel_stats': [cs.to_dict() for cs in self.channel_stats], - 'recommendations': [r.to_dict() for r in self.recommendations], - + "channel_stats": [cs.to_dict() for cs in self.channel_stats], + "recommendations": [r.to_dict() for r in self.recommendations], # Metadata - 'scan_mode': self.scan_mode, - 'interface': self.interface, - 'started_at': self.started_at.isoformat() if self.started_at else None, - 'completed_at': self.completed_at.isoformat() if self.completed_at else None, - 'duration_seconds': round(self.duration_seconds, 2) if self.duration_seconds else None, - + "scan_mode": self.scan_mode, + "interface": self.interface, + "started_at": self.started_at.isoformat() if self.started_at else None, + "completed_at": self.completed_at.isoformat() if self.completed_at else None, + "duration_seconds": round(self.duration_seconds, 2) if self.duration_seconds else None, # Stats - 'network_count': self.network_count, - 'client_count': self.client_count, - 'hidden_count': self.hidden_count, - + "network_count": self.network_count, + "client_count": self.client_count, + "hidden_count": self.hidden_count, # Status - 'is_complete': self.is_complete, - 'error': self.error, - 'warnings': self.warnings, + "is_complete": self.is_complete, + "error": self.error, + "warnings": self.warnings, } def to_summary_dict(self) -> dict: """Compact summary for status endpoints.""" return { - 'scan_mode': self.scan_mode, - 'interface': self.interface, - 'network_count': self.network_count, - 'client_count': self.client_count, - 'hidden_count': self.hidden_count, - 'is_complete': self.is_complete, - 'error': self.error, + "scan_mode": self.scan_mode, + "interface": self.interface, + "network_count": self.network_count, + "client_count": self.client_count, + "hidden_count": self.hidden_count, + "is_complete": self.is_complete, + "error": self.error, } @@ -558,14 +532,14 @@ class WiFiScanStatus: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'is_scanning': self.is_scanning, - 'scan_mode': self.scan_mode, - 'interface': self.interface, - 'started_at': self.started_at.isoformat() if self.started_at else None, - 'elapsed_seconds': round(self.elapsed_seconds, 1) if self.elapsed_seconds else None, - 'networks_found': self.networks_found, - 'clients_found': self.clients_found, - 'error': self.error, + "is_scanning": self.is_scanning, + "scan_mode": self.scan_mode, + "interface": self.interface, + "started_at": self.started_at.isoformat() if self.started_at else None, + "elapsed_seconds": round(self.elapsed_seconds, 1) if self.elapsed_seconds else None, + "networks_found": self.networks_found, + "clients_found": self.clients_found, + "error": self.error, } @@ -574,7 +548,7 @@ class WiFiCapabilities: """WiFi system capabilities check result.""" # Platform - platform: str = 'unknown' # 'linux', 'darwin', 'windows' + platform: str = "unknown" # 'linux', 'darwin', 'windows' is_root: bool = False # Interfaces @@ -600,54 +574,39 @@ class WiFiCapabilities: @property def can_quick_scan(self) -> bool: """Whether quick scanning is available.""" - return ( - self.has_nmcli or - self.has_iw or - self.has_iwlist or - self.has_airport - ) and len(self.interfaces) > 0 + return (self.has_nmcli or self.has_iw or self.has_iwlist or self.has_airport) and len(self.interfaces) > 0 @property def can_deep_scan(self) -> bool: """Whether deep scanning is available.""" - return ( - self.has_airmon_ng and - self.has_airodump_ng and - self.has_monitor_capable_interface and - self.is_root - ) + return self.has_airmon_ng and self.has_airodump_ng and self.has_monitor_capable_interface and self.is_root def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { # Status - 'available': self.can_quick_scan, - 'can_quick_scan': self.can_quick_scan, - 'can_deep_scan': self.can_deep_scan, - + "available": self.can_quick_scan, + "can_quick_scan": self.can_quick_scan, + "can_deep_scan": self.can_deep_scan, # Platform - 'platform': self.platform, - 'is_root': self.is_root, - + "platform": self.platform, + "is_root": self.is_root, # Interfaces - 'interfaces': self.interfaces, - 'default_interface': self.default_interface, - + "interfaces": self.interfaces, + "default_interface": self.default_interface, # Quick scan tools - 'tools': { - 'nmcli': self.has_nmcli, - 'iw': self.has_iw, - 'iwlist': self.has_iwlist, - 'airport': self.has_airport, - 'airmon_ng': self.has_airmon_ng, - 'airodump_ng': self.has_airodump_ng, + "tools": { + "nmcli": self.has_nmcli, + "iw": self.has_iw, + "iwlist": self.has_iwlist, + "airport": self.has_airport, + "airmon_ng": self.has_airmon_ng, + "airodump_ng": self.has_airodump_ng, }, - 'preferred_quick_tool': self.preferred_quick_tool, - + "preferred_quick_tool": self.preferred_quick_tool, # Deep scan - 'has_monitor_capable_interface': self.has_monitor_capable_interface, - 'monitor_interface': self.monitor_interface, - + "has_monitor_capable_interface": self.has_monitor_capable_interface, + "monitor_interface": self.monitor_interface, # Issues - 'issues': self.issues, + "issues": self.issues, } diff --git a/utils/wifi/parsers/__init__.py b/utils/wifi/parsers/__init__.py index d5b32b6..55a98e5 100644 --- a/utils/wifi/parsers/__init__.py +++ b/utils/wifi/parsers/__init__.py @@ -11,9 +11,9 @@ from .iwlist import parse_iwlist_scan from .nmcli import parse_nmcli_scan __all__ = [ - 'parse_airport_scan', - 'parse_nmcli_scan', - 'parse_iw_scan', - 'parse_iwlist_scan', - 'parse_airodump_csv', + "parse_airport_scan", + "parse_nmcli_scan", + "parse_iw_scan", + "parse_iwlist_scan", + "parse_airodump_csv", ] diff --git a/utils/wifi/parsers/airodump.py b/utils/wifi/parsers/airodump.py index 2bcac83..c0bc7df 100644 --- a/utils/wifi/parsers/airodump.py +++ b/utils/wifi/parsers/airodump.py @@ -62,28 +62,28 @@ def parse_airodump_csv(filepath: str) -> tuple[list[WiFiObservation], list[dict] clients = [] try: - with open(filepath, encoding='utf-8', errors='replace') as f: + with open(filepath, encoding="utf-8", errors="replace") as f: content = f.read() # airodump-ng separates sections with blank lines # Split into AP section and Station section - sections = content.split('\n\n') + sections = content.split("\n\n") for section in sections: section = section.strip() if not section: continue - lines = section.split('\n') + lines = section.split("\n") if not lines: continue header = lines[0].strip() - if header.startswith('BSSID'): + if header.startswith("BSSID"): # Access Points section networks = _parse_ap_section(lines) - elif header.startswith('Station MAC'): + elif header.startswith("Station MAC"): # Clients/Stations section clients = _parse_client_section(lines) @@ -104,33 +104,33 @@ def _parse_ap_section(lines: list[str]) -> list[WiFiObservation]: # Parse header to get column indices header = lines[0] - header_parts = [h.strip().lower() for h in header.split(',')] + header_parts = [h.strip().lower() for h in header.split(",")] # Find column indices col_map = {} for i, col in enumerate(header_parts): - if 'bssid' in col: - col_map['bssid'] = i - elif 'channel' in col and 'id-length' not in col: - col_map['channel'] = i - elif 'privacy' in col: - col_map['privacy'] = i - elif 'cipher' in col: - col_map['cipher'] = i - elif 'authentication' in col: - col_map['auth'] = i - elif 'power' in col: - col_map['power'] = i - elif 'beacons' in col or '# beacons' in col: - col_map['beacons'] = i - elif '# iv' in col or 'iv' in col: - col_map['data'] = i - elif 'essid' in col: - col_map['essid'] = i - elif 'first time seen' in col: - col_map['first_seen'] = i - elif 'last time seen' in col: - col_map['last_seen'] = i + if "bssid" in col: + col_map["bssid"] = i + elif "channel" in col and "id-length" not in col: + col_map["channel"] = i + elif "privacy" in col: + col_map["privacy"] = i + elif "cipher" in col: + col_map["cipher"] = i + elif "authentication" in col: + col_map["auth"] = i + elif "power" in col: + col_map["power"] = i + elif "beacons" in col or "# beacons" in col: + col_map["beacons"] = i + elif "# iv" in col or "iv" in col: + col_map["data"] = i + elif "essid" in col: + col_map["essid"] = i + elif "first time seen" in col: + col_map["first_seen"] = i + elif "last time seen" in col: + col_map["last_seen"] = i # Parse data rows for line in lines[1:]: @@ -144,7 +144,7 @@ def _parse_ap_section(lines: list[str]) -> list[WiFiObservation]: reader = csv.reader(io.StringIO(line)) parts = next(reader) except Exception: - parts = line.split(',') + parts = line.split(",") parts = [p.strip() for p in parts] @@ -153,58 +153,58 @@ def _parse_ap_section(lines: list[str]) -> list[WiFiObservation]: try: # Get BSSID - bssid_idx = col_map.get('bssid', 0) + bssid_idx = col_map.get("bssid", 0) bssid = parts[bssid_idx].upper() if bssid_idx < len(parts) else None - if not bssid or not re.match(r'^[0-9A-F:]{17}$', bssid): + if not bssid or not re.match(r"^[0-9A-F:]{17}$", bssid): continue # Get channel channel = None - chan_idx = col_map.get('channel', 3) + chan_idx = col_map.get("channel", 3) if chan_idx < len(parts): chan_str = parts[chan_idx].strip() - if chan_str.lstrip('-').isdigit(): + if chan_str.lstrip("-").isdigit(): channel = int(chan_str) if channel < 0: channel = abs(channel) # Negative indicates not currently on channel # Get power/RSSI rssi = None - power_idx = col_map.get('power', 8) + power_idx = col_map.get("power", 8) if power_idx < len(parts): power_str = parts[power_idx].strip() - if power_str.lstrip('-').isdigit(): + if power_str.lstrip("-").isdigit(): rssi = int(power_str) if rssi > 0: rssi = -rssi # Should be negative # Get security - privacy_idx = col_map.get('privacy', 5) - privacy = parts[privacy_idx].strip() if privacy_idx < len(parts) else '' + privacy_idx = col_map.get("privacy", 5) + privacy = parts[privacy_idx].strip() if privacy_idx < len(parts) else "" security = _parse_airodump_security(privacy) # Get cipher - cipher_idx = col_map.get('cipher', 6) - cipher_str = parts[cipher_idx].strip() if cipher_idx < len(parts) else '' + cipher_idx = col_map.get("cipher", 6) + cipher_str = parts[cipher_idx].strip() if cipher_idx < len(parts) else "" cipher = _parse_airodump_cipher(cipher_str) # Get auth - auth_idx = col_map.get('auth', 7) - auth_str = parts[auth_idx].strip() if auth_idx < len(parts) else '' + auth_idx = col_map.get("auth", 7) + auth_str = parts[auth_idx].strip() if auth_idx < len(parts) else "" auth = _parse_airodump_auth(auth_str) # Get ESSID (usually last column, might contain commas) essid = None - essid_idx = col_map.get('essid', len(parts) - 1) + essid_idx = col_map.get("essid", len(parts) - 1) if essid_idx < len(parts): essid = parts[essid_idx].strip() # Handle special markers - if essid in ('', '', ''): + if essid in ("", "", ""): essid = None # Get beacon count beacon_count = 0 - beacon_idx = col_map.get('beacons', 9) + beacon_idx = col_map.get("beacons", 9) if beacon_idx < len(parts): beacon_str = parts[beacon_idx].strip() if beacon_str.isdigit(): @@ -212,7 +212,7 @@ def _parse_ap_section(lines: list[str]) -> list[WiFiObservation]: # Get data count (IVs) data_count = 0 - data_idx = col_map.get('data', 10) + data_idx = col_map.get("data", 10) if data_idx < len(parts): data_str = parts[data_idx].strip() if data_str.isdigit(): @@ -251,25 +251,25 @@ def _parse_client_section(lines: list[str]) -> list[dict]: # Parse header header = lines[0] - header_parts = [h.strip().lower() for h in header.split(',')] + header_parts = [h.strip().lower() for h in header.split(",")] # Find column indices col_map = {} for i, col in enumerate(header_parts): - if 'station mac' in col: - col_map['mac'] = i - elif 'power' in col: - col_map['power'] = i - elif 'packets' in col or '# packets' in col: - col_map['packets'] = i - elif 'bssid' in col: - col_map['bssid'] = i - elif 'probed' in col: - col_map['probed'] = i - elif 'first time seen' in col: - col_map['first_seen'] = i - elif 'last time seen' in col: - col_map['last_seen'] = i + if "station mac" in col: + col_map["mac"] = i + elif "power" in col: + col_map["power"] = i + elif "packets" in col or "# packets" in col: + col_map["packets"] = i + elif "bssid" in col: + col_map["bssid"] = i + elif "probed" in col: + col_map["probed"] = i + elif "first time seen" in col: + col_map["first_seen"] = i + elif "last time seen" in col: + col_map["last_seen"] = i # Parse data rows for line in lines[1:]: @@ -277,7 +277,7 @@ def _parse_client_section(lines: list[str]) -> list[dict]: if not line: continue - parts = line.split(',') + parts = line.split(",") parts = [p.strip() for p in parts] if len(parts) < 3: @@ -285,24 +285,24 @@ def _parse_client_section(lines: list[str]) -> list[dict]: try: # Get MAC - mac_idx = col_map.get('mac', 0) + mac_idx = col_map.get("mac", 0) mac = parts[mac_idx].upper() if mac_idx < len(parts) else None - if not mac or not re.match(r'^[0-9A-F:]{17}$', mac): + if not mac or not re.match(r"^[0-9A-F:]{17}$", mac): continue # Get power/RSSI rssi = None - power_idx = col_map.get('power', 3) + power_idx = col_map.get("power", 3) if power_idx < len(parts): power_str = parts[power_idx].strip() - if power_str.lstrip('-').isdigit(): + if power_str.lstrip("-").isdigit(): rssi = int(power_str) if rssi > 0: rssi = -rssi # Get packets packets = 0 - packets_idx = col_map.get('packets', 4) + packets_idx = col_map.get("packets", 4) if packets_idx < len(parts): packets_str = parts[packets_idx].strip() if packets_str.isdigit(): @@ -310,14 +310,14 @@ def _parse_client_section(lines: list[str]) -> list[dict]: # Get associated BSSID bssid = None - bssid_idx = col_map.get('bssid', 5) + bssid_idx = col_map.get("bssid", 5) if bssid_idx < len(parts): bssid = parts[bssid_idx].strip().upper() - if bssid == '(NOT ASSOCIATED)' or not re.match(r'^[0-9A-F:]{17}$', bssid): + if bssid == "(NOT ASSOCIATED)" or not re.match(r"^[0-9A-F:]{17}$", bssid): bssid = None # Get probed ESSIDs (remaining columns) - probed_idx = col_map.get('probed', 6) + probed_idx = col_map.get("probed", 6) probed_essids = [] if probed_idx < len(parts): for essid in parts[probed_idx:]: @@ -325,13 +325,15 @@ def _parse_client_section(lines: list[str]) -> list[dict]: if essid and essid not in probed_essids: probed_essids.append(essid) - clients.append({ - 'mac': mac, - 'rssi': rssi, - 'packets': packets, - 'bssid': bssid, - 'probed_essids': probed_essids, - }) + clients.append( + { + "mac": mac, + "rssi": rssi, + "packets": packets, + "bssid": bssid, + "probed_essids": probed_essids, + } + ) except Exception as e: logger.debug(f"Error parsing client line: {line!r} - {e}") @@ -343,17 +345,17 @@ def _parse_airodump_security(privacy: str) -> str: """Parse airodump privacy field to security type.""" privacy = privacy.upper() - if not privacy or privacy in ('', 'OPN', 'OPEN'): + if not privacy or privacy in ("", "OPN", "OPEN"): return SECURITY_OPEN - elif 'WPA3' in privacy: + elif "WPA3" in privacy: return SECURITY_WPA3 - elif 'WPA2' in privacy and 'WPA' in privacy: + elif "WPA2" in privacy and "WPA" in privacy: return SECURITY_WPA_WPA2 - elif 'WPA2' in privacy: + elif "WPA2" in privacy: return SECURITY_WPA2 - elif 'WPA' in privacy: + elif "WPA" in privacy: return SECURITY_WPA - elif 'WEP' in privacy: + elif "WEP" in privacy: return SECURITY_WEP return SECURITY_UNKNOWN @@ -363,11 +365,11 @@ def _parse_airodump_cipher(cipher: str) -> str: """Parse airodump cipher field.""" cipher = cipher.upper() - if 'CCMP' in cipher: + if "CCMP" in cipher: return CIPHER_CCMP - elif 'TKIP' in cipher: + elif "TKIP" in cipher: return CIPHER_TKIP - elif 'WEP' in cipher: + elif "WEP" in cipher: return CIPHER_WEP return CIPHER_UNKNOWN @@ -377,15 +379,15 @@ def _parse_airodump_auth(auth: str) -> str: """Parse airodump authentication field.""" auth = auth.upper() - if 'SAE' in auth: + if "SAE" in auth: return AUTH_SAE - elif 'PSK' in auth: + elif "PSK" in auth: return AUTH_PSK - elif 'MGT' in auth or 'EAP' in auth or '802.1X' in auth: + elif "MGT" in auth or "EAP" in auth or "802.1X" in auth: return AUTH_EAP - elif 'OWE' in auth: + elif "OWE" in auth: return AUTH_OWE - elif 'OPN' in auth or 'OPEN' in auth: + elif "OPN" in auth or "OPEN" in auth: return AUTH_OPEN return AUTH_UNKNOWN diff --git a/utils/wifi/parsers/airport.py b/utils/wifi/parsers/airport.py index 5e8daa3..699c4d2 100644 --- a/utils/wifi/parsers/airport.py +++ b/utils/wifi/parsers/airport.py @@ -52,7 +52,7 @@ def parse_airport_scan(output: str) -> list[WiFiObservation]: List of WiFiObservation objects. """ observations = [] - lines = output.strip().split('\n') + lines = output.strip().split("\n") if len(lines) < 2: return observations @@ -82,7 +82,7 @@ def _parse_airport_line(line: str) -> WiFiObservation | None: # Split into parts, but we need to handle SSID which may have spaces # BSSID is always 17 chars (xx:xx:xx:xx:xx:xx) # Find BSSID using regex - bssid_match = re.search(r'([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}', line) + bssid_match = re.search(r"([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", line) if not bssid_match: return None @@ -93,11 +93,11 @@ def _parse_airport_line(line: str) -> WiFiObservation | None: ssid = line[:bssid_pos].strip() # Handle hidden network indicator - if ssid == '--' or not ssid: + if ssid == "--" or not ssid: ssid = None # Parse remainder after BSSID - remainder = line[bssid_match.end():].strip() + remainder = line[bssid_match.end() :].strip() parts = remainder.split() if len(parts) < 4: @@ -106,23 +106,23 @@ def _parse_airport_line(line: str) -> WiFiObservation | None: # Parse RSSI (negative number) rssi_str = parts[0] - rssi = int(rssi_str) if rssi_str.lstrip('-').isdigit() else None + rssi = int(rssi_str) if rssi_str.lstrip("-").isdigit() else None # Parse channel - might include +1 or -1 for 40MHz channel_str = parts[1] - channel_match = re.match(r'(\d+)', channel_str) + channel_match = re.match(r"(\d+)", channel_str) channel = int(channel_match.group(1)) if channel_match else None # Determine width from channel string width = WIDTH_20_MHZ - if '+' in channel_str or '-' in channel_str: + if "+" in channel_str or "-" in channel_str: width = WIDTH_40_MHZ # HT flag (Y/N) at parts[2] # CC (country code) at parts[3] # Security is the rest (might have multiple parts like WPA2(PSK/AES/AES)) - security_str = ' '.join(parts[4:]) if len(parts) > 4 else '' + security_str = " ".join(parts[4:]) if len(parts) > 4 else "" security, cipher, auth = _parse_airport_security(security_str) # Get frequency @@ -158,44 +158,44 @@ def _parse_airport_security(security_str: str) -> tuple[str, str, str]: 'WEP' -> (WEP, WEP, OPEN) 'NONE' or '' -> (Open, None, Open) """ - if not security_str or security_str.upper() == 'NONE': + if not security_str or security_str.upper() == "NONE": return SECURITY_OPEN, CIPHER_NONE, AUTH_OPEN security_upper = security_str.upper() # Determine security type security = SECURITY_UNKNOWN - if 'WPA3' in security_upper or 'SAE' in security_upper: + if "WPA3" in security_upper or "SAE" in security_upper: security = SECURITY_WPA3 - elif 'RSN' in security_upper or 'WPA2' in security_upper: + elif "RSN" in security_upper or "WPA2" in security_upper: security = SECURITY_WPA2 - elif 'WPA' in security_upper: + elif "WPA" in security_upper: security = SECURITY_WPA - elif 'WEP' in security_upper: + elif "WEP" in security_upper: security = SECURITY_WEP # Handle mixed mode - if 'WPA2' in security_upper and 'WPA3' in security_upper: + if "WPA2" in security_upper and "WPA3" in security_upper: security = SECURITY_WPA2_WPA3 - elif 'WPA' in security_upper and 'WPA2' in security_upper: + elif "WPA" in security_upper and "WPA2" in security_upper: security = SECURITY_WPA_WPA2 # Determine cipher cipher = CIPHER_UNKNOWN - if 'AES' in security_upper or 'CCMP' in security_upper: + if "AES" in security_upper or "CCMP" in security_upper: cipher = CIPHER_CCMP - elif 'TKIP' in security_upper: + elif "TKIP" in security_upper: cipher = CIPHER_TKIP - elif 'WEP' in security_upper: + elif "WEP" in security_upper: cipher = CIPHER_WEP # Determine auth auth = AUTH_UNKNOWN - if 'SAE' in security_upper: + if "SAE" in security_upper: auth = AUTH_SAE - elif 'PSK' in security_upper: + elif "PSK" in security_upper: auth = AUTH_PSK - elif 'EAP' in security_upper or '802.1X' in security_upper: + elif "EAP" in security_upper or "802.1X" in security_upper: auth = AUTH_EAP elif security == SECURITY_OPEN: auth = AUTH_OPEN diff --git a/utils/wifi/parsers/iw.py b/utils/wifi/parsers/iw.py index 433d8a9..ef6c38d 100644 --- a/utils/wifi/parsers/iw.py +++ b/utils/wifi/parsers/iw.py @@ -67,8 +67,8 @@ def parse_iw_scan(output: str) -> list[WiFiObservation]: observations = [] current_block = [] - for line in output.split('\n'): - if line.startswith('BSS '): + for line in output.split("\n"): + if line.startswith("BSS "): # Start of new BSS entry if current_block: obs = _parse_iw_block(current_block) @@ -92,7 +92,7 @@ def _parse_iw_block(lines: list[str]) -> WiFiObservation | None: try: # First line: BSS 00:11:22:33:44:55(on wlan0) -- associated first_line = lines[0] - bssid_match = re.match(r'BSS ([0-9a-fA-F:]{17})', first_line) + bssid_match = re.match(r"BSS ([0-9a-fA-F:]{17})", first_line) if not bssid_match: return None @@ -114,35 +114,35 @@ def _parse_iw_block(lines: list[str]) -> WiFiObservation | None: while i < len(lines): line = lines[i].strip() - if line.startswith('freq:'): - freq_match = re.search(r'freq:\s*(\d+)', line) + if line.startswith("freq:"): + freq_match = re.search(r"freq:\s*(\d+)", line) if freq_match: frequency_mhz = int(freq_match.group(1)) channel = get_channel_from_frequency(frequency_mhz) - elif line.startswith('signal:'): - signal_match = re.search(r'signal:\s*(-?\d+\.?\d*)', line) + elif line.startswith("signal:"): + signal_match = re.search(r"signal:\s*(-?\d+\.?\d*)", line) if signal_match: rssi = int(float(signal_match.group(1))) - elif line.startswith('SSID:'): - ssid_match = re.match(r'SSID:\s*(.*)', line) + elif line.startswith("SSID:"): + ssid_match = re.match(r"SSID:\s*(.*)", line) if ssid_match: ssid = ssid_match.group(1).strip() - if not ssid or ssid == '\\x00' * len(ssid): + if not ssid or ssid == "\\x00" * len(ssid): ssid = None - elif line.startswith('DS Parameter set:'): - chan_match = re.search(r'channel\s*(\d+)', line) + elif line.startswith("DS Parameter set:"): + chan_match = re.search(r"channel\s*(\d+)", line) if chan_match: channel = int(chan_match.group(1)) - elif line.startswith('capability:'): - if 'Privacy' in line: + elif line.startswith("capability:"): + if "Privacy" in line: has_privacy = True - elif line.startswith('RSN:') or line.startswith('WPA:'): - is_rsn = line.startswith('RSN:') + elif line.startswith("RSN:") or line.startswith("WPA:"): + is_rsn = line.startswith("RSN:") if is_rsn: has_rsn = True else: @@ -150,41 +150,41 @@ def _parse_iw_block(lines: list[str]) -> WiFiObservation | None: # Parse the RSN/WPA block i += 1 - while i < len(lines) and lines[i].startswith('\t\t'): + while i < len(lines) and lines[i].startswith("\t\t"): subline = lines[i].strip() - if 'Group cipher:' in subline or 'Pairwise ciphers:' in subline: - if 'CCMP' in subline: + if "Group cipher:" in subline or "Pairwise ciphers:" in subline: + if "CCMP" in subline: cipher = CIPHER_CCMP - elif 'TKIP' in subline: + elif "TKIP" in subline: cipher = CIPHER_TKIP - elif 'GCMP' in subline: + elif "GCMP" in subline: cipher = CIPHER_GCMP - elif 'Authentication suites:' in subline: - if 'SAE' in subline: + elif "Authentication suites:" in subline: + if "SAE" in subline: auth = AUTH_SAE - elif 'PSK' in subline: + elif "PSK" in subline: auth = AUTH_PSK - elif 'IEEE 802.1X' in subline or 'EAP' in subline: + elif "IEEE 802.1X" in subline or "EAP" in subline: auth = AUTH_EAP - elif 'OWE' in subline: + elif "OWE" in subline: auth = AUTH_OWE i += 1 continue - elif 'HT operation:' in line or 'VHT operation:' in line or 'HE operation:' in line: + elif "HT operation:" in line or "VHT operation:" in line or "HE operation:" in line: # Parse width from subsequent lines i += 1 - while i < len(lines) and lines[i].startswith('\t\t'): + while i < len(lines) and lines[i].startswith("\t\t"): subline = lines[i].strip() - if 'channel width:' in subline.lower(): - if '160' in subline: + if "channel width:" in subline.lower(): + if "160" in subline: width = WIDTH_160_MHZ - elif '80' in subline: + elif "80" in subline: width = WIDTH_80_MHZ - elif '40' in subline: + elif "40" in subline: width = WIDTH_40_MHZ i += 1 continue diff --git a/utils/wifi/parsers/iwlist.py b/utils/wifi/parsers/iwlist.py index 403a600..25d312f 100644 --- a/utils/wifi/parsers/iwlist.py +++ b/utils/wifi/parsers/iwlist.py @@ -61,9 +61,9 @@ def parse_iwlist_scan(output: str) -> list[WiFiObservation]: observations = [] current_block = [] - for line in output.split('\n'): + for line in output.split("\n"): # New cell starts with "Cell XX - Address:" - if re.match(r'\s*Cell \d+ - Address:', line): + if re.match(r"\s*Cell \d+ - Address:", line): if current_block: obs = _parse_iwlist_block(current_block) if obs: @@ -86,7 +86,7 @@ def _parse_iwlist_block(lines: list[str]) -> WiFiObservation | None: try: # Extract BSSID from first line first_line = lines[0] - bssid_match = re.search(r'Address:\s*([0-9A-Fa-f:]{17})', first_line) + bssid_match = re.search(r"Address:\s*([0-9A-Fa-f:]{17})", first_line) if not bssid_match: return None @@ -107,33 +107,33 @@ def _parse_iwlist_block(lines: list[str]) -> WiFiObservation | None: line = line.strip() # Channel - if line.startswith('Channel:'): - chan_match = re.search(r'Channel:(\d+)', line) + if line.startswith("Channel:"): + chan_match = re.search(r"Channel:(\d+)", line) if chan_match: channel = int(chan_match.group(1)) # Frequency - elif line.startswith('Frequency:'): + elif line.startswith("Frequency:"): # Format: "Frequency:2.437 GHz (Channel 6)" - freq_match = re.search(r'Frequency:(\d+\.?\d*)\s*GHz', line) + freq_match = re.search(r"Frequency:(\d+\.?\d*)\s*GHz", line) if freq_match: frequency_ghz = float(freq_match.group(1)) frequency_mhz = int(frequency_ghz * 1000) # Also try to get channel from this line - chan_match = re.search(r'\(Channel (\d+)\)', line) + chan_match = re.search(r"\(Channel (\d+)\)", line) if chan_match and not channel: channel = int(chan_match.group(1)) # Signal level - elif 'Signal level' in line: + elif "Signal level" in line: # Format: "Quality=70/70 Signal level=-40 dBm" - signal_match = re.search(r'Signal level[=:]?\s*(-?\d+)', line) + signal_match = re.search(r"Signal level[=:]?\s*(-?\d+)", line) if signal_match: rssi = int(signal_match.group(1)) # ESSID - elif line.startswith('ESSID:'): + elif line.startswith("ESSID:"): ssid_match = re.search(r'ESSID:"([^"]*)"', line) if ssid_match: ssid = ssid_match.group(1) @@ -141,27 +141,27 @@ def _parse_iwlist_block(lines: list[str]) -> WiFiObservation | None: ssid = None # Encryption - elif line.startswith('Encryption key:'): - has_encryption = 'on' in line.lower() + elif line.startswith("Encryption key:"): + has_encryption = "on" in line.lower() # WPA/WPA2 IE - elif 'WPA2' in line or 'IEEE 802.11i' in line: + elif "WPA2" in line or "IEEE 802.11i" in line: has_wpa2 = True - elif 'WPA Version' in line: + elif "WPA Version" in line: has_wpa = True # Cipher - elif 'Group Cipher' in line or 'Pairwise Ciphers' in line: - if 'CCMP' in line: + elif "Group Cipher" in line or "Pairwise Ciphers" in line: + if "CCMP" in line: cipher = CIPHER_CCMP - elif 'TKIP' in line: + elif "TKIP" in line: cipher = CIPHER_TKIP # Auth - elif 'Authentication Suites' in line: - if 'PSK' in line: + elif "Authentication Suites" in line: + if "PSK" in line: auth = AUTH_PSK - elif '802.1x' in line.lower() or 'EAP' in line: + elif "802.1x" in line.lower() or "EAP" in line: auth = AUTH_EAP # Derive channel from frequency if needed diff --git a/utils/wifi/parsers/nmcli.py b/utils/wifi/parsers/nmcli.py index d8b38a0..db81c6c 100644 --- a/utils/wifi/parsers/nmcli.py +++ b/utils/wifi/parsers/nmcli.py @@ -49,7 +49,7 @@ def parse_nmcli_scan(output: str) -> list[WiFiObservation]: """ observations = [] - for line in output.strip().split('\n'): + for line in output.strip().split("\n"): if not line: continue @@ -78,13 +78,13 @@ def _parse_nmcli_line(line: str) -> WiFiObservation | None: freq_str = parts[4] # rate_str = parts[5] # e.g., '130 Mbit/s' signal_str = parts[6] - security_str = parts[7] if len(parts) > 7 else '' + security_str = parts[7] if len(parts) > 7 else "" # Parse channel channel = int(channel_str) if channel_str.isdigit() else None # Parse frequency (e.g., "2437 MHz") - freq_match = re.match(r'(\d+)', freq_str) + freq_match = re.match(r"(\d+)", freq_str) frequency_mhz = int(freq_match.group(1)) if freq_match else None # If no channel, derive from frequency @@ -126,13 +126,13 @@ def _split_nmcli_line(line: str) -> list[str]: i = 0 while i < len(line): - if line[i] == '\\' and i + 1 < len(line) and line[i + 1] == ':': + if line[i] == "\\" and i + 1 < len(line) and line[i + 1] == ":": # Escaped colon - add literal colon - current.append(':') + current.append(":") i += 2 - elif line[i] == ':': + elif line[i] == ":": # Field delimiter - parts.append(''.join(current)) + parts.append("".join(current)) current = [] i += 1 else: @@ -140,7 +140,7 @@ def _split_nmcli_line(line: str) -> list[str]: i += 1 # Add last field - parts.append(''.join(current)) + parts.append("".join(current)) return parts @@ -157,7 +157,7 @@ def _parse_nmcli_security(security_str: str) -> tuple[str, str, str]: 'WEP' -> (WEP, WEP, OPEN) '' or '--' -> (Open, None, Open) """ - if not security_str or security_str == '--': + if not security_str or security_str == "--": return SECURITY_OPEN, CIPHER_UNKNOWN, AUTH_OPEN security_upper = security_str.upper() @@ -165,21 +165,21 @@ def _parse_nmcli_security(security_str: str) -> tuple[str, str, str]: # Determine security type security = SECURITY_UNKNOWN - if '802.1X' in security_upper: + if "802.1X" in security_upper: security = SECURITY_ENTERPRISE - elif 'WPA3' in security_upper: - if 'WPA2' in security_upper: + elif "WPA3" in security_upper: + if "WPA2" in security_upper: security = SECURITY_WPA2_WPA3 else: security = SECURITY_WPA3 - elif 'WPA2' in security_upper: - if 'WPA1' in security_upper or security_upper.count('WPA') > 1: + elif "WPA2" in security_upper: + if "WPA1" in security_upper or security_upper.count("WPA") > 1: security = SECURITY_WPA_WPA2 else: security = SECURITY_WPA2 - elif 'WPA' in security_upper: + elif "WPA" in security_upper: security = SECURITY_WPA - elif 'WEP' in security_upper: + elif "WEP" in security_upper: security = SECURITY_WEP # Determine cipher (assume CCMP for WPA2+) @@ -191,7 +191,7 @@ def _parse_nmcli_security(security_str: str) -> tuple[str, str, str]: # Determine auth auth = AUTH_UNKNOWN - if security == SECURITY_ENTERPRISE or '802.1X' in security_upper: + if security == SECURITY_ENTERPRISE or "802.1X" in security_upper: auth = AUTH_EAP elif security == SECURITY_WPA3: auth = AUTH_SAE diff --git a/utils/wifi/scanner.py b/utils/wifi/scanner.py index 09b4b1a..be21122 100644 --- a/utils/wifi/scanner.py +++ b/utils/wifi/scanner.py @@ -145,55 +145,55 @@ class UnifiedWiFiScanner: """ caps = WiFiCapabilities() caps.platform = platform.system().lower() - caps.is_root = os.geteuid() == 0 if hasattr(os, 'geteuid') else False + caps.is_root = os.geteuid() == 0 if hasattr(os, "geteuid") else False # Detect tools - caps.has_nmcli = shutil.which('nmcli') is not None - caps.has_iw = shutil.which('iw') is not None - caps.has_iwlist = shutil.which('iwlist') is not None - caps.has_airmon_ng = shutil.which('airmon-ng') is not None - caps.has_airodump_ng = shutil.which('airodump-ng') is not None + caps.has_nmcli = shutil.which("nmcli") is not None + caps.has_iw = shutil.which("iw") is not None + caps.has_iwlist = shutil.which("iwlist") is not None + caps.has_airmon_ng = shutil.which("airmon-ng") is not None + caps.has_airodump_ng = shutil.which("airodump-ng") is not None # macOS airport tool - airport_path = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport' + airport_path = "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport" caps.has_airport = os.path.exists(airport_path) # Determine preferred quick scan tool - if caps.platform == 'darwin': + if caps.platform == "darwin": if caps.has_airport: - caps.preferred_quick_tool = 'airport' + caps.preferred_quick_tool = "airport" else: # Linux if caps.has_nmcli: - caps.preferred_quick_tool = 'nmcli' + caps.preferred_quick_tool = "nmcli" elif caps.has_iw: - caps.preferred_quick_tool = 'iw' + caps.preferred_quick_tool = "iw" elif caps.has_iwlist: - caps.preferred_quick_tool = 'iwlist' + caps.preferred_quick_tool = "iwlist" # Detect interfaces caps.interfaces = self._detect_interfaces() if caps.interfaces: - caps.default_interface = caps.interfaces[0].get('name') + caps.default_interface = caps.interfaces[0].get("name") # Check for monitor-capable interface for iface in caps.interfaces: - if iface.get('supports_monitor', False): + if iface.get("supports_monitor", False): caps.has_monitor_capable_interface = True - caps.monitor_interface = iface.get('name') + caps.monitor_interface = iface.get("name") break # Build issues list if not caps.interfaces: - caps.issues.append('No WiFi interfaces detected') + caps.issues.append("No WiFi interfaces detected") if not caps.can_quick_scan: - caps.issues.append('No quick scan tools available') + caps.issues.append("No quick scan tools available") if not caps.can_deep_scan: if not caps.has_airodump_ng: - caps.issues.append('airodump-ng not installed (install aircrack-ng)') + caps.issues.append("airodump-ng not installed (install aircrack-ng)") if not caps.is_root: - caps.issues.append('Root privileges required for deep scan') + caps.issues.append("Root privileges required for deep scan") if not caps.has_monitor_capable_interface: - caps.issues.append('No monitor mode capable interface') + caps.issues.append("No monitor mode capable interface") self._capabilities = caps return caps @@ -202,45 +202,49 @@ class UnifiedWiFiScanner: """Detect available WiFi interfaces.""" interfaces = [] - if platform.system() == 'Darwin': + if platform.system() == "Darwin": # macOS: Use networksetup try: result = subprocess.run( - ['networksetup', '-listallhardwareports'], + ["networksetup", "-listallhardwareports"], capture_output=True, text=True, timeout=TOOL_TIMEOUT_DETECT, ) current_port = None for line in result.stdout.splitlines(): - if line.startswith('Hardware Port:'): - current_port = line.split(':', 1)[1].strip() - elif line.startswith('Device:') and current_port: - device = line.split(':', 1)[1].strip() - if 'Wi-Fi' in current_port or 'wi-fi' in current_port.lower(): - interfaces.append({ - 'name': device, - 'description': current_port, - 'supports_monitor': False, # macOS generally doesn't support monitor mode - }) + if line.startswith("Hardware Port:"): + current_port = line.split(":", 1)[1].strip() + elif line.startswith("Device:") and current_port: + device = line.split(":", 1)[1].strip() + if "Wi-Fi" in current_port or "wi-fi" in current_port.lower(): + interfaces.append( + { + "name": device, + "description": current_port, + "supports_monitor": False, # macOS generally doesn't support monitor mode + } + ) current_port = None except Exception as e: logger.debug(f"Error detecting macOS interfaces: {e}") else: # Linux: Use /sys/class/net or iw try: - net_path = Path('/sys/class/net') + net_path = Path("/sys/class/net") if net_path.exists(): for iface_path in net_path.iterdir(): - wireless_path = iface_path / 'wireless' + wireless_path = iface_path / "wireless" if wireless_path.exists(): iface_name = iface_path.name supports_monitor = self._check_monitor_support(iface_name) - interfaces.append({ - 'name': iface_name, - 'description': f'Wireless interface {iface_name}', - 'supports_monitor': supports_monitor, - }) + interfaces.append( + { + "name": iface_name, + "description": f"Wireless interface {iface_name}", + "supports_monitor": supports_monitor, + } + ) except Exception as e: logger.debug(f"Error detecting Linux interfaces: {e}") @@ -250,23 +254,23 @@ class UnifiedWiFiScanner: """Check if interface supports monitor mode.""" try: result = subprocess.run( - ['iw', interface, 'info'], + ["iw", interface, "info"], capture_output=True, text=True, timeout=TOOL_TIMEOUT_DETECT, ) # Get phy name - phy_match = re.search(r'wiphy (\d+)', result.stdout) + phy_match = re.search(r"wiphy (\d+)", result.stdout) if phy_match: phy = f"phy{phy_match.group(1)}" # Check supported modes result = subprocess.run( - ['iw', phy, 'info'], + ["iw", phy, "info"], capture_output=True, text=True, timeout=TOOL_TIMEOUT_DETECT, ) - return 'monitor' in result.stdout.lower() + return "monitor" in result.stdout.lower() except Exception: pass return False @@ -280,21 +284,21 @@ class UnifiedWiFiScanner: - iw reports type as 'monitor' """ # Quick check by name convention - if interface.endswith('mon'): + if interface.endswith("mon"): return True # Check actual mode via iw - if shutil.which('iw'): + if shutil.which("iw"): try: result = subprocess.run( - ['iw', interface, 'info'], + ["iw", interface, "info"], capture_output=True, text=True, timeout=TOOL_TIMEOUT_DETECT, ) if result.returncode == 0: # Look for "type monitor" in output - if re.search(r'type\s+monitor', result.stdout, re.IGNORECASE): + if re.search(r"type\s+monitor", result.stdout, re.IGNORECASE): return True except Exception: pass @@ -330,10 +334,10 @@ class UnifiedWiFiScanner: pass # Try ip link set up - if shutil.which('ip'): + if shutil.which("ip"): try: result = subprocess.run( - ['ip', 'link', 'set', interface, 'up'], + ["ip", "link", "set", interface, "up"], capture_output=True, text=True, timeout=5, @@ -348,10 +352,10 @@ class UnifiedWiFiScanner: logger.warning(f"Failed to run ip link: {e}") # Fallback to ifconfig - if shutil.which('ifconfig'): + if shutil.which("ifconfig"): try: result = subprocess.run( - ['ifconfig', interface, 'up'], + ["ifconfig", interface, "up"], capture_output=True, text=True, timeout=5, @@ -420,10 +424,10 @@ class UnifiedWiFiScanner: errors_encountered = [] try: - if self._capabilities.platform == 'darwin': + if self._capabilities.platform == "darwin": if self._capabilities.has_airport: observations = self._scan_with_airport(iface, timeout) - tool_used = 'airport' + tool_used = "airport" else: result.error = "No WiFi scanning tool available on macOS (airport not found)" result.is_complete = True @@ -434,11 +438,11 @@ class UnifiedWiFiScanner: tools_to_try = [] if self._capabilities.has_nmcli: - tools_to_try.append(('nmcli', self._scan_with_nmcli)) + tools_to_try.append(("nmcli", self._scan_with_nmcli)) if self._capabilities.has_iw: - tools_to_try.append(('iw', self._scan_with_iw)) + tools_to_try.append(("iw", self._scan_with_iw)) if self._capabilities.has_iwlist: - tools_to_try.append(('iwlist', self._scan_with_iwlist)) + tools_to_try.append(("iwlist", self._scan_with_iwlist)) if not tools_to_try: result.error = "No WiFi scanning tools available. Install NetworkManager (nmcli) or wireless-tools (iw/iwlist)." @@ -457,7 +461,7 @@ class UnifiedWiFiScanner: error_msg = f"{tool_name}: {str(e)}" errors_encountered.append(error_msg) logger.warning(f"Quick scan with {tool_name} failed: {e}") - if 'is down' in str(e): + if "is down" in str(e): interface_was_down = True continue # Try next tool @@ -522,11 +526,11 @@ class UnifiedWiFiScanner: """Scan using macOS airport utility.""" from .parsers.airport import parse_airport_scan - airport_path = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport' + airport_path = "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport" try: result = subprocess.run( - [airport_path, '-s'], + [airport_path, "-s"], capture_output=True, text=True, timeout=timeout, @@ -554,21 +558,31 @@ class UnifiedWiFiScanner: try: # Try to trigger a rescan first (might fail if interface not managed by NM) rescan_result = subprocess.run( - ['nmcli', 'device', 'wifi', 'rescan', 'ifname', interface], + ["nmcli", "device", "wifi", "rescan", "ifname", interface], capture_output=True, timeout=timeout / 2, ) if rescan_result.returncode != 0: # Try without interface specification subprocess.run( - ['nmcli', 'device', 'wifi', 'rescan'], + ["nmcli", "device", "wifi", "rescan"], capture_output=True, timeout=timeout / 2, ) # Get results - try with interface first, then without result = subprocess.run( - ['nmcli', '-t', '-f', 'BSSID,SSID,MODE,CHAN,FREQ,RATE,SIGNAL,SECURITY', 'device', 'wifi', 'list', 'ifname', interface], + [ + "nmcli", + "-t", + "-f", + "BSSID,SSID,MODE,CHAN,FREQ,RATE,SIGNAL,SECURITY", + "device", + "wifi", + "list", + "ifname", + interface, + ], capture_output=True, text=True, timeout=timeout, @@ -578,7 +592,7 @@ class UnifiedWiFiScanner: if result.returncode != 0 or not result.stdout.strip(): logger.debug(f"nmcli scan with interface {interface} failed, trying general scan") result = subprocess.run( - ['nmcli', '-t', '-f', 'BSSID,SSID,MODE,CHAN,FREQ,RATE,SIGNAL,SECURITY', 'device', 'wifi', 'list'], + ["nmcli", "-t", "-f", "BSSID,SSID,MODE,CHAN,FREQ,RATE,SIGNAL,SECURITY", "device", "wifi", "list"], capture_output=True, text=True, timeout=timeout, @@ -587,9 +601,9 @@ class UnifiedWiFiScanner: if result.returncode != 0: error_msg = result.stderr.strip() or f"nmcli returned code {result.returncode}" # Check for common issues - if 'not running' in error_msg.lower(): + if "not running" in error_msg.lower(): raise RuntimeError("NetworkManager is not running") - elif 'not found' in error_msg.lower() or 'no such' in error_msg.lower(): + elif "not found" in error_msg.lower() or "no such" in error_msg.lower(): raise RuntimeError(f"Interface {interface} not found or not managed by NetworkManager") else: raise RuntimeError(f"nmcli scan failed: {error_msg}") @@ -609,7 +623,7 @@ class UnifiedWiFiScanner: try: result = subprocess.run( - ['iw', interface, 'scan'], + ["iw", interface, "scan"], capture_output=True, text=True, timeout=timeout, @@ -618,9 +632,9 @@ class UnifiedWiFiScanner: if result.returncode != 0: error_msg = result.stderr.strip() or f"iw returned code {result.returncode}" # Check for common errors - if 'Operation not permitted' in error_msg or 'Permission denied' in error_msg: + if "Operation not permitted" in error_msg or "Permission denied" in error_msg: raise RuntimeError(f"iw scan requires root privileges: {error_msg}") - elif 'Network is down' in error_msg: + elif "Network is down" in error_msg: raise RuntimeError(f"Interface {interface} is down: {error_msg}") else: raise RuntimeError(f"iw scan failed: {error_msg}") @@ -637,7 +651,7 @@ class UnifiedWiFiScanner: try: result = subprocess.run( - ['iwlist', interface, 'scan'], + ["iwlist", interface, "scan"], capture_output=True, text=True, timeout=timeout, @@ -645,9 +659,9 @@ class UnifiedWiFiScanner: if result.returncode != 0: error_msg = result.stderr.strip() or f"iwlist returned code {result.returncode}" - if 'Operation not permitted' in error_msg or 'Permission denied' in error_msg: + if "Operation not permitted" in error_msg or "Permission denied" in error_msg: raise RuntimeError(f"iwlist scan requires root privileges: {error_msg}") - elif 'Network is down' in error_msg: + elif "Network is down" in error_msg: raise RuntimeError(f"Interface {interface} is down: {error_msg}") else: raise RuntimeError(f"iwlist scan failed: {error_msg}") @@ -665,7 +679,7 @@ class UnifiedWiFiScanner: def start_deep_scan( self, interface: str | None = None, - band: str = 'all', + band: str = "all", channel: int | None = None, channels: list[int] | None = None, ) -> bool: @@ -715,11 +729,13 @@ class UnifiedWiFiScanner: started_at=datetime.now(), ) - self._queue_event({ - 'type': 'scan_started', - 'mode': SCAN_MODE_DEEP, - 'interface': iface, - }) + self._queue_event( + { + "type": "scan_started", + "mode": SCAN_MODE_DEEP, + "interface": iface, + } + ) # Auto-start deauth detector self._start_deauth_detector(iface) @@ -752,10 +768,12 @@ class UnifiedWiFiScanner: self._status.is_scanning = False self._status.error = None - self._queue_event({ - 'type': 'scan_stopped', - 'mode': SCAN_MODE_DEEP, - }) + self._queue_event( + { + "type": "scan_stopped", + "mode": SCAN_MODE_DEEP, + } + ) cleanup_start = time.perf_counter() @@ -768,7 +786,7 @@ class UnifiedWiFiScanner: try: detector.stop() logger.info("Deauth detector stopped") - self._queue_event({'type': 'deauth_detector_stopped'}) + self._queue_event({"type": "deauth_detector_stopped"}) except Exception as exc: logger.error(f"Error stopping deauth detector: {exc}") @@ -790,7 +808,7 @@ class UnifiedWiFiScanner: target=_finalize_stop, args=(cleanup_process, cleanup_thread, cleanup_detector), daemon=True, - name='wifi-deep-stop', + name="wifi-deep-stop", ).start() return True @@ -808,22 +826,22 @@ class UnifiedWiFiScanner: from .parsers.airodump import parse_airodump_csv # Create temp directory for output files - with tempfile.TemporaryDirectory(prefix='wifi_scan_') as tmpdir: - output_prefix = os.path.join(tmpdir, 'scan') + with tempfile.TemporaryDirectory(prefix="wifi_scan_") as tmpdir: + output_prefix = os.path.join(tmpdir, "scan") # Build command - cmd = ['airodump-ng', '-w', output_prefix, '--output-format', 'csv'] + cmd = ["airodump-ng", "-w", output_prefix, "--output-format", "csv"] if channels: - cmd.extend(['-c', ','.join(str(c) for c in channels)]) + cmd.extend(["-c", ",".join(str(c) for c in channels)]) elif channel: - cmd.extend(['-c', str(channel)]) - elif band == '2.4': - cmd.extend(['--band', 'bg']) - elif band == '5': - cmd.extend(['--band', 'a']) + cmd.extend(["-c", str(channel)]) + elif band == "2.4": + cmd.extend(["--band", "bg"]) + elif band == "5": + cmd.extend(["--band", "a"]) else: - cmd.extend(['--band', 'abg']) + cmd.extend(["--band", "abg"]) cmd.append(interface) @@ -878,10 +896,12 @@ class UnifiedWiFiScanner: except Exception as e: logger.exception(f"Deep scan error: {e}") - self._queue_event({ - 'type': 'scan_error', - 'error': str(e), - }) + self._queue_event( + { + "type": "scan_error", + "error": str(e), + } + ) finally: with self._lock: if process is not None and self._deep_scan_process is process: @@ -908,10 +928,12 @@ class UnifiedWiFiScanner: ap.is_new = True # Queue update event - self._queue_event({ - 'type': 'network_update', - 'network': ap.to_summary_dict(), - }) + self._queue_event( + { + "type": "network_update", + "network": ap.to_summary_dict(), + } + ) # Callback if self._on_network_updated: @@ -970,11 +992,13 @@ class UnifiedWiFiScanner: # Update ESSID if revealed if obs.essid and ap.is_hidden: ap.revealed_essid = obs.essid - self._queue_event({ - 'type': 'hidden_revealed', - 'bssid': ap.bssid, - 'revealed_essid': obs.essid, - }) + self._queue_event( + { + "type": "hidden_revealed", + "bssid": ap.bssid, + "revealed_essid": obs.essid, + } + ) # Update RSSI stats if obs.rssi is not None: @@ -1018,8 +1042,8 @@ class UnifiedWiFiScanner: def _process_client(self, client_data: dict): """Process client data from airodump-ng.""" - mac = client_data.get('mac', '').upper() - if not mac or mac == '(not associated)': + mac = client_data.get("mac", "").upper() + if not mac or mac == "(not associated)": return with self._lock: @@ -1031,13 +1055,15 @@ class UnifiedWiFiScanner: self._clients[mac] = client # Queue update event - self._queue_event({ - 'type': 'client_update', - 'client': client.to_dict(), - }) + self._queue_event( + { + "type": "client_update", + "client": client.to_dict(), + } + ) # Process probe requests - probed = client_data.get('probed_essids', []) + probed = client_data.get("probed_essids", []) for ssid in probed: if ssid and ssid not in client.probed_ssids: client.probed_ssids.append(ssid) @@ -1052,10 +1078,12 @@ class UnifiedWiFiScanner: ) self._probe_requests.append(probe) - self._queue_event({ - 'type': 'probe_request', - 'probe': probe.to_dict(), - }) + self._queue_event( + { + "type": "probe_request", + "probe": probe.to_dict(), + } + ) # Callback if self._on_client_updated: @@ -1067,7 +1095,7 @@ class UnifiedWiFiScanner: def _create_client(self, data: dict) -> WiFiClient: """Create new client from data.""" now = datetime.now() - mac = data.get('mac', '').upper() + mac = data.get("mac", "").upper() client = WiFiClient( mac=mac, @@ -1077,7 +1105,7 @@ class UnifiedWiFiScanner: seen_count=1, ) - rssi = data.get('rssi') + rssi = data.get("rssi") if rssi is not None: client.rssi_current = rssi client.rssi_samples = [(now, rssi)] @@ -1088,8 +1116,8 @@ class UnifiedWiFiScanner: client.signal_band = get_signal_band(rssi) client.proximity_band = get_proximity_band(rssi) - bssid = data.get('bssid') - if bssid and bssid != '(not associated)': + bssid = data.get("bssid") + if bssid and bssid != "(not associated)": client.associated_bssid = bssid.upper() client.is_associated = True @@ -1105,7 +1133,7 @@ class UnifiedWiFiScanner: client.last_seen = now client.seen_count += 1 - rssi = data.get('rssi') + rssi = data.get("rssi") if rssi is not None: client.rssi_current = rssi client.rssi_samples.append((now, rssi)) @@ -1166,9 +1194,9 @@ class UnifiedWiFiScanner: if stats.ap_count > 0: # Simple utilization score based on AP and client density from .constants import CHANNEL_WEIGHT_AP_COUNT, CHANNEL_WEIGHT_CLIENT_COUNT + stats.utilization_score = ( - (stats.ap_count * CHANNEL_WEIGHT_AP_COUNT) + - (stats.client_count * CHANNEL_WEIGHT_CLIENT_COUNT) + (stats.ap_count * CHANNEL_WEIGHT_AP_COUNT) + (stats.client_count * CHANNEL_WEIGHT_CLIENT_COUNT) ) / 10.0 # Normalize stats.utilization_score = min(1.0, stats.utilization_score) @@ -1192,25 +1220,29 @@ class UnifiedWiFiScanner: for channel in NON_OVERLAPPING_2_4_GHZ: s = stats_map.get(channel) score = s.utilization_score if s else 0.0 - recommendations.append(ChannelRecommendation( - channel=channel, - band=BAND_2_4_GHZ, - score=score, - reason=f"{s.ap_count if s else 0} APs on channel" if s else "No APs detected", - is_dfs=False, - )) + recommendations.append( + ChannelRecommendation( + channel=channel, + band=BAND_2_4_GHZ, + score=score, + reason=f"{s.ap_count if s else 0} APs on channel" if s else "No APs detected", + is_dfs=False, + ) + ) for channel in NON_OVERLAPPING_5_GHZ: s = stats_map.get(channel) score = s.utilization_score if s else 0.0 is_dfs = 52 <= channel <= 144 - recommendations.append(ChannelRecommendation( - channel=channel, - band=BAND_5_GHZ, - score=score, - reason=f"{s.ap_count if s else 0} APs on channel" + (" (DFS)" if is_dfs else ""), - is_dfs=is_dfs, - )) + recommendations.append( + ChannelRecommendation( + channel=channel, + band=BAND_5_GHZ, + score=score, + reason=f"{s.ap_count if s else 0} APs on channel" + (" (DFS)" if is_dfs else ""), + is_dfs=is_dfs, + ) + ) # Sort by score (lower is better) recommendations.sort(key=lambda r: (r.score, r.is_dfs)) @@ -1244,7 +1276,7 @@ class UnifiedWiFiScanner: event = self._event_queue.get(timeout=1.0) yield event except queue.Empty: - yield {'type': 'keepalive'} + yield {"type": "keepalive"} except Exception: break @@ -1334,10 +1366,11 @@ class UnifiedWiFiScanner: # Also store in app-level DataStore if available try: import app as app_module - if hasattr(app_module, 'deauth_alerts') and event.get('type') == 'deauth_alert': - alert_id = event.get('id', str(time.time())) + + if hasattr(app_module, "deauth_alerts") and event.get("type") == "deauth_alert": + alert_id = event.get("id", str(time.time())) app_module.deauth_alerts[alert_id] = event - if hasattr(app_module, 'deauth_detector_queue'): + if hasattr(app_module, "deauth_detector_queue"): with contextlib.suppress(queue.Full): app_module.deauth_detector_queue.put_nowait(event) except Exception as e: @@ -1363,16 +1396,20 @@ class UnifiedWiFiScanner: self._deauth_detector.start() logger.info(f"Deauth detector started on {interface}") - self._queue_event({ - 'type': 'deauth_detector_started', - 'interface': interface, - }) + self._queue_event( + { + "type": "deauth_detector_started", + "interface": interface, + } + ) except Exception as e: logger.error(f"Failed to start deauth detector: {e}") - self._queue_event({ - 'type': 'deauth_error', - 'error': f"Failed to start deauth detector: {e}", - }) + self._queue_event( + { + "type": "deauth_error", + "error": f"Failed to start deauth detector: {e}", + } + ) def _stop_deauth_detector(self): """Stop the deauth detector.""" @@ -1380,9 +1417,11 @@ class UnifiedWiFiScanner: try: self._deauth_detector.stop() logger.info("Deauth detector stopped") - self._queue_event({ - 'type': 'deauth_detector_stopped', - }) + self._queue_event( + { + "type": "deauth_detector_stopped", + } + ) except Exception as e: logger.error(f"Error stopping deauth detector: {e}") finally: @@ -1406,7 +1445,8 @@ class UnifiedWiFiScanner: # Also clear from app-level store try: import app as app_module - if hasattr(app_module, 'deauth_alerts'): + + if hasattr(app_module, "deauth_alerts"): app_module.deauth_alerts.clear() except Exception: pass @@ -1416,6 +1456,7 @@ class UnifiedWiFiScanner: # Module-level functions # ============================================================================= + def get_wifi_scanner(interface: str | None = None) -> UnifiedWiFiScanner: """ Get or create the global WiFi scanner instance.