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..4ad630f 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 @@ -27,6 +28,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) diff --git a/routes/ais.py b/routes/ais.py new file mode 100644 index 0000000..a4e4b85 --- /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 = False + +# 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 = False + ais_messages_received = 0 + _ais_error_logged = False + + 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 = False + 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/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/templates/ais_dashboard.html b/templates/ais_dashboard.html new file mode 100644 index 0000000..edf4e5a --- /dev/null +++ b/templates/ais_dashboard.html @@ -0,0 +1,1092 @@ + + + + + + VESSEL RADAR // INTERCEPT - See the Invisible + + + + + + + +
+ +
+ Main Dashboard +
+
+ +
+
+
+ 0 + VESSELS +
+
+ 0 + SEEN +
+
+ 0 + MAX NM +
+
+ - + MAX KT +
+
+ - + NEAR NM +
+
+
+ 00:00:00 + SESSION +
+
+
+
+ STANDBY +
+
--:--:-- UTC
+
+
+ +
+
+
+
+ + + +
+
+ DISPLAY +
+ + + +
+
+ +
+ LOCATION +
+ + +
+
+ +
+ AIS TRACKING +
+ + + +
+
+
+
+ + + + diff --git a/templates/index.html b/templates/index.html index 6bdcd00..0c2b891 100644 --- a/templates/index.html +++ b/templates/index.html @@ -95,6 +95,11 @@ Aircraft ADS-B tracking + + + Vessels + AIS ship tracking + Aircraft + Vessels @@ -330,6 +336,7 @@ Aircraft + Vessels @@ -446,6 +453,8 @@ {% include 'partials/modes/tscm.html' %} + {% include 'partials/modes/ais.html' %} + + + + + diff --git a/utils/constants.py b/utils/constants.py index ecc174f..04690a8 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -203,6 +203,32 @@ SBS_RECONNECT_DELAY = 2.0 # Default pager log file DEFAULT_PAGER_LOG_FILE = 'pager_messages.log' + +# ============================================================================= +# AIS (Vessel Tracking) +# ============================================================================= + +# AIS-catcher TCP server port +AIS_TCP_PORT = 10110 + +# AIS stream update interval +AIS_UPDATE_INTERVAL = 0.5 + +# AIS reconnect delay on error +AIS_RECONNECT_DELAY = 2.0 + +# AIS socket timeout +AIS_SOCKET_TIMEOUT = 5 + +# AIS frequencies (MHz) +AIS_FREQUENCIES = [161.975, 162.025] + +# Maximum age for vessel data before cleanup +MAX_VESSEL_AGE_SECONDS = 600 # 10 minutes + +# AIS process termination timeout +AIS_TERMINATE_TIMEOUT = 5 + # WiFi capture temp path prefix WIFI_CAPTURE_PATH_PREFIX = '/tmp/intercept_wifi' diff --git a/utils/dependencies.py b/utils/dependencies.py index 030ce6f..2183b96 100644 --- a/utils/dependencies.py +++ b/utils/dependencies.py @@ -209,6 +209,20 @@ TOOL_DEPENDENCIES = { } } }, + 'ais': { + 'name': 'Vessel Tracking (AIS)', + 'tools': { + 'AIS-catcher': { + 'required': True, + 'description': 'AIS receiver and decoder', + 'install': { + 'apt': 'Download .deb from https://github.com/jvde-github/AIS-catcher/releases', + 'brew': 'brew install aiscatcher', + 'manual': 'https://github.com/jvde-github/AIS-catcher/releases' + } + } + } + }, 'aprs': { 'name': 'APRS Tracking', 'tools': { diff --git a/utils/sdr/airspy.py b/utils/sdr/airspy.py index fcf2127..b70c76d 100644 --- a/utils/sdr/airspy.py +++ b/utils/sdr/airspy.py @@ -155,6 +155,36 @@ class AirspyCommandBuilder(CommandBuilder): return cmd + def build_ais_command( + self, + device: SDRDevice, + gain: Optional[float] = None, + bias_t: bool = False, + tcp_port: int = 10110 + ) -> list[str]: + """ + Build AIS-catcher command for AIS vessel tracking with Airspy. + + Uses AIS-catcher with SoapySDR support. + """ + device_str = self._build_device_string(device) + + cmd = [ + '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))]) + + if bias_t: + cmd.extend(['-gr', 'biastee', '1']) + + return cmd + def get_capabilities(self) -> SDRCapabilities: """Return Airspy capabilities.""" return self.CAPABILITIES diff --git a/utils/sdr/base.py b/utils/sdr/base.py index 7b43c2f..4dc79be 100644 --- a/utils/sdr/base.py +++ b/utils/sdr/base.py @@ -159,6 +159,28 @@ class CommandBuilder(ABC): """ pass + @abstractmethod + def build_ais_command( + self, + device: SDRDevice, + gain: Optional[float] = None, + bias_t: bool = False, + tcp_port: int = 10110 + ) -> list[str]: + """ + Build AIS decoder command for vessel tracking. + + Args: + device: The SDR device to use + gain: Gain in dB (None for auto) + bias_t: Enable bias-T power (for active antennas) + tcp_port: TCP port for JSON output server + + Returns: + Command as list of strings for subprocess + """ + pass + @abstractmethod def get_capabilities(self) -> SDRCapabilities: """Return hardware capabilities for this SDR type.""" diff --git a/utils/sdr/hackrf.py b/utils/sdr/hackrf.py index a214114..ea3a24e 100644 --- a/utils/sdr/hackrf.py +++ b/utils/sdr/hackrf.py @@ -155,6 +155,36 @@ class HackRFCommandBuilder(CommandBuilder): return cmd + def build_ais_command( + self, + device: SDRDevice, + gain: Optional[float] = None, + bias_t: bool = False, + tcp_port: int = 10110 + ) -> list[str]: + """ + Build AIS-catcher command for AIS vessel tracking with HackRF. + + Uses AIS-catcher with SoapySDR support. + """ + device_str = self._build_device_string(device) + + cmd = [ + '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))]) + + if bias_t: + cmd.extend(['-gr', 'biastee', '1']) + + return cmd + def get_capabilities(self) -> SDRCapabilities: """Return HackRF capabilities.""" return self.CAPABILITIES diff --git a/utils/sdr/limesdr.py b/utils/sdr/limesdr.py index c0405af..ad9a9d1 100644 --- a/utils/sdr/limesdr.py +++ b/utils/sdr/limesdr.py @@ -134,6 +134,34 @@ class LimeSDRCommandBuilder(CommandBuilder): return cmd + def build_ais_command( + self, + device: SDRDevice, + gain: Optional[float] = None, + bias_t: bool = False, + tcp_port: int = 10110 + ) -> list[str]: + """ + Build AIS-catcher command for AIS vessel tracking with LimeSDR. + + Uses AIS-catcher with SoapySDR support. + Note: LimeSDR does not support bias-T, parameter is ignored. + """ + device_str = self._build_device_string(device) + + cmd = [ + '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))]) + + return cmd + def get_capabilities(self) -> SDRCapabilities: """Return LimeSDR capabilities.""" return self.CAPABILITIES diff --git a/utils/sdr/rtlsdr.py b/utils/sdr/rtlsdr.py index d8b9eb3..98d9d27 100644 --- a/utils/sdr/rtlsdr.py +++ b/utils/sdr/rtlsdr.py @@ -157,6 +157,41 @@ class RTLSDRCommandBuilder(CommandBuilder): return cmd + def build_ais_command( + self, + device: SDRDevice, + gain: Optional[float] = None, + bias_t: bool = False, + tcp_port: int = 10110 + ) -> list[str]: + """ + Build AIS-catcher command for AIS vessel tracking. + + Uses AIS-catcher with TCP JSON output for real-time vessel data. + AIS operates on 161.975 MHz and 162.025 MHz (handled automatically). + """ + if device.is_network: + raise ValueError( + "AIS-catcher does not support rtl_tcp. " + "For remote AIS, run AIS-catcher on the remote machine." + ) + + cmd = [ + 'AIS-catcher', + '-d', str(device.index), + '-S', str(tcp_port), # TCP server with JSON output + '-o', '5', # JSON output format + '-q', # Quiet mode (less console output) + ] + + if gain is not None and gain > 0: + cmd.extend(['-gr', 'tuner', str(int(gain))]) + + if bias_t: + cmd.extend(['-gr', 'biastee', '1']) + + return cmd + def get_capabilities(self) -> SDRCapabilities: """Return RTL-SDR capabilities.""" return self.CAPABILITIES diff --git a/utils/sdr/sdrplay.py b/utils/sdr/sdrplay.py index 3143dc0..240e286 100644 --- a/utils/sdr/sdrplay.py +++ b/utils/sdr/sdrplay.py @@ -133,6 +133,36 @@ class SDRPlayCommandBuilder(CommandBuilder): return cmd + def build_ais_command( + self, + device: SDRDevice, + gain: Optional[float] = None, + bias_t: bool = False, + tcp_port: int = 10110 + ) -> list[str]: + """ + Build AIS-catcher command for AIS vessel tracking with SDRPlay. + + Uses AIS-catcher with SoapySDR support. + """ + device_str = self._build_device_string(device) + + cmd = [ + '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))]) + + if bias_t: + cmd.extend(['-gr', 'biastee', '1']) + + return cmd + def get_capabilities(self) -> SDRCapabilities: """Return SDRPlay capabilities.""" return self.CAPABILITIES