diff --git a/tests/test_dsc.py b/tests/test_dsc.py index b47c9ae..9b83663 100644 --- a/tests/test_dsc.py +++ b/tests/test_dsc.py @@ -1,8 +1,8 @@ """Tests for DSC (Digital Selective Calling) utilities.""" import json + import pytest -from datetime import datetime class TestDSCParser: @@ -88,17 +88,15 @@ class TestDSCParser: assert get_distress_nature_text('invalid') == 'invalid' def test_get_format_text(self): - """Test format code to text conversion.""" + """Test format code to text conversion per ITU-R M.493.""" 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' + assert get_format_text(114) == 'INDIVIDUAL_ACK' + assert get_format_text(116) == 'GROUP' + assert get_format_text(120) == 'DISTRESS' + assert get_format_text(123) == 'ALL_SHIPS_URGENCY_SAFETY' def test_get_format_text_unknown(self): """Test format code returns unknown for invalid codes.""" @@ -107,6 +105,15 @@ class TestDSCParser: result = get_format_text(999) assert 'UNKNOWN' in result + def test_get_format_text_removed_codes(self): + """Test that non-ITU format codes are no longer recognized.""" + from utils.dsc.parser import get_format_text + + # These were previously defined but are not ITU-R M.493 specifiers + for code in [100, 104, 106, 108, 110, 118]: + result = get_format_text(code) + assert 'UNKNOWN' in result + def test_get_telecommand_text(self): """Test telecommand code to text conversion.""" from utils.dsc.parser import get_telecommand_text @@ -124,14 +131,13 @@ class TestDSCParser: assert get_category_priority('DISTRESS') == 0 assert get_category_priority('distress') == 0 - # Urgency is lower - assert get_category_priority('URGENCY') == 3 + # Urgency/safety + assert get_category_priority('ALL_SHIPS_URGENCY_SAFETY') == 2 - # Safety is lower still - assert get_category_priority('SAFETY') == 4 - - # Routine is lowest - assert get_category_priority('ROUTINE') == 5 + # Routine-level + assert get_category_priority('ALL_SHIPS') == 5 + assert get_category_priority('GROUP') == 5 + assert get_category_priority('INDIVIDUAL') == 5 # Unknown gets default high number assert get_category_priority('UNKNOWN') == 10 @@ -182,19 +188,20 @@ class TestDSCParser: assert classify_mmsi('812345678') == 'unknown' def test_parse_dsc_message_distress(self): - """Test parsing a distress message.""" + """Test parsing a distress message with ITU format 120.""" from utils.dsc.parser import parse_dsc_message raw = json.dumps({ 'type': 'dsc', - 'format': 100, + 'format': 120, 'source_mmsi': '232123456', - 'dest_mmsi': '000000000', + 'dest_mmsi': '002320001', 'category': 'DISTRESS', 'nature': 101, 'position': {'lat': 51.5, 'lon': -0.1}, 'telecommand1': 100, - 'timestamp': '2025-01-15T12:00:00Z' + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': '120002032123456101100127', }) msg = parse_dsc_message(raw) @@ -210,26 +217,49 @@ class TestDSCParser: assert msg['is_critical'] is True assert msg['priority'] == 0 - def test_parse_dsc_message_routine(self): - """Test parsing a routine message.""" + def test_parse_dsc_message_group(self): + """Test parsing a group call 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' + 'dest_mmsi': '023200001', + 'category': 'GROUP', + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': '116023200001366000001117', }) msg = parse_dsc_message(raw) assert msg is not None - assert msg['category'] == 'ROUTINE' + assert msg['category'] == 'GROUP' assert msg['source_country'] == 'USA' assert msg['is_critical'] is False assert msg['priority'] == 5 + def test_parse_dsc_message_individual(self): + """Test parsing an individual call message.""" + from utils.dsc.parser import parse_dsc_message + + raw = json.dumps({ + 'type': 'dsc', + 'format': 112, + 'source_mmsi': '366000001', + 'dest_mmsi': '232123456', + 'category': 'INDIVIDUAL', + 'telecommand1': 100, + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': '112232123456366000001100122', + }) + + msg = parse_dsc_message(raw) + + assert msg is not None + assert msg['category'] == 'INDIVIDUAL' + assert msg['is_critical'] is False + def test_parse_dsc_message_invalid_json(self): """Test parsing rejects invalid JSON.""" from utils.dsc.parser import parse_dsc_message @@ -262,6 +292,171 @@ class TestDSCParser: assert parse_dsc_message(None) is None assert parse_dsc_message(' ') is None + def test_parse_dsc_message_rejects_non_itu_format(self): + """Test parser rejects records with non-ITU format specifier.""" + from utils.dsc.parser import parse_dsc_message + + for bad_format in [100, 104, 106, 108, 110, 118, 999]: + raw = json.dumps({ + 'type': 'dsc', + 'format': bad_format, + 'source_mmsi': '232123456', + 'category': 'ROUTINE', + 'raw': '120232123456100127', + }) + assert parse_dsc_message(raw) is None, f"Format {bad_format} should be rejected" + + def test_parse_dsc_message_rejects_telecommand_out_of_range(self): + """Test parser rejects records with telecommand out of 100-127 range.""" + from utils.dsc.parser import parse_dsc_message + + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '232123456', + 'dest_mmsi': '002320001', + 'category': 'DISTRESS', + 'telecommand1': 200, + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': '120002032123456200127', + }) + assert parse_dsc_message(raw) is None + + def test_parse_dsc_message_accepts_zero_telecommand(self): + """Test parser does not drop telecommand with value 100 (truthiness fix).""" + from utils.dsc.parser import parse_dsc_message + + raw = json.dumps({ + 'type': 'dsc', + 'format': 112, + 'source_mmsi': '232123456', + 'dest_mmsi': '366000001', + 'category': 'INDIVIDUAL', + 'telecommand1': 100, + 'telecommand2': 100, + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': '112366000001232123456100100122', + }) + + msg = parse_dsc_message(raw) + assert msg is not None + assert msg['telecommand1'] == 100 + assert msg['telecommand2'] == 100 + + def test_parse_dsc_message_validates_raw_field(self): + """Test parser validates raw field structure.""" + from utils.dsc.parser import parse_dsc_message + + # Non-digit raw field + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '232123456', + 'category': 'DISTRESS', + 'raw': '12abc', + }) + assert parse_dsc_message(raw) is None + + # Raw field length not divisible by 3 + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '232123456', + 'category': 'DISTRESS', + 'raw': '1201', + }) + assert parse_dsc_message(raw) is None + + # Raw field with non-EOS last token + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '232123456', + 'category': 'DISTRESS', + 'raw': '120100', + }) + assert parse_dsc_message(raw) is None + + def test_parse_dsc_message_accepts_valid_eos_in_raw(self): + """Test parser accepts all three valid EOS values in raw field.""" + from utils.dsc.parser import parse_dsc_message + + for eos in [117, 122, 127]: + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '232123456', + 'dest_mmsi': '002320001', + 'category': 'DISTRESS', + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': f'120002032123456{eos:03d}', + }) + msg = parse_dsc_message(raw) + assert msg is not None, f"EOS {eos} should be accepted" + + def test_parse_dsc_message_rejects_invalid_mmsi(self): + """Test parser rejects invalid MMSI values.""" + from utils.dsc.parser import parse_dsc_message + + # All-zeros MMSI + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '000000000', + 'category': 'DISTRESS', + 'raw': '120000000000127', + }) + assert parse_dsc_message(raw) is None + + # Short MMSI + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '12345', + 'category': 'DISTRESS', + 'raw': '120127', + }) + assert parse_dsc_message(raw) is None + + def test_parse_dsc_message_nature_zero_not_dropped(self): + """Test that nature code 0 is not dropped by truthiness check.""" + from utils.dsc.parser import parse_dsc_message + + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '232123456', + 'dest_mmsi': '002320001', + 'category': 'DISTRESS', + 'nature': 0, + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': '120002032123456000127', + }) + + msg = parse_dsc_message(raw) + assert msg is not None + assert msg['nature_code'] == 0 + + def test_parse_dsc_message_channel_zero_not_dropped(self): + """Test that channel value 0 is not dropped by truthiness check.""" + from utils.dsc.parser import parse_dsc_message + + raw = json.dumps({ + 'type': 'dsc', + 'format': 112, + 'source_mmsi': '232123456', + 'dest_mmsi': '366000001', + 'category': 'INDIVIDUAL', + 'channel': 0, + 'telecommand1': 100, + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': '112366000001232123456100122', + }) + + msg = parse_dsc_message(raw) + assert msg is not None + assert msg['channel'] == 0 + def test_format_dsc_for_display(self): """Test message formatting for display.""" from utils.dsc.parser import format_dsc_for_display @@ -413,17 +608,24 @@ class TestDSCConstants: """Tests for DSC constants.""" def test_format_codes_completeness(self): - """Test that all standard format codes are defined.""" + """Test that all ITU-R M.493 format specifiers 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 + # ITU-R M.493 format specifiers (and only these) + expected_keys = {102, 112, 114, 116, 120, 123} + assert set(FORMAT_CODES.keys()) == expected_keys + + def test_valid_format_specifiers_set(self): + """Test VALID_FORMAT_SPECIFIERS matches FORMAT_CODES keys.""" + from utils.dsc.constants import FORMAT_CODES, VALID_FORMAT_SPECIFIERS + + assert set(FORMAT_CODES.keys()) == VALID_FORMAT_SPECIFIERS + + def test_valid_eos_symbols(self): + """Test VALID_EOS contains the three ITU-defined EOS symbols.""" + from utils.dsc.constants import VALID_EOS + + assert {117, 122, 127} == VALID_EOS def test_distress_nature_codes_completeness(self): """Test that all distress nature codes are defined.""" @@ -458,13 +660,13 @@ class TestDSCConstants: assert VHF_CHANNELS[70] == 156.525 def test_dsc_modulation_parameters(self): - """Test DSC modulation constants.""" + """Test DSC modulation constants per ITU-R M.493.""" 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 + assert DSC_BAUD_RATE == 1200 + assert DSC_MARK_FREQ == 2100 + assert DSC_SPACE_FREQ == 1300 diff --git a/utils/dsc/constants.py b/utils/dsc/constants.py index 44d7ba2..d8372f9 100644 --- a/utils/dsc/constants.py +++ b/utils/dsc/constants.py @@ -14,30 +14,26 @@ from __future__ import annotations # ============================================================================= 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 + 102: 'ALL_SHIPS', # All ships call + 112: 'INDIVIDUAL', # Individual call + 114: 'INDIVIDUAL_ACK', # Individual acknowledgement + 116: 'GROUP', # Group call (including geographic area) + 120: 'DISTRESS', # Distress alert + 123: 'ALL_SHIPS_URGENCY_SAFETY', # All ships urgency/safety } +# Valid ITU-R M.493 format specifiers +VALID_FORMAT_SPECIFIERS = {102, 112, 114, 116, 120, 123} + +# Valid EOS (End of Sequence) symbols per ITU-R M.493 +VALID_EOS = {117, 122, 127} + # Category priority (lower = higher priority) CATEGORY_PRIORITY = { 'DISTRESS': 0, - 'DISTRESS_ACK': 1, - 'DISTRESS_RELAY': 2, - 'URGENCY': 3, - 'SAFETY': 4, - 'ROUTINE': 5, + 'ALL_SHIPS_URGENCY_SAFETY': 2, 'ALL_SHIPS': 5, 'GROUP': 5, - 'GEOGRAPHIC': 5, 'INDIVIDUAL': 5, 'INDIVIDUAL_ACK': 5, } @@ -453,11 +449,11 @@ VHF_CHANNELS = { # DSC Modulation Parameters # ============================================================================= -DSC_BAUD_RATE = 100 # 100 baud per ITU-R M.493 +DSC_BAUD_RATE = 1200 # 1200 bps 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 +# FSK tone frequencies (Hz) on 1700 Hz subcarrier +DSC_MARK_FREQ = 2100 # B (mark) - binary 1 +DSC_SPACE_FREQ = 1300 # Y (space) - binary 0 # Audio sample rate for decoding DSC_AUDIO_SAMPLE_RATE = 48000 diff --git a/utils/dsc/decoder.py b/utils/dsc/decoder.py index 51442bd..3f6399e 100644 --- a/utils/dsc/decoder.py +++ b/utils/dsc/decoder.py @@ -5,9 +5,9 @@ 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 +DSC uses 1200 bps FSK on a 1700 Hz subcarrier with: +- Mark (1): 2100 Hz +- Space (0): 1300 Hz Frame structure: 1. Dot pattern: 200 bits alternating 1/0 for synchronization @@ -42,6 +42,7 @@ from .constants import ( DSC_AUDIO_SAMPLE_RATE, FORMAT_CODES, DISTRESS_NATURE_CODES, + VALID_EOS, ) # Configure logging @@ -57,7 +58,7 @@ class DSCDecoder: """ DSC FSK decoder. - Demodulates 100 baud FSK audio and decodes DSC protocol. + Demodulates 1200 bps FSK audio and decodes DSC protocol. """ def __init__(self, sample_rate: int = DSC_AUDIO_SAMPLE_RATE): @@ -66,13 +67,13 @@ class DSCDecoder: 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 + self.mark_freq = DSC_MARK_FREQ # 2100 Hz = binary 1 + self.space_freq = DSC_SPACE_FREQ # 1300 Hz = binary 0 - # Bandpass filter for DSC band (1100-1900 Hz) + # Bandpass filter for DSC band (1100-2300 Hz) nyq = sample_rate / 2 low = 1100 / nyq - high = 1900 / nyq + high = 2300 / nyq self.bp_b, self.bp_a = scipy_signal.butter(4, [low, high], btype='band') # Build FSK correlators @@ -278,11 +279,11 @@ class DSCDecoder: if len(symbols) < 5: return None - # Look for EOS (End of Sequence) - symbol 127 + # Look for EOS (End of Sequence) - symbols 117, 122, or 127 eos_found = False eos_index = -1 for i, sym in enumerate(symbols): - if sym == 127: # EOS symbol + if sym in VALID_EOS: eos_found = True eos_index = i break @@ -337,20 +338,21 @@ class DSCDecoder: 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: + # Derive category from format specifier per ITU-R M.493 + if format_code == 120: 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 == 123: + category = 'ALL_SHIPS_URGENCY_SAFETY' elif format_code == 102: category = 'ALL_SHIPS' + elif format_code == 116: + category = 'GROUP' + elif format_code == 112: + category = 'INDIVIDUAL' + elif format_code == 114: + category = 'INDIVIDUAL_ACK' + else: + category = FORMAT_CODES.get(format_code, 'UNKNOWN') # Decode MMSI from symbols 1-5 (destination/address) dest_mmsi = self._decode_mmsi(symbols[1:6]) diff --git a/utils/dsc/parser.py b/utils/dsc/parser.py index a8b6d25..30f7dd3 100644 --- a/utils/dsc/parser.py +++ b/utils/dsc/parser.py @@ -19,6 +19,8 @@ from .constants import ( TELECOMMAND_CODES, CATEGORY_PRIORITY, MID_COUNTRY_MAP, + VALID_FORMAT_SPECIFIERS, + VALID_EOS, ) logger = logging.getLogger('intercept.dsc.parser') @@ -139,13 +141,62 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None: if 'source_mmsi' not in data: return None + # ITU-R M.493 validation: format specifier must be valid + format_code = data.get('format') + if format_code not in VALID_FORMAT_SPECIFIERS: + logger.debug(f"Rejected DSC: invalid format specifier {format_code}") + return None + + # Validate MMSIs + source_mmsi = str(data.get('source_mmsi', '')) + if not validate_mmsi(source_mmsi): + logger.debug(f"Rejected DSC: invalid source MMSI {source_mmsi}") + return None + + dest_mmsi_val = data.get('dest_mmsi') + if dest_mmsi_val is not None: + dest_mmsi_str = str(dest_mmsi_val) + if not validate_mmsi(dest_mmsi_str): + logger.debug(f"Rejected DSC: invalid dest MMSI {dest_mmsi_str}") + return None + + # Validate raw field structure if present + raw = data.get('raw') + if raw is not None: + raw_str = str(raw) + if not re.match(r'^\d+$', raw_str): + logger.debug("Rejected DSC: raw field contains non-digits") + return None + if len(raw_str) % 3 != 0: + logger.debug("Rejected DSC: raw field length not divisible by 3") + return None + # Last 3-digit token must be a valid EOS symbol + if len(raw_str) >= 3: + last_token = int(raw_str[-3:]) + if last_token not in VALID_EOS: + logger.debug(f"Rejected DSC: raw EOS token {last_token} not valid") + return None + + # Validate telecommand values if present (must be 100-127) + for tc_field in ('telecommand1', 'telecommand2'): + tc_val = data.get(tc_field) + if tc_val is not None: + try: + tc_int = int(tc_val) + except (ValueError, TypeError): + logger.debug(f"Rejected DSC: invalid {tc_field} value {tc_val}") + return None + if tc_int < 100 or tc_int > 127: + logger.debug(f"Rejected DSC: {tc_field} {tc_int} out of range 100-127") + 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)), + 'source_mmsi': source_mmsi, + 'dest_mmsi': str(data.get('dest_mmsi', '')) if data.get('dest_mmsi') is not None else None, + 'format_code': format_code, + 'format_text': get_format_text(format_code), 'category': data.get('category', 'UNKNOWN').upper(), 'timestamp': data.get('timestamp') or datetime.utcnow().isoformat(), } @@ -156,7 +207,7 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None: msg['source_country'] = country # Add distress nature if present - if 'nature' in data and data['nature']: + if data.get('nature') is not None: msg['nature_code'] = data['nature'] msg['nature_of_distress'] = get_distress_nature_text(data['nature']) @@ -173,16 +224,16 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None: pass # Add telecommand info - if 'telecommand1' in data and data['telecommand1']: + if data.get('telecommand1') is not None: msg['telecommand1'] = data['telecommand1'] msg['telecommand1_text'] = get_telecommand_text(data['telecommand1']) - if 'telecommand2' in data and data['telecommand2']: + if data.get('telecommand2') is not None: msg['telecommand2'] = data['telecommand2'] msg['telecommand2_text'] = get_telecommand_text(data['telecommand2']) # Add channel if present - if 'channel' in data and data['channel']: + if data.get('channel') is not None: msg['channel'] = data['channel'] # Add EOS (End of Sequence) info @@ -197,7 +248,7 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None: 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') + msg['is_critical'] = msg['category'] in ('DISTRESS', 'ALL_SHIPS_URGENCY_SAFETY') return msg