From 3b3ef7f5096f846b651baf88520430b79d4209ef Mon Sep 17 00:00:00 2001 From: AlienMajik <118037572+AlienMajik@users.noreply.github.com> Date: Mon, 2 Jun 2025 21:23:52 -0700 Subject: [PATCH] Update skyhigh.py Advanced aircraft/ADS-B data plugin with robust type-detection, embedded SVG icons, filtering, export, and caching. V.1.1.1 --- skyhigh.py | 1130 +++++++++++++++++++++++----------------------------- 1 file changed, 499 insertions(+), 631 deletions(-) diff --git a/skyhigh.py b/skyhigh.py index 1de47b4..99165d6 100644 --- a/skyhigh.py +++ b/skyhigh.py @@ -2,481 +2,17 @@ import logging import os import json import time +import threading from datetime import datetime, timedelta +from typing import Dict, Any, Optional, List import requests -from threading import Lock -from flask import render_template_string, request, jsonify +from flask import render_template_string, request, Response import pwnagotchi.plugins as plugins import pwnagotchi.ui.fonts as fonts from pwnagotchi.ui.components import LabeledValue from pwnagotchi.ui.view import BLACK -class SkyHigh(plugins.Plugin): - __author__ = 'AlienMajik' - __version__ = '1.0.9' - __license__ = 'GPL3' - __description__ = 'A plugin that fetches aircraft data from the OpenSky API using GPS coordinates, logs it, prunes old entries, and provides a webhook with aircraft type, flight path visualization, and enhanced iconography.' - - def __init__(self): - self.options = { - 'timer': 60, # Time interval in seconds for fetching new aircraft data - 'aircraft_file': '/root/handshakes/skyhigh_aircraft.json', # File to store detected aircraft information - 'adsb_x_coord': 160, - 'adsb_y_coord': 80, - 'latitude': -66.273334, # Default latitude (Flying Saucer) - 'longitude': 100.984166, # Default longitude (Flying Saucer) - 'radius': 50, # Radius in miles to fetch aircraft data - 'prune_minutes': 5, # Default pruning interval in minutes - 'opensky_username': None, # Optional OpenSky username for authenticated requests - 'opensky_password': None # Optional OpenSky password for authenticated requests - } - self.last_fetch_time = 0 - self.data = {} - self.data_lock = Lock() - self.last_gps = {'latitude': None, 'longitude': None} - self.credentials_valid = True - self.flight_path_access = True - self.metadata_access = True - self.historical_positions = {} - - def on_loaded(self): - logging.info("[SkyHigh] Plugin loaded.") - if not os.path.exists(os.path.dirname(self.options['aircraft_file'])): - os.makedirs(os.path.dirname(self.options['aircraft_file'])) - if not os.path.exists(self.options['aircraft_file']): - with open(self.options['aircraft_file'], 'w') as f: - json.dump({}, f) - with open(self.options['aircraft_file'], 'r') as f: - self.data = json.load(f) - - def on_ui_setup(self, ui): - ui.add_element('SkyHigh', LabeledValue(color=BLACK, - label='SkyHigh', - value=" ", - position=(self.options["adsb_x_coord"], - self.options["adsb_y_coord"]), - label_font=fonts.Small, - text_font=fonts.Small)) - - def on_ui_update(self, ui): - current_time = time.time() - if current_time - self.last_fetch_time >= self.options['timer']: - ui.set('SkyHigh', "Updating...") - self.last_fetch_time = current_time - result = self.fetch_aircraft_data() - ui.set('SkyHigh', result) - else: - with self.data_lock: - aircraft_count = len(self.data) - minutes_ago = int((current_time - self.last_fetch_time) / 60) - ui.set('SkyHigh', f"{aircraft_count} aircrafts (Last: {minutes_ago}m)") - - def fetch_aircraft_data(self): - logging.debug("[SkyHigh] Fetching aircraft data from API...") - try: - lat, lon, radius = self.options['latitude'], self.options['longitude'], self.options['radius'] - lat_min, lat_max = lat - (radius / 69), lat + (radius / 69) - lon_min, lon_max = lon - (radius / 69), lon + (radius / 69) - url = f"https://opensky-network.org/api/states/all?lamin={lat_min}&lomin={lon_min}&lamax={lat_max}&lomax={lon_max}" - - headers = {} - if self.options['opensky_username'] and self.options['opensky_password'] and self.credentials_valid: - import base64 - auth_str = f"{self.options['opensky_username']}:{self.options['opensky_password']}" - auth_encoded = base64.b64encode(auth_str.encode()).decode() - headers['Authorization'] = f'Basic {auth_encoded}' - logging.debug(f"[SkyHigh] Attempting authenticated request to {url}") - - response = requests.get(url, headers=headers if headers else None, timeout=10) - - if response.status_code == 200: - aircrafts = self.parse_api_response(response.json()) - self.prune_old_data() - with self.data_lock: - with open(self.options['aircraft_file'], 'w') as f: - json.dump(self.data, f) - logging.debug("[SkyHigh] Fetch completed successfully.") - return f"{len(aircrafts)} aircrafts detected" - elif response.status_code == 401: - logging.warning("[SkyHigh] Authentication failed for /states/all. Falling back to anonymous access.") - self.credentials_valid = False - response = requests.get(url, timeout=10) - if response.status_code == 200: - aircrafts = self.parse_api_response(response.json()) - self.prune_old_data() - with self.data_lock: - with open(self.options['aircraft_file'], 'w') as f: - json.dump(self.data, f) - logging.debug("[SkyHigh] Fetch completed successfully (anonymous).") - return f"{len(aircrafts)} aircrafts detected" - else: - logging.error("[SkyHigh] Anonymous fetch failed with status code %d", response.status_code) - return "Fetch error" - else: - logging.error("[SkyHigh] API returned status code %d", response.status_code) - return "Fetch error" - except requests.exceptions.RequestException as e: - logging.error("[SkyHigh] Error fetching data from API: %s", e) - return "Fetch failed" - - def fetch_aircraft_metadata(self, icao24): - """Fetch metadata for a specific aircraft using its ICAO24 code.""" - try: - if not self.metadata_access: - logging.warning(f"[SkyHigh] Metadata access unavailable for {icao24}. Attempting anonymous access.") - response = requests.get(f"https://opensky-network.org/api/metadata/aircraft/icao/{icao24}", timeout=5) - if response.status_code == 200: - data = response.json() - manufacturer = data.get('manufacturerName', '') or '' - model = data.get('model', 'Unknown') or 'Unknown' - registration = data.get('registration', 'Unknown') or 'Unknown' - db_flags = data.get('special_flags', []) or [] - typecode = data.get('typecode', '') or '' - - # Safely handle None values before calling lower() - manufacturer_lower = manufacturer.lower() if isinstance(manufacturer, str) else '' - model_lower = model.lower() if isinstance(model, str) else '' - typecode_lower = typecode.lower() if isinstance(typecode, str) else '' - db_flags_lower = ', '.join([flag.lower() for flag in db_flags if isinstance(flag, str)]) - - # Categorization based on manufacturer, model, typecode, and DB flags - is_helicopter = 'helicopter' in model_lower - is_commercial_jet = ( - any(jet in manufacturer_lower for jet in ['airbus', 'boeing', 'embraer', 'bombardier']) or - any(model_lower.startswith(prefix) for prefix in ['a', 'b', 'e', 'crj', 'erj']) or - any(typecode_lower.startswith(prefix) for prefix in ['a', 'b', 'e', 'crj', 'erj']) - ) - is_small_plane = ( - any(small in manufacturer_lower for small in ['cessna', 'piper', 'beechcraft', 'cirrus']) or - any(model_lower.startswith(prefix) for prefix in ['c', 'p', 'be', 'sr']) or - any(typecode_lower.startswith(prefix) for prefix in ['c', 'p', 'be', 'sr']) - ) - is_drone = 'drone' in model_lower or 'uav' in model_lower - is_glider = 'glider' in model_lower - is_military = 'military' in db_flags_lower - - logging.debug(f"[SkyHigh] Metadata for {icao24}: manufacturer={manufacturer}, model={model}, typecode={typecode}, db_flags={db_flags_lower}, is_helicopter={is_helicopter}, is_commercial_jet={is_commercial_jet}, is_small_plane={is_small_plane}, is_drone={is_drone}, is_glider={is_glider}, is_military={is_military}") - return { - 'model': model, - 'registration': registration, - 'db_flags': db_flags_lower, - 'is_helicopter': is_helicopter, - 'is_commercial_jet': is_commercial_jet, - 'is_small_plane': is_small_plane, - 'is_drone': is_drone, - 'is_glider': is_glider, - 'is_military': is_military - } - else: - logging.error(f"[SkyHigh] Anonymous metadata fetch failed for {icao24}: {response.status_code}") - return None - - url = f"https://opensky-network.org/api/metadata/aircraft/icao/{icao24}" - auth = None - if self.options['opensky_username'] and self.options['opensky_password'] and self.credentials_valid: - auth = (self.options['opensky_username'], self.options['opensky_password']) - logging.debug(f"[SkyHigh] Using OpenSky credentials: username={self.options['opensky_username']}") - else: - logging.warning("[SkyHigh] OpenSky credentials not provided or invalid. Attempting anonymous metadata fetch.") - - response = requests.get(url, auth=auth, timeout=5) - if response.status_code == 200: - data = response.json() - manufacturer = data.get('manufacturerName', '') or '' - model = data.get('model', 'Unknown') or 'Unknown' - registration = data.get('registration', 'Unknown') or 'Unknown' - db_flags = data.get('special_flags', []) or [] - typecode = data.get('typecode', '') or '' - - # Safely handle None values before calling lower() - manufacturer_lower = manufacturer.lower() if isinstance(manufacturer, str) else '' - model_lower = model.lower() if isinstance(model, str) else '' - typecode_lower = typecode.lower() if isinstance(typecode, str) else '' - db_flags_lower = ', '.join([flag.lower() for flag in db_flags if isinstance(flag, str)]) - - # Categorization based on manufacturer, model, typecode, and DB flags - is_helicopter = 'helicopter' in model_lower - is_commercial_jet = ( - any(jet in manufacturer_lower for jet in ['airbus', 'boeing', 'embraer', 'bombardier']) or - any(model_lower.startswith(prefix) for prefix in ['a', 'b', 'e', 'crj', 'erj']) or - any(typecode_lower.startswith(prefix) for prefix in ['a', 'b', 'e', 'crj', 'erj']) - ) - is_small_plane = ( - any(small in manufacturer_lower for small in ['cessna', 'piper', 'beechcraft', 'cirrus']) or - any(model_lower.startswith(prefix) for prefix in ['c', 'p', 'be', 'sr']) or - any(typecode_lower.startswith(prefix) for prefix in ['c', 'p', 'be', 'sr']) - ) - is_drone = 'drone' in model_lower or 'uav' in model_lower - is_glider = 'glider' in model_lower - is_military = 'military' in db_flags_lower - - logging.debug(f"[SkyHigh] Metadata for {icao24}: manufacturer={manufacturer}, model={model}, typecode={typecode}, db_flags={db_flags_lower}, is_helicopter={is_helicopter}, is_commercial_jet={is_commercial_jet}, is_small_plane={is_small_plane}, is_drone={is_drone}, is_glider={is_glider}, is_military={is_military}") - return { - 'model': model, - 'registration': registration, - 'db_flags': db_flags_lower, - 'is_helicopter': is_helicopter, - 'is_commercial_jet': is_commercial_jet, - 'is_small_plane': is_small_plane, - 'is_drone': is_drone, - 'is_glider': is_glider, - 'is_military': is_military - } - elif response.status_code == 401: - self.credentials_valid = False - self.metadata_access = False - logging.warning("[SkyHigh] Invalid OpenSky credentials or insufficient permissions for metadata fetch. Falling back to anonymous access.") - response = requests.get(url, timeout=5) - if response.status_code == 200: - data = response.json() - manufacturer = data.get('manufacturerName', '') or '' - model = data.get('model', 'Unknown') or 'Unknown' - registration = data.get('registration', 'Unknown') or 'Unknown' - db_flags = data.get('special_flags', []) or [] - typecode = data.get('typecode', '') or '' - - # Safely handle None values before calling lower() - manufacturer_lower = manufacturer.lower() if isinstance(manufacturer, str) else '' - model_lower = model.lower() if isinstance(model, str) else '' - typecode_lower = typecode.lower() if isinstance(typecode, str) else '' - db_flags_lower = ', '.join([flag.lower() for flag in db_flags if isinstance(flag, str)]) - - # Categorization based on manufacturer, model, typecode, and DB flags - is_helicopter = 'helicopter' in model_lower - is_commercial_jet = ( - any(jet in manufacturer_lower for jet in ['airbus', 'boeing', 'embraer', 'bombardier']) or - any(model_lower.startswith(prefix) for prefix in ['a', 'b', 'e', 'crj', 'erj']) or - any(typecode_lower.startswith(prefix) for prefix in ['a', 'b', 'e', 'crj', 'erj']) - ) - is_small_plane = ( - any(small in manufacturer_lower for small in ['cessna', 'piper', 'beechcraft', 'cirrus']) or - any(model_lower.startswith(prefix) for prefix in ['c', 'p', 'be', 'sr']) or - any(typecode_lower.startswith(prefix) for prefix in ['c', 'p', 'be', 'sr']) - ) - is_drone = 'drone' in model_lower or 'uav' in model_lower - is_glider = 'glider' in model_lower - is_military = 'military' in db_flags_lower - - logging.debug(f"[SkyHigh] Metadata for {icao24}: manufacturer={manufacturer}, model={model}, typecode={typecode}, db_flags={db_flags_lower}, is_helicopter={is_helicopter}, is_commercial_jet={is_commercial_jet}, is_small_plane={is_small_plane}, is_drone={is_drone}, is_glider={is_glider}, is_military={is_military}") - return { - 'model': model, - 'registration': registration, - 'db_flags': db_flags_lower, - 'is_helicopter': is_helicopter, - 'is_commercial_jet': is_commercial_jet, - 'is_small_plane': is_small_plane, - 'is_drone': is_drone, - 'is_glider': is_glider, - 'is_military': is_military - } - else: - logging.error(f"[SkyHigh] Anonymous metadata fetch failed for {icao24}: {response.status_code}") - return None - else: - logging.warning(f"[SkyHigh] Failed to fetch metadata for {icao24}: {response.status_code}") - return None - except requests.exceptions.RequestException as e: - logging.error(f"[SkyHigh] Error fetching metadata for {icao24}: {e}") - return None - - def fetch_flight_path(self, icao24): - """Fetch flight path data for a specific aircraft.""" - try: - if not self.credentials_valid: - return {'error': 'OpenSky credentials invalid or insufficient permissions. Falling back to historical positions.'} - - if not self.flight_path_access: - return {'error': 'Flight path access unavailable. Your OpenSky account lacks permissions for flight tracks. Using historical positions instead.'} - - time_ranges = [3600, 7200, 14400] - headers = {} - if self.options['opensky_username'] and self.options['opensky_password']: - import base64 - auth_str = f"{self.options['opensky_username']}:{self.options['opensky_password']}" - auth_encoded = base64.b64encode(auth_str.encode()).decode() - headers['Authorization'] = f'Basic {auth_encoded}' - logging.debug(f"[SkyHigh] Using OpenSky credentials: username={self.options['opensky_username']}") - else: - return {'error': 'Authentication credentials not provided'} - - for time_range in time_ranges: - current_time = int(time.time()) - url = f"https://opensky-network.org/api/tracks/all?icao24={icao24}&time={current_time - time_range}" - response = requests.get(url, headers=headers, timeout=5) - - if response.status_code == 200: - data = response.json() - if 'path' in data and data['path']: - return data - elif response.status_code == 404: - logging.debug(f"[SkyHigh] No flight path data for {icao24} in the last {time_range} seconds") - continue - elif response.status_code == 401: - self.flight_path_access = False - return {'error': 'Flight path access unavailable. Your OpenSky account lacks permissions for flight tracks. Using historical positions instead.'} - else: - logging.warning(f"[SkyHigh] Failed to fetch flight path for {icao24}: {response.status_code}") - continue - - return {'error': 'No recent flight path data available'} - except requests.exceptions.RequestException as e: - logging.error(f"[SkyHigh] Error fetching flight path for {icao24}: {e}") - return {'error': 'Network error'} - - def get_historical_path(self, icao24): - """Retrieve historical positions for an aircraft to approximate its flight path.""" - with self.data_lock: - if icao24 in self.historical_positions and len(self.historical_positions[icao24]) > 1: - path = [] - for pos in self.historical_positions[icao24]: - path.append([int(pos['timestamp']), pos['latitude'], pos['longitude'], pos['baro_altitude'], pos['true_track'], False]) - return {'path': path} - return {'error': 'Insufficient historical data to plot flight path'} - - def parse_api_response(self, api_data): - aircrafts = [] - if 'states' in api_data and api_data['states'] is not None: - for state in api_data['states']: - icao24 = state[0] - callsign = state[1].strip() if state[1] else "Unknown" - latitude = state[6] if state[6] is not None else 'N/A' - longitude = state[5] if state[5] is not None else 'N/A' - baro_altitude = state[7] if state[7] is not None else 'N/A' - geo_altitude = state[13] if state[13] is not None else 'N/A' - velocity = state[9] if state[9] is not None else 'N/A' - true_track = state[10] if state[10] is not None else 'N/A' - vertical_rate = state[11] if state[11] is not None else 'N/A' - on_ground = state[8] - squawk = state[14] if state[14] is not None else 'N/A' - timestamp = state[4] if state[4] is not None else int(time.time()) - - with self.data_lock: - if latitude != 'N/A' and longitude != 'N/A': - position = { - 'timestamp': timestamp, - 'latitude': latitude, - 'longitude': longitude, - 'baro_altitude': baro_altitude, - 'true_track': true_track - } - if icao24 not in self.historical_positions: - self.historical_positions[icao24] = [] - self.historical_positions[icao24].append(position) - self.historical_positions[icao24] = self.historical_positions[icao24][-10:] - - if icao24 not in self.data or 'model' not in self.data[icao24]: - metadata = self.fetch_aircraft_metadata(icao24) - if metadata: - if 'error' in metadata: - self.data[icao24] = { - 'model': 'Unknown', - 'registration': 'Unknown', - 'db_flags': '', - 'is_helicopter': False, - 'is_commercial_jet': False, - 'is_small_plane': False, - 'is_drone': False, - 'is_glider': False, - 'is_military': False - } - else: - self.data[icao24] = metadata - else: - self.data[icao24] = { - 'model': 'Unknown', - 'registration': 'Unknown', - 'db_flags': '', - 'is_helicopter': False, - 'is_commercial_jet': False, - 'is_small_plane': False, - 'is_drone': False, - 'is_glider': False, - 'is_military': False - } - time.sleep(0.1) - self.data[icao24].update({ - 'callsign': callsign, - 'latitude': latitude, - 'longitude': longitude, - 'baro_altitude': baro_altitude, - 'geo_altitude': geo_altitude, - 'velocity': velocity, - 'true_track': true_track, - 'vertical_rate': vertical_rate, - 'on_ground': on_ground, - 'squawk': squawk, - 'last_seen': datetime.now().strftime('%Y-%m-%d %H:%M:%S') - }) - aircrafts.append({ - 'icao24': icao24, - 'callsign': callsign, - 'latitude': latitude, - 'longitude': longitude, - 'baro_altitude': baro_altitude, - 'geo_altitude': geo_altitude, - 'velocity': velocity, - 'true_track': true_track, - 'vertical_rate': vertical_rate, - 'on_ground': on_ground, - 'squawk': squawk, - 'is_helicopter': self.data[icao24].get('is_helicopter', False), - 'is_commercial_jet': self.data[icao24].get('is_commercial_jet', False), - 'is_small_plane': self.data[icao24].get('is_small_plane', False), - 'is_drone': self.data[icao24].get('is_drone', False), - 'is_glider': self.data[icao24].get('is_glider', False), - 'is_military': self.data[icao24].get('is_military', False), - 'model': self.data[icao24].get('model', 'Unknown'), - 'registration': self.data[icao24].get('registration', 'Unknown'), - 'db_flags': self.data[icao24].get('db_flags', '') - }) - return aircrafts - - def prune_old_data(self): - """Remove aircraft entries older than the configured prune_minutes interval.""" - prune_minutes = self.options.get('prune_minutes', 0) - if prune_minutes <= 0: - return - now = datetime.now() - cutoff = now - timedelta(minutes=prune_minutes) - keys_to_remove = [] - with self.data_lock: - for icao24, info in self.data.items(): - last_seen = datetime.strptime(info['last_seen'], '%Y-%m-%d %H:%M:%S') - if last_seen < cutoff: - keys_to_remove.append(icao24) - for key in keys_to_remove: - del self.data[key] - if key in self.historical_positions: - del self.historical_positions[key] - logging.debug(f"[SkyHigh] Pruned {len(keys_to_remove)} old aircraft entries.") - - def on_webhook(self, path, request): - if request.method == 'GET': - if path == '/' or not path: - try: - with open(self.options['aircraft_file'], 'r') as f: - aircraft_dict = json.load(f) - aircrafts = list(aircraft_dict.values()) - center = [self.last_gps['latitude'] or self.options['latitude'], - self.last_gps['longitude'] or self.options['longitude']] - return render_template_string(HTML_TEMPLATE, aircrafts=aircrafts, center=center) - except (FileNotFoundError, json.JSONDecodeError): - aircrafts = [] - center = [self.options['latitude'], self.options['longitude']] - return render_template_string(HTML_TEMPLATE, aircrafts=aircrafts, center=center) - elif path.startswith('flightpath/'): - icao24 = path.split('/')[-1] - flight_path_data = self.fetch_flight_path(icao24) - if 'error' in flight_path_data and 'Using historical positions' in flight_path_data['error']: - flight_path_data = self.get_historical_path(icao24) - return jsonify(flight_path_data) - return "Not found", 404 - - def on_unload(self, ui): - with ui._lock: - ui.remove_element('SkyHigh') - HTML_TEMPLATE = ''' @@ -486,50 +22,66 @@ HTML_TEMPLATE = ''' SkyHigh - Aircraft Map -

Aircraft Detected by SkyHigh

- +

SkyHigh: Aircraft Map

+
+
+ Filter: + + + Altitude: + - + + Export CSV + Export KML + + +
+
- - - - - - - - - - - + + + + + + - {% for aircraft in aircrafts %} + {% for a in aircrafts %} - - - - - - - - - - - - - - + + + + + + + + + {% endfor %} @@ -544,144 +96,460 @@ HTML_TEMPLATE = ''' attribution: '© OpenStreetMap contributors' }).addTo(map); - var helicopterIcon = L.icon({ - iconUrl: 'data:image/svg+xml;base64,' + btoa(` - - - - `), - iconSize: [30, 30], - iconAnchor: [15, 15], - popupAnchor: [0, -15] - }); + var icons = { + 'mil': L.icon({ + iconUrl: 'data:image/svg+xml;base64,' + btoa(` + + + + `), + iconSize: [30, 30], + iconAnchor: [15, 15], + popupAnchor: [0, -15] + }), + 'heli': L.icon({ + iconUrl: 'data:image/svg+xml;base64,' + btoa(` + + + + `), + iconSize: [30, 30], + iconAnchor: [15, 15], + popupAnchor: [0, -15] + }), + 'jet': L.icon({ + iconUrl: 'data:image/svg+xml;base64,' + btoa(` + + + + `), + iconSize: [30, 30], + iconAnchor: [15, 15], + popupAnchor: [0, -15] + }), + 'ga': L.icon({ + iconUrl: 'data:image/svg+xml;base64,' + btoa(` + + + + `), + iconSize: [30, 30], + iconAnchor: [15, 15], + popupAnchor: [0, -15] + }), + 'drone': L.icon({ + iconUrl: 'data:image/svg+xml;base64,' + btoa(` + + + + `), + iconSize: [30, 30], + iconAnchor: [15, 15], + popupAnchor: [0, -15] + }), + 'glider': L.icon({ + iconUrl: 'data:image/svg+xml;base64,' + btoa(` + + + + `), + iconSize: [30, 30], + iconAnchor: [15, 15], + popupAnchor: [0, -15] + }), + 'default': L.icon({ + iconUrl: 'data:image/svg+xml;base64,' + btoa(` + + + + `), + iconSize: [30, 30], + iconAnchor: [15, 15], + popupAnchor: [0, -15] + }) + }; - var commercialJetIcon = L.icon({ - iconUrl: 'data:image/svg+xml;base64,' + btoa(` - - - - `), - iconSize: [30, 30], - iconAnchor: [15, 15], - popupAnchor: [0, -15] - }); - - var smallPlaneIcon = L.icon({ - iconUrl: 'data:image/svg+xml;base64,' + btoa(` - - - - `), - iconSize: [30, 30], - iconAnchor: [15, 15], - popupAnchor: [0, -15] - }); - - var droneIcon = L.icon({ - iconUrl: 'data:image/svg+xml;base64,' + btoa(` - - - - `), - iconSize: [30, 30], - iconAnchor: [15, 15], - popupAnchor: [0, -15] - }); - - var gliderIcon = L.icon({ - iconUrl: 'data:image/svg+xml;base64,' + btoa(` - - - - `), - iconSize: [30, 30], - iconAnchor: [15, 15], - popupAnchor: [0, -15] - }); - - var militaryIcon = L.icon({ - iconUrl: 'data:image/svg+xml;base64,' + btoa(` - - - - `), - iconSize: [30, 30], - iconAnchor: [15, 15], - popupAnchor: [0, -15] - }); - - var flightPaths = {}; - - function fetchFlightPath(icao24, marker) { - fetch(`/plugins/skyhigh/flightpath/${icao24}`) - .then(response => { - if (!response.ok) throw new Error('Failed to fetch flight path: ' + response.status); - return response.json(); - }) - .then(data => { - if (data.error) { - alert(data.error); - return; - } - if (data.path && data.path.length > 0) { - var pathCoords = data.path.map(point => [point[1], point[2]]); - if (flightPaths[icao24]) { - map.removeLayer(flightPaths[icao24]); - delete flightPaths[icao24]; - } else { - flightPaths[icao24] = L.polyline(pathCoords, { color: 'blue', weight: 2 }).addTo(map); - map.fitBounds(flightPaths[icao24].getBounds()); - } - } else { - alert('No recent flight path data available for this aircraft.'); - } - }) - .catch(error => { - console.error('Error fetching flight path:', error); - alert('Failed to retrieve flight path: ' + error.message); - }); - } - - aircrafts.forEach(function(aircraft) { - console.log("Aircraft:", aircraft.icao24, "Model:", aircraft.model, "is_helicopter:", aircraft.is_helicopter, "is_commercial_jet:", aircraft.is_commercial_jet, "is_small_plane:", aircraft.is_small_plane, "is_drone:", aircraft.is_drone, "is_glider:", aircraft.is_glider, "is_military:", aircraft.is_military); - if (aircraft.latitude && aircraft.longitude && aircraft.latitude !== 'N/A' && aircraft.longitude !== 'N/A') { - var marker; - if (aircraft.is_military) { - marker = L.marker([aircraft.latitude, aircraft.longitude], { icon: militaryIcon }); - } else if (aircraft.is_helicopter) { - marker = L.marker([aircraft.latitude, aircraft.longitude], { icon: helicopterIcon }); - } else if (aircraft.is_commercial_jet) { - marker = L.marker([aircraft.latitude, aircraft.longitude], { icon: commercialJetIcon }); - } else if (aircraft.is_small_plane) { - marker = L.marker([aircraft.latitude, aircraft.longitude], { icon: smallPlaneIcon }); - } else if (aircraft.is_drone) { - marker = L.marker([aircraft.latitude, aircraft.longitude], { icon: droneIcon }); - } else if (aircraft.is_glider) { - marker = L.marker([aircraft.latitude, aircraft.longitude], { icon: gliderIcon }); - } else { - marker = L.marker([aircraft.latitude, aircraft.longitude]); - } - marker.addTo(map).bindPopup( - `Callsign: ${aircraft.callsign}
- Model: ${aircraft.model}
- Registration: ${aircraft.registration}
- Altitude: ${aircraft.baro_altitude} m
- Velocity: ${aircraft.velocity} m/s
- True Track: ${aircraft.true_track}°
- Squawk: ${aircraft.squawk}
- DB Flags: ${aircraft.db_flags}` - ); - marker.on('click', function() { - fetchFlightPath(aircraft.icao24, marker); - }); + aircrafts.forEach(function(ac) { + if (ac.latitude && ac.longitude && ac.latitude != "N/A" && ac.longitude != "N/A") { + var key = ac.is_military ? 'mil' : + ac.is_helicopter ? 'heli' : + ac.is_commercial_jet ? 'jet' : + ac.is_small_plane ? 'ga' : + ac.is_drone ? 'drone' : + ac.is_glider ? 'glider' : 'default'; + console.log(`Aircraft ${ac.icao24}: type key = ${key}`); // Debugging log + var marker = L.marker([ac.latitude, ac.longitude], {icon: icons[key]}).addTo(map); + marker.bindPopup( + `${ac.callsign} [${ac.model}]
+ Reg: ${ac.registration}
+ Alt: ${ac.baro_altitude} m
+ Speed: ${ac.velocity} m/s
+ Seen: ${ac.last_seen}`); } }); + var bounds = aircrafts.filter(a => a.latitude && a.longitude && a.latitude != "N/A" && a.longitude != "N/A") + .map(a => [a.latitude, a.longitude]); + if (bounds.length > 0) map.fitBounds(bounds); - var bounds = aircrafts.map(a => [a.latitude, a.longitude]).filter(b => b[0] && b[1] && b[0] !== 'N/A' && b[1] !== 'N/A'); - if (bounds.length > 0) { - map.fitBounds(bounds); + document.getElementById('filterForm').onsubmit = function(e) { + e.preventDefault(); + var callsign = this.callsign.value.toLowerCase(); + var model = this.model.value.toLowerCase(); + var min_alt = parseFloat(this.min_alt.value) || -9999; + var max_alt = parseFloat(this.max_alt.value) || 99999; + var rows = document.querySelectorAll('#aircraftTable tbody tr'); + rows.forEach(function(row) { + var tds = row.children; + var c = tds[0].innerText.toLowerCase(); + var m = tds[1].innerText.toLowerCase(); + var a = parseFloat(tds[5].innerText) || 0; + var show = (!callsign || c.includes(callsign)) && (!model || m.includes(model)) && a >= min_alt && a <= max_alt; + row.style.display = show ? '' : 'none'; + }); } ''' + +class SkyHigh(plugins.Plugin): + __author__ = 'AlienMajik' + __version__ = '1.1.1' + __license__ = 'GPL3' + __description__ = 'Advanced aircraft/ADS-B data plugin with robust type-detection, embedded SVG icons, filtering, export, and caching.' + + METADATA_CACHE_FILE = '/root/handshakes/skyhigh_metadata.json' + + def __init__(self): + self.options = { + 'timer': 60, + 'aircraft_file': '/root/handshakes/skyhigh_aircraft.json', + 'adsb_x_coord': 160, + 'adsb_y_coord': 80, + 'latitude': -66.273334, + 'longitude': 100.984166, + 'radius': 50, + 'prune_minutes': 5, + 'opensky_username': None, + 'opensky_password': None, + 'blocklist': [], + 'allowlist': [], + } + self.last_fetch_time = 0 + self.data = {} + self.data_lock = threading.Lock() + self.metadata_cache = {} + self.historical_positions = {} + self.error_message = "" + self.web_ui_last_update = "" + self._stop_event = threading.Event() + self.fetch_thread = None + + def on_loaded(self): + logging.info("[SkyHigh] Plugin loaded.") + os.makedirs(os.path.dirname(self.options['aircraft_file']), exist_ok=True) + os.makedirs(os.path.dirname(self.METADATA_CACHE_FILE), exist_ok=True) + self.load_metadata_cache() + try: + with open(self.options['aircraft_file'], 'r') as f: + self.data = json.load(f) + except Exception: + self.data = {} + self._stop_event.clear() + self.fetch_thread = threading.Thread(target=self._fetch_loop, daemon=True) + self.fetch_thread.start() + + def on_unload(self, ui): + self._stop_event.set() + if ui: + with ui._lock: + ui.remove_element('SkyHigh') + self.save_metadata_cache() + + def on_ui_setup(self, ui): + ui.add_element('SkyHigh', LabeledValue( + color=BLACK, + label='SkyHigh', + value=" ", + position=(self.options["adsb_x_coord"], self.options["adsb_y_coord"]), + label_font=fonts.Small, + text_font=fonts.Small + )) + + def on_ui_update(self, ui): + with self.data_lock: + aircraft_count = len(self.data) + ui.set('SkyHigh', f"{aircraft_count} aircraft (Last: {self.web_ui_last_update}){(' - ERROR: '+self.error_message) if self.error_message else ''}") + + def _fetch_loop(self): + while not self._stop_event.is_set(): + try: + self.fetch_aircraft_data() + self.web_ui_last_update = datetime.now().strftime("%H:%M:%S") + self.error_message = "" + except Exception as e: + self.error_message = str(e) + logging.error(f"[SkyHigh] Background fetch error: {e}") + time.sleep(self.options['timer']) + + def load_metadata_cache(self): + try: + with open(self.METADATA_CACHE_FILE, 'r') as f: + self.metadata_cache = json.load(f) + except Exception: + self.metadata_cache = {} + + def save_metadata_cache(self): + try: + with open(self.METADATA_CACHE_FILE, 'w') as f: + json.dump(self.metadata_cache, f) + except Exception: + pass + + def fetch_aircraft_data(self): + lat, lon, radius = self._get_current_coords() + lat_min, lat_max = lat - (radius / 69), lat + (radius / 69) + lon_min, lon_max = lon - (radius / 69), lon + (radius / 69) + url = f"https://opensky-network.org/api/states/all?lamin={lat_min}&lomin={lon_min}&lamax={lat_max}&lomax={lon_max}" + + headers = {} + if self.options['opensky_username'] and self.options['opensky_password']: + import base64 + auth_str = f"{self.options['opensky_username']}:{self.options['opensky_password']}" + auth_encoded = base64.b64encode(auth_str.encode()).decode() + headers['Authorization'] = f'Basic {auth_encoded}' + + try: + response = requests.get(url, headers=headers or None, timeout=15) + if response.status_code == 200: + api_data = response.json() + if not api_data or 'states' not in api_data: + logging.error("[SkyHigh] API response missing 'states' key or is None.") + return "No aircraft data" + logging.info(f"[SkyHigh] Sample Raw API Data: {json.dumps(api_data['states'][:2], indent=2)}") + aircrafts = self._parse_and_store(api_data) + self.prune_old_data() + with self.data_lock: + with open(self.options['aircraft_file'], 'w') as f: + json.dump(self.data, f) + return f"{len(aircrafts)} aircraft detected" + elif response.status_code == 401: + self.error_message = "OpenSky authentication failed." + raise Exception("OpenSky authentication failed (401).") + elif response.status_code == 429: + self.error_message = "OpenSky rate-limited." + raise Exception("OpenSky rate-limited (429).") + else: + raise Exception(f"OpenSky API error: {response.status_code}") + except Exception as e: + self.error_message = str(e) + raise + + def _parse_and_store(self, api_data: Dict[str, Any]) -> List[Dict]: + aircrafts = [] + blocklist = set(self.options.get('blocklist', [])) + allowlist = set(self.options.get('allowlist', [])) + if 'states' in api_data and api_data['states']: + for state in api_data['states']: + icao24 = state[0] + if blocklist and icao24 in blocklist: continue + if allowlist and icao24 not in allowlist: continue + callsign = state[1].strip() if state[1] else "Unknown" + lat = state[6] if state[6] is not None else 'N/A' + lon = state[5] if state[5] is not None else 'N/A' + baro_altitude = state[7] if state[7] is not None else 'N/A' + velocity = state[9] if state[9] is not None else 'N/A' + timestamp = state[4] if state[4] is not None else int(time.time()) + with self.data_lock: + if lat != 'N/A' and lon != 'N/A': + pos = {'timestamp': timestamp, 'latitude': lat, 'longitude': lon, 'baro_altitude': baro_altitude} + self.historical_positions.setdefault(icao24, []).append(pos) + self.historical_positions[icao24] = self.historical_positions[icao24][-10:] + meta = self.get_aircraft_metadata(icao24) + info = { + **meta, + 'callsign': callsign, + 'latitude': lat, + 'longitude': lon, + 'baro_altitude': baro_altitude, + 'velocity': velocity, + 'last_seen': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'icao24': icao24 + } + self.data[icao24] = info + aircrafts.append(info) + return aircrafts + + def get_aircraft_metadata(self, icao24: str) -> Dict: + if icao24 in self.metadata_cache: + return self.metadata_cache[icao24] + url = f"https://opensky-network.org/api/metadata/aircraft/icao/{icao24}" + headers = {} + try: + if self.options['opensky_username'] and self.options['opensky_password']: + import base64 + auth_str = f"{self.options['opensky_username']}:{self.options['opensky_password']}" + auth_encoded = base64.b64encode(auth_str.encode()).decode() + headers['Authorization'] = f'Basic {auth_encoded}' + r = requests.get(url, headers=headers, timeout=10) + if r.status_code == 401: + logging.warning("[SkyHigh] OpenSky credentials invalid. Attempting anonymous metadata fetch.") + r = requests.get(url, timeout=10) + else: + logging.warning("[SkyHigh] No OpenSky credentials. Attempting anonymous metadata fetch.") + r = requests.get(url, timeout=10) + if r.status_code == 200: + d = r.json() + manufacturer = d.get('manufacturerName', '') or '' + model = d.get('model', 'Unknown') or 'Unknown' + registration = d.get('registration', 'Unknown') or 'Unknown' + db_flags = ', '.join([f.lower() for f in d.get('special_flags', []) if isinstance(f, str)]) + typecode = d.get('typecode', '') or '' + + logging.info(f"[SkyHigh] Metadata for {icao24}: Manufacturer={manufacturer}, Model={model}, Typecode={typecode}") + + manufacturer_lower = manufacturer.lower() + model_lower = model.lower() + typecode_lower = typecode.lower() + + is_helicopter = ( + 'helicopter' in model_lower or + model_lower.startswith('as') or + model_lower.startswith('ec') + ) + is_commercial_jet = ( + any(j in manufacturer_lower for j in ['airbus', 'boeing', 'embraer', 'bombardier']) or + any(model_lower.startswith(prefix) for prefix in [ + 'a', 'b', 'e', 'crj', 'erj', + '737', '747', '757', '767', '777', '787', '320', '319' + ]) or + any(typecode_lower.startswith(prefix) for prefix in [ + 'a', 'b', 'e', 'crj', 'erj', + '737', '747', '757', '767', '777', '787', '320', '319' + ]) + ) + is_small_plane = ( + any(s in manufacturer_lower for s in ['cessna', 'piper', 'beechcraft', 'cirrus']) or + any(model_lower.startswith(prefix) for prefix in ['c', 'p', 'be', 'sr', 'u']) or + (len(model_lower) <= 4 and model_lower[0] in 'cpu') + ) + is_drone = 'drone' in model_lower or 'uav' in model_lower or 'unmanned' in model_lower + is_glider = 'glider' in model_lower or 'sailplane' in model_lower + is_military = ( + 'military' in db_flags or + 'military' in manufacturer_lower or + 'military' in model_lower + ) + + meta = { + 'model': model, + 'registration': registration, + 'db_flags': db_flags, + 'is_helicopter': is_helicopter, + 'is_commercial_jet': is_commercial_jet, + 'is_small_plane': is_small_plane, + 'is_drone': is_drone, + 'is_glider': is_glider, + 'is_military': is_military + } + + logging.debug(f"[SkyHigh] Aircraft {icao24} type flags: {meta}") + + self.metadata_cache[icao24] = meta + self.save_metadata_cache() + return meta + else: + logging.warning(f"[SkyHigh] Failed to fetch metadata for {icao24}: {r.status_code}") + return { + 'model': 'Unknown', + 'registration': 'Unknown', + 'db_flags': '', + 'is_helicopter': False, + 'is_commercial_jet': False, + 'is_small_plane': True, + 'is_drone': False, + 'is_glider': False, + 'is_military': False + } + except Exception as e: + logging.error(f"[SkyHigh] Exception fetching metadata for {icao24}: {e}") + return { + 'model': 'Unknown', + 'registration': 'Unknown', + 'db_flags': '', + 'is_helicopter': False, + 'is_commercial_jet': False, + 'is_small_plane': True, + 'is_drone': False, + 'is_glider': False, + 'is_military': False + } + + def prune_old_data(self): + prune_minutes = self.options.get('prune_minutes', 5) + now = datetime.now() + cutoff = now - timedelta(minutes=prune_minutes) + remove = [] + with self.data_lock: + for icao24, info in self.data.items(): + try: + last_seen = datetime.strptime(info['last_seen'], '%Y-%m-%d %H:%M:%S') + if last_seen < cutoff: + remove.append(icao24) + except Exception: + continue + for icao24 in remove: + self.data.pop(icao24, None) + self.historical_positions.pop(icao24, None) + + def _get_current_coords(self): + return self.options['latitude'], self.options['longitude'], self.options['radius'] + + def on_webhook(self, path, req): + if req.method == 'GET': + if path == '/' or not path: + with self.data_lock: + aircrafts = list(self.data.values()) + center = [self.options["latitude"], self.options["longitude"]] + logging.debug(f"[SkyHigh] Sending {len(aircrafts)} aircraft to web interface") + return render_template_string(HTML_TEMPLATE, aircrafts=aircrafts, center=center) + elif path.startswith('export/csv'): + return self.export_csv() + elif path.startswith('export/kml'): + return self.export_kml() + return "Not found", 404 + + def export_csv(self): + import io + import csv + si = io.StringIO() + cw = csv.writer(si) + cw.writerow(['icao24', 'callsign', 'model', 'registration', 'latitude', 'longitude', 'altitude', 'velocity', 'type', 'last_seen']) + with self.data_lock: + for icao24, ac in self.data.items(): + t = ("Military" if ac.get('is_military') else + "Drone" if ac.get('is_drone') else + "Helicopter" if ac.get('is_helicopter') else + "Jet" if ac.get('is_commercial_jet') else + "GA" if ac.get('is_small_plane') else + "Glider" if ac.get('is_glider') else "Other") + cw.writerow([icao24, ac.get('callsign'), ac.get('model'), ac.get('registration'), + ac.get('latitude'), ac.get('longitude'), ac.get('baro_altitude'), + ac.get('velocity'), t, ac.get('last_seen')]) + return Response(si.getvalue(), mimetype='text/csv', + headers={"Content-Disposition": "attachment;filename=skyhigh_aircraft.csv"}) + + def export_kml(self): + with self.data_lock: + kml = [''] + for ac in self.data.values(): + kml.append(f''' + + {ac.get('callsign', '')} + Model:{ac.get('model', '')} Alt:{ac.get('baro_altitude', '')}m + {ac.get('longitude')},{ac.get('latitude')},0 + + ''') + kml.append('') + return Response(''.join(kml), mimetype='application/vnd.google-earth.kml+xml', + headers={"Content-Disposition": "attachment;filename=skyhigh_aircraft.kml"})
Callsign ModelRegistrationLatitudeLongitudeBaro AltitudeGeo AltitudeVelocityTrue TrackVertical RateOn GroundSquawkDB FlagsRegLatLonAltTypeSpeed Last Seen
{{ aircraft.callsign }}{{ aircraft.model }}{{ aircraft.registration }}{{ aircraft.latitude }}{{ aircraft.longitude }}{{ aircraft.baro_altitude }}{{ aircraft.geo_altitude }}{{ aircraft.velocity }}{{ aircraft.true_track }}{{ aircraft.vertical_rate }}{{ 'Yes' if aircraft.on_ground else 'No' }}{{ aircraft.squawk }}{{ aircraft.db_flags }}{{ aircraft.last_seen }}{{a.callsign}}{{a.model}}{{a.registration}}{{a.latitude}}{{a.longitude}}{{a.baro_altitude}} + {% if a.is_military %}Mil{% elif a.is_drone %}Drone + {% elif a.is_helicopter %}Heli{% elif a.is_commercial_jet %}Jet + {% elif a.is_small_plane %}GA{% elif a.is_glider %}Glider + {% else %}Other{% endif %} + {{a.velocity}}{{a.last_seen}}