From f724421ce7d307d556148185b3d3710b4e3f9eb1 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 23 Jan 2026 06:02:54 -0600 Subject: [PATCH 1/5] Adding Vessels --- CLAUDE.md | 122 ++++ app.py | 24 +- routes/__init__.py | 2 + routes/ais.py | 480 +++++++++++++ setup.sh | 47 +- templates/ais_dashboard.html | 1092 +++++++++++++++++++++++++++++ templates/index.html | 19 +- templates/partials/modes/ais.html | 119 ++++ utils/constants.py | 26 + utils/dependencies.py | 14 + utils/sdr/airspy.py | 30 + utils/sdr/base.py | 22 + utils/sdr/hackrf.py | 30 + utils/sdr/limesdr.py | 28 + utils/sdr/rtlsdr.py | 35 + utils/sdr/sdrplay.py | 30 + 16 files changed, 2113 insertions(+), 7 deletions(-) create mode 100644 CLAUDE.md create mode 100644 routes/ais.py create mode 100644 templates/ais_dashboard.html create mode 100644 templates/partials/modes/ais.html 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 + + + + + + + +
+ + +
+ +
+
+
+ 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 From 57d448c0037e52a9e7de81d230cda82550fa4018 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 23 Jan 2026 16:00:13 -0600 Subject: [PATCH 2/5] Adjustment to dashboard style and 500 error --- routes/ais.py | 8 +- static/css/ais_dashboard.css | 869 +++++++++++++++++++++++++++++++++++ templates/ais_dashboard.html | 423 +---------------- utils/sdr/rtlsdr.py | 6 +- 4 files changed, 886 insertions(+), 420 deletions(-) create mode 100644 static/css/ais_dashboard.css diff --git a/routes/ais.py b/routes/ais.py index a4e4b85..6aacbfc 100644 --- a/routes/ais.py +++ b/routes/ais.py @@ -42,7 +42,7 @@ ais_connected = False ais_messages_received = 0 ais_last_message_time = None ais_active_device = None -_ais_error_logged = False +_ais_error_logged = True # Common installation paths for AIS-catcher AIS_CATCHER_PATHS = [ @@ -72,9 +72,9 @@ def parse_ais_stream(port: int): 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_connected = True ais_messages_received = 0 - _ais_error_logged = False + _ais_error_logged = True while ais_running: try: @@ -82,7 +82,7 @@ def parse_ais_stream(port: int): sock.settimeout(AIS_SOCKET_TIMEOUT) sock.connect(('localhost', port)) ais_connected = True - _ais_error_logged = False + _ais_error_logged = True logger.info("Connected to AIS-catcher TCP server") buffer = "" diff --git a/static/css/ais_dashboard.css b/static/css/ais_dashboard.css new file mode 100644 index 0000000..4382bd5 --- /dev/null +++ b/static/css/ais_dashboard.css @@ -0,0 +1,869 @@ +/* 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); +} + +.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/templates/ais_dashboard.html b/templates/ais_dashboard.html index edf4e5a..c4f5f3c 100644 --- a/templates/ais_dashboard.html +++ b/templates/ais_dashboard.html @@ -4,420 +4,17 @@ VESSEL RADAR // INTERCEPT - See the Invisible - + + - + +
+
+
@@ -280,6 +286,7 @@ +
@@ -343,6 +350,7 @@ + @@ -455,6 +463,8 @@ {% include 'partials/modes/ais.html' %} + {% include 'partials/modes/spy-stations.html' %} +
+ + +