diff --git a/CHANGELOG.md b/CHANGELOG.md
index b03492d..74c07be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,7 @@
All notable changes to iNTERCEPT will be documented in this file.
-## [2.10.0] - 2026-01-24
+## [2.10.0] - 2026-01-25
### Added
- **AIS Vessel Tracking** - Real-time ship tracking via AIS-catcher
@@ -11,17 +11,30 @@ All notable changes to iNTERCEPT will be documented in this file.
- Navigation data: speed, course, heading, rate of turn
- Ship type classification and dimensions
- Multi-SDR support (RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay)
+- **VHF DSC Channel 70 Monitoring** - Digital Selective Calling for maritime distress
+ - Real-time decoding of DSC messages (Distress, Urgency, Safety, Routine)
+ - MMSI country identification via Maritime Identification Digits (MID) lookup
+ - Position extraction and map markers for distress alerts
+ - Prominent visual overlay for DISTRESS and URGENCY alerts
+ - Permanent database storage for critical alerts with acknowledgement workflow
- **Spy Stations Database** - Number stations and diplomatic HF networks
- Comprehensive database from priyom.org
- Station profiles with frequencies, schedules, operators
- Filter by type (number/diplomatic), country, and mode
- Tune integration with Listening Post
- Famous stations: UVB-76, Cuban HM01, Israeli E17z
+- **SDR Device Conflict Detection** - Prevents collisions between AIS and DSC
+- **DSC Alert Summary** - Dashboard counts for unacknowledged distress/urgency alerts
- **AIS-catcher Installation** - Added to setup.sh for Debian and macOS
### Changed
- **UI Labels** - Renamed "Scanner" to "Listening Post" and "RTLAMR" to "Meters"
- **Pager Filter** - Changed from onchange to oninput for real-time filtering
+- **Vessels Dashboard** - Now includes VHF DSC message panel alongside AIS tracking
+- **Dependencies** - Added scipy and numpy for DSC signal processing
+
+### Fixed
+- **DSC Position Decoder** - Corrected octal literal in quadrant check
---
diff --git a/README.md b/README.md
index b498020..7e0283f 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ Support the developer of this open-source project
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
-- **Vessel Tracking** - AIS ship tracking via AIS-catcher with maritime map
+- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
- **Listening Post** - Frequency scanner with audio monitoring
- **Satellite Tracking** - Pass prediction using TLE data
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/config.py b/config.py
index b990dda..6f60429 100644
--- a/config.py
+++ b/config.py
@@ -15,10 +15,10 @@ CHANGELOG = [
"version": "2.10.0",
"date": "January 2026",
"highlights": [
- "AIS vessel tracking with real-time maritime map",
+ "AIS vessel tracking with VHF DSC distress monitoring",
"Spy Stations database (number stations & diplomatic HF)",
- "Multi-SDR support for AIS (RTL-SDR, HackRF, LimeSDR, etc.)",
- "UI improvements: renamed modes for clarity",
+ "MMSI country identification and distress alert overlays",
+ "SDR device conflict detection for AIS/DSC",
]
},
{
diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index f39c41c..99f216a 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -57,6 +57,31 @@ Complete feature list for all modules.
+## AIS Vessel Tracking
+
+- **Real-time vessel tracking** via AIS-catcher or rtl_ais
+- **Full-screen dashboard** - dedicated popout with maritime map
+- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
+- **Vessel trails** - optional track history visualization
+- **Vessel details popup** - name, MMSI, callsign, destination, ship type, speed, heading
+- **Country identification** - flag lookup via Maritime Identification Digits (MID)
+
+### VHF DSC Channel 70 Monitoring
+
+Digital Selective Calling (DSC) monitoring on the international maritime distress frequency.
+
+- **Real-time DSC decoding** - Distress, Urgency, Safety, and Routine messages
+- **MMSI country lookup** - 180+ Maritime Identification Digit codes
+- **Distress nature identification** - Fire, Flooding, Collision, Sinking, Piracy, MOB, etc.
+- **Position extraction** - Automatic lat/lon parsing from distress messages
+- **Map markers** - Distress positions plotted with pulsing alert markers
+- **Visual alert overlay** - Prominent popup for DISTRESS and URGENCY messages
+- **Audio alerts** - Notification sound for critical messages
+- **Alert persistence** - Critical alerts stored permanently in database
+- **Acknowledgement workflow** - Track response status with notes
+- **SDR conflict detection** - Prevents device collisions with AIS tracking
+- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
+
## Satellite Tracking
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
diff --git a/pyproject.toml b/pyproject.toml
index 479ed47..4c21a50 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "intercept"
-version = "2.9.5"
+version = "2.10.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
diff --git a/requirements.txt b/requirements.txt
index 0fe5775..6b42e9f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,6 +10,10 @@ bleak>=0.21.0
# Satellite tracking (optional - only needed for satellite features)
skyfield>=1.45
+# DSC decoding (optional - only needed for VHF DSC maritime distress)
+scipy>=1.10.0
+numpy>=1.24.0
+
# GPS dongle support (optional - only needed for USB GPS receivers)
pyserial>=3.5
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 @@
+
+
+
+
+ 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 = `
+
+ 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 `
+
+
+
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/tests/test_dsc.py b/tests/test_dsc.py
new file mode 100644
index 0000000..cb74018
--- /dev/null
+++ b/tests/test_dsc.py
@@ -0,0 +1,467 @@
+"""Tests for DSC (Digital Selective Calling) utilities."""
+
+import json
+import pytest
+from datetime import datetime
+
+
+class TestDSCParser:
+ """Tests for DSC parser utilities."""
+
+ def test_get_country_from_mmsi_ship_station(self):
+ """Test country lookup for standard ship MMSI."""
+ from utils.dsc.parser import get_country_from_mmsi
+
+ # UK ships start with 232-235
+ assert get_country_from_mmsi('232123456') == 'United Kingdom'
+ assert get_country_from_mmsi('235987654') == 'United Kingdom'
+
+ # US ships start with 303, 338, 366-369
+ assert get_country_from_mmsi('366123456') == 'USA'
+ assert get_country_from_mmsi('369000001') == 'USA'
+
+ # Panama (common flag of convenience)
+ assert get_country_from_mmsi('351234567') == 'Panama'
+ assert get_country_from_mmsi('370000001') == 'Panama'
+
+ # Norway
+ assert get_country_from_mmsi('257123456') == 'Norway'
+
+ # Germany
+ assert get_country_from_mmsi('211000001') == 'Germany'
+
+ def test_get_country_from_mmsi_coast_station(self):
+ """Test country lookup for coast station MMSI (starts with 00)."""
+ from utils.dsc.parser import get_country_from_mmsi
+
+ # Coast stations: 00 + MID
+ assert get_country_from_mmsi('002320001') == 'United Kingdom'
+ assert get_country_from_mmsi('003660001') == 'USA'
+
+ def test_get_country_from_mmsi_group_station(self):
+ """Test country lookup for group station MMSI (starts with 0)."""
+ from utils.dsc.parser import get_country_from_mmsi
+
+ # Group call: 0 + MID
+ assert get_country_from_mmsi('023200001') == 'United Kingdom'
+ assert get_country_from_mmsi('036600001') == 'USA'
+
+ def test_get_country_from_mmsi_unknown(self):
+ """Test country lookup returns None for unknown MID."""
+ from utils.dsc.parser import get_country_from_mmsi
+
+ assert get_country_from_mmsi('999999999') is None
+ assert get_country_from_mmsi('100000000') is None
+
+ def test_get_country_from_mmsi_invalid(self):
+ """Test country lookup handles invalid input."""
+ from utils.dsc.parser import get_country_from_mmsi
+
+ assert get_country_from_mmsi('') is None
+ assert get_country_from_mmsi(None) is None
+ assert get_country_from_mmsi('12') is None
+
+ def test_get_distress_nature_text(self):
+ """Test distress nature code to text conversion."""
+ from utils.dsc.parser import get_distress_nature_text
+
+ assert get_distress_nature_text(100) == 'UNDESIGNATED'
+ assert get_distress_nature_text(101) == 'FIRE'
+ assert get_distress_nature_text(102) == 'FLOODING'
+ assert get_distress_nature_text(103) == 'COLLISION'
+ assert get_distress_nature_text(106) == 'SINKING'
+ assert get_distress_nature_text(109) == 'PIRACY'
+ assert get_distress_nature_text(110) == 'MOB' # Man overboard
+
+ def test_get_distress_nature_text_unknown(self):
+ """Test distress nature returns formatted unknown for invalid codes."""
+ from utils.dsc.parser import get_distress_nature_text
+
+ assert 'UNKNOWN' in get_distress_nature_text(999)
+ assert '999' in get_distress_nature_text(999)
+
+ def test_get_distress_nature_text_string_input(self):
+ """Test distress nature accepts string input."""
+ from utils.dsc.parser import get_distress_nature_text
+
+ assert get_distress_nature_text('101') == 'FIRE'
+ assert get_distress_nature_text('invalid') == 'invalid'
+
+ def test_get_format_text(self):
+ """Test format code to text conversion."""
+ from utils.dsc.parser import get_format_text
+
+ assert get_format_text(100) == 'DISTRESS'
+ assert get_format_text(102) == 'ALL_SHIPS'
+ assert get_format_text(106) == 'DISTRESS_ACK'
+ assert get_format_text(108) == 'DISTRESS_RELAY'
+ assert get_format_text(112) == 'INDIVIDUAL'
+ assert get_format_text(116) == 'ROUTINE'
+ assert get_format_text(118) == 'SAFETY'
+ assert get_format_text(120) == 'URGENCY'
+
+ def test_get_format_text_unknown(self):
+ """Test format code returns unknown for invalid codes."""
+ from utils.dsc.parser import get_format_text
+
+ result = get_format_text(999)
+ assert 'UNKNOWN' in result
+
+ def test_get_telecommand_text(self):
+ """Test telecommand code to text conversion."""
+ from utils.dsc.parser import get_telecommand_text
+
+ assert get_telecommand_text(100) == 'F3E_G3E_ALL'
+ assert get_telecommand_text(105) == 'DATA'
+ assert get_telecommand_text(107) == 'DISTRESS_ACK'
+ assert get_telecommand_text(111) == 'TEST'
+
+ def test_get_category_priority(self):
+ """Test category priority values."""
+ from utils.dsc.parser import get_category_priority
+
+ # Distress has highest priority (0)
+ assert get_category_priority('DISTRESS') == 0
+ assert get_category_priority('distress') == 0
+
+ # Urgency is lower
+ assert get_category_priority('URGENCY') == 3
+
+ # Safety is lower still
+ assert get_category_priority('SAFETY') == 4
+
+ # Routine is lowest
+ assert get_category_priority('ROUTINE') == 5
+
+ # Unknown gets default high number
+ assert get_category_priority('UNKNOWN') == 10
+
+ def test_validate_mmsi_valid(self):
+ """Test MMSI validation with valid numbers."""
+ from utils.dsc.parser import validate_mmsi
+
+ assert validate_mmsi('232123456') is True
+ assert validate_mmsi('366000001') is True
+ assert validate_mmsi('002320001') is True # Coast station
+ assert validate_mmsi('023200001') is True # Group station
+
+ def test_validate_mmsi_invalid(self):
+ """Test MMSI validation rejects invalid numbers."""
+ from utils.dsc.parser import validate_mmsi
+
+ assert validate_mmsi('') is False
+ assert validate_mmsi(None) is False
+ assert validate_mmsi('12345678') is False # Too short
+ assert validate_mmsi('1234567890') is False # Too long
+ assert validate_mmsi('abcdefghi') is False # Not digits
+ assert validate_mmsi('000000000') is False # All zeros
+
+ def test_classify_mmsi(self):
+ """Test MMSI classification."""
+ from utils.dsc.parser import classify_mmsi
+
+ # Ship stations (start with 2-7)
+ assert classify_mmsi('232123456') == 'ship'
+ assert classify_mmsi('366000001') == 'ship'
+ assert classify_mmsi('503000001') == 'ship'
+
+ # Coast stations (start with 00)
+ assert classify_mmsi('002320001') == 'coast'
+
+ # Group stations (start with 0, not 00)
+ assert classify_mmsi('023200001') == 'group'
+
+ # SAR aircraft (start with 111)
+ assert classify_mmsi('111232001') == 'sar'
+
+ # Aids to Navigation (start with 99)
+ assert classify_mmsi('992321001') == 'aton'
+
+ # Unknown
+ assert classify_mmsi('invalid') == 'unknown'
+ assert classify_mmsi('812345678') == 'unknown'
+
+ def test_parse_dsc_message_distress(self):
+ """Test parsing a distress message."""
+ from utils.dsc.parser import parse_dsc_message
+
+ raw = json.dumps({
+ 'type': 'dsc',
+ 'format': 100,
+ 'source_mmsi': '232123456',
+ 'dest_mmsi': '000000000',
+ 'category': 'DISTRESS',
+ 'nature': 101,
+ 'position': {'lat': 51.5, 'lon': -0.1},
+ 'telecommand1': 100,
+ 'timestamp': '2025-01-15T12:00:00Z'
+ })
+
+ msg = parse_dsc_message(raw)
+
+ assert msg is not None
+ assert msg['type'] == 'dsc_message'
+ assert msg['source_mmsi'] == '232123456'
+ assert msg['category'] == 'DISTRESS'
+ assert msg['source_country'] == 'United Kingdom'
+ assert msg['nature_of_distress'] == 'FIRE'
+ assert msg['latitude'] == 51.5
+ assert msg['longitude'] == -0.1
+ assert msg['is_critical'] is True
+ assert msg['priority'] == 0
+
+ def test_parse_dsc_message_routine(self):
+ """Test parsing a routine message."""
+ from utils.dsc.parser import parse_dsc_message
+
+ raw = json.dumps({
+ 'type': 'dsc',
+ 'format': 116,
+ 'source_mmsi': '366000001',
+ 'category': 'ROUTINE',
+ 'timestamp': '2025-01-15T12:00:00Z'
+ })
+
+ msg = parse_dsc_message(raw)
+
+ assert msg is not None
+ assert msg['category'] == 'ROUTINE'
+ assert msg['source_country'] == 'USA'
+ assert msg['is_critical'] is False
+ assert msg['priority'] == 5
+
+ def test_parse_dsc_message_invalid_json(self):
+ """Test parsing rejects invalid JSON."""
+ from utils.dsc.parser import parse_dsc_message
+
+ assert parse_dsc_message('not json') is None
+ assert parse_dsc_message('{invalid}') is None
+
+ def test_parse_dsc_message_missing_type(self):
+ """Test parsing rejects messages without correct type."""
+ from utils.dsc.parser import parse_dsc_message
+
+ raw = json.dumps({'source_mmsi': '232123456'})
+ assert parse_dsc_message(raw) is None
+
+ raw = json.dumps({'type': 'other', 'source_mmsi': '232123456'})
+ assert parse_dsc_message(raw) is None
+
+ def test_parse_dsc_message_missing_mmsi(self):
+ """Test parsing rejects messages without source MMSI."""
+ from utils.dsc.parser import parse_dsc_message
+
+ raw = json.dumps({'type': 'dsc'})
+ assert parse_dsc_message(raw) is None
+
+ def test_parse_dsc_message_empty(self):
+ """Test parsing handles empty input."""
+ from utils.dsc.parser import parse_dsc_message
+
+ assert parse_dsc_message('') is None
+ assert parse_dsc_message(None) is None
+ assert parse_dsc_message(' ') is None
+
+ def test_format_dsc_for_display(self):
+ """Test message formatting for display."""
+ from utils.dsc.parser import format_dsc_for_display
+
+ msg = {
+ 'category': 'DISTRESS',
+ 'source_mmsi': '232123456',
+ 'source_country': 'United Kingdom',
+ 'dest_mmsi': '002320001',
+ 'nature_of_distress': 'FIRE',
+ 'latitude': 51.5074,
+ 'longitude': -0.1278,
+ 'telecommand1_text': 'F3E_G3E_ALL',
+ 'channel': 16,
+ 'timestamp': '2025-01-15T12:00:00Z'
+ }
+
+ output = format_dsc_for_display(msg)
+
+ assert 'DISTRESS' in output
+ assert '232123456' in output
+ assert 'United Kingdom' in output
+ assert 'FIRE' in output
+ assert '51.5074' in output
+ assert 'Channel: 16' in output
+
+
+class TestDSCDecoder:
+ """Tests for DSC decoder utilities."""
+
+ @pytest.fixture
+ def decoder(self):
+ """Create a DSC decoder instance."""
+ # Skip if scipy not available
+ pytest.importorskip('scipy')
+ pytest.importorskip('numpy')
+ from utils.dsc.decoder import DSCDecoder
+ return DSCDecoder()
+
+ def test_decode_mmsi_valid(self, decoder):
+ """Test MMSI decoding from symbols."""
+ # Each symbol is 2 BCD digits
+ # To encode MMSI 232123456, we need:
+ # 02-32-12-34-56 -> symbols [2, 32, 12, 34, 56]
+ symbols = [2, 32, 12, 34, 56]
+ result = decoder._decode_mmsi(symbols)
+ assert result == '232123456'
+
+ def test_decode_mmsi_with_leading_zeros(self, decoder):
+ """Test MMSI decoding handles leading zeros."""
+ # Coast station: 002320001
+ # 00-23-20-00-01 -> [0, 23, 20, 0, 1]
+ symbols = [0, 23, 20, 0, 1]
+ result = decoder._decode_mmsi(symbols)
+ assert result == '002320001'
+
+ def test_decode_mmsi_short_symbols(self, decoder):
+ """Test MMSI decoding handles short symbol list."""
+ result = decoder._decode_mmsi([1, 2, 3])
+ assert result == '000000000'
+
+ def test_decode_mmsi_invalid_symbols(self, decoder):
+ """Test MMSI decoding handles invalid symbol values."""
+ # Symbols > 99 should be treated as 0
+ symbols = [100, 32, 12, 34, 56]
+ result = decoder._decode_mmsi(symbols)
+ # First symbol becomes 00
+ assert result == '003212345'[-9:]
+
+ def test_decode_position_northeast(self, decoder):
+ """Test position decoding for NE quadrant."""
+ # Quadrant 10 = NE (lat+, lon+)
+ # Position: 51°30'N, 0°10'E
+ symbols = [10, 51, 30, 0, 10, 0, 0, 0, 0, 0]
+ result = decoder._decode_position(symbols)
+
+ assert result is not None
+ assert result['lat'] == pytest.approx(51.5, rel=0.01)
+ assert result['lon'] == pytest.approx(0.1667, rel=0.01)
+
+ def test_decode_position_northwest(self, decoder):
+ """Test position decoding for NW quadrant."""
+ # Quadrant 11 = NW (lat+, lon-)
+ # Position: 40°42'N, 74°00'W (NYC area)
+ symbols = [11, 40, 42, 0, 74, 0, 0, 0, 0, 0]
+ result = decoder._decode_position(symbols)
+
+ assert result is not None
+ assert result['lat'] > 0 # North
+ assert result['lon'] < 0 # West
+
+ def test_decode_position_southeast(self, decoder):
+ """Test position decoding for SE quadrant."""
+ # Quadrant 0 = SE (lat-, lon+)
+ symbols = [0, 33, 51, 1, 51, 12, 0, 0, 0, 0]
+ result = decoder._decode_position(symbols)
+
+ assert result is not None
+ assert result['lat'] < 0 # South
+ assert result['lon'] > 0 # East
+
+ def test_decode_position_short_symbols(self, decoder):
+ """Test position decoding handles short symbol list."""
+ result = decoder._decode_position([10, 51, 30])
+ assert result is None
+
+ def test_decode_position_invalid_values(self, decoder):
+ """Test position decoding handles invalid values gracefully."""
+ # Latitude > 90 should be treated as 0
+ symbols = [10, 95, 30, 0, 10, 0, 0, 0, 0, 0]
+ result = decoder._decode_position(symbols)
+ assert result is not None
+ assert result['lat'] == pytest.approx(0.5, rel=0.01) # 0 deg + 30 min
+
+ def test_bits_to_symbol(self, decoder):
+ """Test bit to symbol conversion."""
+ # Symbol value is first 7 bits (LSB first)
+ # Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1, x,x,x]
+ bits = [0, 0, 1, 0, 0, 1, 1, 0, 0, 0]
+ result = decoder._bits_to_symbol(bits)
+ assert result == 100
+
+ def test_bits_to_symbol_wrong_length(self, decoder):
+ """Test bit to symbol returns -1 for wrong length."""
+ result = decoder._bits_to_symbol([0, 1, 0, 1, 0])
+ assert result == -1
+
+ def test_detect_dot_pattern(self, decoder):
+ """Test dot pattern detection."""
+ # Dot pattern is alternating 1010101...
+ decoder.bit_buffer = [1, 0] * 25 # 50 alternating bits
+ assert decoder._detect_dot_pattern() is True
+
+ def test_detect_dot_pattern_insufficient(self, decoder):
+ """Test dot pattern not detected with insufficient alternations."""
+ decoder.bit_buffer = [1, 0] * 5 # Only 10 bits
+ assert decoder._detect_dot_pattern() is False
+
+ def test_detect_dot_pattern_not_alternating(self, decoder):
+ """Test dot pattern not detected without alternation."""
+ decoder.bit_buffer = [1, 1, 1, 1, 0, 0, 0, 0] * 5
+ assert decoder._detect_dot_pattern() is False
+
+
+class TestDSCConstants:
+ """Tests for DSC constants."""
+
+ def test_format_codes_completeness(self):
+ """Test that all standard format codes are defined."""
+ from utils.dsc.constants import FORMAT_CODES
+
+ # ITU-R M.493 format codes
+ assert 100 in FORMAT_CODES # DISTRESS
+ assert 102 in FORMAT_CODES # ALL_SHIPS
+ assert 106 in FORMAT_CODES # DISTRESS_ACK
+ assert 112 in FORMAT_CODES # INDIVIDUAL
+ assert 116 in FORMAT_CODES # ROUTINE
+ assert 118 in FORMAT_CODES # SAFETY
+ assert 120 in FORMAT_CODES # URGENCY
+
+ def test_distress_nature_codes_completeness(self):
+ """Test that all distress nature codes are defined."""
+ from utils.dsc.constants import DISTRESS_NATURE_CODES
+
+ # ITU-R M.493 distress nature codes
+ assert 100 in DISTRESS_NATURE_CODES # UNDESIGNATED
+ assert 101 in DISTRESS_NATURE_CODES # FIRE
+ assert 102 in DISTRESS_NATURE_CODES # FLOODING
+ assert 103 in DISTRESS_NATURE_CODES # COLLISION
+ assert 106 in DISTRESS_NATURE_CODES # SINKING
+ assert 109 in DISTRESS_NATURE_CODES # PIRACY
+ assert 110 in DISTRESS_NATURE_CODES # MOB
+
+ def test_mid_country_map_completeness(self):
+ """Test that common MID codes are defined."""
+ from utils.dsc.constants import MID_COUNTRY_MAP
+
+ # Verify some key maritime nations
+ assert '232' in MID_COUNTRY_MAP # UK
+ assert '366' in MID_COUNTRY_MAP # USA
+ assert '351' in MID_COUNTRY_MAP # Panama
+ assert '257' in MID_COUNTRY_MAP # Norway
+ assert '211' in MID_COUNTRY_MAP # Germany
+ assert '503' in MID_COUNTRY_MAP # Australia
+ assert '431' in MID_COUNTRY_MAP # Japan
+
+ def test_vhf_channel_70_frequency(self):
+ """Test DSC Channel 70 frequency constant."""
+ from utils.dsc.constants import VHF_CHANNELS
+
+ assert VHF_CHANNELS[70] == 156.525
+
+ def test_dsc_modulation_parameters(self):
+ """Test DSC modulation constants."""
+ from utils.dsc.constants import (
+ DSC_BAUD_RATE,
+ DSC_MARK_FREQ,
+ DSC_SPACE_FREQ,
+ )
+
+ assert DSC_BAUD_RATE == 100
+ assert DSC_MARK_FREQ == 1800
+ assert DSC_SPACE_FREQ == 1200
diff --git a/tests/test_dsc_database.py b/tests/test_dsc_database.py
new file mode 100644
index 0000000..f58aab0
--- /dev/null
+++ b/tests/test_dsc_database.py
@@ -0,0 +1,422 @@
+"""Tests for DSC database operations."""
+
+import tempfile
+import pytest
+from pathlib import Path
+from unittest.mock import patch
+
+
+@pytest.fixture(autouse=True)
+def temp_db():
+ """Use a temporary database for each test."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ test_db_path = Path(tmpdir) / 'test_intercept.db'
+ test_db_dir = Path(tmpdir)
+
+ with patch('utils.database.DB_PATH', test_db_path), \
+ patch('utils.database.DB_DIR', test_db_dir):
+ from utils.database import init_db, close_db
+
+ init_db()
+ yield test_db_path
+ close_db()
+
+
+class TestDSCAlertsCRUD:
+ """Tests for DSC alerts CRUD operations."""
+
+ def test_store_and_get_dsc_alert(self, temp_db):
+ """Test storing and retrieving a DSC alert."""
+ from utils.database import store_dsc_alert, get_dsc_alert
+
+ alert_id = store_dsc_alert(
+ source_mmsi='232123456',
+ format_code='100',
+ category='DISTRESS',
+ source_name='MV Test Ship',
+ nature_of_distress='FIRE',
+ latitude=51.5,
+ longitude=-0.1
+ )
+
+ assert alert_id is not None
+ assert alert_id > 0
+
+ alert = get_dsc_alert(alert_id)
+
+ assert alert is not None
+ assert alert['source_mmsi'] == '232123456'
+ assert alert['format_code'] == '100'
+ assert alert['category'] == 'DISTRESS'
+ assert alert['source_name'] == 'MV Test Ship'
+ assert alert['nature_of_distress'] == 'FIRE'
+ assert alert['latitude'] == 51.5
+ assert alert['longitude'] == -0.1
+ assert alert['acknowledged'] is False
+
+ def test_store_minimal_alert(self, temp_db):
+ """Test storing alert with only required fields."""
+ from utils.database import store_dsc_alert, get_dsc_alert
+
+ alert_id = store_dsc_alert(
+ source_mmsi='366000001',
+ format_code='116',
+ category='ROUTINE'
+ )
+
+ alert = get_dsc_alert(alert_id)
+
+ assert alert is not None
+ assert alert['source_mmsi'] == '366000001'
+ assert alert['category'] == 'ROUTINE'
+ assert alert['latitude'] is None
+ assert alert['longitude'] is None
+
+ def test_get_nonexistent_alert(self, temp_db):
+ """Test getting an alert that doesn't exist."""
+ from utils.database import get_dsc_alert
+
+ alert = get_dsc_alert(99999)
+ assert alert is None
+
+ def test_get_dsc_alerts_all(self, temp_db):
+ """Test getting all alerts."""
+ from utils.database import store_dsc_alert, get_dsc_alerts
+
+ store_dsc_alert('232123456', '100', 'DISTRESS')
+ store_dsc_alert('366000001', '120', 'URGENCY')
+ store_dsc_alert('351234567', '116', 'ROUTINE')
+
+ alerts = get_dsc_alerts()
+
+ assert len(alerts) == 3
+
+ def test_get_dsc_alerts_by_category(self, temp_db):
+ """Test filtering alerts by category."""
+ from utils.database import store_dsc_alert, get_dsc_alerts
+
+ store_dsc_alert('232123456', '100', 'DISTRESS')
+ store_dsc_alert('232123457', '100', 'DISTRESS')
+ store_dsc_alert('366000001', '120', 'URGENCY')
+ store_dsc_alert('351234567', '116', 'ROUTINE')
+
+ distress_alerts = get_dsc_alerts(category='DISTRESS')
+ urgency_alerts = get_dsc_alerts(category='URGENCY')
+
+ assert len(distress_alerts) == 2
+ assert len(urgency_alerts) == 1
+
+ def test_get_dsc_alerts_by_acknowledged(self, temp_db):
+ """Test filtering alerts by acknowledgement status."""
+ from utils.database import (
+ store_dsc_alert,
+ get_dsc_alerts,
+ acknowledge_dsc_alert
+ )
+
+ id1 = store_dsc_alert('232123456', '100', 'DISTRESS')
+ id2 = store_dsc_alert('366000001', '100', 'DISTRESS')
+ store_dsc_alert('351234567', '100', 'DISTRESS')
+
+ acknowledge_dsc_alert(id1)
+ acknowledge_dsc_alert(id2)
+
+ unacked = get_dsc_alerts(acknowledged=False)
+ acked = get_dsc_alerts(acknowledged=True)
+
+ assert len(unacked) == 1
+ assert len(acked) == 2
+
+ def test_get_dsc_alerts_by_mmsi(self, temp_db):
+ """Test filtering alerts by source MMSI."""
+ from utils.database import store_dsc_alert, get_dsc_alerts
+
+ store_dsc_alert('232123456', '100', 'DISTRESS')
+ store_dsc_alert('232123456', '120', 'URGENCY')
+ store_dsc_alert('366000001', '100', 'DISTRESS')
+
+ alerts = get_dsc_alerts(source_mmsi='232123456')
+
+ assert len(alerts) == 2
+ for alert in alerts:
+ assert alert['source_mmsi'] == '232123456'
+
+ def test_get_dsc_alerts_pagination(self, temp_db):
+ """Test alert pagination."""
+ from utils.database import store_dsc_alert, get_dsc_alerts
+
+ # Create 10 alerts
+ for i in range(10):
+ store_dsc_alert(f'23212345{i}', '100', 'DISTRESS')
+
+ # Get first page
+ page1 = get_dsc_alerts(limit=5, offset=0)
+ assert len(page1) == 5
+
+ # Get second page
+ page2 = get_dsc_alerts(limit=5, offset=5)
+ assert len(page2) == 5
+
+ # Ensure no overlap
+ page1_ids = {a['id'] for a in page1}
+ page2_ids = {a['id'] for a in page2}
+ assert page1_ids.isdisjoint(page2_ids)
+
+ def test_get_dsc_alerts_order(self, temp_db):
+ """Test alerts are returned in reverse chronological order."""
+ from utils.database import store_dsc_alert, get_dsc_alerts
+
+ id1 = store_dsc_alert('232123456', '100', 'DISTRESS')
+ id2 = store_dsc_alert('366000001', '100', 'DISTRESS')
+ id3 = store_dsc_alert('351234567', '100', 'DISTRESS')
+
+ alerts = get_dsc_alerts()
+
+ # ORDER BY received_at DESC, so most recent first
+ # When timestamps are identical, higher IDs are more recent
+ # The actual order depends on the DB implementation
+ # We just verify all 3 are present and it's a list
+ assert len(alerts) == 3
+ alert_ids = {a['id'] for a in alerts}
+ assert alert_ids == {id1, id2, id3}
+
+ def test_acknowledge_dsc_alert(self, temp_db):
+ """Test acknowledging a DSC alert."""
+ from utils.database import (
+ store_dsc_alert,
+ get_dsc_alert,
+ acknowledge_dsc_alert
+ )
+
+ alert_id = store_dsc_alert('232123456', '100', 'DISTRESS')
+
+ # Initially not acknowledged
+ alert = get_dsc_alert(alert_id)
+ assert alert['acknowledged'] is False
+
+ # Acknowledge it
+ result = acknowledge_dsc_alert(alert_id)
+ assert result is True
+
+ # Now acknowledged
+ alert = get_dsc_alert(alert_id)
+ assert alert['acknowledged'] is True
+
+ def test_acknowledge_dsc_alert_with_notes(self, temp_db):
+ """Test acknowledging with notes."""
+ from utils.database import (
+ store_dsc_alert,
+ get_dsc_alert,
+ acknowledge_dsc_alert
+ )
+
+ alert_id = store_dsc_alert('232123456', '100', 'DISTRESS')
+
+ acknowledge_dsc_alert(alert_id, notes='Vessel located, rescue underway')
+
+ alert = get_dsc_alert(alert_id)
+ assert alert['acknowledged'] is True
+ assert alert['notes'] == 'Vessel located, rescue underway'
+
+ def test_acknowledge_nonexistent_alert(self, temp_db):
+ """Test acknowledging an alert that doesn't exist."""
+ from utils.database import acknowledge_dsc_alert
+
+ result = acknowledge_dsc_alert(99999)
+ assert result is False
+
+ def test_get_dsc_alert_summary(self, temp_db):
+ """Test getting alert summary counts."""
+ from utils.database import (
+ store_dsc_alert,
+ get_dsc_alert_summary,
+ acknowledge_dsc_alert
+ )
+
+ # Create various alerts
+ store_dsc_alert('232123456', '100', 'DISTRESS')
+ store_dsc_alert('232123457', '100', 'DISTRESS')
+ store_dsc_alert('366000001', '120', 'URGENCY')
+ store_dsc_alert('351234567', '118', 'SAFETY')
+ acked_id = store_dsc_alert('257000001', '100', 'DISTRESS')
+
+ # Acknowledge one distress
+ acknowledge_dsc_alert(acked_id)
+
+ summary = get_dsc_alert_summary()
+
+ assert summary['distress'] == 2 # 3 - 1 acknowledged
+ assert summary['urgency'] == 1
+ assert summary['safety'] == 1
+ assert summary['total'] == 4
+
+ def test_get_dsc_alert_summary_empty(self, temp_db):
+ """Test alert summary with no alerts."""
+ from utils.database import get_dsc_alert_summary
+
+ summary = get_dsc_alert_summary()
+
+ assert summary['distress'] == 0
+ assert summary['urgency'] == 0
+ assert summary['safety'] == 0
+ assert summary['routine'] == 0
+ assert summary['total'] == 0
+
+ def test_cleanup_old_dsc_alerts(self, temp_db):
+ """Test cleanup function behavior."""
+ from utils.database import (
+ store_dsc_alert,
+ get_dsc_alerts,
+ acknowledge_dsc_alert,
+ cleanup_old_dsc_alerts
+ )
+
+ # Create and acknowledge some alerts
+ id1 = store_dsc_alert('232123456', '100', 'DISTRESS')
+ id2 = store_dsc_alert('366000001', '100', 'DISTRESS')
+ id3 = store_dsc_alert('351234567', '100', 'DISTRESS') # Unacknowledged
+
+ acknowledge_dsc_alert(id1)
+ acknowledge_dsc_alert(id2)
+
+ # Cleanup with large max_age shouldn't delete recent records
+ deleted = cleanup_old_dsc_alerts(max_age_days=30)
+ assert deleted == 0 # Nothing old enough to delete
+
+ # All 3 should still be present
+ alerts = get_dsc_alerts()
+ assert len(alerts) == 3
+
+ # Verify unacknowledged one is still unacknowledged
+ unacked = get_dsc_alerts(acknowledged=False)
+ assert len(unacked) == 1
+ assert unacked[0]['id'] == id3
+
+ def test_cleanup_preserves_unacknowledged(self, temp_db):
+ """Test cleanup preserves unacknowledged alerts regardless of age."""
+ from utils.database import (
+ store_dsc_alert,
+ get_dsc_alerts,
+ cleanup_old_dsc_alerts
+ )
+
+ # Create unacknowledged alerts
+ store_dsc_alert('232123456', '100', 'DISTRESS')
+ store_dsc_alert('366000001', '100', 'DISTRESS')
+
+ # Cleanup with 0 days
+ deleted = cleanup_old_dsc_alerts(max_age_days=0)
+
+ # All should remain (none were acknowledged)
+ alerts = get_dsc_alerts()
+ assert len(alerts) == 2
+ assert deleted == 0
+
+ def test_store_alert_with_raw_message(self, temp_db):
+ """Test storing alert with raw message data."""
+ from utils.database import store_dsc_alert, get_dsc_alert
+
+ raw = '100023212345603660000110010010000000000127'
+
+ alert_id = store_dsc_alert(
+ source_mmsi='232123456',
+ format_code='100',
+ category='DISTRESS',
+ raw_message=raw
+ )
+
+ alert = get_dsc_alert(alert_id)
+ assert alert['raw_message'] == raw
+
+ def test_store_alert_with_destination(self, temp_db):
+ """Test storing alert with destination MMSI."""
+ from utils.database import store_dsc_alert, get_dsc_alert
+
+ alert_id = store_dsc_alert(
+ source_mmsi='232123456',
+ format_code='112',
+ category='INDIVIDUAL',
+ dest_mmsi='366000001'
+ )
+
+ alert = get_dsc_alert(alert_id)
+ assert alert['dest_mmsi'] == '366000001'
+
+
+class TestDSCDatabaseIntegration:
+ """Integration tests for DSC database operations."""
+
+ def test_full_alert_lifecycle(self, temp_db):
+ """Test complete lifecycle of a DSC alert."""
+ from utils.database import (
+ store_dsc_alert,
+ get_dsc_alert,
+ get_dsc_alerts,
+ acknowledge_dsc_alert,
+ get_dsc_alert_summary
+ )
+
+ # 1. Store a distress alert
+ alert_id = store_dsc_alert(
+ source_mmsi='232123456',
+ format_code='100',
+ category='DISTRESS',
+ source_name='MV Mayday',
+ nature_of_distress='SINKING',
+ latitude=50.0,
+ longitude=-5.0
+ )
+
+ # 2. Verify it appears in summary
+ summary = get_dsc_alert_summary()
+ assert summary['distress'] == 1
+ assert summary['total'] == 1
+
+ # 3. Verify it appears in unacknowledged list
+ unacked = get_dsc_alerts(acknowledged=False)
+ assert len(unacked) == 1
+ assert unacked[0]['source_mmsi'] == '232123456'
+
+ # 4. Acknowledge with notes
+ acknowledge_dsc_alert(alert_id, 'Rescue helicopter dispatched')
+
+ # 5. Verify it's now acknowledged
+ alert = get_dsc_alert(alert_id)
+ assert alert['acknowledged'] is True
+ assert alert['notes'] == 'Rescue helicopter dispatched'
+
+ # 6. Verify summary updated
+ summary = get_dsc_alert_summary()
+ assert summary['distress'] == 0
+ assert summary['total'] == 0
+
+ # 7. Verify it appears in acknowledged list
+ acked = get_dsc_alerts(acknowledged=True)
+ assert len(acked) == 1
+
+ def test_multiple_vessel_alerts(self, temp_db):
+ """Test handling alerts from multiple vessels."""
+ from utils.database import store_dsc_alert, get_dsc_alerts
+
+ # Simulate multiple vessels in distress
+ vessels = [
+ ('232123456', 'United Kingdom', 'FIRE'),
+ ('366000001', 'USA', 'FLOODING'),
+ ('351234567', 'Panama', 'COLLISION'),
+ ]
+
+ for mmsi, country, nature in vessels:
+ store_dsc_alert(
+ source_mmsi=mmsi,
+ format_code='100',
+ category='DISTRESS',
+ nature_of_distress=nature
+ )
+
+ # Verify all alerts stored
+ alerts = get_dsc_alerts(category='DISTRESS')
+ assert len(alerts) == 3
+
+ # Verify each has correct nature
+ natures = {a['nature_of_distress'] for a in alerts}
+ assert natures == {'FIRE', 'FLOODING', 'COLLISION'}
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..031c5b1
--- /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, 0) 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'