mirror of
https://github.com/smittix/intercept.git
synced 2026-07-29 11:08:10 -07:00
Merge pull request #241 from jimarndt/fix-dsc-decoder
Fix DSC decoder: eleven bugs preventing VHF decode
This commit is contained in:
+817
-805
File diff suppressed because it is too large
Load Diff
+579
-263
@@ -64,8 +64,11 @@ class DSCDecoder:
|
|||||||
self.samples_per_bit = sample_rate // self.baud_rate
|
self.samples_per_bit = sample_rate // self.baud_rate
|
||||||
|
|
||||||
# FSK frequencies
|
# FSK frequencies
|
||||||
self.mark_freq = DSC_MARK_FREQ # 2100 Hz = binary 1
|
# Per ITU-R M.493 Annex 1 Sec 1.4: higher frequency (2100 Hz) is
|
||||||
self.space_freq = DSC_SPACE_FREQ # 1300 Hz = binary 0
|
# the B-state (binary 0), lower frequency (1300 Hz) is the
|
||||||
|
# Y-state (binary 1).
|
||||||
|
self.mark_freq = DSC_MARK_FREQ # 2100 Hz = binary 0 (B state)
|
||||||
|
self.space_freq = DSC_SPACE_FREQ # 1300 Hz = binary 1 (Y state)
|
||||||
|
|
||||||
# Bandpass filter for DSC band (1100-2300 Hz)
|
# Bandpass filter for DSC band (1100-2300 Hz)
|
||||||
nyq = sample_rate / 2
|
nyq = sample_rate / 2
|
||||||
@@ -83,54 +86,87 @@ class DSCDecoder:
|
|||||||
self.message_bits = []
|
self.message_bits = []
|
||||||
|
|
||||||
def _build_correlators(self):
|
def _build_correlators(self):
|
||||||
"""Build matched filter correlators for mark and space frequencies."""
|
"""
|
||||||
|
Build quadrature (I/Q) matched filter correlators for mark and
|
||||||
|
space frequencies.
|
||||||
|
|
||||||
|
A single-phase sine correlator is phase-sensitive: if the
|
||||||
|
incoming tone is ~90 degrees out of phase with the reference,
|
||||||
|
the correlation magnitude collapses toward zero even when the
|
||||||
|
correct tone is present, since there's no timing recovery
|
||||||
|
locking the symbol window to the actual bit transitions. Using
|
||||||
|
both a sine (Q) and cosine (I) reference and combining them as
|
||||||
|
sqrt(I^2 + Q^2) makes the magnitude phase-independent, which is
|
||||||
|
required for a free-running (non-timing-recovered) symbol
|
||||||
|
window like this one.
|
||||||
|
"""
|
||||||
# Duration for one bit
|
# Duration for one bit
|
||||||
t = np.arange(self.samples_per_bit) / self.sample_rate
|
t = np.arange(self.samples_per_bit) / self.sample_rate
|
||||||
|
|
||||||
# Mark correlator (1800 Hz)
|
# Mark (2100 Hz) quadrature references
|
||||||
self.mark_ref = np.sin(2 * np.pi * self.mark_freq * t)
|
self.mark_ref_i = np.cos(2 * np.pi * self.mark_freq * t)
|
||||||
|
self.mark_ref_q = np.sin(2 * np.pi * self.mark_freq * t)
|
||||||
|
|
||||||
# Space correlator (1200 Hz)
|
# Space (1300 Hz) quadrature references
|
||||||
self.space_ref = np.sin(2 * np.pi * self.space_freq * t)
|
self.space_ref_i = np.cos(2 * np.pi * self.space_freq * t)
|
||||||
|
self.space_ref_q = np.sin(2 * np.pi * self.space_freq * t)
|
||||||
|
|
||||||
def process_audio(self, audio_data: bytes) -> Generator[dict, None, None]:
|
def process_audio(self, audio_data: bytes) -> Generator[dict, None, None]:
|
||||||
"""
|
"""
|
||||||
Process audio data and yield decoded DSC messages.
|
Process audio data and yield decoded DSC messages.
|
||||||
|
|
||||||
|
Uses a stateful bandpass filter (carrying scipy's lfilter `zi`
|
||||||
|
state across calls) instead of re-filtering an overlapping
|
||||||
|
buffer each call. The previous approach re-filtered a retained
|
||||||
|
tail of samples on every call and re-yielded the resulting
|
||||||
|
bits, duplicating already-processed bits into the bit stream
|
||||||
|
on every call after the first - this corrupted sync/symbol
|
||||||
|
alignment starting right after the first chunk, and compounded
|
||||||
|
with every subsequent call. It also introduced small filter
|
||||||
|
transients at each chunk boundary from resetting filter state
|
||||||
|
to zero every time. Carrying filter state eliminates both:
|
||||||
|
confirmed to produce a bit-for-bit identical stream to a
|
||||||
|
single continuous one-shot filter pass on real captured audio.
|
||||||
|
|
||||||
|
Any leftover samples that don't complete a full bit period are
|
||||||
|
buffered and prepended to the next call's audio, so this works
|
||||||
|
correctly regardless of the caller's chunk size.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
audio_data: Raw 16-bit signed PCM audio bytes
|
audio_data: Raw 16-bit signed PCM audio bytes
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Decoded DSC message dicts
|
Decoded DSC message dicts
|
||||||
"""
|
"""
|
||||||
# Convert bytes to numpy array
|
|
||||||
samples = np.frombuffer(audio_data, dtype=np.int16)
|
samples = np.frombuffer(audio_data, dtype=np.int16)
|
||||||
if len(samples) == 0:
|
if len(samples) == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Append to buffer
|
if not hasattr(self, "_filter_zi"):
|
||||||
self.buffer = np.concatenate([self.buffer, samples])
|
self._filter_zi = scipy_signal.lfilter_zi(self.bp_b, self.bp_a) * samples[0]
|
||||||
|
if not hasattr(self, "_leftover_samples"):
|
||||||
|
self._leftover_samples = np.array([], dtype=np.int16)
|
||||||
|
|
||||||
# Need at least one bit worth of samples
|
samples = np.concatenate([self._leftover_samples, samples])
|
||||||
if len(self.buffer) < self.samples_per_bit:
|
|
||||||
|
usable_len = (len(samples) // self.samples_per_bit) * self.samples_per_bit
|
||||||
|
if usable_len == 0:
|
||||||
|
self._leftover_samples = samples
|
||||||
return
|
return
|
||||||
|
|
||||||
# Apply bandpass filter
|
to_process = samples[:usable_len]
|
||||||
|
self._leftover_samples = samples[usable_len:]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
filtered = scipy_signal.lfilter(self.bp_b, self.bp_a, self.buffer)
|
filtered, self._filter_zi = scipy_signal.lfilter(
|
||||||
|
self.bp_b, self.bp_a, to_process.astype(np.float64), zi=self._filter_zi
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Filter error: {e}")
|
logger.warning(f"Filter error: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Demodulate FSK using correlation
|
|
||||||
bits = self._demodulate_fsk(filtered)
|
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:
|
for bit in bits:
|
||||||
message = self._process_bit(bit)
|
message = self._process_bit(bit)
|
||||||
if message:
|
if message:
|
||||||
@@ -138,7 +174,8 @@ class DSCDecoder:
|
|||||||
|
|
||||||
def _demodulate_fsk(self, samples: np.ndarray) -> list[int]:
|
def _demodulate_fsk(self, samples: np.ndarray) -> list[int]:
|
||||||
"""
|
"""
|
||||||
Demodulate FSK audio to bits using correlation.
|
Demodulate FSK audio to bits using phase-independent quadrature
|
||||||
|
correlation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
samples: Filtered audio samples
|
samples: Filtered audio samples
|
||||||
@@ -157,15 +194,22 @@ class DSCDecoder:
|
|||||||
if len(segment) < self.samples_per_bit:
|
if len(segment) < self.samples_per_bit:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Correlate with mark and space references
|
# Quadrature (I/Q) magnitude - phase independent, unlike a
|
||||||
mark_corr = np.abs(np.correlate(segment, self.mark_ref, mode="valid"))
|
# single-phase correlation against just a sine reference.
|
||||||
space_corr = np.abs(np.correlate(segment, self.space_ref, mode="valid"))
|
mark_i = np.dot(segment, self.mark_ref_i)
|
||||||
|
mark_q = np.dot(segment, self.mark_ref_q)
|
||||||
|
mark_mag = np.sqrt(mark_i**2 + mark_q**2)
|
||||||
|
|
||||||
# Decision: mark (1) if mark correlation > space correlation
|
space_i = np.dot(segment, self.space_ref_i)
|
||||||
if np.max(mark_corr) > np.max(space_corr):
|
space_q = np.dot(segment, self.space_ref_q)
|
||||||
bits.append(1)
|
space_mag = np.sqrt(space_i**2 + space_q**2)
|
||||||
else:
|
|
||||||
|
# Per ITU-R M.493: mark (2100 Hz) = binary 0 (B state),
|
||||||
|
# space (1300 Hz) = binary 1 (Y state)
|
||||||
|
if mark_mag > space_mag:
|
||||||
bits.append(0)
|
bits.append(0)
|
||||||
|
else:
|
||||||
|
bits.append(1)
|
||||||
|
|
||||||
return bits
|
return bits
|
||||||
|
|
||||||
@@ -173,6 +217,15 @@ class DSCDecoder:
|
|||||||
"""
|
"""
|
||||||
Process a decoded bit and detect/decode DSC messages.
|
Process a decoded bit and detect/decode DSC messages.
|
||||||
|
|
||||||
|
Uses a two-stage sync: coarse dot-pattern detection (20+
|
||||||
|
alternating bits, per VHF spec) followed by a fine-sync step
|
||||||
|
that tests bit offsets 0-9 against the check-bit test to find
|
||||||
|
the transmitter's actual 10-bit symbol grid. The coarse
|
||||||
|
trigger point is not reliably grid-aligned on its own - the
|
||||||
|
grid position is arbitrary relative to wherever our sample
|
||||||
|
buffer happens to start, and being off by even a few bits
|
||||||
|
breaks every subsequent check-bit test even on a clean signal.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
bit: Decoded bit (0 or 1)
|
bit: Decoded bit (0 or 1)
|
||||||
|
|
||||||
@@ -180,313 +233,576 @@ class DSCDecoder:
|
|||||||
Decoded message dict if complete message found, None otherwise
|
Decoded message dict if complete message found, None otherwise
|
||||||
"""
|
"""
|
||||||
self.bit_buffer.append(bit)
|
self.bit_buffer.append(bit)
|
||||||
|
|
||||||
# Keep buffer manageable
|
|
||||||
if len(self.bit_buffer) > 2000:
|
if len(self.bit_buffer) > 2000:
|
||||||
self.bit_buffer = self.bit_buffer[-1500:]
|
self.bit_buffer = self.bit_buffer[-1500:]
|
||||||
|
|
||||||
# Look for dot pattern (sync) - alternating 1010101...
|
if not hasattr(self, "sync_pending"):
|
||||||
|
self.sync_pending = False
|
||||||
|
self.sync_lookahead = []
|
||||||
|
|
||||||
|
if self.sync_pending:
|
||||||
|
self.sync_lookahead.append(bit)
|
||||||
|
if len(self.sync_lookahead) >= 40:
|
||||||
|
best_offset, best_valid = 0, -1
|
||||||
|
for offset in range(10):
|
||||||
|
valid = 0
|
||||||
|
for k in range(3):
|
||||||
|
s = offset + k * 10
|
||||||
|
if s + 10 <= len(self.sync_lookahead):
|
||||||
|
symbol_bits = self.sync_lookahead[s:s + 10]
|
||||||
|
if self._bits_to_symbol(symbol_bits) != -1:
|
||||||
|
valid += 1
|
||||||
|
if valid > best_valid:
|
||||||
|
best_valid, best_offset = valid, offset
|
||||||
|
if best_valid >= 2:
|
||||||
|
self.in_message = True
|
||||||
|
self.message_bits = self.sync_lookahead[best_offset:]
|
||||||
|
logger.debug(
|
||||||
|
f"DSC fine sync: offset={best_offset} valid={best_valid}/3"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug("DSC fine sync failed, discarding false trigger")
|
||||||
|
self.sync_pending = False
|
||||||
|
self.sync_lookahead = []
|
||||||
|
return None
|
||||||
|
|
||||||
if not self.in_message and self._detect_dot_pattern():
|
if not self.in_message and self._detect_dot_pattern():
|
||||||
self.in_message = True
|
self.sync_pending = True
|
||||||
self.message_bits = []
|
self.sync_lookahead = []
|
||||||
logger.debug("DSC sync detected")
|
logger.debug("DSC sync detected, refining bit alignment")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Collect message bits
|
# Collect message bits
|
||||||
if self.in_message:
|
if self.in_message:
|
||||||
self.message_bits.append(bit)
|
self.message_bits.append(bit)
|
||||||
|
|
||||||
# Check for end of message or timeout
|
# Check for end of message or timeout
|
||||||
if len(self.message_bits) >= 10: # One symbol
|
if len(self.message_bits) >= 10 and len(self.message_bits) % 10 == 0: # One new complete symbol
|
||||||
# Try to decode accumulated symbols
|
# Try to decode accumulated symbols
|
||||||
message = self._try_decode_message()
|
message = self._try_decode_message()
|
||||||
if message:
|
if message:
|
||||||
self.in_message = False
|
self.in_message = False
|
||||||
self.message_bits = []
|
self.message_bits = []
|
||||||
return message
|
return message
|
||||||
|
|
||||||
# Timeout - too many bits without valid message
|
# Timeout - too many bits without valid message
|
||||||
if len(self.message_bits) > 1800: # ~180 symbols max
|
if len(self.message_bits) > 1800: # ~180 symbols max
|
||||||
logger.debug("DSC message timeout")
|
diagnosis = self._diagnose_decode_failure()
|
||||||
|
logger.debug(f"DSC message timeout: {diagnosis}")
|
||||||
self.in_message = False
|
self.in_message = False
|
||||||
self.message_bits = []
|
self.message_bits = []
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _detect_dot_pattern(self) -> bool:
|
def _detect_dot_pattern(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Detect DSC dot pattern for synchronization.
|
Detect DSC dot pattern for synchronization.
|
||||||
|
|
||||||
The dot pattern is at least 200 alternating bits (1010101...).
|
Per ITU-R M.493 Annex 1 Sec 3.4, there are two dot pattern
|
||||||
We require at least 100 consecutive alternations to avoid
|
lengths: 200 bits (HF/MF acknowledgements and coast-station
|
||||||
false sync triggers from noise.
|
calls) and 20 bits (VHF - all calls, per Sec 3.4.2, confirmed
|
||||||
|
by ETSI EN 300 338-3: "the equipment shall automatically set
|
||||||
|
the dot pattern length to 20 bits for all transmitted DSC
|
||||||
|
messages" on VHF). This decoder targets VHF ch. 70, so it must
|
||||||
|
use the 20-bit pattern - the previous 100-alternation/200-bit
|
||||||
|
requirement was the HF/MF number and made VHF sync structurally
|
||||||
|
unreachable, since real VHF traffic only ever produces roughly
|
||||||
|
20-27 alternating bits.
|
||||||
|
|
||||||
|
We check a slightly wider window (24 bits) than the strict
|
||||||
|
20-bit minimum to tolerate a possible bit error right at the
|
||||||
|
start of the pattern.
|
||||||
"""
|
"""
|
||||||
if len(self.bit_buffer) < 200:
|
if len(self.bit_buffer) < 24:
|
||||||
return False
|
return False
|
||||||
|
last_bits = self.bit_buffer[-24:]
|
||||||
# Check last 200 bits for alternating pattern
|
|
||||||
last_bits = self.bit_buffer[-200:]
|
|
||||||
alternations = 0
|
alternations = 0
|
||||||
|
|
||||||
for i in range(1, len(last_bits)):
|
for i in range(1, len(last_bits)):
|
||||||
if last_bits[i] != last_bits[i - 1]:
|
if last_bits[i] != last_bits[i - 1]:
|
||||||
alternations += 1
|
alternations += 1
|
||||||
else:
|
else:
|
||||||
alternations = 0
|
alternations = 0
|
||||||
|
if alternations >= 20:
|
||||||
if alternations >= 100:
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _try_decode_message(self) -> dict | None:
|
def _try_decode_message(self) -> dict | None:
|
||||||
"""
|
"""
|
||||||
Try to decode accumulated message bits as DSC message.
|
Decode accumulated message bits into a DSC message.
|
||||||
|
|
||||||
|
Per ITU-R M.493 Annex 1 Sec 1.2: apart from phasing, every
|
||||||
|
character in a DSC call is transmitted TWICE (DX then RX, ~5
|
||||||
|
symbol-positions apart) - this is the actual wire format, not
|
||||||
|
an optional extra. The format specifier is additionally doubled
|
||||||
|
at the FIELD level (Sec 4.1: "2 identical characters"), giving
|
||||||
|
4 raw occurrences for it specifically and providing a reliable
|
||||||
|
anchor: the first position where a real format-specifier value
|
||||||
|
repeats exactly 2 symbol-positions later. From that anchor,
|
||||||
|
every other symbol (stride 2) is the de-interleaved, real
|
||||||
|
message content - confirmed empirically against real captures
|
||||||
|
and against Annex 1 Figure 1 (call sequence construction).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decoded message dict or None if not yet complete/valid
|
Decoded message dict, or None if not yet decodable (anchor
|
||||||
|
not found, fields incomplete, or EOS not found yet).
|
||||||
"""
|
"""
|
||||||
# Need at least a few symbols to start decoding
|
FORMAT_SPECIFIERS = {102, 112, 114, 116, 120, 123}
|
||||||
num_symbols = len(self.message_bits) // 10
|
FORMAT_TEXT = {
|
||||||
|
102: "GEOGRAPHIC_AREA",
|
||||||
|
112: "DISTRESS",
|
||||||
|
114: "GROUP",
|
||||||
|
116: "ALL_SHIPS",
|
||||||
|
120: "INDIVIDUAL",
|
||||||
|
123: "INDIVIDUAL_SEMI_AUTO",
|
||||||
|
}
|
||||||
|
CATEGORY_TEXT = {
|
||||||
|
112: "DISTRESS",
|
||||||
|
110: "URGENCY",
|
||||||
|
108: "SAFETY",
|
||||||
|
106: "SHIPS_BUSINESS",
|
||||||
|
100: "ROUTINE",
|
||||||
|
}
|
||||||
|
# Nature of distress (Table 10 / Table A1-3), confirmed against
|
||||||
|
# spec text directly, consistent across M.493-8 through -15.
|
||||||
|
NATURE_TEXT = {
|
||||||
|
100: "FIRE_EXPLOSION",
|
||||||
|
101: "FLOODING",
|
||||||
|
102: "COLLISION",
|
||||||
|
103: "GROUNDING",
|
||||||
|
104: "LISTING_CAPSIZE_DANGER",
|
||||||
|
105: "SINKING",
|
||||||
|
106: "DISABLED_ADRIFT",
|
||||||
|
107: "UNDESIGNATED",
|
||||||
|
108: "ABANDONING_SHIP",
|
||||||
|
109: "PIRACY_ARMED_ROBBERY",
|
||||||
|
110: "MAN_OVERBOARD",
|
||||||
|
}
|
||||||
|
# Telecommand values (Table 11/12), confirmed directly against
|
||||||
|
# spec text. This is not the full table - only values we've
|
||||||
|
# actually cited are included; anything else reports as
|
||||||
|
# UNKNOWN-<code> rather than a guess. 126 ("no information") is
|
||||||
|
# by far the most common value for the second telecommand slot,
|
||||||
|
# since most calls don't use it for anything.
|
||||||
|
TELECOMMAND_TEXT = {
|
||||||
|
100: "F3E/G3E ALL MODES TP",
|
||||||
|
101: "F3E/G3E DUPLEX TP",
|
||||||
|
118: "TEST",
|
||||||
|
121: "POSITION_OR_LOCATION_UPDATING",
|
||||||
|
126: "NO_INFORMATION",
|
||||||
|
}
|
||||||
|
# EOS (end of sequence) meaning, per ITU-R M.493 Sec 9, confirmed
|
||||||
|
# directly against spec text.
|
||||||
|
EOS_TEXT = {
|
||||||
|
117: "ACKNOWLEDGEMENT_REQUESTED",
|
||||||
|
122: "ACKNOWLEDGEMENT_REPLY",
|
||||||
|
127: "NO_ACKNOWLEDGEMENT_REQUIRED",
|
||||||
|
}
|
||||||
|
VALID_EOS = {117, 122, 127}
|
||||||
|
# Field layouts per format specifier: (field_name, symbol_count),
|
||||||
|
# in transmission order after the format specifier. Per Table 5
|
||||||
|
# (selective calls) and Table 4 (distress). Distress has no
|
||||||
|
# address/category field (priority is the format specifier
|
||||||
|
# itself).
|
||||||
|
FIELD_LAYOUTS = {
|
||||||
|
120: [("address", 5), ("category", 1), ("self_id", 5),
|
||||||
|
("telecommand", 2), ("frequency_1", 3), ("frequency_2", 3)],
|
||||||
|
123: [("address", 5), ("category", 1), ("self_id", 5),
|
||||||
|
("telecommand", 2), ("frequency_1", 3)],
|
||||||
|
116: [("category", 1), ("self_id", 5), ("telecommand", 2),
|
||||||
|
("frequency_1", 3)],
|
||||||
|
114: [("address", 5), ("category", 1), ("self_id", 5),
|
||||||
|
("telecommand", 2), ("frequency_1", 3), ("frequency_2", 3)],
|
||||||
|
102: [("address", 5), ("category", 1), ("self_id", 5),
|
||||||
|
("telecommand", 2), ("frequency_1", 3), ("frequency_2", 3)],
|
||||||
|
112: [("self_id", 5), ("nature", 1), ("coordinates", 5),
|
||||||
|
("time", 2), ("telecommand_msg4", 1)],
|
||||||
|
}
|
||||||
|
# Format 116 is overloaded: normally "All Ships" broadcast (the
|
||||||
|
# FIELD_LAYOUTS[116] entry above), but per ITU-R M.493-16 it is
|
||||||
|
# ALSO used for distress alert acknowledgement / distress
|
||||||
|
# self-cancel (Table A1-4.2) - a structurally different message.
|
||||||
|
# Per spec: "Distress acknowledgments where the transmitting ID
|
||||||
|
# and ship in distress ID are the same...should be interpreted
|
||||||
|
# as a self-cancel operation." Distinguished by category: a
|
||||||
|
# normal All Ships broadcast uses ROUTINE/SAFETY/URGENCY: a
|
||||||
|
# distress acknowledgement/self-cancel uses category DISTRESS
|
||||||
|
# (112). Layout below empirically mapped and validated against a
|
||||||
|
# real captured self-cancel message (own MMSI confirmed present,
|
||||||
|
# nature/coordinates/time matching the alert being cancelled).
|
||||||
|
# field_8_unconfirmed's exact semantic meaning is not yet
|
||||||
|
# confirmed against spec text - flagged honestly rather than
|
||||||
|
# guessed.
|
||||||
|
DISTRESS_ACK_LAYOUT_116 = [
|
||||||
|
("ship_in_distress_id", 5),
|
||||||
|
("field_8_unconfirmed", 1),
|
||||||
|
("ship_in_distress_id_repeat", 5),
|
||||||
|
("nature", 1),
|
||||||
|
("coordinates", 5),
|
||||||
|
("time", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
num_symbols = len(self.message_bits) // 10
|
||||||
if num_symbols < 5:
|
if num_symbols < 5:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Extract symbols (10 bits each)
|
|
||||||
symbols = []
|
symbols = []
|
||||||
for i in range(num_symbols):
|
for i in range(num_symbols):
|
||||||
start = i * 10
|
symbol_bits = self.message_bits[i * 10:i * 10 + 10]
|
||||||
end = start + 10
|
symbol_value = self._bits_to_symbol(symbol_bits)
|
||||||
if end <= len(self.message_bits):
|
symbols.append(symbol_value)
|
||||||
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
|
# Find the message anchor: first position where a real format
|
||||||
# start of the message. Per ITU-R M.493, after the dot pattern
|
# specifier value repeats exactly 2 positions later.
|
||||||
# there are 7 phasing symbols before the format specifier.
|
anchor = None
|
||||||
# Bound to max 7 — if more are present, this is a bad sync.
|
for i in range(len(symbols) - 2):
|
||||||
msg_start = 0
|
if symbols[i] in FORMAT_SPECIFIERS and symbols[i] == symbols[i + 2]:
|
||||||
for i, sym in enumerate(symbols):
|
anchor = i
|
||||||
if 120 <= sym <= 126:
|
|
||||||
msg_start = i + 1
|
|
||||||
else:
|
|
||||||
break
|
break
|
||||||
if msg_start > 7:
|
if anchor is None:
|
||||||
logger.debug("DSC bad sync: >7 phasing symbols stripped")
|
|
||||||
return None
|
|
||||||
symbols = symbols[msg_start:]
|
|
||||||
|
|
||||||
if len(symbols) < 5:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Look for EOS (End of Sequence) - symbols 117, 122, or 127
|
# Track A is the primary de-interleaved sequence used for field
|
||||||
# EOS must appear after at least MIN_SYMBOLS_FOR_FORMAT symbols
|
# boundaries. Track B (offset by one raw symbol position) is the
|
||||||
eos_found = False
|
# redundant time-diversity copy: per ITU-R M.493 Annex 1 Sec 1.2,
|
||||||
eos_index = -1
|
# every character apart from phasing is transmitted twice (DX
|
||||||
for i, sym in enumerate(symbols):
|
# then RX). Confirmed empirically: Track A's logical position k
|
||||||
if sym in VALID_EOS:
|
# pairs with Track B's position k+2 (Track B starts 2 positions
|
||||||
if i < MIN_SYMBOLS_FOR_FORMAT:
|
# "behind" since it still has 2 trailing phasing remnants before
|
||||||
continue # Too early — not a real EOS
|
# it catches up to real content) - validated with zero mismatches
|
||||||
eos_found = True
|
# across a full real capture, and confirmed to correctly recover
|
||||||
eos_index = i
|
# a synthetically-corrupted symbol in testing. When Track A's
|
||||||
|
# copy of a character fails the check-bit test, fall back to
|
||||||
|
# Track B's copy before giving up on that character - this is
|
||||||
|
# the actual error-correction mechanism ITU-R M.493 relies on.
|
||||||
|
dedup = symbols[anchor::2]
|
||||||
|
dedup_b = symbols[anchor + 1::2]
|
||||||
|
if len(dedup) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def value_at(k):
|
||||||
|
"""Track A position k, falling back to Track B's diversity
|
||||||
|
copy (position k+2) if Track A's copy failed check bits.
|
||||||
|
This is the diversity-combining fallback that recovers
|
||||||
|
isolated real-world bit errors using ITU-R M.493's built-in
|
||||||
|
time-diversity redundancy."""
|
||||||
|
primary = dedup[k] if k < len(dedup) else -1
|
||||||
|
if primary != -1:
|
||||||
|
return primary
|
||||||
|
fallback_idx = k + 2
|
||||||
|
if 0 <= fallback_idx < len(dedup_b):
|
||||||
|
return dedup_b[fallback_idx]
|
||||||
|
return -1
|
||||||
|
|
||||||
|
format_code = value_at(0)
|
||||||
|
layout = FIELD_LAYOUTS.get(format_code)
|
||||||
|
if layout is None:
|
||||||
|
logger.debug(f"DSC unsupported/unrecognized format specifier: {format_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Format 116 needs a peek at its category field to know which of
|
||||||
|
# the two structurally different layouts applies (see
|
||||||
|
# DISTRESS_ACK_LAYOUT_116 definition above for why).
|
||||||
|
is_distress_ack = False
|
||||||
|
if format_code == 116:
|
||||||
|
peeked_category = value_at(2)
|
||||||
|
if peeked_category == 112: # DISTRESS
|
||||||
|
layout = DISTRESS_ACK_LAYOUT_116
|
||||||
|
is_distress_ack = True
|
||||||
|
|
||||||
|
idx = 2 # skip the two format-specifier copies
|
||||||
|
fields = {}
|
||||||
|
if is_distress_ack:
|
||||||
|
fields["category"] = [112]
|
||||||
|
idx += 1 # already consumed by the peek above
|
||||||
|
for name, count in layout:
|
||||||
|
if idx + count > len(dedup):
|
||||||
|
return None # not enough data yet, keep accumulating
|
||||||
|
chunk = [value_at(idx + j) for j in range(count)]
|
||||||
|
fields[name] = chunk
|
||||||
|
idx += count
|
||||||
|
|
||||||
|
eos = None
|
||||||
|
for k in range(idx, len(dedup)):
|
||||||
|
v = value_at(k)
|
||||||
|
if v in VALID_EOS:
|
||||||
|
eos = v
|
||||||
break
|
break
|
||||||
|
if eos is None:
|
||||||
|
return None # message not complete yet
|
||||||
|
|
||||||
if not eos_found:
|
message = {
|
||||||
# Not complete yet
|
"type": "dsc",
|
||||||
|
"format": format_code,
|
||||||
|
"format_text": FORMAT_TEXT.get(format_code, f"UNKNOWN-{format_code}"),
|
||||||
|
"eos": eos,
|
||||||
|
"eos_text": EOS_TEXT.get(eos, f"UNKNOWN-{eos}"),
|
||||||
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||||
|
}
|
||||||
|
if is_distress_ack:
|
||||||
|
message["format_text"] = "DISTRESS_ACK_OR_SELF_CANCEL"
|
||||||
|
if "address" in fields:
|
||||||
|
message["dest_mmsi"] = self._decode_mmsi(fields["address"])
|
||||||
|
if "self_id" in fields:
|
||||||
|
message["source_mmsi"] = self._decode_mmsi(fields["self_id"])
|
||||||
|
if "ship_in_distress_id" in fields:
|
||||||
|
ship_mmsi = self._decode_mmsi(fields["ship_in_distress_id"])
|
||||||
|
message["ship_in_distress_mmsi"] = ship_mmsi
|
||||||
|
if "source_mmsi" not in message:
|
||||||
|
# for a self-cancel, transmitting station IS the ship in
|
||||||
|
# distress - report it as source too for consistency with
|
||||||
|
# other message types
|
||||||
|
message["source_mmsi"] = ship_mmsi
|
||||||
|
if "category" in fields:
|
||||||
|
cat_val = fields["category"][0]
|
||||||
|
message["category"] = CATEGORY_TEXT.get(cat_val, f"UNKNOWN-{cat_val}")
|
||||||
|
if format_code == 112 or is_distress_ack:
|
||||||
|
message["category"] = "DISTRESS"
|
||||||
|
if "nature" in fields:
|
||||||
|
nature_val = fields["nature"][0]
|
||||||
|
message["nature"] = nature_val
|
||||||
|
message["nature_text"] = NATURE_TEXT.get(nature_val, f"UNKNOWN-{nature_val}")
|
||||||
|
if "coordinates" in fields:
|
||||||
|
message["coordinates_raw"] = fields["coordinates"]
|
||||||
|
message["position"] = self._decode_position(fields["coordinates"])
|
||||||
|
if "time" in fields:
|
||||||
|
message["time_raw"] = fields["time"]
|
||||||
|
if "telecommand" in fields:
|
||||||
|
message["telecommand"] = fields["telecommand"]
|
||||||
|
message["telecommand_text"] = [
|
||||||
|
TELECOMMAND_TEXT.get(t, f"UNKNOWN-{t}") for t in fields["telecommand"]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Plain-English summary, built from the fields actually present -
|
||||||
|
# different message types (individual/group/area, all ships,
|
||||||
|
# distress alert, distress ack/self-cancel) have different fields,
|
||||||
|
# so this is assembled per-type rather than one fixed template.
|
||||||
|
eos_phrase = {
|
||||||
|
117: "requesting acknowledgement",
|
||||||
|
122: "acknowledging a previous call",
|
||||||
|
127: "no acknowledgement required",
|
||||||
|
}.get(eos, "unknown EOS")
|
||||||
|
tc_phrase = message.get("telecommand_text", [None])[0]
|
||||||
|
tc_phrase = f", purpose: {tc_phrase}" if tc_phrase and tc_phrase != "NO_INFORMATION" else ""
|
||||||
|
|
||||||
|
if is_distress_ack:
|
||||||
|
message["summary"] = (
|
||||||
|
f"Distress self-cancel/acknowledgement from {message.get('source_mmsi')} "
|
||||||
|
f"for vessel {message.get('ship_in_distress_mmsi')} "
|
||||||
|
f"(original distress nature: {message.get('nature_text', 'unknown')})"
|
||||||
|
)
|
||||||
|
elif format_code == 112:
|
||||||
|
message["summary"] = (
|
||||||
|
f"DISTRESS ALERT from {message.get('source_mmsi')} - "
|
||||||
|
f"nature: {message.get('nature_text', 'unknown')}"
|
||||||
|
)
|
||||||
|
elif "dest_mmsi" in message:
|
||||||
|
message["summary"] = (
|
||||||
|
f"{message.get('category', 'UNKNOWN')} individual/group call from "
|
||||||
|
f"{message.get('source_mmsi')} to {message.get('dest_mmsi')}, "
|
||||||
|
f"{eos_phrase}{tc_phrase}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message["summary"] = (
|
||||||
|
f"{message.get('category', 'UNKNOWN')} broadcast from "
|
||||||
|
f"{message.get('source_mmsi')} to all ships, {eos_phrase}{tc_phrase}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
def _diagnose_decode_failure(self) -> str:
|
||||||
|
"""
|
||||||
|
Explain exactly why the current message_bits buffer failed to
|
||||||
|
decode, for logging at timeout. Mirrors _try_decode_message's
|
||||||
|
logic but reports where it stopped instead of returning None
|
||||||
|
silently - normal accumulation still returns None quietly (this
|
||||||
|
is only called once, at the moment of timeout, not on every
|
||||||
|
partial-accumulation call).
|
||||||
|
"""
|
||||||
|
FORMAT_SPECIFIERS = {102, 112, 114, 116, 120, 123}
|
||||||
|
FIELD_LAYOUTS = {
|
||||||
|
120: [("address", 5), ("category", 1), ("self_id", 5),
|
||||||
|
("telecommand", 2), ("frequency_1", 3), ("frequency_2", 3)],
|
||||||
|
123: [("address", 5), ("category", 1), ("self_id", 5),
|
||||||
|
("telecommand", 2), ("frequency_1", 3)],
|
||||||
|
116: [("category", 1), ("self_id", 5), ("telecommand", 2),
|
||||||
|
("frequency_1", 3)],
|
||||||
|
114: [("address", 5), ("category", 1), ("self_id", 5),
|
||||||
|
("telecommand", 2), ("frequency_1", 3), ("frequency_2", 3)],
|
||||||
|
102: [("address", 5), ("category", 1), ("self_id", 5),
|
||||||
|
("telecommand", 2), ("frequency_1", 3), ("frequency_2", 3)],
|
||||||
|
112: [("self_id", 5), ("nature", 1), ("coordinates", 5),
|
||||||
|
("time", 2), ("telecommand_msg4", 1)],
|
||||||
|
}
|
||||||
|
VALID_EOS = {117, 122, 127}
|
||||||
|
|
||||||
|
num_symbols = len(self.message_bits) // 10
|
||||||
|
symbols = [self._bits_to_symbol(self.message_bits[i * 10:i * 10 + 10]) for i in range(num_symbols)]
|
||||||
|
invalid_count = sum(1 for s in symbols if s == -1)
|
||||||
|
|
||||||
|
anchor = None
|
||||||
|
for i in range(len(symbols) - 2):
|
||||||
|
if symbols[i] in FORMAT_SPECIFIERS and symbols[i] == symbols[i + 2]:
|
||||||
|
anchor = i
|
||||||
|
break
|
||||||
|
if anchor is None:
|
||||||
|
return (f"no anchor found ({num_symbols} symbols, {invalid_count} invalid, "
|
||||||
|
f"first 15 symbols={symbols[:15]})")
|
||||||
|
|
||||||
|
dedup = symbols[anchor::2]
|
||||||
|
dedup_b = symbols[anchor + 1::2]
|
||||||
|
|
||||||
|
def value_at(k):
|
||||||
|
primary = dedup[k] if k < len(dedup) else -1
|
||||||
|
if primary != -1:
|
||||||
|
return primary
|
||||||
|
fb = k + 2
|
||||||
|
return dedup_b[fb] if 0 <= fb < len(dedup_b) else -1
|
||||||
|
|
||||||
|
format_code = value_at(0)
|
||||||
|
layout = FIELD_LAYOUTS.get(format_code)
|
||||||
|
if layout is None:
|
||||||
|
return f"anchor found at {anchor}, but format specifier {format_code} not recognized"
|
||||||
|
|
||||||
|
idx = 2
|
||||||
|
for name, count in layout:
|
||||||
|
if idx + count > len(dedup):
|
||||||
|
have = len(dedup) - idx
|
||||||
|
return (f"format={format_code}, stalled waiting for field "
|
||||||
|
f"'{name}' (need {count} symbols, have {max(have, 0)})")
|
||||||
|
idx += count
|
||||||
|
|
||||||
|
for k in range(idx, len(dedup)):
|
||||||
|
if value_at(k) in VALID_EOS:
|
||||||
|
return "all fields complete, EOS found - should have decoded (unexpected)"
|
||||||
|
|
||||||
|
return (f"format={format_code}, all fields complete, but no valid EOS "
|
||||||
|
f"found in remaining {len(dedup) - idx} symbols")
|
||||||
|
|
||||||
|
def _decode_mmsi(self, symbols: list[int]) -> str | None:
|
||||||
|
"""
|
||||||
|
Decode 5 symbols into a 9-digit MMSI string.
|
||||||
|
|
||||||
|
Per ITU-R M.493-16: identities are transmitted as five
|
||||||
|
characters C5C4C3C2C1, comprising ten digits X1...X10, "whereas
|
||||||
|
digit X10 is always the digit 0" (unless M.1080 extended
|
||||||
|
addressing is in use, not handled here). X10 is the LAST digit
|
||||||
|
of the sequence, not the first - so the real 9-digit MMSI is
|
||||||
|
digits 1-9, dropping the trailing padding digit. Confirmed
|
||||||
|
against real over-the-air captures: this produces MMSIs with
|
||||||
|
recognizable, valid Maritime Identification Digits (e.g. "338"
|
||||||
|
= USA) consistently across independent real recordings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbols: 5 raw symbol values (0-99 each)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
9-digit MMSI string, or None if malformed input.
|
||||||
|
"""
|
||||||
|
if len(symbols) != 5 or any(s < 0 or s > 99 for s in symbols):
|
||||||
return None
|
return None
|
||||||
|
digits = "".join(f"{s:02d}" for s in symbols)
|
||||||
|
return digits[:9]
|
||||||
|
|
||||||
# Decode the message from symbols
|
def _decode_position(self, symbols: list[int]) -> dict | None:
|
||||||
return self._decode_symbols(symbols[: eos_index + 1])
|
"""
|
||||||
|
Decode 5 coordinate symbols into a position dict, per ITU-R
|
||||||
|
M.493 Annex 1 Sec 8.1.2/8.2.3 (Table A1-2), confirmed
|
||||||
|
consistent across M.493-8 through -15. 10 digits total:
|
||||||
|
digit 1 = quadrant (0=NE, 1=NW, 2=SE, 3=SW), digits 2-5 =
|
||||||
|
latitude (2 degrees + 2 minutes), digits 6-10 = longitude
|
||||||
|
(3 degrees + 2 minutes). All-9s (9999999999) is the spec-
|
||||||
|
defined "position not available" sentinel.
|
||||||
|
|
||||||
|
Physically-impossible values (degrees/minutes outside valid
|
||||||
|
range - e.g. from a residual bit error the diversity-combining
|
||||||
|
fallback in _process_bit didn't catch) are NOT silently
|
||||||
|
discarded or clamped: the computed position is still returned
|
||||||
|
(a partial position can still be useful - e.g. for search and
|
||||||
|
rescue, where even an approximate position beats none at all),
|
||||||
|
but flagged via "position_uncertain"/"position_issue" so
|
||||||
|
downstream consumers can decide how much to trust it rather
|
||||||
|
than being silently given a wrong-but-plausible-looking value.
|
||||||
|
|
||||||
|
Returns a dict with "lat"/"lon" as signed decimal degrees
|
||||||
|
(matching the pre-existing message["position"] shape this
|
||||||
|
decoder's downstream consumers - parser.py/routes/dsc.py -
|
||||||
|
expect) plus human-readable degree/minute text fields and the
|
||||||
|
uncertainty flag described above. Returns None only if the
|
||||||
|
position is unavailable (all-9s sentinel) or the input itself
|
||||||
|
is malformed (wrong length/out-of-range symbols).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbols: 5 raw symbol values (0-99 each)
|
||||||
|
"""
|
||||||
|
if len(symbols) != 5 or any(s < 0 or s > 99 for s in symbols):
|
||||||
|
return None
|
||||||
|
digits = "".join(f"{s:02d}" for s in symbols)
|
||||||
|
if digits == "9999999999":
|
||||||
|
return None
|
||||||
|
quadrant = int(digits[0])
|
||||||
|
lat_deg = int(digits[1:3])
|
||||||
|
lat_min = int(digits[3:5])
|
||||||
|
lon_deg = int(digits[5:8])
|
||||||
|
lon_min = int(digits[8:10])
|
||||||
|
|
||||||
|
issues = []
|
||||||
|
if lat_deg > 90:
|
||||||
|
issues.append(f"latitude degrees ({lat_deg}) exceeds valid range (0-90)")
|
||||||
|
if lon_deg > 180:
|
||||||
|
issues.append(f"longitude degrees ({lon_deg}) exceeds valid range (0-180)")
|
||||||
|
if lat_min > 59:
|
||||||
|
issues.append(f"latitude minutes ({lat_min}) exceeds valid range (0-59)")
|
||||||
|
if lon_min > 59:
|
||||||
|
issues.append(f"longitude minutes ({lon_min}) exceeds valid range (0-59)")
|
||||||
|
|
||||||
|
lat_sign = 1 if quadrant in (0, 1) else -1 # NE, NW -> North
|
||||||
|
lon_sign = 1 if quadrant in (0, 2) else -1 # NE, SE -> East
|
||||||
|
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),
|
||||||
|
"lat_text": f"{lat_deg:02d}°{lat_min:02d}'{'N' if lat_sign > 0 else 'S'}",
|
||||||
|
"lon_text": f"{lon_deg:03d}°{lon_min:02d}'{'E' if lon_sign > 0 else 'W'}",
|
||||||
|
"position_uncertain": bool(issues),
|
||||||
|
"position_issue": "; ".join(issues) if issues else None,
|
||||||
|
}
|
||||||
|
|
||||||
def _bits_to_symbol(self, bits: list[int]) -> int:
|
def _bits_to_symbol(self, bits: list[int]) -> int:
|
||||||
"""
|
"""
|
||||||
Convert 10 bits to symbol value.
|
Convert 10 bits to symbol value.
|
||||||
|
|
||||||
DSC uses 10-bit symbols: 7 information bits + 3 error bits.
|
Per ITU-R M.493 Annex 1 Sec 2 / Table 1: each DSC character is
|
||||||
The 3 check bits provide parity such that the total number of
|
a 10-bit code - 7 information bits (bits 1-7, transmitted LSB
|
||||||
'1' bits across all 10 bits should be even (even parity).
|
first) plus 3 check bits (bits 8-10, transmitted MSB first).
|
||||||
Returns -1 if the check bits are invalid.
|
The check bits encode, as a 3-bit binary number, the count of
|
||||||
|
"B" elements (binary 0) among the 7 information bits - this is
|
||||||
|
NOT simple parity. E.g. a BYY check-bit sequence (0,1,1) means
|
||||||
|
the symbol's info bits contain exactly 3 B-elements (spec's own
|
||||||
|
worked example).
|
||||||
|
|
||||||
|
Returns -1 if the check bits don't match the actual B-element
|
||||||
|
count in the info bits (character failed the ten-unit
|
||||||
|
error-detecting code).
|
||||||
"""
|
"""
|
||||||
if len(bits) != 10:
|
if len(bits) != 10:
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
# First 7 bits are data (LSB first in DSC)
|
info_bits = bits[:7]
|
||||||
|
check_bits = bits[7:10]
|
||||||
|
|
||||||
|
# Information bits: LSB first
|
||||||
value = 0
|
value = 0
|
||||||
for i in range(7):
|
for i in range(7):
|
||||||
if bits[i]:
|
if info_bits[i]:
|
||||||
value |= 1 << i
|
value |= 1 << i
|
||||||
|
|
||||||
# Validate check bits: total number of 1s should be even
|
# Check bits: MSB first, encode count of B (0-value) info bits
|
||||||
ones = sum(bits)
|
check_value = (check_bits[0] << 2) | (check_bits[1] << 1) | check_bits[2]
|
||||||
if ones % 2 != 0:
|
num_b_elements = 7 - sum(info_bits)
|
||||||
|
|
||||||
|
if check_value != num_b_elements:
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
return value
|
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]:
|
def read_audio_stdin() -> Generator[bytes, None, None]:
|
||||||
"""
|
"""
|
||||||
Read audio from stdin in chunks.
|
Read audio from stdin in chunks.
|
||||||
|
|||||||
Reference in New Issue
Block a user