diff --git a/app.py b/app.py index 3b88474..032bac2 100644 --- a/app.py +++ b/app.py @@ -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}) diff --git a/bin/dsc-decoder b/bin/dsc-decoder new file mode 100755 index 0000000..f207b4b --- /dev/null +++ b/bin/dsc-decoder @@ -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 "$@" diff --git a/routes/__init__.py b/routes/__init__.py index cbdb1ee..894d16e 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -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) diff --git a/routes/dsc.py b/routes/dsc.py new file mode 100644 index 0000000..be59288 --- /dev/null +++ b/routes/dsc.py @@ -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/') +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//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) diff --git a/static/css/ais_dashboard.css b/static/css/ais_dashboard.css index cf17513..068350c 100644 --- a/static/css/ais_dashboard.css +++ b/static/css/ais_dashboard.css @@ -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; + } +} diff --git a/templates/ais_dashboard.html b/templates/ais_dashboard.html index 1927efc..63014ea 100644 --- a/templates/ais_dashboard.html +++ b/templates/ais_dashboard.html @@ -96,6 +96,23 @@ + +
+
+ VHF DSC MESSAGES +
+
+
+ 0 DISTRESS + 0 URGENCY +
+
+
+
No DSC messages
+
Start VHF DSC to monitor
+
+
+
@@ -131,6 +148,17 @@
+ +
+ VHF DSC +
+ + + +
+
@@ -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 = ''; + aisSelect.innerHTML = ''; } 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 = ''; + } 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 = ` +
${data.category}
+
MMSI: ${data.source_mmsi}
+ ${data.source_country ? `
${data.source_country}
` : ''} + ${data.nature_of_distress ? `
${data.nature_of_distress}
` : ''} + ${data.latitude ? `
${data.latitude.toFixed(4)}, ${data.longitude.toFixed(4)}
` : ''} + + `; + 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: `
+ +
`, + iconSize: [28, 28], + iconAnchor: [14, 14] + }); + + dscMarkers[mmsi] = L.marker([data.latitude, data.longitude], { icon }) + .addTo(vesselMap) + .bindPopup(` + ${data.category}
+ MMSI: ${mmsi}
+ ${data.source_country ? `Country: ${data.source_country}
` : ''} + ${data.nature_of_distress ? `Nature: ${data.nature_of_distress}
` : ''} + 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 = ` +
+
No DSC messages
+
Start VHF DSC to monitor
+
+ `; + 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 ` +
+
+ ${msg.category} + ${formatDscTime(msg.timestamp)} +
+
MMSI: ${msg.source_mmsi}
+ ${msg.source_country ? `
${msg.source_country}
` : ''} + ${msg.nature_of_distress ? `
${msg.nature_of_distress}
` : ''} + ${msg.latitude ? `
${msg.latitude.toFixed(4)}, ${msg.longitude.toFixed(4)}
` : ''} +
+ `; + }).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); diff --git a/utils/constants.py b/utils/constants.py index 04690a8..43cf29d 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -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 diff --git a/utils/database.py b/utils/database.py index cdfe369..7b56d3d 100644 --- a/utils/database.py +++ b/utils/database.py @@ -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 diff --git a/utils/dsc/__init__.py b/utils/dsc/__init__.py new file mode 100644 index 0000000..ba17e08 --- /dev/null +++ b/utils/dsc/__init__.py @@ -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', +] diff --git a/utils/dsc/constants.py b/utils/dsc/constants.py new file mode 100644 index 0000000..44d7ba2 --- /dev/null +++ b/utils/dsc/constants.py @@ -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 diff --git a/utils/dsc/decoder.py b/utils/dsc/decoder.py new file mode 100644 index 0000000..5bec42a --- /dev/null +++ b/utils/dsc/decoder.py @@ -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() diff --git a/utils/dsc/parser.py b/utils/dsc/parser.py new file mode 100644 index 0000000..a8b6d25 --- /dev/null +++ b/utils/dsc/parser.py @@ -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'