""" AIS vessel decoder — Types 1/2/3, 5, 18, 19, 24A/24B. Returns dicts compatible with process_message() in main.py. """ from __future__ import annotations from datetime import datetime SHIP_TYPE_NAMES: dict[int, str] = { 0: "Not available", 20: "WIG", 30: "Fishing", 31: "Towing", 32: "Towing >200m", 33: "Dredging", 34: "Diving ops", 35: "Military", 36: "Sailing", 37: "Pleasure craft", 40: "High speed craft", 50: "Pilot vessel", 51: "Search and rescue", 52: "Tug", 53: "Port tender", 54: "Anti-pollution", 55: "Law enforcement", 58: "Medical transport", 60: "Passenger", 61: "Passenger haz-A", 62: "Passenger haz-B", 69: "Passenger (other)", 70: "Cargo", 71: "Cargo haz-A", 72: "Cargo haz-B", 79: "Cargo (other)", 80: "Tanker", 81: "Tanker haz-A", 82: "Tanker haz-B", 89: "Tanker (other)", 90: "Other", 99: "Other (no info)", } NAV_STATUS_NAMES: dict[int, str] = { 0: "Under way using engine", 1: "At anchor", 2: "Not under command", 3: "Restricted manoeuvrability", 4: "Constrained by draught", 5: "Moored", 6: "Aground", 7: "Engaged in fishing", 8: "Under way sailing", 9: "Reserved (HSC)", 10: "Reserved (WIG)", 11: "Power-driven towing astern", 12: "Power-driven pushing ahead", 13: "Reserved", 14: "AIS-SART/MOB/EPIRB", 15: "Undefined", } def _bits(payload: str, start: int, length: int) -> int: acc = 0 for i in range(start, start + length): if i >= len(payload) * 6: return 0 char_idx = i // 6 bit_idx = 5 - (i % 6) char_val = ord(payload[char_idx]) - 48 if char_val > 39: char_val -= 8 acc = (acc << 1) | ((char_val >> bit_idx) & 1) return acc def _signed(val: int, bits: int) -> int: if val >= (1 << (bits - 1)): val -= (1 << bits) return val def _text6(payload: str, start: int, length: int) -> str: chars = [] for i in range(length // 6): v = _bits(payload, start + i * 6, 6) if v < 32: v += 64 if v == 64: break chars.append(chr(v)) return ''.join(chars).strip('@').strip() def decode_type123(payload: str) -> dict | None: """Type 1, 2, 3 — Class A CNB position report.""" try: msg_type = _bits(payload, 0, 6) if msg_type not in (1, 2, 3): return None nav_status = _bits(payload, 6, 4) rot_raw = _signed(_bits(payload, 10, 8), 8) sog_raw = _bits(payload, 50, 10) lon_raw = _signed(_bits(payload, 61, 28), 28) lat_raw = _signed(_bits(payload, 89, 27), 27) cog_raw = _bits(payload, 116, 12) heading = _bits(payload, 128, 9) mmsi = _bits(payload, 8, 30) lon = lon_raw / 600000.0 lat = lat_raw / 600000.0 sog = sog_raw / 10.0 cog = cog_raw / 10.0 if lat == 0.0 and lon == 0.0: return None if abs(lat) > 90 or abs(lon) > 180: return None rot = None if rot_raw not in (0, 127, -128): rot = round((rot_raw / 4.733) ** 2 * (1 if rot_raw > 0 else -1), 1) return { "type": "vessel", "msg_type": msg_type, "mmsi": str(mmsi), "nav_status": nav_status, "nav_status_name": NAV_STATUS_NAMES.get(nav_status, "Unknown"), "rot": rot, "sog": sog, "lat": round(lat, 6), "lon": round(lon, 6), "cog": cog, "heading": heading if heading != 511 else None, "fix_type": _bits(payload, 148, 4), "timestamp": datetime.utcnow().isoformat(), "source": "AIS", } except Exception: return None def decode_type5(payload: str) -> dict | None: """Type 5 — Class A static + voyage data.""" try: msg_type = _bits(payload, 0, 6) if msg_type != 5: return None mmsi = _bits(payload, 8, 30) imo = _bits(payload, 40, 30) callsign = _text6(payload, 70, 42) name = _text6(payload, 112, 120) ship_type = _bits(payload, 232, 8) to_bow = _bits(payload, 240, 9) to_stern = _bits(payload, 249, 9) to_port = _bits(payload, 258, 6) to_stbd = _bits(payload, 264, 6) # ETA: month(4) day(5) hour(5) minute(6) eta_month = _bits(payload, 274, 4) eta_day = _bits(payload, 278, 5) eta_hour = _bits(payload, 283, 5) eta_minute = _bits(payload, 288, 6) draught = _bits(payload, 294, 8) / 10.0 dest = _text6(payload, 302, 120) length = to_bow + to_stern beam = to_port + to_stbd eta_str = None if eta_month and eta_day: eta_str = f"{eta_month:02d}-{eta_day:02d} {eta_hour:02d}:{eta_minute:02d}" return { "type": "vessel_static", "mmsi": str(mmsi), "imo": str(imo) if imo else None, "callsign": callsign or None, "nombre": name or None, "tipo": ship_type, "tipo_nombre": SHIP_TYPE_NAMES.get(ship_type, f"Type {ship_type}"), "length": length if length else None, "beam": beam if beam else None, "to_bow": to_bow if to_bow else None, "to_stern": to_stern if to_stern else None, "to_port": to_port if to_port else None, "to_starboard": to_stbd if to_stbd else None, "draught": draught if draught else None, "eta": eta_str, "destino": dest or None, "timestamp": datetime.utcnow().isoformat(), "source": "AIS", } except Exception: return None def decode_type18(payload: str) -> dict | None: """Type 18 — Class B standard position report.""" try: msg_type = _bits(payload, 0, 6) if msg_type != 18: return None mmsi = _bits(payload, 8, 30) sog_raw = _bits(payload, 46, 10) lon_raw = _signed(_bits(payload, 57, 28), 28) lat_raw = _signed(_bits(payload, 85, 27), 27) cog_raw = _bits(payload, 112, 12) heading = _bits(payload, 124, 9) lon = lon_raw / 600000.0 lat = lat_raw / 600000.0 sog = sog_raw / 10.0 cog = cog_raw / 10.0 if lat == 0.0 and lon == 0.0: return None if abs(lat) > 90 or abs(lon) > 180: return None return { "type": "vessel", "msg_type": 18, "mmsi": str(mmsi), "sog": sog, "lat": round(lat, 6), "lon": round(lon, 6), "cog": cog, "heading": heading if heading != 511 else None, "nav_status": 0, "nav_status_name": "Under way using engine", "rot": None, "fix_type": _bits(payload, 134, 4), "timestamp": datetime.utcnow().isoformat(), "source": "AIS", "class_b": True, } except Exception: return None def decode_type24(payload: str) -> dict | None: """Type 24 — Class B static (Part A: name; Part B: dimensions+callsign).""" try: msg_type = _bits(payload, 0, 6) if msg_type != 24: return None mmsi = _bits(payload, 8, 30) part_no = _bits(payload, 38, 2) if part_no == 0: name = _text6(payload, 40, 120) return { "type": "vessel_static", "mmsi": str(mmsi), "nombre": name or None, "timestamp": datetime.utcnow().isoformat(), "source": "AIS", } elif part_no == 1: ship_type = _bits(payload, 40, 8) callsign = _text6(payload, 90, 42) to_bow = _bits(payload, 132, 9) to_stern = _bits(payload, 141, 9) to_port = _bits(payload, 150, 6) to_stbd = _bits(payload, 156, 6) length = to_bow + to_stern beam = to_port + to_stbd return { "type": "vessel_static", "mmsi": str(mmsi), "callsign": callsign or None, "tipo": ship_type, "tipo_nombre": SHIP_TYPE_NAMES.get(ship_type, f"Type {ship_type}"), "length": length if length else None, "beam": beam if beam else None, "to_bow": to_bow if to_bow else None, "to_stern": to_stern if to_stern else None, "to_port": to_port if to_port else None, "to_starboard": to_stbd if to_stbd else None, "timestamp": datetime.utcnow().isoformat(), "source": "AIS", } except Exception: return None def parse_nmea_sentence(sentence: str) -> dict | None: """ Parse a single complete !AIVDM/!AIVDO NMEA sentence. Returns decoded dict or None if unrecognised/invalid. """ sentence = sentence.strip() if not (sentence.startswith("!AIVDM") or sentence.startswith("!AIVDO")): return None # Strip checksum if '*' in sentence: sentence = sentence[:sentence.rindex('*')] parts = sentence.split(',') if len(parts) < 6: return None payload = parts[5] if not payload: return None try: msg_type = _bits(payload, 0, 6) except Exception: return None if msg_type in (1, 2, 3): return decode_type123(payload) elif msg_type == 5: return decode_type5(payload) elif msg_type == 18: return decode_type18(payload) elif msg_type == 24: return decode_type24(payload) elif msg_type in (21, 8): # Handled by aton_decoder — return raw info so caller can route return {"type": "aton_raw", "msg_type": msg_type, "payload": payload} return None