mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
@@ -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
|
||||
|
||||
@@ -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
34
utils/dsc/__init__.py
Normal 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
468
utils/dsc/constants.py
Normal 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
514
utils/dsc/decoder.py
Normal 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
322
utils/dsc/parser.py
Normal 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'
|
||||
Reference in New Issue
Block a user