Skip to content

Commit ee26d1a

Browse files
committed
Updates for Retro-Radar.
Now with live ATC audio streaming using 'Video-LAN' and 'Python-VLC'. It could be started like: -------------------- @echo off setlocal :: :: Change to suite :: set PYTHON_VLC_LIB_PATH=f:\ProgramFiler\VideoLan\libvlc.dll set GNUTLS_DEBUG_LEVEL= py -3 %~dp0\main.py --------------------
1 parent 86b0425 commit ee26d1a

File tree

10 files changed

+641
-4
lines changed

10 files changed

+641
-4
lines changed

src/externals/Retro-ADSB-radar/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Aircraft radar display built with Python and Pygame. Visualises real-time aircra
1010
- Retro colour palette
1111
- Terminus TTF fonts for an authentic look
1212
- Default configuration is compatible with the [Hagibis Mini PC USB-C Hub](https://hagibis.com/products-p00288p1.html)
13+
- Live ATC audio streaming from a specified URL
1314

1415
![Retro ADS-B Radar Screenshot](screenshot.png)
1516

@@ -61,6 +62,10 @@ MIL_PREFIX_LIST = 7CF # Comma-separated list of military aircraft h
6162
TAR1090_URL = http://localhost/data/aircraft.json # tar1090 data source URL
6263
BLINK_MILITARY = true # Toggle blinking effect for military aircraft (true/false)
6364

65+
[Audio]
66+
ATC_STREAM_URL = # URL of live ATC audio stream (leave blank to disable)
67+
AUTO_START = false # Start ATC stream automatically (true/false)
68+
6469
[Location]
6570
LAT = -31.9522 # Radar centre latitude
6671
LON = 115.8614 # Radar centre longitude
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
class AudioManager:
2+
"""Manages ATC audio stream playback using a single, persistent VLC instance."""
3+
def __init__(self, stream_url: str = None, volume = 100):
4+
self.stream_url = stream_url
5+
self.player = None
6+
self.instance = None
7+
self.initialised = False
8+
self.volume = volume
9+
self.vlc_audio_set_volume = None
10+
11+
def initialise(self) -> bool:
12+
"""
13+
Initialises the VLC instance and loads the stream.
14+
The 'vlc' module is imported here to make it an optional dependency.
15+
"""
16+
if self.initialised:
17+
return False # Already initialised, no need to reinitialise
18+
19+
if not self.stream_url:
20+
return False # Can't initialise without a stream URL
21+
22+
try:
23+
import vlc
24+
25+
self.instance = vlc.Instance()
26+
self.player = self.instance.media_player_new()
27+
media = self.instance.media_new(self.stream_url)
28+
media.add_option(':network-caching=10000')
29+
media.add_option(':clock-jitter=0')
30+
media.add_option(':clock-synchro=0')
31+
self.player.set_media(media)
32+
self.vlc_audio_set_volume = vlc.libvlc_audio_set_volume
33+
self.vlc_audio_set_volume (self.player, self.volume)
34+
self.initialised = True
35+
print("? Audio manager initialised successfully")
36+
return True
37+
except ModuleNotFoundError:
38+
print("? Error: 'python-vlc' not found. Please install it to use the audio feature.")
39+
return False
40+
except Exception as e:
41+
print(f"? Error initialising audio. Is the VLC application installed? Details: {e}")
42+
self.player = None
43+
self.instance = None
44+
return False
45+
46+
def toggle(self):
47+
"""Toggles the audio stream on or off."""
48+
if not self.player:
49+
return
50+
51+
if self.player.is_playing():
52+
self.player.stop()
53+
print("? Audio stream stopped")
54+
else:
55+
self.player.play()
56+
print("? Audio stream started")
57+
58+
def is_playing(self) -> bool:
59+
"""Returns True if the audio stream is currently playing."""
60+
if not self.player:
61+
return False
62+
return self.player.is_playing()
63+
64+
def set_volume(self, delta_volume):
65+
if not self.vlc_audio_set_volume:
66+
print ("vlc_audio_set_volume == None")
67+
return
68+
69+
if self.volume + delta_volume <= 0:
70+
self.volume = 0
71+
changed = False
72+
elif self.volume + delta_volume >= 100:
73+
self.volume = 100
74+
changed = False
75+
else:
76+
self.volume = self.volume + delta_volume
77+
self.vlc_audio_set_volume(self.player, self.volume)
78+
changed = True
79+
print (f"changed: {changed}, delta_volume: {delta_volume}, self.volume: {self.volume}")
80+
81+
def shutdown(self):
82+
"""Stops playback and releases VLC resources cleanly."""
83+
if self.player:
84+
self.player.stop()
85+
if self.instance:
86+
self.instance.release()
87+
self.player = None
88+
self.instance = None
89+
90+
if self.initialised:
91+
print("? Audio shut down cleanly")
92+

src/externals/Retro-ADSB-radar/config.ini

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
[General]
2-
FETCH_INTERVAL = 4
2+
FETCH_INTERVAL = 4
33
MIL_PREFIX_LIST = 7CF
4-
TAR1090_URL = http://127.0.0.1:8080/data/aircraft.json
5-
BLINK_MILITARY = true
4+
TAR1090_URL = http://127.0.0.1:8080/data/aircraft.json
5+
BLINK_MILITARY = true
6+
7+
[Audio]
8+
ATC_STREAM_URL = https://s1-fmt2.liveatc.net/vhhh5
9+
ATC_VOLUME = 70
10+
AUTO_START = true
611

712
[Location]
813
LAT = 60.3045800
@@ -11,7 +16,7 @@ AREA_NAME = Bergen
1116
RADIUS_NM = 200
1217

1318
[Display]
14-
SCREEN_WIDTH = 960
19+
SCREEN_WIDTH = 960
1520
SCREEN_HEIGHT = 640
1621
FPS = 6
1722
MAX_TABLE_ROWS = 10
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import configparser
2+
import os
3+
4+
# Configuration Loading
5+
config = configparser.ConfigParser()
6+
script_dir = os.path.dirname (os.path.abspath(__file__)).replace ("\\", "/")
7+
8+
#print (f"script_dir: {script_dir}")
9+
10+
config.read (f"{script_dir}/config.ini")
11+
12+
#
13+
# General Settings
14+
#
15+
FETCH_INTERVAL = config.getint ('General', 'FETCH_INTERVAL', fallback=10)
16+
MIL_PREFIX_LIST = [prefix.strip() for prefix in config.get('General', 'MIL_PREFIX_LIST', fallback='7CF').split(',')]
17+
TAR1090_URL = config.get ('General', 'TAR1090_URL', fallback='http://localhost/data/aircraft.json')
18+
BLINK_MILITARY = config.getboolean ('General', 'BLINK_MILITARY', fallback=True)
19+
20+
#
21+
# Audio Settings
22+
#
23+
ATC_STREAM_URL = config.get('Audio', 'ATC_STREAM_URL', fallback='')
24+
ATC_VOLUME = config.getint('Audio', 'ATC_VOLUME', fallback=100)
25+
ATC_AUTO_START = config.getboolean('Audio', 'AUTO_START', fallback=False)
26+
27+
#
28+
# Location Settings
29+
#
30+
LAT = config.getfloat('Location', 'LAT', fallback=0.0)
31+
LON = config.getfloat('Location', 'LON', fallback=0.0)
32+
AREA_NAME = config.get('Location', 'AREA_NAME', fallback='UNKNOWN')
33+
RADIUS_NM = config.getint('Location', 'RADIUS_NM', fallback=60)
34+
35+
#
36+
# Display Settings
37+
#
38+
SCREEN_WIDTH = config.getint('Display', 'SCREEN_WIDTH', fallback=960)
39+
SCREEN_HEIGHT = config.getint('Display', 'SCREEN_HEIGHT', fallback=640)
40+
FPS = config.getint('Display', 'FPS', fallback=6)
41+
MAX_TABLE_ROWS = config.getint('Display', 'MAX_TABLE_ROWS', fallback=10)
42+
FONT_PATH = config.get('Display', 'FONT_PATH', fallback=f"{script_dir}/fonts/TerminusTTF-4.49.3.ttf")
43+
BACKGROUND_PATH = config.get('Display', 'BACKGROUND_PATH', fallback=None)
44+
TRAIL_MIN_LENGTH = config.getint('Display', 'TRAIL_MIN_LENGTH', fallback=8)
45+
TRAIL_MAX_LENGTH = config.getint('Display', 'TRAIL_MAX_LENGTH', fallback=25)
46+
TRAIL_MAX_SPEED = config.getint('Display', 'TRAIL_MAX_SPEED', fallback=500)
47+
HEADER_FONT_SIZE = config.getint('Display', 'HEADER_FONT_SIZE', fallback=32)
48+
RADAR_FONT_SIZE = config.getint('Display', 'RADAR_FONT_SIZE', fallback=28)
49+
TABLE_FONT_SIZE = config.getint('Display', 'TABLE_FONT_SIZE', fallback=28)
50+
INSTRUCTION_FONT_SIZE = config.getint('Display', 'INSTRUCTION_FONT_SIZE', fallback=28)
51+
52+
if FONT_PATH.startswith("$0"):
53+
FONT_PATH = f"{script_dir}/{FONT_PATH[2:]}"
54+
# print (f"script_dir: {script_dir}")
55+
# print (f"FONT_PATH: {FONT_PATH}")
56+
57+
# Colours
58+
BLACK = (0, 0, 0)
59+
GREEN = (0, 255, 0)
60+
BRIGHT_GREEN = (50, 255, 50)
61+
DIM_GREEN = (0, 180, 0)
62+
RED = (255, 50, 50)
63+
YELLOW = (255, 255, 0)
64+
AMBER = (255, 191, 0)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import requests
2+
import threading
3+
import time
4+
from typing import List
5+
6+
import config
7+
from data_models import Aircraft
8+
9+
class AircraftTracker:
10+
"""Handles fetching aircraft data from tar1090"""
11+
def __init__(self):
12+
self.aircraft: List[Aircraft] = []
13+
self.status = "INITIALISING"
14+
self.last_update = time.time()
15+
self.running = True
16+
17+
def fetch_data(self) -> List[Aircraft]:
18+
"""Fetch aircraft from local tar1090"""
19+
try:
20+
# print(f"Fetching aircraft data from {config.TAR1090_URL}...")
21+
response = requests.get(config.TAR1090_URL, timeout=10)
22+
response.raise_for_status()
23+
data = response.json()
24+
aircraft_list = []
25+
for ac_data in data.get('aircraft', []):
26+
ac = Aircraft.from_dict(ac_data)
27+
if ac:
28+
aircraft_list.append(ac)
29+
print(f"? Found {len(aircraft_list)} aircraft within {config.RADIUS_NM}NM range")
30+
return aircraft_list
31+
except requests.RequestException as e:
32+
if 0:
33+
print(f"? Error: Couldn't fetch aircraft data: {e}")
34+
else:
35+
print("Couldn't fetch aircraft data")
36+
return []
37+
38+
def update_loop(self):
39+
"""Background thread to fetch data periodically"""
40+
while self.running:
41+
self.status = "SCANNING"
42+
self.last_update = time.time()
43+
self.aircraft = self.fetch_data()
44+
self.status = "ACTIVE" if self.aircraft else "NO CONTACTS"
45+
time.sleep(config.FETCH_INTERVAL)
46+
47+
def start(self):
48+
"""Start the background data fetching"""
49+
thread = threading.Thread(target=self.update_loop, daemon=True)
50+
thread.start()
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Optional
5+
6+
import config
7+
from utils import calculate_distance_bearing
8+
9+
@dataclass
10+
class Aircraft:
11+
"""Aircraft data from tar1090"""
12+
hex_code: str
13+
callsign: str
14+
lat: float
15+
lon: float
16+
altitude: int
17+
speed: int
18+
track: float
19+
distance: float
20+
bearing: float
21+
is_military: bool = False
22+
23+
@staticmethod
24+
def from_dict(data: dict) -> Optional[Aircraft]:
25+
"""Create an Aircraft object from a dictionary."""
26+
if 'lat' not in data or 'lon' not in data:
27+
return None
28+
lat, lon = data['lat'], data['lon']
29+
distance, bearing = calculate_distance_bearing(config.LAT, config.LON, lat, lon)
30+
if distance > config.RADIUS_NM:
31+
return None
32+
hex_code = data['hex'].lower()
33+
mil_prefixes = tuple(prefix.lower() for prefix in config.MIL_PREFIX_LIST)
34+
is_military = hex_code.startswith(mil_prefixes)
35+
return Aircraft(
36+
hex_code=hex_code,
37+
callsign=data.get('flight', 'UNKNOWN').strip()[:8],
38+
lat=lat, lon=lon,
39+
altitude=data.get('alt_baro', 0) or 0,
40+
speed=int(data.get('gs', 0) or 0),
41+
track=data.get('track', 0) or 0,
42+
distance=distance, bearing=bearing,
43+
is_military=is_military
44+
)

0 commit comments

Comments
 (0)