diff --git a/.env.example b/.env.example index 59dc8f02..2c910898 100644 --- a/.env.example +++ b/.env.example @@ -4,15 +4,17 @@ # Latitude in decimal degrees RECEIVER_LAT=-34.9192 -# Longitude in decimal degrees +# Longitude in decimal degrees (note: LON not LONG) RECEIVER_LON=138.6027 # Altitude in meters RECEIVER_ALT=110 # adsb.lol Integration -# Enable fetching aircraft from adsb.lol public network +# Enable fallback to adsb.lol when local feed is unavailable +# The system will prefer local data and only use adsb.lol if local feed fails ADSBLOL_ENABLED=true # Radius in nautical miles for adsb.lol queries +# Aircraft within this radius of your receiver location will be fetched ADSBLOL_RADIUS=40 diff --git a/Dockerfile.tar1090 b/Dockerfile.tar1090 new file mode 100644 index 00000000..925069cd --- /dev/null +++ b/Dockerfile.tar1090 @@ -0,0 +1,19 @@ +FROM ghcr.io/sdr-enthusiasts/docker-tar1090:latest + +USER root + +RUN apt-get update && apt-get install -y \ + curl \ + && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +COPY proxy/server.js /opt/proxy/server.js + +COPY docker/lighttpd-proxy.conf /etc/lighttpd/conf-available/90-proxy.conf +RUN lighttpd-enable-mod proxy + +COPY docker/entrypoint.sh /opt/entrypoint.sh +RUN chmod +x /opt/entrypoint.sh + +ENTRYPOINT ["/opt/entrypoint.sh"] diff --git a/docker-compose.yml b/docker-compose.yml index 63c9ca7e..d6f41ddd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,9 @@ services: - /var/log:size=32M tar1090: - image: ghcr.io/sdr-enthusiasts/docker-tar1090:latest + build: + context: . + dockerfile: Dockerfile.tar1090 container_name: tar1090 hostname: tar1090 restart: unless-stopped @@ -38,6 +40,12 @@ services: - LONG=${RECEIVER_LON:-0} - TAR1090_DEFAULTCENTERLAT=${RECEIVER_LAT:-0} - TAR1090_DEFAULTCENTERLON=${RECEIVER_LON:-0} + - READSB_URL=http://127.0.0.1:80/data/aircraft.json + - ADSBLOL_ENABLED=${ADSBLOL_ENABLED:-false} + - RECEIVER_LAT=${RECEIVER_LAT:-0} + - RECEIVER_LON=${RECEIVER_LON:-0} + - ADSBLOL_RADIUS=${ADSBLOL_RADIUS:-40} + - PROXY_PORT=3000 volumes: readsb-autogain: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 00000000..9785aca1 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +echo "Starting aircraft data proxy service..." +node /opt/proxy/server.js & +PROXY_PID=$! + +echo "Waiting for proxy to be ready..." +sleep 2 + +cleanup() { + echo "Shutting down..." + kill $PROXY_PID 2>/dev/null || true + exit 0 +} + +trap cleanup SIGTERM SIGINT + +echo "Starting tar1090..." +exec /init "$@" diff --git a/docker/lighttpd-proxy.conf b/docker/lighttpd-proxy.conf new file mode 100644 index 00000000..bb87ec99 --- /dev/null +++ b/docker/lighttpd-proxy.conf @@ -0,0 +1,7 @@ +server.modules += ( "mod_proxy" ) + +$HTTP["url"] =~ "^/data/aircraft\.json" { + proxy.server = ( "" => ( + ( "host" => "127.0.0.1", "port" => 3000 ) + )) +} diff --git a/proxy/Dockerfile b/proxy/Dockerfile new file mode 100644 index 00000000..be3c2d5c --- /dev/null +++ b/proxy/Dockerfile @@ -0,0 +1,9 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY server.js . + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/proxy/server.js b/proxy/server.js new file mode 100644 index 00000000..4a30ca42 --- /dev/null +++ b/proxy/server.js @@ -0,0 +1,144 @@ +const http = require('http'); +const https = require('https'); + +const READSB_URL = process.env.READSB_URL || 'http://127.0.0.1:80/data/aircraft.json'; +const ADSBLOL_ENABLED = process.env.ADSBLOL_ENABLED === 'true'; +const RECEIVER_LAT = parseFloat(process.env.RECEIVER_LAT || '0'); +const RECEIVER_LON = parseFloat(process.env.RECEIVER_LON || '0'); +const ADSBLOL_RADIUS = parseInt(process.env.ADSBLOL_RADIUS || '40'); +const PORT = parseInt(process.env.PROXY_PORT || '3000'); + +const ADSBLOL_API = `https://api.adsb.lol/v2/lat/${RECEIVER_LAT}/lon/${RECEIVER_LON}/dist/${ADSBLOL_RADIUS}`; + +function fetchUrl(url) { + return new Promise((resolve, reject) => { + const client = url.startsWith('https') ? https : http; + const timeout = 5000; + + const req = client.get(url, { timeout }, (res) => { + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}`)); + return; + } + + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (e) { + reject(new Error('Invalid JSON')); + } + }); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + req.on('error', reject); + }); +} + +function convertAdsbLolToReadsb(adsbLolData) { + const aircraft = adsbLolData.ac || []; + + return { + now: Date.now() / 1000, + messages: 0, + aircraft: aircraft.map(ac => ({ + hex: ac.hex, + flight: ac.flight?.trim() || '', + alt_baro: ac.alt_baro === 'ground' ? 'ground' : ac.alt_baro, + alt_geom: ac.alt_geom, + gs: ac.gs, + track: ac.track, + baro_rate: ac.baro_rate, + squawk: ac.squawk, + emergency: ac.emergency, + category: ac.category, + lat: ac.lat, + lon: ac.lon, + nic: ac.nic, + rc: ac.rc, + seen_pos: ac.seen_pos, + version: ac.version, + nic_baro: ac.nic_baro, + nac_p: ac.nac_p, + nac_v: ac.nac_v, + sil: ac.sil, + sil_type: ac.sil_type, + gva: ac.gva, + sda: ac.sda, + mlat: ac.mlat || [], + tisb: ac.tisb || [], + messages: ac.messages || 0, + seen: ac.seen || 0, + rssi: ac.rssi + })) + }; +} + +async function getAircraftData() { + try { + console.log('Attempting to fetch from local readsb...'); + const localData = await fetchUrl(READSB_URL); + console.log(`✓ Local readsb: ${localData.aircraft?.length || 0} aircraft`); + return { data: localData, source: 'local' }; + } catch (error) { + console.log(`✗ Local readsb failed: ${error.message}`); + + if (!ADSBLOL_ENABLED) { + console.log('✗ adsb.lol fallback disabled'); + throw new Error('Local feed unavailable and fallback disabled'); + } + + try { + console.log('Attempting fallback to adsb.lol...'); + const adsbLolData = await fetchUrl(ADSBLOL_API); + const convertedData = convertAdsbLolToReadsb(adsbLolData); + console.log(`✓ adsb.lol fallback: ${convertedData.aircraft?.length || 0} aircraft`); + return { data: convertedData, source: 'adsb.lol' }; + } catch (fallbackError) { + console.log(`✗ adsb.lol fallback failed: ${fallbackError.message}`); + throw new Error('Both local and fallback feeds unavailable'); + } + } +} + +const server = http.createServer(async (req, res) => { + if (req.url === '/data/aircraft.json') { + try { + const { data, source } = await getAircraftData(); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'X-Data-Source': source + }); + res.end(JSON.stringify(data)); + } catch (error) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: error.message, + aircraft: [], + now: Date.now() / 1000 + })); + } + } else if (req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + } else { + res.writeHead(404); + res.end('Not Found'); + } +}); + +server.listen(PORT, () => { + console.log(`Aircraft data proxy listening on port ${PORT}`); + console.log(`Local feed: ${READSB_URL}`); + console.log(`adsb.lol fallback: ${ADSBLOL_ENABLED ? 'enabled' : 'disabled'}`); + if (ADSBLOL_ENABLED) { + console.log(`adsb.lol API: ${ADSBLOL_API}`); + } +});