XSayATC - lokalny asystent ATC dla X-Plane 12 (llama.cpp)

This commit is contained in:
pali112 committed 2026-10-20 15:17:01 +02:00
commit 36cf23232c
4 files changed
+757

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# Pliki konfiguracji lokalnej (ustawienia użytkownika)
PythonPlugins/config.json
# Pliki Pythona
__pycache__/
*.pyc
+660
View File
@@ -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)
+10
View File
@@ -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
}
+81
View File
@@ -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.