feat: Add VHF DSC Channel 70 monitoring and decoding

- Implement DSC message decoding (Distress, Urgency, Safety, Routine)

- Add MMSI country identification via MID lookup

- Integrate position extraction and map markers for distress alerts

- Implement device conflict detection to prevent SDR collisions with AIS

- Add permanent storage for critical alerts and visual UI overlays
This commit is contained in:
Marc
2026-01-25 04:07:14 -06:00
committed by Smittix
parent 3b238c3c8f
commit b4d3e65a3d
12 changed files with 2781 additions and 5 deletions

20
app.py
View File

@@ -37,6 +37,7 @@ from utils.constants import (
MAX_WIFI_NETWORK_AGE_SECONDS,
MAX_BT_DEVICE_AGE_SECONDS,
MAX_VESSEL_AGE_SECONDS,
MAX_DSC_MESSAGE_AGE_SECONDS,
QUEUE_MAX_SIZE,
)
import logging
@@ -145,6 +146,12 @@ ais_process = None
ais_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
ais_lock = threading.Lock()
# DSC (Digital Selective Calling)
dsc_process = None
dsc_rtl_process = None
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dsc_lock = threading.Lock()
# TSCM (Technical Surveillance Countermeasures)
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock()
@@ -175,6 +182,9 @@ adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_a
# Vessel (AIS) state - using DataStore for automatic cleanup
ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessels')
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
# Satellite state
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
@@ -185,6 +195,7 @@ cleanup_manager.register(bt_devices)
cleanup_manager.register(bt_beacons)
cleanup_manager.register(adsb_aircraft)
cleanup_manager.register(ais_vessels)
cleanup_manager.register(dsc_messages)
# ============================================
@@ -516,6 +527,7 @@ def health_check() -> Response:
'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),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
},
'data': {
'aircraft_count': len(adsb_aircraft),
@@ -523,6 +535,7 @@ def health_check() -> Response:
'wifi_networks_count': len(wifi_networks),
'wifi_clients_count': len(wifi_clients),
'bt_devices_count': len(bt_devices),
'dsc_messages_count': len(dsc_messages),
}
})
@@ -531,7 +544,7 @@ def health_check() -> Response:
def kill_all() -> Response:
"""Kill all decoder and WiFi processes."""
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
global aprs_process, aprs_rtl_process
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process
# Import adsb and ais modules to reset their state
from routes import adsb as adsb_module
@@ -580,6 +593,11 @@ def kill_all() -> Response:
aprs_process = None
aprs_rtl_process = None
# Reset DSC state
with dsc_lock:
dsc_process = None
dsc_rtl_process = None
return jsonify({'status': 'killed', 'processes': killed})

13
bin/dsc-decoder Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
# DSC (Digital Selective Calling) decoder wrapper
# Invokes the Python DSC decoder module
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Set PYTHONPATH to include project root
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH}"
# Run the decoder module
exec python3 -m utils.dsc.decoder "$@"

View File

@@ -11,6 +11,7 @@ def register_blueprints(app):
from .bluetooth_v2 import bluetooth_v2_bp
from .adsb import adsb_bp
from .ais import ais_bp
from .dsc import dsc_bp
from .acars import acars_bp
from .aprs import aprs_bp
from .satellite import satellite_bp
@@ -30,6 +31,7 @@ def register_blueprints(app):
app.register_blueprint(bluetooth_v2_bp) # New unified Bluetooth API
app.register_blueprint(adsb_bp)
app.register_blueprint(ais_bp)
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
app.register_blueprint(acars_bp)
app.register_blueprint(aprs_bp)
app.register_blueprint(satellite_bp)

575
routes/dsc.py Normal file
View File

@@ -0,0 +1,575 @@
"""VHF DSC (Digital Selective Calling) routes.
DSC operates on VHF Channel 70 (156.525 MHz) for maritime
distress and safety communications per ITU-R M.493.
"""
from __future__ import annotations
import json
import logging
import os
import pty
import queue
import select
import shutil
import subprocess
import threading
import time
from datetime import datetime
from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.constants import (
DSC_VHF_FREQUENCY_MHZ,
DSC_SAMPLE_RATE,
DSC_TERMINATE_TIMEOUT,
)
from utils.database import (
store_dsc_alert,
get_dsc_alerts,
get_dsc_alert,
acknowledge_dsc_alert,
get_dsc_alert_summary,
)
from utils.dsc.parser import parse_dsc_message
from utils.sse import format_sse
from utils.validation import validate_device_index, validate_gain
from utils.sdr import SDRFactory, SDRType
from utils.dependencies import get_tool_path
logger = logging.getLogger('intercept.dsc')
dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc')
# Module state (track if running independent of process state)
dsc_running = False
def _get_dsc_decoder_path() -> str | None:
"""Get path to DSC decoder."""
# Check for our custom decoder
project_bin = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'bin', 'dsc-decoder')
if os.path.isfile(project_bin) and os.access(project_bin, os.X_OK):
return project_bin
# Check system PATH
system_decoder = shutil.which('dsc-decoder')
if system_decoder:
return system_decoder
return None
def _check_dsc_tools() -> dict:
"""Check availability of DSC decoding tools."""
rtl_fm_path = get_tool_path('rtl_fm')
decoder_path = _get_dsc_decoder_path()
# Check for scipy/numpy (needed for decoder)
scipy_available = False
try:
import scipy
import numpy
scipy_available = True
except ImportError:
pass
return {
'rtl_fm': {
'available': rtl_fm_path is not None,
'path': rtl_fm_path
},
'dsc_decoder': {
'available': decoder_path is not None,
'path': decoder_path
},
'scipy': {
'available': scipy_available,
'note': 'Required for DSC signal processing'
},
'ready': rtl_fm_path is not None and decoder_path is not None and scipy_available
}
def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> None:
"""
Stream DSC decoder output to queue using PTY for unbuffered output.
Args:
master_fd: PTY master file descriptor
decoder_process: Decoder subprocess
"""
global dsc_running
try:
app_module.dsc_queue.put({'type': 'status', 'status': 'started'})
buffer = ""
while dsc_running:
try:
ready, _, _ = select.select([master_fd], [], [], 1.0)
except Exception:
break
if ready:
try:
data = os.read(master_fd, 1024)
if not data:
break
buffer += data.decode('utf-8', errors='replace')
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if not line:
continue
# Parse DSC message
parsed = parse_dsc_message(line)
if parsed:
# Generate unique message ID
msg_id = f"{parsed['source_mmsi']}_{int(time.time() * 1000)}"
parsed['id'] = msg_id
# Store in transient DataStore
app_module.dsc_messages.set(msg_id, parsed)
# Queue for SSE
try:
app_module.dsc_queue.put_nowait(parsed)
except queue.Full:
logger.warning("DSC queue full, dropping message")
# Store critical alerts permanently
if parsed.get('is_critical'):
_store_critical_alert(parsed)
else:
# Raw output for debugging
app_module.dsc_queue.put({
'type': 'raw',
'text': line
})
except OSError:
break
# Check if process is still running
if decoder_process.poll() is not None:
break
except Exception as e:
logger.error(f"DSC decoder error: {e}")
app_module.dsc_queue.put({
'type': 'error',
'error': str(e)
})
finally:
try:
os.close(master_fd)
except OSError:
pass
decoder_process.wait()
dsc_running = False
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
with app_module.dsc_lock:
app_module.dsc_process = None
app_module.dsc_rtl_process = None
def _store_critical_alert(msg: dict) -> None:
"""Store critical DSC alert (DISTRESS/URGENCY) to database."""
try:
store_dsc_alert(
source_mmsi=msg.get('source_mmsi', ''),
format_code=str(msg.get('format_code', '')),
category=msg.get('category', 'UNKNOWN'),
source_name=msg.get('source_name'),
dest_mmsi=msg.get('dest_mmsi'),
nature_of_distress=msg.get('nature_of_distress'),
latitude=msg.get('latitude'),
longitude=msg.get('longitude'),
raw_message=msg.get('raw_message')
)
logger.info(f"Stored {msg.get('category')} alert from {msg.get('source_mmsi')}")
except Exception as e:
logger.error(f"Failed to store DSC alert: {e}")
def monitor_rtl_stderr(process: subprocess.Popen) -> None:
"""Monitor rtl_fm stderr for errors."""
global dsc_running
try:
for line in process.stderr:
if not dsc_running:
break
err_text = line.decode('utf-8', errors='replace').strip()
if err_text:
logger.debug(f"[RTL_FM] {err_text}")
# Check for device busy error
if 'usb_claim_interface' in err_text.lower():
app_module.dsc_queue.put({
'type': 'error',
'error': 'SDR device busy',
'error_type': 'DEVICE_BUSY',
'suggestion': 'Use a different SDR device or stop other SDR processes'
})
# Check for other common errors
if 'no supported devices' in err_text.lower():
app_module.dsc_queue.put({
'type': 'error',
'error': 'No SDR device found',
'error_type': 'NO_DEVICE'
})
except Exception:
pass
@dsc_bp.route('/status')
def get_status() -> Response:
"""Get DSC decoder status."""
global dsc_running
with app_module.dsc_lock:
running = (
dsc_running and
app_module.dsc_process is not None and
app_module.dsc_process.poll() is None
)
# Get message counts
message_count = len(app_module.dsc_messages)
alert_summary = get_dsc_alert_summary()
return jsonify({
'running': running,
'frequency': DSC_VHF_FREQUENCY_MHZ,
'message_count': message_count,
'alerts': alert_summary
})
@dsc_bp.route('/tools')
def check_tools() -> Response:
"""Check DSC decoder tool availability."""
tools = _check_dsc_tools()
return jsonify(tools)
@dsc_bp.route('/start', methods=['POST'])
def start_decoding() -> Response:
"""Start DSC decoder."""
global dsc_running
with app_module.dsc_lock:
if app_module.dsc_process and app_module.dsc_process.poll() is None:
return jsonify({
'status': 'error',
'message': 'DSC decoder already running'
}), 409
# Check tools
tools = _check_dsc_tools()
if not tools['ready']:
missing = []
if not tools['rtl_fm']['available']:
missing.append('rtl_fm')
if not tools['dsc_decoder']['available']:
missing.append('dsc-decoder')
if not tools['scipy']['available']:
missing.append('scipy/numpy')
return jsonify({
'status': 'error',
'message': f'Missing required tools: {", ".join(missing)}'
}), 400
data = request.json or {}
# Validate device
try:
device = validate_device_index(data.get('device', '0'))
except ValueError as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 400
# Validate gain
try:
gain = validate_gain(data.get('gain', '40'))
except ValueError as e:
return jsonify({
'status': 'error',
'message': str(e)
}), 400
# Check if device is in use by AIS
try:
from routes import ais as ais_module
if hasattr(ais_module, 'ais_running') and ais_module.ais_running:
# AIS is running - check if same device
if hasattr(ais_module, 'ais_device') and str(ais_module.ais_device) == str(device):
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': f'SDR device {device} is in use by AIS tracking',
'suggestion': 'Use a different SDR device or stop AIS tracking first',
'in_use_by': 'ais'
}), 409
except ImportError:
pass
# Clear queue
while not app_module.dsc_queue.empty():
try:
app_module.dsc_queue.get_nowait()
except queue.Empty:
break
# Build rtl_fm command
rtl_fm_path = tools['rtl_fm']['path']
decoder_path = tools['dsc_decoder']['path']
# rtl_fm command for DSC decoding
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate
rtl_cmd = [
rtl_fm_path,
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M',
'-s', str(DSC_SAMPLE_RATE),
'-d', str(device),
'-g', str(gain),
'-M', 'fm', # FM demodulation
'-l', '0', # No squelch for DSC
'-E', 'dc' # DC blocking filter
]
# Decoder command
decoder_cmd = [decoder_path]
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(decoder_cmd)
logger.info(f"Starting DSC decoder: {full_cmd}")
try:
# Start rtl_fm subprocess
rtl_process = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
# Start stderr monitor thread
stderr_thread = threading.Thread(
target=monitor_rtl_stderr,
args=(rtl_process,),
daemon=True
)
stderr_thread.start()
# Create PTY for decoder output
master_fd, slave_fd = pty.openpty()
# Start decoder subprocess
decoder_process = subprocess.Popen(
decoder_cmd,
stdin=rtl_process.stdout,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True
)
os.close(slave_fd)
rtl_process.stdout.close()
# Store process references
app_module.dsc_process = decoder_process
app_module.dsc_rtl_process = rtl_process
dsc_running = True
# Start output streaming thread
output_thread = threading.Thread(
target=stream_dsc_decoder,
args=(master_fd, decoder_process),
daemon=True
)
output_thread.start()
return jsonify({
'status': 'started',
'frequency': DSC_VHF_FREQUENCY_MHZ,
'device': device,
'gain': gain,
'command': full_cmd
})
except FileNotFoundError as e:
return jsonify({
'status': 'error',
'message': f'Tool not found: {e.filename}'
}), 400
except Exception as e:
logger.error(f"Failed to start DSC decoder: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@dsc_bp.route('/stop', methods=['POST'])
def stop_decoding() -> Response:
"""Stop DSC decoder."""
global dsc_running
with app_module.dsc_lock:
if not app_module.dsc_process:
return jsonify({'status': 'not_running'})
dsc_running = False
# Terminate rtl_fm process first
if app_module.dsc_rtl_process:
try:
app_module.dsc_rtl_process.terminate()
app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
try:
app_module.dsc_rtl_process.kill()
except OSError:
pass
except OSError:
pass
# Terminate decoder process
if app_module.dsc_process:
try:
app_module.dsc_process.terminate()
app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
try:
app_module.dsc_process.kill()
except OSError:
pass
except OSError:
pass
app_module.dsc_process = None
app_module.dsc_rtl_process = None
return jsonify({'status': 'stopped'})
@dsc_bp.route('/stream')
def stream() -> Response:
"""SSE stream for real-time DSC messages."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
msg = app_module.dsc_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= 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'
response.headers['Connection'] = 'keep-alive'
return response
@dsc_bp.route('/messages')
def get_messages() -> Response:
"""Get current DSC messages from transient store."""
messages = list(app_module.dsc_messages.values())
# Sort by timestamp (newest first)
messages.sort(key=lambda m: m.get('timestamp', ''), reverse=True)
return jsonify({
'count': len(messages),
'messages': messages
})
@dsc_bp.route('/alerts')
def get_alerts_endpoint() -> Response:
"""Get stored DSC alerts (paginated)."""
# Parse query params
category = request.args.get('category')
acknowledged = request.args.get('acknowledged')
limit = min(int(request.args.get('limit', 50)), 200)
offset = int(request.args.get('offset', 0))
# Convert acknowledged param
ack_filter = None
if acknowledged is not None:
ack_filter = acknowledged.lower() in ('true', '1', 'yes')
alerts = get_dsc_alerts(
category=category,
acknowledged=ack_filter,
limit=limit,
offset=offset
)
summary = get_dsc_alert_summary()
return jsonify({
'alerts': alerts,
'count': len(alerts),
'summary': summary,
'pagination': {
'limit': limit,
'offset': offset
}
})
@dsc_bp.route('/alerts/<int:alert_id>')
def get_alert(alert_id: int) -> Response:
"""Get a specific DSC alert by ID."""
alert = get_dsc_alert(alert_id)
if not alert:
return jsonify({
'status': 'error',
'message': 'Alert not found'
}), 404
return jsonify(alert)
@dsc_bp.route('/alerts/<int:alert_id>/acknowledge', methods=['POST'])
def acknowledge_alert(alert_id: int) -> Response:
"""Acknowledge a DSC alert."""
data = request.json or {}
notes = data.get('notes')
success = acknowledge_dsc_alert(alert_id, notes)
if not success:
return jsonify({
'status': 'error',
'message': 'Alert not found'
}), 404
return jsonify({
'status': 'acknowledged',
'alert_id': alert_id
})
@dsc_bp.route('/alerts/summary')
def get_alerts_summary() -> Response:
"""Get summary of unacknowledged DSC alerts."""
summary = get_dsc_alert_summary()
return jsonify(summary)

View File

@@ -899,3 +899,308 @@ body {
line-height: 44px !important;
font-size: 18px !important;
}
/* ============================================
DSC (Digital Selective Calling) Styles
============================================ */
/* DSC Control Group - Orange accent (warning/distress theme) */
.control-group.dsc-group {
background: rgba(245, 158, 11, 0.05);
border-color: rgba(245, 158, 11, 0.2);
}
.control-group.dsc-group .control-group-label {
color: var(--accent-orange);
}
.control-group.dsc-group select,
.control-group.dsc-group input[type="number"] {
border-color: rgba(245, 158, 11, 0.3);
color: var(--accent-orange);
}
.dsc-btn {
background: var(--accent-orange) !important;
font-size: 9px !important;
padding: 6px 12px !important;
}
.dsc-btn:hover {
background: #d97706 !important;
box-shadow: 0 0 20px rgba(245, 158, 11, 0.3) !important;
}
.dsc-btn.active {
background: var(--accent-red) !important;
}
/* DSC Panel */
.panel.dsc-messages {
flex: 0 0 auto;
max-height: 250px;
display: flex;
flex-direction: column;
border-top: 1px solid rgba(245, 158, 11, 0.2);
}
.panel.dsc-messages::before {
background: linear-gradient(90deg, transparent, var(--accent-orange), transparent);
}
.panel.dsc-messages .panel-header {
background: rgba(245, 158, 11, 0.05);
border-bottom-color: rgba(245, 158, 11, 0.1);
color: var(--accent-orange);
}
/* DSC Alert Summary */
.dsc-alert-summary {
display: flex;
gap: 8px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid rgba(245, 158, 11, 0.1);
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
}
.dsc-alert-count {
padding: 2px 6px;
border-radius: 3px;
font-weight: 600;
}
.dsc-alert-count.distress {
background: rgba(239, 68, 68, 0.2);
color: var(--accent-red);
}
.dsc-alert-count.urgency {
background: rgba(245, 158, 11, 0.2);
color: var(--accent-orange);
}
/* DSC List Content */
.dsc-list-content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.no-messages {
text-align: center;
padding: 20px 15px;
color: var(--text-secondary);
font-size: 11px;
}
/* DSC Message Items */
.dsc-message-item {
position: relative;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(245, 158, 11, 0.15);
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.dsc-message-item:hover {
border-color: var(--accent-orange);
background: rgba(245, 158, 11, 0.05);
}
.dsc-message-item.distress {
border-color: var(--accent-red);
background: rgba(239, 68, 68, 0.1);
animation: distress-pulse 1.5s ease-in-out infinite;
}
.dsc-message-item.urgency {
border-color: var(--accent-orange);
background: rgba(245, 158, 11, 0.1);
}
@keyframes distress-pulse {
0%, 100% { box-shadow: 0 0 5px rgba(239, 68, 68, 0.3); }
50% { box-shadow: 0 0 15px rgba(239, 68, 68, 0.6); }
}
.dsc-message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.dsc-message-category {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 6px;
border-radius: 3px;
background: rgba(245, 158, 11, 0.2);
color: var(--accent-orange);
}
.dsc-message-item.distress .dsc-message-category {
background: rgba(239, 68, 68, 0.2);
color: var(--accent-red);
}
.dsc-message-time {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--text-dim);
}
.dsc-message-mmsi {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent-orange);
}
.dsc-message-country {
font-size: 9px;
color: var(--text-secondary);
}
.dsc-message-nature {
font-size: 10px;
color: var(--accent-red);
font-weight: 500;
margin-top: 2px;
}
.dsc-message-pos {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--text-secondary);
}
/* DSC Distress Alert Overlay */
.dsc-distress-alert {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10000;
background: rgba(15, 18, 24, 0.98);
border: 2px solid var(--accent-red);
border-radius: 8px;
padding: 24px 32px;
min-width: 300px;
text-align: center;
box-shadow: 0 0 40px rgba(239, 68, 68, 0.5);
animation: alert-flash 0.5s ease-in-out 3;
}
@keyframes alert-flash {
0%, 100% { border-color: var(--accent-red); box-shadow: 0 0 40px rgba(239, 68, 68, 0.5); }
50% { border-color: #ff6b6b; box-shadow: 0 0 60px rgba(239, 68, 68, 0.8); }
}
.dsc-distress-alert .dsc-alert-header {
font-family: 'Orbitron', 'JetBrains Mono', monospace;
font-size: 24px;
font-weight: 700;
color: var(--accent-red);
margin-bottom: 16px;
letter-spacing: 3px;
}
.dsc-distress-alert .dsc-alert-mmsi {
font-family: 'JetBrains Mono', monospace;
font-size: 16px;
color: var(--accent-cyan);
margin-bottom: 8px;
}
.dsc-distress-alert .dsc-alert-country {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.dsc-distress-alert .dsc-alert-nature {
font-size: 18px;
font-weight: 600;
color: var(--accent-orange);
margin-bottom: 12px;
}
.dsc-distress-alert .dsc-alert-position {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
color: var(--accent-cyan);
margin-bottom: 16px;
}
.dsc-distress-alert button {
background: var(--accent-red);
border: none;
color: white;
padding: 10px 24px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.dsc-distress-alert button:hover {
background: #dc2626;
box-shadow: 0 0 20px rgba(239, 68, 68, 0.5);
}
/* DSC Map Markers */
.dsc-marker {
background: transparent;
border: none;
}
.dsc-marker-inner {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: white;
border: 2px solid white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}
.dsc-marker-inner.distress {
animation: distress-marker-pulse 1s ease-in-out infinite;
}
@keyframes distress-marker-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}
/* Mobile adjustments for DSC */
@media (max-width: 767px) {
.panel.dsc-messages {
max-height: 200px;
}
.dsc-distress-alert {
width: 90%;
min-width: auto;
padding: 16px 20px;
}
.dsc-distress-alert .dsc-alert-header {
font-size: 18px;
}
}

View File

@@ -96,6 +96,23 @@
</div>
</div>
</div>
<div class="panel dsc-messages">
<div class="panel-header">
<span>VHF DSC MESSAGES</span>
<div class="panel-indicator" id="dscIndicator"></div>
</div>
<div class="dsc-alert-summary" id="dscAlertSummary">
<span class="dsc-alert-count distress" id="dscDistressCount" title="Distress alerts">0 DISTRESS</span>
<span class="dsc-alert-count urgency" id="dscUrgencyCount" title="Urgency alerts">0 URGENCY</span>
</div>
<div class="dsc-list-content" id="dscMessageList">
<div class="no-messages">
<div>No DSC messages</div>
<div style="font-size: 10px; margin-top: 5px;">Start VHF DSC to monitor</div>
</div>
</div>
</div>
</div>
<div class="controls-bar">
@@ -131,6 +148,17 @@
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
</div>
</div>
<div class="control-group dsc-group">
<span class="control-group-label">VHF DSC</span>
<div class="control-group-items">
<select id="dscDeviceSelect" title="DSC SDR device (secondary)">
<option value="0">SDR 0</option>
</select>
<input type="number" id="dscGain" value="40" min="0" max="50" style="width: 50px;" title="Gain">
<button class="start-btn dsc-btn" id="dscStartBtn" onclick="toggleDscTracking()">START DSC</button>
</div>
</div>
</div>
</main>
@@ -142,6 +170,13 @@
let selectedMmsi = null;
let eventSource = null;
let isTracking = false;
// DSC State
let dscEventSource = null;
let isDscTracking = false;
let dscMessages = {};
let dscMarkers = {};
let dscAlertCounts = { distress: 0, urgency: 0 };
let showTrails = false;
let vesselTrails = {};
let trailLines = {};
@@ -290,18 +325,37 @@
fetch('/devices')
.then(r => r.json())
.then(devices => {
const select = document.getElementById('aisDeviceSelect');
select.innerHTML = '';
// Populate AIS device selector
const aisSelect = document.getElementById('aisDeviceSelect');
aisSelect.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="0">No devices</option>';
aisSelect.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);
aisSelect.appendChild(opt);
});
}
// Populate DSC device selector
const dscSelect = document.getElementById('dscDeviceSelect');
dscSelect.innerHTML = '';
if (devices.length === 0) {
dscSelect.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}`;
dscSelect.appendChild(opt);
});
// Default to second device if available
if (devices.length > 1) {
dscSelect.value = devices[1].index;
}
}
})
.catch(() => {});
}
@@ -758,6 +812,238 @@
}
}
// ============================================
// DSC (Digital Selective Calling) Functions
// ============================================
function toggleDscTracking() {
if (isDscTracking) {
stopDscTracking();
} else {
startDscTracking();
}
}
function startDscTracking() {
const device = document.getElementById('dscDeviceSelect').value;
const gain = document.getElementById('dscGain').value;
fetch('/dsc/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain })
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
isDscTracking = true;
document.getElementById('dscStartBtn').textContent = 'STOP DSC';
document.getElementById('dscStartBtn').classList.add('active');
document.getElementById('dscIndicator').classList.add('active');
startDscSSE();
} else if (data.error_type === 'DEVICE_BUSY') {
alert('SDR device is busy.\n\n' + data.suggestion);
} else {
alert(data.message || 'Failed to start DSC');
}
})
.catch(err => alert('Error: ' + err.message));
}
function stopDscTracking() {
fetch('/dsc/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
isDscTracking = false;
document.getElementById('dscStartBtn').textContent = 'START DSC';
document.getElementById('dscStartBtn').classList.remove('active');
document.getElementById('dscIndicator').classList.remove('active');
if (dscEventSource) {
dscEventSource.close();
dscEventSource = null;
}
});
}
function startDscSSE() {
if (dscEventSource) dscEventSource.close();
dscEventSource = new EventSource('/dsc/stream');
dscEventSource.onmessage = function(e) {
try {
const data = JSON.parse(e.data);
if (data.type === 'dsc_message') {
handleDscMessage(data);
} else if (data.type === 'error') {
console.error('DSC error:', data.error);
if (data.error_type === 'DEVICE_BUSY') {
alert('DSC: Device became busy. ' + (data.suggestion || ''));
stopDscTracking();
}
}
} catch (err) {}
};
dscEventSource.onerror = function() {
setTimeout(() => {
if (isDscTracking) startDscSSE();
}, 2000);
};
}
function handleDscMessage(data) {
const msgId = data.id || data.source_mmsi + '_' + Date.now();
dscMessages[msgId] = data;
// Update alert counts
if (data.category === 'DISTRESS') {
dscAlertCounts.distress++;
} else if (data.category === 'URGENCY') {
dscAlertCounts.urgency++;
}
// Show prominent alert for distress/urgency
if (data.is_critical) {
showDistressAlert(data);
}
// Add position marker if coordinates present
if (data.latitude && data.longitude) {
addDscPositionMarker(data);
}
updateDscMessageList();
updateDscAlertSummary();
}
function showDistressAlert(data) {
// Create alert notification
const alertDiv = document.createElement('div');
alertDiv.className = 'dsc-distress-alert';
alertDiv.innerHTML = `
<div class="dsc-alert-header">${data.category}</div>
<div class="dsc-alert-mmsi">MMSI: ${data.source_mmsi}</div>
${data.source_country ? `<div class="dsc-alert-country">${data.source_country}</div>` : ''}
${data.nature_of_distress ? `<div class="dsc-alert-nature">${data.nature_of_distress}</div>` : ''}
${data.latitude ? `<div class="dsc-alert-position">${data.latitude.toFixed(4)}, ${data.longitude.toFixed(4)}</div>` : ''}
<button onclick="this.parentElement.remove()">ACKNOWLEDGE</button>
`;
document.body.appendChild(alertDiv);
// Auto-remove after 30 seconds
setTimeout(() => {
if (alertDiv.parentElement) alertDiv.remove();
}, 30000);
// Play alert sound if available
try {
const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1yc3R3eXx+foCAfn59fHt5d3VzcWxnYlxVT0hCOzUuJx8YEAkDAP/+/v7+/v7+/v8AAAECAwUHCQsOEBMWGRwfIiUoKy4xNDc6PT9CRUdKTE5QUlRVV1hZWlpbW1taWVhXVlRTUU9NSkdEQT47ODUyLywpJiMgHRoXFBEOCwgFAwEA/v38+/r5+Pf29fTz8vHw7+7t7Ovq6ejn5uXk4+Lh4N/e3dzb2tnY19bV1NPS0dDPzs3MzMvLy8vMzM3Nzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4+Tl5ufp6uvs7e7v8PHy8/T19vf4+fr7/P3+');
audio.volume = 0.5;
audio.play().catch(() => {});
} catch (e) {}
}
function addDscPositionMarker(data) {
const mmsi = data.source_mmsi;
// Remove existing marker
if (dscMarkers[mmsi]) {
vesselMap.removeLayer(dscMarkers[mmsi]);
}
// Create marker with distress icon
const isDistress = data.category === 'DISTRESS';
const color = isDistress ? '#ef4444' : (data.category === 'URGENCY' ? '#f59e0b' : '#4a9eff');
const icon = L.divIcon({
className: 'dsc-marker',
html: `<div class="dsc-marker-inner ${isDistress ? 'distress' : ''}" style="background: ${color};">
<span>&#9888;</span>
</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14]
});
dscMarkers[mmsi] = L.marker([data.latitude, data.longitude], { icon })
.addTo(vesselMap)
.bindPopup(`
<strong>${data.category}</strong><br>
MMSI: ${mmsi}<br>
${data.source_country ? `Country: ${data.source_country}<br>` : ''}
${data.nature_of_distress ? `Nature: ${data.nature_of_distress}<br>` : ''}
Position: ${data.latitude.toFixed(4)}, ${data.longitude.toFixed(4)}
`);
// Pan to distress position
if (isDistress) {
vesselMap.setView([data.latitude, data.longitude], 12);
}
}
function updateDscMessageList() {
const container = document.getElementById('dscMessageList');
const msgArray = Object.values(dscMessages)
.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
if (msgArray.length === 0) {
container.innerHTML = `
<div class="no-messages">
<div>No DSC messages</div>
<div style="font-size: 10px; margin-top: 5px;">Start VHF DSC to monitor</div>
</div>
`;
return;
}
container.innerHTML = msgArray.slice(0, 50).map(msg => {
const isDistress = msg.category === 'DISTRESS';
const isUrgency = msg.category === 'URGENCY';
const categoryClass = isDistress ? 'distress' : (isUrgency ? 'urgency' : '');
return `
<div class="dsc-message-item ${categoryClass}" data-id="${msg.id}">
<div class="dsc-message-header">
<span class="dsc-message-category">${msg.category}</span>
<span class="dsc-message-time">${formatDscTime(msg.timestamp)}</span>
</div>
<div class="dsc-message-mmsi">MMSI: ${msg.source_mmsi}</div>
${msg.source_country ? `<div class="dsc-message-country">${msg.source_country}</div>` : ''}
${msg.nature_of_distress ? `<div class="dsc-message-nature">${msg.nature_of_distress}</div>` : ''}
${msg.latitude ? `<div class="dsc-message-pos">${msg.latitude.toFixed(4)}, ${msg.longitude.toFixed(4)}</div>` : ''}
</div>
`;
}).join('');
}
function formatDscTime(timestamp) {
if (!timestamp) return '--:--';
try {
const d = new Date(timestamp);
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch (e) {
return timestamp.slice(11, 19) || '--:--';
}
}
function updateDscAlertSummary() {
document.getElementById('dscDistressCount').textContent = `${dscAlertCounts.distress} DISTRESS`;
document.getElementById('dscUrgencyCount').textContent = `${dscAlertCounts.urgency} URGENCY`;
}
// Cross-reference DSC MMSI with AIS vessels
function crossReferenceDscWithAis(mmsi) {
const vessel = vessels[mmsi];
if (vessel) {
return {
name: vessel.name,
callsign: vessel.callsign,
ship_type: vessel.ship_type,
destination: vessel.destination
};
}
return null;
}
// Initialize
document.addEventListener('DOMContentLoaded', initMap);
</script>

View File

@@ -237,3 +237,20 @@ HANDSHAKE_CAPTURE_PATH_PREFIX = '/tmp/intercept_handshake_'
# PMKID capture path prefix
PMKID_CAPTURE_PATH_PREFIX = '/tmp/intercept_pmkid_'
# =============================================================================
# DSC (Digital Selective Calling)
# =============================================================================
# VHF DSC frequency (Channel 70)
DSC_VHF_FREQUENCY_MHZ = 156.525
# DSC audio sample rate for rtl_fm
DSC_SAMPLE_RATE = 48000
# Maximum age for DSC messages in transient store
MAX_DSC_MESSAGE_AGE_SECONDS = 3600 # 1 hour
# DSC process termination timeout
DSC_TERMINATE_TIMEOUT = 3

View File

@@ -352,6 +352,39 @@ def init_db() -> None:
ON tscm_cases(status, created_at)
''')
# =====================================================================
# DSC (Digital Selective Calling) Tables
# =====================================================================
# DSC Alerts - Permanent storage for DISTRESS/URGENCY messages
conn.execute('''
CREATE TABLE IF NOT EXISTS dsc_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
source_mmsi TEXT NOT NULL,
source_name TEXT,
dest_mmsi TEXT,
format_code TEXT NOT NULL,
category TEXT NOT NULL,
nature_of_distress TEXT,
latitude REAL,
longitude REAL,
raw_message TEXT,
acknowledged BOOLEAN DEFAULT 0,
notes TEXT
)
''')
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_dsc_alerts_category
ON dsc_alerts(category, received_at)
''')
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_dsc_alerts_mmsi
ON dsc_alerts(source_mmsi, received_at)
''')
logger.info("Database initialized successfully")
@@ -1455,3 +1488,192 @@ def get_sweep_capabilities(sweep_id: int) -> dict | None:
'limitations': json.loads(row['limitations']) if row['limitations'] else [],
'recorded_at': row['recorded_at']
}
# =============================================================================
# DSC (Digital Selective Calling) Functions
# =============================================================================
def store_dsc_alert(
source_mmsi: str,
format_code: str,
category: str,
source_name: str | None = None,
dest_mmsi: str | None = None,
nature_of_distress: str | None = None,
latitude: float | None = None,
longitude: float | None = None,
raw_message: str | None = None
) -> int:
"""
Store a DSC alert (typically DISTRESS or URGENCY) to permanent storage.
Returns:
The ID of the created alert
"""
with get_db() as conn:
cursor = conn.execute('''
INSERT INTO dsc_alerts
(source_mmsi, source_name, dest_mmsi, format_code, category,
nature_of_distress, latitude, longitude, raw_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
source_mmsi, source_name, dest_mmsi, format_code, category,
nature_of_distress, latitude, longitude, raw_message
))
return cursor.lastrowid
def get_dsc_alerts(
category: str | None = None,
acknowledged: bool | None = None,
source_mmsi: str | None = None,
limit: int = 100,
offset: int = 0
) -> list[dict]:
"""
Get DSC alerts with optional filters.
Args:
category: Filter by category (DISTRESS, URGENCY, SAFETY, ROUTINE)
acknowledged: Filter by acknowledgement status
source_mmsi: Filter by source MMSI
limit: Maximum number of results
offset: Offset for pagination
Returns:
List of DSC alert records
"""
conditions = []
params = []
if category is not None:
conditions.append('category = ?')
params.append(category)
if acknowledged is not None:
conditions.append('acknowledged = ?')
params.append(1 if acknowledged else 0)
if source_mmsi is not None:
conditions.append('source_mmsi = ?')
params.append(source_mmsi)
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
params.extend([limit, offset])
with get_db() as conn:
cursor = conn.execute(f'''
SELECT * FROM dsc_alerts
{where_clause}
ORDER BY received_at DESC
LIMIT ? OFFSET ?
''', params)
results = []
for row in cursor:
results.append({
'id': row['id'],
'received_at': row['received_at'],
'source_mmsi': row['source_mmsi'],
'source_name': row['source_name'],
'dest_mmsi': row['dest_mmsi'],
'format_code': row['format_code'],
'category': row['category'],
'nature_of_distress': row['nature_of_distress'],
'latitude': row['latitude'],
'longitude': row['longitude'],
'raw_message': row['raw_message'],
'acknowledged': bool(row['acknowledged']),
'notes': row['notes']
})
return results
def get_dsc_alert(alert_id: int) -> dict | None:
"""Get a specific DSC alert by ID."""
with get_db() as conn:
cursor = conn.execute(
'SELECT * FROM dsc_alerts WHERE id = ?',
(alert_id,)
)
row = cursor.fetchone()
if not row:
return None
return {
'id': row['id'],
'received_at': row['received_at'],
'source_mmsi': row['source_mmsi'],
'source_name': row['source_name'],
'dest_mmsi': row['dest_mmsi'],
'format_code': row['format_code'],
'category': row['category'],
'nature_of_distress': row['nature_of_distress'],
'latitude': row['latitude'],
'longitude': row['longitude'],
'raw_message': row['raw_message'],
'acknowledged': bool(row['acknowledged']),
'notes': row['notes']
}
def acknowledge_dsc_alert(alert_id: int, notes: str | None = None) -> bool:
"""
Acknowledge a DSC alert.
Args:
alert_id: The alert ID to acknowledge
notes: Optional notes about the acknowledgement
Returns:
True if alert was found and updated, False otherwise
"""
with get_db() as conn:
if notes:
cursor = conn.execute(
'UPDATE dsc_alerts SET acknowledged = 1, notes = ? WHERE id = ?',
(notes, alert_id)
)
else:
cursor = conn.execute(
'UPDATE dsc_alerts SET acknowledged = 1 WHERE id = ?',
(alert_id,)
)
return cursor.rowcount > 0
def get_dsc_alert_summary() -> dict:
"""Get summary counts of DSC alerts by category."""
with get_db() as conn:
cursor = conn.execute('''
SELECT category, COUNT(*) as count
FROM dsc_alerts
WHERE acknowledged = 0
GROUP BY category
''')
summary = {'distress': 0, 'urgency': 0, 'safety': 0, 'routine': 0, 'total': 0}
for row in cursor:
cat = row['category'].lower()
if cat in summary:
summary[cat] = row['count']
summary['total'] += row['count']
return summary
def cleanup_old_dsc_alerts(max_age_days: int = 30) -> int:
"""
Remove old acknowledged DSC alerts (keeps unacknowledged ones).
Args:
max_age_days: Maximum age in days for acknowledged alerts
Returns:
Number of deleted alerts
"""
with get_db() as conn:
cursor = conn.execute('''
DELETE FROM dsc_alerts
WHERE acknowledged = 1
AND received_at < datetime('now', ?)
''', (f'-{max_age_days} days',))
return cursor.rowcount

34
utils/dsc/__init__.py Normal file
View File

@@ -0,0 +1,34 @@
"""
DSC (Digital Selective Calling) utilities.
VHF DSC is a maritime distress and safety calling system operating on 156.525 MHz
(VHF Channel 70). It provides automated calling for distress, urgency, safety,
and routine communications per ITU-R M.493.
"""
from .constants import (
FORMAT_CODES,
DISTRESS_NATURE_CODES,
TELECOMMAND_CODES,
CATEGORY_PRIORITY,
MID_COUNTRY_MAP,
)
from .parser import (
parse_dsc_message,
get_country_from_mmsi,
get_distress_nature_text,
get_format_text,
)
__all__ = [
'FORMAT_CODES',
'DISTRESS_NATURE_CODES',
'TELECOMMAND_CODES',
'CATEGORY_PRIORITY',
'MID_COUNTRY_MAP',
'parse_dsc_message',
'get_country_from_mmsi',
'get_distress_nature_text',
'get_format_text',
]

468
utils/dsc/constants.py Normal file
View File

@@ -0,0 +1,468 @@
"""
DSC (Digital Selective Calling) constants per ITU-R M.493.
This module contains all DSC-specific constants including format codes,
distress nature codes, telecommand definitions, and MID (Maritime
Identification Digits) country mappings.
"""
from __future__ import annotations
# =============================================================================
# DSC Format Codes (Category)
# Per ITU-R M.493-15 Table 1
# =============================================================================
FORMAT_CODES = {
100: 'DISTRESS', # All ships distress alert
102: 'ALL_SHIPS', # All ships call
104: 'GROUP', # Group call
106: 'DISTRESS_ACK', # Distress acknowledgement
108: 'DISTRESS_RELAY', # Distress relay
110: 'GEOGRAPHIC', # Geographic area call
112: 'INDIVIDUAL', # Individual call
114: 'INDIVIDUAL_ACK', # Individual acknowledgement
116: 'ROUTINE', # Routine call
118: 'SAFETY', # Safety call
120: 'URGENCY', # Urgency call
}
# Category priority (lower = higher priority)
CATEGORY_PRIORITY = {
'DISTRESS': 0,
'DISTRESS_ACK': 1,
'DISTRESS_RELAY': 2,
'URGENCY': 3,
'SAFETY': 4,
'ROUTINE': 5,
'ALL_SHIPS': 5,
'GROUP': 5,
'GEOGRAPHIC': 5,
'INDIVIDUAL': 5,
'INDIVIDUAL_ACK': 5,
}
# =============================================================================
# Nature of Distress Codes
# Per ITU-R M.493-15 Table 3
# =============================================================================
DISTRESS_NATURE_CODES = {
100: 'UNDESIGNATED', # Undesignated distress
101: 'FIRE', # Fire, explosion
102: 'FLOODING', # Flooding
103: 'COLLISION', # Collision
104: 'GROUNDING', # Grounding
105: 'LISTING', # Listing, in danger of capsizing
106: 'SINKING', # Sinking
107: 'DISABLED', # Disabled and adrift
108: 'ABANDONING', # Abandoning ship
109: 'PIRACY', # Piracy/armed robbery attack
110: 'MOB', # Man overboard
112: 'EPIRB', # EPIRB emission
}
# =============================================================================
# Telecommand Codes (First and Second)
# Per ITU-R M.493-15 Tables 4-5
# =============================================================================
TELECOMMAND_CODES = {
# First telecommand (type of subsequent communication)
100: 'F3E_G3E_ALL', # F3E/G3E all modes (VHF telephony)
101: 'F3E_G3E_DUPLEX', # F3E/G3E duplex
102: 'POLLING', # Polling
103: 'UNABLE_TO_COMPLY', # Unable to comply
104: 'END_OF_CALL', # End of call
105: 'DATA', # Data
106: 'J3E_TELEPHONY', # J3E telephony (SSB)
107: 'DISTRESS_ACK', # Distress acknowledgement
108: 'DISTRESS_RELAY', # Distress relay
109: 'F1B_J2B_FEC', # F1B/J2B FEC NBDP telegraphy
110: 'F1B_J2B_ARQ', # F1B/J2B ARQ NBDP telegraphy
111: 'TEST', # Test
112: 'SHIP_POSITION', # Ship position request
113: 'NO_INFO', # No information
118: 'FREQ_ANNOUNCEMENT', # Frequency announcement
126: 'NO_REASON', # No reason given
# Second telecommand (additional info)
200: 'F3E_G3E_SIMPLEX', # Simplex VHF telephony requested
201: 'POLL_RESPONSE', # Poll response
}
# =============================================================================
# DSC Symbol Definitions
# Per ITU-R M.493-15
# =============================================================================
# Special symbols
DSC_SYMBOLS = {
120: 'DX', # Dot pattern (synchronization)
121: 'RX', # Phasing sequence RX
122: 'SX', # Phasing sequence SX
123: 'S0', # Phasing sequence S0
124: 'S1', # Phasing sequence S1
125: 'S2', # Phasing sequence S2
126: 'S3', # Phasing sequence S3
127: 'EOS', # End of sequence
}
# =============================================================================
# MID (Maritime Identification Digits) Country Mapping
# First 3 digits of MMSI identify the country
# Per ITU MID table (partial list of common codes)
# =============================================================================
MID_COUNTRY_MAP = {
# Americas
'201': 'Albania',
'202': 'Andorra',
'203': 'Austria',
'204': 'Azores',
'205': 'Belgium',
'206': 'Belarus',
'207': 'Bulgaria',
'208': 'Vatican City',
'209': 'Cyprus',
'210': 'Cyprus',
'211': 'Germany',
'212': 'Cyprus',
'213': 'Georgia',
'214': 'Moldova',
'215': 'Malta',
'216': 'Armenia',
'218': 'Germany',
'219': 'Denmark',
'220': 'Denmark',
'224': 'Spain',
'225': 'Spain',
'226': 'France',
'227': 'France',
'228': 'France',
'229': 'Malta',
'230': 'Finland',
'231': 'Faroe Islands',
'232': 'United Kingdom',
'233': 'United Kingdom',
'234': 'United Kingdom',
'235': 'United Kingdom',
'236': 'Gibraltar',
'237': 'Greece',
'238': 'Croatia',
'239': 'Greece',
'240': 'Greece',
'241': 'Greece',
'242': 'Morocco',
'243': 'Hungary',
'244': 'Netherlands',
'245': 'Netherlands',
'246': 'Netherlands',
'247': 'Italy',
'248': 'Malta',
'249': 'Malta',
'250': 'Ireland',
'251': 'Iceland',
'252': 'Liechtenstein',
'253': 'Luxembourg',
'254': 'Monaco',
'255': 'Madeira',
'256': 'Malta',
'257': 'Norway',
'258': 'Norway',
'259': 'Norway',
'261': 'Poland',
'262': 'Montenegro',
'263': 'Portugal',
'264': 'Romania',
'265': 'Sweden',
'266': 'Sweden',
'267': 'Slovakia',
'268': 'San Marino',
'269': 'Switzerland',
'270': 'Czech Republic',
'271': 'Turkey',
'272': 'Ukraine',
'273': 'Russia',
'274': 'North Macedonia',
'275': 'Latvia',
'276': 'Estonia',
'277': 'Lithuania',
'278': 'Slovenia',
'279': 'Serbia',
# North America
'301': 'Anguilla',
'303': 'USA',
'304': 'Antigua and Barbuda',
'305': 'Antigua and Barbuda',
'306': 'Curacao',
'307': 'Aruba',
'308': 'Bahamas',
'309': 'Bahamas',
'310': 'Bermuda',
'311': 'Bahamas',
'312': 'Belize',
'314': 'Barbados',
'316': 'Canada',
'319': 'Cayman Islands',
'321': 'Costa Rica',
'323': 'Cuba',
'325': 'Dominica',
'327': 'Dominican Republic',
'329': 'Guadeloupe',
'330': 'Grenada',
'331': 'Greenland',
'332': 'Guatemala',
'334': 'Honduras',
'336': 'Haiti',
'338': 'USA',
'339': 'Jamaica',
'341': 'Saint Kitts and Nevis',
'343': 'Saint Lucia',
'345': 'Mexico',
'347': 'Martinique',
'348': 'Montserrat',
'350': 'Nicaragua',
'351': 'Panama',
'352': 'Panama',
'353': 'Panama',
'354': 'Panama',
'355': 'Panama',
'356': 'Panama',
'357': 'Panama',
'358': 'Puerto Rico',
'359': 'El Salvador',
'361': 'Saint Pierre and Miquelon',
'362': 'Trinidad and Tobago',
'364': 'Turks and Caicos',
'366': 'USA',
'367': 'USA',
'368': 'USA',
'369': 'USA',
'370': 'Panama',
'371': 'Panama',
'372': 'Panama',
'373': 'Panama',
'374': 'Panama',
'375': 'Saint Vincent and the Grenadines',
'376': 'Saint Vincent and the Grenadines',
'377': 'Saint Vincent and the Grenadines',
'378': 'British Virgin Islands',
'379': 'US Virgin Islands',
# Asia
'401': 'Afghanistan',
'403': 'Saudi Arabia',
'405': 'Bangladesh',
'408': 'Bahrain',
'410': 'Bhutan',
'412': 'China',
'413': 'China',
'414': 'China',
'416': 'Taiwan',
'417': 'Sri Lanka',
'419': 'India',
'422': 'Iran',
'423': 'Azerbaijan',
'425': 'Iraq',
'428': 'Israel',
'431': 'Japan',
'432': 'Japan',
'434': 'Turkmenistan',
'436': 'Kazakhstan',
'437': 'Uzbekistan',
'438': 'Jordan',
'440': 'South Korea',
'441': 'South Korea',
'443': 'Palestine',
'445': 'North Korea',
'447': 'Kuwait',
'450': 'Lebanon',
'451': 'Kyrgyzstan',
'453': 'Macao',
'455': 'Maldives',
'457': 'Mongolia',
'459': 'Nepal',
'461': 'Oman',
'463': 'Pakistan',
'466': 'Qatar',
'468': 'Syria',
'470': 'UAE',
'471': 'UAE',
'472': 'Tajikistan',
'473': 'Yemen',
'475': 'Yemen',
'477': 'Hong Kong',
'478': 'Bosnia and Herzegovina',
# Oceania
'501': 'Adelie Land',
'503': 'Australia',
'506': 'Myanmar',
'508': 'Brunei',
'510': 'Micronesia',
'511': 'Palau',
'512': 'New Zealand',
'514': 'Cambodia',
'515': 'Cambodia',
'516': 'Christmas Island',
'518': 'Cook Islands',
'520': 'Fiji',
'523': 'Cocos Islands',
'525': 'Indonesia',
'529': 'Kiribati',
'531': 'Laos',
'533': 'Malaysia',
'536': 'Northern Mariana Islands',
'538': 'Marshall Islands',
'540': 'New Caledonia',
'542': 'Niue',
'544': 'Nauru',
'546': 'French Polynesia',
'548': 'Philippines',
'550': 'Timor-Leste',
'553': 'Papua New Guinea',
'555': 'Pitcairn Island',
'557': 'Solomon Islands',
'559': 'American Samoa',
'561': 'Samoa',
'563': 'Singapore',
'564': 'Singapore',
'565': 'Singapore',
'566': 'Singapore',
'567': 'Thailand',
'570': 'Tonga',
'572': 'Tuvalu',
'574': 'Vietnam',
'576': 'Vanuatu',
'577': 'Vanuatu',
'578': 'Wallis and Futuna',
# Africa
'601': 'South Africa',
'603': 'Angola',
'605': 'Algeria',
'607': 'St. Paul and Amsterdam Islands',
'608': 'Ascension Island',
'609': 'Burundi',
'610': 'Benin',
'611': 'Botswana',
'612': 'Central African Republic',
'613': 'Cameroon',
'615': 'Congo',
'616': 'Comoros',
'617': 'Cabo Verde',
'618': 'Crozet Archipelago',
'619': 'Ivory Coast',
'620': 'Comoros',
'621': 'Djibouti',
'622': 'Egypt',
'624': 'Ethiopia',
'625': 'Eritrea',
'626': 'Gabon',
'627': 'Ghana',
'629': 'Gambia',
'630': 'Guinea-Bissau',
'631': 'Equatorial Guinea',
'632': 'Guinea',
'633': 'Burkina Faso',
'634': 'Kenya',
'635': 'Kerguelen Islands',
'636': 'Liberia',
'637': 'Liberia',
'638': 'South Sudan',
'642': 'Libya',
'644': 'Lesotho',
'645': 'Mauritius',
'647': 'Madagascar',
'649': 'Mali',
'650': 'Mozambique',
'654': 'Mauritania',
'655': 'Malawi',
'656': 'Niger',
'657': 'Nigeria',
'659': 'Namibia',
'660': 'Reunion',
'661': 'Rwanda',
'662': 'Sudan',
'663': 'Senegal',
'664': 'Seychelles',
'665': 'Saint Helena',
'666': 'Somalia',
'667': 'Sierra Leone',
'668': 'Sao Tome and Principe',
'669': 'Swaziland',
'670': 'Chad',
'671': 'Togo',
'672': 'Tunisia',
'674': 'Tanzania',
'675': 'Uganda',
'676': 'Democratic Republic of Congo',
'677': 'Tanzania',
'678': 'Zambia',
'679': 'Zimbabwe',
# South America
'701': 'Argentina',
'710': 'Brazil',
'720': 'Bolivia',
'725': 'Chile',
'730': 'Colombia',
'735': 'Ecuador',
'740': 'Falkland Islands',
'745': 'Guiana',
'750': 'Guyana',
'755': 'Paraguay',
'760': 'Peru',
'765': 'Suriname',
'770': 'Uruguay',
'775': 'Venezuela',
}
# =============================================================================
# VHF Channel Frequencies (MHz) for DSC follow-up
# =============================================================================
VHF_CHANNELS = {
6: 156.300, # Intership safety
8: 156.400, # Commercial working
9: 156.450, # Calling
10: 156.500, # Commercial working
12: 156.600, # Port operations
13: 156.650, # Bridge-to-bridge navigation safety
14: 156.700, # Port operations
16: 156.800, # Distress, safety and calling (VHF voice)
67: 156.375, # UK small craft safety
68: 156.425, # Marina/yacht club
70: 156.525, # DSC distress, safety and calling
71: 156.575, # Port operations
72: 156.625, # Intership
73: 156.675, # Port operations
74: 156.725, # Port operations
77: 156.875, # Intership
}
# =============================================================================
# DSC Modulation Parameters
# =============================================================================
DSC_BAUD_RATE = 100 # 100 baud per ITU-R M.493
# FSK tone frequencies (Hz)
DSC_MARK_FREQ = 1800 # B (mark) - binary 1
DSC_SPACE_FREQ = 1200 # Y (space) - binary 0
# Audio sample rate for decoding
DSC_AUDIO_SAMPLE_RATE = 48000
# Frame structure
DSC_DOT_PATTERN_LENGTH = 200 # 200 bits of alternating pattern
DSC_PHASING_LENGTH = 7 # 7 symbols phasing sequence
DSC_MESSAGE_MAX_SYMBOLS = 180 # Maximum message length in symbols

514
utils/dsc/decoder.py Normal file
View File

@@ -0,0 +1,514 @@
#!/usr/bin/env python3
"""
DSC (Digital Selective Calling) decoder.
Decodes VHF DSC signals per ITU-R M.493. Reads 48kHz 16-bit signed
audio from stdin (from rtl_fm) and outputs JSON messages to stdout.
DSC uses 100 baud FSK with:
- Mark (1): 1800 Hz
- Space (0): 1200 Hz
Frame structure:
1. Dot pattern: 200 bits alternating 1/0 for synchronization
2. Phasing sequence: 7 symbols (RX or DX pattern)
3. Format specifier: Identifies message type
4. Address/Self-ID fields
5. Category/Nature fields (if distress)
6. Position data (if present)
7. Telecommand fields
8. EOS (End of Sequence)
Each symbol is 10 bits (7 data + 3 error detection).
"""
from __future__ import annotations
import argparse
import json
import logging
import struct
import sys
from datetime import datetime
from typing import Generator
import numpy as np
from scipy import signal as scipy_signal
from .constants import (
DSC_BAUD_RATE,
DSC_MARK_FREQ,
DSC_SPACE_FREQ,
DSC_AUDIO_SAMPLE_RATE,
FORMAT_CODES,
DISTRESS_NATURE_CODES,
)
# Configure logging
logging.basicConfig(
level=logging.WARNING,
format='%(asctime)s [%(levelname)s] %(message)s',
stream=sys.stderr
)
logger = logging.getLogger('dsc.decoder')
class DSCDecoder:
"""
DSC FSK decoder.
Demodulates 100 baud FSK audio and decodes DSC protocol.
"""
def __init__(self, sample_rate: int = DSC_AUDIO_SAMPLE_RATE):
self.sample_rate = sample_rate
self.baud_rate = DSC_BAUD_RATE
self.samples_per_bit = sample_rate // self.baud_rate
# FSK frequencies
self.mark_freq = DSC_MARK_FREQ # 1800 Hz = binary 1
self.space_freq = DSC_SPACE_FREQ # 1200 Hz = binary 0
# Bandpass filter for DSC band (1100-1900 Hz)
nyq = sample_rate / 2
low = 1100 / nyq
high = 1900 / nyq
self.bp_b, self.bp_a = scipy_signal.butter(4, [low, high], btype='band')
# Build FSK correlators
self._build_correlators()
# State
self.buffer = np.array([], dtype=np.int16)
self.bit_buffer = []
self.in_message = False
self.message_bits = []
def _build_correlators(self):
"""Build matched filter correlators for mark and space frequencies."""
# Duration for one bit
t = np.arange(self.samples_per_bit) / self.sample_rate
# Mark correlator (1800 Hz)
self.mark_ref = np.sin(2 * np.pi * self.mark_freq * t)
# Space correlator (1200 Hz)
self.space_ref = np.sin(2 * np.pi * self.space_freq * t)
def process_audio(self, audio_data: bytes) -> Generator[dict, None, None]:
"""
Process audio data and yield decoded DSC messages.
Args:
audio_data: Raw 16-bit signed PCM audio bytes
Yields:
Decoded DSC message dicts
"""
# Convert bytes to numpy array
samples = np.frombuffer(audio_data, dtype=np.int16)
if len(samples) == 0:
return
# Append to buffer
self.buffer = np.concatenate([self.buffer, samples])
# Need at least one bit worth of samples
if len(self.buffer) < self.samples_per_bit:
return
# Apply bandpass filter
try:
filtered = scipy_signal.lfilter(self.bp_b, self.bp_a, self.buffer)
except Exception as e:
logger.warning(f"Filter error: {e}")
return
# Demodulate FSK using correlation
bits = self._demodulate_fsk(filtered)
# Keep unprocessed samples (last bit's worth)
keep_samples = self.samples_per_bit * 2
if len(self.buffer) > keep_samples:
self.buffer = self.buffer[-keep_samples:]
# Process decoded bits
for bit in bits:
message = self._process_bit(bit)
if message:
yield message
def _demodulate_fsk(self, samples: np.ndarray) -> list[int]:
"""
Demodulate FSK audio to bits using correlation.
Args:
samples: Filtered audio samples
Returns:
List of decoded bits (0 or 1)
"""
bits = []
num_bits = len(samples) // self.samples_per_bit
for i in range(num_bits):
start = i * self.samples_per_bit
end = start + self.samples_per_bit
segment = samples[start:end]
if len(segment) < self.samples_per_bit:
break
# Correlate with mark and space references
mark_corr = np.abs(np.correlate(segment, self.mark_ref, mode='valid'))
space_corr = np.abs(np.correlate(segment, self.space_ref, mode='valid'))
# Decision: mark (1) if mark correlation > space correlation
if np.max(mark_corr) > np.max(space_corr):
bits.append(1)
else:
bits.append(0)
return bits
def _process_bit(self, bit: int) -> dict | None:
"""
Process a decoded bit and detect/decode DSC messages.
Args:
bit: Decoded bit (0 or 1)
Returns:
Decoded message dict if complete message found, None otherwise
"""
self.bit_buffer.append(bit)
# Keep buffer manageable
if len(self.bit_buffer) > 2000:
self.bit_buffer = self.bit_buffer[-1500:]
# Look for dot pattern (sync) - alternating 1010101...
if not self.in_message:
if self._detect_dot_pattern():
self.in_message = True
self.message_bits = []
logger.debug("DSC sync detected")
return None
# Collect message bits
if self.in_message:
self.message_bits.append(bit)
# Check for end of message or timeout
if len(self.message_bits) >= 10: # One symbol
# Try to decode accumulated symbols
message = self._try_decode_message()
if message:
self.in_message = False
self.message_bits = []
return message
# Timeout - too many bits without valid message
if len(self.message_bits) > 1800: # ~180 symbols max
logger.debug("DSC message timeout")
self.in_message = False
self.message_bits = []
return None
def _detect_dot_pattern(self) -> bool:
"""
Detect DSC dot pattern for synchronization.
The dot pattern is at least 200 alternating bits (1010101...).
We look for at least 20 consecutive alternations.
"""
if len(self.bit_buffer) < 40:
return False
# Check last 40 bits for alternating pattern
last_bits = self.bit_buffer[-40:]
alternations = 0
for i in range(1, len(last_bits)):
if last_bits[i] != last_bits[i - 1]:
alternations += 1
else:
alternations = 0
if alternations >= 20:
return True
return False
def _try_decode_message(self) -> dict | None:
"""
Try to decode accumulated message bits as DSC message.
Returns:
Decoded message dict or None if not yet complete/valid
"""
# Need at least a few symbols to start decoding
num_symbols = len(self.message_bits) // 10
if num_symbols < 5:
return None
# Extract symbols (10 bits each)
symbols = []
for i in range(num_symbols):
start = i * 10
end = start + 10
if end <= len(self.message_bits):
symbol_bits = self.message_bits[start:end]
symbol_value = self._bits_to_symbol(symbol_bits)
symbols.append(symbol_value)
# Look for EOS (End of Sequence) - symbol 127
eos_found = False
eos_index = -1
for i, sym in enumerate(symbols):
if sym == 127: # EOS symbol
eos_found = True
eos_index = i
break
if not eos_found:
# Not complete yet
return None
# Decode the message from symbols
return self._decode_symbols(symbols[:eos_index + 1])
def _bits_to_symbol(self, bits: list[int]) -> int:
"""
Convert 10 bits to symbol value.
DSC uses 10-bit symbols: 7 information bits + 3 error bits.
We extract the 7-bit value.
"""
if len(bits) != 10:
return -1
# First 7 bits are data (LSB first in DSC)
value = 0
for i in range(7):
if bits[i]:
value |= (1 << i)
return value
def _decode_symbols(self, symbols: list[int]) -> dict | None:
"""
Decode DSC symbol sequence to message.
Message structure (symbols):
0: Format specifier
1-5: Address/MMSI (encoded)
6-10: Self-ID/MMSI (encoded)
11+: Variable fields depending on format
Last: EOS (127)
Args:
symbols: List of decoded symbol values
Returns:
Decoded message dict or None if invalid
"""
if len(symbols) < 12:
return None
try:
# Format specifier (first non-phasing symbol)
format_code = symbols[0]
format_text = FORMAT_CODES.get(format_code, f'UNKNOWN-{format_code}')
# Determine category from format
category = 'ROUTINE'
if format_code == 100:
category = 'DISTRESS'
elif format_code == 106:
category = 'DISTRESS_ACK'
elif format_code == 108:
category = 'DISTRESS_RELAY'
elif format_code == 118:
category = 'SAFETY'
elif format_code == 120:
category = 'URGENCY'
elif format_code == 102:
category = 'ALL_SHIPS'
# Decode MMSI from symbols 1-5 (destination/address)
dest_mmsi = self._decode_mmsi(symbols[1:6])
# Decode self-ID from symbols 6-10 (source)
source_mmsi = self._decode_mmsi(symbols[6:11])
message = {
'type': 'dsc',
'format': format_code,
'format_text': format_text,
'category': category,
'source_mmsi': source_mmsi,
'dest_mmsi': dest_mmsi,
'timestamp': datetime.utcnow().isoformat() + 'Z',
}
# Parse additional fields based on format
remaining = symbols[11:-1] # Exclude EOS
if category in ('DISTRESS', 'DISTRESS_RELAY'):
# Distress messages have nature and position
if len(remaining) >= 1:
message['nature'] = remaining[0]
message['nature_text'] = DISTRESS_NATURE_CODES.get(
remaining[0], f'UNKNOWN-{remaining[0]}'
)
# Try to decode position
if len(remaining) >= 11:
position = self._decode_position(remaining[1:11])
if position:
message['position'] = position
# Telecommand fields (usually last two before EOS)
if len(remaining) >= 2:
message['telecommand1'] = remaining[-2]
message['telecommand2'] = remaining[-1]
# Add raw data for debugging
message['raw'] = ''.join(f'{s:03d}' for s in symbols)
logger.info(f"Decoded DSC: {category} from {source_mmsi}")
return message
except Exception as e:
logger.warning(f"DSC decode error: {e}")
return None
def _decode_mmsi(self, symbols: list[int]) -> str:
"""
Decode MMSI from 5 DSC symbols.
Each symbol represents 2 BCD digits (00-99).
5 symbols = 10 digits, but MMSI is 9 digits (first symbol has leading 0).
"""
if len(symbols) < 5:
return '000000000'
digits = []
for sym in symbols:
if sym < 0 or sym > 99:
sym = 0
# Each symbol is 2 BCD digits
digits.append(f'{sym:02d}')
mmsi = ''.join(digits)
# MMSI is 9 digits, might need to trim leading zero
if len(mmsi) > 9:
mmsi = mmsi[-9:]
return mmsi.zfill(9)
def _decode_position(self, symbols: list[int]) -> dict | None:
"""
Decode position from 10 DSC symbols.
Position encoding (ITU-R M.493):
- Quadrant (10=NE, 11=NW, 00=SE, 01=SW)
- Latitude degrees (2 digits)
- Latitude minutes (2 digits)
- Longitude degrees (3 digits)
- Longitude minutes (2 digits)
"""
if len(symbols) < 10:
return None
try:
# Quadrant indicator
quadrant = symbols[0]
lat_sign = 1 if quadrant in (10, 11) else -1
lon_sign = 1 if quadrant in (10, 00) else -1
# Latitude degrees and minutes
lat_deg = symbols[1] if symbols[1] <= 90 else 0
lat_min = symbols[2] if symbols[2] < 60 else 0
# Longitude degrees (3 digits from 2 symbols)
lon_deg_high = symbols[3] if symbols[3] < 10 else 0
lon_deg_low = symbols[4] if symbols[4] < 100 else 0
lon_deg = lon_deg_high * 100 + lon_deg_low
if lon_deg > 180:
lon_deg = 0
lon_min = symbols[5] if symbols[5] < 60 else 0
lat = lat_sign * (lat_deg + lat_min / 60.0)
lon = lon_sign * (lon_deg + lon_min / 60.0)
return {'lat': round(lat, 6), 'lon': round(lon, 6)}
except Exception:
return None
def read_audio_stdin() -> Generator[bytes, None, None]:
"""
Read audio from stdin in chunks.
Yields:
Audio data chunks
"""
chunk_size = 4800 # 0.1 seconds at 48kHz, 16-bit = 9600 bytes
while True:
try:
data = sys.stdin.buffer.read(chunk_size * 2) # 2 bytes per sample
if not data:
break
yield data
except KeyboardInterrupt:
break
except Exception as e:
logger.error(f"Read error: {e}")
break
def main():
"""Main entry point for DSC decoder."""
parser = argparse.ArgumentParser(
description='DSC (Digital Selective Calling) decoder',
epilog='Reads 48kHz 16-bit signed PCM audio from stdin'
)
parser.add_argument(
'-r', '--sample-rate',
type=int,
default=DSC_AUDIO_SAMPLE_RATE,
help=f'Audio sample rate (default: {DSC_AUDIO_SAMPLE_RATE})'
)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Enable verbose logging'
)
args = parser.parse_args()
if args.verbose:
logger.setLevel(logging.DEBUG)
decoder = DSCDecoder(sample_rate=args.sample_rate)
logger.info(f"DSC decoder started (sample rate: {args.sample_rate})")
for audio_chunk in read_audio_stdin():
for message in decoder.process_audio(audio_chunk):
# Output JSON to stdout
try:
print(json.dumps(message), flush=True)
except Exception as e:
logger.error(f"Output error: {e}")
logger.info("DSC decoder stopped")
if __name__ == '__main__':
main()

322
utils/dsc/parser.py Normal file
View File

@@ -0,0 +1,322 @@
"""
DSC message parser.
Parses DSC decoder JSON output and provides utility functions for
MMSI country resolution, distress nature text, etc.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime
from typing import Any
from .constants import (
FORMAT_CODES,
DISTRESS_NATURE_CODES,
TELECOMMAND_CODES,
CATEGORY_PRIORITY,
MID_COUNTRY_MAP,
)
logger = logging.getLogger('intercept.dsc.parser')
def get_country_from_mmsi(mmsi: str) -> str | None:
"""
Derive country from MMSI using Maritime Identification Digits (MID).
The first 3 digits of a 9-digit MMSI identify the country.
Args:
mmsi: The MMSI number as string
Returns:
Country name if found, None otherwise
"""
if not mmsi or len(mmsi) < 3:
return None
# Normal ship MMSI: starts with MID (3 digits)
mid = mmsi[:3]
if mid in MID_COUNTRY_MAP:
return MID_COUNTRY_MAP[mid]
# Coast station MMSI: starts with 00 + MID
if mmsi.startswith('00') and len(mmsi) >= 5:
mid = mmsi[2:5]
if mid in MID_COUNTRY_MAP:
return MID_COUNTRY_MAP[mid]
# Group ship station MMSI: starts with 0 + MID
if mmsi.startswith('0') and len(mmsi) >= 4:
mid = mmsi[1:4]
if mid in MID_COUNTRY_MAP:
return MID_COUNTRY_MAP[mid]
return None
def get_distress_nature_text(code: int | str) -> str:
"""Get human-readable text for distress nature code."""
if isinstance(code, str):
try:
code = int(code)
except ValueError:
return str(code)
return DISTRESS_NATURE_CODES.get(code, f'UNKNOWN ({code})')
def get_format_text(code: int | str) -> str:
"""Get human-readable text for format code."""
if isinstance(code, str):
try:
code = int(code)
except ValueError:
return str(code)
return FORMAT_CODES.get(code, f'UNKNOWN ({code})')
def get_telecommand_text(code: int | str) -> str:
"""Get human-readable text for telecommand code."""
if isinstance(code, str):
try:
code = int(code)
except ValueError:
return str(code)
return TELECOMMAND_CODES.get(code, f'UNKNOWN ({code})')
def get_category_priority(category: str) -> int:
"""Get priority level for a category (lower = higher priority)."""
return CATEGORY_PRIORITY.get(category.upper(), 10)
def parse_dsc_message(raw_line: str) -> dict[str, Any] | None:
"""
Parse DSC decoder JSON output line.
The decoder outputs JSON lines with fields like:
{
"type": "dsc",
"format": 100,
"source_mmsi": "123456789",
"dest_mmsi": "000000000",
"category": "DISTRESS",
"nature": 101,
"position": {"lat": 51.5, "lon": -0.1},
"telecommand1": 100,
"telecommand2": null,
"channel": 16,
"timestamp": "2025-01-15T12:00:00Z",
"raw": "..."
}
Args:
raw_line: Raw JSON line from decoder
Returns:
Parsed message dict or None if parsing fails
"""
if not raw_line or not raw_line.strip():
return None
try:
data = json.loads(raw_line.strip())
except json.JSONDecodeError as e:
logger.debug(f"Failed to parse DSC JSON: {e}")
return None
# Validate required fields
if data.get('type') != 'dsc':
return None
if 'source_mmsi' not in data:
return None
# Build parsed message
msg = {
'type': 'dsc_message',
'source_mmsi': str(data.get('source_mmsi', '')),
'dest_mmsi': str(data.get('dest_mmsi', '')) if data.get('dest_mmsi') else None,
'format_code': data.get('format'),
'format_text': get_format_text(data.get('format', 0)),
'category': data.get('category', 'UNKNOWN').upper(),
'timestamp': data.get('timestamp') or datetime.utcnow().isoformat(),
}
# Add country from MMSI
country = get_country_from_mmsi(msg['source_mmsi'])
if country:
msg['source_country'] = country
# Add distress nature if present
if 'nature' in data and data['nature']:
msg['nature_code'] = data['nature']
msg['nature_of_distress'] = get_distress_nature_text(data['nature'])
# Add position if present
position = data.get('position')
if position and isinstance(position, dict):
lat = position.get('lat')
lon = position.get('lon')
if lat is not None and lon is not None:
try:
msg['latitude'] = float(lat)
msg['longitude'] = float(lon)
except (ValueError, TypeError):
pass
# Add telecommand info
if 'telecommand1' in data and data['telecommand1']:
msg['telecommand1'] = data['telecommand1']
msg['telecommand1_text'] = get_telecommand_text(data['telecommand1'])
if 'telecommand2' in data and data['telecommand2']:
msg['telecommand2'] = data['telecommand2']
msg['telecommand2_text'] = get_telecommand_text(data['telecommand2'])
# Add channel if present
if 'channel' in data and data['channel']:
msg['channel'] = data['channel']
# Add EOS (End of Sequence) info
if 'eos' in data:
msg['eos'] = data['eos']
# Add raw message for debugging
if 'raw' in data:
msg['raw_message'] = data['raw']
# Calculate priority
msg['priority'] = get_category_priority(msg['category'])
# Mark if this is a critical alert
msg['is_critical'] = msg['category'] in ('DISTRESS', 'DISTRESS_ACK', 'DISTRESS_RELAY', 'URGENCY')
return msg
def format_dsc_for_display(msg: dict) -> str:
"""
Format a DSC message for human-readable display.
Args:
msg: Parsed DSC message dict
Returns:
Formatted string for display
"""
lines = []
# Header with category and MMSI
category = msg.get('category', 'UNKNOWN')
mmsi = msg.get('source_mmsi', 'UNKNOWN')
country = msg.get('source_country', '')
header = f"[{category}] MMSI: {mmsi}"
if country:
header += f" ({country})"
lines.append(header)
# Destination if present
if msg.get('dest_mmsi'):
lines.append(f" To: {msg['dest_mmsi']}")
# Distress nature
if msg.get('nature_of_distress'):
lines.append(f" Nature: {msg['nature_of_distress']}")
# Position
if msg.get('latitude') is not None and msg.get('longitude') is not None:
lat = msg['latitude']
lon = msg['longitude']
lat_dir = 'N' if lat >= 0 else 'S'
lon_dir = 'E' if lon >= 0 else 'W'
lines.append(f" Position: {abs(lat):.4f}{lat_dir} {abs(lon):.4f}{lon_dir}")
# Telecommand
if msg.get('telecommand1_text'):
lines.append(f" Request: {msg['telecommand1_text']}")
# Channel
if msg.get('channel'):
lines.append(f" Channel: {msg['channel']}")
# Timestamp
if msg.get('timestamp'):
lines.append(f" Time: {msg['timestamp']}")
return '\n'.join(lines)
def validate_mmsi(mmsi: str) -> bool:
"""
Validate MMSI format.
MMSI is a 9-digit number. Ship stations start with non-zero digit.
Coast stations start with 00. Group stations start with 0.
Args:
mmsi: MMSI string to validate
Returns:
True if valid MMSI format
"""
if not mmsi:
return False
# Must be 9 digits
if not re.match(r'^\d{9}$', mmsi):
return False
# All zeros is invalid
if mmsi == '000000000':
return False
return True
def classify_mmsi(mmsi: str) -> str:
"""
Classify MMSI type.
Args:
mmsi: MMSI string
Returns:
Classification: 'ship', 'coast', 'group', 'sar', 'aton', or 'unknown'
"""
if not validate_mmsi(mmsi):
return 'unknown'
first_digit = mmsi[0]
first_two = mmsi[:2]
first_three = mmsi[:3]
# Coast station: starts with 00
if first_two == '00':
return 'coast'
# Group call: starts with 0
if first_digit == '0':
return 'group'
# SAR aircraft: starts with 111
if first_three == '111':
return 'sar'
# Aids to Navigation: starts with 99
if first_two == '99':
return 'aton'
# Ship station: starts with MID (2-7)
if first_digit in '234567':
return 'ship'
return 'unknown'