mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
Stricter dot pattern detection (200 bits/100 alternations), bounded phasing strip (max 7), symbol check bit parity validation, EOS minimum position check, strict MMSI decode (reject out-of-range symbols), format-aware telecommand extraction, and expanded critical category detection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
558 lines
17 KiB
Python
558 lines
17 KiB
Python
#!/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 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
|
|
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,
|
|
VALID_EOS,
|
|
TELECOMMAND_FORMATS,
|
|
MIN_SYMBOLS_FOR_FORMAT,
|
|
)
|
|
|
|
# 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 1200 bps 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 # 2100 Hz = binary 1
|
|
self.space_freq = DSC_SPACE_FREQ # 1300 Hz = binary 0
|
|
|
|
# Bandpass filter for DSC band (1100-2300 Hz)
|
|
nyq = sample_rate / 2
|
|
low = 1100 / nyq
|
|
high = 2300 / 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 require at least 100 consecutive alternations to avoid
|
|
false sync triggers from noise.
|
|
"""
|
|
if len(self.bit_buffer) < 200:
|
|
return False
|
|
|
|
# Check last 200 bits for alternating pattern
|
|
last_bits = self.bit_buffer[-200:]
|
|
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 >= 100:
|
|
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)
|
|
if symbol_value == -1:
|
|
logger.debug("DSC symbol check bit failure, aborting decode")
|
|
return None
|
|
symbols.append(symbol_value)
|
|
|
|
# Strip phasing sequence (RX/DX symbols 120-126) from the
|
|
# start of the message. Per ITU-R M.493, after the dot pattern
|
|
# there are 7 phasing symbols before the format specifier.
|
|
# Bound to max 7 — if more are present, this is a bad sync.
|
|
msg_start = 0
|
|
for i, sym in enumerate(symbols):
|
|
if 120 <= sym <= 126:
|
|
msg_start = i + 1
|
|
else:
|
|
break
|
|
if msg_start > 7:
|
|
logger.debug("DSC bad sync: >7 phasing symbols stripped")
|
|
return None
|
|
symbols = symbols[msg_start:]
|
|
|
|
if len(symbols) < 5:
|
|
return None
|
|
|
|
# Look for EOS (End of Sequence) - symbols 117, 122, or 127
|
|
# EOS must appear after at least MIN_SYMBOLS_FOR_FORMAT symbols
|
|
eos_found = False
|
|
eos_index = -1
|
|
for i, sym in enumerate(symbols):
|
|
if sym in VALID_EOS:
|
|
if i < MIN_SYMBOLS_FOR_FORMAT:
|
|
continue # Too early — not a real EOS
|
|
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.
|
|
The 3 check bits provide parity such that the total number of
|
|
'1' bits across all 10 bits should be even (even parity).
|
|
Returns -1 if the check bits are invalid.
|
|
"""
|
|
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)
|
|
|
|
# Validate check bits: total number of 1s should be even
|
|
ones = sum(bits)
|
|
if ones % 2 != 0:
|
|
return -1
|
|
|
|
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}')
|
|
|
|
# Derive category from format specifier per ITU-R M.493
|
|
if format_code == 120:
|
|
category = 'DISTRESS'
|
|
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])
|
|
if dest_mmsi is None:
|
|
return None
|
|
|
|
# Decode self-ID from symbols 6-10 (source)
|
|
source_mmsi = self._decode_mmsi(symbols[6:11])
|
|
if source_mmsi is None:
|
|
return None
|
|
|
|
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 (last two before EOS) — only for formats
|
|
# that carry telecommand fields per ITU-R M.493
|
|
if format_code in TELECOMMAND_FORMATS and 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 | None:
|
|
"""
|
|
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).
|
|
Returns None if any symbol is out of valid BCD range.
|
|
"""
|
|
if len(symbols) < 5:
|
|
return None
|
|
|
|
digits = []
|
|
for sym in symbols:
|
|
if sym < 0 or sym > 99:
|
|
return None
|
|
# Each symbol is 2 BCD digits
|
|
digits.append(f'{sym:02d}')
|
|
|
|
mmsi = ''.join(digits)
|
|
# MMSI is 9 digits - trim the leading digit from the 10-digit
|
|
# BCD result since the first symbol's high digit is always 0
|
|
if len(mmsi) > 9:
|
|
mmsi = mmsi[1:]
|
|
|
|
return mmsi.zfill(9)
|
|
|
|
def _decode_position(self, symbols: list[int]) -> dict | None:
|
|
"""
|
|
Decode position from 10 DSC symbols.
|
|
|
|
Position encoding (ITU-R M.493):
|
|
- Quadrant (10=NE, 11=NW, 00=SE, 01=SW)
|
|
- Latitude degrees (2 digits)
|
|
- Latitude minutes (2 digits)
|
|
- Longitude degrees (3 digits)
|
|
- Longitude minutes (2 digits)
|
|
"""
|
|
if len(symbols) < 10:
|
|
return None
|
|
|
|
try:
|
|
# Quadrant indicator
|
|
quadrant = symbols[0]
|
|
lat_sign = 1 if quadrant in (10, 11) else -1
|
|
lon_sign = 1 if quadrant in (10, 0) else -1
|
|
|
|
# Latitude degrees and minutes
|
|
lat_deg = symbols[1] if symbols[1] <= 90 else 0
|
|
lat_min = symbols[2] if symbols[2] < 60 else 0
|
|
|
|
# Longitude degrees (3 digits from 2 symbols)
|
|
lon_deg_high = symbols[3] if symbols[3] < 10 else 0
|
|
lon_deg_low = symbols[4] if symbols[4] < 100 else 0
|
|
lon_deg = lon_deg_high * 100 + lon_deg_low
|
|
if lon_deg > 180:
|
|
lon_deg = 0
|
|
|
|
lon_min = symbols[5] if symbols[5] < 60 else 0
|
|
|
|
lat = lat_sign * (lat_deg + lat_min / 60.0)
|
|
lon = lon_sign * (lon_deg + lon_min / 60.0)
|
|
|
|
return {'lat': round(lat, 6), 'lon': round(lon, 6)}
|
|
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def read_audio_stdin() -> Generator[bytes, None, None]:
|
|
"""
|
|
Read audio from stdin in chunks.
|
|
|
|
Yields:
|
|
Audio data chunks
|
|
"""
|
|
chunk_size = 4800 # 0.1 seconds at 48kHz, 16-bit = 9600 bytes
|
|
while True:
|
|
try:
|
|
data = sys.stdin.buffer.read(chunk_size * 2) # 2 bytes per sample
|
|
if not data:
|
|
break
|
|
yield data
|
|
except KeyboardInterrupt:
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Read error: {e}")
|
|
break
|
|
|
|
|
|
def main():
|
|
"""Main entry point for DSC decoder."""
|
|
parser = argparse.ArgumentParser(
|
|
description='DSC (Digital Selective Calling) decoder',
|
|
epilog='Reads 48kHz 16-bit signed PCM audio from stdin'
|
|
)
|
|
parser.add_argument(
|
|
'-r', '--sample-rate',
|
|
type=int,
|
|
default=DSC_AUDIO_SAMPLE_RATE,
|
|
help=f'Audio sample rate (default: {DSC_AUDIO_SAMPLE_RATE})'
|
|
)
|
|
parser.add_argument(
|
|
'-v', '--verbose',
|
|
action='store_true',
|
|
help='Enable verbose logging'
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.verbose:
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
decoder = DSCDecoder(sample_rate=args.sample_rate)
|
|
|
|
logger.info(f"DSC decoder started (sample rate: {args.sample_rate})")
|
|
|
|
for audio_chunk in read_audio_stdin():
|
|
for message in decoder.process_audio(audio_chunk):
|
|
# Output JSON to stdout
|
|
try:
|
|
print(json.dumps(message), flush=True)
|
|
except Exception as e:
|
|
logger.error(f"Output error: {e}")
|
|
|
|
logger.info("DSC decoder stopped")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|