diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a885ee3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,122 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +INTERCEPT is a web-based Signal Intelligence (SIGINT) platform providing a unified Flask interface for software-defined radio (SDR) tools. It supports pager decoding, 433MHz sensors, ADS-B aircraft tracking, ACARS messaging, WiFi/Bluetooth scanning, and satellite tracking. + +## Common Commands + +### Setup and Running +```bash +# Initial setup (installs dependencies and configures SDR tools) +./setup.sh + +# Run the application (requires sudo for SDR/network access) +sudo -E venv/bin/python intercept.py + +# Or activate venv first +source venv/bin/activate +sudo -E python intercept.py +``` + +### Testing +```bash +# Run all tests +pytest + +# Run specific test file +pytest tests/test_bluetooth.py + +# Run with coverage +pytest --cov=routes --cov=utils + +# Run a specific test +pytest tests/test_bluetooth.py::test_function_name -v +``` + +### Linting and Formatting +```bash +# Lint with ruff +ruff check . + +# Auto-fix linting issues +ruff check --fix . + +# Format with black +black . + +# Type checking +mypy . +``` + +## Architecture + +### Entry Points +- `intercept.py` - Main entry point script +- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure + +### Route Blueprints (routes/) +Each signal type has its own Flask blueprint: +- `pager.py` - POCSAG/FLEX decoding via rtl_fm + multimon-ng +- `sensor.py` - 433MHz IoT sensors via rtl_433 +- `adsb.py` - Aircraft tracking via dump1090 (SBS protocol on port 30003) +- `acars.py` - Aircraft datalink messages via acarsdec +- `wifi.py`, `wifi_v2.py` - WiFi scanning (legacy and unified APIs) +- `bluetooth.py`, `bluetooth_v2.py` - Bluetooth scanning (legacy and unified APIs) +- `satellite.py` - Pass prediction using TLE data +- `aprs.py` - Amateur packet radio via direwolf +- `rtlamr.py` - Utility meter reading + +### Core Utilities (utils/) + +**SDR Abstraction Layer** (`utils/sdr/`): +- `SDRFactory` with factory pattern for multiple SDR types (RTL-SDR, LimeSDR, HackRF, Airspy, SDRPlay) +- Each type has a `CommandBuilder` for generating CLI commands + +**Bluetooth Module** (`utils/bluetooth/`): +- Multi-backend: DBus/BlueZ primary, fallback for systems without BlueZ +- `aggregator.py` - Merges observations across time +- `tracker_signatures.py` - 47K+ known tracker fingerprints (AirTag, Tile, SmartTag) +- `heuristics.py` - Behavioral analysis for device classification + +**TSCM (Counter-Surveillance)** (`utils/tscm/`): +- `baseline.py` - Snapshot "normal" RF environment +- `detector.py` - Compare current scan to baseline, flag anomalies +- `device_identity.py` - Track devices despite MAC randomization +- `correlation.py` - Cross-reference Bluetooth and WiFi observations + +**WiFi Utilities** (`utils/wifi/`): +- Platform-agnostic scanner with parsers for airodump-ng, nmcli, iw, iwlist, airport (macOS) +- `channel_analyzer.py` - Frequency band analysis + +### Key Patterns + +**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. + +**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions. + +**Data Stores**: `DataStore` class with TTL-based automatic cleanup (WiFi: 10min, Bluetooth: 5min, Aircraft: 5min). + +**Input Validation**: Centralized in `utils/validation.py` - always validate frequencies, gains, device indices before spawning processes. + +### External Tool Integrations + +| Tool | Purpose | Integration | +|------|---------|-------------| +| rtl_fm | FM demodulation | Subprocess, pipes to multimon-ng | +| multimon-ng | Pager decoding | Reads from rtl_fm stdout | +| rtl_433 | 433MHz sensors | JSON output parsing | +| dump1090 | ADS-B decoding | SBS protocol socket (port 30003) | +| acarsdec | ACARS messages | Output parsing | +| airmon-ng/airodump-ng | WiFi scanning | Monitor mode, CSV parsing | +| bluetoothctl/hcitool | Bluetooth | Fallback when DBus unavailable | + +### Configuration +- `config.py` - Environment variable support with `INTERCEPT_` prefix +- Database: SQLite in `instance/` directory for settings, baselines, history + +## Testing Notes + +Tests use pytest with extensive mocking of external tools. Key fixtures in `tests/conftest.py`. Mock subprocess calls when testing decoder integration. diff --git a/app.py b/app.py index 8bdef28..3b88474 100644 --- a/app.py +++ b/app.py @@ -36,6 +36,7 @@ from utils.constants import ( MAX_AIRCRAFT_AGE_SECONDS, MAX_WIFI_NETWORK_AGE_SECONDS, MAX_BT_DEVICE_AGE_SECONDS, + MAX_VESSEL_AGE_SECONDS, QUEUE_MAX_SIZE, ) import logging @@ -139,6 +140,11 @@ rtlamr_process = None rtlamr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) rtlamr_lock = threading.Lock() +# AIS vessel tracking +ais_process = None +ais_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +ais_lock = threading.Lock() + # TSCM (Technical Surveillance Countermeasures) tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) tscm_lock = threading.Lock() @@ -166,6 +172,9 @@ bt_services = {} # MAC -> list of services (not auto-cleaned, user-requested # Aircraft (ADS-B) state - using DataStore for automatic cleanup adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft') +# Vessel (AIS) state - using DataStore for automatic cleanup +ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessels') + # Satellite state satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated) @@ -175,6 +184,7 @@ cleanup_manager.register(wifi_clients) cleanup_manager.register(bt_devices) cleanup_manager.register(bt_beacons) cleanup_manager.register(adsb_aircraft) +cleanup_manager.register(ais_vessels) # ============================================ @@ -501,6 +511,7 @@ def health_check() -> Response: 'pager': current_process is not None and (current_process.poll() is None if current_process else False), 'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False), 'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False), + 'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False), 'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False), 'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False), 'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False), @@ -508,6 +519,7 @@ def health_check() -> Response: }, 'data': { 'aircraft_count': len(adsb_aircraft), + 'vessel_count': len(ais_vessels), 'wifi_networks_count': len(wifi_networks), 'wifi_clients_count': len(wifi_clients), 'bt_devices_count': len(bt_devices), @@ -518,17 +530,18 @@ def health_check() -> Response: @app.route('/killall', methods=['POST']) def kill_all() -> Response: """Kill all decoder and WiFi processes.""" - global current_process, sensor_process, wifi_process, adsb_process, acars_process + global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process global aprs_process, aprs_rtl_process - # Import adsb module to reset its state + # Import adsb and ais modules to reset their state from routes import adsb as adsb_module + from routes import ais as ais_module killed = [] processes_to_kill = [ 'rtl_fm', 'multimon-ng', 'rtl_433', 'airodump-ng', 'aireplay-ng', 'airmon-ng', - 'dump1090', 'acarsdec', 'direwolf' + 'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher' ] for proc in processes_to_kill: @@ -553,6 +566,11 @@ def kill_all() -> Response: adsb_process = None adsb_module.adsb_using_service = False + # Reset AIS state + with ais_lock: + ais_process = None + ais_module.ais_running = False + # Reset ACARS state with acars_lock: acars_process = None diff --git a/routes/__init__.py b/routes/__init__.py index 5960a95..cbdb1ee 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -10,6 +10,7 @@ def register_blueprints(app): from .bluetooth import bluetooth_bp from .bluetooth_v2 import bluetooth_v2_bp from .adsb import adsb_bp + from .ais import ais_bp from .acars import acars_bp from .aprs import aprs_bp from .satellite import satellite_bp @@ -18,6 +19,7 @@ def register_blueprints(app): from .correlation import correlation_bp from .listening_post import listening_post_bp from .tscm import tscm_bp, init_tscm_state + from .spy_stations import spy_stations_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -27,6 +29,7 @@ def register_blueprints(app): app.register_blueprint(bluetooth_bp) app.register_blueprint(bluetooth_v2_bp) # New unified Bluetooth API app.register_blueprint(adsb_bp) + app.register_blueprint(ais_bp) app.register_blueprint(acars_bp) app.register_blueprint(aprs_bp) app.register_blueprint(satellite_bp) @@ -35,6 +38,7 @@ def register_blueprints(app): app.register_blueprint(correlation_bp) app.register_blueprint(listening_post_bp) app.register_blueprint(tscm_bp) + app.register_blueprint(spy_stations_bp) # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/ais.py b/routes/ais.py new file mode 100644 index 0000000..6aacbfc --- /dev/null +++ b/routes/ais.py @@ -0,0 +1,480 @@ +"""AIS vessel tracking routes.""" + +from __future__ import annotations + +import json +import os +import queue +import shutil +import socket +import subprocess +import threading +import time +from typing import Generator + +from flask import Blueprint, jsonify, request, Response, render_template + +import app as app_module +from utils.logging import get_logger +from utils.validation import validate_device_index, validate_gain +from utils.sse import format_sse +from utils.sdr import SDRFactory, SDRType +from utils.constants import ( + AIS_TCP_PORT, + AIS_TERMINATE_TIMEOUT, + AIS_SOCKET_TIMEOUT, + AIS_RECONNECT_DELAY, + AIS_UPDATE_INTERVAL, + SOCKET_BUFFER_SIZE, + SSE_KEEPALIVE_INTERVAL, + SSE_QUEUE_TIMEOUT, + SOCKET_CONNECT_TIMEOUT, + PROCESS_TERMINATE_TIMEOUT, +) + +logger = get_logger('intercept.ais') + +ais_bp = Blueprint('ais', __name__, url_prefix='/ais') + +# Track AIS state +ais_running = False +ais_connected = False +ais_messages_received = 0 +ais_last_message_time = None +ais_active_device = None +_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', +] + + +def find_ais_catcher(): + """Find AIS-catcher binary, checking PATH and common locations.""" + # First try PATH + for name in ['AIS-catcher', 'aiscatcher']: + path = shutil.which(name) + if path: + return path + # Check common installation paths + for path in AIS_CATCHER_PATHS: + if os.path.isfile(path) and os.access(path, os.X_OK): + return path + return None + + +def parse_ais_stream(port: int): + """Parse JSON data from AIS-catcher TCP server.""" + global ais_running, ais_connected, ais_messages_received, ais_last_message_time, _ais_error_logged + + logger.info(f"AIS stream parser started, connecting to localhost:{port}") + ais_connected = True + ais_messages_received = 0 + _ais_error_logged = True + + while ais_running: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(AIS_SOCKET_TIMEOUT) + sock.connect(('localhost', port)) + ais_connected = True + _ais_error_logged = True + logger.info("Connected to AIS-catcher TCP server") + + buffer = "" + last_update = time.time() + pending_updates = set() + + while ais_running: + try: + 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) + line = line.strip() + if not line: + continue + + try: + msg = json.loads(line) + vessel = process_ais_message(msg) + if vessel: + mmsi = vessel.get('mmsi') + if mmsi: + app_module.ais_vessels.set(mmsi, vessel) + pending_updates.add(mmsi) + ais_messages_received += 1 + ais_last_message_time = time.time() + except json.JSONDecodeError: + if ais_messages_received < 5: + logger.debug(f"Invalid JSON: {line[:100]}") + + # Batch updates + now = time.time() + if now - last_update >= AIS_UPDATE_INTERVAL: + for mmsi in pending_updates: + if mmsi in app_module.ais_vessels: + try: + app_module.ais_queue.put_nowait({ + 'type': 'vessel', + **app_module.ais_vessels[mmsi] + }) + except queue.Full: + pass + pending_updates.clear() + last_update = now + + except socket.timeout: + continue + + sock.close() + ais_connected = False + except OSError as e: + ais_connected = False + if not _ais_error_logged: + logger.warning(f"AIS connection error: {e}, reconnecting...") + _ais_error_logged = True + time.sleep(AIS_RECONNECT_DELAY) + + ais_connected = False + logger.info("AIS stream parser stopped") + + +def process_ais_message(msg: dict) -> dict | None: + """Process AIS-catcher JSON message and extract vessel data.""" + # AIS-catcher outputs different message types + # We're interested in position reports and static data + + 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} + + # Extract common fields + if 'lat' in msg and 'lon' in msg: + try: + lat = float(msg['lat']) + lon = float(msg['lon']) + # Validate coordinates (AIS uses 181 for unavailable) + if -90 <= lat <= 90 and -180 <= lon <= 180: + vessel['lat'] = lat + vessel['lon'] = lon + except (ValueError, TypeError): + pass + + # Speed over ground (knots) + if 'speed' in msg: + try: + speed = float(msg['speed']) + if speed < 102.3: # 102.3 = not available + vessel['speed'] = round(speed, 1) + except (ValueError, TypeError): + pass + + # Course over ground (degrees) + if 'course' in msg: + try: + course = float(msg['course']) + if course < 360: # 360 = not available + vessel['course'] = round(course, 1) + except (ValueError, TypeError): + pass + + # True heading (degrees) + if 'heading' in msg: + try: + heading = int(msg['heading']) + if heading < 511: # 511 = not available + 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'] + + # Vessel name (from Type 5 or Type 24 messages) + if 'shipname' in msg: + name = msg['shipname'].strip().strip('@') + if name: + vessel['name'] = name + + # Callsign + if 'callsign' in msg: + callsign = msg['callsign'].strip().strip('@') + if 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'] + + # Destination + if 'destination' in msg: + dest = msg['destination'].strip().strip('@') + if dest: + vessel['destination'] = dest + + # ETA + if 'eta' in msg: + vessel['eta'] = msg['eta'] + + # Dimensions + if 'to_bow' in msg and 'to_stern' in msg: + try: + length = int(msg['to_bow']) + int(msg['to_stern']) + if length > 0: + vessel['length'] = length + except (ValueError, TypeError): + pass + + if 'to_port' in msg and 'to_starboard' in msg: + try: + width = int(msg['to_port']) + int(msg['to_starboard']) + if width > 0: + vessel['width'] = width + except (ValueError, TypeError): + pass + + # Draught + if 'draught' in msg: + try: + draught = float(msg['draught']) + if draught > 0: + vessel['draught'] = draught + except (ValueError, TypeError): + pass + + # Rate of turn + if 'turn' in msg: + try: + turn = float(msg['turn']) + if -127 <= turn <= 127: # Valid range + vessel['rate_of_turn'] = turn + except (ValueError, TypeError): + pass + + # Message type for debugging + if 'type' in msg: + vessel['last_msg_type'] = msg['type'] + + # Timestamp + vessel['last_seen'] = time.time() + + return vessel + + +@ais_bp.route('/tools') +def check_ais_tools(): + """Check for AIS decoding tools and hardware.""" + has_ais_catcher = find_ais_catcher() is not None + + # Check what SDR hardware is detected + 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) + }) + + +@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 + }) + + +@ais_bp.route('/start', methods=['POST']) +def start_ais(): + """Start AIS tracking.""" + global ais_running, ais_active_device + + with app_module.ais_lock: + if ais_running: + return jsonify({'status': 'already_running', 'message': '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')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + # Find AIS-catcher + ais_catcher_path = find_ais_catcher() + if not ais_catcher_path: + return jsonify({ + 'status': 'error', + 'message': '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') + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + + # Kill any existing process + if app_module.ais_process: + try: + pgid = os.getpgid(app_module.ais_process.pid) + os.killpg(pgid, 15) + app_module.ais_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT) + except (subprocess.TimeoutExpired, ProcessLookupError, OSError): + try: + pgid = os.getpgid(app_module.ais_process.pid) + os.killpg(pgid, 9) + except (ProcessLookupError, OSError): + pass + app_module.ais_process = None + logger.info("Killed existing AIS process") + + # 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) + tcp_port = AIS_TCP_PORT + + cmd = builder.build_ais_command( + device=sdr_device, + gain=float(gain), + bias_t=bias_t, + tcp_port=tcp_port + ) + + # Use the found AIS-catcher path + cmd[0] = ais_catcher_path + + 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 + ) + + # Wait for process to start + time.sleep(2.0) + + if app_module.ais_process.poll() is not None: + stderr_output = '' + if app_module.ais_process.stderr: + try: + stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip() + except Exception: + pass + error_msg = 'AIS-catcher failed to start. Check SDR device connection.' + if stderr_output: + error_msg += f' Error: {stderr_output[:200]}' + return jsonify({'status': 'error', 'message': error_msg}), 500 + + ais_running = True + ais_active_device = device + + # Start TCP parser thread + 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 + }) + except Exception as e: + logger.error(f"Failed to start AIS-catcher: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@ais_bp.route('/stop', methods=['POST']) +def stop_ais(): + """Stop AIS tracking.""" + global ais_running, ais_active_device + + with app_module.ais_lock: + if app_module.ais_process: + try: + pgid = os.getpgid(app_module.ais_process.pid) + os.killpg(pgid, 15) + app_module.ais_process.wait(timeout=AIS_TERMINATE_TIMEOUT) + except (subprocess.TimeoutExpired, ProcessLookupError, OSError): + try: + pgid = os.getpgid(app_module.ais_process.pid) + os.killpg(pgid, 9) + except (ProcessLookupError, OSError): + pass + app_module.ais_process = None + logger.info("AIS process stopped") + ais_running = False + ais_active_device = None + + app_module.ais_vessels.clear() + return jsonify({'status': 'stopped'}) + + +@ais_bp.route('/stream') +def stream_ais(): + """SSE stream for AIS vessels.""" + def generate() -> Generator[str, None, None]: + last_keepalive = time.time() + + while True: + try: + msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT) + last_keepalive = time.time() + yield format_sse(msg) + except queue.Empty: + now = time.time() + if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: + yield format_sse({'type': 'keepalive'}) + last_keepalive = now + + response = Response(generate(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response + + +@ais_bp.route('/dashboard') +def ais_dashboard(): + """Popout AIS dashboard.""" + return render_template('ais_dashboard.html') diff --git a/routes/spy_stations.py b/routes/spy_stations.py new file mode 100644 index 0000000..d0446ee --- /dev/null +++ b/routes/spy_stations.py @@ -0,0 +1,625 @@ +"""Spy Stations routes - Number stations and diplomatic HF networks.""" + +from flask import Blueprint, jsonify, request + +spy_stations_bp = Blueprint('spy_stations', __name__, url_prefix='/spy-stations') + +# Active spy stations data from priyom.org +STATIONS = [ + # Number Stations (Intelligence) + { + "id": "e06", + "name": "E06", + "nickname": "English Man", + "type": "number", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 4310, "primary": True}, + {"freq_khz": 4800, "primary": False}, + {"freq_khz": 5370, "primary": False}, + ], + "mode": "USB+carrier", + "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" + }, + { + "id": "s06", + "name": "S06", + "nickname": "Russian Man", + "type": "number", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 4310, "primary": True}, + {"freq_khz": 4800, "primary": False}, + {"freq_khz": 5370, "primary": False}, + ], + "mode": "USB+carrier", + "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" + }, + { + "id": "uvb76", + "name": "UVB-76", + "nickname": "The Buzzer", + "type": "number", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 4625, "primary": True}, + {"freq_khz": 5779, "primary": False}, + {"freq_khz": 6810, "primary": False}, + {"freq_khz": 7490, "primary": False}, + ], + "mode": "USB", + "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" + }, + { + "id": "hm01", + "name": "HM01", + "nickname": "Cuban Numbers", + "type": "number", + "country": "Cuba", + "country_code": "CU", + "frequencies": [ + {"freq_khz": 9065, "primary": True}, + {"freq_khz": 9155, "primary": False}, + {"freq_khz": 9240, "primary": False}, + {"freq_khz": 9330, "primary": False}, + {"freq_khz": 10345, "primary": False}, + {"freq_khz": 10715, "primary": False}, + {"freq_khz": 10860, "primary": False}, + {"freq_khz": 11435, "primary": False}, + {"freq_khz": 11462, "primary": False}, + {"freq_khz": 11530, "primary": False}, + {"freq_khz": 11635, "primary": False}, + {"freq_khz": 12180, "primary": False}, + {"freq_khz": 13435, "primary": False}, + {"freq_khz": 14375, "primary": False}, + {"freq_khz": 16180, "primary": False}, + {"freq_khz": 17480, "primary": False}, + ], + "mode": "AM/OFDM", + "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" + }, + { + "id": "e07", + "name": "E07", + "nickname": "7-dash", + "type": "number", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 5292, "primary": True}, + {"freq_khz": 6388, "primary": False}, + {"freq_khz": 7482, "primary": False}, + {"freq_khz": 8576, "primary": False}, + ], + "mode": "USB", + "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" + }, + { + "id": "e11", + "name": "E11", + "nickname": "Mazielka", + "type": "number", + "country": "Poland", + "country_code": "PL", + "frequencies": [ + {"freq_khz": 4030, "primary": True}, + {"freq_khz": 5240, "primary": False}, + {"freq_khz": 6910, "primary": False}, + ], + "mode": "USB", + "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" + }, + { + "id": "e17z", + "name": "E17z", + "nickname": "Israeli Numbers", + "type": "number", + "country": "Israel", + "country_code": "IL", + "frequencies": [ + {"freq_khz": 4779, "primary": True}, + {"freq_khz": 5091, "primary": False}, + {"freq_khz": 6446, "primary": False}, + ], + "mode": "USB", + "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" + }, + { + "id": "g06", + "name": "G06", + "nickname": "Russian German", + "type": "number", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 4310, "primary": True}, + {"freq_khz": 4800, "primary": False}, + {"freq_khz": 5370, "primary": False}, + ], + "mode": "USB+carrier", + "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" + }, + { + "id": "v02a", + "name": "V02a", + "nickname": "Cuban Spy Numbers", + "type": "number", + "country": "Cuba", + "country_code": "CU", + "frequencies": [ + {"freq_khz": 5855, "primary": True}, + {"freq_khz": 9330, "primary": False}, + {"freq_khz": 11635, "primary": False}, + ], + "mode": "AM", + "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" + }, + { + "id": "v07", + "name": "V07", + "nickname": "Russian 7 Voice", + "type": "number", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 3756, "primary": True}, + {"freq_khz": 4625, "primary": False}, + ], + "mode": "USB", + "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" + }, + { + "id": "s11a", + "name": "S11a", + "nickname": "Russian Phonetic", + "type": "number", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 4560, "primary": True}, + {"freq_khz": 5200, "primary": False}, + ], + "mode": "USB", + "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" + }, + { + "id": "v13", + "name": "V13", + "nickname": "The Pip", + "type": "number", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 3756, "primary": True}, + {"freq_khz": 5448, "primary": False}, + ], + "mode": "USB", + "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" + }, + { + "id": "v24", + "name": "V24", + "nickname": "Air Horn", + "type": "number", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 3243, "primary": True}, + ], + "mode": "USB", + "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" + }, + { + "id": "vc01", + "name": "VC01", + "nickname": "Chinese Robot", + "type": "number", + "country": "China", + "country_code": "CN", + "frequencies": [ + {"freq_khz": 8300, "primary": True}, + {"freq_khz": 9725, "primary": False}, + {"freq_khz": 11430, "primary": False}, + {"freq_khz": 13750, "primary": False}, + ], + "mode": "AM", + "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" + }, + { + "id": "v22", + "name": "V22", + "nickname": "Chinese Lady", + "type": "number", + "country": "China", + "country_code": "CN", + "frequencies": [ + {"freq_khz": 7883, "primary": True}, + {"freq_khz": 9170, "primary": False}, + ], + "mode": "AM", + "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" + }, + # Diplomatic Stations + { + "id": "bulgaria_mfa", + "name": "Bulgaria MFA", + "nickname": "Sofia Diplomatic", + "type": "diplomatic", + "country": "Bulgaria", + "country_code": "BG", + "frequencies": [ + {"freq_khz": 5145, "primary": True}, + {"freq_khz": 6755, "primary": False}, + {"freq_khz": 7670, "primary": False}, + {"freq_khz": 9155, "primary": False}, + {"freq_khz": 10175, "primary": False}, + {"freq_khz": 11445, "primary": False}, + {"freq_khz": 14725, "primary": False}, + {"freq_khz": 18520, "primary": False}, + ], + "mode": "RFSM-8000/MIL-STD-188-110", + "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" + }, + { + "id": "czechia_mfa", + "name": "Czechia MFA", + "nickname": "Czech Diplomatic", + "type": "diplomatic", + "country": "Czechia", + "country_code": "CZ", + "frequencies": [ + {"freq_khz": 6830, "primary": True}, + {"freq_khz": 8130, "primary": False}, + {"freq_khz": 10232, "primary": False}, + {"freq_khz": 13890, "primary": False}, + ], + "mode": "PACTOR-III", + "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" + }, + { + "id": "egypt_mfa", + "name": "Egypt MFA", + "nickname": "Egyptian Diplomatic", + "type": "diplomatic", + "country": "Egypt", + "country_code": "EG", + "frequencies": [ + {"freq_khz": 7830, "primary": True}, + {"freq_khz": 9048, "primary": False}, + {"freq_khz": 10780, "primary": False}, + {"freq_khz": 13950, "primary": False}, + ], + "mode": "SITOR/Codan 3012", + "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" + }, + { + "id": "dprk_mfa", + "name": "DPRK MFA", + "nickname": "North Korea Diplomatic", + "type": "diplomatic", + "country": "North Korea", + "country_code": "KP", + "frequencies": [ + {"freq_khz": 7200, "primary": True}, + {"freq_khz": 9450, "primary": False}, + {"freq_khz": 11475, "primary": False}, + {"freq_khz": 13785, "primary": False}, + {"freq_khz": 15245, "primary": False}, + {"freq_khz": 17550, "primary": False}, + {"freq_khz": 21680, "primary": False}, + {"freq_khz": 25120, "primary": False}, + ], + "mode": "DPRK-ARQ (LSB/BFSK 600Bd/MSK 1200Bd)", + "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" + }, + { + "id": "russia_mfa", + "name": "Russia MFA", + "nickname": "Russian Diplomatic", + "type": "diplomatic", + "country": "Russia", + "country_code": "RU", + "frequencies": [ + {"freq_khz": 5154, "primary": True}, + {"freq_khz": 7654, "primary": False}, + {"freq_khz": 9045, "primary": False}, + {"freq_khz": 10755, "primary": False}, + {"freq_khz": 13455, "primary": False}, + {"freq_khz": 16354, "primary": False}, + {"freq_khz": 18954, "primary": False}, + ], + "mode": "Perelivt/Serdolik/X06/OFDM", + "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" + }, + { + "id": "tunisia_mfa", + "name": "Tunisia MFA", + "nickname": "Tunisian Diplomatic", + "type": "diplomatic", + "country": "Tunisia", + "country_code": "TN", + "frequencies": [ + {"freq_khz": 5810, "primary": True}, + {"freq_khz": 7954, "primary": False}, + {"freq_khz": 8014, "primary": False}, + {"freq_khz": 8180, "primary": False}, + {"freq_khz": 10113, "primary": False}, + {"freq_khz": 10176, "primary": False}, + {"freq_khz": 11111, "primary": False}, + {"freq_khz": 12140, "primary": False}, + {"freq_khz": 13945, "primary": False}, + {"freq_khz": 14700, "primary": False}, + {"freq_khz": 14724, "primary": False}, + {"freq_khz": 15635, "primary": False}, + {"freq_khz": 16125, "primary": False}, + {"freq_khz": 16285, "primary": False}, + {"freq_khz": 16290, "primary": False}, + {"freq_khz": 18295, "primary": False}, + {"freq_khz": 19675, "primary": False}, + {"freq_khz": 23540, "primary": False}, + {"freq_khz": 24080, "primary": False}, + {"freq_khz": 24170, "primary": False}, + {"freq_khz": 26890, "primary": False}, + ], + "mode": "2G ALE/PACTOR-II", + "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" + }, + { + "id": "usa_state", + "name": "US State Dept", + "nickname": "American Diplomatic", + "type": "diplomatic", + "country": "United States", + "country_code": "US", + "frequencies": [ + {"freq_khz": 5749, "primary": True}, + {"freq_khz": 6903, "primary": False}, + {"freq_khz": 8059, "primary": False}, + {"freq_khz": 10734, "primary": False}, + {"freq_khz": 11169, "primary": False}, + {"freq_khz": 13504, "primary": False}, + {"freq_khz": 16284, "primary": False}, + {"freq_khz": 18249, "primary": False}, + {"freq_khz": 20811, "primary": False}, + {"freq_khz": 24884, "primary": False}, + ], + "mode": "2G ALE (MIL-STD-188-141A)", + "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" + }, + { + "id": "morocco_mfa", + "name": "Morocco MFA", + "nickname": "Moroccan Diplomatic", + "type": "diplomatic", + "country": "Morocco", + "country_code": "MA", + "frequencies": [ + {"freq_khz": 8010, "primary": True}, + {"freq_khz": 11205, "primary": False}, + {"freq_khz": 14620, "primary": False}, + ], + "mode": "PACTOR-II/ALE", + "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" + }, + { + "id": "poland_mfa", + "name": "Poland MFA", + "nickname": "Polish Diplomatic", + "type": "diplomatic", + "country": "Poland", + "country_code": "PL", + "frequencies": [ + {"freq_khz": 6825, "primary": True}, + {"freq_khz": 9250, "primary": False}, + {"freq_khz": 13485, "primary": False}, + ], + "mode": "STANAG-4285/ALE", + "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" + }, + { + "id": "france_mfa", + "name": "France MFA", + "nickname": "French Diplomatic", + "type": "diplomatic", + "country": "France", + "country_code": "FR", + "frequencies": [ + {"freq_khz": 6910, "primary": True}, + {"freq_khz": 10640, "primary": False}, + {"freq_khz": 13870, "primary": False}, + {"freq_khz": 16840, "primary": False}, + ], + "mode": "MIL-STD-188-110/ALE", + "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" + }, + { + "id": "romania_mfa", + "name": "Romania MFA", + "nickname": "Romanian Diplomatic", + "type": "diplomatic", + "country": "Romania", + "country_code": "RO", + "frequencies": [ + {"freq_khz": 5390, "primary": True}, + {"freq_khz": 8158, "primary": False}, + {"freq_khz": 11555, "primary": False}, + ], + "mode": "PACTOR-III/ALE", + "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" + }, + { + "id": "algeria_mfa", + "name": "Algeria MFA", + "nickname": "Algerian Diplomatic", + "type": "diplomatic", + "country": "Algeria", + "country_code": "DZ", + "frequencies": [ + {"freq_khz": 7706, "primary": True}, + {"freq_khz": 10235, "primary": False}, + {"freq_khz": 14385, "primary": False}, + ], + "mode": "SITOR-B/PACTOR", + "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" + }, + { + "id": "egypt_mfa_m14a", + "name": "Egypt MFA M14a", + "nickname": "Egyptian Extended", + "type": "diplomatic", + "country": "Egypt", + "country_code": "EG", + "frequencies": [ + {"freq_khz": 12175, "primary": True}, + {"freq_khz": 16360, "primary": False}, + ], + "mode": "Codan 3012/SITOR", + "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" + }, +] + + +@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') + + filtered = STATIONS + + if 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()] + + if mode: + mode_lower = mode.lower() + filtered = [s for s in filtered if mode_lower in s['mode'].lower()] + + return jsonify({ + 'status': 'success', + 'count': len(filtered), + 'stations': filtered + }) + + +@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 + }) + + return jsonify({ + 'status': 'error', + 'message': 'Station not found' + }), 404 + + +@spy_stations_bp.route('/filters') +def get_filters(): + """Return available filter options.""" + types = list(set(s['type'] for s in STATIONS)) + countries = sorted(list(set((s['country'], s['country_code']) for s in STATIONS))) + modes = sorted(list(set(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 + } + }) diff --git a/setup.sh b/setup.sh index 3816b1a..62b910a 100755 --- a/setup.sh +++ b/setup.sh @@ -203,6 +203,7 @@ check_tools() { check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr check_required "dump1090" "ADS-B decoder" dump1090 check_required "acarsdec" "ACARS decoder" acarsdec + check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher echo info "GPS:" @@ -412,7 +413,7 @@ install_multimon_ng_from_source_macos() { } install_macos_packages() { - TOTAL_STEPS=13 + TOTAL_STEPS=14 CURRENT_STEP=0 progress "Checking Homebrew" @@ -458,6 +459,13 @@ install_macos_packages() { progress "Installing acarsdec" (brew_install acarsdec) || warn "acarsdec not available via Homebrew" + progress "Installing AIS-catcher" + if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then + (brew_install aiscatcher) || warn "AIS-catcher not available via Homebrew" + else + ok "AIS-catcher already installed" + fi + progress "Installing aircrack-ng" brew_install aircrack-ng @@ -577,6 +585,34 @@ install_acarsdec_from_source_debian() { ) } +install_aiscatcher_from_source_debian() { + info "AIS-catcher not available via APT. Building from source..." + + apt_install build-essential git cmake pkg-config \ + librtlsdr-dev libusb-1.0-0-dev libcurl4-openssl-dev zlib1g-dev + + # Run in subshell to isolate EXIT trap + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + info "Cloning AIS-catcher..." + git clone --depth 1 https://github.com/jvde-github/AIS-catcher.git "$tmp_dir/AIS-catcher" >/dev/null 2>&1 \ + || { warn "Failed to clone AIS-catcher"; exit 1; } + + cd "$tmp_dir/AIS-catcher" + mkdir -p build && cd build + + info "Compiling AIS-catcher..." + if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then + $SUDO install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher + ok "AIS-catcher installed successfully." + else + warn "Failed to build AIS-catcher from source. AIS vessel tracking will not be available." + fi + ) +} + install_rtlsdr_blog_drivers_debian() { # The RTL-SDR Blog drivers provide better support for: # - RTL-SDR Blog V4 (R828D tuner) @@ -684,7 +720,7 @@ install_debian_packages() { export NEEDRESTART_MODE=a fi - TOTAL_STEPS=18 + TOTAL_STEPS=19 CURRENT_STEP=0 progress "Updating APT package lists" @@ -815,6 +851,13 @@ install_debian_packages() { fi cmd_exists acarsdec || install_acarsdec_from_source_debian + progress "Installing AIS-catcher" + if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then + install_aiscatcher_from_source_debian + else + ok "AIS-catcher already installed" + fi + progress "Configuring udev rules" setup_udev_rules_debian diff --git a/static/css/ais_dashboard.css b/static/css/ais_dashboard.css new file mode 100644 index 0000000..cf17513 --- /dev/null +++ b/static/css/ais_dashboard.css @@ -0,0 +1,901 @@ +/* AIS Dashboard - Vessel Tracking Interface */ +/* Styled to match ADSB Dashboard */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-dark: #0a0c10; + --bg-panel: #0f1218; + --bg-card: #151a23; + --border-color: #1f2937; + --border-glow: #4a9eff; + --text-primary: #e8eaed; + --text-secondary: #9ca3af; + --text-dim: #4b5563; + --accent-green: #22c55e; + --accent-cyan: #4a9eff; + --accent-orange: #f59e0b; + --accent-red: #ef4444; + --accent-yellow: #eab308; + --accent-amber: #d4a853; + --grid-line: rgba(74, 158, 255, 0.08); + --radar-cyan: #4a9eff; + --radar-bg: #0f1218; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-dark); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; +} + +/* Animated radar sweep background */ +.radar-bg { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + linear-gradient(var(--grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); + background-size: 50px 50px; + pointer-events: none; + z-index: 0; +} + +/* Scan line effect - subtle */ +.scanline { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); + animation: scan 6s linear infinite; + pointer-events: none; + z-index: 1000; + opacity: 0.3; +} + +@keyframes scan { + 0% { + top: -4px; + } + + 100% { + top: 100vh; + } +} + +/* Header - Mobile first */ +.header { + position: relative; + z-index: 10; + padding: 10px 12px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-height: 52px; +} + +@media (min-width: 768px) { + .header { + padding: 12px 20px; + flex-wrap: nowrap; + } +} + +.logo { + font-family: 'Inter', sans-serif; + font-size: 16px; + font-weight: 700; + letter-spacing: 2px; + color: var(--text-primary); + text-transform: uppercase; +} + +@media (min-width: 768px) { + .logo { + font-size: 20px; + letter-spacing: 3px; + } +} + +.logo span { + display: none; + color: var(--text-secondary); + font-weight: 400; + font-size: 12px; + margin-left: 10px; + letter-spacing: 1px; +} + +@media (min-width: 768px) { + .logo span { + display: inline; + font-size: 14px; + margin-left: 15px; + letter-spacing: 2px; + } +} + +.status-bar { + display: flex; + gap: 20px; + align-items: center; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; +} + +.back-link { + color: var(--accent-cyan); + text-decoration: none; + font-size: 11px; + padding: 4px 10px; + border: 1px solid var(--accent-cyan); + border-radius: 4px; +} + +/* ============================================ + STATISTICS STRIP + ============================================ */ +.stats-strip { + background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%); + border-bottom: 1px solid var(--border-color); + padding: 6px 12px; + position: relative; + z-index: 9; + overflow-x: auto; +} + +.stats-strip-inner { + display: flex; + align-items: center; + gap: 4px; + min-width: max-content; +} + +.strip-stat { + display: flex; + flex-direction: column; + align-items: center; + padding: 4px 10px; + background: rgba(74, 158, 255, 0.05); + border: 1px solid rgba(74, 158, 255, 0.15); + border-radius: 4px; + min-width: 55px; +} + +.strip-stat:hover { + background: rgba(74, 158, 255, 0.1); + border-color: rgba(74, 158, 255, 0.3); +} + +.strip-value { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + font-weight: 600; + color: var(--accent-cyan); + line-height: 1.2; +} + +.strip-label { + font-size: 8px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 1px; +} + +.strip-stat.session-stat { + background: rgba(34, 197, 94, 0.05); + border-color: rgba(34, 197, 94, 0.2); +} + +.strip-stat.session-stat .strip-value { + color: var(--accent-green); +} + +/* Signal quality states */ +.strip-stat.signal-stat .strip-value { + letter-spacing: 2px; +} + +.strip-stat.signal-stat.good { + background: rgba(34, 197, 94, 0.1); + border-color: rgba(34, 197, 94, 0.3); +} + +.strip-stat.signal-stat.good .strip-value { + color: var(--accent-green); +} + +.strip-stat.signal-stat.warning { + background: rgba(245, 158, 11, 0.1); + border-color: rgba(245, 158, 11, 0.3); +} + +.strip-stat.signal-stat.warning .strip-value { + color: var(--accent-orange); +} + +.strip-stat.signal-stat.poor { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.3); +} + +.strip-stat.signal-stat.poor .strip-value { + color: var(--accent-red); +} + +.strip-divider { + width: 1px; + height: 24px; + background: rgba(74, 158, 255, 0.2); + margin: 0 4px; +} + +.strip-status { + display: flex; + align-items: center; + gap: 6px; + padding: 0 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} + +.strip-status .status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent-red); + transition: all 0.3s ease; +} + +.strip-status .status-dot.active { + background: var(--accent-green); + box-shadow: 0 0 10px var(--accent-green); + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +.strip-time { + font-size: 11px; + font-weight: 500; + color: var(--accent-cyan); + font-family: 'JetBrains Mono', monospace; + padding-left: 8px; + border-left: 1px solid rgba(74, 158, 255, 0.2); + white-space: nowrap; +} + +.strip-btn { + position: relative; + z-index: 10; + background: rgba(74, 158, 255, 0.1); + border: 1px solid rgba(74, 158, 255, 0.2); + color: var(--text-primary); + padding: 6px 10px; + border-radius: 4px; + font-size: 10px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.strip-btn:hover:not(:disabled) { + background: rgba(74, 158, 255, 0.2); + border-color: rgba(74, 158, 255, 0.4); +} + +.strip-btn.primary { + background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%); + border: none; + color: white; +} + +/* Main dashboard grid - Mobile first */ +.dashboard { + position: relative; + z-index: 10; + display: flex; + flex-direction: column; + gap: 0; + height: calc(100dvh - 95px); + height: calc(100vh - 95px); + min-height: 400px; +} + +/* Tablet: Two-column layout */ +@media (min-width: 768px) { + .dashboard { + display: grid; + grid-template-columns: 1fr 300px; + grid-template-rows: 1fr auto; + min-height: 500px; + } +} + +/* Main display container (map) */ +.main-display { + position: relative; + flex: 1; + min-height: 300px; +} + +@media (min-width: 768px) { + .main-display { + grid-column: 1; + grid-row: 1; + min-height: 400px; + } +} + +#vesselMap { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +/* Leaflet overrides - Dark map styling */ +.leaflet-container { + background: var(--bg-dark) !important; + font-family: 'JetBrains Mono', monospace; +} + +.leaflet-tile-pane, +.leaflet-container .leaflet-tile-pane { + filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important; +} + +.leaflet-control-zoom a { + background: var(--bg-panel) !important; + color: var(--accent-cyan) !important; + border-color: var(--border-color) !important; +} + +.leaflet-control-attribution { + background: rgba(0, 0, 0, 0.7) !important; + color: var(--text-secondary) !important; + font-size: 9px !important; +} + +.leaflet-popup-content-wrapper { + background: var(--bg-panel); + color: var(--text-primary); + border-radius: 4px; + border: 1px solid rgba(74, 158, 255, 0.2); +} + +.leaflet-popup-tip { + background: var(--bg-panel); +} + +/* Right sidebar - Mobile first */ +.sidebar { + display: flex; + flex-direction: column; + border-left: none; + border-top: 1px solid rgba(74, 158, 255, 0.2); + overflow: hidden; + max-height: 40vh; + background: var(--bg-panel); +} + +@media (min-width: 768px) { + .sidebar { + grid-column: 2; + grid-row: 1; + border-left: 1px solid rgba(74, 158, 255, 0.2); + border-top: none; + max-height: none; + } +} + +/* Panels */ +.panel { + background: var(--bg-panel); + border: 1px solid rgba(74, 158, 255, 0.2); + overflow: hidden; + position: relative; +} + +.panel::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent); +} + +.panel-header { + padding: 10px 15px; + background: rgba(74, 158, 255, 0.05); + border-bottom: 1px solid rgba(74, 158, 255, 0.1); + font-family: 'Orbitron', 'JetBrains Mono', monospace; + font-size: 11px; + font-weight: 500; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--accent-cyan); + display: flex; + justify-content: space-between; + align-items: center; +} + +.panel-indicator { + width: 6px; + height: 6px; + background: var(--text-dim); + border-radius: 50%; + opacity: 0.5; +} + +.panel-indicator.active { + background: var(--accent-green); + opacity: 1; + animation: blink 1s ease-in-out infinite; +} + +@keyframes blink { + 0%, 100% { + opacity: 1; + } + + 50% { + opacity: 0.3; + } +} + +/* Selected vessel panel */ +.selected-vessel { + flex-shrink: 0; + max-height: 480px; + overflow-y: auto; + border-bottom: 1px solid rgba(74, 158, 255, 0.2); +} + +.selected-info { + padding: 12px; +} + +.no-vessel { + text-align: center; + padding: 30px 15px; + color: var(--text-secondary); +} + +.no-vessel-icon { + font-size: 36px; + margin-bottom: 10px; + opacity: 0.5; +} + +.vessel-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 15px; +} + +.vessel-icon { + font-size: 32px; +} + +.vessel-name { + font-family: 'Orbitron', 'JetBrains Mono', monospace; + font-size: 16px; + font-weight: 700; + color: var(--accent-cyan); + text-shadow: 0 0 15px var(--accent-cyan); +} + +.vessel-mmsi { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-secondary); + background: rgba(74, 158, 255, 0.1); + padding: 2px 5px; + border-radius: 3px; +} + +.vessel-details { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} + +.detail-item { + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; + padding: 8px; + border-left: 2px solid var(--accent-cyan); +} + +.detail-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-secondary); + margin-bottom: 2px; +} + +.detail-value { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: var(--accent-cyan); +} + +/* Vessel list */ +.vessel-list { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.vessel-list-content { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.vessel-item { + position: relative; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(74, 158, 255, 0.15); + border-radius: 4px; + padding: 8px 10px; + margin-bottom: 6px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 10px; +} + +.vessel-item:hover { + border-color: var(--accent-cyan); + background: rgba(74, 158, 255, 0.05); +} + +.vessel-item.selected { + border-color: var(--accent-cyan); + box-shadow: 0 0 15px rgba(74, 158, 255, 0.2); + background: rgba(74, 158, 255, 0.1); +} + +.vessel-item-icon { + font-size: 20px; +} + +.vessel-item-info { + flex: 1; +} + +.vessel-item-name { + font-family: 'Orbitron', 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + color: var(--accent-cyan); +} + +.vessel-item-type { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + color: var(--text-secondary); +} + +.vessel-item-speed { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--accent-cyan); + text-align: right; +} + +/* Bottom controls bar */ +.controls-bar { + grid-column: 1 / -1; + grid-row: 2; + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 8px; + padding: 8px 15px; + background: var(--bg-panel); + border-top: 1px solid rgba(74, 158, 255, 0.3); + font-size: 11px; + overflow-x: auto; +} + +.control-group { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + padding: 6px 10px; + background: rgba(74, 158, 255, 0.03); + border: 1px solid rgba(74, 158, 255, 0.1); + border-radius: 6px; +} + +.control-group-label { + font-size: 8px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--accent-cyan); + opacity: 0.7; +} + +.control-group-items { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.control-group label { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + font-size: 10px; + color: var(--text-primary); + white-space: nowrap; +} + +.control-group input[type="checkbox"] { + accent-color: var(--accent-cyan); + width: 12px; + height: 12px; +} + +.control-group select { + padding: 4px 8px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(74, 158, 255, 0.3); + border-radius: 4px; + color: var(--accent-cyan); + font-family: 'JetBrains Mono', monospace; + font-size: 10px; +} + +.control-group input[type="text"], +.control-group input[type="number"] { + padding: 4px 6px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(74, 158, 255, 0.3); + border-radius: 4px; + color: var(--accent-cyan); + font-family: 'JetBrains Mono', monospace; + font-size: 10px; +} + +.control-group.tracking-group { + background: rgba(34, 197, 94, 0.05); + border-color: rgba(34, 197, 94, 0.2); +} + +.control-group.tracking-group .control-group-label { + color: var(--accent-green); +} + +/* Start/stop button */ +.start-btn { + padding: 6px 16px; + border: none; + background: var(--accent-green); + color: #fff; + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; +} + +.start-btn:hover { + background: #1db954; + box-shadow: 0 0 20px rgba(34, 197, 94, 0.3); +} + +.start-btn.active { + background: var(--accent-red); + color: #fff; +} + +.start-btn.active:hover { + background: #dc2626; + box-shadow: 0 0 20px rgba(239, 68, 68, 0.3); +} + +/* Vessel markers */ +.vessel-marker { + background: transparent; + border: none; +} + +.vessel-marker-inner { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + filter: drop-shadow(0 0 2px rgba(0,0,0,0.8)); + transition: transform 0.3s ease; +} + +.vessel-marker.selected .vessel-marker-inner { + filter: drop-shadow(0 0 6px var(--accent-cyan)); +} + +/* Range rings */ +.range-ring { + fill: none; + stroke: var(--accent-cyan); + stroke-opacity: 0.3; + stroke-width: 1; + stroke-dasharray: 4 4; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: var(--bg-dark); +} + +::-webkit-scrollbar-thumb { + background: var(--accent-cyan); + border-radius: 3px; +} + +/* ============== MOBILE/TABLET FIXES ============== */ +@media (max-width: 767px) { + .dashboard { + display: flex !important; + flex-direction: column !important; + height: auto !important; + min-height: calc(100dvh - 95px); + overflow-y: auto !important; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + } + + .main-display { + flex: none !important; + height: 50vh; + min-height: 300px; + width: 100%; + } + + .sidebar { + max-height: none !important; + overflow: visible !important; + flex-shrink: 0; + width: 100%; + } + + .panel { + overflow: visible !important; + } + + .selected-vessel { + max-height: none !important; + overflow: visible !important; + } + + .selected-info { + overflow: visible !important; + } + + .vessel-list-content { + max-height: 250px; + overflow-y: auto !important; + -webkit-overflow-scrolling: touch; + } + + .controls-bar { + flex-wrap: wrap; + gap: 8px; + padding: 10px; + width: 100%; + } + + .back-link { + white-space: nowrap; + font-size: 10px; + padding: 6px 8px; + } + + .strip-time { + font-size: 10px; + } + + .control-group { + flex-wrap: wrap; + } + + .start-btn { + width: 100%; + margin-left: 0; + margin-top: 8px; + } + + .vessel-details { + grid-template-columns: 1fr; + } +} + +/* Mobile responsiveness for stats strip */ +@media (max-width: 768px) { + .stats-strip { + padding: 4px 8px; + } + + .strip-stat { + padding: 3px 6px; + min-width: 45px; + } + + .strip-value { + font-size: 12px; + } + + .strip-label { + font-size: 7px; + } + + .strip-btn { + padding: 6px 10px; + font-size: 9px; + } +} + +/* Leaflet touch fixes for mobile */ +.leaflet-container { + touch-action: pan-x pan-y; + -webkit-tap-highlight-color: transparent; +} + +.leaflet-control-zoom a { + min-width: 44px !important; + min-height: 44px !important; + line-height: 44px !important; + font-size: 18px !important; +} diff --git a/static/css/modes/spy-stations.css b/static/css/modes/spy-stations.css new file mode 100644 index 0000000..d488ecb --- /dev/null +++ b/static/css/modes/spy-stations.css @@ -0,0 +1,466 @@ +/** + * Spy Stations Mode Styles + * Number stations and diplomatic HF networks + */ + +/* ============================================ + MAIN LAYOUT + ============================================ */ +.spy-stations-container { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + min-height: 0; + flex: 1; + overflow-y: auto; +} + +.spy-stations-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.spy-stations-title { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 10px; +} + +.spy-stations-title svg { + color: var(--accent-cyan); +} + +.spy-stations-count { + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-primary); + padding: 4px 10px; + border-radius: 12px; +} + +/* ============================================ + STATION GRID + ============================================ */ +.spy-stations-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 12px; + padding: 4px; + padding-bottom: 20px; +} + +/* ============================================ + STATION CARD + ============================================ */ +.spy-station-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + transition: all 0.2s ease; + display: flex; + flex-direction: column; +} + +.spy-station-card:hover { + border-color: var(--border-light); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +/* Card Header */ +.spy-station-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid var(--border-color); +} + +.spy-station-title { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +.spy-station-flag { + font-size: 18px; + line-height: 1; +} + +.spy-station-name { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.spy-station-nickname { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Type Badge */ +.spy-station-badge { + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 3px 8px; + border-radius: 3px; + flex-shrink: 0; +} + +.spy-badge-number { + background: rgba(74, 158, 255, 0.15); + color: var(--accent-cyan); + border: 1px solid rgba(74, 158, 255, 0.3); +} + +.spy-badge-diplomatic { + background: rgba(34, 197, 94, 0.15); + color: var(--accent-green); + border: 1px solid rgba(34, 197, 94, 0.3); +} + +/* Card Body */ +.spy-station-body { + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; +} + +.spy-station-meta { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.spy-station-meta-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.spy-meta-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); +} + +.spy-meta-value { + font-size: 12px; + color: var(--text-primary); +} + +.spy-meta-mode { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--accent-orange); +} + +/* Frequencies */ +.spy-station-freqs { + display: flex; + flex-direction: column; + gap: 4px; +} + +.spy-freq-list { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--accent-cyan); + line-height: 1.6; +} + +.spy-freq-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.spy-freq-item { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--accent-cyan); + background: var(--bg-secondary); + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--border-color); +} + +/* Description */ +.spy-station-desc { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.5; +} + +/* Card Footer */ +.spy-station-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: rgba(0, 0, 0, 0.1); + border-top: 1px solid var(--border-color); + flex-shrink: 0; + margin-top: auto; +} + +/* Frequency Selector Group */ +.spy-tune-group { + display: flex; + align-items: center; + gap: 6px; + flex: 1; +} + +.spy-freq-select { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + padding: 6px 8px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + min-width: 120px; + cursor: pointer; +} + +.spy-freq-select:hover { + border-color: var(--border-light); +} + +.spy-freq-select:focus { + outline: none; + border-color: var(--accent-cyan); +} + +/* Clickable frequency items in details modal */ +.spy-freq-clickable { + cursor: pointer; + transition: all 0.15s ease; +} + +.spy-freq-clickable:hover { + background: var(--accent-cyan); + color: #000; + border-color: var(--accent-cyan); +} + +/* Tune Button */ +.spy-tune-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #000; + background: var(--accent-green); + border: none; + padding: 8px 14px; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; +} + +.spy-tune-btn:hover { + background: var(--accent-cyan); + transform: scale(1.02); +} + +.spy-tune-btn svg { + stroke-width: 2.5; +} + +/* Details Button */ +.spy-details-btn { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--text-secondary); + background: transparent; + border: 1px solid var(--border-color); + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; +} + +.spy-details-btn:hover { + color: var(--text-primary); + border-color: var(--border-light); + background: var(--bg-secondary); +} + +/* ============================================ + EMPTY STATE + ============================================ */ +.spy-station-empty { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + color: var(--text-dim); +} + +.spy-station-empty p { + font-size: 13px; + margin-top: 8px; +} + +/* ============================================ + MODE VISIBILITY - Ensure sidebar shows when active + ============================================ */ +#spystationsMode.active { + display: block !important; +} + +/* ============================================ + FILTER CHECKBOX STYLING + ============================================ */ +#spystationsMode .inline-checkbox { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--text-secondary); + cursor: pointer; + padding: 4px 0; +} + +#spystationsMode .inline-checkbox input[type="checkbox"] { + width: 14px; + height: 14px; + accent-color: var(--accent-cyan); +} + +#spystationsMode .inline-checkbox:hover { + color: var(--text-primary); +} + +/* ============================================ + RESPONSIVE + ============================================ */ + +/* Large desktop (1200px+) */ +@media (min-width: 1200px) { + .spy-stations-grid { + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + } +} + +/* Desktop/Tablet landscape (1024px) */ +@media (max-width: 1024px) { + .spy-stations-grid { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } +} + +/* Tablet portrait (768px) */ +@media (max-width: 768px) { + .spy-stations-grid { + grid-template-columns: 1fr; + } + + .spy-station-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .spy-station-badge { + align-self: flex-start; + } + + .spy-station-meta { + grid-template-columns: 1fr; + } +} + +/* Small tablet / large phone (640px) */ +@media (max-width: 640px) { + .spy-station-footer { + flex-direction: column; + gap: 8px; + } + + .spy-tune-btn, + .spy-details-btn { + width: 100%; + justify-content: center; + min-height: 44px; + } + + .spy-tune-group { + width: 100%; + flex-direction: column; + } + + .spy-freq-select { + width: 100%; + min-height: 44px; + } +} + +/* Mobile (480px) */ +@media (max-width: 480px) { + .spy-stations-container { + padding: 8px; + } + + .spy-station-body { + padding: 10px; + } + + .spy-stations-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + } + + .spy-station-desc { + -webkit-line-clamp: 2; + } +} + +/* Touch device compliance */ +@media (pointer: coarse) { + .spy-tune-btn, + .spy-details-btn, + .spy-freq-select { + min-height: 44px; + } + + .spy-freq-clickable { + padding: 8px 12px; + } +} diff --git a/static/js/modes/listening-post.js b/static/js/modes/listening-post.js index 2df11bd..ee9b89c 100644 --- a/static/js/modes/listening-post.js +++ b/static/js/modes/listening-post.js @@ -1580,6 +1580,40 @@ function initListeningPost() { e.preventDefault(); tuneFreq(delta); }); + + // Check if we arrived from Spy Stations with a tune request + checkIncomingTuneRequest(); +} + +/** + * Check for incoming tune request from Spy Stations or other pages + */ +function checkIncomingTuneRequest() { + const tuneFreq = sessionStorage.getItem('tuneFrequency'); + const tuneMode = sessionStorage.getItem('tuneMode'); + + if (tuneFreq) { + // Clear the session storage first + sessionStorage.removeItem('tuneFrequency'); + sessionStorage.removeItem('tuneMode'); + + // Parse and validate frequency + const freq = parseFloat(tuneFreq); + if (!isNaN(freq) && freq >= 0.01 && freq <= 2000) { + console.log('[LISTEN] Incoming tune request:', freq, 'MHz, mode:', tuneMode || 'default'); + + // Determine modulation (default to USB for HF/number stations) + const mod = tuneMode || (freq < 30 ? 'usb' : 'am'); + + // Use quickTune to set frequency and modulation + quickTune(freq, mod); + + // Show notification + if (typeof showNotification === 'function') { + showNotification('Tuned to ' + freq.toFixed(3) + ' MHz', mod.toUpperCase() + ' mode'); + } + } + } } // Initialize when DOM is ready @@ -2265,6 +2299,7 @@ window.skipSignal = skipSignal; window.setBand = setBand; window.tuneFreq = tuneFreq; window.quickTune = quickTune; +window.checkIncomingTuneRequest = checkIncomingTuneRequest; window.addFrequencyBookmark = addFrequencyBookmark; window.removeBookmark = removeBookmark; window.tuneToFrequency = tuneToFrequency; diff --git a/static/js/modes/spy-stations.js b/static/js/modes/spy-stations.js new file mode 100644 index 0000000..df598c5 --- /dev/null +++ b/static/js/modes/spy-stations.js @@ -0,0 +1,530 @@ +/** + * Spy Stations Mode + * Number stations and diplomatic HF radio networks + */ + +const SpyStations = (function() { + // State + let stations = []; + let filteredStations = []; + let activeFilters = { + types: ['number', 'diplomatic'], + countries: [], + modes: [] + }; + + // Country flag emoji map + const countryFlags = { + 'RU': '\u{1F1F7}\u{1F1FA}', + 'CU': '\u{1F1E8}\u{1F1FA}', + 'BG': '\u{1F1E7}\u{1F1EC}', + 'CZ': '\u{1F1E8}\u{1F1FF}', + 'EG': '\u{1F1EA}\u{1F1EC}', + 'KP': '\u{1F1F0}\u{1F1F5}', + 'TN': '\u{1F1F9}\u{1F1F3}', + 'US': '\u{1F1FA}\u{1F1F8}', + 'PL': '\u{1F1F5}\u{1F1F1}', + 'IL': '\u{1F1EE}\u{1F1F1}', + 'CN': '\u{1F1E8}\u{1F1F3}', + 'MA': '\u{1F1F2}\u{1F1E6}', + 'FR': '\u{1F1EB}\u{1F1F7}', + 'RO': '\u{1F1F7}\u{1F1F4}', + 'DZ': '\u{1F1E9}\u{1F1FF}' + }; + + /** + * Initialize the spy stations mode + */ + function init() { + fetchStations(); + checkTuneFrequency(); + } + + /** + * Fetch stations from the API + */ + async function fetchStations() { + try { + const response = await fetch('/spy-stations/stations'); + const data = await response.json(); + + if (data.status === 'success') { + stations = data.stations; + initFilters(); + applyFilters(); + updateStats(); + } + } catch (err) { + console.error('Failed to fetch spy stations:', err); + } + } + + /** + * Initialize filter checkboxes + */ + function initFilters() { + // Get unique countries and modes + const countries = [...new Set(stations.map(s => JSON.stringify({name: s.country, code: s.country_code})))].map(s => JSON.parse(s)); + const modes = [...new Set(stations.map(s => s.mode.split('/')[0]))].sort(); + + // Populate country filters + const countryContainer = document.getElementById('countryFilters'); + if (countryContainer) { + countryContainer.innerHTML = countries.map(c => ` + + `).join(''); + } + + // Populate mode filters + const modeContainer = document.getElementById('modeFilters'); + if (modeContainer) { + modeContainer.innerHTML = modes.map(m => ` + + `).join(''); + } + + // Set initial filter states + activeFilters.countries = countries.map(c => c.code); + activeFilters.modes = modes; + } + + /** + * Apply filters and render stations + */ + function applyFilters() { + // Read type filters + const typeNumber = document.getElementById('filterTypeNumber'); + const typeDiplomatic = document.getElementById('filterTypeDiplomatic'); + + activeFilters.types = []; + if (typeNumber && typeNumber.checked) activeFilters.types.push('number'); + if (typeDiplomatic && typeDiplomatic.checked) activeFilters.types.push('diplomatic'); + + // Read country filters + activeFilters.countries = []; + document.querySelectorAll('#countryFilters input[data-country]:checked').forEach(cb => { + activeFilters.countries.push(cb.dataset.country); + }); + + // Read mode filters + activeFilters.modes = []; + document.querySelectorAll('#modeFilters input[data-mode]:checked').forEach(cb => { + activeFilters.modes.push(cb.dataset.mode); + }); + + // Apply filters + filteredStations = stations.filter(s => { + if (!activeFilters.types.includes(s.type)) return false; + if (!activeFilters.countries.includes(s.country_code)) return false; + const stationMode = s.mode.split('/')[0]; + if (!activeFilters.modes.includes(stationMode)) return false; + return true; + }); + + renderStations(); + updateStats(true); + } + + /** + * Render station cards + */ + function renderStations() { + const container = document.getElementById('spyStationsGrid'); + if (!container) return; + + if (filteredStations.length === 0) { + container.innerHTML = ` +
+ + + + + + + +

No stations match your filters

+
+ `; + return; + } + + container.innerHTML = filteredStations.map(station => renderStationCard(station)).join(''); + } + + /** + * Render a single station card + */ + function renderStationCard(station) { + const flag = countryFlags[station.country_code] || ''; + const typeBadgeClass = station.type === 'number' ? 'spy-badge-number' : 'spy-badge-diplomatic'; + const typeBadgeText = station.type === 'number' ? 'NUMBER' : 'DIPLOMATIC'; + + const primaryFreq = station.frequencies.find(f => f.primary) || station.frequencies[0]; + const freqList = station.frequencies.slice(0, 4).map(f => formatFrequency(f.freq_khz)).join(', '); + const moreFreqs = station.frequencies.length > 4 ? ` +${station.frequencies.length - 4} more` : ''; + + // Build tune button with frequency selector if multiple frequencies + let tuneSection; + if (station.frequencies.length > 1) { + const options = station.frequencies.map(f => { + const label = formatFrequency(f.freq_khz) + (f.primary ? ' (primary)' : ''); + return ``; + }).join(''); + tuneSection = ` +
+ + +
+ `; + } else { + tuneSection = ` + + `; + } + + return ` +
+
+
+ ${flag} + ${station.name} + ${station.nickname ? `- ${station.nickname}` : ''} +
+ ${typeBadgeText} +
+
+
+
+ Origin + ${station.country} +
+
+ Mode + ${station.mode} +
+
+
+ Frequencies + ${freqList}${moreFreqs} +
+
${station.description}
+
+ +
+ `; + } + + /** + * Format frequency for display + */ + function formatFrequency(freqKhz) { + if (freqKhz >= 1000) { + return (freqKhz / 1000).toFixed(3) + ' MHz'; + } + return freqKhz + ' kHz'; + } + + /** + * Get appropriate SDR mode from station mode string + */ + function getModeFromStation(stationMode) { + const mode = stationMode.toLowerCase(); + if (mode.includes('am') || mode.includes('ofdm')) return 'am'; + if (mode.includes('lsb')) return 'lsb'; + if (mode.includes('fm')) return 'fm'; + // Default to USB for most number stations and digital modes + return 'usb'; + } + + /** + * Tune to a station frequency + */ + function tuneToStation(stationId, freqKhz) { + const freqMhz = freqKhz / 1000; + sessionStorage.setItem('tuneFrequency', freqMhz.toString()); + + // Find the station and determine mode + const station = stations.find(s => s.id === stationId); + const tuneMode = station ? getModeFromStation(station.mode) : 'usb'; + sessionStorage.setItem('tuneMode', tuneMode); + + const stationName = station ? station.name : 'Station'; + + if (typeof showNotification === 'function') { + showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')'); + } + + // Switch to listening post mode + if (typeof selectMode === 'function') { + selectMode('listening'); + } else if (typeof switchMode === 'function') { + switchMode('listening'); + } + } + + /** + * Tune to selected frequency from dropdown + */ + function tuneToSelectedFreq(stationId) { + const select = document.getElementById('freq-select-' + stationId); + if (select) { + const freqKhz = parseInt(select.value, 10); + tuneToStation(stationId, freqKhz); + } + } + + /** + * Check if we arrived from another page with a tune request + */ + function checkTuneFrequency() { + // This is for the listening post to check - spy stations sets, listening post reads + } + + /** + * Show station details modal + */ + function showDetails(stationId) { + const station = stations.find(s => s.id === stationId); + if (!station) return; + + let modal = document.getElementById('spyStationDetailsModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'spyStationDetailsModal'; + modal.className = 'signal-details-modal'; + document.body.appendChild(modal); + } + + const flag = countryFlags[station.country_code] || ''; + const allFreqs = station.frequencies.map(f => { + const label = f.primary ? ' (primary)' : ''; + return `${formatFrequency(f.freq_khz)}${label}`; + }).join(''); + + modal.innerHTML = ` +
+
+
+

${flag} ${station.name} ${station.nickname ? '- ' + station.nickname : ''}

+ +
+
+
+
Overview
+
+
+ Type + ${station.type === 'number' ? 'Number Station' : 'Diplomatic Network'} +
+
+ Country + ${station.country} +
+
+ Mode + ${station.mode} +
+
+ Operator + ${station.operator || 'Unknown'} +
+
+
+
+
Description
+

${station.description}

+
+
+
Frequencies (${station.frequencies.length})
+
${allFreqs}
+
+ ${station.schedule ? ` +
+
Schedule
+

${station.schedule}

+
+ ` : ''} + ${station.source_url ? ` +
+
Source
+ ${station.source_url} +
+ ` : ''} +
+ +
+ `; + + modal.classList.add('show'); + } + + /** + * Close details modal + */ + function closeDetails() { + const modal = document.getElementById('spyStationDetailsModal'); + if (modal) { + modal.classList.remove('show'); + } + } + + /** + * Show help modal + */ + function showHelp() { + let modal = document.getElementById('spyStationsHelpModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'spyStationsHelpModal'; + modal.className = 'signal-details-modal'; + document.body.appendChild(modal); + } + + modal.innerHTML = ` +
+
+
+

About Spy Stations

+ +
+
+
+
Number Stations
+

+ Number stations are shortwave radio transmissions believed to be used by intelligence agencies + to communicate with spies in the field. They typically broadcast strings of numbers, letters, + or words read by synthesized or live voices. These one-way broadcasts are encrypted using + one-time pads, making them virtually unbreakable. +

+
+
+
Diplomatic Networks
+

+ Foreign ministries maintain HF radio networks to communicate with embassies worldwide, + especially in regions where satellite or internet connectivity may be unreliable or + compromised. These networks use various digital modes like PACTOR, ALE, and proprietary + protocols for encrypted diplomatic traffic. +

+
+
+
How to Listen
+

+ Click "Tune In" on any station to open the Listening Post with the frequency pre-configured. + Most number stations use USB (Upper Sideband) mode. You'll need an SDR capable of receiving + HF frequencies (typically 3-30 MHz) and an appropriate antenna. +

+
+
+
Best Practices
+
    +
  • HF propagation varies with time of day and solar conditions
  • +
  • Use a long wire or loop antenna for best results
  • +
  • Check schedules on priyom.org for transmission times
  • +
  • Night time generally offers better long-distance reception
  • +
+
+
+
Data Sources
+

+ Station data sourced from priyom.org, + a community-maintained database of number stations and related transmissions. +

+
+
+
+ `; + + modal.classList.add('show'); + } + + /** + * Close help modal + */ + function closeHelp() { + const modal = document.getElementById('spyStationsHelpModal'); + if (modal) { + modal.classList.remove('show'); + } + } + + /** + * Update sidebar stats + * @param {boolean} useFiltered - If true, use filtered stations instead of all stations + */ + function updateStats(useFiltered) { + const stationList = useFiltered ? filteredStations : stations; + const numberCount = stationList.filter(s => s.type === 'number').length; + const diplomaticCount = stationList.filter(s => s.type === 'diplomatic').length; + const countryCount = new Set(stationList.map(s => s.country_code)).size; + const freqCount = stationList.reduce((sum, s) => sum + s.frequencies.length, 0); + + const numberEl = document.getElementById('spyStatsNumber'); + const diplomaticEl = document.getElementById('spyStatsDiplomatic'); + const countriesEl = document.getElementById('spyStatsCountries'); + const freqsEl = document.getElementById('spyStatsFreqs'); + + if (numberEl) numberEl.textContent = numberCount; + if (diplomaticEl) diplomaticEl.textContent = diplomaticCount; + if (countriesEl) countriesEl.textContent = countryCount; + if (freqsEl) freqsEl.textContent = freqCount; + + // Update visible count in header if element exists + const visibleCountEl = document.getElementById('spyStationsVisibleCount'); + if (visibleCountEl) { + visibleCountEl.textContent = stationList.length; + } + } + + // Public API + return { + init, + applyFilters, + tuneToStation, + tuneToSelectedFreq, + showDetails, + closeDetails, + showHelp, + closeHelp + }; +})(); + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', function() { + // Will be initialized when mode is switched to spy stations +}); diff --git a/templates/ais_dashboard.html b/templates/ais_dashboard.html new file mode 100644 index 0000000..1927efc --- /dev/null +++ b/templates/ais_dashboard.html @@ -0,0 +1,765 @@ + + + + + + VESSEL RADAR // INTERCEPT - See the Invisible + + + + + + + + +
+
+ +
+ + +
+ +
+
+
+ 0 + VESSELS +
+
+ 0 + SEEN +
+
+ 0 + MAX NM +
+
+ - + MAX KT +
+
+ - + NEAR NM +
+
+
+ -- + SIGNAL +
+
+ 00:00:00 + SESSION +
+
+
+
+ STANDBY +
+
--:--:-- UTC
+
+
+ +
+
+
+
+ + + +
+
+ DISPLAY +
+ + + +
+
+ +
+ LOCATION +
+ + +
+
+ +
+ AIS TRACKING +
+ + + +
+
+
+
+ + + + diff --git a/templates/index.html b/templates/index.html index 6bdcd00..dd36438 100644 --- a/templates/index.html +++ b/templates/index.html @@ -21,6 +21,7 @@ + @@ -95,6 +96,11 @@ Aircraft ADS-B tracking + + + Vessels + AIS ship tracking + + @@ -271,9 +282,11 @@ Aircraft + Vessels +
@@ -330,12 +343,14 @@ Aircraft + Vessels + @@ -446,6 +461,10 @@ {% include 'partials/modes/tscm.html' %} + {% include 'partials/modes/ais.html' %} + + {% include 'partials/modes/spy-stations.html' %} +
+ + +