feat: Add VHF DSC Channel 70 monitoring and decoding

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

- Add MMSI country identification via MID lookup

- Integrate position extraction and map markers for distress alerts

- Implement device conflict detection to prevent SDR collisions with AIS

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

View File

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

View File

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

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

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

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

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

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

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

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

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