""" 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"