Files
intercept/utils/dsc/parser.py
T
James Smith 96172ca593 style: apply ruff-format to entire codebase
First-time run of ruff-format via pre-commit hook normalises quote
style, trailing commas, and whitespace across 188 Python files.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 14:48:11 +01:00

378 lines
10 KiB
Python

"""
DSC message parser.
Parses DSC decoder JSON output and provides utility functions for
MMSI country resolution, distress nature text, etc.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime
from typing import Any
from .constants import (
CATEGORY_PRIORITY,
DISTRESS_NATURE_CODES,
FORMAT_CODES,
MID_COUNTRY_MAP,
TELECOMMAND_CODES,
VALID_EOS,
VALID_FORMAT_SPECIFIERS,
)
logger = logging.getLogger("intercept.dsc.parser")
def get_country_from_mmsi(mmsi: str) -> str | None:
"""
Derive country from MMSI using Maritime Identification Digits (MID).
The first 3 digits of a 9-digit MMSI identify the country.
Args:
mmsi: The MMSI number as string
Returns:
Country name if found, None otherwise
"""
if not mmsi or len(mmsi) < 3:
return None
# Normal ship MMSI: starts with MID (3 digits)
mid = mmsi[:3]
if mid in MID_COUNTRY_MAP:
return MID_COUNTRY_MAP[mid]
# Coast station MMSI: starts with 00 + MID
if mmsi.startswith("00") and len(mmsi) >= 5:
mid = mmsi[2:5]
if mid in MID_COUNTRY_MAP:
return MID_COUNTRY_MAP[mid]
# Group ship station MMSI: starts with 0 + MID
if mmsi.startswith("0") and len(mmsi) >= 4:
mid = mmsi[1:4]
if mid in MID_COUNTRY_MAP:
return MID_COUNTRY_MAP[mid]
return None
def get_distress_nature_text(code: int | str) -> str:
"""Get human-readable text for distress nature code."""
if isinstance(code, str):
try:
code = int(code)
except ValueError:
return str(code)
return DISTRESS_NATURE_CODES.get(code, f"UNKNOWN ({code})")
def get_format_text(code: int | str) -> str:
"""Get human-readable text for format code."""
if isinstance(code, str):
try:
code = int(code)
except ValueError:
return str(code)
return FORMAT_CODES.get(code, f"UNKNOWN ({code})")
def get_telecommand_text(code: int | str) -> str:
"""Get human-readable text for telecommand code."""
if isinstance(code, str):
try:
code = int(code)
except ValueError:
return str(code)
return TELECOMMAND_CODES.get(code, f"UNKNOWN ({code})")
def get_category_priority(category: str) -> int:
"""Get priority level for a category (lower = higher priority)."""
return CATEGORY_PRIORITY.get(category.upper(), 10)
def parse_dsc_message(raw_line: str) -> dict[str, Any] | None:
"""
Parse DSC decoder JSON output line.
The decoder outputs JSON lines with fields like:
{
"type": "dsc",
"format": 100,
"source_mmsi": "123456789",
"dest_mmsi": "000000000",
"category": "DISTRESS",
"nature": 101,
"position": {"lat": 51.5, "lon": -0.1},
"telecommand1": 100,
"telecommand2": null,
"channel": 16,
"timestamp": "2025-01-15T12:00:00Z",
"raw": "..."
}
Args:
raw_line: Raw JSON line from decoder
Returns:
Parsed message dict or None if parsing fails
"""
if not raw_line or not raw_line.strip():
return None
try:
data = json.loads(raw_line.strip())
except json.JSONDecodeError as e:
logger.debug(f"Failed to parse DSC JSON: {e}")
return None
# Validate required fields
if data.get("type") != "dsc":
return None
if "source_mmsi" not in data:
return None
# 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": 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(),
}
# Add country from MMSI
country = get_country_from_mmsi(msg["source_mmsi"])
if country:
msg["source_country"] = country
# Add distress nature if present
if data.get("nature") is not None:
msg["nature_code"] = data["nature"]
msg["nature_of_distress"] = get_distress_nature_text(data["nature"])
# Add position if present
position = data.get("position")
if position and isinstance(position, dict):
lat = position.get("lat")
lon = position.get("lon")
if lat is not None and lon is not None:
try:
msg["latitude"] = float(lat)
msg["longitude"] = float(lon)
except (ValueError, TypeError):
pass
# Add telecommand info
if data.get("telecommand1") is not None:
msg["telecommand1"] = data["telecommand1"]
msg["telecommand1_text"] = get_telecommand_text(data["telecommand1"])
if data.get("telecommand2") is not None:
msg["telecommand2"] = data["telecommand2"]
msg["telecommand2_text"] = get_telecommand_text(data["telecommand2"])
# Add channel if present
if data.get("channel") is not None:
msg["channel"] = data["channel"]
# Add EOS (End of Sequence) info
if "eos" in data:
msg["eos"] = data["eos"]
# Add raw message for debugging
if "raw" in data:
msg["raw_message"] = data["raw"]
# Calculate priority
msg["priority"] = get_category_priority(msg["category"])
# Mark if this is a critical alert
msg["is_critical"] = msg["category"] in (
"DISTRESS",
"DISTRESS_ACK",
"DISTRESS_RELAY",
"URGENCY",
"SAFETY",
"ALL_SHIPS_URGENCY_SAFETY",
)
return msg
def format_dsc_for_display(msg: dict) -> str:
"""
Format a DSC message for human-readable display.
Args:
msg: Parsed DSC message dict
Returns:
Formatted string for display
"""
lines = []
# Header with category and MMSI
category = msg.get("category", "UNKNOWN")
mmsi = msg.get("source_mmsi", "UNKNOWN")
country = msg.get("source_country", "")
header = f"[{category}] MMSI: {mmsi}"
if country:
header += f" ({country})"
lines.append(header)
# Destination if present
if msg.get("dest_mmsi"):
lines.append(f" To: {msg['dest_mmsi']}")
# Distress nature
if msg.get("nature_of_distress"):
lines.append(f" Nature: {msg['nature_of_distress']}")
# Position
if msg.get("latitude") is not None and msg.get("longitude") is not None:
lat = msg["latitude"]
lon = msg["longitude"]
lat_dir = "N" if lat >= 0 else "S"
lon_dir = "E" if lon >= 0 else "W"
lines.append(f" Position: {abs(lat):.4f}{lat_dir} {abs(lon):.4f}{lon_dir}")
# Telecommand
if msg.get("telecommand1_text"):
lines.append(f" Request: {msg['telecommand1_text']}")
# Channel
if msg.get("channel"):
lines.append(f" Channel: {msg['channel']}")
# Timestamp
if msg.get("timestamp"):
lines.append(f" Time: {msg['timestamp']}")
return "\n".join(lines)
def validate_mmsi(mmsi: str) -> bool:
"""
Validate MMSI format.
MMSI is a 9-digit number. Ship stations start with non-zero digit.
Coast stations start with 00. Group stations start with 0.
Args:
mmsi: MMSI string to validate
Returns:
True if valid MMSI format
"""
if not mmsi:
return False
# Must be 9 digits
if not re.match(r"^\d{9}$", mmsi):
return False
# All zeros is invalid
return mmsi != "000000000"
def classify_mmsi(mmsi: str) -> str:
"""
Classify MMSI type.
Args:
mmsi: MMSI string
Returns:
Classification: 'ship', 'coast', 'group', 'sar', 'aton', or 'unknown'
"""
if not validate_mmsi(mmsi):
return "unknown"
first_digit = mmsi[0]
first_two = mmsi[:2]
first_three = mmsi[:3]
# Coast station: starts with 00
if first_two == "00":
return "coast"
# Group call: starts with 0
if first_digit == "0":
return "group"
# SAR aircraft: starts with 111
if first_three == "111":
return "sar"
# Aids to Navigation: starts with 99
if first_two == "99":
return "aton"
# Ship station: starts with MID (2-7)
if first_digit in "234567":
return "ship"
return "unknown"