Adding Vessels

This commit is contained in:
Marc
2026-01-23 06:02:54 -06:00
parent 9134195eb1
commit f724421ce7
16 changed files with 2113 additions and 7 deletions

122
CLAUDE.md Normal file
View File

@@ -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.

24
app.py
View File

@@ -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

View File

@@ -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)

480
routes/ais.py Normal file
View File

@@ -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')

View File

@@ -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

1092
templates/ais_dashboard.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -95,6 +95,11 @@
<span class="mode-name">Aircraft</span>
<span class="mode-desc">ADS-B tracking</span>
</a>
<a href="/ais/dashboard" class="mode-card" style="text-decoration: none;">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-8l-4-6H6L2 12v8z"/><path d="M12 4v4"/><path d="M8 4v2"/><path d="M16 4v2"/></svg></span>
<span class="mode-name">Vessels</span>
<span class="mode-desc">AIS ship tracking</span>
</a>
<button class="mode-card" onclick="selectMode('wifi')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="mode-name">WiFi</span>
@@ -271,6 +276,7 @@
<button class="mode-nav-btn" onclick="switchMode('sensor')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span><span class="nav-label">433MHz</span></button>
<button class="mode-nav-btn" onclick="switchMode('rtlamr')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span><span class="nav-label">Meters</span></button>
<a href="/adsb/dashboard" class="mode-nav-btn" style="text-decoration: none;"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="nav-label">Aircraft</span></a>
<a href="/ais/dashboard" class="mode-nav-btn" style="text-decoration: none;"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-8l-4-6H6L2 12v8z"/><path d="M12 4v4"/><path d="M8 4v2"/><path d="M16 4v2"/></svg></span><span class="nav-label">Vessels</span></a>
<button class="mode-nav-btn" onclick="switchMode('aprs')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="nav-label">APRS</span></button>
<button class="mode-nav-btn" onclick="switchMode('satellite')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span><span class="nav-label">Satellite</span></button>
<button class="mode-nav-btn" onclick="switchMode('listening')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span><span class="nav-label">Listening Post</span></button>
@@ -330,6 +336,7 @@
<button class="mobile-nav-btn" data-mode="sensor" onclick="switchMode('sensor')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span> 433MHz</button>
<button class="mobile-nav-btn" data-mode="rtlamr" onclick="switchMode('rtlamr')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span> Meters</button>
<a href="/adsb/dashboard" class="mobile-nav-btn" style="text-decoration: none;"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span> Aircraft</a>
<a href="/ais/dashboard" class="mobile-nav-btn" style="text-decoration: none;"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-8l-4-6H6L2 12v8z"/><path d="M12 4v4"/></svg></span> Vessels</a>
<button class="mobile-nav-btn" data-mode="aprs" onclick="switchMode('aprs')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span> APRS</button>
<button class="mobile-nav-btn" data-mode="wifi" onclick="switchMode('wifi')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg></span> WiFi</button>
<button class="mobile-nav-btn" data-mode="bluetooth" onclick="switchMode('bluetooth')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span> BT</button>
@@ -446,6 +453,8 @@
{% include 'partials/modes/tscm.html' %}
{% include 'partials/modes/ais.html' %}
<button class="preset-btn" onclick="killAll()"
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
Kill All Processes
@@ -2046,6 +2055,7 @@
document.getElementById('listeningPostMode')?.classList.toggle('active', mode === 'listening');
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
const pagerStats = document.getElementById('pagerStats');
const sensorStats = document.getElementById('sensorStats');
const satelliteStats = document.getElementById('satelliteStats');
@@ -2075,7 +2085,8 @@
'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST',
'aprs': 'APRS',
'tscm': 'TSCM'
'tscm': 'TSCM',
'ais': 'AIS VESSELS'
};
const activeModeIndicator = document.getElementById('activeModeIndicator');
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
@@ -2108,7 +2119,8 @@
'bluetooth': 'Bluetooth Scanner',
'listening': 'Listening Post',
'aprs': 'APRS Tracker',
'tscm': 'TSCM Counter-Surveillance'
'tscm': 'TSCM Counter-Surveillance',
'ais': 'AIS Vessel Tracker'
};
const outputTitle = document.getElementById('outputTitle');
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
@@ -10495,6 +10507,8 @@
</div>
<div class="icon-item"><span class="icon">✈️</span><span class="desc">Aircraft - Opens ADS-B
Dashboard</span></div>
<div class="icon-item"><span class="icon">🚢</span><span class="desc">Vessels - Opens AIS
Dashboard</span></div>
<div class="icon-item"><span class="icon">📍</span><span class="desc">APRS - Amateur radio
tracking</span></div>
<div class="icon-item"><span class="icon">🛰️</span><span class="desc">Satellite - Pass
@@ -10671,6 +10685,7 @@
<li><strong>Utility Meters:</strong> RTL-SDR, rtl_tcp, rtlamr</li>
<li><strong>Aircraft (ADS-B):</strong> RTL-SDR, dump1090 or rtl_adsb</li>
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>

View File

@@ -0,0 +1,119 @@
<!-- AIS VESSEL TRACKING MODE -->
<div id="aisMode" class="mode-content" style="display: none;">
<div class="section">
<h3>AIS Vessel Tracking</h3>
<div class="info-text" style="margin-bottom: 15px;">
Track ships and vessels via AIS (Automatic Identification System) on 161.975 / 162.025 MHz.
</div>
<a href="/ais/dashboard" target="_blank" class="run-btn" style="display: inline-block; text-decoration: none; text-align: center; margin-bottom: 15px;">
Open AIS Dashboard
</a>
</div>
<div class="section">
<h3>Settings</h3>
<div class="form-group">
<label>Gain (dB, 0 = auto)</label>
<input type="number" id="aisGainInput" value="40" min="0" max="50" placeholder="0-50">
</div>
</div>
<div class="section">
<h3>Status</h3>
<div id="aisStatusDisplay" class="info-text">
<p>Status: <span id="aisStatusText" style="color: var(--accent-yellow);">Standby</span></p>
<p>Vessels: <span id="aisVesselCount">0</span></p>
</div>
</div>
<button class="run-btn" id="startAisBtn" onclick="startAisTracking()">
Start AIS Tracking
</button>
<button class="stop-btn" id="stopAisBtn" onclick="stopAisTracking()" style="display: none;">
Stop AIS Tracking
</button>
</div>
<script>
let aisEventSource = null;
let aisVessels = {};
function startAisTracking() {
const gain = document.getElementById('aisGainInput').value || '40';
const device = document.getElementById('deviceSelect')?.value || '0';
fetch('/ais/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain })
})
.then(r => r.json())
.then(data => {
if (data.status === 'started' || data.status === 'already_running') {
document.getElementById('startAisBtn').style.display = 'none';
document.getElementById('stopAisBtn').style.display = 'block';
document.getElementById('aisStatusText').textContent = 'Tracking';
document.getElementById('aisStatusText').style.color = 'var(--accent-green)';
startAisSSE();
} else {
alert(data.message || 'Failed to start AIS tracking');
}
})
.catch(err => alert('Error: ' + err.message));
}
function stopAisTracking() {
fetch('/ais/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
document.getElementById('startAisBtn').style.display = 'block';
document.getElementById('stopAisBtn').style.display = 'none';
document.getElementById('aisStatusText').textContent = 'Standby';
document.getElementById('aisStatusText').style.color = 'var(--accent-yellow)';
document.getElementById('aisVesselCount').textContent = '0';
if (aisEventSource) {
aisEventSource.close();
aisEventSource = null;
}
aisVessels = {};
});
}
function startAisSSE() {
if (aisEventSource) aisEventSource.close();
aisEventSource = new EventSource('/ais/stream');
aisEventSource.onmessage = function(e) {
try {
const data = JSON.parse(e.data);
if (data.type === 'vessel') {
aisVessels[data.mmsi] = data;
document.getElementById('aisVesselCount').textContent = Object.keys(aisVessels).length;
}
} catch (err) {}
};
aisEventSource.onerror = function() {
setTimeout(() => {
if (document.getElementById('stopAisBtn').style.display === 'block') {
startAisSSE();
}
}, 2000);
};
}
// Check initial status
fetch('/ais/status')
.then(r => r.json())
.then(data => {
if (data.tracking_active) {
document.getElementById('startAisBtn').style.display = 'none';
document.getElementById('stopAisBtn').style.display = 'block';
document.getElementById('aisStatusText').textContent = 'Tracking';
document.getElementById('aisStatusText').style.color = 'var(--accent-green)';
document.getElementById('aisVesselCount').textContent = data.vessel_count || 0;
startAisSSE();
}
})
.catch(() => {});
</script>

View File

@@ -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'

View File

@@ -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': {

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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