Merge pull request #79 from xdep/testing-branch

Merge vessel-tracking: Add AIS-based vessel tracking support + Spy stations (Diplomatic + number stations)
This commit is contained in:
Smittix
2026-01-24 18:10:41 +00:00
committed by GitHub
22 changed files with 4477 additions and 10 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
@@ -18,6 +19,7 @@ def register_blueprints(app):
from .correlation import correlation_bp
from .listening_post import listening_post_bp
from .tscm import tscm_bp, init_tscm_state
from .spy_stations import spy_stations_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -27,6 +29,7 @@ def register_blueprints(app):
app.register_blueprint(bluetooth_bp)
app.register_blueprint(bluetooth_v2_bp) # New unified Bluetooth API
app.register_blueprint(adsb_bp)
app.register_blueprint(ais_bp)
app.register_blueprint(acars_bp)
app.register_blueprint(aprs_bp)
app.register_blueprint(satellite_bp)
@@ -35,6 +38,7 @@ def register_blueprints(app):
app.register_blueprint(correlation_bp)
app.register_blueprint(listening_post_bp)
app.register_blueprint(tscm_bp)
app.register_blueprint(spy_stations_bp)
# Initialize TSCM state with queue and lock from app
import app as app_module

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 = True
# Common installation paths for AIS-catcher
AIS_CATCHER_PATHS = [
'/usr/local/bin/AIS-catcher',
'/usr/bin/AIS-catcher',
'/opt/homebrew/bin/AIS-catcher',
'/opt/homebrew/bin/aiscatcher',
]
def find_ais_catcher():
"""Find AIS-catcher binary, checking PATH and common locations."""
# First try PATH
for name in ['AIS-catcher', 'aiscatcher']:
path = shutil.which(name)
if path:
return path
# Check common installation paths
for path in AIS_CATCHER_PATHS:
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
def parse_ais_stream(port: int):
"""Parse JSON data from AIS-catcher TCP server."""
global ais_running, ais_connected, ais_messages_received, ais_last_message_time, _ais_error_logged
logger.info(f"AIS stream parser started, connecting to localhost:{port}")
ais_connected = True
ais_messages_received = 0
_ais_error_logged = True
while ais_running:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(AIS_SOCKET_TIMEOUT)
sock.connect(('localhost', port))
ais_connected = True
_ais_error_logged = True
logger.info("Connected to AIS-catcher TCP server")
buffer = ""
last_update = time.time()
pending_updates = set()
while ais_running:
try:
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
if not data:
logger.warning("AIS connection closed (no data)")
break
buffer += data
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
vessel = process_ais_message(msg)
if vessel:
mmsi = vessel.get('mmsi')
if mmsi:
app_module.ais_vessels.set(mmsi, vessel)
pending_updates.add(mmsi)
ais_messages_received += 1
ais_last_message_time = time.time()
except json.JSONDecodeError:
if ais_messages_received < 5:
logger.debug(f"Invalid JSON: {line[:100]}")
# Batch updates
now = time.time()
if now - last_update >= AIS_UPDATE_INTERVAL:
for mmsi in pending_updates:
if mmsi in app_module.ais_vessels:
try:
app_module.ais_queue.put_nowait({
'type': 'vessel',
**app_module.ais_vessels[mmsi]
})
except queue.Full:
pass
pending_updates.clear()
last_update = now
except socket.timeout:
continue
sock.close()
ais_connected = False
except OSError as e:
ais_connected = False
if not _ais_error_logged:
logger.warning(f"AIS connection error: {e}, reconnecting...")
_ais_error_logged = True
time.sleep(AIS_RECONNECT_DELAY)
ais_connected = False
logger.info("AIS stream parser stopped")
def process_ais_message(msg: dict) -> dict | None:
"""Process AIS-catcher JSON message and extract vessel data."""
# AIS-catcher outputs different message types
# We're interested in position reports and static data
mmsi = msg.get('mmsi')
if not mmsi:
return None
mmsi = str(mmsi)
# Get existing vessel data or create new
vessel = app_module.ais_vessels.get(mmsi) or {'mmsi': mmsi}
# Extract common fields
if 'lat' in msg and 'lon' in msg:
try:
lat = float(msg['lat'])
lon = float(msg['lon'])
# Validate coordinates (AIS uses 181 for unavailable)
if -90 <= lat <= 90 and -180 <= lon <= 180:
vessel['lat'] = lat
vessel['lon'] = lon
except (ValueError, TypeError):
pass
# Speed over ground (knots)
if 'speed' in msg:
try:
speed = float(msg['speed'])
if speed < 102.3: # 102.3 = not available
vessel['speed'] = round(speed, 1)
except (ValueError, TypeError):
pass
# Course over ground (degrees)
if 'course' in msg:
try:
course = float(msg['course'])
if course < 360: # 360 = not available
vessel['course'] = round(course, 1)
except (ValueError, TypeError):
pass
# True heading (degrees)
if 'heading' in msg:
try:
heading = int(msg['heading'])
if heading < 511: # 511 = not available
vessel['heading'] = heading
except (ValueError, TypeError):
pass
# Navigation status
if 'status' in msg:
vessel['nav_status'] = msg['status']
if 'status_text' in msg:
vessel['nav_status_text'] = msg['status_text']
# Vessel name (from Type 5 or Type 24 messages)
if 'shipname' in msg:
name = msg['shipname'].strip().strip('@')
if name:
vessel['name'] = name
# Callsign
if 'callsign' in msg:
callsign = msg['callsign'].strip().strip('@')
if callsign:
vessel['callsign'] = callsign
# Ship type
if 'shiptype' in msg:
vessel['ship_type'] = msg['shiptype']
if 'shiptype_text' in msg:
vessel['ship_type_text'] = msg['shiptype_text']
# Destination
if 'destination' in msg:
dest = msg['destination'].strip().strip('@')
if dest:
vessel['destination'] = dest
# ETA
if 'eta' in msg:
vessel['eta'] = msg['eta']
# Dimensions
if 'to_bow' in msg and 'to_stern' in msg:
try:
length = int(msg['to_bow']) + int(msg['to_stern'])
if length > 0:
vessel['length'] = length
except (ValueError, TypeError):
pass
if 'to_port' in msg and 'to_starboard' in msg:
try:
width = int(msg['to_port']) + int(msg['to_starboard'])
if width > 0:
vessel['width'] = width
except (ValueError, TypeError):
pass
# Draught
if 'draught' in msg:
try:
draught = float(msg['draught'])
if draught > 0:
vessel['draught'] = draught
except (ValueError, TypeError):
pass
# Rate of turn
if 'turn' in msg:
try:
turn = float(msg['turn'])
if -127 <= turn <= 127: # Valid range
vessel['rate_of_turn'] = turn
except (ValueError, TypeError):
pass
# Message type for debugging
if 'type' in msg:
vessel['last_msg_type'] = msg['type']
# Timestamp
vessel['last_seen'] = time.time()
return vessel
@ais_bp.route('/tools')
def check_ais_tools():
"""Check for AIS decoding tools and hardware."""
has_ais_catcher = find_ais_catcher() is not None
# Check what SDR hardware is detected
devices = SDRFactory.detect_devices()
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
return jsonify({
'ais_catcher': has_ais_catcher,
'ais_catcher_path': find_ais_catcher(),
'has_rtlsdr': has_rtlsdr,
'device_count': len(devices)
})
@ais_bp.route('/status')
def ais_status():
"""Get AIS tracking status for debugging."""
process_running = False
if app_module.ais_process:
process_running = app_module.ais_process.poll() is None
return jsonify({
'tracking_active': ais_running,
'active_device': ais_active_device,
'connected': ais_connected,
'messages_received': ais_messages_received,
'last_message_time': ais_last_message_time,
'vessel_count': len(app_module.ais_vessels),
'vessels': dict(app_module.ais_vessels),
'queue_size': app_module.ais_queue.qsize(),
'ais_catcher_path': find_ais_catcher(),
'process_running': process_running
})
@ais_bp.route('/start', methods=['POST'])
def start_ais():
"""Start AIS tracking."""
global ais_running, ais_active_device
with app_module.ais_lock:
if ais_running:
return jsonify({'status': 'already_running', 'message': 'AIS tracking already active'}), 409
data = request.json or {}
# Validate inputs
try:
gain = int(validate_gain(data.get('gain', '40')))
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
# Find AIS-catcher
ais_catcher_path = find_ais_catcher()
if not ais_catcher_path:
return jsonify({
'status': 'error',
'message': 'AIS-catcher not found. Install from https://github.com/jvde-github/AIS-catcher/releases'
}), 400
# Get SDR type from request
sdr_type_str = data.get('sdr_type', 'rtlsdr')
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Kill any existing process
if app_module.ais_process:
try:
pgid = os.getpgid(app_module.ais_process.pid)
os.killpg(pgid, 15)
app_module.ais_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
pgid = os.getpgid(app_module.ais_process.pid)
os.killpg(pgid, 9)
except (ProcessLookupError, OSError):
pass
app_module.ais_process = None
logger.info("Killed existing AIS process")
# Build command using SDR abstraction
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
bias_t = data.get('bias_t', False)
tcp_port = AIS_TCP_PORT
cmd = builder.build_ais_command(
device=sdr_device,
gain=float(gain),
bias_t=bias_t,
tcp_port=tcp_port
)
# Use the found AIS-catcher path
cmd[0] = ais_catcher_path
try:
logger.info(f"Starting AIS-catcher with device {device}: {' '.join(cmd)}")
app_module.ais_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
start_new_session=True
)
# Wait for process to start
time.sleep(2.0)
if app_module.ais_process.poll() is not None:
stderr_output = ''
if app_module.ais_process.stderr:
try:
stderr_output = app_module.ais_process.stderr.read().decode('utf-8', errors='ignore').strip()
except Exception:
pass
error_msg = 'AIS-catcher failed to start. Check SDR device connection.'
if stderr_output:
error_msg += f' Error: {stderr_output[:200]}'
return jsonify({'status': 'error', 'message': error_msg}), 500
ais_running = True
ais_active_device = device
# Start TCP parser thread
thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True)
thread.start()
return jsonify({
'status': 'started',
'message': 'AIS tracking started',
'device': device,
'port': tcp_port
})
except Exception as e:
logger.error(f"Failed to start AIS-catcher: {e}")
return jsonify({'status': 'error', 'message': str(e)}), 500
@ais_bp.route('/stop', methods=['POST'])
def stop_ais():
"""Stop AIS tracking."""
global ais_running, ais_active_device
with app_module.ais_lock:
if app_module.ais_process:
try:
pgid = os.getpgid(app_module.ais_process.pid)
os.killpg(pgid, 15)
app_module.ais_process.wait(timeout=AIS_TERMINATE_TIMEOUT)
except (subprocess.TimeoutExpired, ProcessLookupError, OSError):
try:
pgid = os.getpgid(app_module.ais_process.pid)
os.killpg(pgid, 9)
except (ProcessLookupError, OSError):
pass
app_module.ais_process = None
logger.info("AIS process stopped")
ais_running = False
ais_active_device = None
app_module.ais_vessels.clear()
return jsonify({'status': 'stopped'})
@ais_bp.route('/stream')
def stream_ais():
"""SSE stream for AIS vessels."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
while True:
try:
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@ais_bp.route('/dashboard')
def ais_dashboard():
"""Popout AIS dashboard."""
return render_template('ais_dashboard.html')

625
routes/spy_stations.py Normal file
View File

@@ -0,0 +1,625 @@
"""Spy Stations routes - Number stations and diplomatic HF networks."""
from flask import Blueprint, jsonify, request
spy_stations_bp = Blueprint('spy_stations', __name__, url_prefix='/spy-stations')
# Active spy stations data from priyom.org
STATIONS = [
# Number Stations (Intelligence)
{
"id": "e06",
"name": "E06",
"nickname": "English Man",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 4310, "primary": True},
{"freq_khz": 4800, "primary": False},
{"freq_khz": 5370, "primary": False},
],
"mode": "USB+carrier",
"description": "Russian intelligence number station operated by 'Russian 6'. Male voice reads 5-figure groups. Broadcasts from Moscow, Orenburg, Smolensk, and Chita.",
"operator": "Russian 6",
"schedule": "Weekdays, 2 transmissions 1 hour apart",
"source_url": "https://priyom.org/number-stations/english/e06"
},
{
"id": "s06",
"name": "S06",
"nickname": "Russian Man",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 4310, "primary": True},
{"freq_khz": 4800, "primary": False},
{"freq_khz": 5370, "primary": False},
],
"mode": "USB+carrier",
"description": "Russian language mode of the Russian 6 operator. Male voice reads 5-figure groups in Russian.",
"operator": "Russian 6",
"schedule": "Same schedule as E06, alternating languages",
"source_url": "https://priyom.org/number-stations/russian/s06"
},
{
"id": "uvb76",
"name": "UVB-76",
"nickname": "The Buzzer",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 4625, "primary": True},
{"freq_khz": 5779, "primary": False},
{"freq_khz": 6810, "primary": False},
{"freq_khz": 7490, "primary": False},
],
"mode": "USB",
"description": "Russian military command network. Continuous buzzing tone with occasional voice messages. Active since 1982. One of the most famous number stations.",
"operator": "Russian Military",
"schedule": "24/7 continuous operation",
"source_url": "https://priyom.org/number-stations/russia/uvb-76"
},
{
"id": "hm01",
"name": "HM01",
"nickname": "Cuban Numbers",
"type": "number",
"country": "Cuba",
"country_code": "CU",
"frequencies": [
{"freq_khz": 9065, "primary": True},
{"freq_khz": 9155, "primary": False},
{"freq_khz": 9240, "primary": False},
{"freq_khz": 9330, "primary": False},
{"freq_khz": 10345, "primary": False},
{"freq_khz": 10715, "primary": False},
{"freq_khz": 10860, "primary": False},
{"freq_khz": 11435, "primary": False},
{"freq_khz": 11462, "primary": False},
{"freq_khz": 11530, "primary": False},
{"freq_khz": 11635, "primary": False},
{"freq_khz": 12180, "primary": False},
{"freq_khz": 13435, "primary": False},
{"freq_khz": 14375, "primary": False},
{"freq_khz": 16180, "primary": False},
{"freq_khz": 17480, "primary": False},
],
"mode": "AM/OFDM",
"description": "Cuban DGI intelligence station. Spanish female voice 'Atencion' followed by number groups. Also uses RDFT OFDM digital mode.",
"operator": "DGI (Cuban Intelligence)",
"schedule": "Multiple daily transmissions",
"source_url": "https://priyom.org/number-stations/cuba/hm01"
},
{
"id": "e07",
"name": "E07",
"nickname": "7-dash",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 5292, "primary": True},
{"freq_khz": 6388, "primary": False},
{"freq_khz": 7482, "primary": False},
{"freq_khz": 8576, "primary": False},
],
"mode": "USB",
"description": "Russian intelligence station using distinctive 7-dash interval signal. Female voice reading 5-figure groups in English. Part of the 'Russian 7' operator network.",
"operator": "Russian 7",
"schedule": "Irregular, typically evenings UTC",
"source_url": "https://priyom.org/number-stations/english/e07"
},
{
"id": "e11",
"name": "E11",
"nickname": "Mazielka",
"type": "number",
"country": "Poland",
"country_code": "PL",
"frequencies": [
{"freq_khz": 4030, "primary": True},
{"freq_khz": 5240, "primary": False},
{"freq_khz": 6910, "primary": False},
],
"mode": "USB",
"description": "Polish intelligence number station. Female voice reads 5-figure groups in English. Named after distinctive melody interval signal.",
"operator": "ABW (Polish Intelligence)",
"schedule": "Weekly transmissions",
"source_url": "https://priyom.org/number-stations/english/e11"
},
{
"id": "e17z",
"name": "E17z",
"nickname": "Israeli Numbers",
"type": "number",
"country": "Israel",
"country_code": "IL",
"frequencies": [
{"freq_khz": 4779, "primary": True},
{"freq_khz": 5091, "primary": False},
{"freq_khz": 6446, "primary": False},
],
"mode": "USB",
"description": "Israeli intelligence number station. Female voice with distinctive Hebrew-accented English. Transmits 5-figure groups with phonetic alphabet.",
"operator": "Mossad (suspected)",
"schedule": "Irregular schedule",
"source_url": "https://priyom.org/number-stations/english/e17z"
},
{
"id": "g06",
"name": "G06",
"nickname": "Russian German",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 4310, "primary": True},
{"freq_khz": 4800, "primary": False},
{"freq_khz": 5370, "primary": False},
],
"mode": "USB+carrier",
"description": "German language mode of Russian 6 operator. Male synthesized voice reads 5-figure groups in German. Shares frequencies with E06/S06.",
"operator": "Russian 6",
"schedule": "Same schedule as E06",
"source_url": "https://priyom.org/number-stations/german/g06"
},
{
"id": "v02a",
"name": "V02a",
"nickname": "Cuban Spy Numbers",
"type": "number",
"country": "Cuba",
"country_code": "CU",
"frequencies": [
{"freq_khz": 5855, "primary": True},
{"freq_khz": 9330, "primary": False},
{"freq_khz": 11635, "primary": False},
],
"mode": "AM",
"description": "Cuban intelligence station using AM mode. Female Spanish voice reading 4-figure groups. Related to HM01 but separate schedule.",
"operator": "DGI (Cuban Intelligence)",
"schedule": "Evening transmissions, weekdays",
"source_url": "https://priyom.org/number-stations/spanish/v02a"
},
{
"id": "v07",
"name": "V07",
"nickname": "Russian 7 Voice",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 3756, "primary": True},
{"freq_khz": 4625, "primary": False},
],
"mode": "USB",
"description": "Russian voice number station. Female voice reads 5-figure groups in Russian. Part of Russian 7 operator network. Often shares 4625 kHz with UVB-76.",
"operator": "Russian 7",
"schedule": "Irregular transmissions",
"source_url": "https://priyom.org/number-stations/russian/v07"
},
{
"id": "s11a",
"name": "S11a",
"nickname": "Russian Phonetic",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 4560, "primary": True},
{"freq_khz": 5200, "primary": False},
],
"mode": "USB",
"description": "Russian phonetic alphabet number station. Male voice reads 5-letter groups using Russian phonetic alphabet (Anna, Boris, etc.).",
"operator": "GRU (suspected)",
"schedule": "Weekly scheduled transmissions",
"source_url": "https://priyom.org/number-stations/russian/s11a"
},
{
"id": "v13",
"name": "V13",
"nickname": "The Pip",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 3756, "primary": True},
{"freq_khz": 5448, "primary": False},
],
"mode": "USB",
"description": "Russian military channel marker known as 'The Pip'. Continuous short beep every 1 second with occasional voice messages. Sister station to UVB-76.",
"operator": "Russian Military",
"schedule": "24/7 continuous operation",
"source_url": "https://priyom.org/military-stations/russia/the-pip"
},
{
"id": "v24",
"name": "V24",
"nickname": "Air Horn",
"type": "number",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 3243, "primary": True},
],
"mode": "USB",
"description": "Russian channel marker known as 'Air Horn' due to distinctive foghorn-like sound. Continuous tone with occasional voice messages in Russian.",
"operator": "Russian Military",
"schedule": "24/7 continuous operation",
"source_url": "https://priyom.org/military-stations/russia/the-air-horn"
},
{
"id": "vc01",
"name": "VC01",
"nickname": "Chinese Robot",
"type": "number",
"country": "China",
"country_code": "CN",
"frequencies": [
{"freq_khz": 8300, "primary": True},
{"freq_khz": 9725, "primary": False},
{"freq_khz": 11430, "primary": False},
{"freq_khz": 13750, "primary": False},
],
"mode": "AM",
"description": "Chinese intelligence number station. Robotic female voice reading 4-figure groups in Chinese. Distinctive electronic music interval signal.",
"operator": "MSS (Chinese Intelligence)",
"schedule": "Daily transmissions",
"source_url": "https://priyom.org/number-stations/chinese/vc01"
},
{
"id": "v22",
"name": "V22",
"nickname": "Chinese Lady",
"type": "number",
"country": "China",
"country_code": "CN",
"frequencies": [
{"freq_khz": 7883, "primary": True},
{"freq_khz": 9170, "primary": False},
],
"mode": "AM",
"description": "Chinese number station using female voice. Reads 4-figure groups in Mandarin Chinese. Often reported in Southeast Asian target areas.",
"operator": "MSS (Chinese Intelligence)",
"schedule": "Evening transmissions UTC",
"source_url": "https://priyom.org/number-stations/chinese/v22"
},
# Diplomatic Stations
{
"id": "bulgaria_mfa",
"name": "Bulgaria MFA",
"nickname": "Sofia Diplomatic",
"type": "diplomatic",
"country": "Bulgaria",
"country_code": "BG",
"frequencies": [
{"freq_khz": 5145, "primary": True},
{"freq_khz": 6755, "primary": False},
{"freq_khz": 7670, "primary": False},
{"freq_khz": 9155, "primary": False},
{"freq_khz": 10175, "primary": False},
{"freq_khz": 11445, "primary": False},
{"freq_khz": 14725, "primary": False},
{"freq_khz": 18520, "primary": False},
],
"mode": "RFSM-8000/MIL-STD-188-110",
"description": "Bulgarian Ministry of Foreign Affairs diplomatic network. Sofia to 14 embassies worldwide. Uses RFSM-8000 modem with MIL-STD-188-110.",
"operator": "Bulgarian MFA",
"schedule": "Daily scheduled transmissions",
"source_url": "https://priyom.org/diplomatic/bulgaria"
},
{
"id": "czechia_mfa",
"name": "Czechia MFA",
"nickname": "Czech Diplomatic",
"type": "diplomatic",
"country": "Czechia",
"country_code": "CZ",
"frequencies": [
{"freq_khz": 6830, "primary": True},
{"freq_khz": 8130, "primary": False},
{"freq_khz": 10232, "primary": False},
{"freq_khz": 13890, "primary": False},
],
"mode": "PACTOR-III",
"description": "Czech diplomatic network using PACTOR-III. Callsigns OLZ52-OLZ88. MoD station OL1A also active.",
"operator": "Czech MFA / MoD",
"schedule": "Regular scheduled traffic",
"source_url": "https://priyom.org/diplomatic/czechia"
},
{
"id": "egypt_mfa",
"name": "Egypt MFA",
"nickname": "Egyptian Diplomatic",
"type": "diplomatic",
"country": "Egypt",
"country_code": "EG",
"frequencies": [
{"freq_khz": 7830, "primary": True},
{"freq_khz": 9048, "primary": False},
{"freq_khz": 10780, "primary": False},
{"freq_khz": 13950, "primary": False},
],
"mode": "SITOR/Codan 3012",
"description": "Egyptian diplomatic network. 5-digit station IDs (66601=Washington, 11107=London). Uses SITOR and Codan 3012 modems.",
"operator": "Egyptian MFA",
"schedule": "Daily traffic windows",
"source_url": "https://priyom.org/diplomatic/egypt"
},
{
"id": "dprk_mfa",
"name": "DPRK MFA",
"nickname": "North Korea Diplomatic",
"type": "diplomatic",
"country": "North Korea",
"country_code": "KP",
"frequencies": [
{"freq_khz": 7200, "primary": True},
{"freq_khz": 9450, "primary": False},
{"freq_khz": 11475, "primary": False},
{"freq_khz": 13785, "primary": False},
{"freq_khz": 15245, "primary": False},
{"freq_khz": 17550, "primary": False},
{"freq_khz": 21680, "primary": False},
{"freq_khz": 25120, "primary": False},
],
"mode": "DPRK-ARQ (LSB/BFSK 600Bd/MSK 1200Bd)",
"description": "North Korean diplomatic network spanning 7-25 MHz. Uses proprietary DPRK-ARQ protocol. Daily encrypted traffic to embassies.",
"operator": "DPRK MFA",
"schedule": "Daily, multiple time slots",
"source_url": "https://priyom.org/diplomatic/north-korea"
},
{
"id": "russia_mfa",
"name": "Russia MFA",
"nickname": "Russian Diplomatic",
"type": "diplomatic",
"country": "Russia",
"country_code": "RU",
"frequencies": [
{"freq_khz": 5154, "primary": True},
{"freq_khz": 7654, "primary": False},
{"freq_khz": 9045, "primary": False},
{"freq_khz": 10755, "primary": False},
{"freq_khz": 13455, "primary": False},
{"freq_khz": 16354, "primary": False},
{"freq_khz": 18954, "primary": False},
],
"mode": "Perelivt/Serdolik/X06/OFDM",
"description": "Extensive Russian diplomatic network using multiple proprietary modes including Perelivt, Serdolik, and OFDM variants.",
"operator": "Russian MFA",
"schedule": "24/7 network operations",
"source_url": "https://priyom.org/diplomatic/russia"
},
{
"id": "tunisia_mfa",
"name": "Tunisia MFA",
"nickname": "Tunisian Diplomatic",
"type": "diplomatic",
"country": "Tunisia",
"country_code": "TN",
"frequencies": [
{"freq_khz": 5810, "primary": True},
{"freq_khz": 7954, "primary": False},
{"freq_khz": 8014, "primary": False},
{"freq_khz": 8180, "primary": False},
{"freq_khz": 10113, "primary": False},
{"freq_khz": 10176, "primary": False},
{"freq_khz": 11111, "primary": False},
{"freq_khz": 12140, "primary": False},
{"freq_khz": 13945, "primary": False},
{"freq_khz": 14700, "primary": False},
{"freq_khz": 14724, "primary": False},
{"freq_khz": 15635, "primary": False},
{"freq_khz": 16125, "primary": False},
{"freq_khz": 16285, "primary": False},
{"freq_khz": 16290, "primary": False},
{"freq_khz": 18295, "primary": False},
{"freq_khz": 19675, "primary": False},
{"freq_khz": 23540, "primary": False},
{"freq_khz": 24080, "primary": False},
{"freq_khz": 24170, "primary": False},
{"freq_khz": 26890, "primary": False},
],
"mode": "2G ALE/PACTOR-II",
"description": "Tunisian MFA network. Callsigns STAT151-155. Uses 2G ALE for linking and PACTOR-II for traffic. MAPI email format.",
"operator": "Tunisian MFA",
"schedule": "Regular diplomatic traffic",
"source_url": "https://priyom.org/diplomatic/tunisia"
},
{
"id": "usa_state",
"name": "US State Dept",
"nickname": "American Diplomatic",
"type": "diplomatic",
"country": "United States",
"country_code": "US",
"frequencies": [
{"freq_khz": 5749, "primary": True},
{"freq_khz": 6903, "primary": False},
{"freq_khz": 8059, "primary": False},
{"freq_khz": 10734, "primary": False},
{"freq_khz": 11169, "primary": False},
{"freq_khz": 13504, "primary": False},
{"freq_khz": 16284, "primary": False},
{"freq_khz": 18249, "primary": False},
{"freq_khz": 20811, "primary": False},
{"freq_khz": 24884, "primary": False},
],
"mode": "2G ALE (MIL-STD-188-141A)",
"description": "US State Department diplomatic network. 140+ embassy callsigns (KWX57=Warsaw, KRH50=Tokyo, etc.). Uses 2G ALE linking.",
"operator": "US State Department",
"schedule": "24/7 global network",
"source_url": "https://priyom.org/diplomatic/united-states"
},
{
"id": "morocco_mfa",
"name": "Morocco MFA",
"nickname": "Moroccan Diplomatic",
"type": "diplomatic",
"country": "Morocco",
"country_code": "MA",
"frequencies": [
{"freq_khz": 8010, "primary": True},
{"freq_khz": 11205, "primary": False},
{"freq_khz": 14620, "primary": False},
],
"mode": "PACTOR-II/ALE",
"description": "Moroccan Ministry of Foreign Affairs diplomatic network. Links Rabat with embassies in Europe and Africa. Uses PACTOR-II and 2G ALE.",
"operator": "Moroccan MFA",
"schedule": "Daily scheduled traffic",
"source_url": "https://priyom.org/diplomatic/morocco"
},
{
"id": "poland_mfa",
"name": "Poland MFA",
"nickname": "Polish Diplomatic",
"type": "diplomatic",
"country": "Poland",
"country_code": "PL",
"frequencies": [
{"freq_khz": 6825, "primary": True},
{"freq_khz": 9250, "primary": False},
{"freq_khz": 13485, "primary": False},
],
"mode": "STANAG-4285/ALE",
"description": "Polish Ministry of Foreign Affairs HF network. Uses NATO STANAG-4285 modem with 2G ALE linking. Connects Warsaw with global embassies.",
"operator": "Polish MFA",
"schedule": "Regular diplomatic traffic",
"source_url": "https://priyom.org/diplomatic/poland"
},
{
"id": "france_mfa",
"name": "France MFA",
"nickname": "French Diplomatic",
"type": "diplomatic",
"country": "France",
"country_code": "FR",
"frequencies": [
{"freq_khz": 6910, "primary": True},
{"freq_khz": 10640, "primary": False},
{"freq_khz": 13870, "primary": False},
{"freq_khz": 16840, "primary": False},
],
"mode": "MIL-STD-188-110/ALE",
"description": "French Ministry of Foreign Affairs network. Extensive global coverage with Paris hub. Uses MIL-STD-188-110 with 2G/3G ALE linking protocols.",
"operator": "French MFA",
"schedule": "24/7 network operations",
"source_url": "https://priyom.org/diplomatic/france"
},
{
"id": "romania_mfa",
"name": "Romania MFA",
"nickname": "Romanian Diplomatic",
"type": "diplomatic",
"country": "Romania",
"country_code": "RO",
"frequencies": [
{"freq_khz": 5390, "primary": True},
{"freq_khz": 8158, "primary": False},
{"freq_khz": 11555, "primary": False},
],
"mode": "PACTOR-III/ALE",
"description": "Romanian diplomatic network linking Bucharest with embassies. Uses PACTOR-III for traffic and 2G ALE for channel establishment.",
"operator": "Romanian MFA",
"schedule": "Scheduled daily windows",
"source_url": "https://priyom.org/diplomatic/romania"
},
{
"id": "algeria_mfa",
"name": "Algeria MFA",
"nickname": "Algerian Diplomatic",
"type": "diplomatic",
"country": "Algeria",
"country_code": "DZ",
"frequencies": [
{"freq_khz": 7706, "primary": True},
{"freq_khz": 10235, "primary": False},
{"freq_khz": 14385, "primary": False},
],
"mode": "SITOR-B/PACTOR",
"description": "Algerian Ministry of Foreign Affairs network. Links Algiers with African and European embassies. Uses SITOR-B and PACTOR modes.",
"operator": "Algerian MFA",
"schedule": "Daily scheduled transmissions",
"source_url": "https://priyom.org/diplomatic/algeria"
},
{
"id": "egypt_mfa_m14a",
"name": "Egypt MFA M14a",
"nickname": "Egyptian Extended",
"type": "diplomatic",
"country": "Egypt",
"country_code": "EG",
"frequencies": [
{"freq_khz": 12175, "primary": True},
{"freq_khz": 16360, "primary": False},
],
"mode": "Codan 3012/SITOR",
"description": "Extended Egyptian diplomatic network frequencies. Higher frequency allocations for long-distance embassy communications to Asia and Americas.",
"operator": "Egyptian MFA",
"schedule": "Daily traffic windows",
"source_url": "https://priyom.org/diplomatic/egypt"
},
]
@spy_stations_bp.route('/stations')
def get_stations():
"""Return all spy stations, optionally filtered."""
station_type = request.args.get('type')
country = request.args.get('country')
mode = request.args.get('mode')
filtered = STATIONS
if station_type:
filtered = [s for s in filtered if s['type'] == station_type]
if country:
filtered = [s for s in filtered if s['country_code'].upper() == country.upper()]
if mode:
mode_lower = mode.lower()
filtered = [s for s in filtered if mode_lower in s['mode'].lower()]
return jsonify({
'status': 'success',
'count': len(filtered),
'stations': filtered
})
@spy_stations_bp.route('/stations/<station_id>')
def get_station(station_id):
"""Get a single station by ID."""
for station in STATIONS:
if station['id'] == station_id:
return jsonify({
'status': 'success',
'station': station
})
return jsonify({
'status': 'error',
'message': 'Station not found'
}), 404
@spy_stations_bp.route('/filters')
def get_filters():
"""Return available filter options."""
types = list(set(s['type'] for s in STATIONS))
countries = sorted(list(set((s['country'], s['country_code']) for s in STATIONS)))
modes = sorted(list(set(s['mode'].split('/')[0] for s in STATIONS)))
return jsonify({
'status': 'success',
'filters': {
'types': types,
'countries': [{'name': c[0], 'code': c[1]} for c in countries],
'modes': modes
}
})

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

View File

@@ -0,0 +1,901 @@
/* AIS Dashboard - Vessel Tracking Interface */
/* Styled to match ADSB Dashboard */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-dark: #0a0c10;
--bg-panel: #0f1218;
--bg-card: #151a23;
--border-color: #1f2937;
--border-glow: #4a9eff;
--text-primary: #e8eaed;
--text-secondary: #9ca3af;
--text-dim: #4b5563;
--accent-green: #22c55e;
--accent-cyan: #4a9eff;
--accent-orange: #f59e0b;
--accent-red: #ef4444;
--accent-yellow: #eab308;
--accent-amber: #d4a853;
--grid-line: rgba(74, 158, 255, 0.08);
--radar-cyan: #4a9eff;
--radar-bg: #0f1218;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
/* Animated radar sweep background */
.radar-bg {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 50px 50px;
pointer-events: none;
z-index: 0;
}
/* Scan line effect - subtle */
.scanline {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
animation: scan 6s linear infinite;
pointer-events: none;
z-index: 1000;
opacity: 0.3;
}
@keyframes scan {
0% {
top: -4px;
}
100% {
top: 100vh;
}
}
/* Header - Mobile first */
.header {
position: relative;
z-index: 10;
padding: 10px 12px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-height: 52px;
}
@media (min-width: 768px) {
.header {
padding: 12px 20px;
flex-wrap: nowrap;
}
}
.logo {
font-family: 'Inter', sans-serif;
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
color: var(--text-primary);
text-transform: uppercase;
}
@media (min-width: 768px) {
.logo {
font-size: 20px;
letter-spacing: 3px;
}
}
.logo span {
display: none;
color: var(--text-secondary);
font-weight: 400;
font-size: 12px;
margin-left: 10px;
letter-spacing: 1px;
}
@media (min-width: 768px) {
.logo span {
display: inline;
font-size: 14px;
margin-left: 15px;
letter-spacing: 2px;
}
}
.status-bar {
display: flex;
gap: 20px;
align-items: center;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
.back-link {
color: var(--accent-cyan);
text-decoration: none;
font-size: 11px;
padding: 4px 10px;
border: 1px solid var(--accent-cyan);
border-radius: 4px;
}
/* ============================================
STATISTICS STRIP
============================================ */
.stats-strip {
background: linear-gradient(180deg, var(--bg-panel) 0%, var(--bg-dark) 100%);
border-bottom: 1px solid var(--border-color);
padding: 6px 12px;
position: relative;
z-index: 9;
overflow-x: auto;
}
.stats-strip-inner {
display: flex;
align-items: center;
gap: 4px;
min-width: max-content;
}
.strip-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 10px;
background: rgba(74, 158, 255, 0.05);
border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
min-width: 55px;
}
.strip-stat:hover {
background: rgba(74, 158, 255, 0.1);
border-color: rgba(74, 158, 255, 0.3);
}
.strip-value {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--accent-cyan);
line-height: 1.2;
}
.strip-label {
font-size: 8px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 1px;
}
.strip-stat.session-stat {
background: rgba(34, 197, 94, 0.05);
border-color: rgba(34, 197, 94, 0.2);
}
.strip-stat.session-stat .strip-value {
color: var(--accent-green);
}
/* Signal quality states */
.strip-stat.signal-stat .strip-value {
letter-spacing: 2px;
}
.strip-stat.signal-stat.good {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
}
.strip-stat.signal-stat.good .strip-value {
color: var(--accent-green);
}
.strip-stat.signal-stat.warning {
background: rgba(245, 158, 11, 0.1);
border-color: rgba(245, 158, 11, 0.3);
}
.strip-stat.signal-stat.warning .strip-value {
color: var(--accent-orange);
}
.strip-stat.signal-stat.poor {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
}
.strip-stat.signal-stat.poor .strip-value {
color: var(--accent-red);
}
.strip-divider {
width: 1px;
height: 24px;
background: rgba(74, 158, 255, 0.2);
margin: 0 4px;
}
.strip-status {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
}
.strip-status .status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent-red);
transition: all 0.3s ease;
}
.strip-status .status-dot.active {
background: var(--accent-green);
box-shadow: 0 0 10px var(--accent-green);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.strip-time {
font-size: 11px;
font-weight: 500;
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
padding-left: 8px;
border-left: 1px solid rgba(74, 158, 255, 0.2);
white-space: nowrap;
}
.strip-btn {
position: relative;
z-index: 10;
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
color: var(--text-primary);
padding: 6px 10px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.strip-btn:hover:not(:disabled) {
background: rgba(74, 158, 255, 0.2);
border-color: rgba(74, 158, 255, 0.4);
}
.strip-btn.primary {
background: linear-gradient(135deg, var(--accent-cyan) 0%, #2563eb 100%);
border: none;
color: white;
}
/* Main dashboard grid - Mobile first */
.dashboard {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
gap: 0;
height: calc(100dvh - 95px);
height: calc(100vh - 95px);
min-height: 400px;
}
/* Tablet: Two-column layout */
@media (min-width: 768px) {
.dashboard {
display: grid;
grid-template-columns: 1fr 300px;
grid-template-rows: 1fr auto;
min-height: 500px;
}
}
/* Main display container (map) */
.main-display {
position: relative;
flex: 1;
min-height: 300px;
}
@media (min-width: 768px) {
.main-display {
grid-column: 1;
grid-row: 1;
min-height: 400px;
}
}
#vesselMap {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* Leaflet overrides - Dark map styling */
.leaflet-container {
background: var(--bg-dark) !important;
font-family: 'JetBrains Mono', monospace;
}
.leaflet-tile-pane,
.leaflet-container .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
}
.leaflet-control-zoom a {
background: var(--bg-panel) !important;
color: var(--accent-cyan) !important;
border-color: var(--border-color) !important;
}
.leaflet-control-attribution {
background: rgba(0, 0, 0, 0.7) !important;
color: var(--text-secondary) !important;
font-size: 9px !important;
}
.leaflet-popup-content-wrapper {
background: var(--bg-panel);
color: var(--text-primary);
border-radius: 4px;
border: 1px solid rgba(74, 158, 255, 0.2);
}
.leaflet-popup-tip {
background: var(--bg-panel);
}
/* Right sidebar - Mobile first */
.sidebar {
display: flex;
flex-direction: column;
border-left: none;
border-top: 1px solid rgba(74, 158, 255, 0.2);
overflow: hidden;
max-height: 40vh;
background: var(--bg-panel);
}
@media (min-width: 768px) {
.sidebar {
grid-column: 2;
grid-row: 1;
border-left: 1px solid rgba(74, 158, 255, 0.2);
border-top: none;
max-height: none;
}
}
/* Panels */
.panel {
background: var(--bg-panel);
border: 1px solid rgba(74, 158, 255, 0.2);
overflow: hidden;
position: relative;
}
.panel::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
}
.panel-header {
padding: 10px 15px;
background: rgba(74, 158, 255, 0.05);
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent-cyan);
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-indicator {
width: 6px;
height: 6px;
background: var(--text-dim);
border-radius: 50%;
opacity: 0.5;
}
.panel-indicator.active {
background: var(--accent-green);
opacity: 1;
animation: blink 1s ease-in-out infinite;
}
@keyframes blink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
/* Selected vessel panel */
.selected-vessel {
flex-shrink: 0;
max-height: 480px;
overflow-y: auto;
border-bottom: 1px solid rgba(74, 158, 255, 0.2);
}
.selected-info {
padding: 12px;
}
.no-vessel {
text-align: center;
padding: 30px 15px;
color: var(--text-secondary);
}
.no-vessel-icon {
font-size: 36px;
margin-bottom: 10px;
opacity: 0.5;
}
.vessel-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 15px;
}
.vessel-icon {
font-size: 32px;
}
.vessel-name {
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-size: 16px;
font-weight: 700;
color: var(--accent-cyan);
text-shadow: 0 0 15px var(--accent-cyan);
}
.vessel-mmsi {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-secondary);
background: rgba(74, 158, 255, 0.1);
padding: 2px 5px;
border-radius: 3px;
}
.vessel-details {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.detail-item {
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
padding: 8px;
border-left: 2px solid var(--accent-cyan);
}
.detail-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
margin-bottom: 2px;
}
.detail-value {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--accent-cyan);
}
/* Vessel list */
.vessel-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.vessel-list-content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.vessel-item {
position: relative;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 10px;
}
.vessel-item:hover {
border-color: var(--accent-cyan);
background: rgba(74, 158, 255, 0.05);
}
.vessel-item.selected {
border-color: var(--accent-cyan);
box-shadow: 0 0 15px rgba(74, 158, 255, 0.2);
background: rgba(74, 158, 255, 0.1);
}
.vessel-item-icon {
font-size: 20px;
}
.vessel-item-info {
flex: 1;
}
.vessel-item-name {
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
color: var(--accent-cyan);
}
.vessel-item-type {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--text-secondary);
}
.vessel-item-speed {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
text-align: right;
}
/* Bottom controls bar */
.controls-bar {
grid-column: 1 / -1;
grid-row: 2;
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
padding: 8px 15px;
background: var(--bg-panel);
border-top: 1px solid rgba(74, 158, 255, 0.3);
font-size: 11px;
overflow-x: auto;
}
.control-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 6px 10px;
background: rgba(74, 158, 255, 0.03);
border: 1px solid rgba(74, 158, 255, 0.1);
border-radius: 6px;
}
.control-group-label {
font-size: 8px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--accent-cyan);
opacity: 0.7;
}
.control-group-items {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.control-group label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
font-size: 10px;
color: var(--text-primary);
white-space: nowrap;
}
.control-group input[type="checkbox"] {
accent-color: var(--accent-cyan);
width: 12px;
height: 12px;
}
.control-group select {
padding: 4px 8px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
}
.control-group input[type="text"],
.control-group input[type="number"] {
padding: 4px 6px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
}
.control-group.tracking-group {
background: rgba(34, 197, 94, 0.05);
border-color: rgba(34, 197, 94, 0.2);
}
.control-group.tracking-group .control-group-label {
color: var(--accent-green);
}
/* Start/stop button */
.start-btn {
padding: 6px 16px;
border: none;
background: var(--accent-green);
color: #fff;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.start-btn:hover {
background: #1db954;
box-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
}
.start-btn.active {
background: var(--accent-red);
color: #fff;
}
.start-btn.active:hover {
background: #dc2626;
box-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
}
/* Vessel markers */
.vessel-marker {
background: transparent;
border: none;
}
.vessel-marker-inner {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
filter: drop-shadow(0 0 2px rgba(0,0,0,0.8));
transition: transform 0.3s ease;
}
.vessel-marker.selected .vessel-marker-inner {
filter: drop-shadow(0 0 6px var(--accent-cyan));
}
/* Range rings */
.range-ring {
fill: none;
stroke: var(--accent-cyan);
stroke-opacity: 0.3;
stroke-width: 1;
stroke-dasharray: 4 4;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-dark);
}
::-webkit-scrollbar-thumb {
background: var(--accent-cyan);
border-radius: 3px;
}
/* ============== MOBILE/TABLET FIXES ============== */
@media (max-width: 767px) {
.dashboard {
display: flex !important;
flex-direction: column !important;
height: auto !important;
min-height: calc(100dvh - 95px);
overflow-y: auto !important;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.main-display {
flex: none !important;
height: 50vh;
min-height: 300px;
width: 100%;
}
.sidebar {
max-height: none !important;
overflow: visible !important;
flex-shrink: 0;
width: 100%;
}
.panel {
overflow: visible !important;
}
.selected-vessel {
max-height: none !important;
overflow: visible !important;
}
.selected-info {
overflow: visible !important;
}
.vessel-list-content {
max-height: 250px;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
}
.controls-bar {
flex-wrap: wrap;
gap: 8px;
padding: 10px;
width: 100%;
}
.back-link {
white-space: nowrap;
font-size: 10px;
padding: 6px 8px;
}
.strip-time {
font-size: 10px;
}
.control-group {
flex-wrap: wrap;
}
.start-btn {
width: 100%;
margin-left: 0;
margin-top: 8px;
}
.vessel-details {
grid-template-columns: 1fr;
}
}
/* Mobile responsiveness for stats strip */
@media (max-width: 768px) {
.stats-strip {
padding: 4px 8px;
}
.strip-stat {
padding: 3px 6px;
min-width: 45px;
}
.strip-value {
font-size: 12px;
}
.strip-label {
font-size: 7px;
}
.strip-btn {
padding: 6px 10px;
font-size: 9px;
}
}
/* Leaflet touch fixes for mobile */
.leaflet-container {
touch-action: pan-x pan-y;
-webkit-tap-highlight-color: transparent;
}
.leaflet-control-zoom a {
min-width: 44px !important;
min-height: 44px !important;
line-height: 44px !important;
font-size: 18px !important;
}

View File

@@ -0,0 +1,466 @@
/**
* Spy Stations Mode Styles
* Number stations and diplomatic HF networks
*/
/* ============================================
MAIN LAYOUT
============================================ */
.spy-stations-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
min-height: 0;
flex: 1;
overflow-y: auto;
}
.spy-stations-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.spy-stations-title {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 10px;
}
.spy-stations-title svg {
color: var(--accent-cyan);
}
.spy-stations-count {
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-primary);
padding: 4px 10px;
border-radius: 12px;
}
/* ============================================
STATION GRID
============================================ */
.spy-stations-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
padding: 4px;
padding-bottom: 20px;
}
/* ============================================
STATION CARD
============================================ */
.spy-station-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
}
.spy-station-card:hover {
border-color: var(--border-light);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Card Header */
.spy-station-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--border-color);
}
.spy-station-title {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.spy-station-flag {
font-size: 18px;
line-height: 1;
}
.spy-station-name {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.spy-station-nickname {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Type Badge */
.spy-station-badge {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 8px;
border-radius: 3px;
flex-shrink: 0;
}
.spy-badge-number {
background: rgba(74, 158, 255, 0.15);
color: var(--accent-cyan);
border: 1px solid rgba(74, 158, 255, 0.3);
}
.spy-badge-diplomatic {
background: rgba(34, 197, 94, 0.15);
color: var(--accent-green);
border: 1px solid rgba(34, 197, 94, 0.3);
}
/* Card Body */
.spy-station-body {
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
.spy-station-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.spy-station-meta-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.spy-meta-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
}
.spy-meta-value {
font-size: 12px;
color: var(--text-primary);
}
.spy-meta-mode {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--accent-orange);
}
/* Frequencies */
.spy-station-freqs {
display: flex;
flex-direction: column;
gap: 4px;
}
.spy-freq-list {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
line-height: 1.6;
}
.spy-freq-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.spy-freq-item {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-cyan);
background: var(--bg-secondary);
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border-color);
}
/* Description */
.spy-station-desc {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.5;
}
/* Card Footer */
.spy-station-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.1);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
margin-top: auto;
}
/* Frequency Selector Group */
.spy-tune-group {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.spy-freq-select {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
padding: 6px 8px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
min-width: 120px;
cursor: pointer;
}
.spy-freq-select:hover {
border-color: var(--border-light);
}
.spy-freq-select:focus {
outline: none;
border-color: var(--accent-cyan);
}
/* Clickable frequency items in details modal */
.spy-freq-clickable {
cursor: pointer;
transition: all 0.15s ease;
}
.spy-freq-clickable:hover {
background: var(--accent-cyan);
color: #000;
border-color: var(--accent-cyan);
}
/* Tune Button */
.spy-tune-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #000;
background: var(--accent-green);
border: none;
padding: 8px 14px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.spy-tune-btn:hover {
background: var(--accent-cyan);
transform: scale(1.02);
}
.spy-tune-btn svg {
stroke-width: 2.5;
}
/* Details Button */
.spy-details-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
background: transparent;
border: 1px solid var(--border-color);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.spy-details-btn:hover {
color: var(--text-primary);
border-color: var(--border-light);
background: var(--bg-secondary);
}
/* ============================================
EMPTY STATE
============================================ */
.spy-station-empty {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
color: var(--text-dim);
}
.spy-station-empty p {
font-size: 13px;
margin-top: 8px;
}
/* ============================================
MODE VISIBILITY - Ensure sidebar shows when active
============================================ */
#spystationsMode.active {
display: block !important;
}
/* ============================================
FILTER CHECKBOX STYLING
============================================ */
#spystationsMode .inline-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
padding: 4px 0;
}
#spystationsMode .inline-checkbox input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: var(--accent-cyan);
}
#spystationsMode .inline-checkbox:hover {
color: var(--text-primary);
}
/* ============================================
RESPONSIVE
============================================ */
/* Large desktop (1200px+) */
@media (min-width: 1200px) {
.spy-stations-grid {
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
}
}
/* Desktop/Tablet landscape (1024px) */
@media (max-width: 1024px) {
.spy-stations-grid {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
}
/* Tablet portrait (768px) */
@media (max-width: 768px) {
.spy-stations-grid {
grid-template-columns: 1fr;
}
.spy-station-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.spy-station-badge {
align-self: flex-start;
}
.spy-station-meta {
grid-template-columns: 1fr;
}
}
/* Small tablet / large phone (640px) */
@media (max-width: 640px) {
.spy-station-footer {
flex-direction: column;
gap: 8px;
}
.spy-tune-btn,
.spy-details-btn {
width: 100%;
justify-content: center;
min-height: 44px;
}
.spy-tune-group {
width: 100%;
flex-direction: column;
}
.spy-freq-select {
width: 100%;
min-height: 44px;
}
}
/* Mobile (480px) */
@media (max-width: 480px) {
.spy-stations-container {
padding: 8px;
}
.spy-station-body {
padding: 10px;
}
.spy-stations-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
}
.spy-station-desc {
-webkit-line-clamp: 2;
}
}
/* Touch device compliance */
@media (pointer: coarse) {
.spy-tune-btn,
.spy-details-btn,
.spy-freq-select {
min-height: 44px;
}
.spy-freq-clickable {
padding: 8px 12px;
}
}

View File

@@ -1580,6 +1580,40 @@ function initListeningPost() {
e.preventDefault();
tuneFreq(delta);
});
// Check if we arrived from Spy Stations with a tune request
checkIncomingTuneRequest();
}
/**
* Check for incoming tune request from Spy Stations or other pages
*/
function checkIncomingTuneRequest() {
const tuneFreq = sessionStorage.getItem('tuneFrequency');
const tuneMode = sessionStorage.getItem('tuneMode');
if (tuneFreq) {
// Clear the session storage first
sessionStorage.removeItem('tuneFrequency');
sessionStorage.removeItem('tuneMode');
// Parse and validate frequency
const freq = parseFloat(tuneFreq);
if (!isNaN(freq) && freq >= 0.01 && freq <= 2000) {
console.log('[LISTEN] Incoming tune request:', freq, 'MHz, mode:', tuneMode || 'default');
// Determine modulation (default to USB for HF/number stations)
const mod = tuneMode || (freq < 30 ? 'usb' : 'am');
// Use quickTune to set frequency and modulation
quickTune(freq, mod);
// Show notification
if (typeof showNotification === 'function') {
showNotification('Tuned to ' + freq.toFixed(3) + ' MHz', mod.toUpperCase() + ' mode');
}
}
}
}
// Initialize when DOM is ready
@@ -2265,6 +2299,7 @@ window.skipSignal = skipSignal;
window.setBand = setBand;
window.tuneFreq = tuneFreq;
window.quickTune = quickTune;
window.checkIncomingTuneRequest = checkIncomingTuneRequest;
window.addFrequencyBookmark = addFrequencyBookmark;
window.removeBookmark = removeBookmark;
window.tuneToFrequency = tuneToFrequency;

View File

@@ -0,0 +1,530 @@
/**
* Spy Stations Mode
* Number stations and diplomatic HF radio networks
*/
const SpyStations = (function() {
// State
let stations = [];
let filteredStations = [];
let activeFilters = {
types: ['number', 'diplomatic'],
countries: [],
modes: []
};
// Country flag emoji map
const countryFlags = {
'RU': '\u{1F1F7}\u{1F1FA}',
'CU': '\u{1F1E8}\u{1F1FA}',
'BG': '\u{1F1E7}\u{1F1EC}',
'CZ': '\u{1F1E8}\u{1F1FF}',
'EG': '\u{1F1EA}\u{1F1EC}',
'KP': '\u{1F1F0}\u{1F1F5}',
'TN': '\u{1F1F9}\u{1F1F3}',
'US': '\u{1F1FA}\u{1F1F8}',
'PL': '\u{1F1F5}\u{1F1F1}',
'IL': '\u{1F1EE}\u{1F1F1}',
'CN': '\u{1F1E8}\u{1F1F3}',
'MA': '\u{1F1F2}\u{1F1E6}',
'FR': '\u{1F1EB}\u{1F1F7}',
'RO': '\u{1F1F7}\u{1F1F4}',
'DZ': '\u{1F1E9}\u{1F1FF}'
};
/**
* Initialize the spy stations mode
*/
function init() {
fetchStations();
checkTuneFrequency();
}
/**
* Fetch stations from the API
*/
async function fetchStations() {
try {
const response = await fetch('/spy-stations/stations');
const data = await response.json();
if (data.status === 'success') {
stations = data.stations;
initFilters();
applyFilters();
updateStats();
}
} catch (err) {
console.error('Failed to fetch spy stations:', err);
}
}
/**
* Initialize filter checkboxes
*/
function initFilters() {
// Get unique countries and modes
const countries = [...new Set(stations.map(s => JSON.stringify({name: s.country, code: s.country_code})))].map(s => JSON.parse(s));
const modes = [...new Set(stations.map(s => s.mode.split('/')[0]))].sort();
// Populate country filters
const countryContainer = document.getElementById('countryFilters');
if (countryContainer) {
countryContainer.innerHTML = countries.map(c => `
<label class="inline-checkbox">
<input type="checkbox" data-country="${c.code}" checked onchange="SpyStations.applyFilters()">
<span>${countryFlags[c.code] || ''} ${c.name}</span>
</label>
`).join('');
}
// Populate mode filters
const modeContainer = document.getElementById('modeFilters');
if (modeContainer) {
modeContainer.innerHTML = modes.map(m => `
<label class="inline-checkbox">
<input type="checkbox" data-mode="${m}" checked onchange="SpyStations.applyFilters()">
<span style="font-family: 'JetBrains Mono', monospace; font-size: 10px;">${m}</span>
</label>
`).join('');
}
// Set initial filter states
activeFilters.countries = countries.map(c => c.code);
activeFilters.modes = modes;
}
/**
* Apply filters and render stations
*/
function applyFilters() {
// Read type filters
const typeNumber = document.getElementById('filterTypeNumber');
const typeDiplomatic = document.getElementById('filterTypeDiplomatic');
activeFilters.types = [];
if (typeNumber && typeNumber.checked) activeFilters.types.push('number');
if (typeDiplomatic && typeDiplomatic.checked) activeFilters.types.push('diplomatic');
// Read country filters
activeFilters.countries = [];
document.querySelectorAll('#countryFilters input[data-country]:checked').forEach(cb => {
activeFilters.countries.push(cb.dataset.country);
});
// Read mode filters
activeFilters.modes = [];
document.querySelectorAll('#modeFilters input[data-mode]:checked').forEach(cb => {
activeFilters.modes.push(cb.dataset.mode);
});
// Apply filters
filteredStations = stations.filter(s => {
if (!activeFilters.types.includes(s.type)) return false;
if (!activeFilters.countries.includes(s.country_code)) return false;
const stationMode = s.mode.split('/')[0];
if (!activeFilters.modes.includes(stationMode)) return false;
return true;
});
renderStations();
updateStats(true);
}
/**
* Render station cards
*/
function renderStations() {
const container = document.getElementById('spyStationsGrid');
if (!container) return;
if (filteredStations.length === 0) {
container.innerHTML = `
<div class="spy-station-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 48px; height: 48px; opacity: 0.3; margin-bottom: 12px;">
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
<circle cx="12" cy="12" r="2"/>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/>
</svg>
<p>No stations match your filters</p>
</div>
`;
return;
}
container.innerHTML = filteredStations.map(station => renderStationCard(station)).join('');
}
/**
* Render a single station card
*/
function renderStationCard(station) {
const flag = countryFlags[station.country_code] || '';
const typeBadgeClass = station.type === 'number' ? 'spy-badge-number' : 'spy-badge-diplomatic';
const typeBadgeText = station.type === 'number' ? 'NUMBER' : 'DIPLOMATIC';
const primaryFreq = station.frequencies.find(f => f.primary) || station.frequencies[0];
const freqList = station.frequencies.slice(0, 4).map(f => formatFrequency(f.freq_khz)).join(', ');
const moreFreqs = station.frequencies.length > 4 ? ` +${station.frequencies.length - 4} more` : '';
// Build tune button with frequency selector if multiple frequencies
let tuneSection;
if (station.frequencies.length > 1) {
const options = station.frequencies.map(f => {
const label = formatFrequency(f.freq_khz) + (f.primary ? ' (primary)' : '');
return `<option value="${f.freq_khz}">${label}</option>`;
}).join('');
tuneSection = `
<div class="spy-tune-group">
<select class="spy-freq-select" id="freq-select-${station.id}">
${options}
</select>
<button class="spy-tune-btn" onclick="SpyStations.tuneToSelectedFreq('${station.id}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;">
<path d="M3 18v-6a9 9 0 0 1 18 0v6"/>
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/>
</svg>
Tune In
</button>
</div>
`;
} else {
tuneSection = `
<button class="spy-tune-btn" onclick="SpyStations.tuneToStation('${station.id}', ${primaryFreq.freq_khz})">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;">
<path d="M3 18v-6a9 9 0 0 1 18 0v6"/>
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/>
</svg>
Tune In
</button>
`;
}
return `
<div class="spy-station-card" data-station-id="${station.id}">
<div class="spy-station-header">
<div class="spy-station-title">
<span class="spy-station-flag">${flag}</span>
<span class="spy-station-name">${station.name}</span>
${station.nickname ? `<span class="spy-station-nickname">- ${station.nickname}</span>` : ''}
</div>
<span class="spy-station-badge ${typeBadgeClass}">${typeBadgeText}</span>
</div>
<div class="spy-station-body">
<div class="spy-station-meta">
<div class="spy-station-meta-item">
<span class="spy-meta-label">Origin</span>
<span class="spy-meta-value">${station.country}</span>
</div>
<div class="spy-station-meta-item">
<span class="spy-meta-label">Mode</span>
<span class="spy-meta-value spy-meta-mode">${station.mode}</span>
</div>
</div>
<div class="spy-station-freqs">
<span class="spy-meta-label">Frequencies</span>
<span class="spy-freq-list">${freqList}${moreFreqs}</span>
</div>
<div class="spy-station-desc">${station.description}</div>
</div>
<div class="spy-station-footer">
${tuneSection}
<button class="spy-details-btn" onclick="SpyStations.showDetails('${station.id}')">
Details
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 12px; height: 12px;">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
</div>
`;
}
/**
* Format frequency for display
*/
function formatFrequency(freqKhz) {
if (freqKhz >= 1000) {
return (freqKhz / 1000).toFixed(3) + ' MHz';
}
return freqKhz + ' kHz';
}
/**
* Get appropriate SDR mode from station mode string
*/
function getModeFromStation(stationMode) {
const mode = stationMode.toLowerCase();
if (mode.includes('am') || mode.includes('ofdm')) return 'am';
if (mode.includes('lsb')) return 'lsb';
if (mode.includes('fm')) return 'fm';
// Default to USB for most number stations and digital modes
return 'usb';
}
/**
* Tune to a station frequency
*/
function tuneToStation(stationId, freqKhz) {
const freqMhz = freqKhz / 1000;
sessionStorage.setItem('tuneFrequency', freqMhz.toString());
// Find the station and determine mode
const station = stations.find(s => s.id === stationId);
const tuneMode = station ? getModeFromStation(station.mode) : 'usb';
sessionStorage.setItem('tuneMode', tuneMode);
const stationName = station ? station.name : 'Station';
if (typeof showNotification === 'function') {
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
}
// Switch to listening post mode
if (typeof selectMode === 'function') {
selectMode('listening');
} else if (typeof switchMode === 'function') {
switchMode('listening');
}
}
/**
* Tune to selected frequency from dropdown
*/
function tuneToSelectedFreq(stationId) {
const select = document.getElementById('freq-select-' + stationId);
if (select) {
const freqKhz = parseInt(select.value, 10);
tuneToStation(stationId, freqKhz);
}
}
/**
* Check if we arrived from another page with a tune request
*/
function checkTuneFrequency() {
// This is for the listening post to check - spy stations sets, listening post reads
}
/**
* Show station details modal
*/
function showDetails(stationId) {
const station = stations.find(s => s.id === stationId);
if (!station) return;
let modal = document.getElementById('spyStationDetailsModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'spyStationDetailsModal';
modal.className = 'signal-details-modal';
document.body.appendChild(modal);
}
const flag = countryFlags[station.country_code] || '';
const allFreqs = station.frequencies.map(f => {
const label = f.primary ? ' (primary)' : '';
return `<span class="spy-freq-item spy-freq-clickable" onclick="SpyStations.tuneToStation('${station.id}', ${f.freq_khz}); SpyStations.closeDetails();">${formatFrequency(f.freq_khz)}${label}</span>`;
}).join('');
modal.innerHTML = `
<div class="signal-details-modal-backdrop" onclick="SpyStations.closeDetails()"></div>
<div class="signal-details-modal-content">
<div class="signal-details-modal-header">
<h3>${flag} ${station.name} ${station.nickname ? '- ' + station.nickname : ''}</h3>
<button class="signal-details-modal-close" onclick="SpyStations.closeDetails()">&times;</button>
</div>
<div class="signal-details-modal-body">
<div class="signal-details-section">
<div class="signal-details-title">Overview</div>
<div class="signal-details-grid">
<div class="signal-details-item">
<span class="signal-details-label">Type</span>
<span class="signal-details-value">${station.type === 'number' ? 'Number Station' : 'Diplomatic Network'}</span>
</div>
<div class="signal-details-item">
<span class="signal-details-label">Country</span>
<span class="signal-details-value">${station.country}</span>
</div>
<div class="signal-details-item">
<span class="signal-details-label">Mode</span>
<span class="signal-details-value">${station.mode}</span>
</div>
<div class="signal-details-item">
<span class="signal-details-label">Operator</span>
<span class="signal-details-value">${station.operator || 'Unknown'}</span>
</div>
</div>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Description</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">${station.description}</p>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Frequencies (${station.frequencies.length})</div>
<div class="spy-freq-grid">${allFreqs}</div>
</div>
${station.schedule ? `
<div class="signal-details-section">
<div class="signal-details-title">Schedule</div>
<p style="color: var(--text-secondary); font-size: 12px;">${station.schedule}</p>
</div>
` : ''}
${station.source_url ? `
<div class="signal-details-section">
<div class="signal-details-title">Source</div>
<a href="${station.source_url}" target="_blank" rel="noopener" style="color: var(--accent-cyan); font-size: 12px;">${station.source_url}</a>
</div>
` : ''}
</div>
<div class="signal-details-modal-footer">
<button class="spy-tune-btn" onclick="SpyStations.tuneToStation('${station.id}', ${station.frequencies[0].freq_khz}); SpyStations.closeDetails();">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px;">
<path d="M3 18v-6a9 9 0 0 1 18 0v6"/>
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/>
</svg>
Tune In
</button>
</div>
</div>
`;
modal.classList.add('show');
}
/**
* Close details modal
*/
function closeDetails() {
const modal = document.getElementById('spyStationDetailsModal');
if (modal) {
modal.classList.remove('show');
}
}
/**
* Show help modal
*/
function showHelp() {
let modal = document.getElementById('spyStationsHelpModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'spyStationsHelpModal';
modal.className = 'signal-details-modal';
document.body.appendChild(modal);
}
modal.innerHTML = `
<div class="signal-details-modal-backdrop" onclick="SpyStations.closeHelp()"></div>
<div class="signal-details-modal-content">
<div class="signal-details-modal-header">
<h3>About Spy Stations</h3>
<button class="signal-details-modal-close" onclick="SpyStations.closeHelp()">&times;</button>
</div>
<div class="signal-details-modal-body">
<div class="signal-details-section">
<div class="signal-details-title">Number Stations</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
Number stations are shortwave radio transmissions believed to be used by intelligence agencies
to communicate with spies in the field. They typically broadcast strings of numbers, letters,
or words read by synthesized or live voices. These one-way broadcasts are encrypted using
one-time pads, making them virtually unbreakable.
</p>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Diplomatic Networks</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
Foreign ministries maintain HF radio networks to communicate with embassies worldwide,
especially in regions where satellite or internet connectivity may be unreliable or
compromised. These networks use various digital modes like PACTOR, ALE, and proprietary
protocols for encrypted diplomatic traffic.
</p>
</div>
<div class="signal-details-section">
<div class="signal-details-title">How to Listen</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
Click "Tune In" on any station to open the Listening Post with the frequency pre-configured.
Most number stations use USB (Upper Sideband) mode. You'll need an SDR capable of receiving
HF frequencies (typically 3-30 MHz) and an appropriate antenna.
</p>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Best Practices</div>
<ul style="color: var(--text-secondary); font-size: 12px; line-height: 1.6; padding-left: 20px;">
<li>HF propagation varies with time of day and solar conditions</li>
<li>Use a long wire or loop antenna for best results</li>
<li>Check schedules on priyom.org for transmission times</li>
<li>Night time generally offers better long-distance reception</li>
</ul>
</div>
<div class="signal-details-section">
<div class="signal-details-title">Data Sources</div>
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
Station data sourced from <a href="https://priyom.org" target="_blank" rel="noopener" style="color: var(--accent-cyan);">priyom.org</a>,
a community-maintained database of number stations and related transmissions.
</p>
</div>
</div>
</div>
`;
modal.classList.add('show');
}
/**
* Close help modal
*/
function closeHelp() {
const modal = document.getElementById('spyStationsHelpModal');
if (modal) {
modal.classList.remove('show');
}
}
/**
* Update sidebar stats
* @param {boolean} useFiltered - If true, use filtered stations instead of all stations
*/
function updateStats(useFiltered) {
const stationList = useFiltered ? filteredStations : stations;
const numberCount = stationList.filter(s => s.type === 'number').length;
const diplomaticCount = stationList.filter(s => s.type === 'diplomatic').length;
const countryCount = new Set(stationList.map(s => s.country_code)).size;
const freqCount = stationList.reduce((sum, s) => sum + s.frequencies.length, 0);
const numberEl = document.getElementById('spyStatsNumber');
const diplomaticEl = document.getElementById('spyStatsDiplomatic');
const countriesEl = document.getElementById('spyStatsCountries');
const freqsEl = document.getElementById('spyStatsFreqs');
if (numberEl) numberEl.textContent = numberCount;
if (diplomaticEl) diplomaticEl.textContent = diplomaticCount;
if (countriesEl) countriesEl.textContent = countryCount;
if (freqsEl) freqsEl.textContent = freqCount;
// Update visible count in header if element exists
const visibleCountEl = document.getElementById('spyStationsVisibleCount');
if (visibleCountEl) {
visibleCountEl.textContent = stationList.length;
}
}
// Public API
return {
init,
applyFilters,
tuneToStation,
tuneToSelectedFreq,
showDetails,
closeDetails,
showHelp,
closeHelp
};
})();
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
// Will be initialized when mode is switched to spy stations
});

View File

@@ -0,0 +1,765 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VESSEL RADAR // INTERCEPT - See the Invisible</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
</head>
<body>
<!-- Radar background effects -->
<div class="radar-bg"></div>
<div class="scanline"></div>
<header class="header">
<div class="logo">
VESSEL RADAR
<span>// INTERCEPT - AIS Tracking</span>
</div>
<div class="status-bar">
<a href="/" class="back-link">Main Dashboard</a>
</div>
</header>
<div class="stats-strip">
<div class="stats-strip-inner">
<div class="strip-stat">
<span class="strip-value" id="stripVesselsNow">0</span>
<span class="strip-label">VESSELS</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripTotalSeen">0</span>
<span class="strip-label">SEEN</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripMaxRange">0</span>
<span class="strip-label">MAX NM</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripFastest">-</span>
<span class="strip-label">MAX KT</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripClosest">-</span>
<span class="strip-label">NEAR NM</span>
</div>
<div class="strip-divider"></div>
<div class="strip-stat signal-stat" title="Signal quality (messages/sec)">
<span class="strip-value" id="stripSignal">--</span>
<span class="strip-label">SIGNAL</span>
</div>
<div class="strip-stat session-stat">
<span class="strip-value" id="stripSession">00:00:00</span>
<span class="strip-label">SESSION</span>
</div>
<div class="strip-divider"></div>
<div class="strip-status">
<div class="status-dot" id="trackingDot"></div>
<span id="trackingStatus">STANDBY</span>
</div>
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
</div>
</div>
<main class="dashboard">
<div class="main-display">
<div id="vesselMap"></div>
</div>
<div class="sidebar">
<div class="panel selected-vessel">
<div class="panel-header">
<span>SELECTED VESSEL</span>
<div class="panel-indicator"></div>
</div>
<div class="selected-info" id="selectedInfo">
<div class="no-vessel">
<div class="no-vessel-icon">&#128674;</div>
<div>Select a vessel</div>
</div>
</div>
</div>
<div class="panel vessel-list">
<div class="panel-header">
<span>TRACKED VESSELS</span>
<div class="panel-indicator"></div>
</div>
<div class="vessel-list-content" id="vesselList">
<div class="no-vessel">
<div>No vessels detected</div>
<div style="font-size: 10px; margin-top: 5px;">Start tracking to begin</div>
</div>
</div>
</div>
</div>
<div class="controls-bar">
<div class="control-group">
<span class="control-group-label">DISPLAY</span>
<div class="control-group-items">
<label title="Show vessel trails"><input type="checkbox" id="showTrails" onchange="toggleTrails()"> Trails</label>
<label title="Show range rings"><input type="checkbox" id="showRangeRings" checked onchange="drawRangeRings()"> Rings</label>
<select id="rangeSelect" onchange="updateRange()" title="Range rings distance">
<option value="10">10nm</option>
<option value="25">25nm</option>
<option value="50" selected>50nm</option>
<option value="100">100nm</option>
</select>
</div>
</div>
<div class="control-group">
<span class="control-group-label">LOCATION</span>
<div class="control-group-items">
<input type="text" id="obsLat" value="51.5074" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat">
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon">
</div>
</div>
<div class="control-group tracking-group">
<span class="control-group-label">AIS TRACKING</span>
<div class="control-group-items">
<select id="aisDeviceSelect" title="SDR device">
<option value="0">SDR 0</option>
</select>
<input type="number" id="aisGain" value="40" min="0" max="50" style="width: 50px;" title="Gain">
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
</div>
</div>
</div>
</main>
<script>
// State
let vesselMap = null;
let vessels = {};
let markers = {};
let selectedMmsi = null;
let eventSource = null;
let isTracking = false;
let showTrails = false;
let vesselTrails = {};
let trailLines = {};
let maxRange = 50;
const MAX_TRAIL_POINTS = 50;
// Observer location
let observerLocation = { lat: 51.5074, lon: -0.1278 };
let rangeRingsLayer = null;
let observerMarker = null;
// Statistics
let stats = {
totalVesselsSeen: new Set(),
maxRange: 0,
fastestSpeed: 0,
closestDistance: Infinity,
sessionStart: null,
messagesReceived: 0,
messagesPerSecond: 0
};
// Session timer
let sessionTimerInterval = null;
let messageRateInterval = null;
let lastMessageCount = 0;
// Ship type to icon mapping
const SHIP_ICONS = {
30: '&#128031;', // Fishing
31: '&#128674;', // Towing
32: '&#128674;', // Towing
36: '&#9973;', // Sailing
37: '&#9973;', // Pleasure craft
60: '&#128674;', // Passenger
61: '&#128674;', // Passenger
62: '&#128674;', // Passenger
63: '&#128674;', // Passenger
64: '&#128674;', // Passenger
65: '&#128674;', // Passenger
66: '&#128674;', // Passenger
67: '&#128674;', // Passenger
68: '&#128674;', // Passenger
69: '&#128674;', // Passenger
70: '&#128674;', // Cargo
71: '&#128674;', // Cargo - hazardous A
72: '&#128674;', // Cargo - hazardous B
73: '&#128674;', // Cargo - hazardous C
74: '&#128674;', // Cargo - hazardous D
80: '&#128674;', // Tanker
81: '&#128674;', // Tanker - hazardous A
82: '&#128674;', // Tanker - hazardous B
83: '&#128674;', // Tanker - hazardous C
84: '&#128674;', // Tanker - hazardous D
default: '&#128674;' // Generic ship
};
// Ship type categories
function getShipCategory(type) {
if (!type) return 'Unknown';
if (type >= 20 && type < 30) return 'Wing in Ground';
if (type === 30) return 'Fishing';
if (type >= 31 && type <= 32) return 'Towing';
if (type >= 33 && type <= 34) return 'Dredging';
if (type === 35) return 'Military';
if (type >= 36 && type <= 37) return 'Sailing/Pleasure';
if (type >= 40 && type < 50) return 'High Speed Craft';
if (type === 50) return 'Pilot Vessel';
if (type === 51) return 'Search & Rescue';
if (type === 52) return 'Tug';
if (type === 53) return 'Port Tender';
if (type === 55) return 'Law Enforcement';
if (type >= 60 && type < 70) return 'Passenger';
if (type >= 70 && type < 80) return 'Cargo';
if (type >= 80 && type < 90) return 'Tanker';
return 'Other';
}
function getShipIcon(type) {
return SHIP_ICONS[type] || SHIP_ICONS.default;
}
// Navigation status text
const NAV_STATUS = {
0: 'Under way using engine',
1: 'At anchor',
2: 'Not under command',
3: 'Restricted maneuverability',
4: 'Constrained by draught',
5: 'Moored',
6: 'Aground',
7: 'Engaged in fishing',
8: 'Under way sailing',
11: 'Power-driven vessel towing astern',
12: 'Power-driven vessel pushing ahead',
14: 'AIS-SART active',
15: 'Undefined'
};
// Initialize map
function initMap() {
// Load saved observer location
const saved = localStorage.getItem('ais_observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) {
observerLocation = parsed;
document.getElementById('obsLat').value = parsed.lat;
document.getElementById('obsLon').value = parsed.lon;
}
} catch (e) {}
}
vesselMap = L.map('vesselMap', {
center: [observerLocation.lat, observerLocation.lon],
zoom: 10,
zoomControl: true
});
// OpenStreetMap tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19
}).addTo(vesselMap);
// Add observer marker
observerMarker = L.circleMarker([observerLocation.lat, observerLocation.lon], {
radius: 8,
fillColor: '#22c55e',
color: '#22c55e',
weight: 2,
opacity: 1,
fillOpacity: 0.5
}).addTo(vesselMap);
observerMarker.bindTooltip('Observer', { permanent: false, direction: 'top' });
drawRangeRings();
loadDevices();
updateClock();
setInterval(updateClock, 1000);
setInterval(cleanupStaleVessels, 10000);
}
function loadDevices() {
fetch('/devices')
.then(r => r.json())
.then(devices => {
const select = document.getElementById('aisDeviceSelect');
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="0">No devices</option>';
} else {
devices.forEach((d, i) => {
const opt = document.createElement('option');
opt.value = d.index;
opt.textContent = `SDR ${d.index}: ${d.name}`;
select.appendChild(opt);
});
}
})
.catch(() => {});
}
function updateObserverLoc() {
const lat = parseFloat(document.getElementById('obsLat').value);
const lon = parseFloat(document.getElementById('obsLon').value);
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
observerLocation = { lat, lon };
localStorage.setItem('ais_observerLocation', JSON.stringify(observerLocation));
if (observerMarker) {
observerMarker.setLatLng([lat, lon]);
}
vesselMap.setView([lat, lon], vesselMap.getZoom());
drawRangeRings();
}
}
function drawRangeRings() {
if (rangeRingsLayer) {
vesselMap.removeLayer(rangeRingsLayer);
}
if (!document.getElementById('showRangeRings').checked) return;
const rings = [];
const nmToMeters = 1852;
const intervals = [maxRange / 4, maxRange / 2, maxRange * 3 / 4, maxRange];
intervals.forEach(nm => {
const circle = L.circle([observerLocation.lat, observerLocation.lon], {
radius: nm * nmToMeters,
fill: false,
color: '#4a9eff',
opacity: 0.3,
weight: 1,
dashArray: '4 4'
});
rings.push(circle);
});
rangeRingsLayer = L.layerGroup(rings).addTo(vesselMap);
}
function updateRange() {
maxRange = parseInt(document.getElementById('rangeSelect').value);
drawRangeRings();
}
function toggleTrails() {
showTrails = document.getElementById('showTrails').checked;
Object.keys(trailLines).forEach(mmsi => {
if (trailLines[mmsi]) {
if (showTrails) {
trailLines[mmsi].addTo(vesselMap);
} else {
vesselMap.removeLayer(trailLines[mmsi]);
}
}
});
}
function toggleTracking() {
if (isTracking) {
stopTracking();
} else {
startTracking();
}
}
function startTracking() {
const device = document.getElementById('aisDeviceSelect').value;
const gain = document.getElementById('aisGain').value;
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') {
isTracking = true;
document.getElementById('startBtn').textContent = 'STOP';
document.getElementById('startBtn').classList.add('active');
document.getElementById('trackingDot').classList.add('active');
document.getElementById('trackingStatus').textContent = 'TRACKING';
startSessionTimer();
startSSE();
} else {
alert(data.message || 'Failed to start');
}
})
.catch(err => alert('Error: ' + err.message));
}
function stopTracking() {
fetch('/ais/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
isTracking = false;
document.getElementById('startBtn').textContent = 'START';
document.getElementById('startBtn').classList.remove('active');
document.getElementById('trackingDot').classList.remove('active');
document.getElementById('trackingStatus').textContent = 'STANDBY';
stopSessionTimer();
updateSignalQuality();
if (eventSource) {
eventSource.close();
eventSource = null;
}
});
}
function startSSE() {
if (eventSource) eventSource.close();
eventSource = new EventSource('/ais/stream');
eventSource.onmessage = function(e) {
try {
const data = JSON.parse(e.data);
if (data.type === 'vessel') {
updateVessel(data);
}
} catch (err) {}
};
eventSource.onerror = function() {
setTimeout(() => {
if (isTracking) startSSE();
}, 2000);
};
}
function updateVessel(data) {
const mmsi = data.mmsi;
if (!mmsi) return;
vessels[mmsi] = data;
stats.totalVesselsSeen.add(mmsi);
stats.messagesReceived++;
// Update statistics
if (data.speed && data.speed > stats.fastestSpeed) {
stats.fastestSpeed = data.speed;
}
if (data.lat && data.lon) {
const dist = calculateDistance(observerLocation.lat, observerLocation.lon, data.lat, data.lon);
if (dist > stats.maxRange) stats.maxRange = dist;
if (dist < stats.closestDistance) stats.closestDistance = dist;
// Update trail
if (!vesselTrails[mmsi]) vesselTrails[mmsi] = [];
vesselTrails[mmsi].push({ lat: data.lat, lon: data.lon, time: Date.now() });
if (vesselTrails[mmsi].length > MAX_TRAIL_POINTS) {
vesselTrails[mmsi].shift();
}
// Update trail line
if (showTrails) {
const points = vesselTrails[mmsi].map(p => [p.lat, p.lon]);
if (trailLines[mmsi]) {
trailLines[mmsi].setLatLngs(points);
} else {
trailLines[mmsi] = L.polyline(points, {
color: '#4a9eff',
weight: 2,
opacity: 0.5
}).addTo(vesselMap);
}
}
}
updateMarker(data);
updateStats();
updateVesselList();
if (mmsi === selectedMmsi) {
showVesselDetails(data);
}
}
function updateMarker(vessel) {
const mmsi = vessel.mmsi;
if (!vessel.lat || !vessel.lon) return;
const heading = vessel.heading || vessel.course || 0;
const icon = getShipIcon(vessel.ship_type);
const markerHtml = `
<div class="vessel-marker-inner" style="transform: rotate(${heading}deg);">
${icon}
</div>
`;
const divIcon = L.divIcon({
className: 'vessel-marker' + (mmsi === selectedMmsi ? ' selected' : ''),
html: markerHtml,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
if (markers[mmsi]) {
markers[mmsi].setLatLng([vessel.lat, vessel.lon]);
markers[mmsi].setIcon(divIcon);
} else {
markers[mmsi] = L.marker([vessel.lat, vessel.lon], { icon: divIcon })
.addTo(vesselMap)
.on('click', () => selectVessel(mmsi));
}
const name = vessel.name || 'Unknown';
markers[mmsi].bindTooltip(`${name}<br>MMSI: ${mmsi}`, { direction: 'top' });
}
function selectVessel(mmsi) {
selectedMmsi = mmsi;
// Update marker styles
Object.keys(markers).forEach(m => {
const el = markers[m].getElement();
if (el) {
el.querySelector('.vessel-marker-inner')?.parentElement?.classList.toggle('selected', m === mmsi);
}
});
// Update list selection
document.querySelectorAll('.vessel-item').forEach(el => {
el.classList.toggle('selected', el.dataset.mmsi === mmsi);
});
if (vessels[mmsi]) {
showVesselDetails(vessels[mmsi]);
}
}
function showVesselDetails(vessel) {
const container = document.getElementById('selectedInfo');
const icon = getShipIcon(vessel.ship_type);
const category = getShipCategory(vessel.ship_type);
const navStatus = NAV_STATUS[vessel.nav_status] || vessel.nav_status_text || 'Unknown';
container.innerHTML = `
<div class="vessel-header">
<div class="vessel-icon">${icon}</div>
<div>
<div class="vessel-name">${vessel.name || 'Unknown Vessel'}</div>
<div class="vessel-mmsi">MMSI: ${vessel.mmsi}</div>
</div>
</div>
<div class="vessel-details">
<div class="detail-item">
<div class="detail-label">Type</div>
<div class="detail-value">${category}</div>
</div>
<div class="detail-item">
<div class="detail-label">Callsign</div>
<div class="detail-value">${vessel.callsign || '-'}</div>
</div>
<div class="detail-item">
<div class="detail-label">Speed</div>
<div class="detail-value">${vessel.speed ? vessel.speed + ' kt' : '-'}</div>
</div>
<div class="detail-item">
<div class="detail-label">Course</div>
<div class="detail-value">${vessel.course ? vessel.course + '°' : '-'}</div>
</div>
<div class="detail-item">
<div class="detail-label">Heading</div>
<div class="detail-value">${vessel.heading ? vessel.heading + '°' : '-'}</div>
</div>
<div class="detail-item">
<div class="detail-label">Status</div>
<div class="detail-value" style="font-size: 10px;">${navStatus}</div>
</div>
<div class="detail-item">
<div class="detail-label">Destination</div>
<div class="detail-value">${vessel.destination || '-'}</div>
</div>
<div class="detail-item">
<div class="detail-label">ETA</div>
<div class="detail-value">${vessel.eta || '-'}</div>
</div>
<div class="detail-item">
<div class="detail-label">Length</div>
<div class="detail-value">${vessel.length ? vessel.length + ' m' : '-'}</div>
</div>
<div class="detail-item">
<div class="detail-label">Width</div>
<div class="detail-value">${vessel.width ? vessel.width + ' m' : '-'}</div>
</div>
<div class="detail-item" style="grid-column: span 2;">
<div class="detail-label">Position</div>
<div class="detail-value">${vessel.lat ? vessel.lat.toFixed(5) + ', ' + vessel.lon.toFixed(5) : '-'}</div>
</div>
</div>
`;
}
function updateVesselList() {
const container = document.getElementById('vesselList');
const vesselArray = Object.values(vessels).sort((a, b) => {
// Sort by name, then MMSI
const nameA = a.name || 'ZZZZZ';
const nameB = b.name || 'ZZZZZ';
return nameA.localeCompare(nameB);
});
if (vesselArray.length === 0) {
container.innerHTML = `
<div class="no-vessel">
<div>No vessels detected</div>
<div style="font-size: 10px; margin-top: 5px;">Start tracking to begin</div>
</div>
`;
return;
}
container.innerHTML = vesselArray.map(v => {
const icon = getShipIcon(v.ship_type);
const category = getShipCategory(v.ship_type);
return `
<div class="vessel-item ${v.mmsi === selectedMmsi ? 'selected' : ''}"
data-mmsi="${v.mmsi}" onclick="selectVessel('${v.mmsi}')">
<div class="vessel-item-icon">${icon}</div>
<div class="vessel-item-info">
<div class="vessel-item-name">${v.name || 'Unknown'}</div>
<div class="vessel-item-type">${category} | ${v.mmsi}</div>
</div>
<div class="vessel-item-speed">${v.speed ? v.speed + ' kt' : '-'}</div>
</div>
`;
}).join('');
}
function updateStats() {
document.getElementById('stripVesselsNow').textContent = Object.keys(vessels).length;
document.getElementById('stripTotalSeen').textContent = stats.totalVesselsSeen.size;
document.getElementById('stripMaxRange').textContent = stats.maxRange.toFixed(1);
document.getElementById('stripFastest').textContent = stats.fastestSpeed > 0 ? stats.fastestSpeed.toFixed(1) : '-';
document.getElementById('stripClosest').textContent = stats.closestDistance < Infinity ? stats.closestDistance.toFixed(1) : '-';
}
function cleanupStaleVessels() {
const now = Date.now();
const maxAge = 600000; // 10 minutes
Object.keys(vessels).forEach(mmsi => {
const lastSeen = vessels[mmsi].last_seen * 1000;
if (now - lastSeen > maxAge) {
delete vessels[mmsi];
if (markers[mmsi]) {
vesselMap.removeLayer(markers[mmsi]);
delete markers[mmsi];
}
if (trailLines[mmsi]) {
vesselMap.removeLayer(trailLines[mmsi]);
delete trailLines[mmsi];
}
delete vesselTrails[mmsi];
}
});
updateVesselList();
}
function calculateDistance(lat1, lon1, lat2, lon2) {
// Haversine formula, returns nautical miles
const R = 3440.065; // Earth radius in nautical miles
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
function updateClock() {
const now = new Date();
const utc = now.toISOString().slice(11, 19);
document.getElementById('utcTime').textContent = utc + ' UTC';
}
// Session timer functions
function startSessionTimer() {
if (!stats.sessionStart) {
stats.sessionStart = Date.now();
}
if (sessionTimerInterval) clearInterval(sessionTimerInterval);
sessionTimerInterval = setInterval(updateSessionTimer, 1000);
// Start message rate tracking
if (messageRateInterval) clearInterval(messageRateInterval);
lastMessageCount = stats.messagesReceived;
messageRateInterval = setInterval(updateMessageRate, 1000);
}
function stopSessionTimer() {
if (sessionTimerInterval) {
clearInterval(sessionTimerInterval);
sessionTimerInterval = null;
}
if (messageRateInterval) {
clearInterval(messageRateInterval);
messageRateInterval = null;
}
}
function updateSessionTimer() {
if (!stats.sessionStart) return;
const elapsed = Date.now() - stats.sessionStart;
const hours = Math.floor(elapsed / 3600000);
const mins = Math.floor((elapsed % 3600000) / 60000);
const secs = Math.floor((elapsed % 60000) / 1000);
document.getElementById('stripSession').textContent =
`${hours.toString().padStart(2,'0')}:${mins.toString().padStart(2,'0')}:${secs.toString().padStart(2,'0')}`;
}
function updateMessageRate() {
const currentCount = stats.messagesReceived;
stats.messagesPerSecond = currentCount - lastMessageCount;
lastMessageCount = currentCount;
updateSignalQuality();
}
// Signal quality display
function updateSignalQuality() {
const msgRate = stats.messagesPerSecond;
const el = document.getElementById('stripSignal');
const stat = el.closest('.strip-stat');
if (!isTracking || msgRate === 0) {
el.textContent = '--';
stat.classList.remove('good', 'warning', 'poor');
return;
}
// Signal quality based on message rate
// Good: >5 msg/s, Warning: 1-5, Poor: <1
if (msgRate >= 5) {
el.textContent = '●●●';
stat.classList.remove('warning', 'poor');
stat.classList.add('good');
} else if (msgRate >= 1) {
el.textContent = '●●○';
stat.classList.remove('good', 'poor');
stat.classList.add('warning');
} else {
el.textContent = '●○○';
stat.classList.remove('good', 'warning');
stat.classList.add('poor');
}
}
// Initialize
document.addEventListener('DOMContentLoaded', initMap);
</script>
</body>
</html>

View File

@@ -21,6 +21,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/device-cards.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/proximity-viz.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/spy-stations.css') }}">
</head>
<body>
@@ -95,6 +96,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="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></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>
@@ -130,6 +136,11 @@
<span class="mode-name">RTLAMR</span>
<span class="mode-desc">Utility meters</span>
</button>
<button class="mode-card" onclick="selectMode('spystations')">
<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="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span>
<span class="mode-name">Spy Stations</span>
<span class="mode-desc">Number stations & diplomatic</span>
</button>
</div>
</div>
</div>
@@ -271,9 +282,11 @@
<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="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></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>
<button class="mode-nav-btn" onclick="switchMode('spystations')"><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="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="nav-label">Spy Stations</span></button>
</div>
</div>
<div class="mode-nav-dropdown" data-group="wireless">
@@ -330,12 +343,14 @@
<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="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></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>
<button class="mobile-nav-btn" data-mode="tscm" onclick="switchMode('tscm')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span> TSCM</button>
<button class="mobile-nav-btn" data-mode="satellite" onclick="switchMode('satellite')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg></span> Sat</button>
<button class="mobile-nav-btn" data-mode="listening" onclick="switchMode('listening')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span> Scanner</button>
<button class="mobile-nav-btn" data-mode="spystations" onclick="switchMode('spystations')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span> Spy</button>
</nav>
<!-- Mobile Drawer Overlay -->
@@ -446,6 +461,10 @@
{% include 'partials/modes/tscm.html' %}
{% include 'partials/modes/ais.html' %}
{% include 'partials/modes/spy-stations.html' %}
<button class="preset-btn" onclick="killAll()"
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
Kill All Processes
@@ -1438,6 +1457,28 @@
</div>
</div>
<!-- Spy Stations Dashboard -->
<div id="spyStationsVisuals" class="spy-stations-container" style="display: none;">
<div class="spy-stations-header">
<div class="spy-stations-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 20px; height: 20px;">
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
<circle cx="12" cy="12" r="2"/>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/>
</svg>
Number Stations & Diplomatic Networks
</div>
<div class="spy-stations-count">
<span id="spyStationsVisibleCount">0</span> stations
</div>
</div>
<div class="spy-stations-grid" id="spyStationsGrid">
<!-- Station cards populated by JavaScript -->
</div>
</div>
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
<div class="recon-panel collapsed" id="reconPanel">
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
@@ -1519,6 +1560,7 @@
<script src="{{ url_for('static', filename='js/components/channel-chart.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/wifi.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
<script>
// ============================================
@@ -1987,7 +2029,8 @@
'pager': 'sdr', 'sensor': 'sdr',
'aprs': 'sdr', 'satellite': 'sdr', 'listening': 'sdr',
'wifi': 'wireless', 'bluetooth': 'wireless',
'tscm': 'security'
'tscm': 'security',
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr'
};
// Remove has-active from all dropdowns
@@ -2046,6 +2089,8 @@
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');
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
const pagerStats = document.getElementById('pagerStats');
const sensorStats = document.getElementById('sensorStats');
const satelliteStats = document.getElementById('satelliteStats');
@@ -2075,7 +2120,9 @@
'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST',
'aprs': 'APRS',
'tscm': 'TSCM'
'tscm': 'TSCM',
'ais': 'AIS VESSELS',
'spystations': 'SPY STATIONS'
};
const activeModeIndicator = document.getElementById('activeModeIndicator');
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
@@ -2085,12 +2132,14 @@
const listeningPostVisuals = document.getElementById('listeningPostVisuals');
const aprsVisuals = document.getElementById('aprsVisuals');
const tscmVisuals = document.getElementById('tscmVisuals');
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
if (listeningPostVisuals) listeningPostVisuals.style.display = mode === 'listening' ? 'grid' : 'none';
if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none';
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
// Show/hide mode-specific timeline containers
const pagerTimelineContainer = document.getElementById('pagerTimelineContainer');
@@ -2108,7 +2157,9 @@
'bluetooth': 'Bluetooth Scanner',
'listening': 'Listening Post',
'aprs': 'APRS Tracker',
'tscm': 'TSCM Counter-Surveillance'
'tscm': 'TSCM Counter-Surveillance',
'ais': 'AIS Vessel Tracker',
'spystations': 'Spy Stations'
};
const outputTitle = document.getElementById('outputTitle');
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
@@ -2126,7 +2177,7 @@
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'listening' || mode === 'aprs' || mode === 'tscm') {
if (mode === 'satellite' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') {
if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -2152,7 +2203,7 @@
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm') ? 'none' : 'block';
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite') ? 'none' : 'flex';
// Load interfaces and initialize visualizations when switching modes
@@ -2177,6 +2228,13 @@
} else if (mode === 'satellite') {
initPolarPlot();
initSatelliteList();
} else if (mode === 'listening') {
// Check for incoming tune requests from Spy Stations
if (typeof checkIncomingTuneRequest === 'function') {
checkIncomingTuneRequest();
}
} else if (mode === 'spystations') {
SpyStations.init();
}
}
@@ -10495,6 +10553,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 +10731,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

@@ -0,0 +1,83 @@
<!-- SPY STATIONS MODE -->
<div id="spystationsMode" class="mode-content">
<div class="section">
<h3>Spy Stations</h3>
<p style="color: var(--text-secondary); font-size: 11px; line-height: 1.5; margin-bottom: 15px;">
Active number stations and diplomatic HF radio networks. Data sourced from priyom.org.
</p>
</div>
<div class="section">
<h3>Filter by Type</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">
<label class="inline-checkbox">
<input type="checkbox" id="filterTypeNumber" checked onchange="SpyStations.applyFilters()">
<span style="color: var(--accent-cyan);">Number Stations</span>
</label>
<label class="inline-checkbox">
<input type="checkbox" id="filterTypeDiplomatic" checked onchange="SpyStations.applyFilters()">
<span style="color: var(--accent-green);">Diplomatic Networks</span>
</label>
</div>
</div>
<div class="section">
<h3>Filter by Country</h3>
<div id="countryFilters" style="display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow-y: auto;">
<!-- Populated by JavaScript -->
</div>
</div>
<div class="section">
<h3>Filter by Mode</h3>
<div id="modeFilters" style="display: flex; flex-direction: column; gap: 4px; max-height: 150px; overflow-y: auto;">
<!-- Populated by JavaScript -->
</div>
</div>
<div class="section">
<h3>Quick Stats</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
<div style="font-size: 18px; font-weight: bold; color: var(--accent-cyan);" id="spyStatsNumber">0</div>
<div style="font-size: 9px; color: var(--text-muted);">NUMBER</div>
</div>
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
<div style="font-size: 18px; font-weight: bold; color: var(--accent-green);" id="spyStatsDiplomatic">0</div>
<div style="font-size: 9px; color: var(--text-muted);">DIPLOMATIC</div>
</div>
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
<div style="font-size: 18px; font-weight: bold; color: var(--accent-orange);" id="spyStatsCountries">0</div>
<div style="font-size: 9px; color: var(--text-muted);">COUNTRIES</div>
</div>
<div style="background: rgba(0,0,0,0.2); padding: 8px; border-radius: 4px; text-align: center;">
<div style="font-size: 18px; font-weight: bold; color: var(--accent-purple);" id="spyStatsFreqs">0</div>
<div style="font-size: 9px; color: var(--text-muted);">FREQUENCIES</div>
</div>
</div>
</div>
<div class="section">
<h3>Help</h3>
<button class="preset-btn" onclick="SpyStations.showHelp()" style="width: 100%;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px; margin-right: 6px; vertical-align: middle;">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
About Spy Stations
</button>
</div>
<div class="section">
<h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">
<a href="https://priyom.org" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
Priyom.org
</a>
<a href="https://www.numbers-stations.com" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
Numbers-Stations.com
</a>
</div>
</div>
</div>

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',
f'-d:{device.index}', # Device index (colon format required)
'-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', 'on'])
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