mirror of
https://github.com/smittix/intercept.git
synced 2026-05-29 21:59:27 -07:00
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:
122
CLAUDE.md
Normal file
122
CLAUDE.md
Normal 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
24
app.py
@@ -36,6 +36,7 @@ from utils.constants import (
|
||||
MAX_AIRCRAFT_AGE_SECONDS,
|
||||
MAX_WIFI_NETWORK_AGE_SECONDS,
|
||||
MAX_BT_DEVICE_AGE_SECONDS,
|
||||
MAX_VESSEL_AGE_SECONDS,
|
||||
QUEUE_MAX_SIZE,
|
||||
)
|
||||
import logging
|
||||
@@ -139,6 +140,11 @@ rtlamr_process = None
|
||||
rtlamr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
rtlamr_lock = threading.Lock()
|
||||
|
||||
# AIS vessel tracking
|
||||
ais_process = None
|
||||
ais_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
ais_lock = threading.Lock()
|
||||
|
||||
# TSCM (Technical Surveillance Countermeasures)
|
||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
tscm_lock = threading.Lock()
|
||||
@@ -166,6 +172,9 @@ bt_services = {} # MAC -> list of services (not auto-cleaned, user-requested
|
||||
# Aircraft (ADS-B) state - using DataStore for automatic cleanup
|
||||
adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft')
|
||||
|
||||
# Vessel (AIS) state - using DataStore for automatic cleanup
|
||||
ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessels')
|
||||
|
||||
# Satellite state
|
||||
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
||||
|
||||
@@ -175,6 +184,7 @@ cleanup_manager.register(wifi_clients)
|
||||
cleanup_manager.register(bt_devices)
|
||||
cleanup_manager.register(bt_beacons)
|
||||
cleanup_manager.register(adsb_aircraft)
|
||||
cleanup_manager.register(ais_vessels)
|
||||
|
||||
|
||||
# ============================================
|
||||
@@ -501,6 +511,7 @@ def health_check() -> Response:
|
||||
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
||||
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
||||
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
||||
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
|
||||
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
||||
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||
@@ -508,6 +519,7 @@ def health_check() -> Response:
|
||||
},
|
||||
'data': {
|
||||
'aircraft_count': len(adsb_aircraft),
|
||||
'vessel_count': len(ais_vessels),
|
||||
'wifi_networks_count': len(wifi_networks),
|
||||
'wifi_clients_count': len(wifi_clients),
|
||||
'bt_devices_count': len(bt_devices),
|
||||
@@ -518,17 +530,18 @@ def health_check() -> Response:
|
||||
@app.route('/killall', methods=['POST'])
|
||||
def kill_all() -> Response:
|
||||
"""Kill all decoder and WiFi processes."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process, acars_process
|
||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||
global aprs_process, aprs_rtl_process
|
||||
|
||||
# Import adsb module to reset its state
|
||||
# Import adsb and ais modules to reset their state
|
||||
from routes import adsb as adsb_module
|
||||
from routes import ais as ais_module
|
||||
|
||||
killed = []
|
||||
processes_to_kill = [
|
||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||
'dump1090', 'acarsdec', 'direwolf'
|
||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher'
|
||||
]
|
||||
|
||||
for proc in processes_to_kill:
|
||||
@@ -553,6 +566,11 @@ def kill_all() -> Response:
|
||||
adsb_process = None
|
||||
adsb_module.adsb_using_service = False
|
||||
|
||||
# Reset AIS state
|
||||
with ais_lock:
|
||||
ais_process = None
|
||||
ais_module.ais_running = False
|
||||
|
||||
# Reset ACARS state
|
||||
with acars_lock:
|
||||
acars_process = None
|
||||
|
||||
@@ -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
480
routes/ais.py
Normal 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
625
routes/spy_stations.py
Normal 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
|
||||
}
|
||||
})
|
||||
47
setup.sh
47
setup.sh
@@ -203,6 +203,7 @@ check_tools() {
|
||||
check_optional "rtlamr" "Utility meter decoder (requires Go)" rtlamr
|
||||
check_required "dump1090" "ADS-B decoder" dump1090
|
||||
check_required "acarsdec" "ACARS decoder" acarsdec
|
||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||
|
||||
echo
|
||||
info "GPS:"
|
||||
@@ -412,7 +413,7 @@ install_multimon_ng_from_source_macos() {
|
||||
}
|
||||
|
||||
install_macos_packages() {
|
||||
TOTAL_STEPS=13
|
||||
TOTAL_STEPS=14
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Checking Homebrew"
|
||||
@@ -458,6 +459,13 @@ install_macos_packages() {
|
||||
progress "Installing acarsdec"
|
||||
(brew_install acarsdec) || warn "acarsdec not available via Homebrew"
|
||||
|
||||
progress "Installing AIS-catcher"
|
||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||
(brew_install aiscatcher) || warn "AIS-catcher not available via Homebrew"
|
||||
else
|
||||
ok "AIS-catcher already installed"
|
||||
fi
|
||||
|
||||
progress "Installing aircrack-ng"
|
||||
brew_install aircrack-ng
|
||||
|
||||
@@ -577,6 +585,34 @@ install_acarsdec_from_source_debian() {
|
||||
)
|
||||
}
|
||||
|
||||
install_aiscatcher_from_source_debian() {
|
||||
info "AIS-catcher not available via APT. Building from source..."
|
||||
|
||||
apt_install build-essential git cmake pkg-config \
|
||||
librtlsdr-dev libusb-1.0-0-dev libcurl4-openssl-dev zlib1g-dev
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning AIS-catcher..."
|
||||
git clone --depth 1 https://github.com/jvde-github/AIS-catcher.git "$tmp_dir/AIS-catcher" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone AIS-catcher"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/AIS-catcher"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling AIS-catcher..."
|
||||
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
$SUDO install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
||||
ok "AIS-catcher installed successfully."
|
||||
else
|
||||
warn "Failed to build AIS-catcher from source. AIS vessel tracking will not be available."
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_rtlsdr_blog_drivers_debian() {
|
||||
# The RTL-SDR Blog drivers provide better support for:
|
||||
# - RTL-SDR Blog V4 (R828D tuner)
|
||||
@@ -684,7 +720,7 @@ install_debian_packages() {
|
||||
export NEEDRESTART_MODE=a
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=18
|
||||
TOTAL_STEPS=19
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Updating APT package lists"
|
||||
@@ -815,6 +851,13 @@ install_debian_packages() {
|
||||
fi
|
||||
cmd_exists acarsdec || install_acarsdec_from_source_debian
|
||||
|
||||
progress "Installing AIS-catcher"
|
||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||
install_aiscatcher_from_source_debian
|
||||
else
|
||||
ok "AIS-catcher already installed"
|
||||
fi
|
||||
|
||||
progress "Configuring udev rules"
|
||||
setup_udev_rules_debian
|
||||
|
||||
|
||||
901
static/css/ais_dashboard.css
Normal file
901
static/css/ais_dashboard.css
Normal 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;
|
||||
}
|
||||
466
static/css/modes/spy-stations.css
Normal file
466
static/css/modes/spy-stations.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
530
static/js/modes/spy-stations.js
Normal file
530
static/js/modes/spy-stations.js
Normal 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()">×</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()">×</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
|
||||
});
|
||||
765
templates/ais_dashboard.html
Normal file
765
templates/ais_dashboard.html
Normal 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">🚢</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: '🐟', // Fishing
|
||||
31: '🚢', // Towing
|
||||
32: '🚢', // Towing
|
||||
36: '⛵', // Sailing
|
||||
37: '⛵', // Pleasure craft
|
||||
60: '🚢', // Passenger
|
||||
61: '🚢', // Passenger
|
||||
62: '🚢', // Passenger
|
||||
63: '🚢', // Passenger
|
||||
64: '🚢', // Passenger
|
||||
65: '🚢', // Passenger
|
||||
66: '🚢', // Passenger
|
||||
67: '🚢', // Passenger
|
||||
68: '🚢', // Passenger
|
||||
69: '🚢', // Passenger
|
||||
70: '🚢', // Cargo
|
||||
71: '🚢', // Cargo - hazardous A
|
||||
72: '🚢', // Cargo - hazardous B
|
||||
73: '🚢', // Cargo - hazardous C
|
||||
74: '🚢', // Cargo - hazardous D
|
||||
80: '🚢', // Tanker
|
||||
81: '🚢', // Tanker - hazardous A
|
||||
82: '🚢', // Tanker - hazardous B
|
||||
83: '🚢', // Tanker - hazardous C
|
||||
84: '🚢', // Tanker - hazardous D
|
||||
default: '🚢' // 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: '© 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>
|
||||
@@ -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>
|
||||
|
||||
119
templates/partials/modes/ais.html
Normal file
119
templates/partials/modes/ais.html
Normal 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>
|
||||
83
templates/partials/modes/spy-stations.html
Normal file
83
templates/partials/modes/spy-stations.html
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user