mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
Fix DSC decoder for ITU-R M.493 compliance
Correct modulation parameters (1200 bps, 2100/1300 Hz tones), replace
invented format codes with the six ITU-defined specifiers {102, 112,
114, 116, 120, 123}, accept all valid EOS symbols (117, 122, 127),
add parser validation (format, MMSI, raw field, telecommand range),
and fix truthiness bugs that dropped zero-valued fields.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+239
-37
@@ -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
|
||||
|
||||
+17
-21
@@ -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
|
||||
|
||||
+23
-21
@@ -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])
|
||||
|
||||
+60
-9
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user