diff --git a/routes/adsb.py b/routes/adsb.py
index cf984b5..e582df1 100644
--- a/routes/adsb.py
+++ b/routes/adsb.py
@@ -803,6 +803,41 @@ def adsb_status():
})
+@adsb_bp.route('/aircraft')
+def adsb_aircraft_export():
+ """Export current ADS-B aircraft data as JSON.
+
+ Returns a snapshot of all tracked aircraft suitable for integration
+ with external tools. For SBS (BaseStation) format, connect directly
+ to port 30003 which dump1090 exposes natively.
+
+ Query parameters:
+ icao: Filter to a specific ICAO hex code (optional)
+ military: 'true' to return only military aircraft (optional)
+
+ Returns:
+ JSON with aircraft list and metadata.
+ """
+ aircraft = dict(app_module.adsb_aircraft)
+
+ icao_filter = request.args.get('icao', '').upper()
+ if icao_filter:
+ aircraft = {k: v for k, v in aircraft.items() if k.upper() == icao_filter}
+
+ if request.args.get('military') == 'true':
+ try:
+ from utils.military_icao import is_military_icao
+ aircraft = {k: v for k, v in aircraft.items() if is_military_icao(k)}
+ except ImportError:
+ pass
+
+ return jsonify({
+ 'count': len(aircraft),
+ 'aircraft': list(aircraft.values()),
+ 'sbs_port': 30003, # dump1090 SBS stream for tools like Virtual Radar Server
+ })
+
+
@adsb_bp.route('/session')
def adsb_session():
"""Get ADS-B session status and uptime."""
diff --git a/routes/ais.py b/routes/ais.py
index 847ef36..4d6eed2 100644
--- a/routes/ais.py
+++ b/routes/ais.py
@@ -408,11 +408,24 @@ def start_ais():
bias_t = data.get('bias_t', False)
tcp_port = AIS_TCP_PORT
+ # Optional UDP NMEA forwarding (e.g. for OpenCPN on port 10110)
+ udp_host = data.get('udp_host') or None
+ udp_port = None
+ if udp_host:
+ try:
+ udp_port = int(data.get('udp_port', 10110))
+ if not 1 <= udp_port <= 65535:
+ raise ValueError
+ except (TypeError, ValueError):
+ return api_error('Invalid udp_port (1-65535)', 400)
+
cmd = builder.build_ais_command(
device=sdr_device,
gain=float(gain),
bias_t=bias_t,
- tcp_port=tcp_port
+ tcp_port=tcp_port,
+ udp_host=udp_host,
+ udp_port=udp_port,
)
# Use the found AIS-catcher path
@@ -535,6 +548,31 @@ def get_vessel_dsc(mmsi: str):
return api_success(data={'mmsi': mmsi, 'dsc_messages': matches})
+@ais_bp.route('/vessels')
+def ais_vessels():
+ """Export current AIS vessel data as JSON.
+
+ Returns a snapshot of all tracked vessels suitable for integration
+ with external tools (OpenCPN, ship tracking apps, etc.).
+
+ Query parameters:
+ mmsi: Filter to a specific MMSI (optional)
+
+ Returns:
+ JSON with vessel list and metadata.
+ """
+ vessels = dict(app_module.ais_vessels)
+
+ mmsi_filter = request.args.get('mmsi')
+ if mmsi_filter:
+ vessels = {k: v for k, v in vessels.items() if str(k) == str(mmsi_filter)}
+
+ return jsonify({
+ 'count': len(vessels),
+ 'vessels': list(vessels.values()),
+ })
+
+
@ais_bp.route('/dashboard')
def ais_dashboard():
"""Popout AIS dashboard."""
diff --git a/templates/partials/modes/ais.html b/templates/partials/modes/ais.html
index dacab19..dbc6083 100644
--- a/templates/partials/modes/ais.html
+++ b/templates/partials/modes/ais.html
@@ -18,6 +18,23 @@
+
Status
@@ -110,11 +127,22 @@
function startAisTracking() {
const gain = document.getElementById('aisGainInput').value || '40';
const device = document.getElementById('deviceSelect')?.value || '0';
+ const udpHost = document.getElementById('aisUdpHost').value.trim();
+ const udpPort = parseInt(document.getElementById('aisUdpPort').value) || 10110;
+
+ const body = {
+ device, gain,
+ bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
+ };
+ if (udpHost) {
+ body.udp_host = udpHost;
+ body.udp_port = udpPort;
+ }
fetch('/ais/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ device, gain, bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false })
+ body: JSON.stringify(body)
})
.then(r => r.json())
.then(data => {
diff --git a/utils/sdr/airspy.py b/utils/sdr/airspy.py
index 104fef1..a146f60 100644
--- a/utils/sdr/airspy.py
+++ b/utils/sdr/airspy.py
@@ -161,7 +161,9 @@ class AirspyCommandBuilder(CommandBuilder):
device: SDRDevice,
gain: float | None = None,
bias_t: bool = False,
- tcp_port: int = 10110
+ tcp_port: int = 10110,
+ udp_host: str | None = None,
+ udp_port: int | None = None,
) -> list[str]:
"""
Build AIS-catcher command for AIS vessel tracking with Airspy.
@@ -184,6 +186,9 @@ class AirspyCommandBuilder(CommandBuilder):
if bias_t:
cmd.extend(['-gr', 'biastee', '1'])
+ if udp_host and udp_port:
+ cmd.extend(['-u', udp_host, str(udp_port)])
+
return cmd
def build_iq_capture_command(
diff --git a/utils/sdr/base.py b/utils/sdr/base.py
index 6fe012c..0ecca03 100644
--- a/utils/sdr/base.py
+++ b/utils/sdr/base.py
@@ -165,7 +165,9 @@ class CommandBuilder(ABC):
device: SDRDevice,
gain: float | None = None,
bias_t: bool = False,
- tcp_port: int = 10110
+ tcp_port: int = 10110,
+ udp_host: str | None = None,
+ udp_port: int | None = None,
) -> list[str]:
"""
Build AIS decoder command for vessel tracking.
@@ -175,6 +177,8 @@ class CommandBuilder(ABC):
gain: Gain in dB (None for auto)
bias_t: Enable bias-T power (for active antennas)
tcp_port: TCP port for JSON output server
+ udp_host: Optional host to forward NMEA 0183 sentences via UDP
+ udp_port: UDP port for NMEA forwarding (required if udp_host set)
Returns:
Command as list of strings for subprocess
diff --git a/utils/sdr/hackrf.py b/utils/sdr/hackrf.py
index 9db3e74..1274ada 100644
--- a/utils/sdr/hackrf.py
+++ b/utils/sdr/hackrf.py
@@ -161,7 +161,9 @@ class HackRFCommandBuilder(CommandBuilder):
device: SDRDevice,
gain: float | None = None,
bias_t: bool = False,
- tcp_port: int = 10110
+ tcp_port: int = 10110,
+ udp_host: str | None = None,
+ udp_port: int | None = None,
) -> list[str]:
"""
Build AIS-catcher command for AIS vessel tracking with HackRF.
@@ -184,6 +186,9 @@ class HackRFCommandBuilder(CommandBuilder):
if bias_t:
cmd.extend(['-gr', 'biastee', '1'])
+ if udp_host and udp_port:
+ cmd.extend(['-u', udp_host, str(udp_port)])
+
return cmd
def build_iq_capture_command(
diff --git a/utils/sdr/limesdr.py b/utils/sdr/limesdr.py
index 5c6c1b8..7b904a6 100644
--- a/utils/sdr/limesdr.py
+++ b/utils/sdr/limesdr.py
@@ -140,7 +140,9 @@ class LimeSDRCommandBuilder(CommandBuilder):
device: SDRDevice,
gain: float | None = None,
bias_t: bool = False,
- tcp_port: int = 10110
+ tcp_port: int = 10110,
+ udp_host: str | None = None,
+ udp_port: int | None = None,
) -> list[str]:
"""
Build AIS-catcher command for AIS vessel tracking with LimeSDR.
@@ -161,6 +163,9 @@ class LimeSDRCommandBuilder(CommandBuilder):
if gain is not None and gain > 0:
cmd.extend(['-gr', 'tuner', str(int(gain))])
+ if udp_host and udp_port:
+ cmd.extend(['-u', udp_host, str(udp_port)])
+
return cmd
def build_iq_capture_command(
diff --git a/utils/sdr/rtlsdr.py b/utils/sdr/rtlsdr.py
index 80b50e5..9437338 100644
--- a/utils/sdr/rtlsdr.py
+++ b/utils/sdr/rtlsdr.py
@@ -281,7 +281,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
device: SDRDevice,
gain: float | None = None,
bias_t: bool = False,
- tcp_port: int = 10110
+ tcp_port: int = 10110,
+ udp_host: str | None = None,
+ udp_port: int | None = None,
) -> list[str]:
"""
Build AIS-catcher command for AIS vessel tracking.
@@ -308,6 +310,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
if bias_t:
cmd.extend(['-gr', 'BIASTEE', 'on'])
+ if udp_host and udp_port:
+ cmd.extend(['-u', udp_host, str(udp_port)])
+
return cmd
def build_iq_capture_command(
diff --git a/utils/sdr/sdrplay.py b/utils/sdr/sdrplay.py
index 1b2c335..6b7293d 100644
--- a/utils/sdr/sdrplay.py
+++ b/utils/sdr/sdrplay.py
@@ -139,7 +139,9 @@ class SDRPlayCommandBuilder(CommandBuilder):
device: SDRDevice,
gain: float | None = None,
bias_t: bool = False,
- tcp_port: int = 10110
+ tcp_port: int = 10110,
+ udp_host: str | None = None,
+ udp_port: int | None = None,
) -> list[str]:
"""
Build AIS-catcher command for AIS vessel tracking with SDRPlay.
@@ -162,6 +164,9 @@ class SDRPlayCommandBuilder(CommandBuilder):
if bias_t:
cmd.extend(['-gr', 'biastee', '1'])
+ if udp_host and udp_port:
+ cmd.extend(['-u', udp_host, str(udp_port)])
+
return cmd
def build_iq_capture_command(