228 lines
8.6 KiB
Python
228 lines
8.6 KiB
Python
import subprocess, json, time, threading
|
||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||
import pyModeS as pms
|
||
|
||
aircraft, cpr = {}, {}
|
||
|
||
HTML = """<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>ADS-B Радар</title>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@10/ol.css">
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { background: #1a1a2e; color: #e0e0e0; font-family: 'Courier New', monospace; height: 100vh; overflow: hidden; }
|
||
#map { width: 100vw; height: 100vh; }
|
||
#header {
|
||
position: absolute; top: 0; left: 0; right: 0; z-index: 1000;
|
||
background: rgba(26,26,46,0.92); border-bottom: 1px solid #2a2a5a;
|
||
padding: 8px 16px; display: flex; align-items: center; gap: 16px;
|
||
}
|
||
#header h1 { font-size: 16px; color: #7eb8f7; letter-spacing: 2px; }
|
||
#count { font-size: 13px; color: #aaa; }
|
||
#popup {
|
||
position: absolute; z-index: 2000;
|
||
background: rgba(16,16,36,0.97); border: 1px solid #3a3a7a;
|
||
border-radius: 8px; padding: 12px 16px; min-width: 200px;
|
||
box-shadow: 0 4px 24px rgba(0,0,0,0.6);
|
||
display: none; pointer-events: none;
|
||
}
|
||
#popup .icao { font-size: 18px; font-weight: bold; color: #7eb8f7; margin-bottom: 6px; }
|
||
#popup .row { font-size: 13px; color: #ccc; margin: 2px 0; }
|
||
#popup .label { color: #888; margin-right: 6px; }
|
||
.ol-attribution { display: none !important; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="header">
|
||
<h1>✈ ADS-B РАДАР</h1>
|
||
<span id="count">Самолётов: 0</span>
|
||
</div>
|
||
<div id="map"></div>
|
||
<div id="popup">
|
||
<div class="icao" id="p-icao"></div>
|
||
<div class="row"><span class="label">Позывной:</span><span id="p-flight">—</span></div>
|
||
<div class="row"><span class="label">Высота:</span><span id="p-alt">—</span></div>
|
||
<div class="row"><span class="label">Скорость:</span><span id="p-speed">—</span></div>
|
||
<div class="row"><span class="label">Курс:</span><span id="p-track">—</span></div>
|
||
<div class="row"><span class="label">Пакетов:</span><span id="p-msgs">—</span></div>
|
||
</div>
|
||
<script src="https://cdn.jsdelivr.net/npm/ol@10/dist/ol.js"></script>
|
||
<script>
|
||
const map = new ol.Map({
|
||
target: 'map',
|
||
layers: [
|
||
new ol.layer.Tile({
|
||
source: new ol.source.XYZ({
|
||
url: 'https://{a-c}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||
})
|
||
})
|
||
],
|
||
view: new ol.View({ center: ol.proj.fromLonLat([37.62, 55.75]), zoom: 8 }),
|
||
controls: []
|
||
});
|
||
|
||
const vectorSource = new ol.source.Vector();
|
||
map.addLayer(new ol.layer.Vector({ source: vectorSource }));
|
||
|
||
const features = {};
|
||
let selectedIcao = null;
|
||
let nowTs = Date.now() / 1000;
|
||
const popup = document.getElementById('popup');
|
||
|
||
function freshColor(seen) {
|
||
const age = nowTs - (seen || 0);
|
||
if (age < 15) return '#00ff88';
|
||
if (age < 45) return '#ffcc00';
|
||
return '#ff4444';
|
||
}
|
||
|
||
function makeStyle(track, selected, seen) {
|
||
const color = selected ? '#ffd700' : freshColor(seen);
|
||
return new ol.style.Style({ text: new ol.style.Text({
|
||
text: '✈', font: (selected ? 'bold 26px' : '22px') + ' sans-serif',
|
||
rotation: (track || 0) * Math.PI / 180,
|
||
fill: new ol.style.Fill({ color }),
|
||
stroke: new ol.style.Stroke({ color: '#1a1a2e', width: 2 })
|
||
})});
|
||
}
|
||
|
||
function showPopup(ac, pixel) {
|
||
selectedIcao = ac.icao;
|
||
document.getElementById('p-icao').textContent = ac.icao.toUpperCase();
|
||
document.getElementById('p-flight').textContent = ac.flight || '—';
|
||
document.getElementById('p-alt').textContent = ac.altitude != null ? ac.altitude + ' ft' : '—';
|
||
document.getElementById('p-speed').textContent = ac.speed != null ? ac.speed + ' кт' : '—';
|
||
document.getElementById('p-track').textContent = ac.track != null ? ac.track + '°' : '—';
|
||
document.getElementById('p-msgs').textContent = ac.msgs || '—';
|
||
let [x, y] = pixel;
|
||
if (x + 220 > map.getSize()[0]) x -= 230; else x += 20;
|
||
popup.style.left = x + 'px'; popup.style.top = (y - 10) + 'px';
|
||
popup.style.display = 'block';
|
||
if (features[ac.icao]) features[ac.icao].setStyle(makeStyle(ac.track, true, ac.seen));
|
||
}
|
||
|
||
function hidePopup() {
|
||
if (selectedIcao && features[selectedIcao]) {
|
||
const d = features[selectedIcao]._data;
|
||
features[selectedIcao].setStyle(makeStyle(d?.track, false, d?.seen));
|
||
}
|
||
selectedIcao = null;
|
||
popup.style.display = 'none';
|
||
}
|
||
|
||
map.on('click', e => {
|
||
const hit = map.forEachFeatureAtPixel(e.pixel, f => f, { hitTolerance: 12 });
|
||
hit?._data ? showPopup(hit._data, e.pixel) : hidePopup();
|
||
});
|
||
map.on('pointermove', e => {
|
||
map.getTargetElement().style.cursor =
|
||
map.hasFeatureAtPixel(e.pixel, { hitTolerance: 12 }) ? 'pointer' : '';
|
||
});
|
||
|
||
async function refresh() {
|
||
try {
|
||
const data = await (await fetch('/aircraft.json')).json();
|
||
nowTs = data.now;
|
||
const active = new Set();
|
||
for (const ac of data.aircraft) {
|
||
if (ac.lat == null || ac.lon == null) continue;
|
||
active.add(ac.icao);
|
||
const coord = ol.proj.fromLonLat([ac.lon, ac.lat]);
|
||
const sel = ac.icao === selectedIcao;
|
||
if (features[ac.icao]) {
|
||
features[ac.icao].getGeometry().setCoordinates(coord);
|
||
features[ac.icao].setStyle(makeStyle(ac.track, sel, ac.seen));
|
||
features[ac.icao]._data = ac;
|
||
} else {
|
||
const f = new ol.Feature({ geometry: new ol.geom.Point(coord) });
|
||
f.setStyle(makeStyle(ac.track, false, ac.seen)); f._data = ac;
|
||
vectorSource.addFeature(f); features[ac.icao] = f;
|
||
}
|
||
}
|
||
for (const icao of Object.keys(features)) {
|
||
if (!active.has(icao)) { vectorSource.removeFeature(features[icao]); delete features[icao]; }
|
||
}
|
||
document.getElementById('count').textContent = 'Самолётов: ' + active.size;
|
||
} catch(e) {}
|
||
}
|
||
refresh(); setInterval(refresh, 3000);
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def decode():
|
||
proc = subprocess.Popen(["rtl_adsb.exe"], stdout=subprocess.PIPE, text=True)
|
||
for line in proc.stdout:
|
||
line = line.strip()
|
||
if not (line.startswith('*') and line.endswith(';')):
|
||
continue
|
||
msg = line[1:-1]
|
||
if len(msg) not in (14, 28):
|
||
continue
|
||
try:
|
||
if pms.df(msg) != 17:
|
||
continue
|
||
icao = pms.icao(msg)
|
||
tc = pms.typecode(msg)
|
||
if icao not in aircraft:
|
||
aircraft[icao] = {'icao': icao}
|
||
aircraft[icao]['seen'] = time.time()
|
||
aircraft[icao]['msgs'] = aircraft[icao].get('msgs', 0) + 1
|
||
if 1 <= tc <= 4:
|
||
cs = pms.adsb.callsign(msg).strip()
|
||
if cs: aircraft[icao]['flight'] = cs
|
||
elif 9 <= tc <= 18:
|
||
alt = pms.adsb.altitude(msg)
|
||
if alt: aircraft[icao]['altitude'] = alt
|
||
oe = pms.adsb.oe_flag(msg)
|
||
cpr.setdefault(icao, {})[oe] = (msg, time.time())
|
||
try:
|
||
pos = pms.adsb.position_with_ref(msg, 55.75, 37.62)
|
||
if pos: aircraft[icao]['lat'], aircraft[icao]['lon'] = pos
|
||
except: pass
|
||
if 0 in cpr[icao] and 1 in cpr[icao]:
|
||
m0,t0 = cpr[icao][0]; m1,t1 = cpr[icao][1]
|
||
if abs(t0-t1) < 10:
|
||
try:
|
||
pos = pms.adsb.position(m0, m1, t0, t1)
|
||
if pos: aircraft[icao]['lat'], aircraft[icao]['lon'] = pos
|
||
except: pass
|
||
elif tc == 19:
|
||
v = pms.adsb.velocity(msg)
|
||
if v: aircraft[icao]['speed'], aircraft[icao]['track'] = v[0], v[1]
|
||
except: pass
|
||
|
||
|
||
class H(BaseHTTPRequestHandler):
|
||
def do_GET(self):
|
||
if self.path == '/aircraft.json':
|
||
now = time.time()
|
||
active = [v for v in aircraft.values() if now - v.get('seen',0) < 120]
|
||
body = json.dumps({'now': now, 'aircraft': active}).encode()
|
||
self.send_response(200)
|
||
self.send_header('Content-Type', 'application/json')
|
||
self.send_header('Access-Control-Allow-Origin', '*')
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
elif self.path == '/':
|
||
body = HTML.encode('utf-8')
|
||
self.send_response(200)
|
||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
else:
|
||
self.send_response(404)
|
||
self.end_headers()
|
||
def log_message(self, *a): pass
|
||
|
||
|
||
threading.Thread(target=decode, daemon=True).start()
|
||
print("Запущен!")
|
||
print(" Карта: http://localhost:8080/")
|
||
print(" API: http://localhost:8080/aircraft.json")
|
||
HTTPServer(('0.0.0.0', 8080), H).serve_forever()
|