XSayATC - lokalny asystent ATC dla X-Plane 12 (llama.cpp)
This commit is contained in:
commit
36cf23232c
4 files changed
+757
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# Pliki konfiguracji lokalnej (ustawienia użytkownika)
|
||||
PythonPlugins/config.json
|
||||
|
||||
# Pliki Pythona
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,660 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
XSayATC - lokalny asystent ATC dla X-Plane 12 (llama.cpp)
|
||||
|
||||
Podlacza sie do lokalnego serwera llama.cpp (OpenAI-compat /v1/chat/completions),
|
||||
pobiera kontekst lotu z datarefow, wysyla Twoja wiadomosc (na razie tekstowa),
|
||||
i streamuje odpowiedz ATC do okna widocznego rowniez w VR.
|
||||
|
||||
Wymaga: XPPython3 (dostarcza wlasnego Pythona 3.12 + imgui + requests).
|
||||
|
||||
Konfiguracja zapisywana jest w config.json obok tego pliku.
|
||||
|
||||
WAZNE (bezpieczenstwo watkow):
|
||||
- Odczyty datarefow (xp.getData*) MUSZA dzialac na glownym watku X-Plane.
|
||||
- Watek roboczy robi WYLACZNIE siec (requests) i aktualizuje self.messages.
|
||||
- Kontekst lotu zbierany jest na glownym watku i przekazywany do watku.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
|
||||
from XPPython3 import xp
|
||||
from XPPython3.xp_imgui import Window as ImguiWindow
|
||||
|
||||
import imgui
|
||||
import requests
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Konfiguracja i domyslne wartosci
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"base_url": "http://localhost:8081", # adres serwera llama.cpp
|
||||
"model": "", # puste = pobierze pierwszy z /v1/models
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 512,
|
||||
"system_prompt": (
|
||||
"You are a concise, professional air traffic controller in X-Plane 12. "
|
||||
"Give short, clear, realistic ATC instructions. Use standard phraseology. "
|
||||
"You have access to the pilot's current flight context (position, altitude, "
|
||||
"speed, heading, transponder, radio frequencies, nearest airport, weather) "
|
||||
"and to nearby traffic. "
|
||||
"IMPORTANT: adopt the role of the facility the pilot is currently tuned to "
|
||||
"(the 'Tuned facility' field): Delivery/Clearance gives IFR/VFR clearances and "
|
||||
"squawk codes; Ground issues taxi instructions; Tower controls takeoff, landing "
|
||||
"and the active runway; Tracon/Approach handles arrivals, departures and "
|
||||
"vectors; Center handles enroute. If 'Tuned facility' is 'None', act as a "
|
||||
"generic ATC using the active COM frequency. "
|
||||
"Use the nearby traffic list when issuing traffic advisories "
|
||||
"(e.g. bearing in clock positions and distance). "
|
||||
"Respond with ATC instructions only, no extra commentary."
|
||||
),
|
||||
"include_flight_context": True,
|
||||
"no_think": True, # dodaje /no_think do promptu (modele Qwen3 z reasoningiem)
|
||||
"window_always_on_top": True,
|
||||
}
|
||||
|
||||
# Dataref -> (nazwa kontekstu, mnoznik, jednostka)
|
||||
CONTEXT_DATAREFS = [
|
||||
("sim/flightmodel/position/latitude", "Latitude", 1.0, "deg"),
|
||||
("sim/flightmodel/position/longitude", "Longitude", 1.0, "deg"),
|
||||
("sim/flightmodel/misc/h_ind", "Alt (baro)", 1.0, "ft"),
|
||||
("sim/flightmodel/position/indicated_airspeed", "IAS", 1.0, "kt"),
|
||||
("sim/flightmodel/position/groundspeed", "GS", 1.943844, "kt"),
|
||||
("sim/flightmodel/position/mag_psi", "HDG (mag)", 1.0, "deg"),
|
||||
("sim/cockpit/radios/transponder_code", "Squawk", 1.0, ""),
|
||||
("sim/cockpit/radios/com1_freq_hz", "COM1", 0.001, "MHz"),
|
||||
("sim/cockpit/radios/com2_freq_hz", "COM2", 0.001, "MHz"),
|
||||
("sim/cockpit/radios/nav1_freq_hz", "NAV1", 0.001, "MHz"),
|
||||
]
|
||||
|
||||
TAILNUM_DATAREF = "sim/aircraft/view/acf_tailnum"
|
||||
VR_ENABLED_DATAREF = "sim/graphics/VR/enabled"
|
||||
TUNED1_DATAREF = "sim/atc/com1_tuned_facility"
|
||||
TUNED2_DATAREF = "sim/atc/com2_tuned_facility"
|
||||
ELEVATION_DATAREF = "sim/flightmodel/position/elevation"
|
||||
|
||||
# Mapa typu sluzby ATC (sim/atc/*_tuned_facility)
|
||||
FACILITY_NAMES = {
|
||||
0: "None",
|
||||
1: "Delivery/Clearance",
|
||||
2: "Ground",
|
||||
3: "Tower",
|
||||
4: "Tracon/Approach-Departure",
|
||||
5: "Center",
|
||||
}
|
||||
|
||||
# Ile samolotow ruchu sprawdzac (X-Plane wspiera do 20 samolotow AI/multiplayer)
|
||||
NUM_TRAFFIC_PLANES = 20
|
||||
|
||||
|
||||
class XSayATC:
|
||||
def __init__(self):
|
||||
self.config = self._load_config()
|
||||
self.datarefs = {} # name -> dataref object
|
||||
self._resolved_datarefs = False
|
||||
self._tailnum_ref = None
|
||||
self._vr_ref = None
|
||||
|
||||
# Stan rozmowy
|
||||
self.messages = [] # lista slownikow {"role": "user"/"assistant"/"system", "text": str}
|
||||
self.pending_text = "" # tekst w polu wprowadzania
|
||||
self.status = "Gotowy."
|
||||
self.streaming = False
|
||||
|
||||
self._model_cache = None
|
||||
self._stop_event = threading.Event()
|
||||
self._worker = None
|
||||
|
||||
# Okna ImGui
|
||||
self._imgui_window = None
|
||||
self._config_window = None
|
||||
|
||||
# Menu
|
||||
self._menu = None
|
||||
self._menu_open_idx = None
|
||||
self._menu_cfg_idx = None
|
||||
|
||||
# VR
|
||||
self._last_vr = None
|
||||
|
||||
# ---------------------------- Config ----------------------------
|
||||
def _load_config(self):
|
||||
cfg = dict(DEFAULT_CONFIG)
|
||||
try:
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
cfg.update(data)
|
||||
except Exception:
|
||||
pass
|
||||
return cfg
|
||||
|
||||
def _save_config(self):
|
||||
try:
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(self.config, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
xp.log(f"[XSayATC] Blad zapisu config.json: {e}")
|
||||
|
||||
# ---------------------------- Datarefs ----------------------------
|
||||
def _ensure_datarefs(self):
|
||||
if self._resolved_datarefs:
|
||||
return
|
||||
for name, _label, _mult, _unit in CONTEXT_DATAREFS:
|
||||
self.datarefs[name] = xp.findDataRef(name)
|
||||
self._tailnum_ref = xp.findDataRef(TAILNUM_DATAREF)
|
||||
self._vr_ref = xp.findDataRef(VR_ENABLED_DATAREF)
|
||||
self._tuned1_ref = xp.findDataRef(TUNED1_DATAREF)
|
||||
self._tuned2_ref = xp.findDataRef(TUNED2_DATAREF)
|
||||
self._elevation_ref = xp.findDataRef(ELEVATION_DATAREF)
|
||||
# Datarefy ruchu lotniczego (AI/multiplayer) - leniwie rozwijane
|
||||
self._traffic_refs = {}
|
||||
self._resolved_datarefs = True
|
||||
|
||||
def _get_dataref(self, name, is_float=True):
|
||||
d = self.datarefs.get(name)
|
||||
if d is None:
|
||||
return None
|
||||
try:
|
||||
if is_float:
|
||||
return xp.getDataf(d)
|
||||
return xp.getDatai(d)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _read_string(self, dref):
|
||||
try:
|
||||
return xp.getDatas(dref)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ---------------------------- Kontekst lotu (TYLKO glowny watek) ----------------------------
|
||||
def _collect_context(self):
|
||||
"""Zbiera kontekst lotu. WYWOLYWAC TYLKO na glownym watku."""
|
||||
ctx = {}
|
||||
lines = []
|
||||
for name, label, mult, unit in CONTEXT_DATAREFS:
|
||||
v = self._get_dataref(name, is_float=(name != "sim/cockpit/radios/transponder_code"))
|
||||
if v is None:
|
||||
continue
|
||||
if name == "sim/cockpit/radios/transponder_code":
|
||||
ctx[label] = int(v)
|
||||
lines.append(f"{label}: {int(v)}")
|
||||
elif name in ("sim/cockpit/radios/com1_freq_hz",
|
||||
"sim/cockpit/radios/com2_freq_hz",
|
||||
"sim/cockpit/radios/nav1_freq_hz"):
|
||||
val = v * mult
|
||||
ctx[label] = val
|
||||
lines.append(f"{label}: {val:.3f} {unit}".strip())
|
||||
else:
|
||||
val = v * mult
|
||||
ctx[label] = val
|
||||
unit_s = (" " + unit) if unit else ""
|
||||
lines.append(f"{label}: {val:.1f}{unit_s}")
|
||||
|
||||
tail = self._read_string(self._tailnum_ref)
|
||||
if tail:
|
||||
tail = tail.strip()
|
||||
ctx["Callsign"] = tail
|
||||
lines.insert(0, f"Callsign: {tail}")
|
||||
|
||||
# Sluzba ATC, na ktora jestes nastrojony
|
||||
fac1 = self._get_tuned_facility(self._tuned1_ref)
|
||||
fac2 = self._get_tuned_facility(self._tuned2_ref)
|
||||
com1 = ctx.get("COM1")
|
||||
com2 = ctx.get("COM2")
|
||||
tuned = None
|
||||
if fac1 and fac1 != "None":
|
||||
tuned = (fac1, com1, 1)
|
||||
elif fac2 and fac2 != "None":
|
||||
tuned = (fac2, com2, 2)
|
||||
if tuned:
|
||||
name, freq, num = tuned
|
||||
freq_s = f"{freq:.3f} MHz" if freq is not None else "? MHz"
|
||||
ctx["TunedFacility"] = name
|
||||
lines.append(f"Tuned facility: {name} ({freq_s} on COM{num})")
|
||||
else:
|
||||
ctx["TunedFacility"] = "None"
|
||||
lines.append("Tuned facility: None (no active ATC service on COM1/COM2)")
|
||||
|
||||
# Ruch lotniczy w poblizu
|
||||
traffic_lines = self._collect_traffic()
|
||||
ctx["Traffic"] = traffic_lines
|
||||
if traffic_lines:
|
||||
lines.append("Nearby traffic:")
|
||||
lines.extend(traffic_lines)
|
||||
else:
|
||||
lines.append("Nearby traffic: none detected")
|
||||
|
||||
return ctx, "\n".join(lines)
|
||||
|
||||
def _get_tuned_facility(self, ref):
|
||||
if ref is None:
|
||||
return None
|
||||
try:
|
||||
code = xp.getDatai(ref)
|
||||
return FACILITY_NAMES.get(code, f"Unknown({code})")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_traffic_ref(self, idx, field):
|
||||
"""Zwraca (i cachuje) dataref dla samolotu idx (1-based) i pola field."""
|
||||
key = (idx, field)
|
||||
if key not in self._traffic_refs:
|
||||
self._traffic_refs[key] = xp.findDataRef(
|
||||
f"sim/multiplayer/position/plane{idx}_{field}")
|
||||
return self._traffic_refs.get(key)
|
||||
|
||||
def _collect_traffic(self):
|
||||
"""Zbiera liste pobliskiego ruchu. WYWOLYWAC TYLKO na glownym watku."""
|
||||
out = []
|
||||
# pozycja wlasna (lat/lon/elevacja)
|
||||
lat = self._get_dataref("sim/flightmodel/position/latitude", is_float=True)
|
||||
lon = self._get_dataref("sim/flightmodel/position/longitude", is_float=True)
|
||||
if lat is None or lon is None:
|
||||
return out
|
||||
my_el = 0.0
|
||||
try:
|
||||
my_el = xp.getDatad(self._elevation_ref) or 0.0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import math
|
||||
for i in range(1, NUM_TRAFFIC_PLANES + 1):
|
||||
lat_r = self._get_traffic_ref(i, "lat")
|
||||
lon_r = self._get_traffic_ref(i, "lon")
|
||||
el_r = self._get_traffic_ref(i, "el")
|
||||
if lat_r is None or lon_r is None:
|
||||
continue
|
||||
try:
|
||||
tlat = xp.getDatad(lat_r)
|
||||
tlon = xp.getDatad(lon_r)
|
||||
except Exception:
|
||||
continue
|
||||
# 0,0 oznacza zwykle brak samolotu
|
||||
if tlat == 0.0 and tlon == 0.0:
|
||||
continue
|
||||
tel = 0.0
|
||||
if el_r is not None:
|
||||
try:
|
||||
tel = xp.getDatad(el_r)
|
||||
except Exception:
|
||||
tel = 0.0
|
||||
|
||||
# przyblizony namiar i dystans (kula)
|
||||
dlat = math.radians(tlat - lat)
|
||||
dlon = math.radians(tlon - lon)
|
||||
a = math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat)) * math.cos(math.radians(tlat)) * math.sin(dlon / 2) ** 2
|
||||
dist_m = 2 * 6371000.0 * math.asin(min(1.0, math.sqrt(a)))
|
||||
dist_nm = dist_m / 1852.0
|
||||
|
||||
# bearing (true)
|
||||
y = math.sin(dlon) * math.cos(math.radians(tlat))
|
||||
x = math.cos(math.radians(lat)) * math.sin(math.radians(tlat)) - \
|
||||
math.sin(math.radians(lat)) * math.cos(math.radians(tlat)) * math.cos(dlon)
|
||||
brg = (math.degrees(math.atan2(y, x)) + 360.0) % 360.0
|
||||
|
||||
# wzgledna wysokosc (feet)
|
||||
rel_alt_ft = (tel - my_el) * 3.28084
|
||||
|
||||
# relatywna pozycja zegarowa (12-godzinna) dla ATC
|
||||
clock = int(round((brg / 30.0) % 12)) or 12
|
||||
|
||||
out.append(
|
||||
f" {dist_nm:.1f} nm, {clock}:00, alt {rel_alt_ft:+.0f} ft (brg {brg:.0f})"
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
# ---------------------------- Komunikacja z LLM ----------------------------
|
||||
def _resolve_model(self):
|
||||
if self._model_cache:
|
||||
return self._model_cache
|
||||
if self.config.get("model"):
|
||||
self._model_cache = self.config["model"]
|
||||
return self._model_cache
|
||||
try:
|
||||
r = requests.get(self.config["base_url"].rstrip("/") + "/v1/models", timeout=5)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
models = data.get("data") or []
|
||||
if models:
|
||||
self._model_cache = models[0].get("id")
|
||||
return self._model_cache
|
||||
except Exception as e:
|
||||
xp.log(f"[XSayATC] Nie mozna pobrac modelu: {e}")
|
||||
return None
|
||||
|
||||
def _build_messages(self, user_text, flight_ctx_text):
|
||||
"""Buduje liste messages. flight_ctx_text musi byc przekazany (bez odczytu datarefow)."""
|
||||
msgs = []
|
||||
sp = self.config.get("system_prompt", "")
|
||||
if self.config.get("no_think", True) and sp and "/no_think" not in sp:
|
||||
sp = sp + "\n/no_think"
|
||||
msgs.append({"role": "system", "content": sp})
|
||||
|
||||
if self.config.get("include_flight_context", True) and flight_ctx_text:
|
||||
msgs.append({"role": "system", "content": "Pilot's current flight context:\n" + flight_ctx_text})
|
||||
|
||||
for m in self.messages[-12:]:
|
||||
msgs.append({"role": m["role"], "content": m["text"]})
|
||||
|
||||
msgs.append({"role": "user", "content": user_text})
|
||||
return msgs
|
||||
|
||||
def _request(self, messages):
|
||||
url = self.config["base_url"].rstrip("/") + "/v1/chat/completions"
|
||||
model = self._resolve_model() or self.config.get("model") or "model"
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": self.config.get("temperature", 0.7),
|
||||
"max_tokens": self.config.get("max_tokens", 512),
|
||||
"stream": True,
|
||||
}
|
||||
return requests.post(url, json=payload, stream=True, timeout=(10, 120))
|
||||
|
||||
def _run_stream(self, user_text, flight_ctx_text):
|
||||
"""Watek roboczy: TYLKO siec i aktualizacja self.messages. Bez xp.getData*."""
|
||||
self.streaming = True
|
||||
self.status = "ATC mysli..."
|
||||
|
||||
# Historia do UI
|
||||
self.messages.append({"role": "user", "text": user_text})
|
||||
self.messages.append({"role": "assistant", "text": ""})
|
||||
|
||||
try:
|
||||
messages = self._build_messages(user_text, flight_ctx_text)
|
||||
resp = self._request(messages)
|
||||
resp.raise_for_status()
|
||||
|
||||
buf = ""
|
||||
for raw in resp.iter_lines(decode_unicode=True):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
if not raw or not raw.startswith("data:"):
|
||||
continue
|
||||
data = raw[len("data:"):].strip()
|
||||
if data == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except Exception:
|
||||
continue
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
buf += content
|
||||
self.messages[-1]["text"] = buf
|
||||
|
||||
if self.messages[-1]["text"] == "":
|
||||
self.messages[-1]["text"] = "(brak odpowiedzi)"
|
||||
self.status = "Gotowy."
|
||||
except Exception as e:
|
||||
xp.log(f"[XSayATC] Blad zapytania: {e}")
|
||||
self.status = f"Blad: {e}"
|
||||
if self.messages and self.messages[-1]["role"] == "assistant":
|
||||
if not self.messages[-1]["text"]:
|
||||
self.messages[-1]["text"] = f"(blad: {e})"
|
||||
finally:
|
||||
self.streaming = False
|
||||
|
||||
def send(self, text):
|
||||
"""Wywolywane na glownym watku (przycisk / PTT). Zbiera kontekst tu, bo tu wolno."""
|
||||
text = (text or "").strip()
|
||||
if not text or self.streaming:
|
||||
return
|
||||
|
||||
# Zbierz kontekst lotu NA GLOWNYM WATKU
|
||||
flight_ctx_text = None
|
||||
if self.config.get("include_flight_context", True):
|
||||
try:
|
||||
_ctx, flight_ctx_text = self._collect_context()
|
||||
except Exception as e:
|
||||
xp.log(f"[XSayATC] Blad zbierania kontekstu: {e}")
|
||||
flight_ctx_text = None
|
||||
|
||||
self._stop_event.clear()
|
||||
self._worker = threading.Thread(
|
||||
target=self._run_stream, args=(text, flight_ctx_text), daemon=True)
|
||||
self._worker.start()
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
|
||||
# ---------------------------- VR ----------------------------
|
||||
def _vr_flight_loop(self, lastCall, elapsed, counter, refCon):
|
||||
"""Co 1s sprawdza stan VR i ustawia tryb pozycjonowania okien (glowny watek)."""
|
||||
vr = self._get_vr_enabled()
|
||||
if vr != self._last_vr:
|
||||
self._last_vr = vr
|
||||
self._apply_vr_mode(vr)
|
||||
return 1.0
|
||||
|
||||
def _get_vr_enabled(self):
|
||||
if self._vr_ref is None:
|
||||
return 0
|
||||
try:
|
||||
return xp.getDatai(self._vr_ref)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def _apply_vr_mode(self, vr):
|
||||
for win in (self._imgui_window, self._config_window):
|
||||
if win is None or win.windowID is None:
|
||||
continue
|
||||
try:
|
||||
if vr:
|
||||
# Okno w przestrzeni VR (3D), widoczne i interaktywne w headsetcie
|
||||
xp.setWindowPositioningMode(win.windowID, xp.WindowVR, -1)
|
||||
xp.setWindowGeometryVR(win.windowID, 800, 900)
|
||||
else:
|
||||
xp.setWindowPositioningMode(win.windowID, xp.WindowPositionFree, -1)
|
||||
except Exception as e:
|
||||
xp.log(f"[XSayATC] Blad ustawiania VR: {e}")
|
||||
|
||||
# ---------------------------- Okno ImGui ----------------------------
|
||||
def _draw(self, windowID, refCon):
|
||||
imgui.text("XSayATC - lokalny ATC")
|
||||
imgui.separator()
|
||||
|
||||
# Obszar czatu (scrollowalny)
|
||||
avail_h = imgui.get_content_region_available()[1]
|
||||
chat_h = max(60.0, avail_h - 130.0)
|
||||
with imgui.begin_child("chat", 0, chat_h, border=True):
|
||||
for m in self.messages:
|
||||
if m["role"] == "user":
|
||||
imgui.text_colored("Ty: " + m["text"], 0.5, 0.8, 1.0, 1.0)
|
||||
elif m["role"] == "assistant":
|
||||
imgui.text_wrapped("ATC: " + m["text"])
|
||||
if self.streaming:
|
||||
imgui.text_colored("...", 0.9, 0.9, 0.3, 1.0)
|
||||
imgui.set_scroll_here_y(1.0)
|
||||
|
||||
# Pole tekstowe + przycisk PTT
|
||||
changed, self.pending_text = imgui.input_text(
|
||||
"##msg", self.pending_text, 512,
|
||||
flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE
|
||||
)
|
||||
|
||||
imgui.same_line()
|
||||
if imgui.button("Wyslij (PTT)") or (changed and self.pending_text.strip()):
|
||||
self.send(self.pending_text)
|
||||
self.pending_text = ""
|
||||
|
||||
imgui.same_line()
|
||||
if imgui.button("Stop"):
|
||||
self.stop()
|
||||
|
||||
imgui.text(self.status)
|
||||
|
||||
imgui.separator()
|
||||
if imgui.button("Konfiguracja"):
|
||||
self._open_config_window()
|
||||
|
||||
def _draw_config(self, windowID, refCon):
|
||||
imgui.text("Konfiguracja XSayATC")
|
||||
imgui.separator()
|
||||
|
||||
_, self.config["base_url"] = imgui.input_text(
|
||||
"Base URL", self.config["base_url"], 256)
|
||||
_, self.config["model"] = imgui.input_text(
|
||||
"Model (puste=auto)", self.config["model"], 512)
|
||||
_, self.config["temperature"] = imgui.slider_float(
|
||||
"Temperature", self.config["temperature"], 0.0, 2.0, "%.2f")
|
||||
_, self.config["max_tokens"] = imgui.slider_int(
|
||||
"Max tokens", self.config["max_tokens"], 64, 4096)
|
||||
_, self.config["no_think"] = imgui.checkbox(
|
||||
"Tryb /no_think (Qwen3)", self.config["no_think"])
|
||||
_, self.config["include_flight_context"] = imgui.checkbox(
|
||||
"Dolacz kontekst lotu", self.config["include_flight_context"])
|
||||
|
||||
imgui.separator()
|
||||
imgui.text("System prompt (instrukcje ATC):")
|
||||
_, self.config["system_prompt"] = imgui.input_text_multiline(
|
||||
"##sysprompt", self.config["system_prompt"], 4096,
|
||||
width=0, height=120)
|
||||
|
||||
imgui.separator()
|
||||
if imgui.button("Zapisz"):
|
||||
self._save_config()
|
||||
self._model_cache = None
|
||||
self.status = "Konfiguracja zapisana."
|
||||
|
||||
# pokaz kontekst lotu na zywo
|
||||
imgui.separator()
|
||||
imgui.text("Kontekst lotu (podglad):")
|
||||
try:
|
||||
_, ctx_text = self._collect_context()
|
||||
imgui.text_wrapped(ctx_text)
|
||||
except Exception as e:
|
||||
imgui.text(f"(blad: {e})")
|
||||
|
||||
# ---------------------------- Cykl zycia pluginu ----------------------------
|
||||
def XPluginStart(self):
|
||||
self._ensure_datarefs()
|
||||
|
||||
# Komenda PTT (przypisz klawisz w Settings > Keyboard)
|
||||
self._cmd = xp.createCommand("xsayatc/ptt", "XSayATC push-to-talk")
|
||||
xp.registerCommandHandler(self._cmd, self._ptt_handler, 1, None)
|
||||
|
||||
# Menu
|
||||
self._menu = xp.createMenu("XSayATC", None, 0, self._menu_handler, None)
|
||||
self._menu_open_idx = xp.appendMenuItem(self._menu, "Pokaz okno", None)
|
||||
self._menu_cfg_idx = xp.appendMenuItem(self._menu, "Konfiguracja", None)
|
||||
|
||||
# Okno glowne
|
||||
self._imgui_window = ImguiWindow(
|
||||
left=100, top=400, right=600, bottom=100,
|
||||
visible=1, draw=self._draw, refCon=None,
|
||||
decoration=1, layer=1)
|
||||
self._imgui_window.setTitle("XSayATC")
|
||||
|
||||
# Okno konfiguracji (ukryte domyslnie)
|
||||
self._config_window = ImguiWindow(
|
||||
left=150, top=500, right=700, bottom=120,
|
||||
visible=0, draw=self._draw_config, refCon=None,
|
||||
decoration=1, layer=1)
|
||||
self._config_window.setTitle("XSayATC - Konfiguracja")
|
||||
|
||||
# Ustaw limity zmiany rozmiaru
|
||||
for win in (self._imgui_window, self._config_window):
|
||||
try:
|
||||
xp.setWindowResizingLimits(win.windowID, 200, 100, 2000, 2000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Flight loop do detekcji VR
|
||||
xp.registerFlightLoopCallback(self._vr_flight_loop, 1.0, None)
|
||||
self._last_vr = self._get_vr_enabled()
|
||||
self._apply_vr_mode(self._last_vr)
|
||||
|
||||
return "XSayATC", "pali112.xsayatc", "Lokalny asystent ATC (llama.cpp)"
|
||||
|
||||
def XPluginStop(self):
|
||||
self.stop()
|
||||
try:
|
||||
xp.unregisterFlightLoopCallback(self._vr_flight_loop, None)
|
||||
except Exception:
|
||||
pass
|
||||
for win in (self._config_window, self._imgui_window):
|
||||
if win is not None:
|
||||
try:
|
||||
win.delete()
|
||||
except Exception:
|
||||
pass
|
||||
self._config_window = None
|
||||
self._imgui_window = None
|
||||
|
||||
def XPluginEnable(self):
|
||||
return 1
|
||||
|
||||
def XPluginDisable(self):
|
||||
pass
|
||||
|
||||
def XPluginReceiveMessage(self, inFromWho, inMessage, inParam):
|
||||
pass
|
||||
|
||||
# ---------------------------- Handlery ----------------------------
|
||||
def _ptt_handler(self, cmdRef, phase, refCon):
|
||||
if phase == xp.CommandBegin:
|
||||
text = self.pending_text.strip()
|
||||
if text:
|
||||
self.send(text)
|
||||
self.pending_text = ""
|
||||
return 0
|
||||
return 1
|
||||
|
||||
def _menu_handler(self, menuRef, itemRef):
|
||||
if itemRef == self._menu_open_idx:
|
||||
self._toggle_main_window()
|
||||
elif itemRef == self._menu_cfg_idx:
|
||||
self._toggle_config_window()
|
||||
|
||||
def _toggle_main_window(self):
|
||||
if self._imgui_window is not None and self._imgui_window.windowID is not None:
|
||||
cur = xp.getWindowIsVisible(self._imgui_window.windowID)
|
||||
xp.setWindowIsVisible(self._imgui_window.windowID, 0 if cur else 1)
|
||||
|
||||
def _open_config_window(self):
|
||||
if self._config_window is not None and self._config_window.windowID is not None:
|
||||
xp.setWindowIsVisible(self._config_window.windowID, 1)
|
||||
xp.bringWindowToFront(self._config_window.windowID)
|
||||
|
||||
def _toggle_config_window(self):
|
||||
if self._config_window is not None and self._config_window.windowID is not None:
|
||||
cur = xp.getWindowIsVisible(self._config_window.windowID)
|
||||
xp.setWindowIsVisible(self._config_window.windowID, 0 if cur else 1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Punkt wejscia XPPython3
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_plugin = XSayATC()
|
||||
|
||||
|
||||
class PythonInterface:
|
||||
def XPluginStart(self):
|
||||
return _plugin.XPluginStart()
|
||||
|
||||
def XPluginStop(self):
|
||||
_plugin.XPluginStop()
|
||||
|
||||
def XPluginEnable(self):
|
||||
return _plugin.XPluginEnable()
|
||||
|
||||
def XPluginDisable(self):
|
||||
_plugin.XPluginDisable()
|
||||
|
||||
def XPluginReceiveMessage(self, inFromWho, inMessage, inParam):
|
||||
_plugin.XPluginReceiveMessage(inFromWho, inMessage, inParam)
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"base_url": "http://localhost:8081",
|
||||
"model": "",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 512,
|
||||
"system_prompt": "You are a concise, professional air traffic controller in X-Plane 12. Give short, clear, realistic ATC instructions. Use standard phraseology. You have access to the pilot's current flight context (position, altitude, speed, heading, transponder, radio frequencies, nearest airport, weather) and to nearby traffic. IMPORTANT: adopt the role of the facility the pilot is currently tuned to (the 'Tuned facility' field): Delivery/Clearance gives IFR/VFR clearances and squawk codes; Ground issues taxi instructions; Tower controls takeoff, landing and the active runway; Tracon/Approach handles arrivals, departures and vectors; Center handles enroute. If 'Tuned facility' is 'None', act as a generic ATC using the active COM frequency. Use the nearby traffic list when issuing traffic advisories (e.g. bearing in clock positions and distance). Respond with ATC instructions only, no extra commentary.",
|
||||
"include_flight_context": true,
|
||||
"no_think": true,
|
||||
"window_always_on_top": true
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# XSayATC
|
||||
|
||||
Lokalny asystent ATC dla X-Plane 12, podłączony do własnego serwera **llama.cpp**.
|
||||
|
||||
Wtyczka pobiera kontekst lotu z datarefów (pozycja, wysokość, prędkość, heading,
|
||||
squawk, częstotliwości, służba ATC, ruch w pobliżu), wysyła Twoją wiadomość do
|
||||
lokalnego modelu LLM i streamuje odpowiedź ATC do okna widocznego **także w VR**.
|
||||
|
||||
## Funkcje
|
||||
|
||||
- **Tekstowe push-to-talk** — wpisujesz w pole i wysyłasz (przycisk, Enter lub komenda `xsayatc/ptt`).
|
||||
- **Streaming odpowiedzi** na żywo w oknie.
|
||||
- **Okno widoczne w VR** — automatycznie przełącza się między trybem monitora a `WindowVR`.
|
||||
- **Kontekst lotu** doklejany do promptu z datarefów X-Plane.
|
||||
- **Wykrywanie służby ATC** (`sim/atc/com1_tuned_facility`) — model przyjmuje rolę
|
||||
Delivery / Ground / Tower / Tracon / Center, w zależności na co jesteś nastrojony.
|
||||
- **Wykrywanie ruchu** — lista pobliskiego ruchu (AI/multiplayer) z namiarem
|
||||
zegarowym i dystansem.
|
||||
- **Konfigurowalny GUI** — adres serwera, model, temperatura, max tokenów, system prompt.
|
||||
|
||||
## Wymagania
|
||||
|
||||
- [X-Plane 12](https://www.x-plane.com/)
|
||||
- [XPPython3](https://xppython3.readthedocs.io/) v4.x (dostarcza własny Python 3.12 + `imgui` + `requests`)
|
||||
- działający serwer **llama.cpp** z endpointem OpenAI-compat (`/v1/chat/completions`)
|
||||
|
||||
## Instalacja
|
||||
|
||||
1. Zainstaluj XPPython3 do `X-Plane 12/Resources/plugins/XPPython3/`.
|
||||
2. Skopiuj zawartość katalogu `PythonPlugins/` z tego repozytorium do:
|
||||
```
|
||||
X-Plane 12/Resources/plugins/PythonPlugins/
|
||||
```
|
||||
3. (Opcjonalnie) skopiuj `config.example.json` jako `config.json` i dopasuj ustawienia.
|
||||
4. Uruchom X-Plane. W menu `Plugins → XSayATC` znajdziesz okno i konfigurację.
|
||||
|
||||
## Konfiguracja
|
||||
|
||||
Ustawienia trzymane są w `config.json` obok pliku wtyczki. Można je edytować
|
||||
w GUI (przycisk **Konfiguracja**) lub ręcznie.
|
||||
|
||||
Kluczowe opcje:
|
||||
|
||||
| Klucz | Opis |
|
||||
|---|---|
|
||||
| `base_url` | Adres serwera llama.cpp (np. `http://localhost:8081`) |
|
||||
| `model` | Nazwa modelu; puste = auto-wykrycie z `/v1/models` |
|
||||
| `temperature` | Temperatura próbkowania |
|
||||
| `max_tokens` | Maksymalna liczba tokenów odpowiedzi |
|
||||
| `system_prompt` | Instrukcje / osobowość ATC |
|
||||
| `include_flight_context` | Czy doklejać kontekst lotu do promptu |
|
||||
| `no_think` | Dodaje token `/no_think` (wyłącza myślenie w modelach Qwen3) |
|
||||
| `window_always_on_top` | Zarezerwowane |
|
||||
|
||||
## Push-to-talk
|
||||
|
||||
Wtyczka rejestruje komendę `xsayatc/ptt`. Przypisz jej klawisz w
|
||||
**Settings → Keyboard → Plugins → XSayATC → push-to-talk**.
|
||||
|
||||
## Uwagi techniczne
|
||||
|
||||
- **Bezpieczeństwo wątków:** odczyty datarefów (`xp.getData*`) muszą działać na
|
||||
głównym wątku X-Plane. Wtyczka zbiera kontekst w `send()` (główny wątek),
|
||||
a wątek roboczy wykonuje wyłącznie sieć (`requests`) i aktualizuje historię.
|
||||
- **Modele Qwen3 (reasoning):** token `/no_think` wyłącza „myślenie". Flagi
|
||||
`thinking:false` / `enable_thinking:false` nie działają w niektórych buildach
|
||||
llama.cpp — stąd domyślny `/no_think`.
|
||||
- **Ruch lotniczy** odczytywany jest z `sim/multiplayer/position/planeN_*`
|
||||
(AI / multiplayer). Jeśli nie widzisz ruchu, dodaj samoloty AI w
|
||||
**Flight → AI Aircraft**.
|
||||
- **`tuned_facility`** działa dla lotnisk z zdefiniowanym ATC w X-Plane.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Głos wejściowy (Whisper / `whisper-cli`)
|
||||
- [ ] Głos wyjściowy (Piper)
|
||||
- [ ] Rozbudowane procedury ATC (do uzupełnienia przez użytkownika)
|
||||
|
||||
## Licencja
|
||||
|
||||
Do uzupełnienia.
|
||||
Reference in new issue
Block a user