mirror of
https://github.com/smittix/intercept.git
synced 2026-07-07 09:08:12 -07:00
Merge pull request #84 from xdep/testing-branch
feat: Add VHF DSC Channel 70 monitoring and decoding for vessels page
This commit is contained in:
+14
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
## [2.10.0] - 2026-01-24
|
## [2.10.0] - 2026-01-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **AIS Vessel Tracking** - Real-time ship tracking via AIS-catcher
|
- **AIS Vessel Tracking** - Real-time ship tracking via AIS-catcher
|
||||||
@@ -11,17 +11,30 @@ All notable changes to iNTERCEPT will be documented in this file.
|
|||||||
- Navigation data: speed, course, heading, rate of turn
|
- Navigation data: speed, course, heading, rate of turn
|
||||||
- Ship type classification and dimensions
|
- Ship type classification and dimensions
|
||||||
- Multi-SDR support (RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay)
|
- Multi-SDR support (RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay)
|
||||||
|
- **VHF DSC Channel 70 Monitoring** - Digital Selective Calling for maritime distress
|
||||||
|
- Real-time decoding of DSC messages (Distress, Urgency, Safety, Routine)
|
||||||
|
- MMSI country identification via Maritime Identification Digits (MID) lookup
|
||||||
|
- Position extraction and map markers for distress alerts
|
||||||
|
- Prominent visual overlay for DISTRESS and URGENCY alerts
|
||||||
|
- Permanent database storage for critical alerts with acknowledgement workflow
|
||||||
- **Spy Stations Database** - Number stations and diplomatic HF networks
|
- **Spy Stations Database** - Number stations and diplomatic HF networks
|
||||||
- Comprehensive database from priyom.org
|
- Comprehensive database from priyom.org
|
||||||
- Station profiles with frequencies, schedules, operators
|
- Station profiles with frequencies, schedules, operators
|
||||||
- Filter by type (number/diplomatic), country, and mode
|
- Filter by type (number/diplomatic), country, and mode
|
||||||
- Tune integration with Listening Post
|
- Tune integration with Listening Post
|
||||||
- Famous stations: UVB-76, Cuban HM01, Israeli E17z
|
- Famous stations: UVB-76, Cuban HM01, Israeli E17z
|
||||||
|
- **SDR Device Conflict Detection** - Prevents collisions between AIS and DSC
|
||||||
|
- **DSC Alert Summary** - Dashboard counts for unacknowledged distress/urgency alerts
|
||||||
- **AIS-catcher Installation** - Added to setup.sh for Debian and macOS
|
- **AIS-catcher Installation** - Added to setup.sh for Debian and macOS
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **UI Labels** - Renamed "Scanner" to "Listening Post" and "RTLAMR" to "Meters"
|
- **UI Labels** - Renamed "Scanner" to "Listening Post" and "RTLAMR" to "Meters"
|
||||||
- **Pager Filter** - Changed from onchange to oninput for real-time filtering
|
- **Pager Filter** - Changed from onchange to oninput for real-time filtering
|
||||||
|
- **Vessels Dashboard** - Now includes VHF DSC message panel alongside AIS tracking
|
||||||
|
- **Dependencies** - Added scipy and numpy for DSC signal processing
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **DSC Position Decoder** - Corrected octal literal in quadrant check
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Support the developer of this open-source project
|
|||||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||||
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
||||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||||
- **Vessel Tracking** - AIS ship tracking via AIS-catcher with maritime map
|
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||||
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||||
- **Listening Post** - Frequency scanner with audio monitoring
|
- **Listening Post** - Frequency scanner with audio monitoring
|
||||||
- **Satellite Tracking** - Pass prediction using TLE data
|
- **Satellite Tracking** - Pass prediction using TLE data
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from utils.constants import (
|
|||||||
MAX_WIFI_NETWORK_AGE_SECONDS,
|
MAX_WIFI_NETWORK_AGE_SECONDS,
|
||||||
MAX_BT_DEVICE_AGE_SECONDS,
|
MAX_BT_DEVICE_AGE_SECONDS,
|
||||||
MAX_VESSEL_AGE_SECONDS,
|
MAX_VESSEL_AGE_SECONDS,
|
||||||
|
MAX_DSC_MESSAGE_AGE_SECONDS,
|
||||||
QUEUE_MAX_SIZE,
|
QUEUE_MAX_SIZE,
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
@@ -145,6 +146,12 @@ ais_process = None
|
|||||||
ais_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
ais_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
ais_lock = threading.Lock()
|
ais_lock = threading.Lock()
|
||||||
|
|
||||||
|
# DSC (Digital Selective Calling)
|
||||||
|
dsc_process = None
|
||||||
|
dsc_rtl_process = None
|
||||||
|
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
dsc_lock = threading.Lock()
|
||||||
|
|
||||||
# TSCM (Technical Surveillance Countermeasures)
|
# TSCM (Technical Surveillance Countermeasures)
|
||||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
tscm_lock = threading.Lock()
|
tscm_lock = threading.Lock()
|
||||||
@@ -175,6 +182,9 @@ adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_a
|
|||||||
# Vessel (AIS) state - using DataStore for automatic cleanup
|
# Vessel (AIS) state - using DataStore for automatic cleanup
|
||||||
ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessels')
|
ais_vessels = DataStore(max_age_seconds=MAX_VESSEL_AGE_SECONDS, name='ais_vessels')
|
||||||
|
|
||||||
|
# DSC (Digital Selective Calling) state - using DataStore for automatic cleanup
|
||||||
|
dsc_messages = DataStore(max_age_seconds=MAX_DSC_MESSAGE_AGE_SECONDS, name='dsc_messages')
|
||||||
|
|
||||||
# Satellite state
|
# Satellite state
|
||||||
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
||||||
|
|
||||||
@@ -185,6 +195,7 @@ cleanup_manager.register(bt_devices)
|
|||||||
cleanup_manager.register(bt_beacons)
|
cleanup_manager.register(bt_beacons)
|
||||||
cleanup_manager.register(adsb_aircraft)
|
cleanup_manager.register(adsb_aircraft)
|
||||||
cleanup_manager.register(ais_vessels)
|
cleanup_manager.register(ais_vessels)
|
||||||
|
cleanup_manager.register(dsc_messages)
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -516,6 +527,7 @@ def health_check() -> Response:
|
|||||||
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
||||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||||
|
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||||
},
|
},
|
||||||
'data': {
|
'data': {
|
||||||
'aircraft_count': len(adsb_aircraft),
|
'aircraft_count': len(adsb_aircraft),
|
||||||
@@ -523,6 +535,7 @@ def health_check() -> Response:
|
|||||||
'wifi_networks_count': len(wifi_networks),
|
'wifi_networks_count': len(wifi_networks),
|
||||||
'wifi_clients_count': len(wifi_clients),
|
'wifi_clients_count': len(wifi_clients),
|
||||||
'bt_devices_count': len(bt_devices),
|
'bt_devices_count': len(bt_devices),
|
||||||
|
'dsc_messages_count': len(dsc_messages),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -531,7 +544,7 @@ def health_check() -> Response:
|
|||||||
def kill_all() -> Response:
|
def kill_all() -> Response:
|
||||||
"""Kill all decoder and WiFi processes."""
|
"""Kill all decoder and WiFi processes."""
|
||||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||||
global aprs_process, aprs_rtl_process
|
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process
|
||||||
|
|
||||||
# Import adsb and ais modules to reset their state
|
# Import adsb and ais modules to reset their state
|
||||||
from routes import adsb as adsb_module
|
from routes import adsb as adsb_module
|
||||||
@@ -580,6 +593,11 @@ def kill_all() -> Response:
|
|||||||
aprs_process = None
|
aprs_process = None
|
||||||
aprs_rtl_process = None
|
aprs_rtl_process = None
|
||||||
|
|
||||||
|
# Reset DSC state
|
||||||
|
with dsc_lock:
|
||||||
|
dsc_process = None
|
||||||
|
dsc_rtl_process = None
|
||||||
|
|
||||||
return jsonify({'status': 'killed', 'processes': killed})
|
return jsonify({'status': 'killed', 'processes': killed})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# DSC (Digital Selective Calling) decoder wrapper
|
||||||
|
# Invokes the Python DSC decoder module
|
||||||
|
|
||||||
|
# Get the directory where this script is located
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
# Set PYTHONPATH to include project root
|
||||||
|
export PYTHONPATH="${PROJECT_ROOT}:${PYTHONPATH}"
|
||||||
|
|
||||||
|
# Run the decoder module
|
||||||
|
exec python3 -m utils.dsc.decoder "$@"
|
||||||
@@ -15,10 +15,10 @@ CHANGELOG = [
|
|||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
"date": "January 2026",
|
"date": "January 2026",
|
||||||
"highlights": [
|
"highlights": [
|
||||||
"AIS vessel tracking with real-time maritime map",
|
"AIS vessel tracking with VHF DSC distress monitoring",
|
||||||
"Spy Stations database (number stations & diplomatic HF)",
|
"Spy Stations database (number stations & diplomatic HF)",
|
||||||
"Multi-SDR support for AIS (RTL-SDR, HackRF, LimeSDR, etc.)",
|
"MMSI country identification and distress alert overlays",
|
||||||
"UI improvements: renamed modes for clarity",
|
"SDR device conflict detection for AIS/DSC",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -57,6 +57,31 @@ Complete feature list for all modules.
|
|||||||
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
|
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
## AIS Vessel Tracking
|
||||||
|
|
||||||
|
- **Real-time vessel tracking** via AIS-catcher or rtl_ais
|
||||||
|
- **Full-screen dashboard** - dedicated popout with maritime map
|
||||||
|
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
||||||
|
- **Vessel trails** - optional track history visualization
|
||||||
|
- **Vessel details popup** - name, MMSI, callsign, destination, ship type, speed, heading
|
||||||
|
- **Country identification** - flag lookup via Maritime Identification Digits (MID)
|
||||||
|
|
||||||
|
### VHF DSC Channel 70 Monitoring
|
||||||
|
|
||||||
|
Digital Selective Calling (DSC) monitoring on the international maritime distress frequency.
|
||||||
|
|
||||||
|
- **Real-time DSC decoding** - Distress, Urgency, Safety, and Routine messages
|
||||||
|
- **MMSI country lookup** - 180+ Maritime Identification Digit codes
|
||||||
|
- **Distress nature identification** - Fire, Flooding, Collision, Sinking, Piracy, MOB, etc.
|
||||||
|
- **Position extraction** - Automatic lat/lon parsing from distress messages
|
||||||
|
- **Map markers** - Distress positions plotted with pulsing alert markers
|
||||||
|
- **Visual alert overlay** - Prominent popup for DISTRESS and URGENCY messages
|
||||||
|
- **Audio alerts** - Notification sound for critical messages
|
||||||
|
- **Alert persistence** - Critical alerts stored permanently in database
|
||||||
|
- **Acknowledgement workflow** - Track response status with notes
|
||||||
|
- **SDR conflict detection** - Prevents device collisions with AIS tracking
|
||||||
|
- **Alert summary** - Dashboard counts for unacknowledged distress/urgency
|
||||||
|
|
||||||
## Satellite Tracking
|
## Satellite Tracking
|
||||||
|
|
||||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.9.5"
|
version = "2.10.0"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ bleak>=0.21.0
|
|||||||
# Satellite tracking (optional - only needed for satellite features)
|
# Satellite tracking (optional - only needed for satellite features)
|
||||||
skyfield>=1.45
|
skyfield>=1.45
|
||||||
|
|
||||||
|
# DSC decoding (optional - only needed for VHF DSC maritime distress)
|
||||||
|
scipy>=1.10.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
|
||||||
# GPS dongle support (optional - only needed for USB GPS receivers)
|
# GPS dongle support (optional - only needed for USB GPS receivers)
|
||||||
pyserial>=3.5
|
pyserial>=3.5
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ def register_blueprints(app):
|
|||||||
from .bluetooth_v2 import bluetooth_v2_bp
|
from .bluetooth_v2 import bluetooth_v2_bp
|
||||||
from .adsb import adsb_bp
|
from .adsb import adsb_bp
|
||||||
from .ais import ais_bp
|
from .ais import ais_bp
|
||||||
|
from .dsc import dsc_bp
|
||||||
from .acars import acars_bp
|
from .acars import acars_bp
|
||||||
from .aprs import aprs_bp
|
from .aprs import aprs_bp
|
||||||
from .satellite import satellite_bp
|
from .satellite import satellite_bp
|
||||||
@@ -30,6 +31,7 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(bluetooth_v2_bp) # New unified Bluetooth API
|
app.register_blueprint(bluetooth_v2_bp) # New unified Bluetooth API
|
||||||
app.register_blueprint(adsb_bp)
|
app.register_blueprint(adsb_bp)
|
||||||
app.register_blueprint(ais_bp)
|
app.register_blueprint(ais_bp)
|
||||||
|
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
|
||||||
app.register_blueprint(acars_bp)
|
app.register_blueprint(acars_bp)
|
||||||
app.register_blueprint(aprs_bp)
|
app.register_blueprint(aprs_bp)
|
||||||
app.register_blueprint(satellite_bp)
|
app.register_blueprint(satellite_bp)
|
||||||
|
|||||||
+575
@@ -0,0 +1,575 @@
|
|||||||
|
"""VHF DSC (Digital Selective Calling) routes.
|
||||||
|
|
||||||
|
DSC operates on VHF Channel 70 (156.525 MHz) for maritime
|
||||||
|
distress and safety communications per ITU-R M.493.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import queue
|
||||||
|
import select
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.constants import (
|
||||||
|
DSC_VHF_FREQUENCY_MHZ,
|
||||||
|
DSC_SAMPLE_RATE,
|
||||||
|
DSC_TERMINATE_TIMEOUT,
|
||||||
|
)
|
||||||
|
from utils.database import (
|
||||||
|
store_dsc_alert,
|
||||||
|
get_dsc_alerts,
|
||||||
|
get_dsc_alert,
|
||||||
|
acknowledge_dsc_alert,
|
||||||
|
get_dsc_alert_summary,
|
||||||
|
)
|
||||||
|
from utils.dsc.parser import parse_dsc_message
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.validation import validate_device_index, validate_gain
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.dependencies import get_tool_path
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.dsc')
|
||||||
|
|
||||||
|
dsc_bp = Blueprint('dsc', __name__, url_prefix='/dsc')
|
||||||
|
|
||||||
|
# Module state (track if running independent of process state)
|
||||||
|
dsc_running = False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dsc_decoder_path() -> str | None:
|
||||||
|
"""Get path to DSC decoder."""
|
||||||
|
# Check for our custom decoder
|
||||||
|
project_bin = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'bin', 'dsc-decoder')
|
||||||
|
if os.path.isfile(project_bin) and os.access(project_bin, os.X_OK):
|
||||||
|
return project_bin
|
||||||
|
|
||||||
|
# Check system PATH
|
||||||
|
system_decoder = shutil.which('dsc-decoder')
|
||||||
|
if system_decoder:
|
||||||
|
return system_decoder
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _check_dsc_tools() -> dict:
|
||||||
|
"""Check availability of DSC decoding tools."""
|
||||||
|
rtl_fm_path = get_tool_path('rtl_fm')
|
||||||
|
decoder_path = _get_dsc_decoder_path()
|
||||||
|
|
||||||
|
# Check for scipy/numpy (needed for decoder)
|
||||||
|
scipy_available = False
|
||||||
|
try:
|
||||||
|
import scipy
|
||||||
|
import numpy
|
||||||
|
scipy_available = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
'rtl_fm': {
|
||||||
|
'available': rtl_fm_path is not None,
|
||||||
|
'path': rtl_fm_path
|
||||||
|
},
|
||||||
|
'dsc_decoder': {
|
||||||
|
'available': decoder_path is not None,
|
||||||
|
'path': decoder_path
|
||||||
|
},
|
||||||
|
'scipy': {
|
||||||
|
'available': scipy_available,
|
||||||
|
'note': 'Required for DSC signal processing'
|
||||||
|
},
|
||||||
|
'ready': rtl_fm_path is not None and decoder_path is not None and scipy_available
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> None:
|
||||||
|
"""
|
||||||
|
Stream DSC decoder output to queue using PTY for unbuffered output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
master_fd: PTY master file descriptor
|
||||||
|
decoder_process: Decoder subprocess
|
||||||
|
"""
|
||||||
|
global dsc_running
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_module.dsc_queue.put({'type': 'status', 'status': 'started'})
|
||||||
|
|
||||||
|
buffer = ""
|
||||||
|
while dsc_running:
|
||||||
|
try:
|
||||||
|
ready, _, _ = select.select([master_fd], [], [], 1.0)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
if ready:
|
||||||
|
try:
|
||||||
|
data = os.read(master_fd, 1024)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
buffer += data.decode('utf-8', errors='replace')
|
||||||
|
|
||||||
|
while '\n' in buffer:
|
||||||
|
line, buffer = buffer.split('\n', 1)
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse DSC message
|
||||||
|
parsed = parse_dsc_message(line)
|
||||||
|
if parsed:
|
||||||
|
# Generate unique message ID
|
||||||
|
msg_id = f"{parsed['source_mmsi']}_{int(time.time() * 1000)}"
|
||||||
|
parsed['id'] = msg_id
|
||||||
|
|
||||||
|
# Store in transient DataStore
|
||||||
|
app_module.dsc_messages.set(msg_id, parsed)
|
||||||
|
|
||||||
|
# Queue for SSE
|
||||||
|
try:
|
||||||
|
app_module.dsc_queue.put_nowait(parsed)
|
||||||
|
except queue.Full:
|
||||||
|
logger.warning("DSC queue full, dropping message")
|
||||||
|
|
||||||
|
# Store critical alerts permanently
|
||||||
|
if parsed.get('is_critical'):
|
||||||
|
_store_critical_alert(parsed)
|
||||||
|
else:
|
||||||
|
# Raw output for debugging
|
||||||
|
app_module.dsc_queue.put({
|
||||||
|
'type': 'raw',
|
||||||
|
'text': line
|
||||||
|
})
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if process is still running
|
||||||
|
if decoder_process.poll() is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DSC decoder error: {e}")
|
||||||
|
app_module.dsc_queue.put({
|
||||||
|
'type': 'error',
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.close(master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
decoder_process.wait()
|
||||||
|
dsc_running = False
|
||||||
|
app_module.dsc_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
|
|
||||||
|
with app_module.dsc_lock:
|
||||||
|
app_module.dsc_process = None
|
||||||
|
app_module.dsc_rtl_process = None
|
||||||
|
|
||||||
|
|
||||||
|
def _store_critical_alert(msg: dict) -> None:
|
||||||
|
"""Store critical DSC alert (DISTRESS/URGENCY) to database."""
|
||||||
|
try:
|
||||||
|
store_dsc_alert(
|
||||||
|
source_mmsi=msg.get('source_mmsi', ''),
|
||||||
|
format_code=str(msg.get('format_code', '')),
|
||||||
|
category=msg.get('category', 'UNKNOWN'),
|
||||||
|
source_name=msg.get('source_name'),
|
||||||
|
dest_mmsi=msg.get('dest_mmsi'),
|
||||||
|
nature_of_distress=msg.get('nature_of_distress'),
|
||||||
|
latitude=msg.get('latitude'),
|
||||||
|
longitude=msg.get('longitude'),
|
||||||
|
raw_message=msg.get('raw_message')
|
||||||
|
)
|
||||||
|
logger.info(f"Stored {msg.get('category')} alert from {msg.get('source_mmsi')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to store DSC alert: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_rtl_stderr(process: subprocess.Popen) -> None:
|
||||||
|
"""Monitor rtl_fm stderr for errors."""
|
||||||
|
global dsc_running
|
||||||
|
|
||||||
|
try:
|
||||||
|
for line in process.stderr:
|
||||||
|
if not dsc_running:
|
||||||
|
break
|
||||||
|
err_text = line.decode('utf-8', errors='replace').strip()
|
||||||
|
if err_text:
|
||||||
|
logger.debug(f"[RTL_FM] {err_text}")
|
||||||
|
|
||||||
|
# Check for device busy error
|
||||||
|
if 'usb_claim_interface' in err_text.lower():
|
||||||
|
app_module.dsc_queue.put({
|
||||||
|
'type': 'error',
|
||||||
|
'error': 'SDR device busy',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'suggestion': 'Use a different SDR device or stop other SDR processes'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for other common errors
|
||||||
|
if 'no supported devices' in err_text.lower():
|
||||||
|
app_module.dsc_queue.put({
|
||||||
|
'type': 'error',
|
||||||
|
'error': 'No SDR device found',
|
||||||
|
'error_type': 'NO_DEVICE'
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/status')
|
||||||
|
def get_status() -> Response:
|
||||||
|
"""Get DSC decoder status."""
|
||||||
|
global dsc_running
|
||||||
|
|
||||||
|
with app_module.dsc_lock:
|
||||||
|
running = (
|
||||||
|
dsc_running and
|
||||||
|
app_module.dsc_process is not None and
|
||||||
|
app_module.dsc_process.poll() is None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get message counts
|
||||||
|
message_count = len(app_module.dsc_messages)
|
||||||
|
alert_summary = get_dsc_alert_summary()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'running': running,
|
||||||
|
'frequency': DSC_VHF_FREQUENCY_MHZ,
|
||||||
|
'message_count': message_count,
|
||||||
|
'alerts': alert_summary
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/tools')
|
||||||
|
def check_tools() -> Response:
|
||||||
|
"""Check DSC decoder tool availability."""
|
||||||
|
tools = _check_dsc_tools()
|
||||||
|
return jsonify(tools)
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/start', methods=['POST'])
|
||||||
|
def start_decoding() -> Response:
|
||||||
|
"""Start DSC decoder."""
|
||||||
|
global dsc_running
|
||||||
|
|
||||||
|
with app_module.dsc_lock:
|
||||||
|
if app_module.dsc_process and app_module.dsc_process.poll() is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'DSC decoder already running'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Check tools
|
||||||
|
tools = _check_dsc_tools()
|
||||||
|
if not tools['ready']:
|
||||||
|
missing = []
|
||||||
|
if not tools['rtl_fm']['available']:
|
||||||
|
missing.append('rtl_fm')
|
||||||
|
if not tools['dsc_decoder']['available']:
|
||||||
|
missing.append('dsc-decoder')
|
||||||
|
if not tools['scipy']['available']:
|
||||||
|
missing.append('scipy/numpy')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Missing required tools: {", ".join(missing)}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate device
|
||||||
|
try:
|
||||||
|
device = validate_device_index(data.get('device', '0'))
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate gain
|
||||||
|
try:
|
||||||
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Check if device is in use by AIS
|
||||||
|
try:
|
||||||
|
from routes import ais as ais_module
|
||||||
|
if hasattr(ais_module, 'ais_running') and ais_module.ais_running:
|
||||||
|
# AIS is running - check if same device
|
||||||
|
if hasattr(ais_module, 'ais_device') and str(ais_module.ais_device) == str(device):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': f'SDR device {device} is in use by AIS tracking',
|
||||||
|
'suggestion': 'Use a different SDR device or stop AIS tracking first',
|
||||||
|
'in_use_by': 'ais'
|
||||||
|
}), 409
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not app_module.dsc_queue.empty():
|
||||||
|
try:
|
||||||
|
app_module.dsc_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Build rtl_fm command
|
||||||
|
rtl_fm_path = tools['rtl_fm']['path']
|
||||||
|
decoder_path = tools['dsc_decoder']['path']
|
||||||
|
|
||||||
|
# rtl_fm command for DSC decoding
|
||||||
|
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate
|
||||||
|
rtl_cmd = [
|
||||||
|
rtl_fm_path,
|
||||||
|
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M',
|
||||||
|
'-s', str(DSC_SAMPLE_RATE),
|
||||||
|
'-d', str(device),
|
||||||
|
'-g', str(gain),
|
||||||
|
'-M', 'fm', # FM demodulation
|
||||||
|
'-l', '0', # No squelch for DSC
|
||||||
|
'-E', 'dc' # DC blocking filter
|
||||||
|
]
|
||||||
|
|
||||||
|
# Decoder command
|
||||||
|
decoder_cmd = [decoder_path]
|
||||||
|
|
||||||
|
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(decoder_cmd)
|
||||||
|
logger.info(f"Starting DSC decoder: {full_cmd}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start rtl_fm subprocess
|
||||||
|
rtl_process = subprocess.Popen(
|
||||||
|
rtl_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start stderr monitor thread
|
||||||
|
stderr_thread = threading.Thread(
|
||||||
|
target=monitor_rtl_stderr,
|
||||||
|
args=(rtl_process,),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
stderr_thread.start()
|
||||||
|
|
||||||
|
# Create PTY for decoder output
|
||||||
|
master_fd, slave_fd = pty.openpty()
|
||||||
|
|
||||||
|
# Start decoder subprocess
|
||||||
|
decoder_process = subprocess.Popen(
|
||||||
|
decoder_cmd,
|
||||||
|
stdin=rtl_process.stdout,
|
||||||
|
stdout=slave_fd,
|
||||||
|
stderr=slave_fd,
|
||||||
|
close_fds=True
|
||||||
|
)
|
||||||
|
|
||||||
|
os.close(slave_fd)
|
||||||
|
rtl_process.stdout.close()
|
||||||
|
|
||||||
|
# Store process references
|
||||||
|
app_module.dsc_process = decoder_process
|
||||||
|
app_module.dsc_rtl_process = rtl_process
|
||||||
|
dsc_running = True
|
||||||
|
|
||||||
|
# Start output streaming thread
|
||||||
|
output_thread = threading.Thread(
|
||||||
|
target=stream_dsc_decoder,
|
||||||
|
args=(master_fd, decoder_process),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
output_thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequency': DSC_VHF_FREQUENCY_MHZ,
|
||||||
|
'device': device,
|
||||||
|
'gain': gain,
|
||||||
|
'command': full_cmd
|
||||||
|
})
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Tool not found: {e.filename}'
|
||||||
|
}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start DSC decoder: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_decoding() -> Response:
|
||||||
|
"""Stop DSC decoder."""
|
||||||
|
global dsc_running
|
||||||
|
|
||||||
|
with app_module.dsc_lock:
|
||||||
|
if not app_module.dsc_process:
|
||||||
|
return jsonify({'status': 'not_running'})
|
||||||
|
|
||||||
|
dsc_running = False
|
||||||
|
|
||||||
|
# Terminate rtl_fm process first
|
||||||
|
if app_module.dsc_rtl_process:
|
||||||
|
try:
|
||||||
|
app_module.dsc_rtl_process.terminate()
|
||||||
|
app_module.dsc_rtl_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
try:
|
||||||
|
app_module.dsc_rtl_process.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Terminate decoder process
|
||||||
|
if app_module.dsc_process:
|
||||||
|
try:
|
||||||
|
app_module.dsc_process.terminate()
|
||||||
|
app_module.dsc_process.wait(timeout=DSC_TERMINATE_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
try:
|
||||||
|
app_module.dsc_process.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
app_module.dsc_process = None
|
||||||
|
app_module.dsc_rtl_process = None
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/stream')
|
||||||
|
def stream() -> Response:
|
||||||
|
"""SSE stream for real-time DSC messages."""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = app_module.dsc_queue.get(timeout=1)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/messages')
|
||||||
|
def get_messages() -> Response:
|
||||||
|
"""Get current DSC messages from transient store."""
|
||||||
|
messages = list(app_module.dsc_messages.values())
|
||||||
|
|
||||||
|
# Sort by timestamp (newest first)
|
||||||
|
messages.sort(key=lambda m: m.get('timestamp', ''), reverse=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'count': len(messages),
|
||||||
|
'messages': messages
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/alerts')
|
||||||
|
def get_alerts_endpoint() -> Response:
|
||||||
|
"""Get stored DSC alerts (paginated)."""
|
||||||
|
# Parse query params
|
||||||
|
category = request.args.get('category')
|
||||||
|
acknowledged = request.args.get('acknowledged')
|
||||||
|
limit = min(int(request.args.get('limit', 50)), 200)
|
||||||
|
offset = int(request.args.get('offset', 0))
|
||||||
|
|
||||||
|
# Convert acknowledged param
|
||||||
|
ack_filter = None
|
||||||
|
if acknowledged is not None:
|
||||||
|
ack_filter = acknowledged.lower() in ('true', '1', 'yes')
|
||||||
|
|
||||||
|
alerts = get_dsc_alerts(
|
||||||
|
category=category,
|
||||||
|
acknowledged=ack_filter,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = get_dsc_alert_summary()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'alerts': alerts,
|
||||||
|
'count': len(alerts),
|
||||||
|
'summary': summary,
|
||||||
|
'pagination': {
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/alerts/<int:alert_id>')
|
||||||
|
def get_alert(alert_id: int) -> Response:
|
||||||
|
"""Get a specific DSC alert by ID."""
|
||||||
|
alert = get_dsc_alert(alert_id)
|
||||||
|
if not alert:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Alert not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
return jsonify(alert)
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/alerts/<int:alert_id>/acknowledge', methods=['POST'])
|
||||||
|
def acknowledge_alert(alert_id: int) -> Response:
|
||||||
|
"""Acknowledge a DSC alert."""
|
||||||
|
data = request.json or {}
|
||||||
|
notes = data.get('notes')
|
||||||
|
|
||||||
|
success = acknowledge_dsc_alert(alert_id, notes)
|
||||||
|
if not success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Alert not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'acknowledged',
|
||||||
|
'alert_id': alert_id
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dsc_bp.route('/alerts/summary')
|
||||||
|
def get_alerts_summary() -> Response:
|
||||||
|
"""Get summary of unacknowledged DSC alerts."""
|
||||||
|
summary = get_dsc_alert_summary()
|
||||||
|
return jsonify(summary)
|
||||||
@@ -899,3 +899,308 @@ body {
|
|||||||
line-height: 44px !important;
|
line-height: 44px !important;
|
||||||
font-size: 18px !important;
|
font-size: 18px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DSC (Digital Selective Calling) Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* DSC Control Group - Orange accent (warning/distress theme) */
|
||||||
|
.control-group.dsc-group {
|
||||||
|
background: rgba(245, 158, 11, 0.05);
|
||||||
|
border-color: rgba(245, 158, 11, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group.dsc-group .control-group-label {
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group.dsc-group select,
|
||||||
|
.control-group.dsc-group input[type="number"] {
|
||||||
|
border-color: rgba(245, 158, 11, 0.3);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-btn {
|
||||||
|
background: var(--accent-orange) !important;
|
||||||
|
font-size: 9px !important;
|
||||||
|
padding: 6px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-btn:hover {
|
||||||
|
background: #d97706 !important;
|
||||||
|
box-shadow: 0 0 20px rgba(245, 158, 11, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-btn.active {
|
||||||
|
background: var(--accent-red) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DSC Panel */
|
||||||
|
.panel.dsc-messages {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-height: 250px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: 1px solid rgba(245, 158, 11, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.dsc-messages::before {
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent-orange), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.dsc-messages .panel-header {
|
||||||
|
background: rgba(245, 158, 11, 0.05);
|
||||||
|
border-bottom-color: rgba(245, 158, 11, 0.1);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DSC Alert Summary */
|
||||||
|
.dsc-alert-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-bottom: 1px solid rgba(245, 158, 11, 0.1);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-alert-count {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-alert-count.distress {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-alert-count.urgency {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DSC List Content */
|
||||||
|
.dsc-list-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-messages {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 15px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DSC Message Items */
|
||||||
|
.dsc-message-item {
|
||||||
|
position: relative;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-message-item:hover {
|
||||||
|
border-color: var(--accent-orange);
|
||||||
|
background: rgba(245, 158, 11, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-message-item.distress {
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
animation: distress-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-message-item.urgency {
|
||||||
|
border-color: var(--accent-orange);
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes distress-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 5px rgba(239, 68, 68, 0.3); }
|
||||||
|
50% { box-shadow: 0 0 15px rgba(239, 68, 68, 0.6); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-message-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-message-category {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-message-item.distress .dsc-message-category {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-message-time {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-message-mmsi {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-message-country {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-message-nature {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent-red);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-message-pos {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DSC Distress Alert Overlay */
|
||||||
|
.dsc-distress-alert {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 10000;
|
||||||
|
background: rgba(15, 18, 24, 0.98);
|
||||||
|
border: 2px solid var(--accent-red);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px 32px;
|
||||||
|
min-width: 300px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 40px rgba(239, 68, 68, 0.5);
|
||||||
|
animation: alert-flash 0.5s ease-in-out 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes alert-flash {
|
||||||
|
0%, 100% { border-color: var(--accent-red); box-shadow: 0 0 40px rgba(239, 68, 68, 0.5); }
|
||||||
|
50% { border-color: #ff6b6b; box-shadow: 0 0 60px rgba(239, 68, 68, 0.8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-distress-alert .dsc-alert-header {
|
||||||
|
font-family: 'Orbitron', 'JetBrains Mono', monospace;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-red);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-distress-alert .dsc-alert-mmsi {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-distress-alert .dsc-alert-country {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-distress-alert .dsc-alert-nature {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-orange);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-distress-alert .dsc-alert-position {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-distress-alert button {
|
||||||
|
background: var(--accent-red);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 24px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-distress-alert button:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
box-shadow: 0 0 20px rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DSC Map Markers */
|
||||||
|
.dsc-marker {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-marker-inner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: white;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-marker-inner.distress {
|
||||||
|
animation: distress-marker-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes distress-marker-pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile adjustments for DSC */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.panel.dsc-messages {
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-distress-alert {
|
||||||
|
width: 90%;
|
||||||
|
min-width: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dsc-distress-alert .dsc-alert-header {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,6 +96,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel dsc-messages">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span>VHF DSC MESSAGES</span>
|
||||||
|
<div class="panel-indicator" id="dscIndicator"></div>
|
||||||
|
</div>
|
||||||
|
<div class="dsc-alert-summary" id="dscAlertSummary">
|
||||||
|
<span class="dsc-alert-count distress" id="dscDistressCount" title="Distress alerts">0 DISTRESS</span>
|
||||||
|
<span class="dsc-alert-count urgency" id="dscUrgencyCount" title="Urgency alerts">0 URGENCY</span>
|
||||||
|
</div>
|
||||||
|
<div class="dsc-list-content" id="dscMessageList">
|
||||||
|
<div class="no-messages">
|
||||||
|
<div>No DSC messages</div>
|
||||||
|
<div style="font-size: 10px; margin-top: 5px;">Start VHF DSC to monitor</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls-bar">
|
<div class="controls-bar">
|
||||||
@@ -131,6 +148,17 @@
|
|||||||
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
|
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group dsc-group">
|
||||||
|
<span class="control-group-label">VHF DSC</span>
|
||||||
|
<div class="control-group-items">
|
||||||
|
<select id="dscDeviceSelect" title="DSC SDR device (secondary)">
|
||||||
|
<option value="0">SDR 0</option>
|
||||||
|
</select>
|
||||||
|
<input type="number" id="dscGain" value="40" min="0" max="50" style="width: 50px;" title="Gain">
|
||||||
|
<button class="start-btn dsc-btn" id="dscStartBtn" onclick="toggleDscTracking()">START DSC</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -142,6 +170,13 @@
|
|||||||
let selectedMmsi = null;
|
let selectedMmsi = null;
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
let isTracking = false;
|
let isTracking = false;
|
||||||
|
|
||||||
|
// DSC State
|
||||||
|
let dscEventSource = null;
|
||||||
|
let isDscTracking = false;
|
||||||
|
let dscMessages = {};
|
||||||
|
let dscMarkers = {};
|
||||||
|
let dscAlertCounts = { distress: 0, urgency: 0 };
|
||||||
let showTrails = false;
|
let showTrails = false;
|
||||||
let vesselTrails = {};
|
let vesselTrails = {};
|
||||||
let trailLines = {};
|
let trailLines = {};
|
||||||
@@ -290,18 +325,37 @@
|
|||||||
fetch('/devices')
|
fetch('/devices')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(devices => {
|
.then(devices => {
|
||||||
const select = document.getElementById('aisDeviceSelect');
|
// Populate AIS device selector
|
||||||
select.innerHTML = '';
|
const aisSelect = document.getElementById('aisDeviceSelect');
|
||||||
|
aisSelect.innerHTML = '';
|
||||||
if (devices.length === 0) {
|
if (devices.length === 0) {
|
||||||
select.innerHTML = '<option value="0">No devices</option>';
|
aisSelect.innerHTML = '<option value="0">No devices</option>';
|
||||||
} else {
|
} else {
|
||||||
devices.forEach((d, i) => {
|
devices.forEach((d, i) => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = d.index;
|
opt.value = d.index;
|
||||||
opt.textContent = `SDR ${d.index}: ${d.name}`;
|
opt.textContent = `SDR ${d.index}: ${d.name}`;
|
||||||
select.appendChild(opt);
|
aisSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate DSC device selector
|
||||||
|
const dscSelect = document.getElementById('dscDeviceSelect');
|
||||||
|
dscSelect.innerHTML = '';
|
||||||
|
if (devices.length === 0) {
|
||||||
|
dscSelect.innerHTML = '<option value="0">No devices</option>';
|
||||||
|
} else {
|
||||||
|
devices.forEach((d, i) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = d.index;
|
||||||
|
opt.textContent = `SDR ${d.index}: ${d.name}`;
|
||||||
|
dscSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
// Default to second device if available
|
||||||
|
if (devices.length > 1) {
|
||||||
|
dscSelect.value = devices[1].index;
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -758,6 +812,238 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// DSC (Digital Selective Calling) Functions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
function toggleDscTracking() {
|
||||||
|
if (isDscTracking) {
|
||||||
|
stopDscTracking();
|
||||||
|
} else {
|
||||||
|
startDscTracking();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDscTracking() {
|
||||||
|
const device = document.getElementById('dscDeviceSelect').value;
|
||||||
|
const gain = document.getElementById('dscGain').value;
|
||||||
|
|
||||||
|
fetch('/dsc/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ device, gain })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'started') {
|
||||||
|
isDscTracking = true;
|
||||||
|
document.getElementById('dscStartBtn').textContent = 'STOP DSC';
|
||||||
|
document.getElementById('dscStartBtn').classList.add('active');
|
||||||
|
document.getElementById('dscIndicator').classList.add('active');
|
||||||
|
startDscSSE();
|
||||||
|
} else if (data.error_type === 'DEVICE_BUSY') {
|
||||||
|
alert('SDR device is busy.\n\n' + data.suggestion);
|
||||||
|
} else {
|
||||||
|
alert(data.message || 'Failed to start DSC');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => alert('Error: ' + err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDscTracking() {
|
||||||
|
fetch('/dsc/stop', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => {
|
||||||
|
isDscTracking = false;
|
||||||
|
document.getElementById('dscStartBtn').textContent = 'START DSC';
|
||||||
|
document.getElementById('dscStartBtn').classList.remove('active');
|
||||||
|
document.getElementById('dscIndicator').classList.remove('active');
|
||||||
|
if (dscEventSource) {
|
||||||
|
dscEventSource.close();
|
||||||
|
dscEventSource = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDscSSE() {
|
||||||
|
if (dscEventSource) dscEventSource.close();
|
||||||
|
|
||||||
|
dscEventSource = new EventSource('/dsc/stream');
|
||||||
|
dscEventSource.onmessage = function(e) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'dsc_message') {
|
||||||
|
handleDscMessage(data);
|
||||||
|
} else if (data.type === 'error') {
|
||||||
|
console.error('DSC error:', data.error);
|
||||||
|
if (data.error_type === 'DEVICE_BUSY') {
|
||||||
|
alert('DSC: Device became busy. ' + (data.suggestion || ''));
|
||||||
|
stopDscTracking();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
dscEventSource.onerror = function() {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isDscTracking) startDscSSE();
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDscMessage(data) {
|
||||||
|
const msgId = data.id || data.source_mmsi + '_' + Date.now();
|
||||||
|
dscMessages[msgId] = data;
|
||||||
|
|
||||||
|
// Update alert counts
|
||||||
|
if (data.category === 'DISTRESS') {
|
||||||
|
dscAlertCounts.distress++;
|
||||||
|
} else if (data.category === 'URGENCY') {
|
||||||
|
dscAlertCounts.urgency++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show prominent alert for distress/urgency
|
||||||
|
if (data.is_critical) {
|
||||||
|
showDistressAlert(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add position marker if coordinates present
|
||||||
|
if (data.latitude && data.longitude) {
|
||||||
|
addDscPositionMarker(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDscMessageList();
|
||||||
|
updateDscAlertSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDistressAlert(data) {
|
||||||
|
// Create alert notification
|
||||||
|
const alertDiv = document.createElement('div');
|
||||||
|
alertDiv.className = 'dsc-distress-alert';
|
||||||
|
alertDiv.innerHTML = `
|
||||||
|
<div class="dsc-alert-header">${data.category}</div>
|
||||||
|
<div class="dsc-alert-mmsi">MMSI: ${data.source_mmsi}</div>
|
||||||
|
${data.source_country ? `<div class="dsc-alert-country">${data.source_country}</div>` : ''}
|
||||||
|
${data.nature_of_distress ? `<div class="dsc-alert-nature">${data.nature_of_distress}</div>` : ''}
|
||||||
|
${data.latitude ? `<div class="dsc-alert-position">${data.latitude.toFixed(4)}, ${data.longitude.toFixed(4)}</div>` : ''}
|
||||||
|
<button onclick="this.parentElement.remove()">ACKNOWLEDGE</button>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(alertDiv);
|
||||||
|
|
||||||
|
// Auto-remove after 30 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (alertDiv.parentElement) alertDiv.remove();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
// Play alert sound if available
|
||||||
|
try {
|
||||||
|
const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1yc3R3eXx+foCAfn59fHt5d3VzcWxnYlxVT0hCOzUuJx8YEAkDAP/+/v7+/v7+/v8AAAECAwUHCQsOEBMWGRwfIiUoKy4xNDc6PT9CRUdKTE5QUlRVV1hZWlpbW1taWVhXVlRTUU9NSkdEQT47ODUyLywpJiMgHRoXFBEOCwgFAwEA/v38+/r5+Pf29fTz8vHw7+7t7Ovq6ejn5uXk4+Lh4N/e3dzb2tnY19bV1NPS0dDPzs3MzMvLy8vMzM3Nzs/Q0dLT1NXW19jZ2tvc3d7f4OHi4+Tl5ufp6uvs7e7v8PHy8/T19vf4+fr7/P3+');
|
||||||
|
audio.volume = 0.5;
|
||||||
|
audio.play().catch(() => {});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDscPositionMarker(data) {
|
||||||
|
const mmsi = data.source_mmsi;
|
||||||
|
|
||||||
|
// Remove existing marker
|
||||||
|
if (dscMarkers[mmsi]) {
|
||||||
|
vesselMap.removeLayer(dscMarkers[mmsi]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create marker with distress icon
|
||||||
|
const isDistress = data.category === 'DISTRESS';
|
||||||
|
const color = isDistress ? '#ef4444' : (data.category === 'URGENCY' ? '#f59e0b' : '#4a9eff');
|
||||||
|
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: 'dsc-marker',
|
||||||
|
html: `<div class="dsc-marker-inner ${isDistress ? 'distress' : ''}" style="background: ${color};">
|
||||||
|
<span>⚠</span>
|
||||||
|
</div>`,
|
||||||
|
iconSize: [28, 28],
|
||||||
|
iconAnchor: [14, 14]
|
||||||
|
});
|
||||||
|
|
||||||
|
dscMarkers[mmsi] = L.marker([data.latitude, data.longitude], { icon })
|
||||||
|
.addTo(vesselMap)
|
||||||
|
.bindPopup(`
|
||||||
|
<strong>${data.category}</strong><br>
|
||||||
|
MMSI: ${mmsi}<br>
|
||||||
|
${data.source_country ? `Country: ${data.source_country}<br>` : ''}
|
||||||
|
${data.nature_of_distress ? `Nature: ${data.nature_of_distress}<br>` : ''}
|
||||||
|
Position: ${data.latitude.toFixed(4)}, ${data.longitude.toFixed(4)}
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Pan to distress position
|
||||||
|
if (isDistress) {
|
||||||
|
vesselMap.setView([data.latitude, data.longitude], 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDscMessageList() {
|
||||||
|
const container = document.getElementById('dscMessageList');
|
||||||
|
const msgArray = Object.values(dscMessages)
|
||||||
|
.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
|
||||||
|
|
||||||
|
if (msgArray.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="no-messages">
|
||||||
|
<div>No DSC messages</div>
|
||||||
|
<div style="font-size: 10px; margin-top: 5px;">Start VHF DSC to monitor</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = msgArray.slice(0, 50).map(msg => {
|
||||||
|
const isDistress = msg.category === 'DISTRESS';
|
||||||
|
const isUrgency = msg.category === 'URGENCY';
|
||||||
|
const categoryClass = isDistress ? 'distress' : (isUrgency ? 'urgency' : '');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="dsc-message-item ${categoryClass}" data-id="${msg.id}">
|
||||||
|
<div class="dsc-message-header">
|
||||||
|
<span class="dsc-message-category">${msg.category}</span>
|
||||||
|
<span class="dsc-message-time">${formatDscTime(msg.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dsc-message-mmsi">MMSI: ${msg.source_mmsi}</div>
|
||||||
|
${msg.source_country ? `<div class="dsc-message-country">${msg.source_country}</div>` : ''}
|
||||||
|
${msg.nature_of_distress ? `<div class="dsc-message-nature">${msg.nature_of_distress}</div>` : ''}
|
||||||
|
${msg.latitude ? `<div class="dsc-message-pos">${msg.latitude.toFixed(4)}, ${msg.longitude.toFixed(4)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDscTime(timestamp) {
|
||||||
|
if (!timestamp) return '--:--';
|
||||||
|
try {
|
||||||
|
const d = new Date(timestamp);
|
||||||
|
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
} catch (e) {
|
||||||
|
return timestamp.slice(11, 19) || '--:--';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDscAlertSummary() {
|
||||||
|
document.getElementById('dscDistressCount').textContent = `${dscAlertCounts.distress} DISTRESS`;
|
||||||
|
document.getElementById('dscUrgencyCount').textContent = `${dscAlertCounts.urgency} URGENCY`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-reference DSC MMSI with AIS vessels
|
||||||
|
function crossReferenceDscWithAis(mmsi) {
|
||||||
|
const vessel = vessels[mmsi];
|
||||||
|
if (vessel) {
|
||||||
|
return {
|
||||||
|
name: vessel.name,
|
||||||
|
callsign: vessel.callsign,
|
||||||
|
ship_type: vessel.ship_type,
|
||||||
|
destination: vessel.destination
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
document.addEventListener('DOMContentLoaded', initMap);
|
document.addEventListener('DOMContentLoaded', initMap);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
"""Tests for DSC (Digital Selective Calling) utilities."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TestDSCParser:
|
||||||
|
"""Tests for DSC parser utilities."""
|
||||||
|
|
||||||
|
def test_get_country_from_mmsi_ship_station(self):
|
||||||
|
"""Test country lookup for standard ship MMSI."""
|
||||||
|
from utils.dsc.parser import get_country_from_mmsi
|
||||||
|
|
||||||
|
# UK ships start with 232-235
|
||||||
|
assert get_country_from_mmsi('232123456') == 'United Kingdom'
|
||||||
|
assert get_country_from_mmsi('235987654') == 'United Kingdom'
|
||||||
|
|
||||||
|
# US ships start with 303, 338, 366-369
|
||||||
|
assert get_country_from_mmsi('366123456') == 'USA'
|
||||||
|
assert get_country_from_mmsi('369000001') == 'USA'
|
||||||
|
|
||||||
|
# Panama (common flag of convenience)
|
||||||
|
assert get_country_from_mmsi('351234567') == 'Panama'
|
||||||
|
assert get_country_from_mmsi('370000001') == 'Panama'
|
||||||
|
|
||||||
|
# Norway
|
||||||
|
assert get_country_from_mmsi('257123456') == 'Norway'
|
||||||
|
|
||||||
|
# Germany
|
||||||
|
assert get_country_from_mmsi('211000001') == 'Germany'
|
||||||
|
|
||||||
|
def test_get_country_from_mmsi_coast_station(self):
|
||||||
|
"""Test country lookup for coast station MMSI (starts with 00)."""
|
||||||
|
from utils.dsc.parser import get_country_from_mmsi
|
||||||
|
|
||||||
|
# Coast stations: 00 + MID
|
||||||
|
assert get_country_from_mmsi('002320001') == 'United Kingdom'
|
||||||
|
assert get_country_from_mmsi('003660001') == 'USA'
|
||||||
|
|
||||||
|
def test_get_country_from_mmsi_group_station(self):
|
||||||
|
"""Test country lookup for group station MMSI (starts with 0)."""
|
||||||
|
from utils.dsc.parser import get_country_from_mmsi
|
||||||
|
|
||||||
|
# Group call: 0 + MID
|
||||||
|
assert get_country_from_mmsi('023200001') == 'United Kingdom'
|
||||||
|
assert get_country_from_mmsi('036600001') == 'USA'
|
||||||
|
|
||||||
|
def test_get_country_from_mmsi_unknown(self):
|
||||||
|
"""Test country lookup returns None for unknown MID."""
|
||||||
|
from utils.dsc.parser import get_country_from_mmsi
|
||||||
|
|
||||||
|
assert get_country_from_mmsi('999999999') is None
|
||||||
|
assert get_country_from_mmsi('100000000') is None
|
||||||
|
|
||||||
|
def test_get_country_from_mmsi_invalid(self):
|
||||||
|
"""Test country lookup handles invalid input."""
|
||||||
|
from utils.dsc.parser import get_country_from_mmsi
|
||||||
|
|
||||||
|
assert get_country_from_mmsi('') is None
|
||||||
|
assert get_country_from_mmsi(None) is None
|
||||||
|
assert get_country_from_mmsi('12') is None
|
||||||
|
|
||||||
|
def test_get_distress_nature_text(self):
|
||||||
|
"""Test distress nature code to text conversion."""
|
||||||
|
from utils.dsc.parser import get_distress_nature_text
|
||||||
|
|
||||||
|
assert get_distress_nature_text(100) == 'UNDESIGNATED'
|
||||||
|
assert get_distress_nature_text(101) == 'FIRE'
|
||||||
|
assert get_distress_nature_text(102) == 'FLOODING'
|
||||||
|
assert get_distress_nature_text(103) == 'COLLISION'
|
||||||
|
assert get_distress_nature_text(106) == 'SINKING'
|
||||||
|
assert get_distress_nature_text(109) == 'PIRACY'
|
||||||
|
assert get_distress_nature_text(110) == 'MOB' # Man overboard
|
||||||
|
|
||||||
|
def test_get_distress_nature_text_unknown(self):
|
||||||
|
"""Test distress nature returns formatted unknown for invalid codes."""
|
||||||
|
from utils.dsc.parser import get_distress_nature_text
|
||||||
|
|
||||||
|
assert 'UNKNOWN' in get_distress_nature_text(999)
|
||||||
|
assert '999' in get_distress_nature_text(999)
|
||||||
|
|
||||||
|
def test_get_distress_nature_text_string_input(self):
|
||||||
|
"""Test distress nature accepts string input."""
|
||||||
|
from utils.dsc.parser import get_distress_nature_text
|
||||||
|
|
||||||
|
assert get_distress_nature_text('101') == 'FIRE'
|
||||||
|
assert get_distress_nature_text('invalid') == 'invalid'
|
||||||
|
|
||||||
|
def test_get_format_text(self):
|
||||||
|
"""Test format code to text conversion."""
|
||||||
|
from utils.dsc.parser import get_format_text
|
||||||
|
|
||||||
|
assert get_format_text(100) == 'DISTRESS'
|
||||||
|
assert get_format_text(102) == 'ALL_SHIPS'
|
||||||
|
assert get_format_text(106) == 'DISTRESS_ACK'
|
||||||
|
assert get_format_text(108) == 'DISTRESS_RELAY'
|
||||||
|
assert get_format_text(112) == 'INDIVIDUAL'
|
||||||
|
assert get_format_text(116) == 'ROUTINE'
|
||||||
|
assert get_format_text(118) == 'SAFETY'
|
||||||
|
assert get_format_text(120) == 'URGENCY'
|
||||||
|
|
||||||
|
def test_get_format_text_unknown(self):
|
||||||
|
"""Test format code returns unknown for invalid codes."""
|
||||||
|
from utils.dsc.parser import get_format_text
|
||||||
|
|
||||||
|
result = get_format_text(999)
|
||||||
|
assert 'UNKNOWN' in result
|
||||||
|
|
||||||
|
def test_get_telecommand_text(self):
|
||||||
|
"""Test telecommand code to text conversion."""
|
||||||
|
from utils.dsc.parser import get_telecommand_text
|
||||||
|
|
||||||
|
assert get_telecommand_text(100) == 'F3E_G3E_ALL'
|
||||||
|
assert get_telecommand_text(105) == 'DATA'
|
||||||
|
assert get_telecommand_text(107) == 'DISTRESS_ACK'
|
||||||
|
assert get_telecommand_text(111) == 'TEST'
|
||||||
|
|
||||||
|
def test_get_category_priority(self):
|
||||||
|
"""Test category priority values."""
|
||||||
|
from utils.dsc.parser import get_category_priority
|
||||||
|
|
||||||
|
# Distress has highest priority (0)
|
||||||
|
assert get_category_priority('DISTRESS') == 0
|
||||||
|
assert get_category_priority('distress') == 0
|
||||||
|
|
||||||
|
# Urgency is lower
|
||||||
|
assert get_category_priority('URGENCY') == 3
|
||||||
|
|
||||||
|
# Safety is lower still
|
||||||
|
assert get_category_priority('SAFETY') == 4
|
||||||
|
|
||||||
|
# Routine is lowest
|
||||||
|
assert get_category_priority('ROUTINE') == 5
|
||||||
|
|
||||||
|
# Unknown gets default high number
|
||||||
|
assert get_category_priority('UNKNOWN') == 10
|
||||||
|
|
||||||
|
def test_validate_mmsi_valid(self):
|
||||||
|
"""Test MMSI validation with valid numbers."""
|
||||||
|
from utils.dsc.parser import validate_mmsi
|
||||||
|
|
||||||
|
assert validate_mmsi('232123456') is True
|
||||||
|
assert validate_mmsi('366000001') is True
|
||||||
|
assert validate_mmsi('002320001') is True # Coast station
|
||||||
|
assert validate_mmsi('023200001') is True # Group station
|
||||||
|
|
||||||
|
def test_validate_mmsi_invalid(self):
|
||||||
|
"""Test MMSI validation rejects invalid numbers."""
|
||||||
|
from utils.dsc.parser import validate_mmsi
|
||||||
|
|
||||||
|
assert validate_mmsi('') is False
|
||||||
|
assert validate_mmsi(None) is False
|
||||||
|
assert validate_mmsi('12345678') is False # Too short
|
||||||
|
assert validate_mmsi('1234567890') is False # Too long
|
||||||
|
assert validate_mmsi('abcdefghi') is False # Not digits
|
||||||
|
assert validate_mmsi('000000000') is False # All zeros
|
||||||
|
|
||||||
|
def test_classify_mmsi(self):
|
||||||
|
"""Test MMSI classification."""
|
||||||
|
from utils.dsc.parser import classify_mmsi
|
||||||
|
|
||||||
|
# Ship stations (start with 2-7)
|
||||||
|
assert classify_mmsi('232123456') == 'ship'
|
||||||
|
assert classify_mmsi('366000001') == 'ship'
|
||||||
|
assert classify_mmsi('503000001') == 'ship'
|
||||||
|
|
||||||
|
# Coast stations (start with 00)
|
||||||
|
assert classify_mmsi('002320001') == 'coast'
|
||||||
|
|
||||||
|
# Group stations (start with 0, not 00)
|
||||||
|
assert classify_mmsi('023200001') == 'group'
|
||||||
|
|
||||||
|
# SAR aircraft (start with 111)
|
||||||
|
assert classify_mmsi('111232001') == 'sar'
|
||||||
|
|
||||||
|
# Aids to Navigation (start with 99)
|
||||||
|
assert classify_mmsi('992321001') == 'aton'
|
||||||
|
|
||||||
|
# Unknown
|
||||||
|
assert classify_mmsi('invalid') == 'unknown'
|
||||||
|
assert classify_mmsi('812345678') == 'unknown'
|
||||||
|
|
||||||
|
def test_parse_dsc_message_distress(self):
|
||||||
|
"""Test parsing a distress message."""
|
||||||
|
from utils.dsc.parser import parse_dsc_message
|
||||||
|
|
||||||
|
raw = json.dumps({
|
||||||
|
'type': 'dsc',
|
||||||
|
'format': 100,
|
||||||
|
'source_mmsi': '232123456',
|
||||||
|
'dest_mmsi': '000000000',
|
||||||
|
'category': 'DISTRESS',
|
||||||
|
'nature': 101,
|
||||||
|
'position': {'lat': 51.5, 'lon': -0.1},
|
||||||
|
'telecommand1': 100,
|
||||||
|
'timestamp': '2025-01-15T12:00:00Z'
|
||||||
|
})
|
||||||
|
|
||||||
|
msg = parse_dsc_message(raw)
|
||||||
|
|
||||||
|
assert msg is not None
|
||||||
|
assert msg['type'] == 'dsc_message'
|
||||||
|
assert msg['source_mmsi'] == '232123456'
|
||||||
|
assert msg['category'] == 'DISTRESS'
|
||||||
|
assert msg['source_country'] == 'United Kingdom'
|
||||||
|
assert msg['nature_of_distress'] == 'FIRE'
|
||||||
|
assert msg['latitude'] == 51.5
|
||||||
|
assert msg['longitude'] == -0.1
|
||||||
|
assert msg['is_critical'] is True
|
||||||
|
assert msg['priority'] == 0
|
||||||
|
|
||||||
|
def test_parse_dsc_message_routine(self):
|
||||||
|
"""Test parsing a routine message."""
|
||||||
|
from utils.dsc.parser import parse_dsc_message
|
||||||
|
|
||||||
|
raw = json.dumps({
|
||||||
|
'type': 'dsc',
|
||||||
|
'format': 116,
|
||||||
|
'source_mmsi': '366000001',
|
||||||
|
'category': 'ROUTINE',
|
||||||
|
'timestamp': '2025-01-15T12:00:00Z'
|
||||||
|
})
|
||||||
|
|
||||||
|
msg = parse_dsc_message(raw)
|
||||||
|
|
||||||
|
assert msg is not None
|
||||||
|
assert msg['category'] == 'ROUTINE'
|
||||||
|
assert msg['source_country'] == 'USA'
|
||||||
|
assert msg['is_critical'] is False
|
||||||
|
assert msg['priority'] == 5
|
||||||
|
|
||||||
|
def test_parse_dsc_message_invalid_json(self):
|
||||||
|
"""Test parsing rejects invalid JSON."""
|
||||||
|
from utils.dsc.parser import parse_dsc_message
|
||||||
|
|
||||||
|
assert parse_dsc_message('not json') is None
|
||||||
|
assert parse_dsc_message('{invalid}') is None
|
||||||
|
|
||||||
|
def test_parse_dsc_message_missing_type(self):
|
||||||
|
"""Test parsing rejects messages without correct type."""
|
||||||
|
from utils.dsc.parser import parse_dsc_message
|
||||||
|
|
||||||
|
raw = json.dumps({'source_mmsi': '232123456'})
|
||||||
|
assert parse_dsc_message(raw) is None
|
||||||
|
|
||||||
|
raw = json.dumps({'type': 'other', 'source_mmsi': '232123456'})
|
||||||
|
assert parse_dsc_message(raw) is None
|
||||||
|
|
||||||
|
def test_parse_dsc_message_missing_mmsi(self):
|
||||||
|
"""Test parsing rejects messages without source MMSI."""
|
||||||
|
from utils.dsc.parser import parse_dsc_message
|
||||||
|
|
||||||
|
raw = json.dumps({'type': 'dsc'})
|
||||||
|
assert parse_dsc_message(raw) is None
|
||||||
|
|
||||||
|
def test_parse_dsc_message_empty(self):
|
||||||
|
"""Test parsing handles empty input."""
|
||||||
|
from utils.dsc.parser import parse_dsc_message
|
||||||
|
|
||||||
|
assert parse_dsc_message('') is None
|
||||||
|
assert parse_dsc_message(None) is None
|
||||||
|
assert parse_dsc_message(' ') is None
|
||||||
|
|
||||||
|
def test_format_dsc_for_display(self):
|
||||||
|
"""Test message formatting for display."""
|
||||||
|
from utils.dsc.parser import format_dsc_for_display
|
||||||
|
|
||||||
|
msg = {
|
||||||
|
'category': 'DISTRESS',
|
||||||
|
'source_mmsi': '232123456',
|
||||||
|
'source_country': 'United Kingdom',
|
||||||
|
'dest_mmsi': '002320001',
|
||||||
|
'nature_of_distress': 'FIRE',
|
||||||
|
'latitude': 51.5074,
|
||||||
|
'longitude': -0.1278,
|
||||||
|
'telecommand1_text': 'F3E_G3E_ALL',
|
||||||
|
'channel': 16,
|
||||||
|
'timestamp': '2025-01-15T12:00:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
output = format_dsc_for_display(msg)
|
||||||
|
|
||||||
|
assert 'DISTRESS' in output
|
||||||
|
assert '232123456' in output
|
||||||
|
assert 'United Kingdom' in output
|
||||||
|
assert 'FIRE' in output
|
||||||
|
assert '51.5074' in output
|
||||||
|
assert 'Channel: 16' in output
|
||||||
|
|
||||||
|
|
||||||
|
class TestDSCDecoder:
|
||||||
|
"""Tests for DSC decoder utilities."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def decoder(self):
|
||||||
|
"""Create a DSC decoder instance."""
|
||||||
|
# Skip if scipy not available
|
||||||
|
pytest.importorskip('scipy')
|
||||||
|
pytest.importorskip('numpy')
|
||||||
|
from utils.dsc.decoder import DSCDecoder
|
||||||
|
return DSCDecoder()
|
||||||
|
|
||||||
|
def test_decode_mmsi_valid(self, decoder):
|
||||||
|
"""Test MMSI decoding from symbols."""
|
||||||
|
# Each symbol is 2 BCD digits
|
||||||
|
# To encode MMSI 232123456, we need:
|
||||||
|
# 02-32-12-34-56 -> symbols [2, 32, 12, 34, 56]
|
||||||
|
symbols = [2, 32, 12, 34, 56]
|
||||||
|
result = decoder._decode_mmsi(symbols)
|
||||||
|
assert result == '232123456'
|
||||||
|
|
||||||
|
def test_decode_mmsi_with_leading_zeros(self, decoder):
|
||||||
|
"""Test MMSI decoding handles leading zeros."""
|
||||||
|
# Coast station: 002320001
|
||||||
|
# 00-23-20-00-01 -> [0, 23, 20, 0, 1]
|
||||||
|
symbols = [0, 23, 20, 0, 1]
|
||||||
|
result = decoder._decode_mmsi(symbols)
|
||||||
|
assert result == '002320001'
|
||||||
|
|
||||||
|
def test_decode_mmsi_short_symbols(self, decoder):
|
||||||
|
"""Test MMSI decoding handles short symbol list."""
|
||||||
|
result = decoder._decode_mmsi([1, 2, 3])
|
||||||
|
assert result == '000000000'
|
||||||
|
|
||||||
|
def test_decode_mmsi_invalid_symbols(self, decoder):
|
||||||
|
"""Test MMSI decoding handles invalid symbol values."""
|
||||||
|
# Symbols > 99 should be treated as 0
|
||||||
|
symbols = [100, 32, 12, 34, 56]
|
||||||
|
result = decoder._decode_mmsi(symbols)
|
||||||
|
# First symbol becomes 00
|
||||||
|
assert result == '003212345'[-9:]
|
||||||
|
|
||||||
|
def test_decode_position_northeast(self, decoder):
|
||||||
|
"""Test position decoding for NE quadrant."""
|
||||||
|
# Quadrant 10 = NE (lat+, lon+)
|
||||||
|
# Position: 51°30'N, 0°10'E
|
||||||
|
symbols = [10, 51, 30, 0, 10, 0, 0, 0, 0, 0]
|
||||||
|
result = decoder._decode_position(symbols)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result['lat'] == pytest.approx(51.5, rel=0.01)
|
||||||
|
assert result['lon'] == pytest.approx(0.1667, rel=0.01)
|
||||||
|
|
||||||
|
def test_decode_position_northwest(self, decoder):
|
||||||
|
"""Test position decoding for NW quadrant."""
|
||||||
|
# Quadrant 11 = NW (lat+, lon-)
|
||||||
|
# Position: 40°42'N, 74°00'W (NYC area)
|
||||||
|
symbols = [11, 40, 42, 0, 74, 0, 0, 0, 0, 0]
|
||||||
|
result = decoder._decode_position(symbols)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result['lat'] > 0 # North
|
||||||
|
assert result['lon'] < 0 # West
|
||||||
|
|
||||||
|
def test_decode_position_southeast(self, decoder):
|
||||||
|
"""Test position decoding for SE quadrant."""
|
||||||
|
# Quadrant 0 = SE (lat-, lon+)
|
||||||
|
symbols = [0, 33, 51, 1, 51, 12, 0, 0, 0, 0]
|
||||||
|
result = decoder._decode_position(symbols)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result['lat'] < 0 # South
|
||||||
|
assert result['lon'] > 0 # East
|
||||||
|
|
||||||
|
def test_decode_position_short_symbols(self, decoder):
|
||||||
|
"""Test position decoding handles short symbol list."""
|
||||||
|
result = decoder._decode_position([10, 51, 30])
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_decode_position_invalid_values(self, decoder):
|
||||||
|
"""Test position decoding handles invalid values gracefully."""
|
||||||
|
# Latitude > 90 should be treated as 0
|
||||||
|
symbols = [10, 95, 30, 0, 10, 0, 0, 0, 0, 0]
|
||||||
|
result = decoder._decode_position(symbols)
|
||||||
|
assert result is not None
|
||||||
|
assert result['lat'] == pytest.approx(0.5, rel=0.01) # 0 deg + 30 min
|
||||||
|
|
||||||
|
def test_bits_to_symbol(self, decoder):
|
||||||
|
"""Test bit to symbol conversion."""
|
||||||
|
# Symbol value is first 7 bits (LSB first)
|
||||||
|
# Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1, x,x,x]
|
||||||
|
bits = [0, 0, 1, 0, 0, 1, 1, 0, 0, 0]
|
||||||
|
result = decoder._bits_to_symbol(bits)
|
||||||
|
assert result == 100
|
||||||
|
|
||||||
|
def test_bits_to_symbol_wrong_length(self, decoder):
|
||||||
|
"""Test bit to symbol returns -1 for wrong length."""
|
||||||
|
result = decoder._bits_to_symbol([0, 1, 0, 1, 0])
|
||||||
|
assert result == -1
|
||||||
|
|
||||||
|
def test_detect_dot_pattern(self, decoder):
|
||||||
|
"""Test dot pattern detection."""
|
||||||
|
# Dot pattern is alternating 1010101...
|
||||||
|
decoder.bit_buffer = [1, 0] * 25 # 50 alternating bits
|
||||||
|
assert decoder._detect_dot_pattern() is True
|
||||||
|
|
||||||
|
def test_detect_dot_pattern_insufficient(self, decoder):
|
||||||
|
"""Test dot pattern not detected with insufficient alternations."""
|
||||||
|
decoder.bit_buffer = [1, 0] * 5 # Only 10 bits
|
||||||
|
assert decoder._detect_dot_pattern() is False
|
||||||
|
|
||||||
|
def test_detect_dot_pattern_not_alternating(self, decoder):
|
||||||
|
"""Test dot pattern not detected without alternation."""
|
||||||
|
decoder.bit_buffer = [1, 1, 1, 1, 0, 0, 0, 0] * 5
|
||||||
|
assert decoder._detect_dot_pattern() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestDSCConstants:
|
||||||
|
"""Tests for DSC constants."""
|
||||||
|
|
||||||
|
def test_format_codes_completeness(self):
|
||||||
|
"""Test that all standard format codes are defined."""
|
||||||
|
from utils.dsc.constants import FORMAT_CODES
|
||||||
|
|
||||||
|
# ITU-R M.493 format codes
|
||||||
|
assert 100 in FORMAT_CODES # DISTRESS
|
||||||
|
assert 102 in FORMAT_CODES # ALL_SHIPS
|
||||||
|
assert 106 in FORMAT_CODES # DISTRESS_ACK
|
||||||
|
assert 112 in FORMAT_CODES # INDIVIDUAL
|
||||||
|
assert 116 in FORMAT_CODES # ROUTINE
|
||||||
|
assert 118 in FORMAT_CODES # SAFETY
|
||||||
|
assert 120 in FORMAT_CODES # URGENCY
|
||||||
|
|
||||||
|
def test_distress_nature_codes_completeness(self):
|
||||||
|
"""Test that all distress nature codes are defined."""
|
||||||
|
from utils.dsc.constants import DISTRESS_NATURE_CODES
|
||||||
|
|
||||||
|
# ITU-R M.493 distress nature codes
|
||||||
|
assert 100 in DISTRESS_NATURE_CODES # UNDESIGNATED
|
||||||
|
assert 101 in DISTRESS_NATURE_CODES # FIRE
|
||||||
|
assert 102 in DISTRESS_NATURE_CODES # FLOODING
|
||||||
|
assert 103 in DISTRESS_NATURE_CODES # COLLISION
|
||||||
|
assert 106 in DISTRESS_NATURE_CODES # SINKING
|
||||||
|
assert 109 in DISTRESS_NATURE_CODES # PIRACY
|
||||||
|
assert 110 in DISTRESS_NATURE_CODES # MOB
|
||||||
|
|
||||||
|
def test_mid_country_map_completeness(self):
|
||||||
|
"""Test that common MID codes are defined."""
|
||||||
|
from utils.dsc.constants import MID_COUNTRY_MAP
|
||||||
|
|
||||||
|
# Verify some key maritime nations
|
||||||
|
assert '232' in MID_COUNTRY_MAP # UK
|
||||||
|
assert '366' in MID_COUNTRY_MAP # USA
|
||||||
|
assert '351' in MID_COUNTRY_MAP # Panama
|
||||||
|
assert '257' in MID_COUNTRY_MAP # Norway
|
||||||
|
assert '211' in MID_COUNTRY_MAP # Germany
|
||||||
|
assert '503' in MID_COUNTRY_MAP # Australia
|
||||||
|
assert '431' in MID_COUNTRY_MAP # Japan
|
||||||
|
|
||||||
|
def test_vhf_channel_70_frequency(self):
|
||||||
|
"""Test DSC Channel 70 frequency constant."""
|
||||||
|
from utils.dsc.constants import VHF_CHANNELS
|
||||||
|
|
||||||
|
assert VHF_CHANNELS[70] == 156.525
|
||||||
|
|
||||||
|
def test_dsc_modulation_parameters(self):
|
||||||
|
"""Test DSC modulation constants."""
|
||||||
|
from utils.dsc.constants import (
|
||||||
|
DSC_BAUD_RATE,
|
||||||
|
DSC_MARK_FREQ,
|
||||||
|
DSC_SPACE_FREQ,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert DSC_BAUD_RATE == 100
|
||||||
|
assert DSC_MARK_FREQ == 1800
|
||||||
|
assert DSC_SPACE_FREQ == 1200
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""Tests for DSC database operations."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def temp_db():
|
||||||
|
"""Use a temporary database for each test."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
test_db_path = Path(tmpdir) / 'test_intercept.db'
|
||||||
|
test_db_dir = Path(tmpdir)
|
||||||
|
|
||||||
|
with patch('utils.database.DB_PATH', test_db_path), \
|
||||||
|
patch('utils.database.DB_DIR', test_db_dir):
|
||||||
|
from utils.database import init_db, close_db
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
yield test_db_path
|
||||||
|
close_db()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDSCAlertsCRUD:
|
||||||
|
"""Tests for DSC alerts CRUD operations."""
|
||||||
|
|
||||||
|
def test_store_and_get_dsc_alert(self, temp_db):
|
||||||
|
"""Test storing and retrieving a DSC alert."""
|
||||||
|
from utils.database import store_dsc_alert, get_dsc_alert
|
||||||
|
|
||||||
|
alert_id = store_dsc_alert(
|
||||||
|
source_mmsi='232123456',
|
||||||
|
format_code='100',
|
||||||
|
category='DISTRESS',
|
||||||
|
source_name='MV Test Ship',
|
||||||
|
nature_of_distress='FIRE',
|
||||||
|
latitude=51.5,
|
||||||
|
longitude=-0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
assert alert_id is not None
|
||||||
|
assert alert_id > 0
|
||||||
|
|
||||||
|
alert = get_dsc_alert(alert_id)
|
||||||
|
|
||||||
|
assert alert is not None
|
||||||
|
assert alert['source_mmsi'] == '232123456'
|
||||||
|
assert alert['format_code'] == '100'
|
||||||
|
assert alert['category'] == 'DISTRESS'
|
||||||
|
assert alert['source_name'] == 'MV Test Ship'
|
||||||
|
assert alert['nature_of_distress'] == 'FIRE'
|
||||||
|
assert alert['latitude'] == 51.5
|
||||||
|
assert alert['longitude'] == -0.1
|
||||||
|
assert alert['acknowledged'] is False
|
||||||
|
|
||||||
|
def test_store_minimal_alert(self, temp_db):
|
||||||
|
"""Test storing alert with only required fields."""
|
||||||
|
from utils.database import store_dsc_alert, get_dsc_alert
|
||||||
|
|
||||||
|
alert_id = store_dsc_alert(
|
||||||
|
source_mmsi='366000001',
|
||||||
|
format_code='116',
|
||||||
|
category='ROUTINE'
|
||||||
|
)
|
||||||
|
|
||||||
|
alert = get_dsc_alert(alert_id)
|
||||||
|
|
||||||
|
assert alert is not None
|
||||||
|
assert alert['source_mmsi'] == '366000001'
|
||||||
|
assert alert['category'] == 'ROUTINE'
|
||||||
|
assert alert['latitude'] is None
|
||||||
|
assert alert['longitude'] is None
|
||||||
|
|
||||||
|
def test_get_nonexistent_alert(self, temp_db):
|
||||||
|
"""Test getting an alert that doesn't exist."""
|
||||||
|
from utils.database import get_dsc_alert
|
||||||
|
|
||||||
|
alert = get_dsc_alert(99999)
|
||||||
|
assert alert is None
|
||||||
|
|
||||||
|
def test_get_dsc_alerts_all(self, temp_db):
|
||||||
|
"""Test getting all alerts."""
|
||||||
|
from utils.database import store_dsc_alert, get_dsc_alerts
|
||||||
|
|
||||||
|
store_dsc_alert('232123456', '100', 'DISTRESS')
|
||||||
|
store_dsc_alert('366000001', '120', 'URGENCY')
|
||||||
|
store_dsc_alert('351234567', '116', 'ROUTINE')
|
||||||
|
|
||||||
|
alerts = get_dsc_alerts()
|
||||||
|
|
||||||
|
assert len(alerts) == 3
|
||||||
|
|
||||||
|
def test_get_dsc_alerts_by_category(self, temp_db):
|
||||||
|
"""Test filtering alerts by category."""
|
||||||
|
from utils.database import store_dsc_alert, get_dsc_alerts
|
||||||
|
|
||||||
|
store_dsc_alert('232123456', '100', 'DISTRESS')
|
||||||
|
store_dsc_alert('232123457', '100', 'DISTRESS')
|
||||||
|
store_dsc_alert('366000001', '120', 'URGENCY')
|
||||||
|
store_dsc_alert('351234567', '116', 'ROUTINE')
|
||||||
|
|
||||||
|
distress_alerts = get_dsc_alerts(category='DISTRESS')
|
||||||
|
urgency_alerts = get_dsc_alerts(category='URGENCY')
|
||||||
|
|
||||||
|
assert len(distress_alerts) == 2
|
||||||
|
assert len(urgency_alerts) == 1
|
||||||
|
|
||||||
|
def test_get_dsc_alerts_by_acknowledged(self, temp_db):
|
||||||
|
"""Test filtering alerts by acknowledgement status."""
|
||||||
|
from utils.database import (
|
||||||
|
store_dsc_alert,
|
||||||
|
get_dsc_alerts,
|
||||||
|
acknowledge_dsc_alert
|
||||||
|
)
|
||||||
|
|
||||||
|
id1 = store_dsc_alert('232123456', '100', 'DISTRESS')
|
||||||
|
id2 = store_dsc_alert('366000001', '100', 'DISTRESS')
|
||||||
|
store_dsc_alert('351234567', '100', 'DISTRESS')
|
||||||
|
|
||||||
|
acknowledge_dsc_alert(id1)
|
||||||
|
acknowledge_dsc_alert(id2)
|
||||||
|
|
||||||
|
unacked = get_dsc_alerts(acknowledged=False)
|
||||||
|
acked = get_dsc_alerts(acknowledged=True)
|
||||||
|
|
||||||
|
assert len(unacked) == 1
|
||||||
|
assert len(acked) == 2
|
||||||
|
|
||||||
|
def test_get_dsc_alerts_by_mmsi(self, temp_db):
|
||||||
|
"""Test filtering alerts by source MMSI."""
|
||||||
|
from utils.database import store_dsc_alert, get_dsc_alerts
|
||||||
|
|
||||||
|
store_dsc_alert('232123456', '100', 'DISTRESS')
|
||||||
|
store_dsc_alert('232123456', '120', 'URGENCY')
|
||||||
|
store_dsc_alert('366000001', '100', 'DISTRESS')
|
||||||
|
|
||||||
|
alerts = get_dsc_alerts(source_mmsi='232123456')
|
||||||
|
|
||||||
|
assert len(alerts) == 2
|
||||||
|
for alert in alerts:
|
||||||
|
assert alert['source_mmsi'] == '232123456'
|
||||||
|
|
||||||
|
def test_get_dsc_alerts_pagination(self, temp_db):
|
||||||
|
"""Test alert pagination."""
|
||||||
|
from utils.database import store_dsc_alert, get_dsc_alerts
|
||||||
|
|
||||||
|
# Create 10 alerts
|
||||||
|
for i in range(10):
|
||||||
|
store_dsc_alert(f'23212345{i}', '100', 'DISTRESS')
|
||||||
|
|
||||||
|
# Get first page
|
||||||
|
page1 = get_dsc_alerts(limit=5, offset=0)
|
||||||
|
assert len(page1) == 5
|
||||||
|
|
||||||
|
# Get second page
|
||||||
|
page2 = get_dsc_alerts(limit=5, offset=5)
|
||||||
|
assert len(page2) == 5
|
||||||
|
|
||||||
|
# Ensure no overlap
|
||||||
|
page1_ids = {a['id'] for a in page1}
|
||||||
|
page2_ids = {a['id'] for a in page2}
|
||||||
|
assert page1_ids.isdisjoint(page2_ids)
|
||||||
|
|
||||||
|
def test_get_dsc_alerts_order(self, temp_db):
|
||||||
|
"""Test alerts are returned in reverse chronological order."""
|
||||||
|
from utils.database import store_dsc_alert, get_dsc_alerts
|
||||||
|
|
||||||
|
id1 = store_dsc_alert('232123456', '100', 'DISTRESS')
|
||||||
|
id2 = store_dsc_alert('366000001', '100', 'DISTRESS')
|
||||||
|
id3 = store_dsc_alert('351234567', '100', 'DISTRESS')
|
||||||
|
|
||||||
|
alerts = get_dsc_alerts()
|
||||||
|
|
||||||
|
# ORDER BY received_at DESC, so most recent first
|
||||||
|
# When timestamps are identical, higher IDs are more recent
|
||||||
|
# The actual order depends on the DB implementation
|
||||||
|
# We just verify all 3 are present and it's a list
|
||||||
|
assert len(alerts) == 3
|
||||||
|
alert_ids = {a['id'] for a in alerts}
|
||||||
|
assert alert_ids == {id1, id2, id3}
|
||||||
|
|
||||||
|
def test_acknowledge_dsc_alert(self, temp_db):
|
||||||
|
"""Test acknowledging a DSC alert."""
|
||||||
|
from utils.database import (
|
||||||
|
store_dsc_alert,
|
||||||
|
get_dsc_alert,
|
||||||
|
acknowledge_dsc_alert
|
||||||
|
)
|
||||||
|
|
||||||
|
alert_id = store_dsc_alert('232123456', '100', 'DISTRESS')
|
||||||
|
|
||||||
|
# Initially not acknowledged
|
||||||
|
alert = get_dsc_alert(alert_id)
|
||||||
|
assert alert['acknowledged'] is False
|
||||||
|
|
||||||
|
# Acknowledge it
|
||||||
|
result = acknowledge_dsc_alert(alert_id)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
# Now acknowledged
|
||||||
|
alert = get_dsc_alert(alert_id)
|
||||||
|
assert alert['acknowledged'] is True
|
||||||
|
|
||||||
|
def test_acknowledge_dsc_alert_with_notes(self, temp_db):
|
||||||
|
"""Test acknowledging with notes."""
|
||||||
|
from utils.database import (
|
||||||
|
store_dsc_alert,
|
||||||
|
get_dsc_alert,
|
||||||
|
acknowledge_dsc_alert
|
||||||
|
)
|
||||||
|
|
||||||
|
alert_id = store_dsc_alert('232123456', '100', 'DISTRESS')
|
||||||
|
|
||||||
|
acknowledge_dsc_alert(alert_id, notes='Vessel located, rescue underway')
|
||||||
|
|
||||||
|
alert = get_dsc_alert(alert_id)
|
||||||
|
assert alert['acknowledged'] is True
|
||||||
|
assert alert['notes'] == 'Vessel located, rescue underway'
|
||||||
|
|
||||||
|
def test_acknowledge_nonexistent_alert(self, temp_db):
|
||||||
|
"""Test acknowledging an alert that doesn't exist."""
|
||||||
|
from utils.database import acknowledge_dsc_alert
|
||||||
|
|
||||||
|
result = acknowledge_dsc_alert(99999)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_get_dsc_alert_summary(self, temp_db):
|
||||||
|
"""Test getting alert summary counts."""
|
||||||
|
from utils.database import (
|
||||||
|
store_dsc_alert,
|
||||||
|
get_dsc_alert_summary,
|
||||||
|
acknowledge_dsc_alert
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create various alerts
|
||||||
|
store_dsc_alert('232123456', '100', 'DISTRESS')
|
||||||
|
store_dsc_alert('232123457', '100', 'DISTRESS')
|
||||||
|
store_dsc_alert('366000001', '120', 'URGENCY')
|
||||||
|
store_dsc_alert('351234567', '118', 'SAFETY')
|
||||||
|
acked_id = store_dsc_alert('257000001', '100', 'DISTRESS')
|
||||||
|
|
||||||
|
# Acknowledge one distress
|
||||||
|
acknowledge_dsc_alert(acked_id)
|
||||||
|
|
||||||
|
summary = get_dsc_alert_summary()
|
||||||
|
|
||||||
|
assert summary['distress'] == 2 # 3 - 1 acknowledged
|
||||||
|
assert summary['urgency'] == 1
|
||||||
|
assert summary['safety'] == 1
|
||||||
|
assert summary['total'] == 4
|
||||||
|
|
||||||
|
def test_get_dsc_alert_summary_empty(self, temp_db):
|
||||||
|
"""Test alert summary with no alerts."""
|
||||||
|
from utils.database import get_dsc_alert_summary
|
||||||
|
|
||||||
|
summary = get_dsc_alert_summary()
|
||||||
|
|
||||||
|
assert summary['distress'] == 0
|
||||||
|
assert summary['urgency'] == 0
|
||||||
|
assert summary['safety'] == 0
|
||||||
|
assert summary['routine'] == 0
|
||||||
|
assert summary['total'] == 0
|
||||||
|
|
||||||
|
def test_cleanup_old_dsc_alerts(self, temp_db):
|
||||||
|
"""Test cleanup function behavior."""
|
||||||
|
from utils.database import (
|
||||||
|
store_dsc_alert,
|
||||||
|
get_dsc_alerts,
|
||||||
|
acknowledge_dsc_alert,
|
||||||
|
cleanup_old_dsc_alerts
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create and acknowledge some alerts
|
||||||
|
id1 = store_dsc_alert('232123456', '100', 'DISTRESS')
|
||||||
|
id2 = store_dsc_alert('366000001', '100', 'DISTRESS')
|
||||||
|
id3 = store_dsc_alert('351234567', '100', 'DISTRESS') # Unacknowledged
|
||||||
|
|
||||||
|
acknowledge_dsc_alert(id1)
|
||||||
|
acknowledge_dsc_alert(id2)
|
||||||
|
|
||||||
|
# Cleanup with large max_age shouldn't delete recent records
|
||||||
|
deleted = cleanup_old_dsc_alerts(max_age_days=30)
|
||||||
|
assert deleted == 0 # Nothing old enough to delete
|
||||||
|
|
||||||
|
# All 3 should still be present
|
||||||
|
alerts = get_dsc_alerts()
|
||||||
|
assert len(alerts) == 3
|
||||||
|
|
||||||
|
# Verify unacknowledged one is still unacknowledged
|
||||||
|
unacked = get_dsc_alerts(acknowledged=False)
|
||||||
|
assert len(unacked) == 1
|
||||||
|
assert unacked[0]['id'] == id3
|
||||||
|
|
||||||
|
def test_cleanup_preserves_unacknowledged(self, temp_db):
|
||||||
|
"""Test cleanup preserves unacknowledged alerts regardless of age."""
|
||||||
|
from utils.database import (
|
||||||
|
store_dsc_alert,
|
||||||
|
get_dsc_alerts,
|
||||||
|
cleanup_old_dsc_alerts
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create unacknowledged alerts
|
||||||
|
store_dsc_alert('232123456', '100', 'DISTRESS')
|
||||||
|
store_dsc_alert('366000001', '100', 'DISTRESS')
|
||||||
|
|
||||||
|
# Cleanup with 0 days
|
||||||
|
deleted = cleanup_old_dsc_alerts(max_age_days=0)
|
||||||
|
|
||||||
|
# All should remain (none were acknowledged)
|
||||||
|
alerts = get_dsc_alerts()
|
||||||
|
assert len(alerts) == 2
|
||||||
|
assert deleted == 0
|
||||||
|
|
||||||
|
def test_store_alert_with_raw_message(self, temp_db):
|
||||||
|
"""Test storing alert with raw message data."""
|
||||||
|
from utils.database import store_dsc_alert, get_dsc_alert
|
||||||
|
|
||||||
|
raw = '100023212345603660000110010010000000000127'
|
||||||
|
|
||||||
|
alert_id = store_dsc_alert(
|
||||||
|
source_mmsi='232123456',
|
||||||
|
format_code='100',
|
||||||
|
category='DISTRESS',
|
||||||
|
raw_message=raw
|
||||||
|
)
|
||||||
|
|
||||||
|
alert = get_dsc_alert(alert_id)
|
||||||
|
assert alert['raw_message'] == raw
|
||||||
|
|
||||||
|
def test_store_alert_with_destination(self, temp_db):
|
||||||
|
"""Test storing alert with destination MMSI."""
|
||||||
|
from utils.database import store_dsc_alert, get_dsc_alert
|
||||||
|
|
||||||
|
alert_id = store_dsc_alert(
|
||||||
|
source_mmsi='232123456',
|
||||||
|
format_code='112',
|
||||||
|
category='INDIVIDUAL',
|
||||||
|
dest_mmsi='366000001'
|
||||||
|
)
|
||||||
|
|
||||||
|
alert = get_dsc_alert(alert_id)
|
||||||
|
assert alert['dest_mmsi'] == '366000001'
|
||||||
|
|
||||||
|
|
||||||
|
class TestDSCDatabaseIntegration:
|
||||||
|
"""Integration tests for DSC database operations."""
|
||||||
|
|
||||||
|
def test_full_alert_lifecycle(self, temp_db):
|
||||||
|
"""Test complete lifecycle of a DSC alert."""
|
||||||
|
from utils.database import (
|
||||||
|
store_dsc_alert,
|
||||||
|
get_dsc_alert,
|
||||||
|
get_dsc_alerts,
|
||||||
|
acknowledge_dsc_alert,
|
||||||
|
get_dsc_alert_summary
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Store a distress alert
|
||||||
|
alert_id = store_dsc_alert(
|
||||||
|
source_mmsi='232123456',
|
||||||
|
format_code='100',
|
||||||
|
category='DISTRESS',
|
||||||
|
source_name='MV Mayday',
|
||||||
|
nature_of_distress='SINKING',
|
||||||
|
latitude=50.0,
|
||||||
|
longitude=-5.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Verify it appears in summary
|
||||||
|
summary = get_dsc_alert_summary()
|
||||||
|
assert summary['distress'] == 1
|
||||||
|
assert summary['total'] == 1
|
||||||
|
|
||||||
|
# 3. Verify it appears in unacknowledged list
|
||||||
|
unacked = get_dsc_alerts(acknowledged=False)
|
||||||
|
assert len(unacked) == 1
|
||||||
|
assert unacked[0]['source_mmsi'] == '232123456'
|
||||||
|
|
||||||
|
# 4. Acknowledge with notes
|
||||||
|
acknowledge_dsc_alert(alert_id, 'Rescue helicopter dispatched')
|
||||||
|
|
||||||
|
# 5. Verify it's now acknowledged
|
||||||
|
alert = get_dsc_alert(alert_id)
|
||||||
|
assert alert['acknowledged'] is True
|
||||||
|
assert alert['notes'] == 'Rescue helicopter dispatched'
|
||||||
|
|
||||||
|
# 6. Verify summary updated
|
||||||
|
summary = get_dsc_alert_summary()
|
||||||
|
assert summary['distress'] == 0
|
||||||
|
assert summary['total'] == 0
|
||||||
|
|
||||||
|
# 7. Verify it appears in acknowledged list
|
||||||
|
acked = get_dsc_alerts(acknowledged=True)
|
||||||
|
assert len(acked) == 1
|
||||||
|
|
||||||
|
def test_multiple_vessel_alerts(self, temp_db):
|
||||||
|
"""Test handling alerts from multiple vessels."""
|
||||||
|
from utils.database import store_dsc_alert, get_dsc_alerts
|
||||||
|
|
||||||
|
# Simulate multiple vessels in distress
|
||||||
|
vessels = [
|
||||||
|
('232123456', 'United Kingdom', 'FIRE'),
|
||||||
|
('366000001', 'USA', 'FLOODING'),
|
||||||
|
('351234567', 'Panama', 'COLLISION'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for mmsi, country, nature in vessels:
|
||||||
|
store_dsc_alert(
|
||||||
|
source_mmsi=mmsi,
|
||||||
|
format_code='100',
|
||||||
|
category='DISTRESS',
|
||||||
|
nature_of_distress=nature
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify all alerts stored
|
||||||
|
alerts = get_dsc_alerts(category='DISTRESS')
|
||||||
|
assert len(alerts) == 3
|
||||||
|
|
||||||
|
# Verify each has correct nature
|
||||||
|
natures = {a['nature_of_distress'] for a in alerts}
|
||||||
|
assert natures == {'FIRE', 'FLOODING', 'COLLISION'}
|
||||||
@@ -237,3 +237,20 @@ HANDSHAKE_CAPTURE_PATH_PREFIX = '/tmp/intercept_handshake_'
|
|||||||
|
|
||||||
# PMKID capture path prefix
|
# PMKID capture path prefix
|
||||||
PMKID_CAPTURE_PATH_PREFIX = '/tmp/intercept_pmkid_'
|
PMKID_CAPTURE_PATH_PREFIX = '/tmp/intercept_pmkid_'
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DSC (Digital Selective Calling)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# VHF DSC frequency (Channel 70)
|
||||||
|
DSC_VHF_FREQUENCY_MHZ = 156.525
|
||||||
|
|
||||||
|
# DSC audio sample rate for rtl_fm
|
||||||
|
DSC_SAMPLE_RATE = 48000
|
||||||
|
|
||||||
|
# Maximum age for DSC messages in transient store
|
||||||
|
MAX_DSC_MESSAGE_AGE_SECONDS = 3600 # 1 hour
|
||||||
|
|
||||||
|
# DSC process termination timeout
|
||||||
|
DSC_TERMINATE_TIMEOUT = 3
|
||||||
|
|||||||
@@ -352,6 +352,39 @@ def init_db() -> None:
|
|||||||
ON tscm_cases(status, created_at)
|
ON tscm_cases(status, created_at)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# DSC (Digital Selective Calling) Tables
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
# DSC Alerts - Permanent storage for DISTRESS/URGENCY messages
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS dsc_alerts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
source_mmsi TEXT NOT NULL,
|
||||||
|
source_name TEXT,
|
||||||
|
dest_mmsi TEXT,
|
||||||
|
format_code TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
nature_of_distress TEXT,
|
||||||
|
latitude REAL,
|
||||||
|
longitude REAL,
|
||||||
|
raw_message TEXT,
|
||||||
|
acknowledged BOOLEAN DEFAULT 0,
|
||||||
|
notes TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dsc_alerts_category
|
||||||
|
ON dsc_alerts(category, received_at)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dsc_alerts_mmsi
|
||||||
|
ON dsc_alerts(source_mmsi, received_at)
|
||||||
|
''')
|
||||||
|
|
||||||
logger.info("Database initialized successfully")
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
|
|
||||||
@@ -1455,3 +1488,192 @@ def get_sweep_capabilities(sweep_id: int) -> dict | None:
|
|||||||
'limitations': json.loads(row['limitations']) if row['limitations'] else [],
|
'limitations': json.loads(row['limitations']) if row['limitations'] else [],
|
||||||
'recorded_at': row['recorded_at']
|
'recorded_at': row['recorded_at']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DSC (Digital Selective Calling) Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def store_dsc_alert(
|
||||||
|
source_mmsi: str,
|
||||||
|
format_code: str,
|
||||||
|
category: str,
|
||||||
|
source_name: str | None = None,
|
||||||
|
dest_mmsi: str | None = None,
|
||||||
|
nature_of_distress: str | None = None,
|
||||||
|
latitude: float | None = None,
|
||||||
|
longitude: float | None = None,
|
||||||
|
raw_message: str | None = None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Store a DSC alert (typically DISTRESS or URGENCY) to permanent storage.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ID of the created alert
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
INSERT INTO dsc_alerts
|
||||||
|
(source_mmsi, source_name, dest_mmsi, format_code, category,
|
||||||
|
nature_of_distress, latitude, longitude, raw_message)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
source_mmsi, source_name, dest_mmsi, format_code, category,
|
||||||
|
nature_of_distress, latitude, longitude, raw_message
|
||||||
|
))
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_dsc_alerts(
|
||||||
|
category: str | None = None,
|
||||||
|
acknowledged: bool | None = None,
|
||||||
|
source_mmsi: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Get DSC alerts with optional filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Filter by category (DISTRESS, URGENCY, SAFETY, ROUTINE)
|
||||||
|
acknowledged: Filter by acknowledgement status
|
||||||
|
source_mmsi: Filter by source MMSI
|
||||||
|
limit: Maximum number of results
|
||||||
|
offset: Offset for pagination
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of DSC alert records
|
||||||
|
"""
|
||||||
|
conditions = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if category is not None:
|
||||||
|
conditions.append('category = ?')
|
||||||
|
params.append(category)
|
||||||
|
if acknowledged is not None:
|
||||||
|
conditions.append('acknowledged = ?')
|
||||||
|
params.append(1 if acknowledged else 0)
|
||||||
|
if source_mmsi is not None:
|
||||||
|
conditions.append('source_mmsi = ?')
|
||||||
|
params.append(source_mmsi)
|
||||||
|
|
||||||
|
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(f'''
|
||||||
|
SELECT * FROM dsc_alerts
|
||||||
|
{where_clause}
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
''', params)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in cursor:
|
||||||
|
results.append({
|
||||||
|
'id': row['id'],
|
||||||
|
'received_at': row['received_at'],
|
||||||
|
'source_mmsi': row['source_mmsi'],
|
||||||
|
'source_name': row['source_name'],
|
||||||
|
'dest_mmsi': row['dest_mmsi'],
|
||||||
|
'format_code': row['format_code'],
|
||||||
|
'category': row['category'],
|
||||||
|
'nature_of_distress': row['nature_of_distress'],
|
||||||
|
'latitude': row['latitude'],
|
||||||
|
'longitude': row['longitude'],
|
||||||
|
'raw_message': row['raw_message'],
|
||||||
|
'acknowledged': bool(row['acknowledged']),
|
||||||
|
'notes': row['notes']
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def get_dsc_alert(alert_id: int) -> dict | None:
|
||||||
|
"""Get a specific DSC alert by ID."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'SELECT * FROM dsc_alerts WHERE id = ?',
|
||||||
|
(alert_id,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'id': row['id'],
|
||||||
|
'received_at': row['received_at'],
|
||||||
|
'source_mmsi': row['source_mmsi'],
|
||||||
|
'source_name': row['source_name'],
|
||||||
|
'dest_mmsi': row['dest_mmsi'],
|
||||||
|
'format_code': row['format_code'],
|
||||||
|
'category': row['category'],
|
||||||
|
'nature_of_distress': row['nature_of_distress'],
|
||||||
|
'latitude': row['latitude'],
|
||||||
|
'longitude': row['longitude'],
|
||||||
|
'raw_message': row['raw_message'],
|
||||||
|
'acknowledged': bool(row['acknowledged']),
|
||||||
|
'notes': row['notes']
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def acknowledge_dsc_alert(alert_id: int, notes: str | None = None) -> bool:
|
||||||
|
"""
|
||||||
|
Acknowledge a DSC alert.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
alert_id: The alert ID to acknowledge
|
||||||
|
notes: Optional notes about the acknowledgement
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if alert was found and updated, False otherwise
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
if notes:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'UPDATE dsc_alerts SET acknowledged = 1, notes = ? WHERE id = ?',
|
||||||
|
(notes, alert_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'UPDATE dsc_alerts SET acknowledged = 1 WHERE id = ?',
|
||||||
|
(alert_id,)
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_dsc_alert_summary() -> dict:
|
||||||
|
"""Get summary counts of DSC alerts by category."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
SELECT category, COUNT(*) as count
|
||||||
|
FROM dsc_alerts
|
||||||
|
WHERE acknowledged = 0
|
||||||
|
GROUP BY category
|
||||||
|
''')
|
||||||
|
|
||||||
|
summary = {'distress': 0, 'urgency': 0, 'safety': 0, 'routine': 0, 'total': 0}
|
||||||
|
for row in cursor:
|
||||||
|
cat = row['category'].lower()
|
||||||
|
if cat in summary:
|
||||||
|
summary[cat] = row['count']
|
||||||
|
summary['total'] += row['count']
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_dsc_alerts(max_age_days: int = 30) -> int:
|
||||||
|
"""
|
||||||
|
Remove old acknowledged DSC alerts (keeps unacknowledged ones).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_age_days: Maximum age in days for acknowledged alerts
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of deleted alerts
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
DELETE FROM dsc_alerts
|
||||||
|
WHERE acknowledged = 1
|
||||||
|
AND received_at < datetime('now', ?)
|
||||||
|
''', (f'-{max_age_days} days',))
|
||||||
|
return cursor.rowcount
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
DSC (Digital Selective Calling) utilities.
|
||||||
|
|
||||||
|
VHF DSC is a maritime distress and safety calling system operating on 156.525 MHz
|
||||||
|
(VHF Channel 70). It provides automated calling for distress, urgency, safety,
|
||||||
|
and routine communications per ITU-R M.493.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .constants import (
|
||||||
|
FORMAT_CODES,
|
||||||
|
DISTRESS_NATURE_CODES,
|
||||||
|
TELECOMMAND_CODES,
|
||||||
|
CATEGORY_PRIORITY,
|
||||||
|
MID_COUNTRY_MAP,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .parser import (
|
||||||
|
parse_dsc_message,
|
||||||
|
get_country_from_mmsi,
|
||||||
|
get_distress_nature_text,
|
||||||
|
get_format_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'FORMAT_CODES',
|
||||||
|
'DISTRESS_NATURE_CODES',
|
||||||
|
'TELECOMMAND_CODES',
|
||||||
|
'CATEGORY_PRIORITY',
|
||||||
|
'MID_COUNTRY_MAP',
|
||||||
|
'parse_dsc_message',
|
||||||
|
'get_country_from_mmsi',
|
||||||
|
'get_distress_nature_text',
|
||||||
|
'get_format_text',
|
||||||
|
]
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
"""
|
||||||
|
DSC (Digital Selective Calling) constants per ITU-R M.493.
|
||||||
|
|
||||||
|
This module contains all DSC-specific constants including format codes,
|
||||||
|
distress nature codes, telecommand definitions, and MID (Maritime
|
||||||
|
Identification Digits) country mappings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DSC Format Codes (Category)
|
||||||
|
# Per ITU-R M.493-15 Table 1
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
FORMAT_CODES = {
|
||||||
|
100: 'DISTRESS', # All ships distress alert
|
||||||
|
102: 'ALL_SHIPS', # All ships call
|
||||||
|
104: 'GROUP', # Group call
|
||||||
|
106: 'DISTRESS_ACK', # Distress acknowledgement
|
||||||
|
108: 'DISTRESS_RELAY', # Distress relay
|
||||||
|
110: 'GEOGRAPHIC', # Geographic area call
|
||||||
|
112: 'INDIVIDUAL', # Individual call
|
||||||
|
114: 'INDIVIDUAL_ACK', # Individual acknowledgement
|
||||||
|
116: 'ROUTINE', # Routine call
|
||||||
|
118: 'SAFETY', # Safety call
|
||||||
|
120: 'URGENCY', # Urgency call
|
||||||
|
}
|
||||||
|
|
||||||
|
# Category priority (lower = higher priority)
|
||||||
|
CATEGORY_PRIORITY = {
|
||||||
|
'DISTRESS': 0,
|
||||||
|
'DISTRESS_ACK': 1,
|
||||||
|
'DISTRESS_RELAY': 2,
|
||||||
|
'URGENCY': 3,
|
||||||
|
'SAFETY': 4,
|
||||||
|
'ROUTINE': 5,
|
||||||
|
'ALL_SHIPS': 5,
|
||||||
|
'GROUP': 5,
|
||||||
|
'GEOGRAPHIC': 5,
|
||||||
|
'INDIVIDUAL': 5,
|
||||||
|
'INDIVIDUAL_ACK': 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Nature of Distress Codes
|
||||||
|
# Per ITU-R M.493-15 Table 3
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
DISTRESS_NATURE_CODES = {
|
||||||
|
100: 'UNDESIGNATED', # Undesignated distress
|
||||||
|
101: 'FIRE', # Fire, explosion
|
||||||
|
102: 'FLOODING', # Flooding
|
||||||
|
103: 'COLLISION', # Collision
|
||||||
|
104: 'GROUNDING', # Grounding
|
||||||
|
105: 'LISTING', # Listing, in danger of capsizing
|
||||||
|
106: 'SINKING', # Sinking
|
||||||
|
107: 'DISABLED', # Disabled and adrift
|
||||||
|
108: 'ABANDONING', # Abandoning ship
|
||||||
|
109: 'PIRACY', # Piracy/armed robbery attack
|
||||||
|
110: 'MOB', # Man overboard
|
||||||
|
112: 'EPIRB', # EPIRB emission
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Telecommand Codes (First and Second)
|
||||||
|
# Per ITU-R M.493-15 Tables 4-5
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
TELECOMMAND_CODES = {
|
||||||
|
# First telecommand (type of subsequent communication)
|
||||||
|
100: 'F3E_G3E_ALL', # F3E/G3E all modes (VHF telephony)
|
||||||
|
101: 'F3E_G3E_DUPLEX', # F3E/G3E duplex
|
||||||
|
102: 'POLLING', # Polling
|
||||||
|
103: 'UNABLE_TO_COMPLY', # Unable to comply
|
||||||
|
104: 'END_OF_CALL', # End of call
|
||||||
|
105: 'DATA', # Data
|
||||||
|
106: 'J3E_TELEPHONY', # J3E telephony (SSB)
|
||||||
|
107: 'DISTRESS_ACK', # Distress acknowledgement
|
||||||
|
108: 'DISTRESS_RELAY', # Distress relay
|
||||||
|
109: 'F1B_J2B_FEC', # F1B/J2B FEC NBDP telegraphy
|
||||||
|
110: 'F1B_J2B_ARQ', # F1B/J2B ARQ NBDP telegraphy
|
||||||
|
111: 'TEST', # Test
|
||||||
|
112: 'SHIP_POSITION', # Ship position request
|
||||||
|
113: 'NO_INFO', # No information
|
||||||
|
118: 'FREQ_ANNOUNCEMENT', # Frequency announcement
|
||||||
|
126: 'NO_REASON', # No reason given
|
||||||
|
|
||||||
|
# Second telecommand (additional info)
|
||||||
|
200: 'F3E_G3E_SIMPLEX', # Simplex VHF telephony requested
|
||||||
|
201: 'POLL_RESPONSE', # Poll response
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DSC Symbol Definitions
|
||||||
|
# Per ITU-R M.493-15
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Special symbols
|
||||||
|
DSC_SYMBOLS = {
|
||||||
|
120: 'DX', # Dot pattern (synchronization)
|
||||||
|
121: 'RX', # Phasing sequence RX
|
||||||
|
122: 'SX', # Phasing sequence SX
|
||||||
|
123: 'S0', # Phasing sequence S0
|
||||||
|
124: 'S1', # Phasing sequence S1
|
||||||
|
125: 'S2', # Phasing sequence S2
|
||||||
|
126: 'S3', # Phasing sequence S3
|
||||||
|
127: 'EOS', # End of sequence
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MID (Maritime Identification Digits) Country Mapping
|
||||||
|
# First 3 digits of MMSI identify the country
|
||||||
|
# Per ITU MID table (partial list of common codes)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
MID_COUNTRY_MAP = {
|
||||||
|
# Americas
|
||||||
|
'201': 'Albania',
|
||||||
|
'202': 'Andorra',
|
||||||
|
'203': 'Austria',
|
||||||
|
'204': 'Azores',
|
||||||
|
'205': 'Belgium',
|
||||||
|
'206': 'Belarus',
|
||||||
|
'207': 'Bulgaria',
|
||||||
|
'208': 'Vatican City',
|
||||||
|
'209': 'Cyprus',
|
||||||
|
'210': 'Cyprus',
|
||||||
|
'211': 'Germany',
|
||||||
|
'212': 'Cyprus',
|
||||||
|
'213': 'Georgia',
|
||||||
|
'214': 'Moldova',
|
||||||
|
'215': 'Malta',
|
||||||
|
'216': 'Armenia',
|
||||||
|
'218': 'Germany',
|
||||||
|
'219': 'Denmark',
|
||||||
|
'220': 'Denmark',
|
||||||
|
'224': 'Spain',
|
||||||
|
'225': 'Spain',
|
||||||
|
'226': 'France',
|
||||||
|
'227': 'France',
|
||||||
|
'228': 'France',
|
||||||
|
'229': 'Malta',
|
||||||
|
'230': 'Finland',
|
||||||
|
'231': 'Faroe Islands',
|
||||||
|
'232': 'United Kingdom',
|
||||||
|
'233': 'United Kingdom',
|
||||||
|
'234': 'United Kingdom',
|
||||||
|
'235': 'United Kingdom',
|
||||||
|
'236': 'Gibraltar',
|
||||||
|
'237': 'Greece',
|
||||||
|
'238': 'Croatia',
|
||||||
|
'239': 'Greece',
|
||||||
|
'240': 'Greece',
|
||||||
|
'241': 'Greece',
|
||||||
|
'242': 'Morocco',
|
||||||
|
'243': 'Hungary',
|
||||||
|
'244': 'Netherlands',
|
||||||
|
'245': 'Netherlands',
|
||||||
|
'246': 'Netherlands',
|
||||||
|
'247': 'Italy',
|
||||||
|
'248': 'Malta',
|
||||||
|
'249': 'Malta',
|
||||||
|
'250': 'Ireland',
|
||||||
|
'251': 'Iceland',
|
||||||
|
'252': 'Liechtenstein',
|
||||||
|
'253': 'Luxembourg',
|
||||||
|
'254': 'Monaco',
|
||||||
|
'255': 'Madeira',
|
||||||
|
'256': 'Malta',
|
||||||
|
'257': 'Norway',
|
||||||
|
'258': 'Norway',
|
||||||
|
'259': 'Norway',
|
||||||
|
'261': 'Poland',
|
||||||
|
'262': 'Montenegro',
|
||||||
|
'263': 'Portugal',
|
||||||
|
'264': 'Romania',
|
||||||
|
'265': 'Sweden',
|
||||||
|
'266': 'Sweden',
|
||||||
|
'267': 'Slovakia',
|
||||||
|
'268': 'San Marino',
|
||||||
|
'269': 'Switzerland',
|
||||||
|
'270': 'Czech Republic',
|
||||||
|
'271': 'Turkey',
|
||||||
|
'272': 'Ukraine',
|
||||||
|
'273': 'Russia',
|
||||||
|
'274': 'North Macedonia',
|
||||||
|
'275': 'Latvia',
|
||||||
|
'276': 'Estonia',
|
||||||
|
'277': 'Lithuania',
|
||||||
|
'278': 'Slovenia',
|
||||||
|
'279': 'Serbia',
|
||||||
|
|
||||||
|
# North America
|
||||||
|
'301': 'Anguilla',
|
||||||
|
'303': 'USA',
|
||||||
|
'304': 'Antigua and Barbuda',
|
||||||
|
'305': 'Antigua and Barbuda',
|
||||||
|
'306': 'Curacao',
|
||||||
|
'307': 'Aruba',
|
||||||
|
'308': 'Bahamas',
|
||||||
|
'309': 'Bahamas',
|
||||||
|
'310': 'Bermuda',
|
||||||
|
'311': 'Bahamas',
|
||||||
|
'312': 'Belize',
|
||||||
|
'314': 'Barbados',
|
||||||
|
'316': 'Canada',
|
||||||
|
'319': 'Cayman Islands',
|
||||||
|
'321': 'Costa Rica',
|
||||||
|
'323': 'Cuba',
|
||||||
|
'325': 'Dominica',
|
||||||
|
'327': 'Dominican Republic',
|
||||||
|
'329': 'Guadeloupe',
|
||||||
|
'330': 'Grenada',
|
||||||
|
'331': 'Greenland',
|
||||||
|
'332': 'Guatemala',
|
||||||
|
'334': 'Honduras',
|
||||||
|
'336': 'Haiti',
|
||||||
|
'338': 'USA',
|
||||||
|
'339': 'Jamaica',
|
||||||
|
'341': 'Saint Kitts and Nevis',
|
||||||
|
'343': 'Saint Lucia',
|
||||||
|
'345': 'Mexico',
|
||||||
|
'347': 'Martinique',
|
||||||
|
'348': 'Montserrat',
|
||||||
|
'350': 'Nicaragua',
|
||||||
|
'351': 'Panama',
|
||||||
|
'352': 'Panama',
|
||||||
|
'353': 'Panama',
|
||||||
|
'354': 'Panama',
|
||||||
|
'355': 'Panama',
|
||||||
|
'356': 'Panama',
|
||||||
|
'357': 'Panama',
|
||||||
|
'358': 'Puerto Rico',
|
||||||
|
'359': 'El Salvador',
|
||||||
|
'361': 'Saint Pierre and Miquelon',
|
||||||
|
'362': 'Trinidad and Tobago',
|
||||||
|
'364': 'Turks and Caicos',
|
||||||
|
'366': 'USA',
|
||||||
|
'367': 'USA',
|
||||||
|
'368': 'USA',
|
||||||
|
'369': 'USA',
|
||||||
|
'370': 'Panama',
|
||||||
|
'371': 'Panama',
|
||||||
|
'372': 'Panama',
|
||||||
|
'373': 'Panama',
|
||||||
|
'374': 'Panama',
|
||||||
|
'375': 'Saint Vincent and the Grenadines',
|
||||||
|
'376': 'Saint Vincent and the Grenadines',
|
||||||
|
'377': 'Saint Vincent and the Grenadines',
|
||||||
|
'378': 'British Virgin Islands',
|
||||||
|
'379': 'US Virgin Islands',
|
||||||
|
|
||||||
|
# Asia
|
||||||
|
'401': 'Afghanistan',
|
||||||
|
'403': 'Saudi Arabia',
|
||||||
|
'405': 'Bangladesh',
|
||||||
|
'408': 'Bahrain',
|
||||||
|
'410': 'Bhutan',
|
||||||
|
'412': 'China',
|
||||||
|
'413': 'China',
|
||||||
|
'414': 'China',
|
||||||
|
'416': 'Taiwan',
|
||||||
|
'417': 'Sri Lanka',
|
||||||
|
'419': 'India',
|
||||||
|
'422': 'Iran',
|
||||||
|
'423': 'Azerbaijan',
|
||||||
|
'425': 'Iraq',
|
||||||
|
'428': 'Israel',
|
||||||
|
'431': 'Japan',
|
||||||
|
'432': 'Japan',
|
||||||
|
'434': 'Turkmenistan',
|
||||||
|
'436': 'Kazakhstan',
|
||||||
|
'437': 'Uzbekistan',
|
||||||
|
'438': 'Jordan',
|
||||||
|
'440': 'South Korea',
|
||||||
|
'441': 'South Korea',
|
||||||
|
'443': 'Palestine',
|
||||||
|
'445': 'North Korea',
|
||||||
|
'447': 'Kuwait',
|
||||||
|
'450': 'Lebanon',
|
||||||
|
'451': 'Kyrgyzstan',
|
||||||
|
'453': 'Macao',
|
||||||
|
'455': 'Maldives',
|
||||||
|
'457': 'Mongolia',
|
||||||
|
'459': 'Nepal',
|
||||||
|
'461': 'Oman',
|
||||||
|
'463': 'Pakistan',
|
||||||
|
'466': 'Qatar',
|
||||||
|
'468': 'Syria',
|
||||||
|
'470': 'UAE',
|
||||||
|
'471': 'UAE',
|
||||||
|
'472': 'Tajikistan',
|
||||||
|
'473': 'Yemen',
|
||||||
|
'475': 'Yemen',
|
||||||
|
'477': 'Hong Kong',
|
||||||
|
'478': 'Bosnia and Herzegovina',
|
||||||
|
|
||||||
|
# Oceania
|
||||||
|
'501': 'Adelie Land',
|
||||||
|
'503': 'Australia',
|
||||||
|
'506': 'Myanmar',
|
||||||
|
'508': 'Brunei',
|
||||||
|
'510': 'Micronesia',
|
||||||
|
'511': 'Palau',
|
||||||
|
'512': 'New Zealand',
|
||||||
|
'514': 'Cambodia',
|
||||||
|
'515': 'Cambodia',
|
||||||
|
'516': 'Christmas Island',
|
||||||
|
'518': 'Cook Islands',
|
||||||
|
'520': 'Fiji',
|
||||||
|
'523': 'Cocos Islands',
|
||||||
|
'525': 'Indonesia',
|
||||||
|
'529': 'Kiribati',
|
||||||
|
'531': 'Laos',
|
||||||
|
'533': 'Malaysia',
|
||||||
|
'536': 'Northern Mariana Islands',
|
||||||
|
'538': 'Marshall Islands',
|
||||||
|
'540': 'New Caledonia',
|
||||||
|
'542': 'Niue',
|
||||||
|
'544': 'Nauru',
|
||||||
|
'546': 'French Polynesia',
|
||||||
|
'548': 'Philippines',
|
||||||
|
'550': 'Timor-Leste',
|
||||||
|
'553': 'Papua New Guinea',
|
||||||
|
'555': 'Pitcairn Island',
|
||||||
|
'557': 'Solomon Islands',
|
||||||
|
'559': 'American Samoa',
|
||||||
|
'561': 'Samoa',
|
||||||
|
'563': 'Singapore',
|
||||||
|
'564': 'Singapore',
|
||||||
|
'565': 'Singapore',
|
||||||
|
'566': 'Singapore',
|
||||||
|
'567': 'Thailand',
|
||||||
|
'570': 'Tonga',
|
||||||
|
'572': 'Tuvalu',
|
||||||
|
'574': 'Vietnam',
|
||||||
|
'576': 'Vanuatu',
|
||||||
|
'577': 'Vanuatu',
|
||||||
|
'578': 'Wallis and Futuna',
|
||||||
|
|
||||||
|
# Africa
|
||||||
|
'601': 'South Africa',
|
||||||
|
'603': 'Angola',
|
||||||
|
'605': 'Algeria',
|
||||||
|
'607': 'St. Paul and Amsterdam Islands',
|
||||||
|
'608': 'Ascension Island',
|
||||||
|
'609': 'Burundi',
|
||||||
|
'610': 'Benin',
|
||||||
|
'611': 'Botswana',
|
||||||
|
'612': 'Central African Republic',
|
||||||
|
'613': 'Cameroon',
|
||||||
|
'615': 'Congo',
|
||||||
|
'616': 'Comoros',
|
||||||
|
'617': 'Cabo Verde',
|
||||||
|
'618': 'Crozet Archipelago',
|
||||||
|
'619': 'Ivory Coast',
|
||||||
|
'620': 'Comoros',
|
||||||
|
'621': 'Djibouti',
|
||||||
|
'622': 'Egypt',
|
||||||
|
'624': 'Ethiopia',
|
||||||
|
'625': 'Eritrea',
|
||||||
|
'626': 'Gabon',
|
||||||
|
'627': 'Ghana',
|
||||||
|
'629': 'Gambia',
|
||||||
|
'630': 'Guinea-Bissau',
|
||||||
|
'631': 'Equatorial Guinea',
|
||||||
|
'632': 'Guinea',
|
||||||
|
'633': 'Burkina Faso',
|
||||||
|
'634': 'Kenya',
|
||||||
|
'635': 'Kerguelen Islands',
|
||||||
|
'636': 'Liberia',
|
||||||
|
'637': 'Liberia',
|
||||||
|
'638': 'South Sudan',
|
||||||
|
'642': 'Libya',
|
||||||
|
'644': 'Lesotho',
|
||||||
|
'645': 'Mauritius',
|
||||||
|
'647': 'Madagascar',
|
||||||
|
'649': 'Mali',
|
||||||
|
'650': 'Mozambique',
|
||||||
|
'654': 'Mauritania',
|
||||||
|
'655': 'Malawi',
|
||||||
|
'656': 'Niger',
|
||||||
|
'657': 'Nigeria',
|
||||||
|
'659': 'Namibia',
|
||||||
|
'660': 'Reunion',
|
||||||
|
'661': 'Rwanda',
|
||||||
|
'662': 'Sudan',
|
||||||
|
'663': 'Senegal',
|
||||||
|
'664': 'Seychelles',
|
||||||
|
'665': 'Saint Helena',
|
||||||
|
'666': 'Somalia',
|
||||||
|
'667': 'Sierra Leone',
|
||||||
|
'668': 'Sao Tome and Principe',
|
||||||
|
'669': 'Swaziland',
|
||||||
|
'670': 'Chad',
|
||||||
|
'671': 'Togo',
|
||||||
|
'672': 'Tunisia',
|
||||||
|
'674': 'Tanzania',
|
||||||
|
'675': 'Uganda',
|
||||||
|
'676': 'Democratic Republic of Congo',
|
||||||
|
'677': 'Tanzania',
|
||||||
|
'678': 'Zambia',
|
||||||
|
'679': 'Zimbabwe',
|
||||||
|
|
||||||
|
# South America
|
||||||
|
'701': 'Argentina',
|
||||||
|
'710': 'Brazil',
|
||||||
|
'720': 'Bolivia',
|
||||||
|
'725': 'Chile',
|
||||||
|
'730': 'Colombia',
|
||||||
|
'735': 'Ecuador',
|
||||||
|
'740': 'Falkland Islands',
|
||||||
|
'745': 'Guiana',
|
||||||
|
'750': 'Guyana',
|
||||||
|
'755': 'Paraguay',
|
||||||
|
'760': 'Peru',
|
||||||
|
'765': 'Suriname',
|
||||||
|
'770': 'Uruguay',
|
||||||
|
'775': 'Venezuela',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# VHF Channel Frequencies (MHz) for DSC follow-up
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
VHF_CHANNELS = {
|
||||||
|
6: 156.300, # Intership safety
|
||||||
|
8: 156.400, # Commercial working
|
||||||
|
9: 156.450, # Calling
|
||||||
|
10: 156.500, # Commercial working
|
||||||
|
12: 156.600, # Port operations
|
||||||
|
13: 156.650, # Bridge-to-bridge navigation safety
|
||||||
|
14: 156.700, # Port operations
|
||||||
|
16: 156.800, # Distress, safety and calling (VHF voice)
|
||||||
|
67: 156.375, # UK small craft safety
|
||||||
|
68: 156.425, # Marina/yacht club
|
||||||
|
70: 156.525, # DSC distress, safety and calling
|
||||||
|
71: 156.575, # Port operations
|
||||||
|
72: 156.625, # Intership
|
||||||
|
73: 156.675, # Port operations
|
||||||
|
74: 156.725, # Port operations
|
||||||
|
77: 156.875, # Intership
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DSC Modulation Parameters
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
DSC_BAUD_RATE = 100 # 100 baud per ITU-R M.493
|
||||||
|
|
||||||
|
# FSK tone frequencies (Hz)
|
||||||
|
DSC_MARK_FREQ = 1800 # B (mark) - binary 1
|
||||||
|
DSC_SPACE_FREQ = 1200 # Y (space) - binary 0
|
||||||
|
|
||||||
|
# Audio sample rate for decoding
|
||||||
|
DSC_AUDIO_SAMPLE_RATE = 48000
|
||||||
|
|
||||||
|
# Frame structure
|
||||||
|
DSC_DOT_PATTERN_LENGTH = 200 # 200 bits of alternating pattern
|
||||||
|
DSC_PHASING_LENGTH = 7 # 7 symbols phasing sequence
|
||||||
|
DSC_MESSAGE_MAX_SYMBOLS = 180 # Maximum message length in symbols
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
#!/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 100 baud FSK with:
|
||||||
|
- Mark (1): 1800 Hz
|
||||||
|
- Space (0): 1200 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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 100 baud 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 # 1800 Hz = binary 1
|
||||||
|
self.space_freq = DSC_SPACE_FREQ # 1200 Hz = binary 0
|
||||||
|
|
||||||
|
# Bandpass filter for DSC band (1100-1900 Hz)
|
||||||
|
nyq = sample_rate / 2
|
||||||
|
low = 1100 / nyq
|
||||||
|
high = 1900 / 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 look for at least 20 consecutive alternations.
|
||||||
|
"""
|
||||||
|
if len(self.bit_buffer) < 40:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check last 40 bits for alternating pattern
|
||||||
|
last_bits = self.bit_buffer[-40:]
|
||||||
|
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 >= 20:
|
||||||
|
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)
|
||||||
|
symbols.append(symbol_value)
|
||||||
|
|
||||||
|
# Look for EOS (End of Sequence) - symbol 127
|
||||||
|
eos_found = False
|
||||||
|
eos_index = -1
|
||||||
|
for i, sym in enumerate(symbols):
|
||||||
|
if sym == 127: # EOS symbol
|
||||||
|
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.
|
||||||
|
We extract the 7-bit value.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
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}')
|
||||||
|
|
||||||
|
# Determine category from format
|
||||||
|
category = 'ROUTINE'
|
||||||
|
if format_code == 100:
|
||||||
|
category = 'DISTRESS'
|
||||||
|
elif format_code == 106:
|
||||||
|
category = 'DISTRESS_ACK'
|
||||||
|
elif format_code == 108:
|
||||||
|
category = 'DISTRESS_RELAY'
|
||||||
|
elif format_code == 118:
|
||||||
|
category = 'SAFETY'
|
||||||
|
elif format_code == 120:
|
||||||
|
category = 'URGENCY'
|
||||||
|
elif format_code == 102:
|
||||||
|
category = 'ALL_SHIPS'
|
||||||
|
|
||||||
|
# Decode MMSI from symbols 1-5 (destination/address)
|
||||||
|
dest_mmsi = self._decode_mmsi(symbols[1:6])
|
||||||
|
|
||||||
|
# Decode self-ID from symbols 6-10 (source)
|
||||||
|
source_mmsi = self._decode_mmsi(symbols[6:11])
|
||||||
|
|
||||||
|
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 (usually last two before EOS)
|
||||||
|
if 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:
|
||||||
|
"""
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
if len(symbols) < 5:
|
||||||
|
return '000000000'
|
||||||
|
|
||||||
|
digits = []
|
||||||
|
for sym in symbols:
|
||||||
|
if sym < 0 or sym > 99:
|
||||||
|
sym = 0
|
||||||
|
# Each symbol is 2 BCD digits
|
||||||
|
digits.append(f'{sym:02d}')
|
||||||
|
|
||||||
|
mmsi = ''.join(digits)
|
||||||
|
# MMSI is 9 digits, might need to trim leading zero
|
||||||
|
if len(mmsi) > 9:
|
||||||
|
mmsi = mmsi[-9:]
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
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 (
|
||||||
|
FORMAT_CODES,
|
||||||
|
DISTRESS_NATURE_CODES,
|
||||||
|
TELECOMMAND_CODES,
|
||||||
|
CATEGORY_PRIORITY,
|
||||||
|
MID_COUNTRY_MAP,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Build parsed message
|
||||||
|
msg = {
|
||||||
|
'type': 'dsc_message',
|
||||||
|
'source_mmsi': str(data.get('source_mmsi', '')),
|
||||||
|
'dest_mmsi': str(data.get('dest_mmsi', '')) if data.get('dest_mmsi') else None,
|
||||||
|
'format_code': data.get('format'),
|
||||||
|
'format_text': get_format_text(data.get('format', 0)),
|
||||||
|
'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 'nature' in data and data['nature']:
|
||||||
|
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 'telecommand1' in data and data['telecommand1']:
|
||||||
|
msg['telecommand1'] = data['telecommand1']
|
||||||
|
msg['telecommand1_text'] = get_telecommand_text(data['telecommand1'])
|
||||||
|
|
||||||
|
if 'telecommand2' in data and data['telecommand2']:
|
||||||
|
msg['telecommand2'] = data['telecommand2']
|
||||||
|
msg['telecommand2_text'] = get_telecommand_text(data['telecommand2'])
|
||||||
|
|
||||||
|
# Add channel if present
|
||||||
|
if 'channel' in data and data['channel']:
|
||||||
|
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')
|
||||||
|
|
||||||
|
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
|
||||||
|
if mmsi == '000000000':
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
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'
|
||||||
Reference in New Issue
Block a user