simconnect-mcp 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simconnect_mcp/__init__.py +10 -0
- simconnect_mcp/__main__.py +5 -0
- simconnect_mcp/connection.py +620 -0
- simconnect_mcp/data/__init__.py +1 -0
- simconnect_mcp/data/catalog.py +240 -0
- simconnect_mcp/data/hubhop.py +647 -0
- simconnect_mcp/data/pmdg_737.json +22835 -0
- simconnect_mcp/data/pmdg_777.json +20669 -0
- simconnect_mcp/data/simvar_catalog.py +162 -0
- simconnect_mcp/data/simvars_catalog.json +6538 -0
- simconnect_mcp/dispatch.py +427 -0
- simconnect_mcp/docs/best_practices.md +118 -0
- simconnect_mcp/docs/events.md +119 -0
- simconnect_mcp/docs/lvars.md +102 -0
- simconnect_mcp/docs/overview.md +65 -0
- simconnect_mcp/docs/pmdg_737.md +89 -0
- simconnect_mcp/docs/pmdg_777.md +492 -0
- simconnect_mcp/docs/rpn.md +150 -0
- simconnect_mcp/docs/simvars.md +124 -0
- simconnect_mcp/facilities.py +305 -0
- simconnect_mcp/pmdg.py +908 -0
- simconnect_mcp/pmdg_detect.py +194 -0
- simconnect_mcp/pmdg_ng3.py +907 -0
- simconnect_mcp/prompts/__init__.py +1 -0
- simconnect_mcp/prompts/templates.py +191 -0
- simconnect_mcp/resources/__init__.py +1 -0
- simconnect_mcp/resources/documentation.py +119 -0
- simconnect_mcp/resources/state.py +57 -0
- simconnect_mcp/server.py +264 -0
- simconnect_mcp/simvar_access.py +622 -0
- simconnect_mcp/tools/__init__.py +130 -0
- simconnect_mcp/tools/aircraft.py +164 -0
- simconnect_mcp/tools/connection_tools.py +59 -0
- simconnect_mcp/tools/events.py +541 -0
- simconnect_mcp/tools/facilities.py +444 -0
- simconnect_mcp/tools/flight.py +602 -0
- simconnect_mcp/tools/formatting.py +118 -0
- simconnect_mcp/tools/hubhop.py +294 -0
- simconnect_mcp/tools/lvars.py +897 -0
- simconnect_mcp/tools/models.py +482 -0
- simconnect_mcp/tools/pmdg.py +533 -0
- simconnect_mcp/tools/simvars.py +455 -0
- simconnect_mcp/tools/utilities.py +294 -0
- simconnect_mcp/vendor/MOBIFLIGHT_LICENSE +21 -0
- simconnect_mcp/vendor/__init__.py +6 -0
- simconnect_mcp/vendor/mobiflight_variable_requests.py +204 -0
- simconnect_mcp/vendor/simconnect_mobiflight.py +39 -0
- simconnect_mcp-1.0.0.dist-info/METADATA +464 -0
- simconnect_mcp-1.0.0.dist-info/RECORD +52 -0
- simconnect_mcp-1.0.0.dist-info/WHEEL +4 -0
- simconnect_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- simconnect_mcp-1.0.0.dist-info/licenses/LICENSE.txt +662 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""SimConnect MCP Server — MSFS add-on development companion."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("simconnect-mcp")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree without an install
|
|
8
|
+
__version__ = "0.0.0+unknown"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
"""SimConnectManager — singleton, thread-safe, lazy-connect wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import enum
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _decode_identity_value(raw: Any) -> str | None:
|
|
19
|
+
"""Normalize a raw TITLE/ATC_MODEL SimVar read to a stripped str.
|
|
20
|
+
|
|
21
|
+
The sim can hand back bytes, a plain str, or None depending on the code
|
|
22
|
+
path (native accessor vs. legacy AircraftRequests), so this is
|
|
23
|
+
defensive about all three.
|
|
24
|
+
"""
|
|
25
|
+
if raw is None:
|
|
26
|
+
return None
|
|
27
|
+
if isinstance(raw, bytes):
|
|
28
|
+
return raw.decode("ascii", errors="replace").strip()
|
|
29
|
+
return str(raw).strip()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConnectionState(enum.Enum):
|
|
33
|
+
DISCONNECTED = "disconnected"
|
|
34
|
+
CONNECTING = "connecting"
|
|
35
|
+
CONNECTED = "connected"
|
|
36
|
+
ERROR = "error"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SimConnectManager:
|
|
40
|
+
"""Singleton manager for the SimConnect connection."""
|
|
41
|
+
|
|
42
|
+
_instance: SimConnectManager | None = None
|
|
43
|
+
_lock = threading.Lock()
|
|
44
|
+
|
|
45
|
+
def __new__(cls) -> SimConnectManager:
|
|
46
|
+
if cls._instance is None:
|
|
47
|
+
with cls._lock:
|
|
48
|
+
if cls._instance is None:
|
|
49
|
+
cls._instance = super().__new__(cls)
|
|
50
|
+
cls._instance._initialized = False
|
|
51
|
+
return cls._instance
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
if self._initialized:
|
|
55
|
+
return
|
|
56
|
+
self._initialized = True
|
|
57
|
+
self._state = ConnectionState.DISCONNECTED
|
|
58
|
+
self._sim_lock = threading.Lock()
|
|
59
|
+
self.sm = None
|
|
60
|
+
self.aq = None
|
|
61
|
+
self.ae = None
|
|
62
|
+
self.fr = None
|
|
63
|
+
self.mobiflight = None
|
|
64
|
+
self.accessor = None # SimVarAccessor, created on connect
|
|
65
|
+
self._mobiflight_available = False
|
|
66
|
+
self.pmdg = None # PmdgDataManager (777), lazy-initialized
|
|
67
|
+
self.pmdg_ng3 = None # PmdgNG3DataManager (737), lazy-initialized
|
|
68
|
+
# (timestamp, title, model); see detect_aircraft_identity().
|
|
69
|
+
self._title_cache: tuple[float, str | None, str | None] | None = None
|
|
70
|
+
# (title, model, variant) from tools.pmdg's client-data-area probe.
|
|
71
|
+
# That probe is a real SimConnect round trip against two data areas
|
|
72
|
+
# (expensive relative to a SimVar read), so the result is cached --
|
|
73
|
+
# but the loaded aircraft CAN change mid-connection with no
|
|
74
|
+
# reconnect (confirmed against real usage: a user swapped aircraft
|
|
75
|
+
# mid-session), so this is keyed on the aircraft identity it was
|
|
76
|
+
# found under, not cached unconditionally for the connection's
|
|
77
|
+
# whole lifetime. See get_cached_pmdg_variant/set_cached_pmdg_variant
|
|
78
|
+
# below -- a cache hit requires the *current* identity to match.
|
|
79
|
+
self._pmdg_variant_cache: tuple[str | None, str | None, str] | None = None
|
|
80
|
+
# Facility lists (tools/facilities.py's _collect), keyed by
|
|
81
|
+
# FacilityKind.value. This dict is generic -- any kind string could
|
|
82
|
+
# be stored here -- but tools/facilities.py's _CACHEABLE_KINDS only
|
|
83
|
+
# ever populates "airport" in practice. Measured live: AIRPORT is
|
|
84
|
+
# genuinely the whole world (85,249 facilities, unrelated to the
|
|
85
|
+
# aircraft's position), but WAYPOINT/NDB/VOR are a "reality bubble"
|
|
86
|
+
# scoped to wherever the aircraft currently is (all within ~193nm
|
|
87
|
+
# of it) -- caching those would keep serving a stale position's
|
|
88
|
+
# facilities after a reposition or a flight, with no signal to the
|
|
89
|
+
# caller. See tools/facilities.py's module docstring for the full
|
|
90
|
+
# measurement and the policy this backs. Cleared on disconnect()
|
|
91
|
+
# like the two caches above.
|
|
92
|
+
self._facility_cache: dict[str, list[dict[str, Any]]] = {}
|
|
93
|
+
# Per-kind asyncio.Lock serializing facility collection. Without
|
|
94
|
+
# this, a second caller's collector.reset(kind) can wipe the buffer
|
|
95
|
+
# the first caller is still waiting to fill, handing both callers a
|
|
96
|
+
# torn result -- reset-then-subscribe is not atomic with the wait
|
|
97
|
+
# that follows it. Built lazily; see facility_lock() below. Not
|
|
98
|
+
# cleared on disconnect: an asyncio.Lock is not bound to a specific
|
|
99
|
+
# event loop at construction (Python 3.10+) and is always released
|
|
100
|
+
# by the code that acquires it, so reusing one across a reconnect
|
|
101
|
+
# is safe -- unlike _facility_cache, it is not connection-specific
|
|
102
|
+
# state that could go stale.
|
|
103
|
+
self._facility_locks: dict[str, asyncio.Lock] = {}
|
|
104
|
+
# Serializes msfs_list_lvars invocations end to end (register
|
|
105
|
+
# response handler -> send -> wait -> unregister). Same bug class
|
|
106
|
+
# as _facility_locks above, found the same way: the vendored
|
|
107
|
+
# MobiFlightVariableRequests fan-out (_deliver_response) delivers
|
|
108
|
+
# every WASM response-channel message to every currently
|
|
109
|
+
# registered handler with no per-call correlation, so two
|
|
110
|
+
# overlapping list_lvars calls would each receive the other's
|
|
111
|
+
# MF.LVars.List burst too -- inflating the raw pre-dedup count and
|
|
112
|
+
# able to trip the 1000-name truncation cap for a response nowhere
|
|
113
|
+
# near it. One lock, not a dict like facility_lock's: there is
|
|
114
|
+
# only one kind of listing here, unlike facilities' four kinds.
|
|
115
|
+
# Not cleared on disconnect for the same reason _facility_locks
|
|
116
|
+
# isn't -- see the comment above.
|
|
117
|
+
self._list_lvars_lock = asyncio.Lock()
|
|
118
|
+
# Serializes tools/flight.py's create_ai_object end to end (register
|
|
119
|
+
# -> AICreateSimulatedObject -> wait for ASSIGNED_OBJECT_ID ->
|
|
120
|
+
# discard). Same bug class as _list_lvars_lock above: create_ai_object
|
|
121
|
+
# correlates its reply against the single request id
|
|
122
|
+
# reserved_request_id() reserves for the "ai_object" key (ring=1, the
|
|
123
|
+
# default -- see that method's docstring), so two overlapping
|
|
124
|
+
# creations would both register a PendingRequest under that same id.
|
|
125
|
+
# RequestRegistry.register()'s plain dict assignment lets the second
|
|
126
|
+
# silently replace the first: whichever ASSIGNED_OBJECT_ID reply
|
|
127
|
+
# arrives would only ever reach the second call's waiter, leaving the
|
|
128
|
+
# first waiting on a reply that will never resolve -- reporting a
|
|
129
|
+
# real creation as object_id: None. One lock across the whole
|
|
130
|
+
# sequence rules this out. Not cleared on disconnect, for the same
|
|
131
|
+
# reason _list_lvars_lock isn't.
|
|
132
|
+
self._ai_object_lock = asyncio.Lock()
|
|
133
|
+
# Small per-key rings of SimConnect request IDs reserved once and
|
|
134
|
+
# reused for the whole connection, for the call sites that need a
|
|
135
|
+
# request ID but never register a PendingRequest with the
|
|
136
|
+
# dispatcher's RequestRegistry (facility subscriptions, AI object
|
|
137
|
+
# creation). See reserved_request_id() below for the why. MUST be
|
|
138
|
+
# cleared on disconnect alongside _facility_cache: a reconnect
|
|
139
|
+
# builds a fresh SimConnectDispatcher whose DATA_REQUEST_ID Enum
|
|
140
|
+
# restarts from scratch, so IDs carried over from the previous
|
|
141
|
+
# connection would collide with ones SimVarAccessor is about to
|
|
142
|
+
# allocate.
|
|
143
|
+
self._reserved_request_ids: dict[str, list[int]] = {}
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def state(self) -> ConnectionState:
|
|
147
|
+
return self._state
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def is_connected(self) -> bool:
|
|
151
|
+
return self._state == ConnectionState.CONNECTED
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def mobiflight_available(self) -> bool:
|
|
155
|
+
return self._mobiflight_available
|
|
156
|
+
|
|
157
|
+
def connect(self) -> dict[str, Any]:
|
|
158
|
+
"""Establish SimConnect connection. Returns status dict.
|
|
159
|
+
|
|
160
|
+
Attempts to use SimConnectDispatcher first, which owns the dispatch
|
|
161
|
+
loop (enabling SimVar exception correlation via `self.accessor`) and
|
|
162
|
+
is itself a drop-in subclass of the vendored SimConnectMobiFlight, so
|
|
163
|
+
client-data support for the MobiFlight WASM module is preserved.
|
|
164
|
+
Falls back to plain SimConnect if the dispatcher isn't available; in
|
|
165
|
+
that case `self.accessor` stays None.
|
|
166
|
+
"""
|
|
167
|
+
if self._state == ConnectionState.CONNECTED:
|
|
168
|
+
return {"status": "ok", "message": "Already connected"}
|
|
169
|
+
|
|
170
|
+
self._state = ConnectionState.CONNECTING
|
|
171
|
+
try:
|
|
172
|
+
from SimConnect import AircraftEvents, AircraftRequests
|
|
173
|
+
|
|
174
|
+
# Note: no _sim_lock here — connect/disconnect are only called
|
|
175
|
+
# from a single thread, and locking during init can deadlock
|
|
176
|
+
# because SimConnect's constructor starts a dispatch thread.
|
|
177
|
+
|
|
178
|
+
# Prefer SimConnectDispatcher -- owns the dispatch loop, which
|
|
179
|
+
# both enables SimVar exception correlation and keeps the
|
|
180
|
+
# library's print()ing branches out of the stdio stream.
|
|
181
|
+
try:
|
|
182
|
+
from simconnect_mcp.dispatch import SimConnectDispatcher
|
|
183
|
+
self.sm = SimConnectDispatcher()
|
|
184
|
+
logger.info("Using SimConnectDispatcher (WASM client-data enabled)")
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logger.info("SimConnectDispatcher unavailable (%s), falling back", e)
|
|
187
|
+
from SimConnect import SimConnect
|
|
188
|
+
self.sm = SimConnect()
|
|
189
|
+
|
|
190
|
+
self.aq = AircraftRequests(self.sm, _time=2000)
|
|
191
|
+
self.ae = AircraftEvents(self.sm)
|
|
192
|
+
|
|
193
|
+
# Generic SimVar access. Requires the dispatcher's request
|
|
194
|
+
# registry, so it is only available on the dispatcher path.
|
|
195
|
+
if hasattr(self.sm, "registry"):
|
|
196
|
+
from simconnect_mcp.simvar_access import SimVarAccessor
|
|
197
|
+
self.accessor = SimVarAccessor(self.sm)
|
|
198
|
+
else:
|
|
199
|
+
self.accessor = None
|
|
200
|
+
logger.warning(
|
|
201
|
+
"Plain SimConnect fallback: unit-aware SimVar access unavailable"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Try to initialize FacilitiesRequests
|
|
205
|
+
try:
|
|
206
|
+
from SimConnect import FacilitiesRequests
|
|
207
|
+
self.fr = FacilitiesRequests(self.sm)
|
|
208
|
+
except Exception:
|
|
209
|
+
self.fr = None
|
|
210
|
+
logger.info("FacilitiesRequests not available")
|
|
211
|
+
|
|
212
|
+
# Try MobiFlight variable requests (requires SimConnectMobiFlight + WASM module)
|
|
213
|
+
try:
|
|
214
|
+
from simconnect_mcp.vendor.mobiflight_variable_requests import (
|
|
215
|
+
MobiFlightVariableRequests,
|
|
216
|
+
)
|
|
217
|
+
self.mobiflight = MobiFlightVariableRequests(self.sm)
|
|
218
|
+
# Clear stale variable registrations from prior sessions —
|
|
219
|
+
# without this, the WASM module returns 0 for all reads.
|
|
220
|
+
self.mobiflight.clear_sim_variables()
|
|
221
|
+
self._mobiflight_available = True
|
|
222
|
+
logger.info("MobiFlight WASM variable requests initialized")
|
|
223
|
+
except Exception as e:
|
|
224
|
+
self._mobiflight_available = False
|
|
225
|
+
logger.info("MobiFlight variable requests not available: %s", e)
|
|
226
|
+
|
|
227
|
+
self._state = ConnectionState.CONNECTED
|
|
228
|
+
return {
|
|
229
|
+
"status": "ok",
|
|
230
|
+
"message": "Connected to MSFS",
|
|
231
|
+
"mobiflight": self._mobiflight_available,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
except ConnectionError as e:
|
|
235
|
+
self._state = ConnectionState.ERROR
|
|
236
|
+
return {
|
|
237
|
+
"status": "error",
|
|
238
|
+
"error": "NOT_CONNECTED",
|
|
239
|
+
"message": f"Could not connect to MSFS: {e}",
|
|
240
|
+
"suggestion": "Ensure MSFS is running and SimConnect is accessible.",
|
|
241
|
+
}
|
|
242
|
+
except Exception as e:
|
|
243
|
+
self._state = ConnectionState.ERROR
|
|
244
|
+
return {
|
|
245
|
+
"status": "error",
|
|
246
|
+
"error": "NOT_CONNECTED",
|
|
247
|
+
"message": f"Connection failed: {e}",
|
|
248
|
+
"suggestion": "Ensure MSFS is running and SimConnect is accessible.",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
def disconnect(self) -> dict[str, Any]:
|
|
252
|
+
"""Close the SimConnect connection."""
|
|
253
|
+
if self._state == ConnectionState.DISCONNECTED:
|
|
254
|
+
return {"status": "ok", "message": "Already disconnected"}
|
|
255
|
+
try:
|
|
256
|
+
if self.sm is not None:
|
|
257
|
+
self.sm.exit()
|
|
258
|
+
except Exception as e:
|
|
259
|
+
logger.warning("Error during disconnect: %s", e)
|
|
260
|
+
finally:
|
|
261
|
+
# Cleanup unregisters handlers on self.sm, so it must run first.
|
|
262
|
+
if self.pmdg is not None:
|
|
263
|
+
self.pmdg.cleanup()
|
|
264
|
+
self.pmdg = None
|
|
265
|
+
if self.pmdg_ng3 is not None:
|
|
266
|
+
self.pmdg_ng3.cleanup()
|
|
267
|
+
self.pmdg_ng3 = None
|
|
268
|
+
self.sm = None
|
|
269
|
+
self.aq = None
|
|
270
|
+
self.ae = None
|
|
271
|
+
self.fr = None
|
|
272
|
+
self.mobiflight = None
|
|
273
|
+
self.accessor = None
|
|
274
|
+
self._mobiflight_available = False
|
|
275
|
+
self._title_cache = None
|
|
276
|
+
self._pmdg_variant_cache = None
|
|
277
|
+
self._facility_cache = {}
|
|
278
|
+
# Connection-scoped: the next connect() builds a new dispatcher
|
|
279
|
+
# with a fresh DATA_REQUEST_ID Enum, so these numbers stop being
|
|
280
|
+
# reserved for anything. See reserved_request_id().
|
|
281
|
+
self._reserved_request_ids = {}
|
|
282
|
+
self._state = ConnectionState.DISCONNECTED
|
|
283
|
+
return {"status": "ok", "message": "Disconnected"}
|
|
284
|
+
|
|
285
|
+
def ensure_connected(self) -> dict[str, Any] | None:
|
|
286
|
+
"""Lazy-connect. Returns error dict if connection fails, None on success."""
|
|
287
|
+
if self._state == ConnectionState.CONNECTED:
|
|
288
|
+
return None
|
|
289
|
+
result = self.connect()
|
|
290
|
+
if result["status"] == "error":
|
|
291
|
+
return result
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
async def run_sync(self, fn: Callable[..., T], *args: Any) -> T:
|
|
295
|
+
"""Run a blocking SimConnect call in an executor with lock."""
|
|
296
|
+
loop = asyncio.get_running_loop()
|
|
297
|
+
|
|
298
|
+
def _locked_call() -> T:
|
|
299
|
+
with self._sim_lock:
|
|
300
|
+
return fn(*args)
|
|
301
|
+
|
|
302
|
+
return await loop.run_in_executor(None, _locked_call)
|
|
303
|
+
|
|
304
|
+
TITLE_CACHE_TTL = 5.0
|
|
305
|
+
|
|
306
|
+
async def detect_aircraft_identity(self) -> tuple[str | None, str | None]:
|
|
307
|
+
"""Read TITLE and ATC_MODEL for aircraft detection.
|
|
308
|
+
|
|
309
|
+
Four call sites used to read TITLE directly on the event loop with no
|
|
310
|
+
lock. This routes through run_sync and caches briefly, since it is
|
|
311
|
+
consulted on most catalog operations.
|
|
312
|
+
|
|
313
|
+
ATC_MODEL is read alongside TITLE because some add-ons carry their
|
|
314
|
+
vendor branding there instead: a PMDG 777F's TITLE is the terse
|
|
315
|
+
"777F", which matches no catalog's title_pattern on its own. The two
|
|
316
|
+
reads are independent -- one failing (e.g. an aircraft that doesn't
|
|
317
|
+
expose ATC_MODEL) must not blank out a TITLE that succeeded.
|
|
318
|
+
"""
|
|
319
|
+
if not self.is_connected or self.accessor is None:
|
|
320
|
+
return None, None
|
|
321
|
+
|
|
322
|
+
now = time.monotonic()
|
|
323
|
+
if self._title_cache is not None and (now - self._title_cache[0]) < self.TITLE_CACHE_TTL:
|
|
324
|
+
return self._title_cache[1], self._title_cache[2]
|
|
325
|
+
|
|
326
|
+
def _read() -> tuple[Any, Any]:
|
|
327
|
+
try:
|
|
328
|
+
raw_title = self.accessor.read("TITLE")
|
|
329
|
+
except Exception:
|
|
330
|
+
logger.debug("Could not read TITLE", exc_info=True)
|
|
331
|
+
raw_title = None
|
|
332
|
+
try:
|
|
333
|
+
raw_model = self.accessor.read("ATC_MODEL")
|
|
334
|
+
except Exception:
|
|
335
|
+
logger.debug("Could not read ATC_MODEL", exc_info=True)
|
|
336
|
+
raw_model = None
|
|
337
|
+
return raw_title, raw_model
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
raw_title, raw_model = await self.run_sync(_read)
|
|
341
|
+
except Exception:
|
|
342
|
+
logger.debug("Could not read aircraft identity", exc_info=True)
|
|
343
|
+
return None, None
|
|
344
|
+
|
|
345
|
+
title = _decode_identity_value(raw_title)
|
|
346
|
+
model = _decode_identity_value(raw_model)
|
|
347
|
+
|
|
348
|
+
self._title_cache = (now, title, model)
|
|
349
|
+
return title, model
|
|
350
|
+
|
|
351
|
+
async def detect_aircraft_title(self) -> str | None:
|
|
352
|
+
"""Read the TITLE SimVar for aircraft detection.
|
|
353
|
+
|
|
354
|
+
Thin wrapper over detect_aircraft_identity() for the common case
|
|
355
|
+
where only the title is needed.
|
|
356
|
+
"""
|
|
357
|
+
title, _ = await self.detect_aircraft_identity()
|
|
358
|
+
return title
|
|
359
|
+
|
|
360
|
+
def get_cached_pmdg_variant(self, title: str | None, model: str | None) -> str | None:
|
|
361
|
+
"""Return the PMDG variant probed for this exact aircraft identity.
|
|
362
|
+
|
|
363
|
+
None if no probe has run yet, none responded, or -- critically --
|
|
364
|
+
the cached result was found under a *different* (title, model): the
|
|
365
|
+
loaded aircraft can change mid-connection with no reconnect, so a
|
|
366
|
+
cache that ignored identity would keep answering with a previous
|
|
367
|
+
aircraft's variant, mislabelled "probed" for the current one. This
|
|
368
|
+
is exact rather than time-based: `title`/`model` is the same signal
|
|
369
|
+
detect_aircraft_identity() uses to notice a swap at all, so a
|
|
370
|
+
mismatch here means the aircraft has definitely changed, not just
|
|
371
|
+
that some arbitrary TTL elapsed. Cleared on disconnect() regardless.
|
|
372
|
+
"""
|
|
373
|
+
if self._pmdg_variant_cache is None:
|
|
374
|
+
return None
|
|
375
|
+
cached_title, cached_model, variant = self._pmdg_variant_cache
|
|
376
|
+
if (cached_title, cached_model) != (title, model):
|
|
377
|
+
return None
|
|
378
|
+
return variant
|
|
379
|
+
|
|
380
|
+
def set_cached_pmdg_variant(self, title: str | None, model: str | None, variant: str) -> None:
|
|
381
|
+
"""Record a successful probe result, keyed to the aircraft identity
|
|
382
|
+
it was found under."""
|
|
383
|
+
self._pmdg_variant_cache = (title, model, variant)
|
|
384
|
+
|
|
385
|
+
def get_cached_facilities(self, kind: str) -> list[dict[str, Any]] | None:
|
|
386
|
+
"""Return the cached facility list for one kind, if this kind is
|
|
387
|
+
ever cached.
|
|
388
|
+
|
|
389
|
+
None on a cache miss: never collected yet this connection, cleared
|
|
390
|
+
by a disconnect(), or (in practice, for "waypoint"/"ndb"/"vor")
|
|
391
|
+
deliberately never written by tools/facilities.py's
|
|
392
|
+
_CACHEABLE_KINDS -- see that module's docstring for why those three
|
|
393
|
+
must not be cached. This method itself has no opinion on which
|
|
394
|
+
kinds qualify; it just stores whatever the caller gives it. `kind`
|
|
395
|
+
is a FacilityKind.value string ("airport"/"waypoint"/"ndb"/"vor")
|
|
396
|
+
rather than the enum itself, so this module has no need to import
|
|
397
|
+
simconnect_mcp.facilities.
|
|
398
|
+
"""
|
|
399
|
+
return self._facility_cache.get(kind)
|
|
400
|
+
|
|
401
|
+
def set_cached_facilities(self, kind: str, entries: list[dict[str, Any]]) -> None:
|
|
402
|
+
"""Record a completed facility collection for one kind.
|
|
403
|
+
|
|
404
|
+
Generic by design -- see get_cached_facilities. The decision of
|
|
405
|
+
which kinds this is safe to call for belongs to the caller
|
|
406
|
+
(tools/facilities.py's _CACHEABLE_KINDS), not to this method.
|
|
407
|
+
"""
|
|
408
|
+
self._facility_cache[kind] = entries
|
|
409
|
+
|
|
410
|
+
def facility_lock(self, kind: str) -> asyncio.Lock:
|
|
411
|
+
"""Per-kind lock serializing tools/facilities.py's collection step.
|
|
412
|
+
|
|
413
|
+
Lazily created and memoized per kind -- there are only ever four
|
|
414
|
+
(airport/waypoint/ndb/vor), so this dict never grows unbounded. The
|
|
415
|
+
plain get-then-set below needs no guarding lock of its own: this is
|
|
416
|
+
only ever called from coroutine code on the single asyncio
|
|
417
|
+
event-loop thread, with no `await` between the check and the set,
|
|
418
|
+
so two concurrent callers can never interleave inside it.
|
|
419
|
+
"""
|
|
420
|
+
lock = self._facility_locks.get(kind)
|
|
421
|
+
if lock is None:
|
|
422
|
+
lock = asyncio.Lock()
|
|
423
|
+
self._facility_locks[kind] = lock
|
|
424
|
+
return lock
|
|
425
|
+
|
|
426
|
+
def reserved_request_id(self, key: str, ring: int = 1) -> int:
|
|
427
|
+
"""Return the next of a small set of request IDs reserved for `key`.
|
|
428
|
+
|
|
429
|
+
SimConnect.new_request_id() rebuilds an Enum from every prior member
|
|
430
|
+
on *every* call and never reclaims one, so calling it per operation
|
|
431
|
+
makes cost grow with the cumulative number of operations ever
|
|
432
|
+
issued -- measured on real hardware at ~4.5ms per call after 600
|
|
433
|
+
allocations, ~31ms after 2000, unbounded over a long-running server.
|
|
434
|
+
That is precisely what RequestRegistry.acquire_request_id
|
|
435
|
+
(dispatch.py) was built to bound. tools/facilities.py's per-kind
|
|
436
|
+
subscriptions cannot use that pool on their own: FacilityCollector
|
|
437
|
+
correlates chunks itself (its own `_request_id` dict, not
|
|
438
|
+
RequestRegistry), so nothing ever calls register()/discard() for a
|
|
439
|
+
facility subscription and there would be nothing to return an ID to
|
|
440
|
+
the free-list. tools/flight.py's AI object creation DOES register a
|
|
441
|
+
PendingRequest and discard() it once done (with `recycle=False` --
|
|
442
|
+
see RequestRegistry.discard's docstring for why), but still reserves
|
|
443
|
+
here rather than calling acquire_request_id directly: the id must
|
|
444
|
+
stay stable across every create_ai_object call for the life of the
|
|
445
|
+
connection, not be handed back to the general pool the moment one
|
|
446
|
+
creation resolves.
|
|
447
|
+
|
|
448
|
+
The `ring` IDs for a key are allocated once, on first use, and then
|
|
449
|
+
rotated through: call N gets ids[N % ring]. `ring` is therefore the
|
|
450
|
+
allocation budget for that key for the whole connection, and the
|
|
451
|
+
rotation is what a caller that correlates on dwRequestID needs. A
|
|
452
|
+
facility subscription must NOT reuse the same ID it used last time:
|
|
453
|
+
UnsubscribeToFacilities does not retroactively cancel chunks
|
|
454
|
+
SimConnect already queued, so a late chunk from an abandoned
|
|
455
|
+
subscription would otherwise match the very next collection for the
|
|
456
|
+
same kind and silently complete it on a mix of old and new data
|
|
457
|
+
(see FacilityCollector.handle's request-id check, and
|
|
458
|
+
tools/facilities.py's module docstring). Rotating over `ring`
|
|
459
|
+
distinct IDs means a stale chunk has to outlive `ring - 1` whole
|
|
460
|
+
collections of its own kind before it can be mistaken for a current
|
|
461
|
+
one -- the dispatch thread drains SimConnect's queue every 2ms and
|
|
462
|
+
the shortest collection cycle is a 100ms poll interval, so even
|
|
463
|
+
ring=2 puts that far outside the plausible window. The default of 1
|
|
464
|
+
is for a key with no such overlap risk: AI object creation serializes
|
|
465
|
+
every call through SimConnectManager.ai_object_lock() end to end
|
|
466
|
+
(register through discard), so only one creation is ever waiting on
|
|
467
|
+
"ai_object"'s reserved id at a time -- there is no previous
|
|
468
|
+
subscription's straggler to confuse with a current one, unlike
|
|
469
|
+
facilities' rotating kinds.
|
|
470
|
+
|
|
471
|
+
Allocation goes through the registry's acquire_request_id so it
|
|
472
|
+
happens under `pending_lock` -- new_request_id() mutates an Enum
|
|
473
|
+
shared with SimVarAccessor -- and so a reserved ID can come off the
|
|
474
|
+
free-list instead of growing that Enum at all. An ID taken here is
|
|
475
|
+
never released back: it stays reserved for this key until
|
|
476
|
+
disconnect() drops the whole dict, so SimVarAccessor can never be
|
|
477
|
+
handed one that a facility subscription is still using.
|
|
478
|
+
"""
|
|
479
|
+
ids = self._reserved_request_ids.get(key)
|
|
480
|
+
if ids is None:
|
|
481
|
+
registry = getattr(self.sm, "registry", None)
|
|
482
|
+
if registry is not None:
|
|
483
|
+
ids = [
|
|
484
|
+
registry.acquire_request_id(lambda: self.sm.new_request_id().value)
|
|
485
|
+
for _ in range(ring)
|
|
486
|
+
]
|
|
487
|
+
else:
|
|
488
|
+
# Plain SimConnect fallback, no dispatcher and so no
|
|
489
|
+
# registry. Nothing to pool against; allocate directly.
|
|
490
|
+
ids = [self.sm.new_request_id().value for _ in range(ring)]
|
|
491
|
+
self._reserved_request_ids[key] = ids
|
|
492
|
+
request_id = ids.pop(0)
|
|
493
|
+
ids.append(request_id)
|
|
494
|
+
return request_id
|
|
495
|
+
|
|
496
|
+
def list_lvars_lock(self) -> asyncio.Lock:
|
|
497
|
+
"""Lock serializing tools/lvars.py's list_lvars end to end.
|
|
498
|
+
|
|
499
|
+
See the comment on `_list_lvars_lock` above for why this exists --
|
|
500
|
+
same bug class as facility_lock, one call site rather than four
|
|
501
|
+
kinds, so this returns a single pre-built lock rather than
|
|
502
|
+
maintaining a dict.
|
|
503
|
+
"""
|
|
504
|
+
return self._list_lvars_lock
|
|
505
|
+
|
|
506
|
+
def ai_object_lock(self) -> asyncio.Lock:
|
|
507
|
+
"""Lock serializing tools/flight.py's create_ai_object end to end.
|
|
508
|
+
|
|
509
|
+
See the comment on `_ai_object_lock` above for why this exists --
|
|
510
|
+
same bug class as list_lvars_lock: create_ai_object correlates on
|
|
511
|
+
the single request id reserved_request_id() reserves for the
|
|
512
|
+
"ai_object" key (ring=1), so only one creation may be registered and
|
|
513
|
+
waiting on it at a time.
|
|
514
|
+
"""
|
|
515
|
+
return self._ai_object_lock
|
|
516
|
+
|
|
517
|
+
async def get_status(self) -> dict[str, Any]:
|
|
518
|
+
"""Return current connection status.
|
|
519
|
+
|
|
520
|
+
The sim_paused/sim_running lookup goes through run_sync rather than
|
|
521
|
+
acquiring `_sim_lock` directly: this method used to take that lock
|
|
522
|
+
synchronously on the event loop thread. If some other call was
|
|
523
|
+
already holding it for a while (e.g. a large get_simvar_bulk against
|
|
524
|
+
a hung sim), that direct acquisition blocked the event loop itself
|
|
525
|
+
until the lock freed -- freezing every other tool call on the
|
|
526
|
+
server, not just this one. Routing the wait through an executor
|
|
527
|
+
keeps the event loop free regardless of how long the lock is held.
|
|
528
|
+
"""
|
|
529
|
+
result: dict[str, Any] = {
|
|
530
|
+
"state": self._state.value,
|
|
531
|
+
"connected": self.is_connected,
|
|
532
|
+
"mobiflight_available": self._mobiflight_available,
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if self.is_connected and self.sm is not None:
|
|
536
|
+
try:
|
|
537
|
+
extra = await self.run_sync(self._read_sim_state)
|
|
538
|
+
result.update(extra)
|
|
539
|
+
except Exception:
|
|
540
|
+
pass
|
|
541
|
+
|
|
542
|
+
return result
|
|
543
|
+
|
|
544
|
+
def _read_sim_state(self) -> dict[str, Any]:
|
|
545
|
+
"""Blocking read of sim_paused/sim_running. Call only via run_sync."""
|
|
546
|
+
return {
|
|
547
|
+
"sim_paused": bool(self.sm.paused),
|
|
548
|
+
"sim_running": bool(self.sm.running),
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
LVAR_UNIT = "number"
|
|
552
|
+
|
|
553
|
+
def set_lvar(self, name: str, value: float, verify: bool = False) -> bool | None:
|
|
554
|
+
"""Write an L-var through the SimVar accessor's definition layer.
|
|
555
|
+
|
|
556
|
+
Still AddToDataDefinition + SetDataOnSimObject -- the native path
|
|
557
|
+
that works with proprietary aircraft like the Fenix, where the
|
|
558
|
+
MobiFlight RPN set() command does not. What changed is that it now
|
|
559
|
+
goes through SimVarAccessor instead of a hand-rolled copy of that
|
|
560
|
+
pattern, which fixes three things at once:
|
|
561
|
+
|
|
562
|
+
* **The definition cache.** This used to call new_def_id() on every
|
|
563
|
+
write. That function rebuilds an Enum from every prior member on
|
|
564
|
+
each call, and the IDs it hands out were never reclaimed -- so a
|
|
565
|
+
long session leaked one definition per write, unbounded.
|
|
566
|
+
CLAUDE.md's documented Fenix FCU procedure issues one write per
|
|
567
|
+
knob click at 15 ms intervals, making the documented usage
|
|
568
|
+
pattern the one that leaked fastest.
|
|
569
|
+
* **A typed error for a bad name.** `name.encode("ascii")` here
|
|
570
|
+
raised a bare UnicodeEncodeError, which
|
|
571
|
+
handle_simconnect_errors' catch-all turned into an UNEXPECTED
|
|
572
|
+
envelope leaking Python exception text. The accessor converts
|
|
573
|
+
that same failure to SimVarNotFoundError.
|
|
574
|
+
* **Send-ID correlation**, so a write SimConnect actually rejects
|
|
575
|
+
surfaces as SimVarNotSettableError instead of being invisible.
|
|
576
|
+
|
|
577
|
+
Returns tri-state: True if a read-back confirmed the value landed,
|
|
578
|
+
False if it confirmed it did not, None if verification was not
|
|
579
|
+
attempted (`verify=False`) or could not be completed. Never
|
|
580
|
+
reports False as True, and never reports None as either.
|
|
581
|
+
|
|
582
|
+
Verification reads back natively rather than through MobiFlight.
|
|
583
|
+
Measured live against MSFS 2024 with the WASM module loaded: after
|
|
584
|
+
writing 0.0 over a previous 7.0, the native read returned 0.0 while
|
|
585
|
+
MobiFlight still returned 7.0. MobiFlight's value is cached, so it
|
|
586
|
+
would have reported a landed write as failed.
|
|
587
|
+
"""
|
|
588
|
+
from simconnect_mcp.simvar_access import SimVarError, values_match
|
|
589
|
+
|
|
590
|
+
if self.accessor is None:
|
|
591
|
+
raise RuntimeError(
|
|
592
|
+
"L-var writes require the SimConnect dispatcher; this connection "
|
|
593
|
+
"fell back to plain SimConnect."
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
datum = name if name.startswith("L:") else f"L:{name}"
|
|
597
|
+
self.accessor.write(datum, value, unit=self.LVAR_UNIT, raw_name=True)
|
|
598
|
+
|
|
599
|
+
if not verify:
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
# A read-back that fails is "could not verify", not "did not land":
|
|
603
|
+
# reporting False here would assert something this call has no
|
|
604
|
+
# evidence for. The write itself already raised for anything
|
|
605
|
+
# SimConnect rejected outright.
|
|
606
|
+
try:
|
|
607
|
+
readback = self.accessor.read(datum, unit=self.LVAR_UNIT, raw_name=True)
|
|
608
|
+
except SimVarError:
|
|
609
|
+
logger.debug("L-var read-back failed for %s", datum, exc_info=True)
|
|
610
|
+
return None
|
|
611
|
+
if readback is None:
|
|
612
|
+
return None
|
|
613
|
+
return values_match(readback, value)
|
|
614
|
+
|
|
615
|
+
@classmethod
|
|
616
|
+
def reset(cls) -> None:
|
|
617
|
+
"""Reset singleton (for testing)."""
|
|
618
|
+
if cls._instance is not None:
|
|
619
|
+
cls._instance.disconnect()
|
|
620
|
+
cls._instance = None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Aircraft L-var catalogs for variable discovery."""
|